From 88bd92e40adfafb30c495724073683f5c1781812 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 2 Apr 2026 21:12:06 +0800 Subject: [PATCH] =?UTF-8?q?fead.Hub=E7=BB=84=E4=BB=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E6=89=93=E5=BC=80=E5=9B=BE=E7=89=87=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=89=E6=8C=87=E7=BF=BB=E9=A1=B5=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LanMountainDesktop/App.axaml.cs | 73 ++- .../Behaviors/WindowSlideAnimationBehavior.cs | 147 ------ LanMountainDesktop/Localization/en-US.json | 2 +- LanMountainDesktop/Localization/zh-CN.json | 2 +- .../Models/AppSettingsSnapshot.cs | 2 + .../Models/FusedDesktopLayoutSnapshot.cs | 96 ++++ .../Services/FusedDesktopLayoutService.cs | 173 +++++++ .../Services/StudyAnalyticsService.cs | 30 +- LanMountainDesktop/Services/StudyDataStore.cs | 57 ++- .../Services/WindowPassthroughService.cs | 350 +++++++++++++ .../ViewModels/SettingsViewModels.cs | 178 ++++--- .../StudyScoreOverviewWidget.axaml.cs | 6 - .../StudySessionControlWidget.axaml.cs | 20 - .../StudySessionHistoryWidget.axaml.cs | 39 +- .../Components/ZhiJiaoHubWidget.axaml.cs | 91 ++++ .../FusedDesktopComponentLibraryControl.axaml | 30 ++ ...sedDesktopComponentLibraryControl.axaml.cs | 83 ++++ .../FusedDesktopComponentLibraryWindow.axaml | 41 ++ ...usedDesktopComponentLibraryWindow.axaml.cs | 61 +++ .../Views/MainWindow.DesktopPaging.cs | 93 +++- LanMountainDesktop/Views/MainWindow.axaml.cs | 59 +-- .../SettingsPages/GeneralSettingsPage.axaml | 7 + .../Views/StudySessionReportWindow.axaml | 148 ++++++ .../Views/StudySessionReportWindow.axaml.cs | 95 ++++ .../Views/TransparentOverlayWindow.axaml | 24 + .../Views/TransparentOverlayWindow.axaml.cs | 467 ++++++++++++++++++ 26 files changed, 1991 insertions(+), 383 deletions(-) delete mode 100644 LanMountainDesktop/Behaviors/WindowSlideAnimationBehavior.cs create mode 100644 LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs create mode 100644 LanMountainDesktop/Services/FusedDesktopLayoutService.cs create mode 100644 LanMountainDesktop/Services/WindowPassthroughService.cs create mode 100644 LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml create mode 100644 LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs create mode 100644 LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml create mode 100644 LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs create mode 100644 LanMountainDesktop/Views/StudySessionReportWindow.axaml create mode 100644 LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs create mode 100644 LanMountainDesktop/Views/TransparentOverlayWindow.axaml create mode 100644 LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index dfa681e..cdbba1c 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -67,6 +67,7 @@ public partial class App : Application private NativeMenuItem? _trayExitMenuItem; private PluginRuntimeService? _pluginRuntimeService; private MainWindow? _mainWindow; + private TransparentOverlayWindow? _transparentOverlayWindow; private bool _mainWindowClosed; private bool _uiUnhandledExceptionHooked; private DesktopShellHost? _desktopShellHost; @@ -218,12 +219,37 @@ public partial class App : Application { _ = sender; _ = e; - if (_mainWindow is null) + + // 仅在 Windows 上支持融合桌面功能 + if (!OperatingSystem.IsWindows()) { + AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows."); return; } - - _detachedComponentLibraryWindowService.Open(_mainWindow); + + // 确保透明覆盖层窗口存在 + EnsureTransparentOverlayWindow(); + + // 打开融合桌面组件库窗口 + Dispatcher.UIThread.Post(() => + { + try + { + var window = new FusedDesktopComponentLibraryWindow(); + + if (_transparentOverlayWindow is not null) + { + window.SetOverlayWindow(_transparentOverlayWindow); + } + + window.Show(); + window.Activate(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex); + } + }, DispatcherPriority.Send); } private void DisableAvaloniaDataAnnotationValidation() @@ -481,9 +507,9 @@ public partial class App : Application RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance"); } - private async void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source) + private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source) { - Dispatcher.UIThread.Post(async () => + Dispatcher.UIThread.Post(() => { if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) { @@ -492,19 +518,18 @@ public partial class App : Application try { + // 先隐藏透明覆盖层窗口 + if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible) + { + _transparentOverlayWindow.Hide(); + } + var mainWindow = GetOrCreateMainWindow(desktop, source); mainWindow.ShowInTaskbar = true; if (!mainWindow.IsVisible) { mainWindow.Show(); - if (mainWindow._isFirstLaunchAfterOpen) - { - mainWindow._isFirstLaunchAfterOpen = false; - mainWindow.ForceDesktopPageToFirst(); - } - - await mainWindow.SlideInAsync(); } if (mainWindow.WindowState == WindowState.Minimized) @@ -536,6 +561,18 @@ public partial class App : Application } }, DispatcherPriority.Send); } + + private void EnsureTransparentOverlayWindow() + { + if (_transparentOverlayWindow is null) + { + _transparentOverlayWindow = new TransparentOverlayWindow(); + _transparentOverlayWindow.RestoreMainWindowRequested += (s, e) => + { + RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay"); + }; + } + } internal void PrepareForShutdown(bool isRestart, string source) { @@ -886,17 +923,25 @@ public partial class App : Application SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored"); } - private async void HideMainWindowToTray(MainWindow mainWindow, string source) + internal void HideMainWindowToTray(MainWindow mainWindow, string source) { try { - await mainWindow.SlideOutAsync(); mainWindow.ShowInTaskbar = false; mainWindow.Hide(); SetDesktopShellState(DesktopShellState.TrayOnly, source); AppLogger.Info( "DesktopShell", $"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'."); + + // 检查三指滑动功能是否启用 + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + if (appSnapshot.EnableThreeFingerSwipe) + { + // 显示透明覆盖层窗口 + EnsureTransparentOverlayWindow(); + _transparentOverlayWindow?.Show(); + } } catch (Exception ex) { diff --git a/LanMountainDesktop/Behaviors/WindowSlideAnimationBehavior.cs b/LanMountainDesktop/Behaviors/WindowSlideAnimationBehavior.cs deleted file mode 100644 index 326bb17..0000000 --- a/LanMountainDesktop/Behaviors/WindowSlideAnimationBehavior.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Animation; -using Avalonia.Animation.Easings; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Styling; -using Avalonia.Threading; -using LanMountainDesktop.Theme; - -namespace LanMountainDesktop.Behaviors; - -public static class WindowSlideAnimationBehavior -{ - private static readonly Easing DecelerateEasing = Easing.Parse(FluttermotionToken.StandardBezier); - private static readonly Easing AccelerateEasing = new CubicEaseIn(); - - public static readonly TimeSpan SlideInDuration = TimeSpan.FromMilliseconds(350); - public static readonly TimeSpan SlideOutDuration = TimeSpan.FromMilliseconds(280); - - public static async Task SlideInAsync(Window window, Border desktopHost) - { - if (window is null || desktopHost is null) - { - return; - } - - var screenWidth = Math.Max(1, window.Bounds.Width > 1 ? window.Bounds.Width : PrimaryScreenWidth(window)); - var transform = EnsureTranslateTransform(desktopHost); - - transform.X = screenWidth; - desktopHost.Opacity = 1; - window.Show(); - - if (screenWidth <= 1) - { - transform.X = 0; - return; - } - - var animation = new Animation - { - Duration = SlideInDuration, - Easing = DecelerateEasing, - Children = - { - new KeyFrame - { - Cue = new Cue(0d), - Setters = { new Setter(TranslateTransform.XProperty, screenWidth) } - }, - new KeyFrame - { - Cue = new Cue(1d), - Setters = { new Setter(TranslateTransform.XProperty, 0d) } - } - } - }; - - await animation.RunAsync(desktopHost); - } - - public static async Task SlideOutAsync(Window window, Border desktopHost, Action? onCompleted = null) - { - if (window is null || desktopHost is null) - { - onCompleted?.Invoke(); - return; - } - - var screenWidth = Math.Max(1, window.Bounds.Width > 1 ? window.Bounds.Width : PrimaryScreenWidth(window)); - var transform = EnsureTranslateTransform(desktopHost); - - if (screenWidth <= 1) - { - onCompleted?.Invoke(); - return; - } - - var animation = new Animation - { - Duration = SlideOutDuration, - Easing = AccelerateEasing, - Children = - { - new KeyFrame - { - Cue = new Cue(0d), - Setters = { new Setter(TranslateTransform.XProperty, 0d) } - }, - new KeyFrame - { - Cue = new Cue(1d), - Setters = { new Setter(TranslateTransform.XProperty, screenWidth) } - } - } - }; - - await animation.RunAsync(desktopHost); - onCompleted?.Invoke(); - } - - public static void ResetSlidePosition(Border desktopHost) - { - if (desktopHost is null) - { - return; - } - - var transform = desktopHost.RenderTransform as TranslateTransform; - if (transform is not null) - { - transform.X = 0; - } - - desktopHost.Opacity = 1; - } - - private static TranslateTransform EnsureTranslateTransform(Border desktopHost) - { - if (desktopHost.RenderTransform is TranslateTransform existingTransform) - { - return existingTransform; - } - - var newTransform = new TranslateTransform(); - desktopHost.RenderTransform = newTransform; - return newTransform; - } - - private static double PrimaryScreenWidth(Window window) - { - try - { - if (window.Screens?.Primary is { } screen) - { - return screen.WorkingArea.Width; - } - } - catch - { - } - - return 1920; - } -} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 21d25ef..d4b738d 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -3,7 +3,7 @@ "tray.tooltip": "LanMountainDesktop", "tray.menu.show_desktop": "Open Desktop", "tray.menu.settings": "Settings", - "tray.menu.component_library": "Component Library", + "tray.menu.component_library": "Fused Desktop Settings", "tray.menu.restart": "Restart App", "tray.menu.exit": "Exit App", "button.back_to_windows": "Back to Windows", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 6de73be..389f95b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -3,7 +3,7 @@ "tray.tooltip": "LanMountainDesktop", "tray.menu.show_desktop": "打开桌面", "tray.menu.settings": "设置", - "tray.menu.component_library": "独立组件库", + "tray.menu.component_library": "融合桌面设置", "tray.menu.restart": "重启应用", "tray.menu.exit": "退出应用", "button.back_to_windows": "回到Windows", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 2835b5d..bef7ada 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -116,6 +116,8 @@ public sealed class AppSettingsSnapshot public int StatusBarCustomSpacingPercent { get; set; } = 12; + public bool EnableThreeFingerSwipe { get; set; } = false; + public List DisabledPluginIds { get; set; } = []; #region Study Settings diff --git a/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs new file mode 100644 index 0000000..df071be --- /dev/null +++ b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; + +namespace LanMountainDesktop.Models; + +/// +/// 融合桌面组件放置快照 - 用于在系统桌面(负一屏)上放置组件 +/// +public sealed class FusedDesktopComponentPlacementSnapshot +{ + /// + /// 放置实例ID(唯一标识) + /// + public string PlacementId { get; set; } = string.Empty; + + /// + /// 组件类型ID + /// + public string ComponentId { get; set; } = string.Empty; + + /// + /// X 坐标(像素,相对于屏幕左上角) + /// + public double X { get; set; } + + /// + /// Y 坐标(像素,相对于屏幕左上角) + /// + public double Y { get; set; } + + /// + /// 宽度(像素) + /// + public double Width { get; set; } = 200; + + /// + /// 高度(像素) + /// + public double Height { get; set; } = 200; + + /// + /// Z-Index(用于控制组件层叠顺序) + /// + public int ZIndex { get; set; } + + /// + /// 是否锁定位置(锁定后不可拖动) + /// + public bool IsLocked { get; set; } + + /// + /// 创建深拷贝 + /// + public FusedDesktopComponentPlacementSnapshot Clone() + { + return new FusedDesktopComponentPlacementSnapshot + { + PlacementId = PlacementId, + ComponentId = ComponentId, + X = X, + Y = Y, + Width = Width, + Height = Height, + ZIndex = ZIndex, + IsLocked = IsLocked + }; + } +} + +/// +/// 融合桌面布局快照 - 包含所有在系统桌面上显示的组件 +/// +public sealed class FusedDesktopLayoutSnapshot +{ + /// + /// 是否启用融合桌面功能 + /// + public bool IsEnabled { get; set; } + + /// + /// 组件放置列表 + /// + public List ComponentPlacements { get; set; } = []; + + /// + /// 创建深拷贝 + /// + public FusedDesktopLayoutSnapshot Clone() + { + return new FusedDesktopLayoutSnapshot + { + IsEnabled = IsEnabled, + ComponentPlacements = [.. ComponentPlacements.ConvertAll(p => p.Clone())] + }; + } +} diff --git a/LanMountainDesktop/Services/FusedDesktopLayoutService.cs b/LanMountainDesktop/Services/FusedDesktopLayoutService.cs new file mode 100644 index 0000000..2f1cd8a --- /dev/null +++ b/LanMountainDesktop/Services/FusedDesktopLayoutService.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +/// +/// 融合桌面布局存储服务接口 +/// +public interface IFusedDesktopLayoutService +{ + /// + /// 加载融合桌面布局 + /// + FusedDesktopLayoutSnapshot Load(); + + /// + /// 保存融合桌面布局 + /// + void Save(FusedDesktopLayoutSnapshot snapshot); + + /// + /// 添加组件放置 + /// + void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement); + + /// + /// 更新组件放置 + /// + void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement); + + /// + /// 移除组件放置 + /// + void RemoveComponentPlacement(string placementId); + + /// + /// 清除所有组件放置 + /// + void ClearAllPlacements(); +} + +/// +/// 融合桌面布局存储服务实现 +/// +internal sealed class FusedDesktopLayoutService : IFusedDesktopLayoutService +{ + private static readonly string ConfigFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "LanMountainDesktop", + "fused_desktop_layout.json"); + + private readonly object _lock = new(); + private FusedDesktopLayoutSnapshot? _cachedSnapshot; + + public FusedDesktopLayoutSnapshot Load() + { + lock (_lock) + { + if (_cachedSnapshot is not null) + { + return _cachedSnapshot.Clone(); + } + + try + { + if (!File.Exists(ConfigFilePath)) + { + _cachedSnapshot = new FusedDesktopLayoutSnapshot(); + return _cachedSnapshot.Clone(); + } + + var json = File.ReadAllText(ConfigFilePath); + var snapshot = JsonSerializer.Deserialize(json, JsonOptions); + _cachedSnapshot = snapshot ?? new FusedDesktopLayoutSnapshot(); + return _cachedSnapshot.Clone(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktopLayout", "Failed to load fused desktop layout.", ex); + _cachedSnapshot = new FusedDesktopLayoutSnapshot(); + return _cachedSnapshot.Clone(); + } + } + } + + public void Save(FusedDesktopLayoutSnapshot snapshot) + { + lock (_lock) + { + try + { + _cachedSnapshot = snapshot.Clone(); + + var directory = Path.GetDirectoryName(ConfigFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(snapshot, JsonOptions); + File.WriteAllText(ConfigFilePath, json); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktopLayout", "Failed to save fused desktop layout.", ex); + } + } + } + + public void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement) + { + var snapshot = Load(); + snapshot.ComponentPlacements.Add(placement); + Save(snapshot); + } + + public void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement) + { + var snapshot = Load(); + var index = snapshot.ComponentPlacements.FindIndex(p => p.PlacementId == placement.PlacementId); + if (index >= 0) + { + snapshot.ComponentPlacements[index] = placement; + Save(snapshot); + } + } + + public void RemoveComponentPlacement(string placementId) + { + var snapshot = Load(); + snapshot.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId); + Save(snapshot); + } + + public void ClearAllPlacements() + { + var snapshot = Load(); + snapshot.ComponentPlacements.Clear(); + Save(snapshot); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +} + +/// +/// 融合桌面布局服务提供者 +/// +public static class FusedDesktopLayoutServiceProvider +{ + private static IFusedDesktopLayoutService? _instance; + private static readonly object _lock = new(); + + public static IFusedDesktopLayoutService GetOrCreate() + { + if (_instance is not null) + { + return _instance; + } + + lock (_lock) + { + _instance ??= new FusedDesktopLayoutService(); + return _instance; + } + } +} diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs index 41cddf5..5d4b856 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs @@ -320,9 +320,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService return false; } + // 如果找不到报告,尝试重新从数据库加载 if (!TryFindSessionReportLocked(sessionId, out var report)) { - return false; + // 重新加载历史数据 + RestoreSessionHistoryFromDatabaseLocked(); + + // 再次尝试查找 + if (!TryFindSessionReportLocked(sessionId, out report)) + { + return false; + } } _selectedSessionReportId = report.SessionId; @@ -356,9 +364,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService { ThrowIfDisposedLocked(); var index = FindSessionReportIndexLocked(sessionId); + + // 如果找不到报告,尝试重新从数据库加载 if (index < 0) { - return false; + RestoreSessionHistoryFromDatabaseLocked(); + index = FindSessionReportIndexLocked(sessionId); + + if (index < 0) + { + return false; + } } var updated = _sessionHistory[index] with { Label = normalizedLabel }; @@ -389,9 +405,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService { ThrowIfDisposedLocked(); var index = FindSessionReportIndexLocked(sessionId); + + // 如果找不到报告,尝试重新从数据库加载 if (index < 0) { - return false; + RestoreSessionHistoryFromDatabaseLocked(); + index = FindSessionReportIndexLocked(sessionId); + + if (index < 0) + { + return false; + } } var removed = _sessionHistory[index]; diff --git a/LanMountainDesktop/Services/StudyDataStore.cs b/LanMountainDesktop/Services/StudyDataStore.cs index f7102f6..534f537 100644 --- a/LanMountainDesktop/Services/StudyDataStore.cs +++ b/LanMountainDesktop/Services/StudyDataStore.cs @@ -17,10 +17,17 @@ public sealed class StudyDataStore }; private readonly AppDatabaseService _databaseService; + private readonly Action? _logger; - public StudyDataStore(AppDatabaseService? databaseService = null) + public StudyDataStore(AppDatabaseService? databaseService = null, Action? logger = null) { _databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault(); + _logger = logger; + } + + private void Log(string message) + { + _logger?.Invoke($"[StudyDataStore] {message}"); } public IReadOnlyList LoadSessionReports(int limit = 120) @@ -61,17 +68,25 @@ public sealed class StudyDataStore continue; } - var report = JsonSerializer.Deserialize(json, JsonOptions); - if (report is not null) + try { - reports.Add(report); + var report = JsonSerializer.Deserialize(json, JsonOptions); + if (report is not null) + { + reports.Add(report); + } + } + catch (JsonException ex) + { + Log($"Failed to deserialize session report: {ex.Message}"); } } return reports; } - catch + catch (Exception ex) { + Log($"Failed to load session reports: {ex.Message}"); return Array.Empty(); } } @@ -99,20 +114,28 @@ public sealed class StudyDataStore var json = command.ExecuteScalar() as string; if (string.IsNullOrWhiteSpace(json)) { + Log($"Session report not found for id: {sessionId}"); return false; } var parsed = JsonSerializer.Deserialize(json, JsonOptions); if (parsed is null) { + Log($"Failed to deserialize session report for id: {sessionId}"); return false; } report = parsed; return true; } - catch + catch (JsonException ex) { + Log($"JSON deserialization error for session {sessionId}: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Log($"Failed to get session report {sessionId}: {ex.Message}"); return false; } } @@ -138,9 +161,9 @@ public sealed class StudyDataStore transaction.Commit(); } - catch + catch (Exception ex) { - // Keep runtime resilient when persistence is unavailable. + Log($"Failed to replace session reports: {ex.Message}"); } } @@ -162,8 +185,9 @@ public sealed class StudyDataStore ? null : value.Trim(); } - catch + catch (Exception ex) { + Log($"Failed to get selected session report id: {ex.Message}"); return null; } } @@ -192,9 +216,9 @@ public sealed class StudyDataStore upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim()); upsertCommand.ExecuteNonQuery(); } - catch + catch (Exception ex) { - // Keep runtime resilient when persistence is unavailable. + Log($"Failed to set selected session report id: {ex.Message}"); } } @@ -271,9 +295,9 @@ public sealed class StudyDataStore transaction.Commit(); } - catch + catch (Exception ex) { - // Keep runtime resilient when persistence is unavailable. + Log($"Failed to append noise slice: {ex.Message}"); } } @@ -365,8 +389,9 @@ public sealed class StudyDataStore return entries; } - catch + catch (Exception ex) { + Log($"Failed to load noise slice timeline: {ex.Message}"); return Array.Empty(); } } @@ -389,9 +414,9 @@ public sealed class StudyDataStore command.ExecuteNonQuery(); } - catch + catch (Exception ex) { - // Keep runtime resilient when persistence is unavailable. + Log($"Failed to clear noise slice timeline: {ex.Message}"); } } diff --git a/LanMountainDesktop/Services/WindowPassthroughService.cs b/LanMountainDesktop/Services/WindowPassthroughService.cs new file mode 100644 index 0000000..5fff130 --- /dev/null +++ b/LanMountainDesktop/Services/WindowPassthroughService.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; + +namespace LanMountainDesktop.Services; + +/// +/// 窗口置底服务接口 +/// +public interface IWindowBottomMostService +{ + void SetupBottomMost(Window window); + void SendToBottom(Window window); + bool IsBottomMostSupported { get; } +} + +/// +/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现 +/// +public interface IRegionPassthroughService +{ + /// + /// 设置窗口的可交互区域 + /// + void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions); + + /// + /// 清除所有可交互区域 + /// + void ClearInteractiveRegions(Window window); + + /// + /// 获取当前平台是否支持区域级穿透 + /// + bool IsRegionPassthroughSupported { get; } +} + +/// +/// 窗口置底服务工厂 +/// +public static class WindowBottomMostServiceFactory +{ + private static IWindowBottomMostService? _instance; + private static readonly object _lock = new(); + + public static IWindowBottomMostService GetOrCreate() + { + lock (_lock) + { + return _instance ??= OperatingSystem.IsWindows() + ? new WindowsWindowBottomMostService() + : new NullWindowBottomMostService(); + } + } +} + +/// +/// 区域级穿透服务工厂 +/// +public static class RegionPassthroughServiceFactory +{ + private static IRegionPassthroughService? _instance; + private static readonly object _lock = new(); + + public static IRegionPassthroughService GetOrCreate() + { + lock (_lock) + { + return _instance ??= OperatingSystem.IsWindows() + ? new WindowsRegionPassthroughService() + : new NullRegionPassthroughService(); + } + } +} + +/// +/// Windows 平台窗口置底服务 +/// +internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService +{ + private const int GWL_EXSTYLE = -20; + private const int GWL_HWNDPARENT = -8; + private const int GWLP_WNDPROC = -4; + private const int WS_EX_TOOLWINDOW = 0x00000080; + private const int WS_EX_APPWINDOW = 0x00040000; + private const int WS_EX_NOACTIVATE = 0x08000000; + private const int WS_EX_LAYERED = 0x00080000; + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOACTIVATE = 0x0010; + private const int WM_WINDOWPOSCHANGING = 0x0046; + private const int WM_NCHITTEST = 0x0084; + private const int HTTRANSPARENT = -1; + private const int HTCLIENT = 1; + + private static readonly IntPtr HWND_BOTTOM = new(1); + private static readonly Dictionary _bottomMostWindows = new(); + private static readonly Dictionary _originalWndProcs = new(); + private static readonly Dictionary> _interactiveRegions = new(); + private static readonly object _staticLock = new(); + + public bool IsBottomMostSupported => true; + + public void SetupBottomMost(Window window) + { + if (!OperatingSystem.IsWindows()) return; + + window.Opened += (s, e) => + { + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) return; + + // 设置扩展样式 + var exStyle = GetWindowLong(handle, GWL_EXSTYLE); + exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW; + SetWindowLong(handle, GWL_EXSTYLE, exStyle); + + // 设置为桌面子窗口 + SetAsDesktopChild(handle); + + // 注册置底状态 + lock (_staticLock) + { + _bottomMostWindows[handle] = true; + _interactiveRegions[handle] = []; + } + + // 注入消息钩子 + InstallMessageHook(handle); + + // 初始置底 + SendToBottomInternal(handle); + + AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}"); + }; + + window.Closed += (s, e) => + { + var handle = GetWindowHandle(window); + if (handle != IntPtr.Zero) + { + lock (_staticLock) + { + _bottomMostWindows.Remove(handle); + _originalWndProcs.Remove(handle); + _interactiveRegions.Remove(handle); + } + } + }; + } + + public void SendToBottom(Window window) + { + var handle = GetWindowHandle(window); + if (handle != IntPtr.Zero) SendToBottomInternal(handle); + } + + private static IntPtr GetWindowHandle(Window window) + { + try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; } + catch { return IntPtr.Zero; } + } + + private static void SendToBottomInternal(IntPtr handle) + { + SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE); + } + + private static void SetAsDesktopChild(IntPtr handle) + { + var windowHandles = new ArrayList(); + EnumWindows(EnumWindowsCallback, windowHandles); + foreach (IntPtr h in windowHandles) + { + var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null); + if (hDefView != IntPtr.Zero) + { + SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32()); + break; + } + } + } + + private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles) + { + handles.Add(handle); + return true; + } + + private static void InstallMessageHook(IntPtr handle) + { + var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC); + if (originalWndProc == IntPtr.Zero) return; + + lock (_staticLock) + { + _originalWndProcs[handle] = originalWndProc; + } + + SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(SubclassWndProc)); + } + + private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam) + { + // 处理 WM_WINDOWPOSCHANGING - 保持置底 + if (msg == WM_WINDOWPOSCHANGING) + { + lock (_staticLock) + { + if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost) + { + SendToBottomInternal(hWnd); + } + } + } + + // 处理 WM_NCHITTEST - 区域级穿透 + if (msg == WM_NCHITTEST) + { + // 从 lParam 解析坐标(低字为 X,高字为 Y) + var x = (short)(wParam.ToInt32() & 0xFFFF); + var y = (short)((wParam.ToInt32() >> 16) & 0xFFFF); + var point = new Point(x, y); + + lock (_staticLock) + { + if (_interactiveRegions.TryGetValue(hWnd, out var regions)) + { + foreach (var region in regions) + { + if (region.Contains(point)) + { + // 在可交互区域内,返回 HTCLIENT + return (IntPtr)HTCLIENT; + } + } + } + } + + // 不在可交互区域内,返回 HTTRANSPARENT 让事件穿透 + return (IntPtr)HTTRANSPARENT; + } + + // 调用原始窗口过程 + IntPtr originalWndProc; + lock (_staticLock) + { + if (!_originalWndProcs.TryGetValue(hWnd, out originalWndProc)) + { + return DefWindowProc(hWnd, msg, wParam, lParam); + } + } + + return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam); + } + + /// + /// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用) + /// + internal static void SetInteractiveRegionsInternal(IntPtr handle, List regions) + { + lock (_staticLock) + { + _interactiveRegions[handle] = regions; + } + } + + private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll")] + private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, ArrayList lParam); + + private delegate bool EnumWindowsProc(IntPtr handle, ArrayList handles); + + [DllImport("user32.dll")] + private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam); +} + +/// +/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST +/// +internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService +{ + public bool IsRegionPassthroughSupported => true; + + public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) + { + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) return; + + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List(interactiveRegions)); + AppLogger.Info("RegionPassthrough", $"Set {interactiveRegions.Count} interactive regions."); + } + + public void ClearInteractiveRegions(Window window) + { + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) return; + + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, []); + } + + private static IntPtr GetWindowHandle(Window window) + { + try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; } + catch { return IntPtr.Zero; } + } +} + +/// +/// 空实现 +/// +internal sealed class NullWindowBottomMostService : IWindowBottomMostService +{ + public bool IsBottomMostSupported => false; + public void SetupBottomMost(Window window) { } + public void SendToBottom(Window window) { } +} + +internal sealed class NullRegionPassthroughService : IRegionPassthroughService +{ + public bool IsRegionPassthroughSupported => false; + public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) { } + public void ClearInteractiveRegions(Window window) { } +} diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index ef7015e..6af1761 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -164,14 +164,18 @@ public sealed class TimeZoneOption public string Label { get; } } -public sealed partial class GeneralSettingsPageViewModel : ViewModelBase -{ - private readonly ISettingsFacadeService _settingsFacade; - private readonly TimeZoneService _timeZoneService; - private readonly LocalizationService _localizationService = new(); - private readonly string _startupRenderMode; - private string _languageCode; - private bool _isInitializing; +public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDisposable + { + private readonly ISettingsFacadeService _settingsFacade; + private readonly TimeZoneService _timeZoneService; + private readonly LocalizationService _localizationService = new(); + private readonly string _startupRenderMode; + private string _languageCode; + private bool _isInitializing; + private bool _disposed; + + [ObservableProperty] + private bool _enableThreeFingerSwipe; public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade) { @@ -200,9 +204,65 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase SelectedRenderMode = RenderModes.FirstOrDefault(option => string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase)) ?? RenderModes[0]; + EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe; _isInitializing = false; RefreshPreview(); + + // 监听设置变更,防止被意外重置 + _settingsFacade.Settings.Changed += OnSettingsChanged; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _settingsFacade.Settings.Changed -= OnSettingsChanged; + _disposed = true; + } + + private void OnSettingsChanged(object? sender, SettingsChangedEvent e) + { + if (e.Scope != SettingsScope.App) + { + return; + } + + var changedKeys = e.ChangedKeys?.ToArray(); + if (changedKeys is null || changedKeys.Length == 0) + { + return; + } + + // 如果是其他设置变更,重新加载我们的设置 + _isInitializing = true; + try + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe; + } + finally + { + _isInitializing = false; + } + } + + partial void OnEnableThreeFingerSwipeChanged(bool value) + { + if (_isInitializing) + { + return; + } + + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + appSnapshot.EnableThreeFingerSwipe = value; + _settingsFacade.Settings.SaveSnapshot( + SettingsScope.App, + appSnapshot, + changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]); } public event Action? RestartRequested; @@ -2330,25 +2390,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase private bool _isInitializing; private readonly IStudyAnalyticsService _studyAnalyticsService; - // 防抖计时器 - private System.Timers.Timer? _noiseSettingsDebounceTimer; - private System.Timers.Timer? _timerSettingsDebounceTimer; - private System.Timers.Timer? _alertSettingsDebounceTimer; - private System.Timers.Timer? _displaySettingsDebounceTimer; - private bool _hasPendingNoiseSave; - private bool _hasPendingTimerSave; - private bool _hasPendingAlertSave; - private bool _hasPendingDisplaySave; - public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade, IStudyAnalyticsService? studyAnalyticsService = null) { _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); _studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault(); _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); - // 初始化防抖计时器 - InitializeDebounceTimers(); - RefreshLocalizedText(); _isInitializing = true; @@ -2356,21 +2403,6 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase _isInitializing = false; } - private void InitializeDebounceTimers() - { - _noiseSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false }; - _noiseSettingsDebounceTimer.Elapsed += async (s, e) => await SaveNoiseSettingsDebounced(); - - _timerSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false }; - _timerSettingsDebounceTimer.Elapsed += async (s, e) => await SaveTimerSettingsDebounced(); - - _alertSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false }; - _alertSettingsDebounceTimer.Elapsed += async (s, e) => await SaveAlertSettingsDebounced(); - - _displaySettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false }; - _displaySettingsDebounceTimer.Elapsed += async (s, e) => await SaveDisplaySettingsDebounced(); - } - #region Properties - Master Switch [ObservableProperty] @@ -2455,7 +2487,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateThresholdText(); if (!_isInitializing) { - DebounceNoiseSettingsSave(); + SaveNoiseSettings(); } } @@ -2471,7 +2503,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateSamplingRateText(); if (!_isInitializing) { - DebounceNoiseSettingsSave(); + SaveNoiseSettings(); } } @@ -2485,18 +2517,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS"; } - private void DebounceNoiseSettingsSave() + private void SaveNoiseSettings() { - _hasPendingNoiseSave = true; - _noiseSettingsDebounceTimer?.Stop(); - _noiseSettingsDebounceTimer?.Start(); - } - - private async Task SaveNoiseSettingsDebounced() - { - if (!_hasPendingNoiseSave) return; - _hasPendingNoiseSave = false; - try { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -2601,7 +2623,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateFocusDurationText(); if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2617,7 +2639,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateBreakDurationText(); if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2633,7 +2655,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateLongBreakDurationText(); if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2649,7 +2671,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateSessionsBeforeLongBreakText(); if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2657,7 +2679,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase { if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2665,7 +2687,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase { if (!_isInitializing) { - DebounceTimerSettingsSave(); + SaveTimerSettings(); } } @@ -2693,18 +2715,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} {unit}"; } - private void DebounceTimerSettingsSave() + private void SaveTimerSettings() { - _hasPendingTimerSave = true; - _timerSettingsDebounceTimer?.Stop(); - _timerSettingsDebounceTimer?.Start(); - } - - private async Task SaveTimerSettingsDebounced() - { - if (!_hasPendingTimerSave) return; - _hasPendingTimerSave = false; - try { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -2762,7 +2774,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase { if (!_isInitializing) { - DebounceAlertSettingsSave(); + SaveAlertSettings(); } } @@ -2777,22 +2789,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase if (!_isInitializing) { - DebounceAlertSettingsSave(); + SaveAlertSettings(); } } - private void DebounceAlertSettingsSave() + private void SaveAlertSettings() { - _hasPendingAlertSave = true; - _alertSettingsDebounceTimer?.Stop(); - _alertSettingsDebounceTimer?.Start(); - } - - private async Task SaveAlertSettingsDebounced() - { - if (!_hasPendingAlertSave) return; - _hasPendingAlertSave = false; - try { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -2855,7 +2857,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase { if (!_isInitializing) { - DebounceDisplaySettingsSave(); + SaveDisplaySettings(); } } @@ -2871,7 +2873,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateBaselineDbText(); if (!_isInitializing) { - DebounceDisplaySettingsSave(); + SaveDisplaySettings(); } } @@ -2887,7 +2889,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase UpdateAvgWindowSecText(); if (!_isInitializing) { - DebounceDisplaySettingsSave(); + SaveDisplaySettings(); } } @@ -2907,18 +2909,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase AvgWindowSecValueText = $"{AvgWindowSec} {unit}"; } - private void DebounceDisplaySettingsSave() + private void SaveDisplaySettings() { - _hasPendingDisplaySave = true; - _displaySettingsDebounceTimer?.Stop(); - _displaySettingsDebounceTimer?.Start(); - } - - private async Task SaveDisplaySettingsDebounced() - { - if (!_hasPendingDisplaySave) return; - _hasPendingDisplaySave = false; - try { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); diff --git a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs index 9ce318c..d2c06d9 100644 --- a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs @@ -187,12 +187,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi return; } - if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null) - { - ApplySessionReportMode(snapshot, panelColor); - return; - } - ApplyRealtimeMode(snapshot, realtimeScore, panelColor); } diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs index e826f44..7cf23c6 100644 --- a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs @@ -169,15 +169,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { var snapshot = _studyAnalyticsService.GetSnapshot(); - var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; - if (isReportViewing) - { - _studyAnalyticsService.ClearLastSessionReport(); - _transientMessage = null; - RefreshVisual(); - return; - } - var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running; var success = isRunning @@ -221,17 +212,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW _transientMessage = null; } - var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; - if (isReportViewing) - { - PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report"); - SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm"); - ActionIcon.Kind = MaterialIconKind.Check; - ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399")); - ApplyTransientWarningTintIfNeeded(panelColor); - return; - } - var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running; if (isRunning) { diff --git a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs index 9e0d5eb..4c8e058 100644 --- a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs @@ -386,24 +386,39 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW { CloseDialog(); - _loadingSessionId = sessionId; - SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4); - if (_currentSnapshot is not null) - { - RenderSnapshot(_currentSnapshot); - } - - if (_studyAnalyticsService.SelectSessionReport(sessionId)) + // 直接从服务获取报告数据 + var snapshot = _studyAnalyticsService.GetSnapshot(); + var entry = FindHistoryEntry(snapshot.SessionHistory, sessionId); + + if (entry is null) { + SetTransientStatus(L("study.session_history.select_failed", "Unable to find session")); return; } - _loadingSessionId = null; - SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session")); - if (_currentSnapshot is not null) + // 加载完整的报告数据 + if (!_studyAnalyticsService.SelectSessionReport(sessionId)) { - RenderSnapshot(_currentSnapshot); + SetTransientStatus(L("study.session_history.select_failed", "Unable to load session")); + return; } + + // 获取完整报告 + snapshot = _studyAnalyticsService.GetSnapshot(); + var report = snapshot.LastSessionReport; + + if (report is null) + { + SetTransientStatus(L("study.session_history.select_failed", "Unable to load session data")); + return; + } + + // 打开报告详情窗口 + var window = new StudySessionReportWindow(report); + window.Show(); + + // 清除选中状态,不保持联动模式 + _studyAnalyticsService.ClearLastSessionReport(); } private void ShowRenameDialog(string sessionId, string label) diff --git a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs index 7d634e5..a50cdcd 100644 --- a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; @@ -63,6 +64,8 @@ public partial class ZhiJiaoHubWidget : UserControl, private double _dragOffset; private int _lastSwipeDirection = 0; private bool _isInErrorState; + private DateTime _lastClickTime = DateTime.MinValue; + private const int DoubleClickThresholdMs = 300; private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler { @@ -679,6 +682,19 @@ public partial class ZhiJiaoHubWidget : UserControl, return; } + var currentTime = DateTime.Now; + var timeSinceLastClick = (currentTime - _lastClickTime).TotalMilliseconds; + + if (timeSinceLastClick < DoubleClickThresholdMs) + { + _lastClickTime = DateTime.MinValue; + _ = OpenCurrentImageAsync(); + e.Handled = true; + return; + } + + _lastClickTime = currentTime; + if (_images.Count <= 1) { return; @@ -689,6 +705,81 @@ public partial class ZhiJiaoHubWidget : UserControl, _dragOffset = 0; } + private async Task OpenCurrentImageAsync() + { + if (_images.Count == 0 || _currentImageIndex < 0 || _currentImageIndex >= _images.Count) + { + return; + } + + var imageItem = _images[_currentImageIndex]; + + try + { + string? filePath = null; + + if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath)) + { + filePath = imageItem.LocalPath; + } + else + { + filePath = await DownloadImageToTempAsync(imageItem); + } + + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true + }; + Process.Start(startInfo); + } + catch + { + } + } + } + catch + { + } + } + + private async Task DownloadImageToTempAsync(ZhiJiaoHubHybridImageItem imageItem) + { + try + { + var imageUrl = imageItem.RemoteUrl; + if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase)) + { + imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl; + } + + using var response = await ImageHttpClient.GetAsync(imageUrl); + response.EnsureSuccessStatusCode(); + + var fileExtension = Path.GetExtension(new Uri(imageUrl).AbsolutePath); + if (string.IsNullOrEmpty(fileExtension)) + { + fileExtension = ".jpg"; + } + + var tempPath = Path.Combine(Path.GetTempPath(), $"LanMountain_ZhiJiaoHub_{Guid.NewGuid():N}{fileExtension}"); + await using var fileStream = File.OpenWrite(tempPath); + var contentStream = await response.Content.ReadAsStreamAsync(); + await contentStream.CopyToAsync(fileStream); + + return tempPath; + } + catch + { + return null; + } + } + private void OnPointerMoved(object? sender, PointerEventArgs e) { if (!_isDragging || _images.Count <= 1) diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml new file mode 100644 index 0000000..de6e051 --- /dev/null +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs new file mode 100644 index 0000000..2a46d01 --- /dev/null +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -0,0 +1,83 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views; + +/// +/// 融合桌面组件库控件 - 专门用于添加组件到系统桌面(负一屏) +/// +public partial class FusedDesktopComponentLibraryControl : UserControl +{ + /// + /// 添加组件到融合桌面事件 + /// + public event EventHandler? AddComponentRequested; + + public FusedDesktopComponentLibraryControl() + { + InitializeComponent(); + LoadComponents(); + } + + /// + /// 加载可用组件列表 + /// + private void LoadComponents() + { + var registry = ComponentRegistry.CreateDefault(); + + foreach (var definition in registry.GetAll()) + { + if (!definition.AllowDesktopPlacement) + { + continue; + } + + var button = new Button + { + Width = 100, + Height = 100, + Margin = new Thickness(4), + Padding = new Thickness(8), + CornerRadius = new CornerRadius(12), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Tag = definition.Id + }; + + var textBlock = new TextBlock + { + Text = definition.DisplayName, + FontSize = 11, + TextAlignment = TextAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 2, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + button.Content = textBlock; + button.Click += OnAddComponentClick; + + ComponentPanel.Children.Add(button); + } + } + + /// + /// 添加组件按钮点击 + /// + private void OnAddComponentClick(object? sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is string componentId) + { + AddComponentRequested?.Invoke(this, componentId); + } + } +} diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml new file mode 100644 index 0000000..1325811 --- /dev/null +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs new file mode 100644 index 0000000..631f8a7 --- /dev/null +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs @@ -0,0 +1,61 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views; + +/// +/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏) +/// +/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面 +/// +public partial class FusedDesktopComponentLibraryWindow : Window +{ + private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); + private TransparentOverlayWindow? _overlayWindow; + + public FusedDesktopComponentLibraryWindow() + { + InitializeComponent(); + + LibraryControl.AddComponentRequested += OnAddComponentRequested; + } + + /// + /// 设置透明覆盖层窗口引用 + /// + public void SetOverlayWindow(TransparentOverlayWindow overlayWindow) + { + _overlayWindow = overlayWindow; + } + + /// + /// 添加组件请求处理 + /// + private void OnAddComponentRequested(object? sender, string componentId) + { + if (_overlayWindow is null) + { + AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set."); + return; + } + + // 在屏幕中央添加组件 + var screenBounds = _overlayWindow.Bounds; + var x = screenBounds.Width / 2 - 100; // 居中 + var y = screenBounds.Height / 2 - 100; + + _overlayWindow.AddComponent(componentId, x, y, 200, 200); + + AppLogger.Info("FusedDesktopLibrary", $"Added component {componentId} to fused desktop."); + + // 关闭窗口 + Close(); + } + + private void OnCloseClick(object? sender, RoutedEventArgs e) + { + Close(); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index 46f40bd..7a661b0 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -16,6 +16,7 @@ using Avalonia.Threading; using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; @@ -73,6 +74,10 @@ public partial class MainWindow private int? _desktopPageContextSettlingTargetIndex; private int _desktopPageContextSettleRevision; + // 三指滑动/右键拖动相关 + private bool _isThreeFingerOrRightDragSwipeActive; + private readonly HashSet _activePointerIds = []; + private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount); private int TotalSurfaceCount => LauncherSurfaceIndex + 1; @@ -375,18 +380,6 @@ public partial class MainWindow UpdateDesktopPageAwareComponentContext(); } - public void ForceDesktopPageToFirst() - { - if (_currentDesktopSurfaceIndex == 0) - { - return; - } - - _currentDesktopSurfaceIndex = 0; - ApplyDesktopSurfaceOffset(); - SchedulePersistSettings(delayMs: 120); - } - private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled) { if (_desktopPagesHostTransform is null) @@ -527,6 +520,49 @@ public partial class MainWindow return; } + // 检查三指滑动功能是否启用 + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + var isThreeFingerSwipeEnabled = appSnapshot.EnableThreeFingerSwipe; + + var currentPoint = e.GetCurrentPoint(DesktopPagesViewport); + var pointerId = e.Pointer?.Id ?? 0; + var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed; + var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed; + + // 处理三指滑动/右键拖动模式 + if (isThreeFingerSwipeEnabled) + { + // 跟踪活跃指针 + if (isLeftButtonPressed || isRightButtonPressed) + { + _activePointerIds.Add(pointerId); + } + + // 判断是否是三指滑动或右键拖动 + var isThreeFinger = _activePointerIds.Count >= 3; + var isRightDrag = isRightButtonPressed; + + if (isThreeFinger || isRightDrag) + { + // 三指/右键拖动模式:跳过所有组件交互检查,直接开始滑动 + ClearDesktopPageContextSettle(refreshContext: false); + _isThreeFingerOrRightDragSwipeActive = true; + _isDesktopSwipeActive = true; + _isDesktopSwipeDirectionLocked = false; + _desktopSwipeStartPoint = pointerInViewport; + _desktopSwipeCurrentPoint = _desktopSwipeStartPoint; + _desktopSwipeLastPoint = _desktopSwipeStartPoint; + _desktopSwipeVelocityX = 0; + _desktopSwipeLastTimestamp = Stopwatch.GetTimestamp(); + _desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth; + + // 标记事件已处理,防止组件响应 + e.Handled = true; + return; + } + } + + // 原有单指滑动逻辑 if (IsInteractivePointerSource(e.Source)) { return; @@ -537,7 +573,7 @@ public partial class MainWindow return; } - if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed) + if (!isLeftButtonPressed) { return; } @@ -788,6 +824,10 @@ public partial class MainWindow private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e) { + // 清理活跃指针 + var pointerId = e.Pointer?.Id ?? 0; + _activePointerIds.Remove(pointerId); + if (EndDesktopSwipeInteraction(e.Pointer)) { e.Handled = true; @@ -796,6 +836,10 @@ public partial class MainWindow private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) { + // 清理活跃指针 + var pointerId = e.Pointer?.Id ?? 0; + _activePointerIds.Remove(pointerId); + EndDesktopSwipeInteraction(e.Pointer); } @@ -814,6 +858,8 @@ public partial class MainWindow _isDesktopSwipeActive = false; _isDesktopSwipeDirectionLocked = false; + _isThreeFingerOrRightDragSwipeActive = false; + _activePointerIds.Clear(); _desktopSwipeVelocityX = 0; _desktopSwipeLastTimestamp = 0; if (wasDirectionLocked) @@ -831,8 +877,12 @@ public partial class MainWindow } var wasDirectionLocked = _isDesktopSwipeDirectionLocked; + var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive; _isDesktopSwipeActive = false; _isDesktopSwipeDirectionLocked = false; + _isThreeFingerOrRightDragSwipeActive = false; + _activePointerIds.Clear(); + if (pointer?.Captured == DesktopPagesViewport) { pointer.Capture(null); @@ -861,6 +911,23 @@ public partial class MainWindow var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05; var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold; + // 检查:三指/右键拖动 && 在第一页 && 向右滑动 + if (wasThreeFingerOrRightDrag && + _currentDesktopSurfaceIndex == 0 && + deltaX > 0 && // 向右滑动 + (hasDistanceIntent || hasVelocityIntent)) + { + // 最小化到 Windows 桌面 + if (Application.Current is App app) + { + app.HideMainWindowToTray(this, "ThreeFingerOrRightDragSwipe"); + } + + ApplyDesktopSurfaceOffset(); + _desktopSwipeVelocityX = 0; + return true; + } + if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent)) { projectedTargetIndex = Math.Clamp( diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 16c3f39..abbf02f 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -18,7 +18,6 @@ using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using FluentAvalonia.Styling; -using LanMountainDesktop.Behaviors; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; @@ -109,9 +108,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider private bool _suppressWeatherLocationEvents; private bool _suppressSettingsPersistence; private bool _isComponentLibraryOpen; - private bool _isSlideAnimating; - private int _slideAnimationGuard; - internal bool _isFirstLaunchAfterOpen = true; private Border? _selectedDesktopComponentHost; private bool _reopenSettingsAfterComponentLibraryClose; private TranslateTransform? _settingsContentPanelTransform; @@ -789,60 +785,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider _ = cellSize; } - private async void OnMinimizeClick(object? sender, RoutedEventArgs e) + private void OnMinimizeClick(object? sender, RoutedEventArgs e) { - if (_isSlideAnimating) - { - return; - } - - await SlideOutAsync(); - } - - public async Task SlideInAsync() - { - if (_isSlideAnimating || DesktopHost is null) - { - return; - } - - var guard = System.Threading.Interlocked.Increment(ref _slideAnimationGuard); - _isSlideAnimating = true; - - try - { - await WindowSlideAnimationBehavior.SlideInAsync(this, DesktopHost); - } - finally - { - _isSlideAnimating = false; - System.Threading.Interlocked.Decrement(ref _slideAnimationGuard); - } - } - - public async Task SlideOutAsync() - { - if (_isSlideAnimating || DesktopHost is null) - { - return; - } - - var guard = System.Threading.Interlocked.Increment(ref _slideAnimationGuard); - _isSlideAnimating = true; - - try - { - await WindowSlideAnimationBehavior.SlideOutAsync(this, DesktopHost, () => - { - WindowState = WindowState.Minimized; - WindowSlideAnimationBehavior.ResetSlidePosition(DesktopHost!); - }); - } - finally - { - _isSlideAnimating = false; - System.Threading.Interlocked.Decrement(ref _slideAnimationGuard); - } + WindowState = WindowState.Minimized; } private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) diff --git a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml index e192389..8c36a06 100644 --- a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml @@ -14,6 +14,13 @@ Text="{Binding BasicHeader}" Margin="0,0,0,4" /> + + + + + + diff --git a/LanMountainDesktop/Views/StudySessionReportWindow.axaml b/LanMountainDesktop/Views/StudySessionReportWindow.axaml new file mode 100644 index 0000000..e90a7c7 --- /dev/null +++ b/LanMountainDesktop/Views/StudySessionReportWindow.axaml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs b/LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs new file mode 100644 index 0000000..4adfbf4 --- /dev/null +++ b/LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Interactivity; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Views; + +public partial class StudySessionReportWindow : Window +{ + private StudySessionReport? _report; + + public StudySessionReportWindow() + { + InitializeComponent(); + CloseButton.Click += OnCloseButtonClick; + } + + public StudySessionReportWindow(StudySessionReport report) : this() + { + LoadReport(report); + } + + public void LoadReport(StudySessionReport report) + { + _report = report; + + // 设置标题 + TitleTextBlock.Text = string.IsNullOrWhiteSpace(report.Label) + ? "自习报告" + : report.Label; + SubtitleTextBlock.Text = string.Format( + CultureInfo.CurrentCulture, + "{0:yyyy-MM-dd HH:mm} - {1:HH:mm} ({2})", + report.StartedAt.ToLocalTime(), + report.EndedAt.ToLocalTime(), + FormatDuration(report.Duration)); + + // 设置汇总数据 + AvgScoreTextBlock.Text = report.Metrics.AvgScore.ToString("F1", CultureInfo.CurrentCulture); + MaxScoreTextBlock.Text = report.Metrics.MaxScore.ToString("F1", CultureInfo.CurrentCulture); + MinScoreTextBlock.Text = report.Metrics.MinScore.ToString("F1", CultureInfo.CurrentCulture); + InterruptCountTextBlock.Text = report.Metrics.TotalSegmentCount.ToString(CultureInfo.CurrentCulture); + + // 构建详细数据表 + BuildDetailDataTable(report); + } + + private void BuildDetailDataTable(StudySessionReport report) + { + var items = new ObservableCollection(); + + foreach (var slice in report.Slices) + { + items.Add(new DetailDataRow( + TimeRange: $"{slice.StartAt.ToLocalTime():HH:mm} - {slice.EndAt.ToLocalTime():HH:mm}", + AvgDb: slice.Display.AvgDb, + Score: slice.Score, + SegmentCount: slice.Raw.SegmentCount)); + } + + DetailDataGrid.ItemsSource = items; + } + + private void OnCloseButtonClick(object? sender, RoutedEventArgs e) + { + Close(); + } + + private static string FormatDuration(TimeSpan duration) + { + if (duration.TotalHours >= 1) + { + return string.Format( + CultureInfo.CurrentCulture, + "{0}小时{1}分钟", + (int)duration.TotalHours, + duration.Minutes); + } + + return string.Format( + CultureInfo.CurrentCulture, + "{0}分钟", + duration.Minutes); + } +} + +public record DetailDataRow( + string TimeRange, + double AvgDb, + double Score, + int SegmentCount); diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml new file mode 100644 index 0000000..d5b8838 --- /dev/null +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml @@ -0,0 +1,24 @@ + + + + + + diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs new file mode 100644 index 0000000..b53646a --- /dev/null +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Views; + +/// +/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上 +/// 支持在系统桌面上自由摆放组件 +/// +public partial class TransparentOverlayWindow : Window +{ + private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); + private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); + private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); + + // 滑动状态 + private bool _isSwipeActive; + private bool _isSwipeDirectionLocked; + private Point _swipeStartPoint; + private Point _swipeCurrentPoint; + private Point _swipeLastPoint; + private double _swipeVelocityX; + private long _swipeLastTimestamp; + + // 三指/右键拖动状态 + private bool _isThreeFingerOrRightDragSwipeActive; + private readonly HashSet _activePointerIds = []; + + // 组件管理 + private readonly Dictionary _componentHosts = []; + private readonly List _interactiveRegions = []; + private FusedDesktopLayoutSnapshot _layout = new(); + + // 拖拽状态 + private bool _isDragging; + private string? _draggingPlacementId; + private Point _dragStartPoint; + private Border? _draggingHost; + + public event EventHandler? RestoreMainWindowRequested; + + public TransparentOverlayWindow() + { + InitializeComponent(); + + // 仅在 Windows 上启用置底功能 + if (OperatingSystem.IsWindows()) + { + _bottomMostService.SetupBottomMost(this); + } + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + if (OperatingSystem.IsWindows()) + { + _bottomMostService.SendToBottom(this); + } + + // 加载布局 + _layout = _layoutService.Load(); + + // TODO: 渲染组件(需要从 MainWindow 获取组件注册表) + + AppLogger.Info("TransparentOverlay", "Transparent overlay window opened."); + } + + protected override void OnClosed(EventArgs e) + { + SaveLayout(); + base.OnClosed(e); + } + + /// + /// 更新可交互区域 + /// + private void UpdateInteractiveRegions() + { + _interactiveRegions.Clear(); + + foreach (var host in _componentHosts.Values) + { + var x = Canvas.GetLeft(host); + var y = Canvas.GetTop(host); + _interactiveRegions.Add(new Rect(x, y, host.Width, host.Height)); + } + + _regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions); + } + + /// + /// 保存布局 + /// + private void SaveLayout() + { + _layoutService.Save(_layout); + } + + /// + /// 添加组件(供外部调用) + /// + public void AddComponent(string componentId, double x, double y, double width = 200, double height = 200) + { + var placementId = Guid.NewGuid().ToString("N"); + var placement = new FusedDesktopComponentPlacementSnapshot + { + PlacementId = placementId, + ComponentId = componentId, + X = x, + Y = y, + Width = width, + Height = height, + ZIndex = _layout.ComponentPlacements.Count + }; + + _layout.ComponentPlacements.Add(placement); + UpdateInteractiveRegions(); + SaveLayout(); + + AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y})"); + } + + /// + /// 移除组件 + /// + public void RemoveComponent(string placementId) + { + if (_componentHosts.TryGetValue(placementId, out var host)) + { + if (Content is Canvas canvas) + { + canvas.Children.Remove(host); + } + _componentHosts.Remove(placementId); + } + + _layout.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId); + UpdateInteractiveRegions(); + SaveLayout(); + } + + /// + /// 渲染组件(从外部传入控件) + /// + public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) + { + var host = new Border + { + Tag = placementId, + Width = width, + Height = height, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(12), + ClipToBounds = true, + Child = component + }; + + Canvas.SetLeft(host, x); + Canvas.SetTop(host, y); + + // 添加拖拽支持 + host.PointerPressed += OnComponentPointerPressed; + host.PointerMoved += OnComponentPointerMoved; + host.PointerReleased += OnComponentPointerReleased; + + if (Content is Canvas canvas) + { + canvas.Children.Add(host); + } + + _componentHosts[placementId] = host; + UpdateInteractiveRegions(); + } + + // 组件拖拽处理 + private void OnComponentPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Border host || host.Tag is not string placementId) return; + + var point = e.GetCurrentPoint(this); + if (!point.Properties.IsLeftButtonPressed) return; + + _isDragging = true; + _draggingPlacementId = placementId; + _draggingHost = host; + _dragStartPoint = e.GetPosition(this); + + e.Pointer.Capture(host); + e.Handled = true; + } + + private void OnComponentPointerMoved(object? sender, PointerEventArgs e) + { + if (!_isDragging || _draggingHost is null) return; + + var currentPoint = e.GetPosition(this); + var deltaX = currentPoint.X - _dragStartPoint.X; + var deltaY = currentPoint.Y - _dragStartPoint.Y; + + var currentX = Canvas.GetLeft(_draggingHost); + var currentY = Canvas.GetTop(_draggingHost); + + Canvas.SetLeft(_draggingHost, currentX + deltaX); + Canvas.SetTop(_draggingHost, currentY + deltaY); + + _dragStartPoint = currentPoint; + e.Handled = true; + } + + private void OnComponentPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isDragging || _draggingHost is null || _draggingPlacementId is null) + { + _isDragging = false; + return; + } + + // 更新布局中的位置 + var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _draggingPlacementId); + if (placement is not null) + { + placement.X = Canvas.GetLeft(_draggingHost); + placement.Y = Canvas.GetTop(_draggingHost); + } + + UpdateInteractiveRegions(); + SaveLayout(); + + _isDragging = false; + _draggingPlacementId = null; + _draggingHost = null; + + e.Pointer.Capture(null); + e.Handled = true; + } + + // 三指滑动处理 + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + if (!appSnapshot.EnableThreeFingerSwipe) + { + base.OnPointerPressed(e); + return; + } + + if (!TryGetPointerPosition(e, out var pointerPos)) + { + base.OnPointerPressed(e); + return; + } + + var currentPoint = e.GetCurrentPoint(this); + var pointerId = e.Pointer?.Id ?? 0; + var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed; + var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed; + + if (isLeftButtonPressed || isRightButtonPressed) + { + _activePointerIds.Add(pointerId); + } + + var isThreeFinger = _activePointerIds.Count >= 3; + var isRightDrag = isRightButtonPressed; + + if (isThreeFinger || isRightDrag) + { + _isSwipeActive = true; + _isThreeFingerOrRightDragSwipeActive = true; + _isSwipeDirectionLocked = false; + _swipeStartPoint = pointerPos; + _swipeCurrentPoint = pointerPos; + _swipeLastPoint = pointerPos; + _swipeVelocityX = 0; + _swipeLastTimestamp = Stopwatch.GetTimestamp(); + e.Handled = true; + } + else + { + base.OnPointerPressed(e); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (!_isSwipeActive) + { + base.OnPointerMoved(e); + return; + } + + if (!TryGetPointerPosition(e, out var pointerPos)) + { + base.OnPointerMoved(e); + return; + } + + _swipeCurrentPoint = pointerPos; + UpdateSwipeVelocity(pointerPos); + + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; + var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; + + if (!_isSwipeDirectionLocked) + { + const double activationThreshold = 14; + const double horizontalBias = 1.15; + var absDeltaX = Math.Abs(deltaX); + var absDeltaY = Math.Abs(deltaY); + + if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias) + { + CancelSwipeInteraction(e.Pointer); + base.OnPointerMoved(e); + return; + } + + if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias) + { + base.OnPointerMoved(e); + return; + } + + _isSwipeDirectionLocked = true; + if (e.Pointer?.Captured != this) + { + e.Pointer?.Capture(this); + } + } + + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + var pointerId = e.Pointer?.Id ?? 0; + _activePointerIds.Remove(pointerId); + + if (_isSwipeActive) + { + if (EndSwipeInteraction(e.Pointer)) + { + e.Handled = true; + return; + } + } + + base.OnPointerReleased(e); + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + var pointerId = e.Pointer?.Id ?? 0; + _activePointerIds.Remove(pointerId); + + if (_isSwipeActive) + { + EndSwipeInteraction(e.Pointer); + } + + base.OnPointerCaptureLost(e); + } + + private bool TryGetPointerPosition(PointerEventArgs e, out Point point) + { + try + { + point = e.GetPosition(this); + return true; + } + catch + { + point = default; + return false; + } + } + + private void UpdateSwipeVelocity(Point currentPoint) + { + var now = Stopwatch.GetTimestamp(); + var elapsed = Stopwatch.GetElapsedTime(_swipeLastTimestamp, now).TotalSeconds; + + if (elapsed > 0) + { + var dx = currentPoint.X - _swipeLastPoint.X; + _swipeVelocityX = dx / elapsed; + } + + _swipeLastPoint = currentPoint; + _swipeLastTimestamp = now; + } + + private void CancelSwipeInteraction(IPointer? pointer) + { + if (!_isSwipeActive) return; + + if (pointer?.Captured == this) + { + pointer?.Capture(null); + } + + _isSwipeActive = false; + _isSwipeDirectionLocked = false; + _isThreeFingerOrRightDragSwipeActive = false; + _activePointerIds.Clear(); + _swipeVelocityX = 0; + _swipeLastTimestamp = 0; + } + + private bool EndSwipeInteraction(IPointer? pointer) + { + if (!_isSwipeActive) return false; + + var wasDirectionLocked = _isSwipeDirectionLocked; + var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive; + + _isSwipeActive = false; + _isSwipeDirectionLocked = false; + _isThreeFingerOrRightDragSwipeActive = false; + _activePointerIds.Clear(); + + if (pointer?.Captured == this) + { + pointer?.Capture(null); + } + + _swipeLastTimestamp = 0; + + if (!wasDirectionLocked) + { + _swipeVelocityX = 0; + return false; + } + + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; + var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; + var absDeltaX = Math.Abs(deltaX); + var distanceThreshold = Math.Max(48, this.Bounds.Width * 0.14); + var velocityThreshold = Math.Max(860, this.Bounds.Width * 1.08); + var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > Math.Abs(deltaY) * 1.05; + var hasVelocityIntent = Math.Abs(_swipeVelocityX) >= velocityThreshold; + + // 向左滑动回到第一页 + if (wasThreeFingerOrRightDrag && deltaX < 0 && (hasDistanceIntent || hasVelocityIntent)) + { + RestoreMainWindowRequested?.Invoke(this, EventArgs.Empty); + _swipeVelocityX = 0; + return true; + } + + _swipeVelocityX = 0; + return hasDistanceIntent || hasVelocityIntent; + } +}