From 382d1baaf1edac1f6e2ca6a389da23e07f182b1d Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 6 Mar 2026 18:38:20 +0800 Subject: [PATCH] 0.4.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2×2英语单词组件,修复了stcn组件 --- .../ComponentSystem/BuiltInComponentIds.cs | 1 + .../ComponentSystem/ComponentRegistry.cs | 9 + LanMountainDesktop/Localization/en-US.json | 2 + LanMountainDesktop/Localization/zh-CN.json | 2 + .../Services/LocalizationService.cs | 3 +- .../Services/RecommendationDataService.cs | 67 ++- .../Views/Components/DailyWord2x2Widget.axaml | 89 +++ .../Components/DailyWord2x2Widget.axaml.cs | 507 ++++++++++++++++++ .../DesktopComponentRuntimeRegistry.cs | 5 + .../Views/Components/Stcn24ForumWidget.axaml | 166 +++++- .../Components/Stcn24ForumWidget.axaml.cs | 105 +++- .../Views/MainWindow.ComponentSystem.cs | 12 +- .../Views/MainWindow.DesktopPaging.cs | 2 +- .../Views/MainWindow.Localization.cs | 2 +- 14 files changed, 949 insertions(+), 23 deletions(-) create mode 100644 LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml create mode 100644 LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 00f8639..6c3ea96 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -30,6 +30,7 @@ public static class BuiltInComponentIds public const string DesktopDailyPoetry = "DesktopDailyPoetry"; public const string DesktopDailyArtwork = "DesktopDailyArtwork"; public const string DesktopDailyWord = "DesktopDailyWord"; + public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; public const string DesktopStcn24Forum = "DesktopStcn24Forum"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 49af41f..89d291f 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -234,6 +234,15 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopDailyWord2x2, + "Daily Word 2x2", + "Book", + "Info", + MinWidthCells: 2, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopCnrDailyNews, "CNR Daily News", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index c349baf..6488123 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -295,6 +295,7 @@ "component.daily_poetry": "Daily Poetry", "component.daily_artwork": "Daily Artwork", "component.daily_word": "Daily Word", + "component.daily_word_2x2": "Daily Word 2x2", "component.cnr_daily_news": "CNR Headlines", "component.bilibili_hot_search": "Bilibili Hot Search", "component.stcn24_forum": "STCN 24", @@ -343,6 +344,7 @@ "dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.", "dailyword.widget.fallback_example": "Tap the refresh button and try again.", "dailyword.widget.fallback_example_translation": "It will retry when network recovers.", + "dailyword2x2.widget.tap_to_show": "Tap to reveal meaning", "cnrnews.widget.loading": "Loading...", "cnrnews.widget.loading_title": "Fetching CNR headlines", "cnrnews.widget.loading_subtitle": "Please wait", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 89a2ee1..627bf0a 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -295,6 +295,7 @@ "component.daily_poetry": "每日诗词", "component.daily_artwork": "每日名画", "component.daily_word": "每日单词", + "component.daily_word_2x2": "每日单词 2x2", "component.cnr_daily_news": "央广网头条", "component.bilibili_hot_search": "B站热搜", "component.stcn24_forum": "STCN 24", @@ -343,6 +344,7 @@ "dailyword.widget.fallback_meaning": "有道词典暂不可用", "dailyword.widget.fallback_example": "请点击右上角刷新重试", "dailyword.widget.fallback_example_translation": "网络恢复后将自动更新", + "dailyword2x2.widget.tap_to_show": "点击查看释义", "cnrnews.widget.loading": "加载中...", "cnrnews.widget.loading_title": "正在获取新闻热点", "cnrnews.widget.loading_subtitle": "请稍候", diff --git a/LanMountainDesktop/Services/LocalizationService.cs b/LanMountainDesktop/Services/LocalizationService.cs index 0b31bdd..bf56313 100644 --- a/LanMountainDesktop/Services/LocalizationService.cs +++ b/LanMountainDesktop/Services/LocalizationService.cs @@ -46,6 +46,8 @@ public sealed class LocalizationService if (File.Exists(filePath)) { var json = File.ReadAllText(filePath); + // Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start. + json = json.TrimStart('\uFEFF'); var data = JsonSerializer.Deserialize>(json, JsonOptions); if (data is not null) { @@ -62,4 +64,3 @@ public sealed class LocalizationService return result; } } - diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 602ab21..673ce87 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -819,15 +819,25 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis string sourceType, CancellationToken cancellationToken) { + var normalizedSourceType = Stcn24ForumSourceTypes.Normalize(sourceType); + var isLatestCreatedSource = string.Equals( + normalizedSourceType, + Stcn24ForumSourceTypes.LatestCreated, + StringComparison.OrdinalIgnoreCase); var safeCount = Math.Clamp(targetCount, 1, 12); var requestCount = Math.Clamp(Math.Max(safeCount * 3, 12), safeCount, 40); var keyword = NormalizeInlineText(_options.SmartTeachStcnKeyword); - if (string.IsNullOrWhiteSpace(keyword)) + if (isLatestCreatedSource) + { + // For latest posts, rely on discussion id ordering from the full discussion stream. + keyword = string.Empty; + } + else if (string.IsNullOrWhiteSpace(keyword)) { keyword = "STCN"; } - var sortToken = ResolveSmartTeachDiscussionSortToken(sourceType); + var sortToken = ResolveSmartTeachDiscussionSortToken(normalizedSourceType); var requestUrl = string.Format( CultureInfo.InvariantCulture, @@ -895,10 +905,17 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } - var items = new List(safeCount); + var candidates = new List<(Stcn24ForumPostItemSnapshot Item, long? DiscussionId)>(requestCount); foreach (var discussionNode in dataArray.EnumerateArray()) { - if (discussionNode.ValueKind != JsonValueKind.Object || IsSmartTeachPinnedDiscussion(discussionNode)) + if (discussionNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var discussionType = ReadString(discussionNode, "type"); + if (!string.Equals(discussionType, "discussions", StringComparison.OrdinalIgnoreCase) || + IsSmartTeachPinnedDiscussion(discussionNode)) { continue; } @@ -940,22 +957,37 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis var createdAtText = ReadString(discussionNode, "attributes", "createdAt"); var createdAt = TryParseDateTimeOffset(createdAtText); - items.Add(new Stcn24ForumPostItemSnapshot( + candidates.Add(( + new Stcn24ForumPostItemSnapshot( Title: title, Url: targetUrl, AuthorDisplayName: authorDisplayName, AuthorAvatarUrl: authorAvatarUrl, - CreatedAt: createdAt)); + CreatedAt: createdAt), + TryParseSmartTeachDiscussionId(discussionId))); + } - if (items.Count >= safeCount) - { - break; - } + IReadOnlyList items; + if (isLatestCreatedSource) + { + items = candidates + .OrderByDescending(candidate => candidate.DiscussionId ?? long.MinValue) + .ThenByDescending(candidate => candidate.Item.CreatedAt ?? DateTimeOffset.MinValue) + .Take(safeCount) + .Select(candidate => candidate.Item) + .ToArray(); + } + else + { + items = candidates + .Take(safeCount) + .Select(candidate => candidate.Item) + .ToArray(); } return new Stcn24ForumPostsSnapshot( Provider: "SmartTeachForum", - Source: ResolveStcn24ForumSourceLabel(sourceType), + Source: ResolveStcn24ForumSourceLabel(normalizedSourceType), Items: items, FetchedAt: DateTimeOffset.UtcNow); } @@ -2331,6 +2363,18 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : null; } + private static long? TryParseSmartTeachDiscussionId(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + return long.TryParse(rawValue.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : null; + } + private static string NormalizeInlineText(string? text) { if (string.IsNullOrWhiteSpace(text)) @@ -2566,4 +2610,3 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : $"{text[..maxLength]}..."; } } - diff --git a/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml b/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml new file mode 100644 index 0000000..252df12 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs b/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs new file mode 100644 index 0000000..6a6b89c --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class DailyWord2x2Widget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget +{ + private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); + + private const double BaseCellSize = 48d; + private const int BaseWidthCells = 2; + private const int BaseHeightCells = 2; + private static readonly IReadOnlyList SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromHours(6) + }; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly ComponentSettingsService _componentSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private DailyWordSnapshot? _latestSnapshot; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + private bool _autoRefreshEnabled = true; + private bool _isMeaningVisible; + + public DailyWord2x2Widget() + { + InitializeComponent(); + + WordTextBlock.FontFamily = MiSansFontFamily; + MeaningTextBlock.FontFamily = MiSansFontFamily; + HiddenHintTextBlock.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyAutoRefreshSettings(); + ApplyLoadingState(); + UpdateRefreshButtonState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshWordAsync(forceRefresh: false); + } + } + + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + ApplyAutoRefreshSettings(); + if (_isAttached) + { + _ = RefreshWordAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ApplyAutoRefreshSettings(); + UpdateRefreshButtonState(); + _ = RefreshWordAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + UpdateRefreshButtonState(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + if (_isRefreshing) + { + return; + } + + await RefreshWordAsync(forceRefresh: true); + e.Handled = true; + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshWordAsync(forceRefresh: false); + } + + private void OnCardPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_latestSnapshot is null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + if (e.Source is Visual sourceVisual) + { + for (Visual? current = sourceVisual; current is not null; current = current.GetVisualParent()) + { + if (ReferenceEquals(current, RefreshButton)) + { + return; + } + } + } + + _isMeaningVisible = !_isMeaningVisible; + UpdateRevealState(); + UpdateAdaptiveLayout(); + e.Handled = true; + } + + private async Task RefreshWordAsync(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 DailyWordQuery( + Locale: _languageCode, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetDailyWordAsync(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(DailyWordSnapshot snapshot) + { + _latestSnapshot = snapshot; + WordTextBlock.Text = NormalizeCompactText(snapshot.Word); + MeaningTextBlock.Text = BuildMeaningPreview(snapshot.Meaning); + HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning"); + StatusTextBlock.IsVisible = false; + + UpdateRevealState(); + UpdateAdaptiveLayout(); + } + + private void ApplyLoadingState() + { + _latestSnapshot = null; + _isMeaningVisible = false; + WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word"); + MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning..."); + HiddenHintTextBlock.Text = L("dailyword.widget.loading", "Loading..."); + StatusTextBlock.Text = L("dailyword.widget.loading", "Loading..."); + StatusTextBlock.IsVisible = true; + UpdateRevealState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + _latestSnapshot = null; + _isMeaningVisible = false; + WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word"); + MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable."); + HiddenHintTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed"); + StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed"); + StatusTextBlock.IsVisible = true; + UpdateRevealState(); + UpdateAdaptiveLayout(); + } + + private void UpdateRevealState() + { + var canShowMeaning = _latestSnapshot is not null && !string.IsNullOrWhiteSpace(MeaningTextBlock.Text); + var showMeaning = _isMeaningVisible && canShowMeaning; + MeaningTextBlock.IsVisible = showMeaning; + HiddenHintTextBlock.IsVisible = !showMeaning; + + if (!showMeaning && _latestSnapshot is not null) + { + HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning"); + } + } + + private void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 14, 40)); + CardBorder.CornerRadius = RootBorder.CornerRadius; + CardBorder.Padding = new Thickness( + Math.Clamp(12 * scale, 8, 18), + Math.Clamp(11 * scale, 7, 16), + Math.Clamp(12 * scale, 8, 18), + Math.Clamp(11 * scale, 7, 16)); + + var refreshSize = Math.Clamp(30 * scale, 20, 38); + RefreshButton.Width = refreshSize; + RefreshButton.Height = refreshSize; + RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d); + RefreshIcon.FontSize = Math.Clamp(14 * scale, 10, 20); + + var contentWidth = Math.Max(80, totalWidth - CardBorder.Padding.Left - CardBorder.Padding.Right); + var wordWidth = Math.Max(48, contentWidth - refreshSize - Math.Clamp(6 * scale, 4, 10)); + WordTextBlock.MaxWidth = wordWidth; + + var contentHeight = Math.Max(52, totalHeight - CardBorder.Padding.Top - CardBorder.Padding.Bottom); + var wordHeightBudget = Math.Max(18, contentHeight * 0.34); + var detailHeightBudget = Math.Max(18, contentHeight - wordHeightBudget - Math.Clamp(8 * scale, 4, 14)); + + WordTextBlock.FontSize = FitFontSize( + WordTextBlock.Text, + wordWidth, + wordHeightBudget, + maxLines: 1, + minFontSize: Math.Clamp(18 * scale, 12, 22), + maxFontSize: Math.Clamp(38 * scale, 20, 50), + weight: FontWeight.Bold, + lineHeightFactor: 1.02); + WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.02; + + var detailFont = FitFontSize( + MeaningTextBlock.IsVisible ? MeaningTextBlock.Text : HiddenHintTextBlock.Text, + contentWidth, + detailHeightBudget, + maxLines: MeaningTextBlock.IsVisible ? 5 : 4, + minFontSize: Math.Clamp(12 * scale, 9, 14), + maxFontSize: Math.Clamp(18 * scale, 12, 22), + weight: FontWeight.SemiBold, + lineHeightFactor: 1.10); + + MeaningTextBlock.MaxWidth = contentWidth; + MeaningTextBlock.FontSize = detailFont; + MeaningTextBlock.LineHeight = detailFont * 1.10; + MeaningTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 4 : 5; + + HiddenHintTextBlock.MaxWidth = contentWidth; + HiddenHintTextBlock.FontSize = detailFont; + HiddenHintTextBlock.LineHeight = detailFont * 1.10; + HiddenHintTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 3 : 4; + + StatusTextBlock.FontSize = Math.Clamp(14 * scale, 9, 18); + } + + private void UpdateRefreshButtonState() + { + RefreshButton.IsEnabled = !_isRefreshing; + RefreshButton.Opacity = _isRefreshing ? 0.60 : 1.0; + RefreshIcon.Opacity = _isRefreshing ? 0.60 : 1.0; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void ApplyAutoRefreshSettings() + { + var enabled = true; + var intervalMinutes = 360; + + try + { + var snapshot = _componentSettingsService.Load(); + enabled = snapshot.DailyWordAutoRefreshEnabled; + intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.DailyWordAutoRefreshIntervalMinutes); + } + catch + { + // Keep fallback defaults. + } + + _autoRefreshEnabled = enabled; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRefreshEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRefreshIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 360; + } + + if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRefreshIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(360); + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private static string BuildMeaningPreview(string? rawMeaning) + { + var normalized = NormalizeCompactText(rawMeaning); + if (string.IsNullOrWhiteSpace(normalized)) + { + return "Meaning unavailable"; + } + + var compact = normalized.Replace(";", "; ", StringComparison.Ordinal); + return compact.Length <= 160 ? compact : $"{compact[..160]}..."; + } + + 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; + } + + 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; + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index a4cdb76..17c8cb1 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -240,6 +240,11 @@ public sealed class DesktopComponentRuntimeRegistry "component.daily_word", () => new DailyWordWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopDailyWord2x2, + "component.daily_word_2x2", + () => new DailyWord2x2Widget(), + cellSize => Math.Clamp(cellSize * 0.34, 12, 26)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopCnrDailyNews, "component.cnr_daily_news", diff --git a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml index 88ae222..1d49b34 100644 --- a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml +++ b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml @@ -22,7 +22,7 @@ BorderThickness="0" Padding="12,12,12,12"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs index b1f2bd7..5ceef64 100644 --- a/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs @@ -35,7 +35,8 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I private const double BaseCellSize = 48d; private const int BaseWidthCells = 4; private const int BaseHeightCells = 4; - private const int MaxDisplayItemCount = 4; + private const int BaseDisplayItemCount = 4; + private const int MaxDisplayItemCount = 8; private static readonly IReadOnlyList SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes; private readonly DispatcherTimer _refreshTimer = new() @@ -55,6 +56,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I private string _languageCode = "zh-CN"; private string _sourceType = Stcn24ForumSourceTypes.LatestCreated; private double _currentCellSize = BaseCellSize; + private int _visibleItemCount = BaseDisplayItemCount; private bool _isAttached; private bool _isRefreshing; private bool _autoRefreshEnabled = true; @@ -76,10 +78,18 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I PostItem2TitleTextBlock.FontFamily = MiSansFontFamily; PostItem3TitleTextBlock.FontFamily = MiSansFontFamily; PostItem4TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem5TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem6TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem7TitleTextBlock.FontFamily = MiSansFontFamily; + PostItem8TitleTextBlock.FontFamily = MiSansFontFamily; PostItem1AvatarFallbackText.FontFamily = MiSansFontFamily; PostItem2AvatarFallbackText.FontFamily = MiSansFontFamily; PostItem3AvatarFallbackText.FontFamily = MiSansFontFamily; PostItem4AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem5AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem6AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem7AvatarFallbackText.FontFamily = MiSansFontFamily; + PostItem8AvatarFallbackText.FontFamily = MiSansFontFamily; StatusTextBlock.FontFamily = MiSansFontFamily; _itemVisuals.Add(new ForumItemVisual( @@ -110,6 +120,34 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I PostItem4AvatarImage, PostItem4AvatarFallbackText, PostItem4TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem5Host, + PostItem5Grid, + PostItem5AvatarHost, + PostItem5AvatarImage, + PostItem5AvatarFallbackText, + PostItem5TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem6Host, + PostItem6Grid, + PostItem6AvatarHost, + PostItem6AvatarImage, + PostItem6AvatarFallbackText, + PostItem6TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem7Host, + PostItem7Grid, + PostItem7AvatarHost, + PostItem7AvatarImage, + PostItem7AvatarFallbackText, + PostItem7TitleTextBlock)); + _itemVisuals.Add(new ForumItemVisual( + PostItem8Host, + PostItem8Grid, + PostItem8AvatarHost, + PostItem8AvatarImage, + PostItem8AvatarFallbackText, + PostItem8TitleTextBlock)); _refreshTimer.Tick += OnRefreshTimerTick; AttachedToVisualTree += OnAttachedToVisualTree; @@ -222,7 +260,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I { var query = new Stcn24ForumPostsQuery( Locale: _languageCode, - ItemCount: MaxDisplayItemCount, + ItemCount: _visibleItemCount, SourceType: _sourceType, ForceRefresh: forceRefresh); var result = await _recommendationService.GetStcn24ForumPostsAsync(query, cts.Token); @@ -274,7 +312,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I } _activeItems.Add(item); - if (_activeItems.Count >= MaxDisplayItemCount) + if (_activeItems.Count >= _visibleItemCount) { break; } @@ -284,6 +322,14 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I for (var i = 0; i < _itemVisuals.Count; i++) { var visual = _itemVisuals[i]; + var isRowVisible = i < _visibleItemCount; + visual.Host.IsVisible = isRowVisible; + if (!isRowVisible) + { + SetAvatarBitmap(i, null); + continue; + } + if (i < _activeItems.Count) { var item = _activeItems[i]; @@ -304,6 +350,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I UpdateAdaptiveLayout(); var tasks = _activeItems + .Take(_visibleItemCount) .Select(item => TryDownloadAvatarBitmapAsync(item.AuthorAvatarUrl, cancellationToken)) .ToArray(); if (tasks.Length == 0) @@ -338,6 +385,14 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I for (var i = 0; i < _itemVisuals.Count; i++) { var visual = _itemVisuals[i]; + var isRowVisible = i < _visibleItemCount; + visual.Host.IsVisible = isRowVisible; + if (!isRowVisible) + { + SetAvatarBitmap(i, null); + continue; + } + visual.TitleTextBlock.Text = loadingText; visual.AvatarFallbackText.Text = "?"; SetAvatarBitmap(i, null); @@ -357,6 +412,14 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I for (var i = 0; i < _itemVisuals.Count; i++) { var visual = _itemVisuals[i]; + var isRowVisible = i < _visibleItemCount; + visual.Host.IsVisible = isRowVisible; + if (!isRowVisible) + { + SetAvatarBitmap(i, null); + continue; + } + visual.TitleTextBlock.Text = fallbackText; visual.AvatarFallbackText.Text = "?"; SetAvatarBitmap(i, null); @@ -374,7 +437,11 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I for (var i = 0; i < _itemVisuals.Count; i++) { var visual = _itemVisuals[i]; - var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url); + var inVisibleRange = i < _visibleItemCount; + visual.Host.IsVisible = inVisibleRange; + var enabled = inVisibleRange && + i < _activeItems.Count && + !string.IsNullOrWhiteSpace(_activeItems[i].Url); visual.Host.IsHitTestVisible = enabled; visual.Host.Opacity = enabled ? 1.0 : 0.72; visual.Host.Cursor = enabled @@ -500,6 +567,28 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I var titleFont = Math.Clamp(14 * softScale, 10, 19); var titleMaxWidth = Math.Max(60, innerWidth - avatarSize - (rowPaddingHorizontal * 2d) - 18); + var estimatedHeaderHeight = Math.Max( + Math.Clamp(20 * softScale, 12, 28) + Math.Clamp(4 * softScale, 2, 8), + Math.Clamp(34 * softScale, 22, 42)); + var estimatedRowHeight = avatarSize + (rowPaddingVertical * 2d); + var availablePostsHeight = Math.Max( + 0d, + totalHeight - + CardBorder.Padding.Top - + CardBorder.Padding.Bottom - + estimatedHeaderHeight - + rowSpacing); + var rowFootprint = Math.Max(1d, estimatedRowHeight + rowSpacing); + var capacityByHeight = (int)Math.Floor((availablePostsHeight + rowSpacing) / rowFootprint); + var resolvedItemCount = Math.Clamp(capacityByHeight, BaseDisplayItemCount, MaxDisplayItemCount); + if (scale < 1.08d) + { + resolvedItemCount = Math.Min(resolvedItemCount, BaseDisplayItemCount); + } + + var previousVisibleItemCount = _visibleItemCount; + _visibleItemCount = resolvedItemCount; + foreach (var visual in _itemVisuals) { visual.Host.CornerRadius = new CornerRadius(itemCornerRadius); @@ -516,6 +605,14 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I } StatusTextBlock.FontSize = Math.Clamp(14 * softScale, 10, 18); + + if (_visibleItemCount != previousVisibleItemCount && + _isAttached && + !_isRefreshing && + _activeItems.Count < _visibleItemCount) + { + _ = RefreshPostsAsync(forceRefresh: false); + } } private static string NormalizeCompactText(string? text) diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index fc322b8..d39394c 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -754,7 +754,8 @@ public partial class MainWindow return; } - if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord) + if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord || + placement.ComponentId == BuiltInComponentIds.DesktopDailyWord2x2) { OpenDailyWordComponentSettings(); return; @@ -1131,9 +1132,14 @@ public partial class MainWindow continue; } - if (TryGetContentHost(host)?.Child is DailyWordWidget widget) + var widget = TryGetContentHost(host)?.Child; + if (widget is DailyWordWidget dailyWordWidget) { - widget.RefreshFromSettings(); + dailyWordWidget.RefreshFromSettings(); + } + else if (widget is DailyWord2x2Widget dailyWord2x2Widget) + { + dailyWord2x2Widget.RefreshFromSettings(); } } } diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index a823234..843e3aa 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 0957e51..69683cf 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Interactivity;