diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 4ff4186..d0a7378 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -251,8 +251,7 @@ public sealed class ComponentRegistry MinWidthCells: 4, MinHeightCells: 2, AllowStatusBarPlacement: false, - AllowDesktopPlacement: true, - ResizeMode: DesktopComponentResizeMode.Free), + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopWhiteboard, "Blackboard Portrait", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index c9efa47..13da5e4 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -337,6 +337,17 @@ "dailysentence.widget.fallback_sentence": "Daily sentence is temporarily unavailable.", "dailysentence.widget.fallback_translation": "Tap refresh and try again.", "dailysentence.widget.source_default": "Youdao Dictionary", + "daily_sentence.settings.title": "Daily Sentence Settings", + "daily_sentence.settings.desc": "Configure auto-rotation and refresh interval.", + "daily_sentence.settings.auto_rotate_label": "Auto-rotation", + "daily_sentence.settings.auto_rotate_enabled": "Enable auto-rotation", + "daily_sentence.settings.frequency_label": "Rotation interval", + "daily_sentence.settings.frequency_5m": "5 minutes", + "daily_sentence.settings.frequency_10m": "10 minutes", + "daily_sentence.settings.frequency_40m": "40 minutes", + "daily_sentence.settings.frequency_1h": "1 hour", + "daily_sentence.settings.frequency_12h": "12 hours", + "daily_sentence.settings.frequency_24h": "24 hours", "cnrnews.widget.loading": "Loading...", "cnrnews.widget.loading_title": "Fetching CNR headlines", "cnrnews.widget.loading_subtitle": "Please wait", @@ -344,6 +355,17 @@ "cnrnews.widget.fallback_title": "CNR news is temporarily unavailable", "cnrnews.widget.fallback_subtitle": "Tap refresh and try again", "cnrnews.widget.hot_label": "Hot", + "cnrnews.settings.title": "CNR Settings", + "cnrnews.settings.desc": "Configure auto-rotation and refresh interval.", + "cnrnews.settings.auto_rotate_label": "Auto-rotation", + "cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation", + "cnrnews.settings.frequency_label": "Rotation interval", + "cnrnews.settings.frequency_5m": "5 minutes", + "cnrnews.settings.frequency_10m": "10 minutes", + "cnrnews.settings.frequency_40m": "40 minutes", + "cnrnews.settings.frequency_1h": "1 hour", + "cnrnews.settings.frequency_12h": "12 hours", + "cnrnews.settings.frequency_24h": "24 hours", "artwork.settings.title": "Daily Artwork Settings", "artwork.settings.desc": "Switch the data source used by Daily Artwork.", "artwork.settings.source_label": "Mirror Source", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 8189ce5..7215fbf 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -337,6 +337,17 @@ "dailysentence.widget.fallback_sentence": "今日英语句子暂不可用", "dailysentence.widget.fallback_translation": "请点击右上角刷新重试", "dailysentence.widget.source_default": "有道词典", + "daily_sentence.settings.title": "英语句子设置", + "daily_sentence.settings.desc": "配置自动轮换与刷新频率。", + "daily_sentence.settings.auto_rotate_label": "自动轮换", + "daily_sentence.settings.auto_rotate_enabled": "启用自动轮换", + "daily_sentence.settings.frequency_label": "轮换频率", + "daily_sentence.settings.frequency_5m": "5 分钟", + "daily_sentence.settings.frequency_10m": "10 分钟", + "daily_sentence.settings.frequency_40m": "40 分钟", + "daily_sentence.settings.frequency_1h": "1 小时", + "daily_sentence.settings.frequency_12h": "12 小时", + "daily_sentence.settings.frequency_24h": "24 小时", "cnrnews.widget.loading": "加载中...", "cnrnews.widget.loading_title": "正在获取新闻热点", "cnrnews.widget.loading_subtitle": "请稍候", @@ -344,6 +355,17 @@ "cnrnews.widget.fallback_title": "央广网新闻暂不可用", "cnrnews.widget.fallback_subtitle": "点击右上角稍后重试", "cnrnews.widget.hot_label": "热点", + "cnrnews.settings.title": "央广网设置", + "cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。", + "cnrnews.settings.auto_rotate_label": "自动轮换", + "cnrnews.settings.auto_rotate_enabled": "启用自动轮换", + "cnrnews.settings.frequency_label": "轮换频率", + "cnrnews.settings.frequency_5m": "5 分钟", + "cnrnews.settings.frequency_10m": "10 分钟", + "cnrnews.settings.frequency_40m": "40 分钟", + "cnrnews.settings.frequency_1h": "1 小时", + "cnrnews.settings.frequency_12h": "12 小时", + "cnrnews.settings.frequency_24h": "24 小时", "artwork.settings.title": "每日图片设置", "artwork.settings.desc": "切换每日图片的数据源。", "artwork.settings.source_label": "镜像源", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 6705237..25279f3 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -98,6 +98,14 @@ public sealed class AppSettingsSnapshot ]; public string WorldClockSecondHandMode { get; set; } = "Tick"; + public bool DailySentenceAutoRotateEnabled { get; set; } = true; + + public int DailySentenceAutoRotateIntervalMinutes { get; set; } = 60; + + public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true; + + public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60; + public AppSettingsSnapshot Clone() { var clone = (AppSettingsSnapshot)MemberwiseClone(); diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 330899c..d8d9711 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private DailyPoetryCacheEntry? _dailyPoetryCache; private DailyNewsCacheEntry? _dailyNewsCache; private DailyWordCacheEntry? _dailyWordCache; + private int _dailyNewsRotationCursor; static RecommendationDataService() { @@ -206,10 +207,10 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis "No CNR news items were returned."); } - var snapshot = new DailyNewsSnapshot( + var snapshot = new DailyNewsSnapshot( Provider: "CNR", Source: "央广网·头条", - Items: items.Take(targetCount).ToArray(), + Items: SelectDailyNewsItems(items, targetCount, normalizedQuery.ForceRefresh), FetchedAt: DateTimeOffset.UtcNow); SetDailyNewsCache(snapshot); @@ -521,6 +522,33 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private IReadOnlyList SelectDailyNewsItems( + IReadOnlyList items, + int targetCount, + bool forceRefresh) + { + if (items.Count == 0 || targetCount <= 0) + { + return []; + } + + var safeCount = Math.Min(targetCount, items.Count); + if (!forceRefresh || items.Count <= safeCount) + { + return items.Take(safeCount).ToArray(); + } + + var cursor = Math.Abs(Interlocked.Increment(ref _dailyNewsRotationCursor) - 1); + var startIndex = cursor % items.Count; + var selection = new List(safeCount); + for (var i = 0; i < safeCount; i++) + { + selection.Add(items[(startIndex + i) % items.Count]); + } + + return selection; + } + private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot) { lock (_cacheGate) @@ -723,7 +751,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis return null; } - return string.Join(";", lines + return string.Join("; ", lines .Where(line => !string.IsNullOrWhiteSpace(line)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3)); @@ -1646,3 +1674,4 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : $"{text[..maxLength]}..."; } } + diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml new file mode 100644 index 0000000..51c7f18 --- /dev/null +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs new file mode 100644 index 0000000..5b64fc2 --- /dev/null +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsSettingsWindow.axaml.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class CnrDailyNewsSettingsWindow : UserControl +{ + private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440]; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public CnrDailyNewsSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var enabled = snapshot.CnrDailyNewsAutoRotateEnabled; + var interval = NormalizeInterval(snapshot.CnrDailyNewsAutoRotateIntervalMinutes); + + _suppressEvents = true; + AutoRotateCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("cnrnews.settings.title", "CNR news settings"); + DescriptionTextBlock.Text = L("cnrnews.settings.desc", "Configure auto-rotation and refresh interval."); + AutoRotateLabelTextBlock.Text = L("cnrnews.settings.auto_rotate_label", "Auto-rotation"); + AutoRotateCheckBox.Content = L("cnrnews.settings.auto_rotate_enabled", "Enable auto-rotation"); + FrequencyLabelTextBlock.Text = L("cnrnews.settings.frequency_label", "Rotation interval"); + Frequency5mItem.Content = L("cnrnews.settings.frequency_5m", "5 min"); + Frequency10mItem.Content = L("cnrnews.settings.frequency_10m", "10 min"); + Frequency40mItem.Content = L("cnrnews.settings.frequency_40m", "40 min"); + Frequency1hItem.Content = L("cnrnews.settings.frequency_1h", "1 hour"); + Frequency12hItem.Content = L("cnrnews.settings.frequency_12h", "12 hours"); + Frequency24hItem.Content = L("cnrnews.settings.frequency_24h", "24 hours"); + } + + private void OnAutoRotateChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRotateCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _appSettingsService.Load(); + snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true; + snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval(); + _appSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 60; + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + if (minutes <= 0) + { + return 60; + } + + if (SupportedIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(60); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml index 5b16dbf..6f85e62 100644 --- a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:fi="using:FluentIcons.Avalonia" mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="320" @@ -56,12 +57,12 @@ Spacing="4" HorizontalAlignment="Center" VerticalAlignment="Center"> - + 0 && desiredCount != previousRenderedCount) - { - RenderExtraNewsRows(_activeNewsItems.Take(desiredCount).Skip(2).ToArray()); - UpdateNewsInteractionState(); - } - - var shouldFetchMoreItems = desiredCount > _activeNewsItems.Count; - var shouldReloadExpandedImages = - desiredCount > previousRenderedCount && - desiredCount <= _activeNewsItems.Count; - - if (_isAttached && - !_isRefreshing && - _activeNewsItems.Count > 0 && - (shouldFetchMoreItems || shouldReloadExpandedImages)) - { - _ = RefreshNewsAsync(forceRefresh: false); - } } private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) @@ -190,7 +173,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private async void OnRefreshTimerTick(object? sender, EventArgs e) { - await RefreshNewsAsync(forceRefresh: false); + await RefreshNewsAsync(forceRefresh: true); } private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e) @@ -290,10 +273,9 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken) { - var desiredCount = ResolveDesiredNewsItemCount(); var items = snapshot.Items is null ? [] - : snapshot.Items.Take(desiredCount).ToArray(); + : snapshot.Items.Take(2).ToArray(); _activeNewsItems = items; var item1 = items.Length > 0 ? items[0] : null; @@ -308,52 +290,27 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, _newsUrls.Add(NormalizeHttpUrl(item.Url)); } - RenderExtraNewsRows(items.Skip(2).ToArray()); + RenderExtraNewsRows([]); UpdateNewsInteractionState(); StatusTextBlock.IsVisible = false; UpdateAdaptiveLayout(); - var loadTasks = items - .Select(item => TryDownloadBitmapAsync(item.ImageUrl, cancellationToken)) - .ToArray(); + var loadTasks = new[] + { + TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken), + TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken) + }; var bitmaps = await Task.WhenAll(loadTasks); if (cancellationToken.IsCancellationRequested || !_isAttached) { - foreach (var bitmap in bitmaps) - { - bitmap?.Dispose(); - } + bitmaps[0]?.Dispose(); + bitmaps[1]?.Dispose(); return; } - var consumed = new bool[bitmaps.Length]; - Bitmap? TakeBitmapAt(int index) - { - if (index < 0 || index >= bitmaps.Length) - { - return null; - } - - consumed[index] = true; - return bitmaps[index]; - } - - SetNewsBitmap(0, TakeBitmapAt(0)); - SetNewsBitmap(1, TakeBitmapAt(1)); - - for (var rowIndex = 0; rowIndex < _extraNewsRows.Count; rowIndex++) - { - SetExtraNewsBitmap(rowIndex, TakeBitmapAt(rowIndex + 2)); - } - - for (var i = 0; i < bitmaps.Length; i++) - { - if (!consumed[i]) - { - bitmaps[i]?.Dispose(); - } - } + SetNewsBitmap(0, bitmaps[0]); + SetNewsBitmap(1, bitmaps[1]); } private void ApplyLoadingState() @@ -389,24 +346,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private int ResolveDesiredNewsItemCount() { - var span = ResolveCurrentCellSpan(); - var baseEquivalentHeight = span.HeightCells * (double)BaseWidthCells / Math.Max(BaseWidthCells, span.WidthCells); - var effectiveHeightCells = (int)Math.Round(baseEquivalentHeight, MidpointRounding.AwayFromZero); - return Math.Clamp(Math.Max(BaseHeightCells, effectiveHeightCells), 2, 12); - } - - private (int WidthCells, int HeightCells) ResolveCurrentCellSpan() - { - var pitch = Math.Max(1, _currentCellSize); - var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; - var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; - - var normalizedWidth = totalWidth + Math.Clamp(pitch * 0.20, 6, 16); - var normalizedHeight = totalHeight + Math.Clamp(pitch * 0.18, 4, 12); - - var widthCells = Math.Max(BaseWidthCells, (int)Math.Round(normalizedWidth / pitch, MidpointRounding.AwayFromZero)); - var heightCells = Math.Max(BaseHeightCells, (int)Math.Round(normalizedHeight / pitch, MidpointRounding.AwayFromZero)); - return (widthCells, heightCells); + return 2; } private void UpdateHotHeadlineText(string? title) @@ -558,7 +498,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, RefreshButton.Height = refreshHeight; RefreshButton.Width = refreshWidth; RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d); - RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 24); + RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24); RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29); var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170); @@ -621,7 +561,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, { RefreshButton.IsEnabled = !_isRefreshing; RefreshButton.Opacity = _isAttached ? 1.0 : 0.85; - RefreshGlyphTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0; + RefreshGlyphIcon.Opacity = _isRefreshing ? 0.56 : 1.0; RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0; } @@ -774,6 +714,60 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, } } + private void ApplyAutoRotateSettings() + { + var enabled = true; + var intervalMinutes = 60; + + try + { + var snapshot = _settingsService.Load(); + enabled = snapshot.CnrDailyNewsAutoRotateEnabled; + intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes); + } + catch + { + // Keep fallback defaults. + } + + _autoRotateEnabled = enabled; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRotateEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRotateIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 60; + } + + if (SupportedAutoRotateIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRotateIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(60); + } + private void CancelRefreshRequest() { var cts = Interlocked.Exchange(ref _refreshCts, null); diff --git a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml new file mode 100644 index 0000000..7023916 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs new file mode 100644 index 0000000..8ab4ce1 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailySentenceSettingsWindow.axaml.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class DailySentenceSettingsWindow : UserControl +{ + private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440]; + + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private bool _suppressEvents; + private string _languageCode = "zh-CN"; + + public event EventHandler? SettingsChanged; + + public DailySentenceSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var enabled = snapshot.DailySentenceAutoRotateEnabled; + var interval = NormalizeInterval(snapshot.DailySentenceAutoRotateIntervalMinutes); + + _suppressEvents = true; + AutoRotateCheckBox.IsChecked = enabled; + SelectInterval(interval); + FrequencyCardBorder.IsVisible = enabled; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("daily_sentence.settings.title", "Daily sentence settings"); + DescriptionTextBlock.Text = L("daily_sentence.settings.desc", "Configure auto-rotation and refresh interval."); + AutoRotateLabelTextBlock.Text = L("daily_sentence.settings.auto_rotate_label", "Auto-rotation"); + AutoRotateCheckBox.Content = L("daily_sentence.settings.auto_rotate_enabled", "Enable auto-rotation"); + FrequencyLabelTextBlock.Text = L("daily_sentence.settings.frequency_label", "Rotation interval"); + Frequency5mItem.Content = L("daily_sentence.settings.frequency_5m", "5 min"); + Frequency10mItem.Content = L("daily_sentence.settings.frequency_10m", "10 min"); + Frequency40mItem.Content = L("daily_sentence.settings.frequency_40m", "40 min"); + Frequency1hItem.Content = L("daily_sentence.settings.frequency_1h", "1 hour"); + Frequency12hItem.Content = L("daily_sentence.settings.frequency_12h", "12 hours"); + Frequency24hItem.Content = L("daily_sentence.settings.frequency_24h", "24 hours"); + } + + private void OnAutoRotateChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var enabled = AutoRotateCheckBox.IsChecked == true; + FrequencyCardBorder.IsVisible = enabled; + SaveState(); + } + + private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + SaveState(); + } + + private void SaveState() + { + var snapshot = _appSettingsService.Load(); + snapshot.DailySentenceAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true; + snapshot.DailySentenceAutoRotateIntervalMinutes = GetSelectedInterval(); + _appSettingsService.Save(snapshot); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private int GetSelectedInterval() + { + if (FrequencyComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagText && + int.TryParse(tagText, out var minutes)) + { + return NormalizeInterval(minutes); + } + + return 60; + } + + private void SelectInterval(int intervalMinutes) + { + var selected = FrequencyComboBox.Items + .OfType() + .FirstOrDefault(item => + item.Tag is string tagText && + int.TryParse(tagText, out var minutes) && + minutes == intervalMinutes); + FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType().FirstOrDefault(); + } + + private static int NormalizeInterval(int minutes) + { + if (minutes <= 0) + { + return 60; + } + + if (SupportedIntervals.Contains(minutes)) + { + return minutes; + } + + return SupportedIntervals + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(60); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml index cea2470..7539432 100644 --- a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml +++ b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml @@ -35,7 +35,8 @@ Margin="16,14,16,14" RowDefinitions="Auto,*,Auto" RowSpacing="8"> - - availableRowsHeight) + { + var scaling = availableRowsHeight / Math.Max(1, topRowHeight + bottomRowHeight + minMiddleRowHeight); + topRowHeight = Math.Max(minTopRowHeight, topRowHeight * scaling); + bottomRowHeight = Math.Max(minBottomRowHeight, bottomRowHeight * scaling); + } + + var middleHeight = Math.Max( + minMiddleRowHeight, + availableRowsHeight - topRowHeight - bottomRowHeight); + + if (ContentGrid.RowDefinitions.Count >= 3) + { + ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight, GridUnitType.Pixel); + ContentGrid.RowDefinitions[1].Height = new GridLength(middleHeight, GridUnitType.Pixel); + ContentGrid.RowDefinitions[2].Height = new GridLength(bottomRowHeight, GridUnitType.Pixel); + } var topTextWidth = Math.Max(76, innerWidth - refreshSize - ContentGrid.RowSpacing); - var dayWidth = Math.Max(20, topTextWidth * 0.16); - var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - 6 * scale); + var dayWidth = Math.Clamp(topTextWidth * 0.18, 20, Math.Max(24, topTextWidth * 0.32)); + var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - Math.Clamp(6 * scale, 2, 12)); DayTextBlock.MaxWidth = dayWidth; MonthYearTextBlock.MaxWidth = monthYearWidth; @@ -448,17 +476,50 @@ public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, MonthYearTextBlock.FontWeight = monthLayout.Weight; MonthYearTextBlock.LineHeight = monthLayout.LineHeight; - var sentenceLineLimit = innerHeight < _currentCellSize * 1.78 ? 2 : 3; - var sentenceHeight = Math.Max(16, middleHeight * 0.66); - var translationHeight = Math.Max(14, middleHeight - sentenceHeight - Math.Clamp(8 * scale, 3, 12)); + var sentenceTextDemand = Math.Clamp(NormalizeCompactText(SentenceTextBlock.Text).Length, 12, 360); + var translationTextDemand = Math.Clamp(NormalizeCompactText(TranslationTextBlock.Text).Length, 8, 260); + + var sentenceMinLines = innerHeight >= _currentCellSize * 1.78 ? 2 : 1; + var sentenceMaxLines = innerHeight >= _currentCellSize * 2.7 + ? 4 + : innerHeight >= _currentCellSize * 1.92 + ? 3 + : 2; + var translationMaxLines = innerHeight >= _currentCellSize * 2.35 ? 3 : 2; + + var sentenceMinFont = Math.Clamp(23 * scale, 10, 42); + var translationMinFont = Math.Clamp(16 * scale, 8.5, 30); + var sentenceMinHeight = sentenceMinFont * 1.06 * sentenceMinLines; + var translationMinHeight = translationMinFont * 1.06; + var bodyGap = Math.Clamp(8 * scale, 3, 12); + SentenceStack.Spacing = bodyGap; + var minBodyHeight = sentenceMinHeight + translationMinHeight + bodyGap; + + double sentenceHeight; + double translationHeight; + if (middleHeight <= minBodyHeight + 0.6) + { + var compression = middleHeight / Math.Max(1, minBodyHeight); + sentenceHeight = Math.Max(10, sentenceMinHeight * compression); + translationHeight = Math.Max(8, translationMinHeight * compression); + } + else + { + var extraHeight = middleHeight - minBodyHeight; + var sentenceWeight = sentenceTextDemand + 16d; + var translationWeight = translationTextDemand + 8d; + var totalWeight = Math.Max(1d, sentenceWeight + translationWeight); + sentenceHeight = sentenceMinHeight + extraHeight * (sentenceWeight / totalWeight); + translationHeight = translationMinHeight + extraHeight * (translationWeight / totalWeight); + } var sentenceLayout = FitAdaptiveTextLayout( SentenceTextBlock.Text, innerWidth, sentenceHeight, - minLines: 1, - maxLines: sentenceLineLimit, - minFontSize: Math.Clamp(23 * scale, 10, 42), + minLines: sentenceMinLines, + maxLines: sentenceMaxLines, + minFontSize: sentenceMinFont, maxFontSize: Math.Clamp(58 * scale, 18, 80), weightCandidates: HeadlineWeightCandidates, lineHeightFactor: 1.06); @@ -473,8 +534,8 @@ public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, innerWidth, translationHeight, minLines: 1, - maxLines: 2, - minFontSize: Math.Clamp(16 * scale, 8.5, 30), + maxLines: translationMaxLines, + minFontSize: translationMinFont, maxFontSize: Math.Clamp(40 * scale, 12, 54), weightCandidates: BodyWeightCandidates, lineHeightFactor: 1.06); @@ -483,6 +544,8 @@ public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, TranslationTextBlock.FontSize = translationLayout.FontSize; TranslationTextBlock.FontWeight = translationLayout.Weight; TranslationTextBlock.LineHeight = translationLayout.LineHeight; + SentenceTextBlock.MinHeight = Math.Max(0, sentenceLayout.LineHeight * Math.Min(sentenceLayout.MaxLines, Math.Max(1, sentenceMinLines))); + TranslationTextBlock.MinHeight = Math.Max(0, translationLayout.LineHeight); var sourceLayout = FitAdaptiveTextLayout( SourceTextBlock.Text, @@ -532,6 +595,60 @@ public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, } } + private void ApplyAutoRotateSettings() + { + var enabled = true; + var intervalMinutes = 60; + + try + { + var snapshot = _settingsService.Load(); + enabled = snapshot.DailySentenceAutoRotateEnabled; + intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.DailySentenceAutoRotateIntervalMinutes); + } + catch + { + // Keep fallback defaults. + } + + _autoRotateEnabled = enabled; + _refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes); + + if (!_isAttached) + { + return; + } + + if (_autoRotateEnabled) + { + if (!_refreshTimer.IsEnabled) + { + _refreshTimer.Start(); + } + } + else if (_refreshTimer.IsEnabled) + { + _refreshTimer.Stop(); + } + } + + private static int NormalizeAutoRotateIntervalMinutes(int minutes) + { + if (minutes <= 0) + { + return 60; + } + + if (SupportedAutoRotateIntervalsMinutes.Contains(minutes)) + { + return minutes; + } + + return SupportedAutoRotateIntervalsMinutes + .OrderBy(value => Math.Abs(value - minutes)) + .FirstOrDefault(60); + } + private void UpdateDateText() { var now = DateTime.Now; diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 9817d62..d6a9966 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -725,9 +725,22 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopDailySentence) + { + OpenDailySentenceComponentSettings(); + return; + } + + if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews) + { + OpenCnrDailyNewsComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment) { OpenStudyEnvironmentComponentSettings(); + return; } } @@ -827,6 +840,38 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenDailySentenceComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new DailySentenceSettingsWindow(); + settingsContent.SettingsChanged += OnDailySentenceSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + + private void OpenCnrDailyNewsComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new CnrDailyNewsSettingsWindow(); + settingsContent.SettingsChanged += OnCnrDailyNewsSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OnClassScheduleSettingsChanged(object? sender, EventArgs e) { if (_selectedDesktopComponentHost is null) @@ -931,6 +976,54 @@ public partial class MainWindow PersistSettings(); } + private void OnDailySentenceSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is DailySentenceWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + + PersistSettings(); + } + + private void OnCnrDailyNewsSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is CnrDailyNewsWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + + PersistSettings(); + } + private void CloseComponentSettingsWindow() { if (ComponentSettingsWindow is null) @@ -963,6 +1056,16 @@ public partial class MainWindow worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged; } + if (ComponentSettingsContentHost?.Content is DailySentenceSettingsWindow dailySentenceSettingsWindow) + { + dailySentenceSettingsWindow.SettingsChanged -= OnDailySentenceSettingsChanged; + } + + if (ComponentSettingsContentHost?.Content is CnrDailyNewsSettingsWindow cnrDailyNewsSettingsWindow) + { + cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => @@ -1374,6 +1477,14 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopCnrDailyNews, StringComparison.OrdinalIgnoreCase)) + { + // Keep CNR widget at a 2:1 ratio: 4x2, 6x3, 8x4... + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); + } + if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase)) { // Keep noise curve widget in a 2:1 ratio with minimum 4x2.