mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fead.Hub组件支持双击打开图片,支持三指翻页退出应用
This commit is contained in:
@@ -67,6 +67,7 @@ public partial class App : Application
|
|||||||
private NativeMenuItem? _trayExitMenuItem;
|
private NativeMenuItem? _trayExitMenuItem;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
|
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
@@ -218,12 +219,37 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
_ = e;
|
_ = e;
|
||||||
if (_mainWindow is null)
|
|
||||||
|
// 仅在 Windows 上支持融合桌面功能
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||||
return;
|
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()
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
@@ -481,9 +507,9 @@ public partial class App : Application
|
|||||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
|
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)
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
@@ -492,19 +518,18 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 先隐藏透明覆盖层窗口
|
||||||
|
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||||
|
{
|
||||||
|
_transparentOverlayWindow.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||||
mainWindow.ShowInTaskbar = true;
|
mainWindow.ShowInTaskbar = true;
|
||||||
|
|
||||||
if (!mainWindow.IsVisible)
|
if (!mainWindow.IsVisible)
|
||||||
{
|
{
|
||||||
mainWindow.Show();
|
mainWindow.Show();
|
||||||
if (mainWindow._isFirstLaunchAfterOpen)
|
|
||||||
{
|
|
||||||
mainWindow._isFirstLaunchAfterOpen = false;
|
|
||||||
mainWindow.ForceDesktopPageToFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
await mainWindow.SlideInAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainWindow.WindowState == WindowState.Minimized)
|
if (mainWindow.WindowState == WindowState.Minimized)
|
||||||
@@ -536,6 +561,18 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
}, DispatcherPriority.Send);
|
}, 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)
|
internal void PrepareForShutdown(bool isRestart, string source)
|
||||||
{
|
{
|
||||||
@@ -886,17 +923,25 @@ public partial class App : Application
|
|||||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void HideMainWindowToTray(MainWindow mainWindow, string source)
|
internal void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await mainWindow.SlideOutAsync();
|
|
||||||
mainWindow.ShowInTaskbar = false;
|
mainWindow.ShowInTaskbar = false;
|
||||||
mainWindow.Hide();
|
mainWindow.Hide();
|
||||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
|
// 检查三指滑动功能是否启用
|
||||||
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
if (appSnapshot.EnableThreeFingerSwipe)
|
||||||
|
{
|
||||||
|
// 显示透明覆盖层窗口
|
||||||
|
EnsureTransparentOverlayWindow();
|
||||||
|
_transparentOverlayWindow?.Show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"tray.tooltip": "LanMountainDesktop",
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
"tray.menu.show_desktop": "Open Desktop",
|
"tray.menu.show_desktop": "Open Desktop",
|
||||||
"tray.menu.settings": "Settings",
|
"tray.menu.settings": "Settings",
|
||||||
"tray.menu.component_library": "Component Library",
|
"tray.menu.component_library": "Fused Desktop Settings",
|
||||||
"tray.menu.restart": "Restart App",
|
"tray.menu.restart": "Restart App",
|
||||||
"tray.menu.exit": "Exit App",
|
"tray.menu.exit": "Exit App",
|
||||||
"button.back_to_windows": "Back to Windows",
|
"button.back_to_windows": "Back to Windows",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"tray.tooltip": "LanMountainDesktop",
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
"tray.menu.show_desktop": "打开桌面",
|
"tray.menu.show_desktop": "打开桌面",
|
||||||
"tray.menu.settings": "设置",
|
"tray.menu.settings": "设置",
|
||||||
"tray.menu.component_library": "独立组件库",
|
"tray.menu.component_library": "融合桌面设置",
|
||||||
"tray.menu.restart": "重启应用",
|
"tray.menu.restart": "重启应用",
|
||||||
"tray.menu.exit": "退出应用",
|
"tray.menu.exit": "退出应用",
|
||||||
"button.back_to_windows": "回到Windows",
|
"button.back_to_windows": "回到Windows",
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||||
|
|
||||||
|
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||||
|
|
||||||
public List<string> DisabledPluginIds { get; set; } = [];
|
public List<string> DisabledPluginIds { get; set; } = [];
|
||||||
|
|
||||||
#region Study Settings
|
#region Study Settings
|
||||||
|
|||||||
96
LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
Normal file
96
LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面组件放置快照 - 用于在系统桌面(负一屏)上放置组件
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FusedDesktopComponentPlacementSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 放置实例ID(唯一标识)
|
||||||
|
/// </summary>
|
||||||
|
public string PlacementId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 组件类型ID
|
||||||
|
/// </summary>
|
||||||
|
public string ComponentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X 坐标(像素,相对于屏幕左上角)
|
||||||
|
/// </summary>
|
||||||
|
public double X { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Y 坐标(像素,相对于屏幕左上角)
|
||||||
|
/// </summary>
|
||||||
|
public double Y { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 宽度(像素)
|
||||||
|
/// </summary>
|
||||||
|
public double Width { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高度(像素)
|
||||||
|
/// </summary>
|
||||||
|
public double Height { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Z-Index(用于控制组件层叠顺序)
|
||||||
|
/// </summary>
|
||||||
|
public int ZIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否锁定位置(锁定后不可拖动)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsLocked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建深拷贝
|
||||||
|
/// </summary>
|
||||||
|
public FusedDesktopComponentPlacementSnapshot Clone()
|
||||||
|
{
|
||||||
|
return new FusedDesktopComponentPlacementSnapshot
|
||||||
|
{
|
||||||
|
PlacementId = PlacementId,
|
||||||
|
ComponentId = ComponentId,
|
||||||
|
X = X,
|
||||||
|
Y = Y,
|
||||||
|
Width = Width,
|
||||||
|
Height = Height,
|
||||||
|
ZIndex = ZIndex,
|
||||||
|
IsLocked = IsLocked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面布局快照 - 包含所有在系统桌面上显示的组件
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FusedDesktopLayoutSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用融合桌面功能
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 组件放置列表
|
||||||
|
/// </summary>
|
||||||
|
public List<FusedDesktopComponentPlacementSnapshot> ComponentPlacements { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建深拷贝
|
||||||
|
/// </summary>
|
||||||
|
public FusedDesktopLayoutSnapshot Clone()
|
||||||
|
{
|
||||||
|
return new FusedDesktopLayoutSnapshot
|
||||||
|
{
|
||||||
|
IsEnabled = IsEnabled,
|
||||||
|
ComponentPlacements = [.. ComponentPlacements.ConvertAll(p => p.Clone())]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
173
LanMountainDesktop/Services/FusedDesktopLayoutService.cs
Normal file
173
LanMountainDesktop/Services/FusedDesktopLayoutService.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面布局存储服务接口
|
||||||
|
/// </summary>
|
||||||
|
public interface IFusedDesktopLayoutService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 加载融合桌面布局
|
||||||
|
/// </summary>
|
||||||
|
FusedDesktopLayoutSnapshot Load();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存融合桌面布局
|
||||||
|
/// </summary>
|
||||||
|
void Save(FusedDesktopLayoutSnapshot snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加组件放置
|
||||||
|
/// </summary>
|
||||||
|
void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新组件放置
|
||||||
|
/// </summary>
|
||||||
|
void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 移除组件放置
|
||||||
|
/// </summary>
|
||||||
|
void RemoveComponentPlacement(string placementId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除所有组件放置
|
||||||
|
/// </summary>
|
||||||
|
void ClearAllPlacements();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面布局存储服务实现
|
||||||
|
/// </summary>
|
||||||
|
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<FusedDesktopLayoutSnapshot>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面布局服务提供者
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -320,9 +320,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果找不到报告,尝试重新从数据库加载
|
||||||
if (!TryFindSessionReportLocked(sessionId, out var report))
|
if (!TryFindSessionReportLocked(sessionId, out var report))
|
||||||
{
|
{
|
||||||
return false;
|
// 重新加载历史数据
|
||||||
|
RestoreSessionHistoryFromDatabaseLocked();
|
||||||
|
|
||||||
|
// 再次尝试查找
|
||||||
|
if (!TryFindSessionReportLocked(sessionId, out report))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectedSessionReportId = report.SessionId;
|
_selectedSessionReportId = report.SessionId;
|
||||||
@@ -356,9 +364,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
{
|
{
|
||||||
ThrowIfDisposedLocked();
|
ThrowIfDisposedLocked();
|
||||||
var index = FindSessionReportIndexLocked(sessionId);
|
var index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
|
||||||
|
// 如果找不到报告,尝试重新从数据库加载
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return false;
|
RestoreSessionHistoryFromDatabaseLocked();
|
||||||
|
index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
||||||
@@ -389,9 +405,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
{
|
{
|
||||||
ThrowIfDisposedLocked();
|
ThrowIfDisposedLocked();
|
||||||
var index = FindSessionReportIndexLocked(sessionId);
|
var index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
|
||||||
|
// 如果找不到报告,尝试重新从数据库加载
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return false;
|
RestoreSessionHistoryFromDatabaseLocked();
|
||||||
|
index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var removed = _sessionHistory[index];
|
var removed = _sessionHistory[index];
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ public sealed class StudyDataStore
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly AppDatabaseService _databaseService;
|
private readonly AppDatabaseService _databaseService;
|
||||||
|
private readonly Action<string>? _logger;
|
||||||
|
|
||||||
public StudyDataStore(AppDatabaseService? databaseService = null)
|
public StudyDataStore(AppDatabaseService? databaseService = null, Action<string>? logger = null)
|
||||||
{
|
{
|
||||||
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Log(string message)
|
||||||
|
{
|
||||||
|
_logger?.Invoke($"[StudyDataStore] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
||||||
@@ -61,17 +68,25 @@ public sealed class StudyDataStore
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
try
|
||||||
if (report is not null)
|
|
||||||
{
|
{
|
||||||
reports.Add(report);
|
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||||
|
if (report is not null)
|
||||||
|
{
|
||||||
|
reports.Add(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
Log($"Failed to deserialize session report: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return reports;
|
return reports;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Log($"Failed to load session reports: {ex.Message}");
|
||||||
return Array.Empty<StudySessionReport>();
|
return Array.Empty<StudySessionReport>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,20 +114,28 @@ public sealed class StudyDataStore
|
|||||||
var json = command.ExecuteScalar() as string;
|
var json = command.ExecuteScalar() as string;
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
{
|
{
|
||||||
|
Log($"Session report not found for id: {sessionId}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||||
if (parsed is null)
|
if (parsed is null)
|
||||||
{
|
{
|
||||||
|
Log($"Failed to deserialize session report for id: {sessionId}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
report = parsed;
|
report = parsed;
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,9 +161,9 @@ public sealed class StudyDataStore
|
|||||||
|
|
||||||
transaction.Commit();
|
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
|
? null
|
||||||
: value.Trim();
|
: value.Trim();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Log($"Failed to get selected session report id: {ex.Message}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,9 +216,9 @@ public sealed class StudyDataStore
|
|||||||
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
||||||
upsertCommand.ExecuteNonQuery();
|
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();
|
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;
|
return entries;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Log($"Failed to load noise slice timeline: {ex.Message}");
|
||||||
return Array.Empty<NoiseSliceTimelineEntry>();
|
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,9 +414,9 @@ public sealed class StudyDataStore
|
|||||||
|
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Keep runtime resilient when persistence is unavailable.
|
Log($"Failed to clear noise slice timeline: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
350
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
350
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口置底服务接口
|
||||||
|
/// </summary>
|
||||||
|
public interface IWindowBottomMostService
|
||||||
|
{
|
||||||
|
void SetupBottomMost(Window window);
|
||||||
|
void SendToBottom(Window window);
|
||||||
|
bool IsBottomMostSupported { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
|
||||||
|
/// </summary>
|
||||||
|
public interface IRegionPassthroughService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 设置窗口的可交互区域
|
||||||
|
/// </summary>
|
||||||
|
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除所有可交互区域
|
||||||
|
/// </summary>
|
||||||
|
void ClearInteractiveRegions(Window window);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前平台是否支持区域级穿透
|
||||||
|
/// </summary>
|
||||||
|
bool IsRegionPassthroughSupported { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口置底服务工厂
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 区域级穿透服务工厂
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 平台窗口置底服务
|
||||||
|
/// </summary>
|
||||||
|
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<IntPtr, bool> _bottomMostWindows = new();
|
||||||
|
private static readonly Dictionary<IntPtr, IntPtr> _originalWndProcs = new();
|
||||||
|
private static readonly Dictionary<IntPtr, List<Rect>> _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<WndProcDelegate>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用)
|
||||||
|
/// </summary>
|
||||||
|
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService
|
||||||
|
{
|
||||||
|
public bool IsRegionPassthroughSupported => true;
|
||||||
|
|
||||||
|
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions)
|
||||||
|
{
|
||||||
|
var handle = GetWindowHandle(window);
|
||||||
|
if (handle == IntPtr.Zero) return;
|
||||||
|
|
||||||
|
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List<Rect>(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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 空实现
|
||||||
|
/// </summary>
|
||||||
|
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<Rect> interactiveRegions) { }
|
||||||
|
public void ClearInteractiveRegions(Window window) { }
|
||||||
|
}
|
||||||
@@ -164,14 +164,18 @@ public sealed class TimeZoneOption
|
|||||||
public string Label { get; }
|
public string Label { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly TimeZoneService _timeZoneService;
|
private readonly TimeZoneService _timeZoneService;
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly string _startupRenderMode;
|
private readonly string _startupRenderMode;
|
||||||
private string _languageCode;
|
private string _languageCode;
|
||||||
private bool _isInitializing;
|
private bool _isInitializing;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _enableThreeFingerSwipe;
|
||||||
|
|
||||||
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
@@ -200,9 +204,65 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||||
?? RenderModes[0];
|
?? RenderModes[0];
|
||||||
|
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
|
||||||
_isInitializing = false;
|
_isInitializing = false;
|
||||||
|
|
||||||
RefreshPreview();
|
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<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnEnableThreeFingerSwipeChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
appSnapshot.EnableThreeFingerSwipe = value;
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
appSnapshot,
|
||||||
|
changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public event Action? RestartRequested;
|
public event Action? RestartRequested;
|
||||||
@@ -2330,25 +2390,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
private bool _isInitializing;
|
private bool _isInitializing;
|
||||||
private readonly IStudyAnalyticsService _studyAnalyticsService;
|
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)
|
public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade, IStudyAnalyticsService? studyAnalyticsService = null)
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
_studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault();
|
_studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault();
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
|
|
||||||
// 初始化防抖计时器
|
|
||||||
InitializeDebounceTimers();
|
|
||||||
|
|
||||||
RefreshLocalizedText();
|
RefreshLocalizedText();
|
||||||
|
|
||||||
_isInitializing = true;
|
_isInitializing = true;
|
||||||
@@ -2356,21 +2403,6 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
_isInitializing = false;
|
_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
|
#region Properties - Master Switch
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -2455,7 +2487,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateThresholdText();
|
UpdateThresholdText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceNoiseSettingsSave();
|
SaveNoiseSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2471,7 +2503,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateSamplingRateText();
|
UpdateSamplingRateText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceNoiseSettingsSave();
|
SaveNoiseSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2485,18 +2517,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
|
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
|
try
|
||||||
{
|
{
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
@@ -2601,7 +2623,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateFocusDurationText();
|
UpdateFocusDurationText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2617,7 +2639,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateBreakDurationText();
|
UpdateBreakDurationText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2633,7 +2655,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateLongBreakDurationText();
|
UpdateLongBreakDurationText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2649,7 +2671,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateSessionsBeforeLongBreakText();
|
UpdateSessionsBeforeLongBreakText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2657,7 +2679,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2665,7 +2687,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceTimerSettingsSave();
|
SaveTimerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2693,18 +2715,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} {unit}";
|
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
|
try
|
||||||
{
|
{
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
@@ -2762,7 +2774,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceAlertSettingsSave();
|
SaveAlertSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2777,22 +2789,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (!_isInitializing)
|
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
|
try
|
||||||
{
|
{
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
@@ -2855,7 +2857,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceDisplaySettingsSave();
|
SaveDisplaySettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2871,7 +2873,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateBaselineDbText();
|
UpdateBaselineDbText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceDisplaySettingsSave();
|
SaveDisplaySettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2887,7 +2889,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
UpdateAvgWindowSecText();
|
UpdateAvgWindowSecText();
|
||||||
if (!_isInitializing)
|
if (!_isInitializing)
|
||||||
{
|
{
|
||||||
DebounceDisplaySettingsSave();
|
SaveDisplaySettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2907,18 +2909,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
|||||||
AvgWindowSecValueText = $"{AvgWindowSec} {unit}";
|
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
|
try
|
||||||
{
|
{
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
|||||||
@@ -187,12 +187,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null)
|
|
||||||
{
|
|
||||||
ApplySessionReportMode(snapshot, panelColor);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,15 +169,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
|||||||
private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
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 isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||||
|
|
||||||
var success = isRunning
|
var success = isRunning
|
||||||
@@ -221,17 +212,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
|||||||
_transientMessage = null;
|
_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;
|
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||||
if (isRunning)
|
if (isRunning)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -386,24 +386,39 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
|||||||
{
|
{
|
||||||
CloseDialog();
|
CloseDialog();
|
||||||
|
|
||||||
_loadingSessionId = sessionId;
|
// 直接从服务获取报告数据
|
||||||
SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
|
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||||
if (_currentSnapshot is not null)
|
var entry = FindHistoryEntry(snapshot.SessionHistory, sessionId);
|
||||||
{
|
|
||||||
RenderSnapshot(_currentSnapshot);
|
if (entry is null)
|
||||||
}
|
|
||||||
|
|
||||||
if (_studyAnalyticsService.SelectSessionReport(sessionId))
|
|
||||||
{
|
{
|
||||||
|
SetTransientStatus(L("study.session_history.select_failed", "Unable to find session"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadingSessionId = null;
|
// 加载完整的报告数据
|
||||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
|
if (!_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||||
if (_currentSnapshot is not null)
|
|
||||||
{
|
{
|
||||||
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)
|
private void ShowRenameDialog(string sessionId, string label)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -63,6 +64,8 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
|||||||
private double _dragOffset;
|
private double _dragOffset;
|
||||||
private int _lastSwipeDirection = 0;
|
private int _lastSwipeDirection = 0;
|
||||||
private bool _isInErrorState;
|
private bool _isInErrorState;
|
||||||
|
private DateTime _lastClickTime = DateTime.MinValue;
|
||||||
|
private const int DoubleClickThresholdMs = 300;
|
||||||
|
|
||||||
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
|
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
|
||||||
{
|
{
|
||||||
@@ -679,6 +682,19 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
|||||||
return;
|
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)
|
if (_images.Count <= 1)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -689,6 +705,81 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
|||||||
_dragOffset = 0;
|
_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<string?> 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)
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_isDragging || _images.Count <= 1)
|
if (!_isDragging || _images.Count <= 1)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||||
|
Width="400" Height="500">
|
||||||
|
<!--
|
||||||
|
融合桌面组件库 - 专门用于添加组件到系统桌面(负一屏)
|
||||||
|
注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||||
|
Padding="16,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="融合桌面组件"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="16" />
|
||||||
|
<TextBlock Text="选择组件添加到系统桌面"
|
||||||
|
Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,4,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 组件列表 -->
|
||||||
|
<ScrollViewer Grid.Row="1"
|
||||||
|
Padding="12">
|
||||||
|
<WrapPanel x:Name="ComponentPanel" Orientation="Horizontal" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面组件库控件 - 专门用于添加组件到系统桌面(负一屏)
|
||||||
|
/// </summary>
|
||||||
|
public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 添加组件到融合桌面事件
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<string>? AddComponentRequested;
|
||||||
|
|
||||||
|
public FusedDesktopComponentLibraryControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
LoadComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载可用组件列表
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加组件按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button button && button.Tag is string componentId)
|
||||||
|
{
|
||||||
|
AddComponentRequested?.Invoke(this, componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:LanMountainDesktop.Views"
|
||||||
|
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||||
|
Width="420" Height="560"
|
||||||
|
MinWidth="380" MinHeight="400"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
CanResize="True"
|
||||||
|
Title="融合桌面组件">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="融合桌面设置"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="18" />
|
||||||
|
<TextBlock Text="选择组件添加到系统桌面(负一屏)"
|
||||||
|
Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,4,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
CornerRadius="20"
|
||||||
|
Padding="8"
|
||||||
|
Click="OnCloseClick">
|
||||||
|
<TextBlock Text="✕"
|
||||||
|
FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 组件库控件 -->
|
||||||
|
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||||
|
Grid.Row="1" />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
|
||||||
|
///
|
||||||
|
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||||
|
/// </summary>
|
||||||
|
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||||
|
{
|
||||||
|
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
private TransparentOverlayWindow? _overlayWindow;
|
||||||
|
|
||||||
|
public FusedDesktopComponentLibraryWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置透明覆盖层窗口引用
|
||||||
|
/// </summary>
|
||||||
|
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
|
||||||
|
{
|
||||||
|
_overlayWindow = overlayWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加组件请求处理
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using Avalonia.Threading;
|
|||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
|
|
||||||
@@ -73,6 +74,10 @@ public partial class MainWindow
|
|||||||
private int? _desktopPageContextSettlingTargetIndex;
|
private int? _desktopPageContextSettlingTargetIndex;
|
||||||
private int _desktopPageContextSettleRevision;
|
private int _desktopPageContextSettleRevision;
|
||||||
|
|
||||||
|
// 三指滑动/右键拖动相关
|
||||||
|
private bool _isThreeFingerOrRightDragSwipeActive;
|
||||||
|
private readonly HashSet<int> _activePointerIds = [];
|
||||||
|
|
||||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||||
|
|
||||||
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
||||||
@@ -375,18 +380,6 @@ public partial class MainWindow
|
|||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ForceDesktopPageToFirst()
|
|
||||||
{
|
|
||||||
if (_currentDesktopSurfaceIndex == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_currentDesktopSurfaceIndex = 0;
|
|
||||||
ApplyDesktopSurfaceOffset();
|
|
||||||
SchedulePersistSettings(delayMs: 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
|
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
if (_desktopPagesHostTransform is null)
|
if (_desktopPagesHostTransform is null)
|
||||||
@@ -527,6 +520,49 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查三指滑动功能是否启用
|
||||||
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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))
|
if (IsInteractivePointerSource(e.Source))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -537,7 +573,7 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
if (!isLeftButtonPressed)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -788,6 +824,10 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
{
|
{
|
||||||
|
// 清理活跃指针
|
||||||
|
var pointerId = e.Pointer?.Id ?? 0;
|
||||||
|
_activePointerIds.Remove(pointerId);
|
||||||
|
|
||||||
if (EndDesktopSwipeInteraction(e.Pointer))
|
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -796,6 +836,10 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||||
{
|
{
|
||||||
|
// 清理活跃指针
|
||||||
|
var pointerId = e.Pointer?.Id ?? 0;
|
||||||
|
_activePointerIds.Remove(pointerId);
|
||||||
|
|
||||||
EndDesktopSwipeInteraction(e.Pointer);
|
EndDesktopSwipeInteraction(e.Pointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,6 +858,8 @@ public partial class MainWindow
|
|||||||
|
|
||||||
_isDesktopSwipeActive = false;
|
_isDesktopSwipeActive = false;
|
||||||
_isDesktopSwipeDirectionLocked = false;
|
_isDesktopSwipeDirectionLocked = false;
|
||||||
|
_isThreeFingerOrRightDragSwipeActive = false;
|
||||||
|
_activePointerIds.Clear();
|
||||||
_desktopSwipeVelocityX = 0;
|
_desktopSwipeVelocityX = 0;
|
||||||
_desktopSwipeLastTimestamp = 0;
|
_desktopSwipeLastTimestamp = 0;
|
||||||
if (wasDirectionLocked)
|
if (wasDirectionLocked)
|
||||||
@@ -831,8 +877,12 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||||
|
var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive;
|
||||||
_isDesktopSwipeActive = false;
|
_isDesktopSwipeActive = false;
|
||||||
_isDesktopSwipeDirectionLocked = false;
|
_isDesktopSwipeDirectionLocked = false;
|
||||||
|
_isThreeFingerOrRightDragSwipeActive = false;
|
||||||
|
_activePointerIds.Clear();
|
||||||
|
|
||||||
if (pointer?.Captured == DesktopPagesViewport)
|
if (pointer?.Captured == DesktopPagesViewport)
|
||||||
{
|
{
|
||||||
pointer.Capture(null);
|
pointer.Capture(null);
|
||||||
@@ -861,6 +911,23 @@ public partial class MainWindow
|
|||||||
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
||||||
var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold;
|
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))
|
if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent))
|
||||||
{
|
{
|
||||||
projectedTargetIndex = Math.Clamp(
|
projectedTargetIndex = Math.Clamp(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ using Avalonia.Styling;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using FluentAvalonia.Styling;
|
using FluentAvalonia.Styling;
|
||||||
using LanMountainDesktop.Behaviors;
|
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
@@ -109,9 +108,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private bool _suppressWeatherLocationEvents;
|
private bool _suppressWeatherLocationEvents;
|
||||||
private bool _suppressSettingsPersistence;
|
private bool _suppressSettingsPersistence;
|
||||||
private bool _isComponentLibraryOpen;
|
private bool _isComponentLibraryOpen;
|
||||||
private bool _isSlideAnimating;
|
|
||||||
private int _slideAnimationGuard;
|
|
||||||
internal bool _isFirstLaunchAfterOpen = true;
|
|
||||||
private Border? _selectedDesktopComponentHost;
|
private Border? _selectedDesktopComponentHost;
|
||||||
private bool _reopenSettingsAfterComponentLibraryClose;
|
private bool _reopenSettingsAfterComponentLibraryClose;
|
||||||
private TranslateTransform? _settingsContentPanelTransform;
|
private TranslateTransform? _settingsContentPanelTransform;
|
||||||
@@ -789,60 +785,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
_ = cellSize;
|
_ = cellSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnMinimizeClick(object? sender, RoutedEventArgs e)
|
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_isSlideAnimating)
|
WindowState = WindowState.Minimized;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
Text="{Binding BasicHeader}"
|
Text="{Binding BasicHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|
||||||
|
<ui:SettingsExpander Header="启用三指滑动"
|
||||||
|
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面">
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
<ui:SettingsExpander Header="{Binding LanguageHeader}">
|
<ui:SettingsExpander Header="{Binding LanguageHeader}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
<fi:SymbolIconSource Symbol="Settings" />
|
<fi:SymbolIconSource Symbol="Settings" />
|
||||||
|
|||||||
148
LanMountainDesktop/Views/StudySessionReportWindow.axaml
Normal file
148
LanMountainDesktop/Views/StudySessionReportWindow.axaml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
x:Class="LanMountainDesktop.Views.StudySessionReportWindow"
|
||||||
|
x:CompileBindings="False"
|
||||||
|
SystemDecorations="None"
|
||||||
|
Background="Transparent"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Topmost="True"
|
||||||
|
CanResize="False"
|
||||||
|
Width="800"
|
||||||
|
Height="600"
|
||||||
|
TransparencyLevelHint="Transparent"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaChromeHints="NoChrome"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
Background="#E8EAED"
|
||||||
|
CornerRadius="20"
|
||||||
|
Padding="0">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="#F5F5F5"
|
||||||
|
CornerRadius="20,20,0,0"
|
||||||
|
Padding="24,16"
|
||||||
|
BorderBrush="#DDDDDD"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<fi:SymbolIcon Grid.Column="0"
|
||||||
|
Symbol="Hourglass"
|
||||||
|
FontSize="24"
|
||||||
|
Foreground="#333333"
|
||||||
|
Margin="0,0,12,0" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="4">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#333333" />
|
||||||
|
<TextBlock x:Name="SubtitleTextBlock"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="#666666" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
x:Name="CloseButton"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
CornerRadius="16"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<fi:SymbolIcon Symbol="Dismiss"
|
||||||
|
FontSize="16"
|
||||||
|
Foreground="#666666" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<ScrollViewer Grid.Row="1"
|
||||||
|
Background="#FAFAFA"
|
||||||
|
Padding="24,20"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Spacing="20">
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<Grid ColumnDefinitions="*,*,*,*" ColumnSpacing="12">
|
||||||
|
<Border x:Name="AvgScoreCard"
|
||||||
|
Background="White"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="16"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="平均分数" FontSize="12" Foreground="#888888" />
|
||||||
|
<TextBlock x:Name="AvgScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Column="1"
|
||||||
|
Background="White"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="16"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="最高分数" FontSize="12" Foreground="#888888" />
|
||||||
|
<TextBlock x:Name="MaxScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Column="2"
|
||||||
|
Background="White"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="16"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="最低分数" FontSize="12" Foreground="#888888" />
|
||||||
|
<TextBlock x:Name="MinScoreTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Column="3"
|
||||||
|
Background="White"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="16"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="打断次数" FontSize="12" Foreground="#888888" />
|
||||||
|
<TextBlock x:Name="InterruptCountTextBlock" FontSize="24" FontWeight="Bold" Foreground="#333333" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Detail Data Table -->
|
||||||
|
<Border Background="White"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="16"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="详细数据" FontSize="16" FontWeight="SemiBold" Foreground="#333333" />
|
||||||
|
<DataGrid x:Name="DetailDataGrid"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
IsReadOnly="True"
|
||||||
|
GridLinesVisibility="All"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="#E0E0E0"
|
||||||
|
CornerRadius="8"
|
||||||
|
MaxHeight="400">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="时间段" Width="*" Binding="{Binding TimeRange, Mode=OneWay}" />
|
||||||
|
<DataGridTextColumn Header="平均分贝" Width="Auto" Binding="{Binding AvgDb, Mode=OneWay, StringFormat={}{0:F1}}" />
|
||||||
|
<DataGridTextColumn Header="分数" Width="Auto" Binding="{Binding Score, Mode=OneWay, StringFormat={}{0:F1}}" />
|
||||||
|
<DataGridTextColumn Header="打断次数" Width="Auto" Binding="{Binding SegmentCount, Mode=OneWay}" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
95
LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs
Normal file
95
LanMountainDesktop/Views/StudySessionReportWindow.axaml.cs
Normal file
@@ -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<DetailDataRow>();
|
||||||
|
|
||||||
|
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);
|
||||||
24
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
24
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
||||||
|
WindowState="FullScreen"
|
||||||
|
SystemDecorations="None"
|
||||||
|
CanResize="False"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaChromeHints="NoChrome"
|
||||||
|
Background="Transparent"
|
||||||
|
Title="LanMountainDesktop Fused Desktop">
|
||||||
|
<!--
|
||||||
|
融合桌面(负一屏)- 在系统桌面上显示组件
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 窗口置底(在桌面图标层显示)
|
||||||
|
- 区域级穿透(组件区域可交互,其他区域穿透)
|
||||||
|
- 组件可自由拖拽摆放
|
||||||
|
- 三指/右键左滑回到阗山桌面第一页
|
||||||
|
-->
|
||||||
|
<Canvas x:Name="ComponentCanvas">
|
||||||
|
<!-- 组件将动态添加到这里 -->
|
||||||
|
</Canvas>
|
||||||
|
</Window>
|
||||||
467
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
467
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上
|
||||||
|
/// 支持在系统桌面上自由摆放组件
|
||||||
|
/// </summary>
|
||||||
|
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<int> _activePointerIds = [];
|
||||||
|
|
||||||
|
// 组件管理
|
||||||
|
private readonly Dictionary<string, Border> _componentHosts = [];
|
||||||
|
private readonly List<Rect> _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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新可交互区域
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存布局
|
||||||
|
/// </summary>
|
||||||
|
private void SaveLayout()
|
||||||
|
{
|
||||||
|
_layoutService.Save(_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加组件(供外部调用)
|
||||||
|
/// </summary>
|
||||||
|
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})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 移除组件
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渲染组件(从外部传入控件)
|
||||||
|
/// </summary>
|
||||||
|
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<AppSettingsSnapshot>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user