mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021c7ff245 | ||
|
|
675096b6c4 | ||
|
|
1c3cc76f21 | ||
|
|
44b87ba12e | ||
|
|
35976c3f3d | ||
|
|
88bd92e40a | ||
|
|
ff014717fa |
@@ -67,6 +67,7 @@ public partial class App : Application
|
||||
private NativeMenuItem? _trayExitMenuItem;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
@@ -148,6 +149,11 @@ public partial class App : Application
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
{
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().Initialize();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
@@ -218,12 +224,59 @@ public partial class App : Application
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_mainWindow is null)
|
||||
|
||||
// 仅在 Windows 上支持融合桌面功能
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
_detachedComponentLibraryWindowService.Open(_mainWindow);
|
||||
// 切换进入编辑模式,隐藏常态零散的小部件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||
|
||||
// 确保透明覆盖层窗口存在并显示
|
||||
EnsureTransparentOverlayWindow();
|
||||
|
||||
// 打开融合桌面组件库窗口
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show)
|
||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Show();
|
||||
}
|
||||
|
||||
var window = new FusedDesktopComponentLibraryWindow();
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
||||
}
|
||||
|
||||
// 当组件库关闭时,退出编辑态
|
||||
window.Closed += (s, ev) =>
|
||||
{
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
// 触发画布保存,并隐藏画布
|
||||
_transparentOverlayWindow.SaveLayoutAndHide();
|
||||
}
|
||||
|
||||
// 让管理器根据已存储的最新快照重建生成所有实体小组件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
};
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -492,6 +545,12 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
// 先隐藏透明覆盖层窗口
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
@@ -529,6 +588,18 @@ public partial class App : Application
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private void EnsureTransparentOverlayWindow()
|
||||
{
|
||||
if (_transparentOverlayWindow is null)
|
||||
{
|
||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal void PrepareForShutdown(bool isRestart, string source)
|
||||
{
|
||||
@@ -879,7 +950,7 @@ public partial class App : Application
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
||||
}
|
||||
|
||||
private void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||
internal void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -889,6 +960,15 @@ public partial class App : Application
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"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)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.show_desktop": "Open Desktop",
|
||||
"tray.menu.settings": "Settings",
|
||||
"tray.menu.component_library": "Component Library",
|
||||
"tray.menu.component_library": "Fused Desktop Settings",
|
||||
"tray.menu.restart": "Restart App",
|
||||
"tray.menu.exit": "Exit App",
|
||||
"button.back_to_windows": "Back to Windows",
|
||||
@@ -388,6 +388,41 @@
|
||||
"settings.status_bar.clock_format_label": "Clock format",
|
||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||
"settings.status_bar.clock_position_label": "Clock position",
|
||||
"settings.status_bar.clock_position.left": "Left",
|
||||
"settings.status_bar.clock_position.center": "Center",
|
||||
"settings.status_bar.clock_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_header": "Text Capsule",
|
||||
"settings.status_bar.text_capsule_description": "Display custom text on the status bar with Markdown support.",
|
||||
"settings.status_bar.text_capsule_position_label": "Text capsule position",
|
||||
"settings.status_bar.text_capsule_position.left": "Left",
|
||||
"settings.status_bar.text_capsule_position.center": "Center",
|
||||
"settings.status_bar.text_capsule_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_content_label": "Text content (Markdown supported)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.network_speed_header": "Network Speed",
|
||||
"settings.status_bar.network_speed_description": "Display real-time network upload and download speed on the status bar.",
|
||||
"settings.status_bar.network_speed_position_label": "Network speed position",
|
||||
"settings.status_bar.network_speed_position.left": "Left",
|
||||
"settings.status_bar.network_speed_position.center": "Center",
|
||||
"settings.status_bar.network_speed_position.right": "Right",
|
||||
"settings.status_bar.network_speed_mode_label": "Display mode",
|
||||
"settings.status_bar.network_speed_mode.both": "Upload + Download",
|
||||
"settings.status_bar.network_speed_mode.upload": "Upload only",
|
||||
"settings.status_bar.network_speed_mode.download": "Download only",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.show_network_type_icon_label": "Show network type icon",
|
||||
"settings.status_bar.shadow_header": "Status Bar Shadow",
|
||||
"settings.status_bar.shadow_desc": "Add shadow effect to the status bar for better visibility of transparent components.",
|
||||
"settings.status_bar.shadow_enabled_label": "Enable shadow",
|
||||
"settings.status_bar.shadow_color_label": "Shadow color",
|
||||
"settings.status_bar.shadow_opacity_label": "Shadow opacity",
|
||||
"settings.status_bar.theme_header": "Status Bar Theme",
|
||||
"settings.status_bar.theme_desc": "Set the theme mode for the status bar independently.",
|
||||
"settings.status_bar.theme_mode_label": "Theme mode",
|
||||
"settings.status_bar.theme_mode.follow_global": "Follow Global",
|
||||
"settings.status_bar.theme_mode.dark": "Dark",
|
||||
"settings.status_bar.theme_mode.light": "Light",
|
||||
"settings.components.title": "Components",
|
||||
"settings.components.description": "Adjust component layout and corner design.",
|
||||
"settings.components.grid_header": "Grid Settings",
|
||||
|
||||
@@ -331,6 +331,41 @@
|
||||
"settings.status_bar.clock_format_label": "時計の形式",
|
||||
"settings.status_bar.clock_format.hm": "時:分",
|
||||
"settings.status_bar.clock_format.hms": "時:分:秒",
|
||||
"settings.status_bar.clock_position_label": "時計の位置",
|
||||
"settings.status_bar.clock_position.left": "左",
|
||||
"settings.status_bar.clock_position.center": "中央",
|
||||
"settings.status_bar.clock_position.right": "右",
|
||||
"settings.status_bar.text_capsule_header": "テキストカプセル",
|
||||
"settings.status_bar.text_capsule_description": "ステータスバーにMarkdown形式のカスタムテキストを表示します。",
|
||||
"settings.status_bar.text_capsule_position_label": "テキストカプセルの位置",
|
||||
"settings.status_bar.text_capsule_position.left": "左",
|
||||
"settings.status_bar.text_capsule_position.center": "中央",
|
||||
"settings.status_bar.text_capsule_position.right": "右",
|
||||
"settings.status_bar.text_capsule_content_label": "テキスト内容(Markdown対応)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.network_speed_header": "ネットワーク速度",
|
||||
"settings.status_bar.network_speed_description": "ステータスバーにリアルタイムのネットワーク速度を表示します。",
|
||||
"settings.status_bar.network_speed_position_label": "ネットワーク速度の位置",
|
||||
"settings.status_bar.network_speed_position.left": "左",
|
||||
"settings.status_bar.network_speed_position.center": "中央",
|
||||
"settings.status_bar.network_speed_position.right": "右",
|
||||
"settings.status_bar.network_speed_mode_label": "表示モード",
|
||||
"settings.status_bar.network_speed_mode.both": "アップロード + ダウンロード",
|
||||
"settings.status_bar.network_speed_mode.upload": "アップロードのみ",
|
||||
"settings.status_bar.network_speed_mode.download": "ダウンロードのみ",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "ネットワークタイプアイコンを表示",
|
||||
"settings.status_bar.shadow_header": "ステータスバーの影",
|
||||
"settings.status_bar.shadow_desc": "透明なコンポーネントの視認性を高めるために、ステータスバーに影効果を追加します。",
|
||||
"settings.status_bar.shadow_enabled_label": "影を有効にする",
|
||||
"settings.status_bar.shadow_color_label": "影の色",
|
||||
"settings.status_bar.shadow_opacity_label": "影の不透明度",
|
||||
"settings.status_bar.theme_header": "ステータスバーのテーマ",
|
||||
"settings.status_bar.theme_desc": "ステータスバーのテーマモードを独立して設定します。",
|
||||
"settings.status_bar.theme_mode_label": "テーマモード",
|
||||
"settings.status_bar.theme_mode.follow_global": "グローバルに従う",
|
||||
"settings.status_bar.theme_mode.dark": "ダーク",
|
||||
"settings.status_bar.theme_mode.light": "ライト",
|
||||
"settings.components.title": "コンポーネント",
|
||||
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
|
||||
"settings.components.grid_header": "グリッド設定",
|
||||
|
||||
@@ -377,6 +377,41 @@
|
||||
"settings.status_bar.clock_format_label": "시계 형식",
|
||||
"settings.status_bar.clock_format.hm": "시:분",
|
||||
"settings.status_bar.clock_format.hms": "시:분:초",
|
||||
"settings.status_bar.clock_position_label": "시계 위치",
|
||||
"settings.status_bar.clock_position.left": "왼쪽",
|
||||
"settings.status_bar.clock_position.center": "가욍데",
|
||||
"settings.status_bar.clock_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_header": "텍스트 캡슐",
|
||||
"settings.status_bar.text_capsule_description": "Markdown 형식의 사용자 정의 텍스트를 상태 표시줄에 표시합니다.",
|
||||
"settings.status_bar.text_capsule_position_label": "텍스트 캡슐 위치",
|
||||
"settings.status_bar.text_capsule_position.left": "왼쪽",
|
||||
"settings.status_bar.text_capsule_position.center": "가욍데",
|
||||
"settings.status_bar.text_capsule_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_content_label": "텍스트 내용 (Markdown 지원)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.network_speed_header": "네트워크 속도",
|
||||
"settings.status_bar.network_speed_description": "상태 표시줄에 실시간 네트워크 속도를 표시합니다.",
|
||||
"settings.status_bar.network_speed_position_label": "네트워크 속도 위치",
|
||||
"settings.status_bar.network_speed_position.left": "왼쪽",
|
||||
"settings.status_bar.network_speed_position.center": "가욍데",
|
||||
"settings.status_bar.network_speed_position.right": "오른쪽",
|
||||
"settings.status_bar.network_speed_mode_label": "표시 모드",
|
||||
"settings.status_bar.network_speed_mode.both": "업로드 + 다운로드",
|
||||
"settings.status_bar.network_speed_mode.upload": "업로드만",
|
||||
"settings.status_bar.network_speed_mode.download": "다운로드만",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.show_network_type_icon_label": "네트워크 유형 아이콘 표시",
|
||||
"settings.status_bar.shadow_header": "상태 표시줄 그림자",
|
||||
"settings.status_bar.shadow_desc": "투명한 구성 요소의 가시성을 높이기 위해 상태 표시줄에 그림자 효과를 추가합니다.",
|
||||
"settings.status_bar.shadow_enabled_label": "그림자 활성화",
|
||||
"settings.status_bar.shadow_color_label": "그림자 색상",
|
||||
"settings.status_bar.shadow_opacity_label": "그림자 불투명도",
|
||||
"settings.status_bar.theme_header": "상태 표시줄 테마",
|
||||
"settings.status_bar.theme_desc": "상태 표시줄의 테마 모드를 독립적으로 설정합니다.",
|
||||
"settings.status_bar.theme_mode_label": "테마 모드",
|
||||
"settings.status_bar.theme_mode.follow_global": "전역 따르기",
|
||||
"settings.status_bar.theme_mode.dark": "다크",
|
||||
"settings.status_bar.theme_mode.light": "라이트",
|
||||
"settings.components.title": "컴포넌트",
|
||||
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
|
||||
"settings.components.grid_header": "그리드 설정",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.show_desktop": "打开桌面",
|
||||
"tray.menu.settings": "设置",
|
||||
"tray.menu.component_library": "独立组件库",
|
||||
"tray.menu.component_library": "融合桌面设置",
|
||||
"tray.menu.restart": "重启应用",
|
||||
"tray.menu.exit": "退出应用",
|
||||
"button.back_to_windows": "回到Windows",
|
||||
@@ -383,6 +383,41 @@
|
||||
"settings.status_bar.clock_format_label": "时钟格式",
|
||||
"settings.status_bar.clock_format.hm": "时:分",
|
||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||
"settings.status_bar.clock_position_label": "时钟位置",
|
||||
"settings.status_bar.clock_position.left": "靠左",
|
||||
"settings.status_bar.clock_position.center": "居中",
|
||||
"settings.status_bar.clock_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_header": "文字胶囊",
|
||||
"settings.status_bar.text_capsule_description": "在状态栏显示自定义文字,支持 Markdown 格式。",
|
||||
"settings.status_bar.text_capsule_position_label": "文字胶囊位置",
|
||||
"settings.status_bar.text_capsule_position.left": "靠左",
|
||||
"settings.status_bar.text_capsule_position.center": "居中",
|
||||
"settings.status_bar.text_capsule_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_content_label": "文字内容(支持 Markdown)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.network_speed_header": "网速显示",
|
||||
"settings.status_bar.network_speed_description": "在状态栏显示实时网络上传和下载速度。",
|
||||
"settings.status_bar.network_speed_position_label": "网速显示位置",
|
||||
"settings.status_bar.network_speed_position.left": "靠左",
|
||||
"settings.status_bar.network_speed_position.center": "居中",
|
||||
"settings.status_bar.network_speed_position.right": "靠右",
|
||||
"settings.status_bar.network_speed_mode_label": "显示模式",
|
||||
"settings.status_bar.network_speed_mode.both": "上传 + 下载",
|
||||
"settings.status_bar.network_speed_mode.upload": "仅上传",
|
||||
"settings.status_bar.network_speed_mode.download": "仅下载",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "显示网络类型图标",
|
||||
"settings.status_bar.shadow_header": "状态栏阴影",
|
||||
"settings.status_bar.shadow_desc": "为状态栏添加阴影效果,使透明背景的组件更清晰。",
|
||||
"settings.status_bar.shadow_enabled_label": "启用阴影",
|
||||
"settings.status_bar.shadow_color_label": "阴影颜色",
|
||||
"settings.status_bar.shadow_opacity_label": "阴影透明度",
|
||||
"settings.status_bar.theme_header": "状态栏主题",
|
||||
"settings.status_bar.theme_desc": "独立设置状态栏的主题模式。",
|
||||
"settings.status_bar.theme_mode_label": "主题模式",
|
||||
"settings.status_bar.theme_mode.follow_global": "跟随全局",
|
||||
"settings.status_bar.theme_mode.dark": "暗色",
|
||||
"settings.status_bar.theme_mode.light": "浅色",
|
||||
"settings.components.title": "组件",
|
||||
"settings.components.description": "调整组件布局与圆角设计。",
|
||||
"settings.components.grid_header": "网格设置",
|
||||
|
||||
@@ -112,10 +112,44 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool StatusBarClockTransparentBackground { get; set; }
|
||||
|
||||
public string ClockPosition { get; set; } = "Left"; // Left, Center, Right
|
||||
|
||||
public string ClockFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowTextCapsule { get; set; } = false;
|
||||
|
||||
public string TextCapsuleContent { get; set; } = "**Hello** World!";
|
||||
|
||||
public string TextCapsulePosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public bool TextCapsuleTransparentBackground { get; set; } = false;
|
||||
|
||||
public string TextCapsuleFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowNetworkSpeed { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedPosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public string NetworkSpeedDisplayMode { get; set; } = "Both"; // Upload, Download, Both
|
||||
|
||||
public bool NetworkSpeedTransparentBackground { get; set; } = false;
|
||||
|
||||
public bool ShowNetworkTypeIcon { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||
|
||||
public bool StatusBarShadowEnabled { get; set; } = false;
|
||||
|
||||
public string StatusBarShadowColor { get; set; } = "#000000";
|
||||
|
||||
public double StatusBarShadowOpacity { get; set; } = 0.3;
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
#region Study Settings
|
||||
|
||||
@@ -124,15 +124,66 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
public const string RinLit = "rinlit";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return value?.ToLowerInvariant() switch
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
"rinlit" => RinLit,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetDisplayName(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
Sectl => "SECTL 图库",
|
||||
RinLit => "Rin's 图库",
|
||||
_ => "ClassIsland 图库"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub数据源配置
|
||||
public sealed class ZhiJiaoHubSourceConfig
|
||||
{
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
public string Repo { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
|
||||
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
|
||||
|
||||
public static ZhiJiaoHubSourceConfig GetConfig(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "SECTL",
|
||||
Repo = "SECTL-hub",
|
||||
Path = "docs/.vuepress/public/images",
|
||||
DisplayName = "SECTL 图库"
|
||||
},
|
||||
ZhiJiaoHubSources.RinLit => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "RinLit-233-shiroko",
|
||||
Repo = "Rin-sHub",
|
||||
Path = "assets/images",
|
||||
DisplayName = "Rin's 图库"
|
||||
},
|
||||
_ => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "ClassIsland",
|
||||
Repo = "classisland-hub",
|
||||
Path = "images",
|
||||
DisplayName = "ClassIsland 图库"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub镜像加速源常量
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
195
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务接口
|
||||
/// </summary>
|
||||
public interface IFusedDesktopManagerService
|
||||
{
|
||||
void Initialize();
|
||||
void EnterEditMode();
|
||||
void ExitEditMode();
|
||||
void ReloadWidgets();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
|
||||
/// </summary>
|
||||
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||
|
||||
// 基础服务依赖
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private bool _isEditMode;
|
||||
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopManagerService(
|
||||
IFusedDesktopLayoutService layoutService,
|
||||
ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_layoutService = layoutService;
|
||||
_settingsFacade = settingsFacade;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
|
||||
EnsureRegistries();
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
private void EnsureRegistries()
|
||||
{
|
||||
if (_componentRuntimeRegistry is not null) return;
|
||||
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
public void EnterEditMode()
|
||||
{
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 隐藏所有底层小窗口
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
public void ExitEditMode()
|
||||
{
|
||||
if (!_isEditMode) return;
|
||||
_isEditMode = false;
|
||||
|
||||
// 编辑完成,重新加载布局(可能已发生更改)并显示
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
public void ReloadWidgets()
|
||||
{
|
||||
if (_isEditMode) return; // 编辑模式下不渲染小窗口
|
||||
|
||||
var layout = _layoutService.Load();
|
||||
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
||||
|
||||
foreach (var placement in layout.ComponentPlacements)
|
||||
{
|
||||
existingIds.Remove(placement.PlacementId);
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新组件,生成窗口
|
||||
try
|
||||
{
|
||||
var window = CreateWidgetWindow(placement);
|
||||
if (window != null)
|
||||
{
|
||||
_widgetWindows[placement.PlacementId] = window;
|
||||
window.Show();
|
||||
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Failed to render tiny window for {placement.ComponentId}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除被删除的组件
|
||||
foreach (var id in existingIds)
|
||||
{
|
||||
if (_widgetWindows.Remove(id, out var windowToRemove))
|
||||
{
|
||||
windowToRemove.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
EnsureRegistries();
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
DefaultCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
|
||||
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
|
||||
control.Width = placement.Width;
|
||||
control.Height = placement.Height;
|
||||
|
||||
var window = new DesktopWidgetWindow(control);
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工厂
|
||||
/// </summary>
|
||||
public static class FusedDesktopManagerServiceFactory
|
||||
{
|
||||
private static IFusedDesktopManagerService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IFusedDesktopManagerService GetOrCreate()
|
||||
{
|
||||
if (_instance is not null) return _instance;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
var settings = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_instance ??= new FusedDesktopManagerService(layoutService, settings);
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,11 +317,15 @@ public sealed record RecommendationApiOptions
|
||||
|
||||
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/docs/.vuepress/public/images";
|
||||
|
||||
public string RinLitHubApiUrl { get; init; } = "https://api.github.com/repos/RinLit-233-shiroko/Rin-sHub/contents/images";
|
||||
|
||||
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/docs/.vuepress/public/images/{0}";
|
||||
|
||||
public string RinLitHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/RinLit-233-shiroko/Rin-sHub/main/images/{0}";
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
|
||||
@@ -3244,15 +3244,10 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
|
||||
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var (owner, repo, path) = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||
_ => ("ClassIsland", "classisland-hub", "images")
|
||||
};
|
||||
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
// 如果使用镜像加速,代理 GitHub API 请求
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
@@ -3260,18 +3255,16 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||
var images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("未找到图片文件");
|
||||
throw new InvalidOperationException($"在 {config.DisplayName} 中未找到图片文件");
|
||||
}
|
||||
|
||||
// 随机打乱图片顺序
|
||||
var random = new Random();
|
||||
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||
|
||||
// 重新设置索引
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var item = shuffled[i];
|
||||
@@ -3286,11 +3279,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||
throw new HttpRequestException($"从 {config.DisplayName} 获取图片列表失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string contentsUrl,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
@@ -3308,7 +3305,17 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
|
||||
if ((int)response.StatusCode == 404)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"在 {config.DisplayName} 中找不到图片目录。请检查仓库结构和路径配置。\n" +
|
||||
$"仓库: {config.Owner}/{config.Repo}\n" +
|
||||
$"路径: {config.Path}");
|
||||
}
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"从 {config.DisplayName} 获取数据失败: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
}
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
@@ -3320,9 +3327,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||
{
|
||||
var errorMessage = messageNode.GetString();
|
||||
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||
throw new InvalidOperationException($"GitHub API 错误 ({config.DisplayName}): {errorMessage}");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||
throw new InvalidOperationException($"从 {config.DisplayName} 返回的数据格式无效");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
@@ -3342,18 +3349,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只处理图片文件
|
||||
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解码文件名
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||
|
||||
// 构造图片 URL
|
||||
string imageUrl;
|
||||
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||
{
|
||||
@@ -3361,10 +3365,12 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||
imageUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
config.RawUrlTemplate,
|
||||
Uri.EscapeDataString(name));
|
||||
}
|
||||
|
||||
// 应用镜像加速到图片 URL
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||
|
||||
@@ -41,8 +41,31 @@ public sealed record StatusBarSettingsState(
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string ClockPosition,
|
||||
string ClockFontSize,
|
||||
bool ShowTextCapsule,
|
||||
string TextCapsuleContent,
|
||||
string TextCapsulePosition,
|
||||
bool TextCapsuleTransparentBackground,
|
||||
string TextCapsuleFontSize,
|
||||
bool ShowNetworkSpeed,
|
||||
string NetworkSpeedPosition,
|
||||
string NetworkSpeedDisplayMode,
|
||||
bool NetworkSpeedTransparentBackground,
|
||||
bool ShowNetworkTypeIcon,
|
||||
string NetworkSpeedFontSize,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
int CustomSpacingPercent,
|
||||
bool ShadowEnabled,
|
||||
string ShadowColor,
|
||||
double ShadowOpacity);
|
||||
|
||||
public sealed record TextCapsuleSettingsState(
|
||||
bool ShowTextCapsule,
|
||||
string Content,
|
||||
string Position,
|
||||
bool TransparentBackground);
|
||||
|
||||
public sealed record WeatherSettingsState(
|
||||
string LocationMode,
|
||||
string LocationKey,
|
||||
@@ -274,6 +297,12 @@ public interface IStatusBarSettingsService
|
||||
void Save(StatusBarSettingsState state);
|
||||
}
|
||||
|
||||
public interface ITextCapsuleSettingsService
|
||||
{
|
||||
TextCapsuleSettingsState Get();
|
||||
void Save(TextCapsuleSettingsState state);
|
||||
}
|
||||
|
||||
public interface IWeatherProvider
|
||||
{
|
||||
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
@@ -385,6 +414,7 @@ public interface ISettingsFacadeService
|
||||
IWallpaperMediaService WallpaperMedia { get; }
|
||||
IThemeAppearanceService Theme { get; }
|
||||
IStatusBarSettingsService StatusBar { get; }
|
||||
ITextCapsuleSettingsService TextCapsule { get; }
|
||||
IWeatherSettingsService Weather { get; }
|
||||
IRegionSettingsService Region { get; }
|
||||
IPrivacySettingsService Privacy { get; }
|
||||
|
||||
@@ -386,8 +386,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode,
|
||||
snapshot.ClockDisplayFormat,
|
||||
snapshot.StatusBarClockTransparentBackground,
|
||||
snapshot.ClockPosition,
|
||||
snapshot.ClockFontSize,
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground,
|
||||
snapshot.TextCapsuleFontSize,
|
||||
snapshot.ShowNetworkSpeed,
|
||||
snapshot.NetworkSpeedPosition,
|
||||
snapshot.NetworkSpeedDisplayMode,
|
||||
snapshot.NetworkSpeedTransparentBackground,
|
||||
snapshot.ShowNetworkTypeIcon,
|
||||
snapshot.NetworkSpeedFontSize,
|
||||
snapshot.StatusBarSpacingMode,
|
||||
snapshot.StatusBarCustomSpacingPercent);
|
||||
snapshot.StatusBarCustomSpacingPercent,
|
||||
snapshot.StatusBarShadowEnabled,
|
||||
snapshot.StatusBarShadowColor,
|
||||
snapshot.StatusBarShadowOpacity);
|
||||
}
|
||||
|
||||
public void Save(StatusBarSettingsState state)
|
||||
@@ -399,8 +415,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||
snapshot.ClockPosition = state.ClockPosition;
|
||||
snapshot.ClockFontSize = state.ClockFontSize;
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.TextCapsuleContent;
|
||||
snapshot.TextCapsulePosition = state.TextCapsulePosition;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
snapshot.TextCapsuleFontSize = state.TextCapsuleFontSize;
|
||||
snapshot.ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
snapshot.NetworkSpeedPosition = state.NetworkSpeedPosition;
|
||||
snapshot.NetworkSpeedDisplayMode = state.NetworkSpeedDisplayMode;
|
||||
snapshot.NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
snapshot.ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
snapshot.NetworkSpeedFontSize = state.NetworkSpeedFontSize;
|
||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||
snapshot.StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
snapshot.StatusBarShadowColor = state.ShadowColor;
|
||||
snapshot.StatusBarShadowOpacity = state.ShadowOpacity;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
@@ -412,8 +444,63 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ClockPosition),
|
||||
nameof(AppSettingsSnapshot.ClockFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkSpeed),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedPosition),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedDisplayMode),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkTypeIcon),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedFontSize),
|
||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowEnabled),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowColor),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowOpacity)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TextCapsuleSettingsService : ITextCapsuleSettingsService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public TextCapsuleSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
public TextCapsuleSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
return new TextCapsuleSettingsState(
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground);
|
||||
}
|
||||
|
||||
public void Save(TextCapsuleSettingsState state)
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.Content;
|
||||
snapshot.TextCapsulePosition = state.Position;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TransparentBackground;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground)
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1198,6 +1285,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
WallpaperMedia = new WallpaperMediaService();
|
||||
Theme = new ThemeAppearanceService(Settings);
|
||||
StatusBar = new StatusBarSettingsService(Settings);
|
||||
TextCapsule = new TextCapsuleSettingsService(Settings);
|
||||
_weatherSettingsService = new WeatherSettingsService(Settings);
|
||||
Weather = _weatherSettingsService;
|
||||
Region = new RegionSettingsService(Settings);
|
||||
@@ -1227,6 +1315,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
|
||||
public IStatusBarSettingsService StatusBar { get; }
|
||||
|
||||
public ITextCapsuleSettingsService TextCapsule { get; }
|
||||
|
||||
public IWeatherSettingsService Weather { get; }
|
||||
|
||||
public IRegionSettingsService Region { get; }
|
||||
|
||||
@@ -320,9 +320,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (!TryFindSessionReportLocked(sessionId, out var report))
|
||||
{
|
||||
return false;
|
||||
// 重新加载历史数据
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
|
||||
// 再次尝试查找
|
||||
if (!TryFindSessionReportLocked(sessionId, out report))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_selectedSessionReportId = report.SessionId;
|
||||
@@ -356,9 +364,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
||||
@@ -389,9 +405,17 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
// 如果找不到报告,尝试重新从数据库加载
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
index = FindSessionReportIndexLocked(sessionId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var removed = _sessionHistory[index];
|
||||
|
||||
@@ -17,10 +17,17 @@ public sealed class StudyDataStore
|
||||
};
|
||||
|
||||
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();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
_logger?.Invoke($"[StudyDataStore] {message}");
|
||||
}
|
||||
|
||||
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
||||
@@ -61,17 +68,25 @@ public sealed class StudyDataStore
|
||||
continue;
|
||||
}
|
||||
|
||||
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (report is not null)
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to load session reports: {ex.Message}");
|
||||
return Array.Empty<StudySessionReport>();
|
||||
}
|
||||
}
|
||||
@@ -99,20 +114,28 @@ public sealed class StudyDataStore
|
||||
var json = command.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Log($"Session report not found for id: {sessionId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (parsed is null)
|
||||
{
|
||||
Log($"Failed to deserialize session report for id: {sessionId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
report = parsed;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log($"JSON deserialization error for session {sessionId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to get session report {sessionId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -138,9 +161,9 @@ public sealed class StudyDataStore
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to replace session reports: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +185,9 @@ public sealed class StudyDataStore
|
||||
? null
|
||||
: value.Trim();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to get selected session report id: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -192,9 +216,9 @@ public sealed class StudyDataStore
|
||||
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
||||
upsertCommand.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to set selected session report id: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +295,9 @@ public sealed class StudyDataStore
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to append noise slice: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,8 +389,9 @@ public sealed class StudyDataStore
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Failed to load noise slice timeline: {ex.Message}");
|
||||
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||
}
|
||||
}
|
||||
@@ -389,9 +414,9 @@ public sealed class StudyDataStore
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
Log($"Failed to clear noise slice timeline: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
379
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
379
LanMountainDesktop/Services/WindowPassthroughService.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
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();
|
||||
|
||||
// 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = 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] = [];
|
||||
UpdateWindowScreenOrigin(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);
|
||||
_windowScreenOrigins.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)
|
||||
{
|
||||
// WM_NCHITTEST 的鼠标坐标在 lParam(低16位=X,高16位=Y),且为屏幕坐标
|
||||
var screenX = (short)(lParam.ToInt64() & 0xFFFF);
|
||||
var screenY = (short)((lParam.ToInt64() >> 16) & 0xFFFF);
|
||||
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
var point = new Point(clientX, clientY);
|
||||
|
||||
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;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标)
|
||||
/// </summary>
|
||||
private static void UpdateWindowScreenOrigin(IntPtr handle)
|
||||
{
|
||||
if (GetWindowRect(handle, out var rect))
|
||||
{
|
||||
_windowScreenOrigins[handle] = new Point(rect.Left, rect.Top);
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
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 sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly string _startupRenderMode;
|
||||
private string _languageCode;
|
||||
private bool _isInitializing;
|
||||
public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly string _startupRenderMode;
|
||||
private string _languageCode;
|
||||
private bool _isInitializing;
|
||||
private bool _disposed;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableThreeFingerSwipe;
|
||||
|
||||
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -200,9 +204,65 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? RenderModes[0];
|
||||
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
|
||||
_isInitializing = false;
|
||||
|
||||
RefreshPreview();
|
||||
|
||||
// 监听设置变更,防止被意外重置
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope != SettingsScope.App)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedKeys = e.ChangedKeys?.ToArray();
|
||||
if (changedKeys is null || changedKeys.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是其他设置变更,重新加载我们的设置
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<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;
|
||||
@@ -2330,25 +2390,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
private bool _isInitializing;
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService;
|
||||
|
||||
// 防抖计时器
|
||||
private System.Timers.Timer? _noiseSettingsDebounceTimer;
|
||||
private System.Timers.Timer? _timerSettingsDebounceTimer;
|
||||
private System.Timers.Timer? _alertSettingsDebounceTimer;
|
||||
private System.Timers.Timer? _displaySettingsDebounceTimer;
|
||||
private bool _hasPendingNoiseSave;
|
||||
private bool _hasPendingTimerSave;
|
||||
private bool _hasPendingAlertSave;
|
||||
private bool _hasPendingDisplaySave;
|
||||
|
||||
public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade, IStudyAnalyticsService? studyAnalyticsService = null)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
// 初始化防抖计时器
|
||||
InitializeDebounceTimers();
|
||||
|
||||
RefreshLocalizedText();
|
||||
|
||||
_isInitializing = true;
|
||||
@@ -2356,21 +2403,6 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
private void InitializeDebounceTimers()
|
||||
{
|
||||
_noiseSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
|
||||
_noiseSettingsDebounceTimer.Elapsed += async (s, e) => await SaveNoiseSettingsDebounced();
|
||||
|
||||
_timerSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
|
||||
_timerSettingsDebounceTimer.Elapsed += async (s, e) => await SaveTimerSettingsDebounced();
|
||||
|
||||
_alertSettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
|
||||
_alertSettingsDebounceTimer.Elapsed += async (s, e) => await SaveAlertSettingsDebounced();
|
||||
|
||||
_displaySettingsDebounceTimer = new System.Timers.Timer(500) { AutoReset = false };
|
||||
_displaySettingsDebounceTimer.Elapsed += async (s, e) => await SaveDisplaySettingsDebounced();
|
||||
}
|
||||
|
||||
#region Properties - Master Switch
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -2455,7 +2487,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateThresholdText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceNoiseSettingsSave();
|
||||
SaveNoiseSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2471,7 +2503,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateSamplingRateText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceNoiseSettingsSave();
|
||||
SaveNoiseSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2485,18 +2517,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
|
||||
}
|
||||
|
||||
private void DebounceNoiseSettingsSave()
|
||||
private void SaveNoiseSettings()
|
||||
{
|
||||
_hasPendingNoiseSave = true;
|
||||
_noiseSettingsDebounceTimer?.Stop();
|
||||
_noiseSettingsDebounceTimer?.Start();
|
||||
}
|
||||
|
||||
private async Task SaveNoiseSettingsDebounced()
|
||||
{
|
||||
if (!_hasPendingNoiseSave) return;
|
||||
_hasPendingNoiseSave = false;
|
||||
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
@@ -2601,7 +2623,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateFocusDurationText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2617,7 +2639,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateBreakDurationText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2633,7 +2655,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateLongBreakDurationText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2649,7 +2671,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateSessionsBeforeLongBreakText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2657,7 +2679,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2665,7 +2687,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceTimerSettingsSave();
|
||||
SaveTimerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2693,18 +2715,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} {unit}";
|
||||
}
|
||||
|
||||
private void DebounceTimerSettingsSave()
|
||||
private void SaveTimerSettings()
|
||||
{
|
||||
_hasPendingTimerSave = true;
|
||||
_timerSettingsDebounceTimer?.Stop();
|
||||
_timerSettingsDebounceTimer?.Start();
|
||||
}
|
||||
|
||||
private async Task SaveTimerSettingsDebounced()
|
||||
{
|
||||
if (!_hasPendingTimerSave) return;
|
||||
_hasPendingTimerSave = false;
|
||||
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
@@ -2762,7 +2774,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceAlertSettingsSave();
|
||||
SaveAlertSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2777,22 +2789,12 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceAlertSettingsSave();
|
||||
SaveAlertSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private void DebounceAlertSettingsSave()
|
||||
private void SaveAlertSettings()
|
||||
{
|
||||
_hasPendingAlertSave = true;
|
||||
_alertSettingsDebounceTimer?.Stop();
|
||||
_alertSettingsDebounceTimer?.Start();
|
||||
}
|
||||
|
||||
private async Task SaveAlertSettingsDebounced()
|
||||
{
|
||||
if (!_hasPendingAlertSave) return;
|
||||
_hasPendingAlertSave = false;
|
||||
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
@@ -2855,7 +2857,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceDisplaySettingsSave();
|
||||
SaveDisplaySettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2871,7 +2873,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateBaselineDbText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceDisplaySettingsSave();
|
||||
SaveDisplaySettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2887,7 +2889,7 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
UpdateAvgWindowSecText();
|
||||
if (!_isInitializing)
|
||||
{
|
||||
DebounceDisplaySettingsSave();
|
||||
SaveDisplaySettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2907,18 +2909,8 @@ public sealed partial class StudySettingsPageViewModel : ViewModelBase
|
||||
AvgWindowSecValueText = $"{AvgWindowSec} {unit}";
|
||||
}
|
||||
|
||||
private void DebounceDisplaySettingsSave()
|
||||
private void SaveDisplaySettings()
|
||||
{
|
||||
_hasPendingDisplaySave = true;
|
||||
_displaySettingsDebounceTimer?.Stop();
|
||||
_displaySettingsDebounceTimer?.Start();
|
||||
}
|
||||
|
||||
private async Task SaveDisplaySettingsDebounced()
|
||||
{
|
||||
if (!_hasPendingDisplaySave) return;
|
||||
_hasPendingDisplaySave = false;
|
||||
|
||||
try
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -21,6 +22,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
ClockFormats = CreateClockFormats();
|
||||
ClockPositions = CreateClockPositions();
|
||||
ClockFontSizes = CreateFontSizes();
|
||||
TextCapsulePositions = CreateTextCapsulePositions();
|
||||
NetworkSpeedPositions = CreateNetworkSpeedPositions();
|
||||
NetworkSpeedDisplayModes = CreateNetworkSpeedDisplayModes();
|
||||
NetworkSpeedFontSizes = CreateFontSizes();
|
||||
SpacingModes = CreateSpacingModes();
|
||||
RefreshLocalizedText();
|
||||
|
||||
@@ -31,8 +38,20 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFormats { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> TextCapsulePositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedDisplayModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> SpacingModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFontSizes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedFontSizes { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showClock = true;
|
||||
|
||||
@@ -42,6 +61,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _clockTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockPosition = new("Left", "Left");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||
|
||||
@@ -75,6 +97,81 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showTextCapsule;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContent = "**Hello** World!";
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedTextCapsulePosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _textCapsuleTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsulePositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContentLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkSpeed;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedPosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedDisplayMode = new("Both", "Both");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _networkSpeedTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDisplayModeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkTypeIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _showNetworkTypeIconLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _spacingHeader = string.Empty;
|
||||
|
||||
@@ -84,6 +181,32 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _customSpacingLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _statusBarShadowEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _statusBarShadowColor = Colors.Black;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _statusBarShadowOpacity = 30;
|
||||
|
||||
public IBrush StatusBarShadowColorBrush => new SolidColorBrush(StatusBarShadowColor);
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowEnabledLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowColorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowOpacityLabel = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -99,12 +222,59 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
?? ClockFormats[1];
|
||||
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||
|
||||
var clockPosition = NormalizeClockPosition(state.ClockPosition);
|
||||
SelectedClockPosition = ClockPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockPositions[0];
|
||||
|
||||
// 时钟字体大小设置
|
||||
var clockFontSize = NormalizeFontSize(state.ClockFontSize);
|
||||
SelectedClockFontSize = ClockFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockFontSizes[1]; // 默认中等
|
||||
|
||||
// 文字胶囊设置
|
||||
ShowTextCapsule = state.ShowTextCapsule;
|
||||
TextCapsuleContent = state.TextCapsuleContent ?? "**Hello** World!";
|
||||
var textCapsulePosition = NormalizeTextCapsulePosition(state.TextCapsulePosition);
|
||||
SelectedTextCapsulePosition = TextCapsulePositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, textCapsulePosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? TextCapsulePositions[2]; // 默认靠右
|
||||
TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
|
||||
// 网速设置
|
||||
ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
var networkSpeedPosition = NormalizeNetworkSpeedPosition(state.NetworkSpeedPosition);
|
||||
SelectedNetworkSpeedPosition = NetworkSpeedPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedPositions[2]; // 默认靠右
|
||||
var networkSpeedDisplayMode = NormalizeNetworkSpeedDisplayMode(state.NetworkSpeedDisplayMode);
|
||||
SelectedNetworkSpeedDisplayMode = NetworkSpeedDisplayModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedDisplayMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedDisplayModes[0]; // 默认双向
|
||||
NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
|
||||
// 网速字体大小设置
|
||||
var networkSpeedFontSize = NormalizeFontSize(state.NetworkSpeedFontSize);
|
||||
SelectedNetworkSpeedFontSize = NetworkSpeedFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedFontSizes[1]; // 默认中等
|
||||
|
||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, spacingMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? SpacingModes[1];
|
||||
CustomSpacingPercent = Math.Clamp(state.CustomSpacingPercent, 0, 30);
|
||||
IsCustomSpacingVisible = string.Equals(SelectedSpacingMode.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 状态栏阴影设置
|
||||
StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
if (Color.TryParse(state.ShadowColor, out var shadowColor))
|
||||
{
|
||||
StatusBarShadowColor = shadowColor;
|
||||
}
|
||||
StatusBarShadowOpacity = Math.Clamp(state.ShadowOpacity * 100, 0, 100);
|
||||
}
|
||||
|
||||
partial void OnShowClockChanged(bool value)
|
||||
@@ -137,6 +307,126 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowTextCapsuleChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleContentChanged(string value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedTextCapsulePositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkSpeedChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedDisplayModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnNetworkSpeedTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkTypeIconChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||
{
|
||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -165,6 +455,37 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowEnabledChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowColorChanged(Color value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusBarShadowColorBrush));
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowOpacityChanged(double value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -184,8 +505,24 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
state.TaskbarLayoutMode,
|
||||
SelectedClockFormat.Value,
|
||||
ClockTransparentBackground,
|
||||
SelectedClockPosition.Value,
|
||||
SelectedClockFontSize?.Value ?? "Medium",
|
||||
ShowTextCapsule,
|
||||
TextCapsuleContent ?? "**Hello** World!",
|
||||
SelectedTextCapsulePosition?.Value ?? "Right",
|
||||
TextCapsuleTransparentBackground,
|
||||
"Medium", // TextCapsuleFontSize - 暂时使用默认值
|
||||
ShowNetworkSpeed,
|
||||
SelectedNetworkSpeedPosition?.Value ?? "Right",
|
||||
SelectedNetworkSpeedDisplayMode?.Value ?? "Both",
|
||||
NetworkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon,
|
||||
SelectedNetworkSpeedFontSize?.Value ?? "Medium",
|
||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30),
|
||||
StatusBarShadowEnabled,
|
||||
StatusBarShadowColor.ToString(),
|
||||
StatusBarShadowOpacity / 100.0));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockFormats()
|
||||
@@ -197,6 +534,46 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.clock_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.clock_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.clock_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateTextCapsulePositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.text_capsule_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.text_capsule_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.text_capsule_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.network_speed_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.network_speed_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.network_speed_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedDisplayModes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Both", L("settings.status_bar.network_speed_mode.both", "Upload + Download")),
|
||||
new SelectionOption("Upload", L("settings.status_bar.network_speed_mode.upload", "Upload only")),
|
||||
new SelectionOption("Download", L("settings.status_bar.network_speed_mode.download", "Download only"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateSpacingModes()
|
||||
{
|
||||
return
|
||||
@@ -217,9 +594,28 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
||||
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
|
||||
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
|
||||
ClockPositionLabel = L("settings.status_bar.clock_position_label", "Clock position");
|
||||
ClockFontSizeLabel = L("settings.status_bar.clock_font_size_label", "Font size");
|
||||
TextCapsuleHeader = L("settings.status_bar.text_capsule_header", "Text Capsule");
|
||||
TextCapsuleDescription = L("settings.status_bar.text_capsule_description", "Display custom text with Markdown support on the status bar.");
|
||||
TextCapsulePositionLabel = L("settings.status_bar.text_capsule_position_label", "Text capsule position");
|
||||
TextCapsuleContentLabel = L("settings.status_bar.text_capsule_content_label", "Text content (Markdown supported)");
|
||||
TextCapsuleTransparentBackgroundLabel = L("settings.status_bar.text_capsule_transparent_background_label", "Transparent background");
|
||||
NetworkSpeedHeader = L("settings.status_bar.network_speed_header", "Network Speed");
|
||||
NetworkSpeedDescription = L("settings.status_bar.network_speed_description", "Display real-time network upload and download speed.");
|
||||
NetworkSpeedPositionLabel = L("settings.status_bar.network_speed_position_label", "Network speed position");
|
||||
NetworkSpeedDisplayModeLabel = L("settings.status_bar.network_speed_mode_label", "Display mode");
|
||||
NetworkSpeedTransparentBackgroundLabel = L("settings.status_bar.network_speed_transparent_background_label", "Transparent background");
|
||||
ShowNetworkTypeIconLabel = L("settings.status_bar.show_network_type_icon_label", "Show network type icon");
|
||||
NetworkSpeedFontSizeLabel = L("settings.status_bar.network_speed_font_size_label", "Font size");
|
||||
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
StatusBarShadowHeader = L("settings.status_bar.shadow_header", "Status Bar Shadow");
|
||||
StatusBarShadowDescription = L("settings.status_bar.shadow_desc", "Add shadow effect to the status bar for better visibility.");
|
||||
StatusBarShadowEnabledLabel = L("settings.status_bar.shadow_enabled_label", "Enable shadow");
|
||||
StatusBarShadowColorLabel = L("settings.status_bar.shadow_color_label", "Shadow color");
|
||||
StatusBarShadowOpacityLabel = L("settings.status_bar.shadow_opacity_label", "Shadow opacity");
|
||||
}
|
||||
|
||||
private string NormalizeSpacingMode(string? value)
|
||||
@@ -232,6 +628,66 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeClockPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) => "Right",
|
||||
_ => "Left"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTextCapsulePosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedDisplayMode(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Upload", StringComparison.OrdinalIgnoreCase) => "Upload",
|
||||
_ when string.Equals(value, "Download", StringComparison.OrdinalIgnoreCase) => "Download",
|
||||
_ => "Both"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeFontSize(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Small", StringComparison.OrdinalIgnoreCase) => "Small",
|
||||
_ when string.Equals(value, "Large", StringComparison.OrdinalIgnoreCase) => "Large",
|
||||
_ => "Medium"
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateFontSizes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Small", L("settings.status_bar.font_size.small", "Small")),
|
||||
new SelectionOption("Medium", L("settings.status_bar.font_size.medium", "Medium")),
|
||||
new SelectionOption("Large", L("settings.status_bar.font_size.large", "Large"))
|
||||
];
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<ComboBoxItem x:Name="SectlItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="sectl" />
|
||||
<ComboBoxItem x:Name="RinLitItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="rinlit" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
|
||||
@@ -29,10 +29,11 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
RinLitItem.Content = L("zhijiaohub.settings.rinlit", "Rin's 图库");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
@@ -65,6 +66,7 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceComboBox.SelectedItem = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
ZhiJiaoHubSources.RinLit => RinLitItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
return Symbol.Info;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -25,6 +25,7 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public ClockWidget()
|
||||
{
|
||||
@@ -72,6 +73,21 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
@@ -138,7 +154,14 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
|
||||
// 3. 核心:满盈字阶 (Filled Typography)
|
||||
// 使主时间文字占据容器高度的 ~68%,产生饱满的视觉张力
|
||||
var mainFontSize = targetHeight * 0.68;
|
||||
// 根据字体大小设置调整基础大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.55,
|
||||
"Large" => 0.85,
|
||||
_ => 0.68 // Medium (default)
|
||||
};
|
||||
var mainFontSize = targetHeight * fontSizeMultiplier;
|
||||
MainTimeTextBlock.FontSize = mainFontSize;
|
||||
MainTimeTextBlock.FontWeight = FontWeight.SemiBold;
|
||||
|
||||
|
||||
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
@@ -0,0 +1,72 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="160"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.NetworkSpeedWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0">
|
||||
<!-- 上传速度 -->
|
||||
<StackPanel x:Name="UploadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↑"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="UploadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<Rectangle x:Name="Separator"
|
||||
Width="1"
|
||||
Height="16"
|
||||
Margin="8,0"
|
||||
Opacity="0.3"
|
||||
Fill="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
|
||||
<!-- 下载速度 -->
|
||||
<StackPanel x:Name="DownloadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↓"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="DownloadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 网络类型图标 -->
|
||||
<fi:SymbolIcon x:Name="NetworkTypeIcon"
|
||||
Symbol="Globe"
|
||||
FontSize="14"
|
||||
Margin="8,0,0,0"
|
||||
Opacity="0.8"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
@@ -0,0 +1,451 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Services;
|
||||
using Symbol = FluentIcons.Common.Symbol;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class NetworkSpeedWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new();
|
||||
private readonly DispatcherTimer _networkTypeTimer = new();
|
||||
private NetworkInterface? _selectedInterface;
|
||||
private long _lastBytesReceived;
|
||||
private long _lastBytesSent;
|
||||
private bool _isFirstUpdate = true;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private bool _transparentBackground;
|
||||
private string _displayMode = "Both"; // "Upload", "Download", "Both"
|
||||
private bool _showNetworkTypeIcon;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public NetworkSpeedWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
SetupTimer();
|
||||
SelectBestInterface();
|
||||
UpdateDisplayMode();
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
|
||||
public string DisplayMode
|
||||
{
|
||||
get => _displayMode;
|
||||
set
|
||||
{
|
||||
if (_displayMode == value) return;
|
||||
_displayMode = value;
|
||||
UpdateDisplayMode();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value) return;
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowNetworkTypeIcon
|
||||
{
|
||||
get => _showNetworkTypeIcon;
|
||||
set
|
||||
{
|
||||
if (_showNetworkTypeIcon == value) return;
|
||||
_showNetworkTypeIcon = value;
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDisplayMode(string mode)
|
||||
{
|
||||
DisplayMode = mode;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparent)
|
||||
{
|
||||
TransparentBackground = transparent;
|
||||
}
|
||||
|
||||
public void SetShowNetworkTypeIcon(bool show)
|
||||
{
|
||||
ShowNetworkTypeIcon = show;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
private void SetupTimer()
|
||||
{
|
||||
// 网速更新定时器(每秒)
|
||||
_timer.Interval = TimeSpan.FromSeconds(1);
|
||||
_timer.Tick += (_, _) => UpdateSpeed();
|
||||
_timer.Start();
|
||||
|
||||
// 网络类型检测定时器(每500ms,满足响应延迟要求)
|
||||
_networkTypeTimer.Interval = TimeSpan.FromMilliseconds(500);
|
||||
_networkTypeTimer.Tick += (_, _) => UpdateNetworkTypeIcon();
|
||||
_networkTypeTimer.Start();
|
||||
}
|
||||
|
||||
private void SelectBestInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.Where(ni => !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(ni => !ni.Description.Contains("VPN", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
// 优先选择有流量的物理网卡
|
||||
_selectedInterface = interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
|
||||
// 如果没有找到,选择第一个活动的非虚拟网卡
|
||||
_selectedInterface ??= interfaces.FirstOrDefault();
|
||||
|
||||
if (_selectedInterface != null)
|
||||
{
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
_lastBytesReceived = stats.BytesReceived;
|
||||
_lastBytesSent = stats.BytesSent;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误,下次重试
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSpeed()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 如果当前网卡不可用,尝试重新选择
|
||||
if (_selectedInterface == null ||
|
||||
_selectedInterface.OperationalStatus != OperationalStatus.Up)
|
||||
{
|
||||
SelectBestInterface();
|
||||
}
|
||||
|
||||
if (_selectedInterface == null)
|
||||
{
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
var currentBytesReceived = stats.BytesReceived;
|
||||
var currentBytesSent = stats.BytesSent;
|
||||
|
||||
if (_isFirstUpdate)
|
||||
{
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
_isFirstUpdate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算速度(每秒字节数)
|
||||
var downloadBytes = currentBytesReceived - _lastBytesReceived;
|
||||
var uploadBytes = currentBytesSent - _lastBytesSent;
|
||||
|
||||
// 处理计数器重置的情况
|
||||
if (downloadBytes < 0) downloadBytes = 0;
|
||||
if (uploadBytes < 0) uploadBytes = 0;
|
||||
|
||||
UploadSpeedTextBlock.Text = FormatSpeed(uploadBytes);
|
||||
DownloadSpeedTextBlock.Text = FormatSpeed(downloadBytes);
|
||||
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时显示 --
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNetworkTypeIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_showNetworkTypeIcon || NetworkTypeIcon == null)
|
||||
{
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前活动的网络接口
|
||||
var activeInterface = GetActiveNetworkInterface();
|
||||
|
||||
if (activeInterface == null)
|
||||
{
|
||||
// 无网络连接
|
||||
NetworkTypeIcon.Symbol = Symbol.DismissCircle;
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据网络类型设置图标
|
||||
switch (activeInterface.NetworkInterfaceType)
|
||||
{
|
||||
case NetworkInterfaceType.Wireless80211:
|
||||
// WiFi
|
||||
NetworkTypeIcon.Symbol = Symbol.WiFi;
|
||||
break;
|
||||
|
||||
case NetworkInterfaceType.Ethernet:
|
||||
// 有线网络 - 检查是否是移动网络热点
|
||||
if (IsLikelyMobileHotspot(activeInterface))
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.Phone;
|
||||
}
|
||||
else
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.PlugConnected;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型,尝试根据描述判断
|
||||
var symbol = GetSymbolFromDescription(activeInterface.Description);
|
||||
NetworkTypeIcon.Symbol = symbol;
|
||||
break;
|
||||
}
|
||||
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时隐藏图标
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkInterface? GetActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用当前选中的网卡
|
||||
if (_selectedInterface != null &&
|
||||
_selectedInterface.OperationalStatus == OperationalStatus.Up)
|
||||
{
|
||||
return _selectedInterface;
|
||||
}
|
||||
|
||||
// 否则查找最佳网卡
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.ToList();
|
||||
|
||||
// 优先返回有流量的网卡
|
||||
return interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyMobileHotspot(NetworkInterface ni)
|
||||
{
|
||||
// 通过描述判断是否是移动热点
|
||||
var desc = ni.Description.ToLowerInvariant();
|
||||
return desc.Contains("mobile") ||
|
||||
desc.Contains("cellular") ||
|
||||
desc.Contains("phone") ||
|
||||
desc.Contains("tether");
|
||||
}
|
||||
|
||||
private static Symbol GetSymbolFromDescription(string description)
|
||||
{
|
||||
var desc = description.ToLowerInvariant();
|
||||
|
||||
if (desc.Contains("wifi") || desc.Contains("wi-fi") || desc.Contains("wireless"))
|
||||
return Symbol.WiFi;
|
||||
|
||||
if (desc.Contains("ethernet") || desc.Contains("lan") || desc.Contains("wired"))
|
||||
return Symbol.PlugConnected;
|
||||
|
||||
if (desc.Contains("cellular") || desc.Contains("mobile") || desc.Contains("lte") || desc.Contains("5g") || desc.Contains("4g"))
|
||||
return Symbol.Phone;
|
||||
|
||||
if (desc.Contains("bluetooth"))
|
||||
return Symbol.Bluetooth;
|
||||
|
||||
// 默认使用 Globe 图标
|
||||
return Symbol.Globe;
|
||||
}
|
||||
|
||||
private static string FormatSpeed(long bytesPerSecond)
|
||||
{
|
||||
// 根据数值大小决定显示格式,始终保持3个字符宽度
|
||||
// 例如: 1.23, 12.3, 123
|
||||
return bytesPerSecond switch
|
||||
{
|
||||
>= 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
|
||||
>= 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
|
||||
>= 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
|
||||
_ => FormatWithThreeDigits(bytesPerSecond, "B")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化数字,始终保持3个有效数字的显示宽度
|
||||
/// </summary>
|
||||
private static string FormatWithThreeDigits(double value, string unit)
|
||||
{
|
||||
// 根据数值大小决定小数位数,确保总宽度一致
|
||||
// < 10: 显示两位小数 (如 1.23)
|
||||
// 10-99: 显示一位小数 (如 12.3)
|
||||
// >= 100: 显示整数 (如 123)
|
||||
string formatted = value switch
|
||||
{
|
||||
< 10 => $"{value:F2}",
|
||||
< 100 => $"{value:F1}",
|
||||
_ => $"{value:F0}"
|
||||
};
|
||||
|
||||
return formatted + unit;
|
||||
}
|
||||
|
||||
private void UpdateDisplayMode()
|
||||
{
|
||||
switch (_displayMode)
|
||||
{
|
||||
case "Upload":
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = false;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Download":
|
||||
UploadPanel.IsVisible = false;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Both":
|
||||
default:
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 根据单元格大小和字体大小设置调整字体大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.32,
|
||||
"Large" => 0.48,
|
||||
_ => 0.4 // Medium (default)
|
||||
};
|
||||
var fontSize = Math.Clamp(targetHeight * fontSizeMultiplier, 11, 22);
|
||||
UploadSpeedTextBlock.FontSize = fontSize;
|
||||
DownloadSpeedTextBlock.FontSize = fontSize;
|
||||
|
||||
// 调整图标大小
|
||||
if (NetworkTypeIcon != null)
|
||||
{
|
||||
NetworkTypeIcon.FontSize = Math.Clamp(targetHeight * 0.35, 10, 18);
|
||||
}
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 5;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_timer?.Stop();
|
||||
_networkTypeTimer?.Stop();
|
||||
}
|
||||
}
|
||||
@@ -187,12 +187,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null)
|
||||
{
|
||||
ApplySessionReportMode(snapshot, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
||||
}
|
||||
|
||||
|
||||
@@ -169,15 +169,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isReportViewing)
|
||||
{
|
||||
_studyAnalyticsService.ClearLastSessionReport();
|
||||
_transientMessage = null;
|
||||
RefreshVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
|
||||
var success = isRunning
|
||||
@@ -221,17 +212,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
_transientMessage = null;
|
||||
}
|
||||
|
||||
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isReportViewing)
|
||||
{
|
||||
PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report");
|
||||
SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm");
|
||||
ActionIcon.Kind = MaterialIconKind.Check;
|
||||
ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399"));
|
||||
ApplyTransientWarningTintIfNeeded(panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
if (isRunning)
|
||||
{
|
||||
|
||||
@@ -386,24 +386,39 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
{
|
||||
CloseDialog();
|
||||
|
||||
_loadingSessionId = sessionId;
|
||||
SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
|
||||
if (_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||
// 直接从服务获取报告数据
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var entry = FindHistoryEntry(snapshot.SessionHistory, sessionId);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to find session"));
|
||||
return;
|
||||
}
|
||||
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
|
||||
if (_currentSnapshot is not null)
|
||||
// 加载完整的报告数据
|
||||
if (!_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to load session"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取完整报告
|
||||
snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var report = snapshot.LastSessionReport;
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to load session data"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开报告详情窗口
|
||||
var window = new StudySessionReportWindow(report);
|
||||
window.Show();
|
||||
|
||||
// 清除选中状态,不保持联动模式
|
||||
_studyAnalyticsService.ClearLastSessionReport();
|
||||
}
|
||||
|
||||
private void ShowRenameDialog(string sessionId, string label)
|
||||
|
||||
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
@@ -0,0 +1,22 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="200"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.TextCapsuleWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,6"
|
||||
MaxWidth="400" />
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class TextCapsuleWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private string _text = string.Empty;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public TextCapsuleWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
if (_text == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_text = value;
|
||||
DebouncedUpdateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetText(string text)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparentBackground)
|
||||
{
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
private void DebouncedUpdateDisplay()
|
||||
{
|
||||
// 取消之前的延迟任务
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts?.Dispose();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
// 延迟 150ms 后更新显示,避免频繁输入时过度渲染
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(150, token);
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
UpdateDisplay();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消异常
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_text))
|
||||
{
|
||||
MarkdownViewer.Markdown = "*Empty*";
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Markdown 引擎渲染文本
|
||||
MarkdownViewer.Markdown = _text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 错误处理:显示错误信息而不是崩溃
|
||||
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 6;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -47,6 +48,10 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private int _pendingImageIndex = 0;
|
||||
|
||||
private string _lastLoadedSource = string.Empty;
|
||||
private bool _lastLoadedAutoRefreshEnabled = true;
|
||||
private int _lastLoadedRefreshIntervalMinutes = 30;
|
||||
|
||||
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
|
||||
private int _currentImageIndex = 0;
|
||||
|
||||
@@ -59,6 +64,8 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
private double _dragOffset;
|
||||
private int _lastSwipeDirection = 0;
|
||||
private bool _isInErrorState;
|
||||
private DateTime _lastClickTime = DateTime.MinValue;
|
||||
private const int DoubleClickThresholdMs = 300;
|
||||
|
||||
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
|
||||
{
|
||||
@@ -147,11 +154,39 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_placementId = context.PlacementId ?? string.Empty;
|
||||
_componentSettingsAccessor = context.ComponentSettingsAccessor;
|
||||
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
try
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
if (snapshot is not null && NeedsReinitialization(snapshot))
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
else if (_images.Count > 0)
|
||||
{
|
||||
_pendingImageIndex = snapshot?.ZhiJiaoHubCurrentImageIndex ?? 0;
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
UpdateIndicators();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +198,28 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
if (_isAttached)
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
|
||||
if (_isAttached && NeedsReinitialization(snapshot))
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +244,24 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
}
|
||||
}
|
||||
|
||||
private bool NeedsReinitialization(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var newSource = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
var newAutoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
var newRefreshIntervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
|
||||
return newSource != _lastLoadedSource ||
|
||||
newAutoRefreshEnabled != _lastLoadedAutoRefreshEnabled ||
|
||||
newRefreshIntervalMinutes != _lastLoadedRefreshIntervalMinutes;
|
||||
}
|
||||
|
||||
private void UpdateLastLoadedSettings(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
_lastLoadedSource = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
_lastLoadedAutoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
_lastLoadedRefreshIntervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
}
|
||||
|
||||
private void SaveCurrentImageIndex()
|
||||
{
|
||||
try
|
||||
@@ -259,6 +329,12 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is not null)
|
||||
{
|
||||
UpdateLastLoadedSettings(snapshot);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
UpdateIndicators();
|
||||
@@ -606,6 +682,19 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
return;
|
||||
}
|
||||
|
||||
var currentTime = DateTime.Now;
|
||||
var timeSinceLastClick = (currentTime - _lastClickTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastClick < DoubleClickThresholdMs)
|
||||
{
|
||||
_lastClickTime = DateTime.MinValue;
|
||||
_ = OpenCurrentImageAsync();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_lastClickTime = currentTime;
|
||||
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
@@ -616,6 +705,81 @@ public partial class ZhiJiaoHubWidget : UserControl,
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
private async Task OpenCurrentImageAsync()
|
||||
{
|
||||
if (_images.Count == 0 || _currentImageIndex < 0 || _currentImageIndex >= _images.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var imageItem = _images[_currentImageIndex];
|
||||
|
||||
try
|
||||
{
|
||||
string? filePath = null;
|
||||
|
||||
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
filePath = imageItem.LocalPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
filePath = await DownloadImageToTempAsync(imageItem);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
if (!_isDragging || _images.Count <= 1)
|
||||
|
||||
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.DesktopWidgetWindow"
|
||||
Title="Desktop Component"
|
||||
ShowInTaskbar="False"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
Topmost="False"
|
||||
SizeToContent="WidthAndHeight"
|
||||
TransparencyLevelHint="Transparent"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
CanResize="False">
|
||||
|
||||
<Border x:Name="ComponentContainer"
|
||||
Background="Transparent"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<!-- Component control will be injected here -->
|
||||
</Border>
|
||||
</Window>
|
||||
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。
|
||||
/// </summary>
|
||||
public partial class DesktopWidgetWindow : Window
|
||||
{
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
|
||||
public DesktopWidgetWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public DesktopWidgetWindow(Control componentContent) : this()
|
||||
{
|
||||
ComponentContainer.Child = componentContent;
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// 通过现有的置底服务将独立的小窗口锁定到底层
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
_bottomMostService.SendToBottom(this);
|
||||
|
||||
// 当窗口展示完毕且有了尺寸后,更新可交互区域,使得整个组件都能被点击
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnSizeChanged(SizeChangedEventArgs e)
|
||||
{
|
||||
base.OnSizeChanged(e);
|
||||
|
||||
if (OperatingSystem.IsWindows() && IsVisible)
|
||||
{
|
||||
UpdateInteractiveRegion();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInteractiveRegion()
|
||||
{
|
||||
// 既然是一个完全紧贴在组件身上的小窗,它的全部都是可交互的
|
||||
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
||||
{
|
||||
new(0, 0, Bounds.Width, Bounds.Height)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="240,*"
|
||||
ColumnSpacing="12"
|
||||
Margin="0">
|
||||
<!-- 分类列表 (左侧) -->
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBox x:Name="SearchBox"
|
||||
Watermark="搜索组件..."
|
||||
Margin="0,0,0,12"
|
||||
Classes="clear"
|
||||
Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="12,8">
|
||||
<TextBox.InnerLeftContent>
|
||||
<fi:SymbolIcon Symbol="Search" FontSize="16" Margin="10,0,0,0" Opacity="0.5" />
|
||||
</TextBox.InnerLeftContent>
|
||||
</TextBox>
|
||||
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionChanged="OnCategorySelectionChanged"
|
||||
ItemsSource="{Binding Categories}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Border Padding="10"
|
||||
Margin="0,0,0,6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:SymbolIcon Symbol="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
<TextBlock Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding Title}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件网格 (右侧) -->
|
||||
<Border Grid.Column="1"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="ComponentItemsControl"
|
||||
ItemsSource="{Binding Components}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryItemViewModel">
|
||||
<Border Width="240"
|
||||
Height="220"
|
||||
Margin="6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="10"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="*,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<!-- 预览区域 -->
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<Grid>
|
||||
<Image Source="{Binding PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding IsPreviewReady}" />
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<ProgressBar Width="96"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 失败状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewErrorMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件名称 -->
|
||||
<TextBlock Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<Button Grid.Row="2"
|
||||
HorizontalAlignment="Center"
|
||||
Padding="12,6"
|
||||
Tag="{Binding ComponentId}"
|
||||
Click="OnAddComponentClick">
|
||||
<TextBlock Text="添加到桌面" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = _viewModel;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
|
||||
LoadRegistry();
|
||||
LoadCategories();
|
||||
SearchBox.KeyUp += (s, e) => FilterComponents();
|
||||
|
||||
// 默认选择第一个分类
|
||||
if (_viewModel.Categories.Count > 0)
|
||||
{
|
||||
CategoryListBox.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadRegistry()
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
|
||||
_allDefinitions = _componentRegistry.GetAll()
|
||||
.Where(d => d.AllowDesktopPlacement)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void LoadCategories()
|
||||
{
|
||||
_viewModel.Categories.Clear();
|
||||
_viewModel.Components.Clear();
|
||||
|
||||
// 添加"全部组件"分类
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
"all",
|
||||
"全部组件",
|
||||
Symbol.Apps,
|
||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||
|
||||
var categoryMap = new Dictionary<string, (string Display, Symbol Icon)>
|
||||
{
|
||||
{ "clock", ("时钟", Symbol.Clock) },
|
||||
{ "date", ("日历", Symbol.CalendarDate) },
|
||||
{ "weather", ("天气", Symbol.WeatherSunny) },
|
||||
{ "board", ("画板", Symbol.Edit) },
|
||||
{ "media", ("媒体", Symbol.Play) },
|
||||
{ "info", ("资讯", Symbol.News) },
|
||||
{ "calculator", ("工具", Symbol.Calculator) },
|
||||
{ "study", ("学习", Symbol.Hourglass) },
|
||||
{ "file", ("文件", Symbol.Folder) }
|
||||
};
|
||||
|
||||
var usedCategories = _allDefinitions
|
||||
.Select(d => d.Category)
|
||||
.Distinct()
|
||||
.Where(c => !string.IsNullOrEmpty(c));
|
||||
|
||||
foreach (var cat in usedCategories)
|
||||
{
|
||||
if (categoryMap.TryGetValue(cat.ToLower(), out var info))
|
||||
{
|
||||
var categoryComponents = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName)
|
||||
.Select(d => CreateComponentItem(d))
|
||||
.ToArray();
|
||||
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
cat,
|
||||
info.Display,
|
||||
info.Icon,
|
||||
categoryComponents));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||
{
|
||||
var previewKey = ComponentPreviewKey.ForComponentType(
|
||||
definition.Id,
|
||||
definition.MinWidthCells,
|
||||
definition.MinHeightCells);
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
ComponentPreviewImageEntry? previewEntry = null;
|
||||
|
||||
if (mainWindow is not null)
|
||||
{
|
||||
previewEntry = mainWindow.GetPreviewEntry(previewKey);
|
||||
}
|
||||
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
definition.Id,
|
||||
definition.DisplayName,
|
||||
previewKey,
|
||||
"正在加载预览...",
|
||||
"预览不可用",
|
||||
previewEntry);
|
||||
|
||||
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
|
||||
{
|
||||
mainWindow.RequestDetachedLibraryPreview(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
foreach (var category in _viewModel.Categories)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(entry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
FilterComponents();
|
||||
}
|
||||
|
||||
private void FilterComponents()
|
||||
{
|
||||
var selectedCategory = (CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel)?.Id;
|
||||
var searchText = SearchBox.Text?.ToLower() ?? "";
|
||||
|
||||
var filtered = _allDefinitions.Where(d =>
|
||||
{
|
||||
var matchesCategory = selectedCategory == "all" || string.Equals(d.Category, selectedCategory, StringComparison.OrdinalIgnoreCase);
|
||||
var matchesSearch = string.IsNullOrEmpty(searchText) || d.DisplayName.ToLower().Contains(searchText) || d.Id.ToLower().Contains(searchText);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
_viewModel.Components.Clear();
|
||||
foreach (var def in filtered)
|
||||
{
|
||||
_viewModel.Components.Add(CreateComponentItem(def));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button button && button.Tag is string componentId)
|
||||
{
|
||||
AddComponentRequested?.Invoke(this, componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:LanMountainDesktop.Views"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||
Width="860" Height="620"
|
||||
MinWidth="600" MinHeight="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="Full"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Mica"
|
||||
Title="融合桌面组件库">
|
||||
|
||||
<Panel>
|
||||
<!-- 背景磨砂效果 -->
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
Opacity="0.85" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 自定义标题栏 -->
|
||||
<Border Background="Transparent"
|
||||
IsHitTestVisible="True"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="融合桌面组件库"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock Text="将精美组件放置在您的系统桌面上(负一屏)"
|
||||
Opacity="0.6"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="accent"
|
||||
Width="36" Height="36"
|
||||
Padding="0"
|
||||
CornerRadius="18"
|
||||
BorderThickness="0"
|
||||
Background="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"
|
||||
Click="OnCloseClick">
|
||||
<fi:SymbolIcon Symbol="Dismiss" FontSize="18" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件库控件 -->
|
||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Window>
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
|
||||
///
|
||||
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||
/// </summary>
|
||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private TransparentOverlayWindow? _overlayWindow;
|
||||
|
||||
// 与 TransparentOverlayWindow 保持一致的默认 cellSize
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.RegisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
/// <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 (componentWidth, componentHeight) = ResolveComponentSize(componentId);
|
||||
|
||||
// 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央
|
||||
var overlayBounds = _overlayWindow.Bounds;
|
||||
var centerX = overlayBounds.Width / 2.0 - componentWidth / 2.0;
|
||||
var centerY = overlayBounds.Height / 2.0 - componentHeight / 2.0;
|
||||
|
||||
// 边界保护:确保组件不超出屏幕边界
|
||||
centerX = Math.Max(0, Math.Min(centerX, overlayBounds.Width - componentWidth));
|
||||
centerY = Math.Max(0, Math.Min(centerY, overlayBounds.Height - componentHeight));
|
||||
|
||||
_overlayWindow.AddComponent(componentId, centerX, centerY, componentWidth, componentHeight);
|
||||
|
||||
AppLogger.Info("FusedDesktopLibrary",
|
||||
$"Added component '{componentId}' at center ({centerX:F0}, {centerY:F0}) size ({componentWidth}x{componentHeight}).");
|
||||
|
||||
// 关闭窗口
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize)
|
||||
/// </summary>
|
||||
private (double Width, double Height) ResolveComponentSize(string componentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
var registry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
if (registry.TryGetDefinition(componentId, out var definition))
|
||||
{
|
||||
var w = Math.Max(1, definition.MinWidthCells) * DefaultCellSize;
|
||||
var h = Math.Max(1, definition.MinHeightCells) * DefaultCellSize;
|
||||
return (w, h);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLibrary", $"Failed to resolve component size for '{componentId}'.", ex);
|
||||
}
|
||||
|
||||
// 回退为 2×2 格子的默认尺寸
|
||||
return (DefaultCellSize * 2, DefaultCellSize * 2);
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
LibraryControl.UpdatePreviewImage(entry);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public partial class MainWindow
|
||||
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||
private bool _componentLibraryPreviewWarmupStarted;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
|
||||
|
||||
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||
|
||||
@@ -519,6 +520,7 @@ public partial class MainWindow
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||
_fusedLibraryWindow?.UpdatePreviewImage(entry);
|
||||
|
||||
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
{
|
||||
@@ -597,4 +599,30 @@ public partial class MainWindow
|
||||
action: "DetachedLibraryRender",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
// FusedDesktop 支持
|
||||
|
||||
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
_fusedLibraryWindow = window;
|
||||
}
|
||||
|
||||
public void UnregisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
if (ReferenceEquals(_fusedLibraryWindow, window))
|
||||
{
|
||||
_fusedLibraryWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
|
||||
{
|
||||
RequestDetachedLibraryPreviewWarm(key);
|
||||
RequestDetachedLibraryPreviewRender(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,12 +364,407 @@ public partial class MainWindow
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
|
||||
_clockPosition = NormalizeClockPosition(snapshot.ClockPosition);
|
||||
_clockFontSize = NormalizeFontSize(snapshot.ClockFontSize);
|
||||
|
||||
if (ClockWidget is not null)
|
||||
_showTextCapsule = snapshot.ShowTextCapsule;
|
||||
_textCapsuleContent = snapshot.TextCapsuleContent ?? "**Hello** World!";
|
||||
_textCapsulePosition = NormalizeTextCapsulePosition(snapshot.TextCapsulePosition);
|
||||
_textCapsuleTransparentBackground = snapshot.TextCapsuleTransparentBackground;
|
||||
_textCapsuleFontSize = NormalizeFontSize(snapshot.TextCapsuleFontSize);
|
||||
|
||||
_showNetworkSpeed = snapshot.ShowNetworkSpeed;
|
||||
_networkSpeedPosition = NormalizeNetworkSpeedPosition(snapshot.NetworkSpeedPosition);
|
||||
_networkSpeedDisplayMode = NormalizeNetworkSpeedDisplayMode(snapshot.NetworkSpeedDisplayMode);
|
||||
_networkSpeedTransparentBackground = snapshot.NetworkSpeedTransparentBackground;
|
||||
_showNetworkTypeIcon = snapshot.ShowNetworkTypeIcon;
|
||||
_networkSpeedFontSize = NormalizeFontSize(snapshot.NetworkSpeedFontSize);
|
||||
|
||||
_statusBarShadowEnabled = snapshot.StatusBarShadowEnabled;
|
||||
_statusBarShadowColor = snapshot.StatusBarShadowColor ?? "#000000";
|
||||
_statusBarShadowOpacity = snapshot.StatusBarShadowOpacity;
|
||||
|
||||
ApplyClockSettingsToAllWidgets();
|
||||
ApplyTextCapsuleSettingsToAllWidgets();
|
||||
ApplyNetworkSpeedSettingsToAllWidgets();
|
||||
ApplyStatusBarShadow();
|
||||
}
|
||||
|
||||
private void ApplyClockSettingsToAllWidgets()
|
||||
{
|
||||
if (ClockWidgetLeft is not null)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetLeft.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetLeft.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetLeft.SetFontSize(_clockFontSize);
|
||||
}
|
||||
if (ClockWidgetCenter is not null)
|
||||
{
|
||||
ClockWidgetCenter.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetCenter.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetCenter.SetFontSize(_clockFontSize);
|
||||
}
|
||||
if (ClockWidgetRight is not null)
|
||||
{
|
||||
ClockWidgetRight.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidgetRight.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
ClockWidgetRight.SetFontSize(_clockFontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeClockPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) => "Right",
|
||||
_ => "Left"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeFontSize(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Small", StringComparison.OrdinalIgnoreCase) => "Small",
|
||||
_ when string.Equals(value, "Large", StringComparison.OrdinalIgnoreCase) => "Large",
|
||||
_ => "Medium"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyTextCapsuleSettingsToAllWidgets()
|
||||
{
|
||||
if (TextCapsuleWidgetLeft is not null)
|
||||
{
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
if (TextCapsuleWidgetCenter is not null)
|
||||
{
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
if (TextCapsuleWidgetRight is not null)
|
||||
{
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTextCapsulePosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyNetworkSpeedSettingsToAllWidgets()
|
||||
{
|
||||
if (NetworkSpeedWidgetLeft is not null)
|
||||
{
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetLeft.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
if (NetworkSpeedWidgetCenter is not null)
|
||||
{
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetCenter.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
if (NetworkSpeedWidgetRight is not null)
|
||||
{
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetShowNetworkTypeIcon(_showNetworkTypeIcon);
|
||||
NetworkSpeedWidgetRight.SetFontSize(_networkSpeedFontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedDisplayMode(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Upload", StringComparison.OrdinalIgnoreCase) => "Upload",
|
||||
_ when string.Equals(value, "Download", StringComparison.OrdinalIgnoreCase) => "Download",
|
||||
_ => "Both"
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyStatusBarShadow()
|
||||
{
|
||||
if (StatusBarOverlay is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_statusBarShadowEnabled)
|
||||
{
|
||||
if (Color.TryParse(_statusBarShadowColor, out var shadowColor))
|
||||
{
|
||||
var opacity = Math.Clamp(_statusBarShadowOpacity, 0, 1);
|
||||
|
||||
StatusBarOverlay.IsVisible = true;
|
||||
|
||||
var gradientBrush = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative)
|
||||
};
|
||||
|
||||
var alpha1 = (byte)(shadowColor.A * opacity * 0.8);
|
||||
var alpha2 = (byte)(shadowColor.A * opacity * 0.4);
|
||||
var color1 = Color.FromArgb(alpha1, shadowColor.R, shadowColor.G, shadowColor.B);
|
||||
var color2 = Color.FromArgb(alpha2, shadowColor.R, shadowColor.G, shadowColor.B);
|
||||
|
||||
gradientBrush.GradientStops.Add(new GradientStop(color1, 0.0));
|
||||
gradientBrush.GradientStops.Add(new GradientStop(color2, 0.3));
|
||||
gradientBrush.GradientStops.Add(new GradientStop(Colors.Transparent, 1.0));
|
||||
|
||||
StatusBarOverlay.Background = gradientBrush;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusBarOverlay.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测状态栏组件是否会发生碰撞
|
||||
/// </summary>
|
||||
private bool WouldComponentsCollide()
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
// 获取各区域当前占用的宽度
|
||||
var leftWidth = GetLeftPanelOccupiedWidth();
|
||||
var centerWidth = GetCenterPanelOccupiedWidth();
|
||||
var rightWidth = GetRightPanelOccupiedWidth();
|
||||
|
||||
// 获取状态栏总宽度
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return false;
|
||||
|
||||
// 计算中间区域的实际位置
|
||||
// 左列是 *, 中列是 Auto, 右列是 *
|
||||
// 中间区域居中显示
|
||||
var centerLeft = (totalWidth - centerWidth) / 2;
|
||||
var centerRight = centerLeft + centerWidth;
|
||||
|
||||
// 安全间距(像素)
|
||||
const double safetyMargin = 20;
|
||||
|
||||
// 检测左侧组件是否会与中间区域碰撞
|
||||
// 左侧组件右边界 = leftWidth
|
||||
// 中间区域左边界 = centerLeft
|
||||
if (leftWidth + safetyMargin > centerLeft)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检测右侧组件是否会与中间区域碰撞
|
||||
// 右侧组件左边界 = totalWidth - rightWidth
|
||||
// 中间区域右边界 = centerRight
|
||||
if (totalWidth - rightWidth - safetyMargin < centerRight)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检测中间区域是否会与左右两侧碰撞(中间区域过宽)
|
||||
if (centerLeft < leftWidth + safetyMargin ||
|
||||
centerRight > totalWidth - rightWidth - safetyMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取左侧面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetLeftPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusLeftPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusLeftPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusLeftPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取中间面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetCenterPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusCenterPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusCenterPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusCenterPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取右侧面板占用的宽度(包括间距)
|
||||
/// </summary>
|
||||
private double GetRightPanelOccupiedWidth()
|
||||
{
|
||||
if (TopStatusRightPanel is null)
|
||||
return 0;
|
||||
|
||||
var spacing = TopStatusRightPanel.Spacing;
|
||||
var width = 0.0;
|
||||
var visibleCount = 0;
|
||||
|
||||
foreach (var child in TopStatusRightPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
{
|
||||
width += control.Bounds.Width;
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加间距
|
||||
if (visibleCount > 1)
|
||||
{
|
||||
width += spacing * (visibleCount - 1);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否可以在指定位置添加组件
|
||||
/// </summary>
|
||||
private bool CanAddComponentAtPosition(string position)
|
||||
{
|
||||
// 先临时显示组件以计算宽度
|
||||
var wouldCollide = WouldComponentsCollide();
|
||||
if (!wouldCollide)
|
||||
return true;
|
||||
|
||||
// 如果会发生碰撞,检查是否是因为目标位置导致的
|
||||
// 获取当前各区域宽度
|
||||
var leftWidth = GetLeftPanelOccupiedWidth();
|
||||
var centerWidth = GetCenterPanelOccupiedWidth();
|
||||
var rightWidth = GetRightPanelOccupiedWidth();
|
||||
|
||||
// 估算新组件的宽度(基于当前单元格大小)
|
||||
var estimatedNewComponentWidth = _currentDesktopCellSize > 0 ? _currentDesktopCellSize * 2 : 120;
|
||||
|
||||
// 根据目标位置检查添加后是否会碰撞
|
||||
return position switch
|
||||
{
|
||||
"Left" => CanAddToLeft(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
"Center" => CanAddToCenter(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
"Right" => CanAddToRight(leftWidth, centerWidth, rightWidth, estimatedNewComponentWidth),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CanAddToLeft(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newLeftWidth = leftWidth + newWidth + TopStatusLeftPanel?.Spacing ?? 6;
|
||||
var centerLeft = (totalWidth - centerWidth) / 2;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return newLeftWidth + safetyMargin <= centerLeft;
|
||||
}
|
||||
|
||||
private bool CanAddToCenter(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newCenterWidth = centerWidth + newWidth + TopStatusCenterPanel?.Spacing ?? 6;
|
||||
var centerLeft = (totalWidth - newCenterWidth) / 2;
|
||||
var centerRight = centerLeft + newCenterWidth;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return centerLeft >= leftWidth + safetyMargin &&
|
||||
centerRight <= totalWidth - rightWidth - safetyMargin;
|
||||
}
|
||||
|
||||
private bool CanAddToRight(double leftWidth, double centerWidth, double rightWidth, double newWidth)
|
||||
{
|
||||
if (TopStatusBarHost is null)
|
||||
return false;
|
||||
|
||||
var totalWidth = TopStatusBarHost.Bounds.Width;
|
||||
if (totalWidth <= 0)
|
||||
return true;
|
||||
|
||||
var newRightWidth = rightWidth + newWidth + TopStatusRightPanel?.Spacing ?? 6;
|
||||
var centerRight = (totalWidth + centerWidth) / 2;
|
||||
|
||||
const double safetyMargin = 20;
|
||||
return totalWidth - newRightWidth - safetyMargin >= centerRight;
|
||||
}
|
||||
|
||||
private void ApplyTopStatusComponentVisibility()
|
||||
@@ -377,16 +772,168 @@ public partial class MainWindow
|
||||
var showClock = _topStatusComponentIds.Contains(BuiltInComponentIds.Clock);
|
||||
var hasVisibleTopStatusComponent = false;
|
||||
|
||||
if (ClockWidget is not null)
|
||||
// 先隐藏所有时钟控件
|
||||
if (ClockWidgetLeft is not null)
|
||||
ClockWidgetLeft.IsVisible = false;
|
||||
if (ClockWidgetCenter is not null)
|
||||
ClockWidgetCenter.IsVisible = false;
|
||||
if (ClockWidgetRight is not null)
|
||||
ClockWidgetRight.IsVisible = false;
|
||||
|
||||
// 先隐藏所有文字胶囊控件
|
||||
if (TextCapsuleWidgetLeft is not null)
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
if (TextCapsuleWidgetCenter is not null)
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
if (TextCapsuleWidgetRight is not null)
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
|
||||
// 先隐藏所有网速控件
|
||||
if (NetworkSpeedWidgetLeft is not null)
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
if (NetworkSpeedWidgetCenter is not null)
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
if (NetworkSpeedWidgetRight is not null)
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
|
||||
// 根据位置设置显示对应的时钟控件(带碰撞检测)
|
||||
if (showClock)
|
||||
{
|
||||
ClockWidget.IsVisible = showClock;
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
if (showClock)
|
||||
var targetPosition = _clockPosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
var columnSpan = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? 2 : 3;
|
||||
Grid.SetColumnSpan(ClockWidget, columnSpan);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
var targetClock = targetPosition switch
|
||||
{
|
||||
"Center" => ClockWidgetCenter,
|
||||
"Right" => ClockWidgetRight,
|
||||
_ => ClockWidgetLeft
|
||||
};
|
||||
|
||||
if (targetClock is not null)
|
||||
{
|
||||
targetClock.IsVisible = true;
|
||||
targetClock.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
targetClock.SetDisplayFormat(_clockDisplayFormat);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetClock = alternativePosition switch
|
||||
{
|
||||
"Center" => ClockWidgetCenter,
|
||||
"Right" => ClockWidgetRight,
|
||||
_ => ClockWidgetLeft
|
||||
};
|
||||
|
||||
if (targetClock is not null)
|
||||
{
|
||||
targetClock.IsVisible = true;
|
||||
targetClock.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
targetClock.SetDisplayFormat(_clockDisplayFormat);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据位置设置显示对应的文字胶囊控件(带碰撞检测)
|
||||
if (_showTextCapsule)
|
||||
{
|
||||
var targetPosition = _textCapsulePosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
var targetTextCapsule = targetPosition switch
|
||||
{
|
||||
"Left" => TextCapsuleWidgetLeft,
|
||||
"Center" => TextCapsuleWidgetCenter,
|
||||
_ => TextCapsuleWidgetRight
|
||||
};
|
||||
|
||||
if (targetTextCapsule is not null)
|
||||
{
|
||||
targetTextCapsule.IsVisible = true;
|
||||
targetTextCapsule.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
targetTextCapsule.SetText(_textCapsuleContent);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetTextCapsule = alternativePosition switch
|
||||
{
|
||||
"Left" => TextCapsuleWidgetLeft,
|
||||
"Center" => TextCapsuleWidgetCenter,
|
||||
_ => TextCapsuleWidgetRight
|
||||
};
|
||||
|
||||
if (targetTextCapsule is not null)
|
||||
{
|
||||
targetTextCapsule.IsVisible = true;
|
||||
targetTextCapsule.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
targetTextCapsule.SetText(_textCapsuleContent);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据位置设置显示对应的网速控件(带碰撞检测)
|
||||
if (_showNetworkSpeed)
|
||||
{
|
||||
var targetPosition = _networkSpeedPosition;
|
||||
var canAdd = CanAddComponentAtPosition(targetPosition);
|
||||
|
||||
if (canAdd)
|
||||
{
|
||||
var targetNetworkSpeed = targetPosition switch
|
||||
{
|
||||
"Left" => NetworkSpeedWidgetLeft,
|
||||
"Center" => NetworkSpeedWidgetCenter,
|
||||
_ => NetworkSpeedWidgetRight
|
||||
};
|
||||
|
||||
if (targetNetworkSpeed is not null)
|
||||
{
|
||||
targetNetworkSpeed.IsVisible = true;
|
||||
targetNetworkSpeed.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
targetNetworkSpeed.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果目标位置无法添加,尝试其他位置
|
||||
var alternativePosition = FindAlternativePosition(targetPosition);
|
||||
if (alternativePosition is not null)
|
||||
{
|
||||
var targetNetworkSpeed = alternativePosition switch
|
||||
{
|
||||
"Left" => NetworkSpeedWidgetLeft,
|
||||
"Center" => NetworkSpeedWidgetCenter,
|
||||
_ => NetworkSpeedWidgetRight
|
||||
};
|
||||
|
||||
if (targetNetworkSpeed is not null)
|
||||
{
|
||||
targetNetworkSpeed.IsVisible = true;
|
||||
targetNetworkSpeed.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
targetNetworkSpeed.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
hasVisibleTopStatusComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +941,244 @@ public partial class MainWindow
|
||||
{
|
||||
TopStatusBarHost.IsVisible = hasVisibleTopStatusComponent;
|
||||
}
|
||||
|
||||
// 延迟检查碰撞并调整
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(50);
|
||||
AdjustComponentsIfColliding();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当组件发生碰撞时,自动调整位置
|
||||
/// </summary>
|
||||
private void AdjustComponentsIfColliding()
|
||||
{
|
||||
if (!WouldComponentsCollide())
|
||||
return;
|
||||
|
||||
// 获取当前可见的组件
|
||||
var leftComponents = GetVisibleLeftComponents();
|
||||
var centerComponents = GetVisibleCenterComponents();
|
||||
var rightComponents = GetVisibleRightComponents();
|
||||
|
||||
// 优先保留时钟,调整文字胶囊位置
|
||||
if (TextCapsuleWidgetLeft?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将左侧文字胶囊移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
TextCapsuleWidgetCenter!.IsVisible = true;
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
TextCapsuleWidgetRight!.IsVisible = true;
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetLeft.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (TextCapsuleWidgetRight?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将右侧文字胶囊移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
TextCapsuleWidgetCenter!.IsVisible = true;
|
||||
TextCapsuleWidgetCenter.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetCenter.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到左侧
|
||||
else if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
TextCapsuleWidgetLeft!.IsVisible = true;
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetRight.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (TextCapsuleWidgetCenter?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将中间文字胶囊移到左侧
|
||||
if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
TextCapsuleWidgetLeft!.IsVisible = true;
|
||||
TextCapsuleWidgetLeft.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetLeft.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
TextCapsuleWidgetRight!.IsVisible = true;
|
||||
TextCapsuleWidgetRight.SetTransparentBackground(_textCapsuleTransparentBackground);
|
||||
TextCapsuleWidgetRight.SetText(_textCapsuleContent);
|
||||
}
|
||||
// 如果都无法添加,则隐藏文字胶囊
|
||||
else
|
||||
{
|
||||
TextCapsuleWidgetCenter.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 调整网速组件位置(优先级:时钟 > 文字胶囊 > 网速)
|
||||
if (NetworkSpeedWidgetLeft?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将左侧网速移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
NetworkSpeedWidgetCenter!.IsVisible = true;
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
NetworkSpeedWidgetRight!.IsVisible = true;
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetLeft.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (NetworkSpeedWidgetRight?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将右侧网速移到中间
|
||||
if (CanAddComponentAtPosition("Center"))
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
NetworkSpeedWidgetCenter!.IsVisible = true;
|
||||
NetworkSpeedWidgetCenter.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetCenter.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到左侧
|
||||
else if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
NetworkSpeedWidgetLeft!.IsVisible = true;
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetRight.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (NetworkSpeedWidgetCenter?.IsVisible == true && WouldComponentsCollide())
|
||||
{
|
||||
// 尝试将中间网速移到左侧
|
||||
if (CanAddComponentAtPosition("Left"))
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
NetworkSpeedWidgetLeft!.IsVisible = true;
|
||||
NetworkSpeedWidgetLeft.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetLeft.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 或者移到右侧
|
||||
else if (CanAddComponentAtPosition("Right"))
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
NetworkSpeedWidgetRight!.IsVisible = true;
|
||||
NetworkSpeedWidgetRight.SetTransparentBackground(_networkSpeedTransparentBackground);
|
||||
NetworkSpeedWidgetRight.SetDisplayMode(_networkSpeedDisplayMode);
|
||||
}
|
||||
// 如果都无法添加,则隐藏网速
|
||||
else
|
||||
{
|
||||
NetworkSpeedWidgetCenter.IsVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找可用的替代位置
|
||||
/// </summary>
|
||||
private string? FindAlternativePosition(string originalPosition)
|
||||
{
|
||||
// 尝试所有可能的位置
|
||||
var positions = new[] { "Left", "Center", "Right" };
|
||||
foreach (var position in positions)
|
||||
{
|
||||
if (position != originalPosition && CanAddComponentAtPosition(position))
|
||||
{
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取左侧可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleLeftComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusLeftPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusLeftPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取中间可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleCenterComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusCenterPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusCenterPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取右侧可见组件列表
|
||||
/// </summary>
|
||||
private List<Control> GetVisibleRightComponents()
|
||||
{
|
||||
var result = new List<Control>();
|
||||
if (TopStatusRightPanel is null) return result;
|
||||
|
||||
foreach (var child in TopStatusRightPanel.Children)
|
||||
{
|
||||
if (child is Control control && control.IsVisible)
|
||||
result.Add(control);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TaskbarContext GetCurrentTaskbarContext()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -16,6 +16,7 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
@@ -73,6 +74,10 @@ public partial class MainWindow
|
||||
private int? _desktopPageContextSettlingTargetIndex;
|
||||
private int _desktopPageContextSettleRevision;
|
||||
|
||||
// 三指滑动/右键拖动相关
|
||||
private bool _isThreeFingerOrRightDragSwipeActive;
|
||||
private readonly HashSet<int> _activePointerIds = [];
|
||||
|
||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||
|
||||
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
||||
@@ -264,12 +269,6 @@ public partial class MainWindow
|
||||
LauncherPagePanel.MaxWidth = pageWidth - launcherMargin * 2;
|
||||
LauncherPagePanel.MaxHeight = pageHeight - launcherMargin * 2;
|
||||
|
||||
if (LauncherFolderPanel is not null)
|
||||
{
|
||||
LauncherFolderPanel.MaxWidth = Math.Max(320, pageWidth - 96);
|
||||
LauncherFolderPanel.MaxHeight = Math.Max(220, pageHeight - 96);
|
||||
}
|
||||
|
||||
// 更新启动台图标布局
|
||||
UpdateLauncherTileLayout();
|
||||
|
||||
@@ -326,19 +325,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 同样更新文件夹视图的图标尺寸
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Width = availableWidth;
|
||||
foreach (var child in LauncherFolderTilePanel.Children)
|
||||
{
|
||||
if (child is Button button)
|
||||
{
|
||||
button.Width = tileWidth;
|
||||
button.Height = tileHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampSurfaceIndex()
|
||||
@@ -515,6 +501,49 @@ public partial class MainWindow
|
||||
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))
|
||||
{
|
||||
return;
|
||||
@@ -525,7 +554,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||
if (!isLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -582,8 +611,12 @@ public partial class MainWindow
|
||||
|
||||
foreach (var node in button.GetSelfAndVisualAncestors())
|
||||
{
|
||||
if (node is WrapPanel panel &&
|
||||
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
|
||||
if (node is WrapPanel panel && panel.Name == "LauncherRootTilePanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node is Grid grid && grid.Name == "LauncherFolderGridPanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -671,8 +704,7 @@ public partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer" ||
|
||||
scrollViewer.Name == "LauncherFolderScrollViewer";
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer";
|
||||
}
|
||||
|
||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||
@@ -776,6 +808,10 @@ public partial class MainWindow
|
||||
|
||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
// 清理活跃指针
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||
{
|
||||
e.Handled = true;
|
||||
@@ -784,6 +820,10 @@ public partial class MainWindow
|
||||
|
||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
// 清理活跃指针
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
@@ -802,6 +842,8 @@ public partial class MainWindow
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
@@ -819,8 +861,12 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||
var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive;
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
pointer.Capture(null);
|
||||
@@ -849,6 +895,23 @@ public partial class MainWindow
|
||||
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
||||
var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold;
|
||||
|
||||
// 检查:三指/右键拖动 && 在第一页 && 向右滑动
|
||||
if (wasThreeFingerOrRightDrag &&
|
||||
_currentDesktopSurfaceIndex == 0 &&
|
||||
deltaX > 0 && // 向右滑动
|
||||
(hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
// 最小化到 Windows 桌面
|
||||
if (Application.Current is App app)
|
||||
{
|
||||
app.HideMainWindowToTray(this, "ThreeFingerOrRightDragSwipe");
|
||||
}
|
||||
|
||||
ApplyDesktopSurfaceOffset();
|
||||
_desktopSwipeVelocityX = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
projectedTargetIndex = Math.Clamp(
|
||||
@@ -1482,18 +1545,17 @@ public partial class MainWindow
|
||||
LauncherFolderOverlay.IsVisible = false;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
if (LauncherFolderGridPanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderLauncherFolderFromStack()
|
||||
{
|
||||
if (LauncherFolderOverlay is null ||
|
||||
LauncherFolderTilePanel is null ||
|
||||
LauncherFolderTitleTextBlock is null ||
|
||||
LauncherFolderBackButton is null)
|
||||
LauncherFolderGridPanel is null ||
|
||||
LauncherFolderTitleTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1508,38 +1570,230 @@ public partial class MainWindow
|
||||
var folder = _launcherFolderStack.Peek();
|
||||
LauncherFolderOverlay.IsVisible = true;
|
||||
LauncherFolderTitleTextBlock.Text = folder.Name;
|
||||
LauncherFolderBackButton.IsVisible = _launcherFolderStack.Count > 1;
|
||||
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
foreach (var subFolder in folder.Folders)
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
|
||||
const int maxCols = 4;
|
||||
const int maxRows = 3;
|
||||
const int maxItems = maxCols * maxRows;
|
||||
|
||||
var visibleFolders = folder.Folders.Where(IsLauncherFolderVisible).ToList();
|
||||
var visibleApps = folder.Apps.Where(IsLauncherAppVisible).ToList();
|
||||
|
||||
if (visibleFolders.Count == 0 && visibleApps.Count == 0)
|
||||
{
|
||||
if (!IsLauncherFolderVisible(subFolder))
|
||||
LauncherFolderGridPanel.Children.Add(CreateLauncherFolderGridHintCell(
|
||||
L("launcher.empty_folder", "This folder is empty.")));
|
||||
return;
|
||||
}
|
||||
|
||||
var allItems = new List<(StartMenuFolderNode? Folder, StartMenuAppEntry? App)>();
|
||||
foreach (var f in visibleFolders)
|
||||
{
|
||||
allItems.Add((f, null));
|
||||
}
|
||||
foreach (var a in visibleApps)
|
||||
{
|
||||
allItems.Add((null, a));
|
||||
}
|
||||
|
||||
var displayCount = Math.Min(allItems.Count, maxItems);
|
||||
for (var i = 0; i < displayCount; i++)
|
||||
{
|
||||
var col = i % maxCols;
|
||||
var row = i / maxCols;
|
||||
var (itemFolder, itemApp) = allItems[i];
|
||||
|
||||
Control cell;
|
||||
if (itemFolder is not null)
|
||||
{
|
||||
var capturedFolder = itemFolder;
|
||||
cell = CreateLauncherFolderGridTile(itemFolder.Name, GetLauncherFolderIconBitmap(), () => OpenLauncherFolder(capturedFolder));
|
||||
}
|
||||
else if (itemApp is not null)
|
||||
{
|
||||
var capturedApp = itemApp;
|
||||
cell = CreateLauncherFolderGridTile(capturedApp, () => LaunchStartMenuEntry(capturedApp));
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
LauncherFolderGridPanel.Children.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
{
|
||||
if (!IsLauncherAppVisible(app))
|
||||
private Button CreateLauncherFolderGridTile(StartMenuAppEntry app, Action clickAction)
|
||||
{
|
||||
var iconBitmap = GetLauncherIconBitmap(app);
|
||||
var monogram = BuildMonogram(app.DisplayName);
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
continue;
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = app.DisplayName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel.Children.Count == 0)
|
||||
private Button CreateLauncherFolderGridTile(string folderName, Bitmap? iconBitmap, Action clickAction)
|
||||
{
|
||||
var monogram = "DIR";
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherHintTile(
|
||||
L("launcher.empty_folder", "This folder is empty."),
|
||||
string.Empty));
|
||||
}
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = folderName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
// 在图标渲染完成后,应用布局计算
|
||||
Dispatcher.UIThread.Post(() => UpdateLauncherTileLayout(), DispatcherPriority.Background);
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message)
|
||||
{
|
||||
return CreateLauncherFolderGridHintCell(message, 0, 0);
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message, int col, int row)
|
||||
{
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Opacity = 0.6
|
||||
};
|
||||
|
||||
var cell = new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Child = textBlock
|
||||
};
|
||||
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static string BuildMonogram(string text)
|
||||
@@ -1610,18 +1864,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLauncherFolderBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_launcherFolderStack.Count <= 1)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_launcherFolderStack.Pop();
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
private void OnLauncherFolderOverlayPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (LauncherFolderPanel is null)
|
||||
@@ -1642,11 +1884,6 @@ public partial class MainWindow
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLauncherFolderCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
|
||||
private void DisposeLauncherResources()
|
||||
{
|
||||
foreach (var bitmap in _launcherIconCache.Values)
|
||||
|
||||
@@ -38,6 +38,12 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// 组件实例范围的设置变更不应触发整个桌面重新加载(比如翻页保存图片索引)
|
||||
if (e.Scope == SettingsScope.ComponentInstance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
||||
{
|
||||
var changedKeys = e.ChangedKeys.ToArray();
|
||||
@@ -644,8 +650,24 @@ public partial class MainWindow
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||
ClockPosition = _clockPosition,
|
||||
ClockFontSize = _clockFontSize,
|
||||
ShowTextCapsule = _showTextCapsule,
|
||||
TextCapsuleContent = _textCapsuleContent,
|
||||
TextCapsulePosition = _textCapsulePosition,
|
||||
TextCapsuleTransparentBackground = _textCapsuleTransparentBackground,
|
||||
TextCapsuleFontSize = _textCapsuleFontSize,
|
||||
ShowNetworkSpeed = _showNetworkSpeed,
|
||||
NetworkSpeedPosition = _networkSpeedPosition,
|
||||
NetworkSpeedDisplayMode = _networkSpeedDisplayMode,
|
||||
NetworkSpeedTransparentBackground = _networkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon = _showNetworkTypeIcon,
|
||||
NetworkSpeedFontSize = _networkSpeedFontSize,
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||
StatusBarShadowEnabled = _statusBarShadowEnabled,
|
||||
StatusBarShadowColor = _statusBarShadowColor,
|
||||
StatusBarShadowOpacity = _statusBarShadowOpacity,
|
||||
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
|
||||
StudyFrameMs = existingSnapshot.StudyFrameMs,
|
||||
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,
|
||||
|
||||
@@ -189,50 +189,21 @@
|
||||
Classes="surface-solid-strong"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="52"
|
||||
MaxWidth="760"
|
||||
MaxHeight="520"
|
||||
CornerRadius="36"
|
||||
Padding="14">
|
||||
<Border.RenderTransform>
|
||||
<TranslateTransform Y="42" />
|
||||
</Border.RenderTransform>
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="LauncherFolderBackButton"
|
||||
Grid.Column="0"
|
||||
Width="38"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
Click="OnLauncherFolderBackClick">
|
||||
<fi:FluentIcon Icon="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
<TextBlock x:Name="LauncherFolderTitleTextBlock"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold" />
|
||||
<Button x:Name="LauncherFolderCloseButton"
|
||||
Grid.Column="2"
|
||||
Width="38"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
Click="OnLauncherFolderCloseClick">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
Width="464"
|
||||
Height="384"
|
||||
CornerRadius="24"
|
||||
Padding="16,14,16,12">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBlock x:Name="LauncherFolderTitleTextBlock"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<ScrollViewer x:Name="LauncherFolderScrollViewer"
|
||||
Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<WrapPanel x:Name="LauncherFolderTilePanel"
|
||||
Orientation="Horizontal" />
|
||||
</ScrollViewer>
|
||||
<Grid x:Name="LauncherFolderGridPanel"
|
||||
Grid.Row="1"
|
||||
ColumnDefinitions="*,*,*,*"
|
||||
RowDefinitions="*,*,*" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -255,20 +226,84 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 状态栏阴影层 - macOS 风格的完整阴影带 -->
|
||||
<Border x:Name="StatusBarOverlay"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="1"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
Height="24"
|
||||
ZIndex="0"
|
||||
Margin="0,0,0,-24">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Color="#CC000000" Offset="0.0" />
|
||||
<GradientStop Color="#66000000" Offset="0.3" />
|
||||
<GradientStop Color="#00000000" Offset="1.0" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="TopStatusBarHost"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="1"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="4">
|
||||
<StackPanel x:Name="TopStatusComponentsPanel"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<comp:ClockWidget x:Name="ClockWidget"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
Padding="4"
|
||||
ZIndex="2">
|
||||
<Grid ColumnDefinitions="*,Auto,*">
|
||||
<!-- 左侧状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusLeftPanel"
|
||||
Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Left">
|
||||
<comp:ClockWidget x:Name="ClockWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetLeft"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
<!-- 中间状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusCenterPanel"
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Center">
|
||||
<comp:ClockWidget x:Name="ClockWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetCenter"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
<!-- 右侧状态栏组件 -->
|
||||
<StackPanel x:Name="TopStatusRightPanel"
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Right">
|
||||
<comp:ClockWidget x:Name="ClockWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:TextCapsuleWidget x:Name="TextCapsuleWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
<comp:NetworkSpeedWidget x:Name="NetworkSpeedWidgetRight"
|
||||
IsVisible="False"
|
||||
Margin="0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BottomTaskbarContainer"
|
||||
|
||||
@@ -135,6 +135,22 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private bool _statusBarClockTransparentBackground;
|
||||
private string _clockPosition = "Left"; // Left, Center, Right
|
||||
private string _clockFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _showTextCapsule;
|
||||
private string _textCapsuleContent = "**Hello** World!";
|
||||
private string _textCapsulePosition = "Right"; // Left, Center, Right
|
||||
private bool _textCapsuleTransparentBackground;
|
||||
private string _textCapsuleFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _showNetworkSpeed;
|
||||
private string _networkSpeedPosition = "Right"; // Left, Center, Right
|
||||
private string _networkSpeedDisplayMode = "Both"; // Upload, Download, Both
|
||||
private bool _networkSpeedTransparentBackground;
|
||||
private bool _showNetworkTypeIcon;
|
||||
private string _networkSpeedFontSize = "Medium"; // Small, Medium, Large
|
||||
private bool _statusBarShadowEnabled;
|
||||
private string _statusBarShadowColor = "#000000";
|
||||
private double _statusBarShadowOpacity = 0.3;
|
||||
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
||||
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
||||
private string _languageCode = "zh-CN";
|
||||
@@ -238,9 +254,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
TaskbarProfileButton.IsEnabled = false;
|
||||
TaskbarProfilePopup.IsOpen = false;
|
||||
|
||||
ClockWidget.IsVisible = true;
|
||||
ClockWidget.SetDisplayFormat(ClockDisplayFormat.HourMinute);
|
||||
ClockWidget.SetTransparentBackground(false);
|
||||
ClockWidgetLeft.IsVisible = true;
|
||||
ClockWidgetLeft.SetDisplayFormat(ClockDisplayFormat.HourMinute);
|
||||
ClockWidgetLeft.SetTransparentBackground(false);
|
||||
|
||||
ConfigureDesignTimeDesktopGrid();
|
||||
PopulateDesignTimeDesktopSurface();
|
||||
@@ -288,7 +304,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
DesktopPagesHost.ColumnDefinitions.Clear();
|
||||
DesktopPagesHost.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
|
||||
|
||||
ClockWidget.ApplyCellSize(72);
|
||||
ClockWidgetLeft.ApplyCellSize(72);
|
||||
}
|
||||
|
||||
private void PopulateDesignTimeDesktopSurface()
|
||||
@@ -481,7 +497,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
RebuildDesktopGrid();
|
||||
LoadLauncherEntriesAsync();
|
||||
InitializeTimeZoneSettings();
|
||||
ClockWidget.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetLeft.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetCenter.SetTimeZoneService(_timeZoneService);
|
||||
ClockWidgetRight.SetTimeZoneService(_timeZoneService);
|
||||
|
||||
_suppressSettingsPersistence = false;
|
||||
PersistSettings();
|
||||
@@ -621,7 +639,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
|
||||
private void ApplyDesktopStatusBarComponentSpacing()
|
||||
{
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusComponentsPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusLeftPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusCenterPanel, _currentDesktopCellSize);
|
||||
ApplyStatusBarComponentSpacingForPanel(TopStatusRightPanel, _currentDesktopCellSize);
|
||||
}
|
||||
|
||||
private int ResolveStatusBarSpacingPercent()
|
||||
@@ -697,8 +717,26 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
ApplyUnifiedMainRectangleChrome();
|
||||
BottomTaskbarContainer.Padding = new Thickness(Math.Clamp(taskbarCellHeight * 0.16, 6, 14));
|
||||
|
||||
ClockWidget.Margin = new Thickness(0);
|
||||
ClockWidget.ApplyCellSize(cellSize);
|
||||
ClockWidgetLeft.Margin = new Thickness(0);
|
||||
ClockWidgetLeft.ApplyCellSize(cellSize);
|
||||
ClockWidgetCenter.Margin = new Thickness(0);
|
||||
ClockWidgetCenter.ApplyCellSize(cellSize);
|
||||
ClockWidgetRight.Margin = new Thickness(0);
|
||||
ClockWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
TextCapsuleWidgetLeft.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetLeft.ApplyCellSize(cellSize);
|
||||
TextCapsuleWidgetCenter.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetCenter.ApplyCellSize(cellSize);
|
||||
TextCapsuleWidgetRight.Margin = new Thickness(0);
|
||||
TextCapsuleWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
NetworkSpeedWidgetLeft.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetLeft.ApplyCellSize(cellSize);
|
||||
NetworkSpeedWidgetCenter.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetCenter.ApplyCellSize(cellSize);
|
||||
NetworkSpeedWidgetRight.Margin = new Thickness(0);
|
||||
NetworkSpeedWidgetRight.ApplyCellSize(cellSize);
|
||||
|
||||
var buttonMinWidth = Math.Clamp(taskbarCellHeight * 2.35, 100, 340);
|
||||
|
||||
@@ -737,7 +775,15 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
|
||||
if (_currentDesktopCellSize > 0)
|
||||
{
|
||||
ClockWidget.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
ClockWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
TextCapsuleWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetLeft.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetCenter.ApplyCellSize(_currentDesktopCellSize);
|
||||
NetworkSpeedWidgetRight.ApplyCellSize(_currentDesktopCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
Text="{Binding BasicHeader}"
|
||||
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.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Settings" />
|
||||
|
||||
@@ -52,6 +52,182 @@
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ClockPositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowClock}"
|
||||
ItemsSource="{Binding ClockPositions}"
|
||||
SelectedItem="{Binding SelectedClockPosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ClockFontSizeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowClock}"
|
||||
ItemsSource="{Binding ClockFontSizes}"
|
||||
SelectedItem="{Binding SelectedClockFontSize}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding TextCapsuleHeader}"
|
||||
Description="{Binding TextCapsuleDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="TextQuote" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding ShowTextCapsule}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsuleContentLabel}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,8,0,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Height="100"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
Text="{Binding TextCapsuleContent}"
|
||||
Watermark="Enter Markdown text..." />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsulePositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
ItemsSource="{Binding TextCapsulePositions}"
|
||||
SelectedItem="{Binding SelectedTextCapsulePosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding TextCapsuleTransparentBackgroundLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding TextCapsuleTransparentBackground}"
|
||||
IsEnabled="{Binding ShowTextCapsule}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="{Binding NetworkSpeedHeader}"
|
||||
Description="{Binding NetworkSpeedDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowBidirectionalUpDown" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding ShowNetworkSpeed}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedPositionLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedPositions}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedPosition}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedDisplayModeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedDisplayModes}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedDisplayMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedTransparentBackgroundLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding NetworkSpeedTransparentBackground}"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding ShowNetworkTypeIconLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding ShowNetworkTypeIcon}"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding NetworkSpeedFontSizeLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Width="220"
|
||||
IsEnabled="{Binding ShowNetworkSpeed}"
|
||||
ItemsSource="{Binding NetworkSpeedFontSizes}"
|
||||
SelectedItem="{Binding SelectedNetworkSpeedFontSize}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
@@ -92,6 +268,55 @@
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Square"
|
||||
Text="{Binding StatusBarShadowHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding StatusBarShadowHeader}"
|
||||
Description="{Binding StatusBarShadowDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Square" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding StatusBarShadowEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding StatusBarShadowColorLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
IsEnabled="{Binding StatusBarShadowEnabled}">
|
||||
<Border Width="32"
|
||||
Height="32"
|
||||
CornerRadius="4"
|
||||
Background="{Binding StatusBarShadowColorBrush}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight">
|
||||
<ColorPicker Color="{Binding StatusBarShadowColor}" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding StatusBarShadowOpacityLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<Slider Grid.Column="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="10"
|
||||
IsEnabled="{Binding StatusBarShadowEnabled}"
|
||||
Value="{Binding StatusBarShadowOpacity}" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
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);
|
||||
23
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
23
LanMountainDesktop/Views/TransparentOverlayWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
||||
SystemDecorations="None"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
Background="Transparent"
|
||||
Title="LanMountainDesktop Fused Desktop">
|
||||
<!--
|
||||
融合桌面(负一屏)- 在系统桌面上显示组件
|
||||
|
||||
特性:
|
||||
- 窗口置底(在桌面图标层显示)
|
||||
- 区域级穿透(组件区域可交互,其他区域穿透)
|
||||
- 组件可自由拖拽摆放
|
||||
- 三指/右键左滑回到阗山桌面第一页
|
||||
-->
|
||||
<Canvas x:Name="ComponentCanvas">
|
||||
<!-- 组件将动态添加到这里 -->
|
||||
</Canvas>
|
||||
</Window>
|
||||
749
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
749
LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs
Normal file
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上
|
||||
/// 支持在系统桌面上自由摆放组件
|
||||
/// </summary>
|
||||
public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
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 ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
|
||||
// 基础服务
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
// 渲染参数
|
||||
private const double DefaultCellSize = 100;
|
||||
private double _currentDesktopCellSize;
|
||||
|
||||
// 拖拽与缩放状态
|
||||
private bool _isDragging;
|
||||
private bool _isResizing;
|
||||
private string? _interactionPlacementId;
|
||||
private Point _interactionStartPoint;
|
||||
private double _interactionOriginalX;
|
||||
private double _interactionOriginalY;
|
||||
private double _interactionOriginalWidth;
|
||||
private double _interactionOriginalHeight;
|
||||
private Border? _interactionHost;
|
||||
|
||||
// 选中状态
|
||||
private Border? _selectedHost;
|
||||
|
||||
public event EventHandler? RestoreMainWindowRequested;
|
||||
|
||||
public TransparentOverlayWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var facade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||
_settingsFacade = facade;
|
||||
}
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
|
||||
public void SaveLayoutAndHide()
|
||||
{
|
||||
SaveLayout();
|
||||
Hide();
|
||||
|
||||
// Remove all components so that next time we open it builds fresh from snapshot
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Clear();
|
||||
}
|
||||
_componentHosts.Clear();
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (Screens.Primary is { } primaryScreen)
|
||||
{
|
||||
// 避开系统任务栏
|
||||
var workArea = primaryScreen.WorkingArea;
|
||||
var scaling = primaryScreen.Scaling;
|
||||
Position = new PixelPoint(workArea.X, workArea.Y);
|
||||
Width = workArea.Width / scaling;
|
||||
Height = workArea.Height / scaling;
|
||||
|
||||
// 基于设置计算单元格尺寸
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var shortCells = Math.Clamp(appSnapshot.GridShortSideCells > 0 ? appSnapshot.GridShortSideCells : 12, 6, 96);
|
||||
_currentDesktopCellSize = Height / shortCells;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentDesktopCellSize = DefaultCellSize;
|
||||
}
|
||||
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
// 保证透明区域也能被抓取事件
|
||||
canvas.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
}
|
||||
|
||||
// 确保注册表已初始化
|
||||
EnsureRegistries();
|
||||
|
||||
// 加载布局并渲染
|
||||
_layout = _layoutService.Load();
|
||||
RenderAllComponents();
|
||||
|
||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保组件运行时注册表已初始化
|
||||
/// </summary>
|
||||
private void EnsureRegistries()
|
||||
{
|
||||
if (_componentRuntimeRegistry is not null) return;
|
||||
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渲染所有布局中的组件
|
||||
/// </summary>
|
||||
private void RenderAllComponents()
|
||||
{
|
||||
if (Content is not Canvas canvas) return;
|
||||
|
||||
canvas.Children.Clear();
|
||||
_componentHosts.Clear();
|
||||
_selectedHost = null;
|
||||
|
||||
foreach (var placement in _layout.ComponentPlacements)
|
||||
{
|
||||
try
|
||||
{
|
||||
RenderComponentInternal(placement);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Failed to render component {placement.ComponentId}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateInteractiveRegions();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SaveLayout();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新可交互区域
|
||||
/// </summary>
|
||||
private void UpdateInteractiveRegions()
|
||||
{
|
||||
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存布局
|
||||
/// </summary>
|
||||
private void SaveLayout()
|
||||
{
|
||||
_layoutService.Save(_layout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件(供外部调用)
|
||||
/// </summary>
|
||||
public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null)
|
||||
{
|
||||
EnsureRegistries();
|
||||
|
||||
if (_componentRegistry == null || !_componentRegistry.TryGetDefinition(componentId, out var definition))
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var finalWidth = width ?? (definition.MinWidthCells * _currentDesktopCellSize);
|
||||
var finalHeight = height ?? (definition.MinHeightCells * _currentDesktopCellSize);
|
||||
|
||||
// 对齐网格
|
||||
x = Math.Round(x / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
y = Math.Round(y / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
finalWidth = Math.Round(finalWidth / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
finalHeight = Math.Round(finalHeight / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
var placementId = Guid.NewGuid().ToString("N");
|
||||
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placementId,
|
||||
ComponentId = componentId,
|
||||
X = x,
|
||||
Y = y,
|
||||
Width = finalWidth,
|
||||
Height = finalHeight,
|
||||
ZIndex = _layout.ComponentPlacements.Count
|
||||
};
|
||||
|
||||
_layout.ComponentPlacements.Add(placement);
|
||||
|
||||
// 立即渲染
|
||||
try
|
||||
{
|
||||
RenderComponentInternal(placement);
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y}) size ({finalWidth}x{finalHeight})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex);
|
||||
_layout.ComponentPlacements.Remove(placement);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内部渲染单个组件
|
||||
/// </summary>
|
||||
private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||
{
|
||||
AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
|
||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
||||
}
|
||||
|
||||
/// <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 grid = new Grid();
|
||||
grid.Children.Add(component);
|
||||
|
||||
var resizeHandle = new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")),
|
||||
CornerRadius = new Avalonia.CornerRadius(12),
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
|
||||
Margin = new Avalonia.Thickness(0, 0, -12, -12),
|
||||
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.BottomRightCorner),
|
||||
Tag = "desktop-component-resize-handle",
|
||||
IsVisible = false
|
||||
};
|
||||
grid.Children.Add(resizeHandle);
|
||||
|
||||
var host = new Border
|
||||
{
|
||||
Tag = placementId,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Background = Avalonia.Media.Brushes.Transparent,
|
||||
CornerRadius = new Avalonia.CornerRadius(12),
|
||||
ClipToBounds = false, // 允许把手溢出
|
||||
BorderBrush = Avalonia.Media.Brushes.Transparent,
|
||||
BorderThickness = new Avalonia.Thickness(3),
|
||||
Child = grid,
|
||||
Classes = { "desktop-component-host" }
|
||||
};
|
||||
|
||||
Canvas.SetLeft(host, x);
|
||||
Canvas.SetTop(host, y);
|
||||
|
||||
host.PointerPressed += OnComponentPointerPressed;
|
||||
host.PointerMoved += OnInteractionPointerMoved;
|
||||
host.PointerReleased += OnInteractionPointerReleased;
|
||||
|
||||
// 右键上下文菜单(删除组件)
|
||||
host.ContextRequested += OnComponentContextRequested;
|
||||
|
||||
if (Content is Canvas canvas)
|
||||
{
|
||||
canvas.Children.Add(host);
|
||||
}
|
||||
|
||||
_componentHosts[placementId] = host;
|
||||
UpdateInteractiveRegions();
|
||||
}
|
||||
|
||||
// 组件右键上下文菜单(删除)
|
||||
private void OnComponentContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (sender is not Border host || host.Tag is not string placementId) return;
|
||||
|
||||
// 构建上下文菜单
|
||||
var deleteItem = new MenuItem
|
||||
{
|
||||
Header = "移除组件",
|
||||
Icon = new Avalonia.Controls.TextBlock { Text = "🗑" }
|
||||
};
|
||||
deleteItem.Click += (_, _) =>
|
||||
{
|
||||
RemoveComponent(placementId);
|
||||
AppLogger.Info("TransparentOverlay", $"Component removed via context menu: {placementId}");
|
||||
};
|
||||
|
||||
var menu = new ContextMenu
|
||||
{
|
||||
Items = { deleteItem }
|
||||
};
|
||||
|
||||
// 显示在当前控件上
|
||||
menu.Open(host);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// 取消选中
|
||||
private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
DeselectComponent();
|
||||
}
|
||||
|
||||
// 选中组件
|
||||
private void SelectComponent(Border host)
|
||||
{
|
||||
if (_selectedHost == host) return;
|
||||
DeselectComponent();
|
||||
|
||||
_selectedHost = host;
|
||||
|
||||
// 渲染选中边框和把手
|
||||
host.BorderBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6"));
|
||||
host.Classes.Add("desktop-component-host-selected");
|
||||
|
||||
if (host.Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle")
|
||||
{
|
||||
c.IsVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeselectComponent()
|
||||
{
|
||||
if (_selectedHost != null)
|
||||
{
|
||||
_selectedHost.BorderBrush = Avalonia.Media.Brushes.Transparent;
|
||||
_selectedHost.Classes.Remove("desktop-component-host-selected");
|
||||
|
||||
if (_selectedHost.Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle")
|
||||
{
|
||||
c.IsVisible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_selectedHost = null;
|
||||
}
|
||||
|
||||
// 组件拖拽与缩放处理
|
||||
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;
|
||||
|
||||
SelectComponent(host);
|
||||
|
||||
_interactionPlacementId = placementId;
|
||||
_interactionHost = host;
|
||||
_interactionStartPoint = e.GetPosition(this);
|
||||
|
||||
// 这里必须用未吸附的原始屏幕位置计算 delta
|
||||
_interactionOriginalX = Canvas.GetLeft(host);
|
||||
_interactionOriginalY = Canvas.GetTop(host);
|
||||
_interactionOriginalWidth = host.Width;
|
||||
_interactionOriginalHeight = host.Height;
|
||||
|
||||
if (e.Source is Control sourceControl && sourceControl.Tag is string tag && tag == "desktop-component-resize-handle")
|
||||
{
|
||||
_isResizing = true;
|
||||
_isDragging = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isDragging = true;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(host);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInteractionPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if ((!_isDragging && !_isResizing) || _interactionHost is null) return;
|
||||
|
||||
var currentPoint = e.GetPosition(this);
|
||||
var deltaX = currentPoint.X - _interactionStartPoint.X;
|
||||
var deltaY = currentPoint.Y - _interactionStartPoint.Y;
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
var rawX = _interactionOriginalX + deltaX;
|
||||
var rawY = _interactionOriginalY + deltaY;
|
||||
|
||||
var snapX = Math.Round(rawX / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
var snapY = Math.Round(rawY / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
Canvas.SetLeft(_interactionHost, snapX);
|
||||
Canvas.SetTop(_interactionHost, snapY);
|
||||
}
|
||||
else if (_isResizing)
|
||||
{
|
||||
var rawWidth = _interactionOriginalWidth + deltaX;
|
||||
var rawHeight = _interactionOriginalHeight + deltaY;
|
||||
|
||||
var snapWidth = Math.Round(rawWidth / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
var snapHeight = Math.Round(rawHeight / _currentDesktopCellSize) * _currentDesktopCellSize;
|
||||
|
||||
// 防溢出与极小值保护
|
||||
snapWidth = Math.Max(_currentDesktopCellSize, snapWidth);
|
||||
snapHeight = Math.Max(_currentDesktopCellSize, snapHeight);
|
||||
|
||||
_interactionHost.Width = snapWidth;
|
||||
_interactionHost.Height = snapHeight;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if ((!_isDragging && !_isResizing) || _interactionHost is null || _interactionPlacementId is null)
|
||||
{
|
||||
_isDragging = false;
|
||||
_isResizing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新布局中的位置与尺寸
|
||||
var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _interactionPlacementId);
|
||||
if (placement is not null)
|
||||
{
|
||||
placement.X = Canvas.GetLeft(_interactionHost);
|
||||
placement.Y = Canvas.GetTop(_interactionHost);
|
||||
placement.Width = _interactionHost.Width;
|
||||
placement.Height = _interactionHost.Height;
|
||||
}
|
||||
|
||||
UpdateInteractiveRegions();
|
||||
SaveLayout();
|
||||
|
||||
_isDragging = false;
|
||||
_isResizing = false;
|
||||
_interactionPlacementId = null;
|
||||
_interactionHost = 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