diff --git a/Actions/SetVolume.cs b/Actions/SetVolume.cs index 1a01d7b1..f783166e 100644 --- a/Actions/SetVolume.cs +++ b/Actions/SetVolume.cs @@ -167,7 +167,18 @@ internal class MMDeviceEnumeratorWrapper public MMDeviceEnumeratorWrapper() { var type = Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")); - _enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(type); + if (type == null) + { + throw new InvalidOperationException("无法获取 MMDeviceEnumerator 类型。"); + } + + var instance = Activator.CreateInstance(type); + if (instance == null) + { + throw new InvalidOperationException("无法创建 MMDeviceEnumerator 实例。"); + } + + _enumerator = (IMMDeviceEnumerator)instance; } public IMMDevice GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role) diff --git a/Actions/ShowFloatingWindowAction.cs b/Actions/ShowFloatingWindowAction.cs index d304d392..94aceae3 100644 --- a/Actions/ShowFloatingWindowAction.cs +++ b/Actions/ShowFloatingWindowAction.cs @@ -26,14 +26,26 @@ protected override async Task OnInvoke() try { var shouldShow = Settings.ShowFloatingWindow; + var config = GlobalConstants.MainConfig?.Data; - if (IsRevertable) + // 如果没有可用的悬浮窗组件,则强制隐藏且不允许显示 + if (_floatingWindowService.Entries.Count == 0) { - PreviousStates[ActionSet.Guid] = GlobalConstants.MainConfig!.Data.ShowFloatingWindow; + shouldShow = false; + _logger.LogDebug("没有可用的悬浮窗组件,强制隐藏悬浮窗"); + } + + if (IsRevertable && config != null) + { + PreviousStates[ActionSet.Guid] = config.ShowFloatingWindow; + } + + if (config != null) + { + config.ShowFloatingWindow = shouldShow; + GlobalConstants.MainConfig?.Save(); } - GlobalConstants.MainConfig!.Data.ShowFloatingWindow = shouldShow; - GlobalConstants.MainConfig.Save(); _floatingWindowService.UpdateWindowState(); _logger.LogInformation("悬浮窗状态已更新为: {State}", shouldShow ? "开启" : "关闭"); @@ -58,8 +70,15 @@ protected override async Task OnRevert() return; } - GlobalConstants.MainConfig!.Data.ShowFloatingWindow = previousState; - GlobalConstants.MainConfig.Save(); + var config = GlobalConstants.MainConfig; + if (config == null) + { + _logger.LogWarning("主配置为空,无法恢复悬浮窗状态"); + return; + } + + config.Data.ShowFloatingWindow = previousState; + config.Save(); _floatingWindowService.UpdateWindowState(); _logger.LogInformation("已恢复悬浮窗状态为: {State}", previousState ? "开启" : "关闭"); diff --git a/Actions/SwitchFloatingWindowThemeAction.cs b/Actions/SwitchFloatingWindowThemeAction.cs new file mode 100644 index 00000000..f33b6aed --- /dev/null +++ b/Actions/SwitchFloatingWindowThemeAction.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using ClassIsland.Core.Abstractions.Automation; +using ClassIsland.Core.Attributes; +using ClassIsland.Shared; +using Microsoft.Extensions.Logging; +using SystemTools.Services; +using SystemTools.Settings; +using SystemTools.Shared; + +namespace SystemTools.Actions; + +/// +/// 切换悬浮窗主题行动 +/// +[ActionInfo("SystemTools.SwitchFloatingWindowTheme", "切换悬浮窗主题", "\uE790", false)] +public class SwitchFloatingWindowThemeAction(ILogger logger) : ActionBase +{ + private readonly ILogger _logger = logger; + private static readonly ConcurrentDictionary PreviousThemes = new(); + + protected override async Task OnInvoke() + { + _logger.LogDebug("SwitchFloatingWindowThemeAction OnInvoke 开始"); + + try + { + var service = IAppHost.GetService(); + var config = GlobalConstants.MainConfig?.Data; + + if (Settings.TargetTheme >= 0) + { + if (IsRevertable && config != null) + { + PreviousThemes[ActionSet.Guid] = config.FloatingWindowTheme; + } + + service.SetWindowTheme(Settings.TargetTheme); + _logger.LogInformation("已设置悬浮窗主题为: {Theme}", GetThemeName(Settings.TargetTheme)); + } + else + { + if (IsRevertable && config != null) + { + PreviousThemes[ActionSet.Guid] = config.FloatingWindowTheme; + } + + service.ToggleWindowTheme(); + _logger.LogInformation("已切换到下一个悬浮窗主题"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "切换悬浮窗主题失败"); + throw; + } + + await base.OnInvoke(); + _logger.LogDebug("SwitchFloatingWindowThemeAction OnInvoke 完成"); + } + + protected override async Task OnRevert() + { + await base.OnRevert(); + + if (!PreviousThemes.TryRemove(ActionSet.Guid, out var previousTheme)) + { + _logger.LogInformation("未找到主题恢复快照,跳过悬浮窗主题恢复。ActionSet={ActionSetGuid}", ActionSet.Guid); + return; + } + + try + { + var service = IAppHost.GetService(); + service.SetWindowTheme(previousTheme); + _logger.LogInformation("已恢复悬浮窗主题为: {Theme}", GetThemeName(previousTheme)); + } + catch (Exception ex) + { + _logger.LogError(ex, "恢复悬浮窗主题失败"); + throw; + } + } + + private static string GetThemeName(int theme) + { + return theme switch + { + 0 => "跟随系统", + 1 => "浅色", + 2 => "深色", + _ => "未知" + }; + } +} diff --git a/Actions/ToggleFloatingWindowLayerAction.cs b/Actions/ToggleFloatingWindowLayerAction.cs new file mode 100644 index 00000000..d1edcd35 --- /dev/null +++ b/Actions/ToggleFloatingWindowLayerAction.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using ClassIsland.Core.Abstractions.Automation; +using ClassIsland.Core.Attributes; +using ClassIsland.Shared; +using Microsoft.Extensions.Logging; +using SystemTools.Services; +using SystemTools.Settings; + +namespace SystemTools.Actions; + +/// +/// 切换悬浮窗层级行动 +/// +[ActionInfo("SystemTools.ToggleFloatingWindowLayer", "切换悬浮窗层级", "\uE9A8", false)] +public class ToggleFloatingWindowLayerAction(ILogger logger) : ActionBase +{ + private readonly ILogger _logger = logger; + private static readonly ConcurrentDictionary PreviousLayers = new(); + + protected override async Task OnInvoke() + { + _logger.LogDebug("ToggleFloatingWindowLayerAction OnInvoke 开始"); + + try + { + var service = IAppHost.GetService(); + var profile = service.ProfileManager.CurrentProfile; + + // 根据设置决定是切换还是设置到指定层级 + // TargetLayer: -1=切换, 0=置底, 1=置顶 + if (Settings.TargetLayer >= 0) + { + if (IsRevertable) + { + PreviousLayers[ActionSet.Guid] = profile.FloatingWindowLayer; + } + + service.SetWindowLayer(Settings.TargetLayer); + _logger.LogInformation("已设置悬浮窗层级为: {Layer}", Settings.TargetLayer == 0 ? "置底" : "置顶"); + } + else + { + if (IsRevertable) + { + PreviousLayers[ActionSet.Guid] = profile.FloatingWindowLayer; + } + + service.ToggleWindowLayer(); + _logger.LogInformation("已切换悬浮窗层级状态"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "切换悬浮窗层级失败"); + throw; + } + + await base.OnInvoke(); + _logger.LogDebug("ToggleFloatingWindowLayerAction OnInvoke 完成"); + } + + protected override async Task OnRevert() + { + await base.OnRevert(); + + if (!PreviousLayers.TryRemove(ActionSet.Guid, out var previousLayer)) + { + _logger.LogInformation("未找到层级恢复快照,跳过悬浮窗层级恢复。ActionSet={ActionSetGuid}", ActionSet.Guid); + return; + } + + try + { + var service = IAppHost.GetService(); + service.SetWindowLayer(previousLayer); + _logger.LogInformation("已恢复悬浮窗层级为: {Layer}", previousLayer == 0 ? "置底" : "置顶"); + } + catch (Exception ex) + { + _logger.LogError(ex, "恢复悬浮窗层级失败"); + throw; + } + } +} diff --git a/Actions/ToggleFloatingWindowProfileAction.cs b/Actions/ToggleFloatingWindowProfileAction.cs new file mode 100644 index 00000000..fae43bba --- /dev/null +++ b/Actions/ToggleFloatingWindowProfileAction.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using ClassIsland.Core.Abstractions.Automation; +using ClassIsland.Core.Attributes; +using ClassIsland.Shared; +using Microsoft.Extensions.Logging; +using SystemTools.Services; +using SystemTools.Settings; + +namespace SystemTools.Actions; + +/// +/// 切换悬浮窗配置方案行动 +/// +[ActionInfo("SystemTools.ToggleFloatingWindowProfile", "切换悬浮窗配置方案", "\uE9A8", false)] +public class ToggleFloatingWindowProfileAction(ILogger logger) : ActionBase +{ + private readonly ILogger _logger = logger; + private static readonly ConcurrentDictionary PreviousProfiles = new(); + + protected override async Task OnInvoke() + { + _logger.LogDebug("ToggleFloatingWindowProfileAction OnInvoke 开始"); + + try + { + var service = IAppHost.GetService(); + var currentProfileName = service.ProfileManager.CurrentProfileName; + + // 根据设置决定是切换到下一个还是切换到指定方案 + // TargetProfileName: null=切换到下一个, 其他=指定方案名称 + if (!string.IsNullOrWhiteSpace(Settings.TargetProfileName)) + { + if (IsRevertable) + { + PreviousProfiles[ActionSet.Guid] = currentProfileName; + } + + service.SwitchToProfile(Settings.TargetProfileName); + _logger.LogInformation("已切换到悬浮窗配置方案: {Name}", Settings.TargetProfileName); + } + else + { + if (IsRevertable) + { + PreviousProfiles[ActionSet.Guid] = currentProfileName; + } + + service.ToggleWindowProfile(); + _logger.LogInformation("已切换到下一个悬浮窗配置方案"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "切换悬浮窗配置方案失败"); + throw; + } + + await base.OnInvoke(); + _logger.LogDebug("ToggleFloatingWindowProfileAction OnInvoke 完成"); + } + + protected override async Task OnRevert() + { + await base.OnRevert(); + + if (!PreviousProfiles.TryRemove(ActionSet.Guid, out var previousProfile)) + { + _logger.LogInformation("未找到配置方案恢复快照,跳过悬浮窗配置方案恢复。ActionSet={ActionSetGuid}", ActionSet.Guid); + return; + } + + try + { + var service = IAppHost.GetService(); + service.SwitchToProfile(previousProfile); + _logger.LogInformation("已恢复悬浮窗配置方案为: {Name}", previousProfile); + } + catch (Exception ex) + { + _logger.LogError(ex, "恢复悬浮窗配置方案失败"); + throw; + } + } +} diff --git a/Actions/ToggleWorkflowAction.cs b/Actions/ToggleWorkflowAction.cs index 3dac4fbf..86fef99d 100644 --- a/Actions/ToggleWorkflowAction.cs +++ b/Actions/ToggleWorkflowAction.cs @@ -12,7 +12,7 @@ namespace SystemTools.Actions; -[ActionInfo("SystemTools.ToggleWorkflow", "开关自动化", "\uE9A8", false)] +[ActionInfo("SystemTools.ToggleWorkflow", "开关自动化", "\uE8B8", false)] public class ToggleWorkflowAction(ILogger logger) : ActionBase { private readonly ILogger _logger = logger; @@ -98,6 +98,13 @@ protected override async Task OnRevert() { await base.OnRevert(); + if (Settings == null || !Settings.RevertToOriginal) + { + _logger.LogDebug("RevertToOriginal 为 false,跳过恢复"); + PreviousSnapshots.TryRemove(ActionSet.Guid, out _); + return; + } + if (!PreviousSnapshots.TryRemove(ActionSet.Guid, out var snapshot)) { _logger.LogWarning("未找到触发前状态,跳过恢复。ActionSet={ActionSetGuid}", ActionSet.Guid); diff --git a/ConfigHandlers/ButtonRulesetConfig.cs b/ConfigHandlers/ButtonRulesetConfig.cs new file mode 100644 index 00000000..9a045695 --- /dev/null +++ b/ConfigHandlers/ButtonRulesetConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using ClassIsland.Core.Models.Ruleset; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SystemTools.ConfigHandlers; + +/// +/// 悬浮窗按钮的规则集配置 +/// +public partial class ButtonRulesetConfig : ObservableObject +{ + [ObservableProperty] + [JsonPropertyName("isVisible")] + private bool _isVisible = true; + + [ObservableProperty] + [JsonPropertyName("hideOnRule")] + private bool _hideOnRule; + + [ObservableProperty] + [JsonPropertyName("hidingRules")] + private Ruleset _hidingRules = new(); +} diff --git a/ConfigHandlers/FloatingWindowProfile.cs b/ConfigHandlers/FloatingWindowProfile.cs new file mode 100644 index 00000000..3a4f88ae --- /dev/null +++ b/ConfigHandlers/FloatingWindowProfile.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SystemTools.ConfigHandlers; + +/// +/// 悬浮窗配置方案,保存一套完整的悬浮窗布局和外观配置。 +/// 注意:显示状态(ShowFloatingWindow)和规则集(HideOnRule/HidingRules)是全局设置,不随方案切换。 +/// +public partial class FloatingWindowProfile : ObservableObject +{ + [ObservableProperty] + [JsonPropertyName("name")] + private string _name = "Default"; + + [ObservableProperty] + [JsonPropertyName("floatingWindowHorizontal")] + private bool _floatingWindowHorizontal; + + [JsonPropertyName("floatingWindowButtonOrder")] + public List FloatingWindowButtonOrder { get; set; } = new(); + + [JsonPropertyName("floatingWindowButtonRows")] + public List> FloatingWindowButtonRows { get; set; } = new(); + + [ObservableProperty] + [JsonPropertyName("floatingWindowScale")] + private double _floatingWindowScale = 1.0; + + [ObservableProperty] + [JsonPropertyName("floatingWindowIconSize")] + private int _floatingWindowIconSize = 22; + + [ObservableProperty] + [JsonPropertyName("floatingWindowTextSize")] + private int _floatingWindowTextSize = 12; + + [ObservableProperty] + [JsonPropertyName("floatingWindowOpacity")] + private int _floatingWindowOpacity = 80; + + [ObservableProperty] + [JsonPropertyName("floatingWindowPositionX")] + private int _floatingWindowPositionX = 100; + + [ObservableProperty] + [JsonPropertyName("floatingWindowPositionY")] + private int _floatingWindowPositionY = 100; + + [ObservableProperty] + [JsonPropertyName("floatingWindowLayer")] + private int _floatingWindowLayer = 1; + + [ObservableProperty] + [JsonPropertyName("floatingWindowLayerRecheckMode")] + private int _floatingWindowLayerRecheckMode = 1; + + [ObservableProperty] + [JsonPropertyName("floatingWindowShadowEnabled")] + private bool _floatingWindowShadowEnabled = true; + + [ObservableProperty] + [JsonPropertyName("floatingWindowDragHandleAlwaysVisible")] + private bool _floatingWindowDragHandleAlwaysVisible; + + [JsonPropertyName("floatingWindowButtonRulesets")] + public Dictionary FloatingWindowButtonRulesets { get; set; } = new(); + + [JsonPropertyName("floatingWindowRowRulesets")] + public List FloatingWindowRowRulesets { get; set; } = new(); + + /// + /// 清理不存在的按钮ID,返回是否有变更 + /// + public bool PruneInvalidButtonIds(IEnumerable validButtonIds) + { + var validSet = validButtonIds.ToHashSet(); + var changed = false; + + var newOrder = FloatingWindowButtonOrder.Where(id => validSet.Contains(id)).ToList(); + if (newOrder.Count != FloatingWindowButtonOrder.Count) + { + FloatingWindowButtonOrder = newOrder; + changed = true; + } + + var newRows = FloatingWindowButtonRows + .Select(row => row.Where(id => validSet.Contains(id)).ToList()) + .ToList(); + if (newRows.Count != FloatingWindowButtonRows.Count || + newRows.Zip(FloatingWindowButtonRows, (a, b) => a.SequenceEqual(b)).Any(x => !x)) + { + FloatingWindowButtonRows = newRows; + changed = true; + } + + var invalidButtonConfigs = FloatingWindowButtonRulesets.Keys.Where(id => !validSet.Contains(id)).ToList(); + foreach (var id in invalidButtonConfigs) + { + FloatingWindowButtonRulesets.Remove(id); + changed = true; + } + + return changed; + } +} diff --git a/ConfigHandlers/FloatingWindowProfileManager.cs b/ConfigHandlers/FloatingWindowProfileManager.cs new file mode 100644 index 00000000..3d1dc585 --- /dev/null +++ b/ConfigHandlers/FloatingWindowProfileManager.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ClassIsland.Shared.Helpers; +using SystemTools.Shared; + +namespace SystemTools.ConfigHandlers; + +/// +/// 管理悬浮窗配置方案的存储和加载,每个方案为独立的 JSON 文件。 +/// +public class FloatingWindowProfileManager +{ + private readonly string _profilesDirectory; + private FloatingWindowProfile _currentProfile = new(); + private string _currentProfileName = "Default"; + + public static FloatingWindowProfile DefaultProfile { get; } = new() + { + Name = "Default", + FloatingWindowScale = 1.0, + FloatingWindowIconSize = 22, + FloatingWindowTextSize = 12, + FloatingWindowOpacity = 80, + FloatingWindowPositionX = 100, + FloatingWindowPositionY = 100, + FloatingWindowLayer = 1, + FloatingWindowLayerRecheckMode = 1, + FloatingWindowShadowEnabled = true, + FloatingWindowButtonOrder = new List(), + FloatingWindowButtonRows = new List>(), + FloatingWindowButtonRulesets = new Dictionary(), + FloatingWindowRowRulesets = new List() + }; + + public FloatingWindowProfileManager() + { + _profilesDirectory = Path.Combine(DependencyPaths.GetDependencyRoot(), "FloatingWindowProfiles"); + if (!Directory.Exists(_profilesDirectory)) + { + Directory.CreateDirectory(_profilesDirectory); + } + } + + /// + /// 从旧版 MainConfigData 迁移配置到文件存储 + /// + public void MigrateFromLegacyConfig(MainConfigData legacyData) + { + var defaultPath = GetProfilePath("Default"); + if (File.Exists(defaultPath)) + { + return; + } + + var profile = new FloatingWindowProfile + { + Name = "Default", + FloatingWindowHorizontal = legacyData.FloatingWindowHorizontal, + FloatingWindowButtonOrder = new List(legacyData.FloatingWindowButtonOrder ?? []), + FloatingWindowButtonRows = (legacyData.FloatingWindowButtonRows ?? []).Select(r => new List(r)).ToList(), + FloatingWindowScale = legacyData.FloatingWindowScale, + FloatingWindowIconSize = legacyData.FloatingWindowIconSize, + FloatingWindowTextSize = legacyData.FloatingWindowTextSize, + FloatingWindowOpacity = legacyData.FloatingWindowOpacity, + FloatingWindowPositionX = legacyData.FloatingWindowPositionX, + FloatingWindowPositionY = legacyData.FloatingWindowPositionY, + FloatingWindowLayer = legacyData.FloatingWindowLayer, + FloatingWindowLayerRecheckMode = legacyData.FloatingWindowLayerRecheckMode, + FloatingWindowShadowEnabled = legacyData.FloatingWindowShadowEnabled, + FloatingWindowDragHandleAlwaysVisible = legacyData.FloatingWindowDragHandleAlwaysVisible, + FloatingWindowButtonRulesets = new Dictionary(legacyData.FloatingWindowButtonRulesets ?? []), + FloatingWindowRowRulesets = new List(legacyData.FloatingWindowRowRulesets ?? []) + }; + + ConfigureFileHelper.SaveConfig(defaultPath, profile); + } + + public string ProfilesDirectory => _profilesDirectory; + + /// + /// 判断指定名称的方案文件是否存在于磁盘。 + /// + public bool ProfileFileExists(string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return false; + } + + return File.Exists(GetProfilePath(profileName)); + } + + public FloatingWindowProfile CurrentProfile => _currentProfile; + + public string CurrentProfileName + { + get => _currentProfileName; + private set + { + if (_currentProfileName == value) return; + _currentProfileName = value; + CurrentProfile.Name = value; + } + } + + /// + /// 获取所有可用的方案名称列表 + /// + public IReadOnlyList GetProfileNames() + { + if (!Directory.Exists(_profilesDirectory)) + { + return new List { "Default" }; + } + + var names = Directory.GetFiles(_profilesDirectory, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + + if (names.Count == 0) + { + names.Add("Default"); + } + + return names; + } + + /// + /// 加载指定名称的方案 + /// + public void LoadProfile(string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + profileName = "Default"; + } + + var path = GetProfilePath(profileName); + if (!File.Exists(path)) + { + // 文件不存在时只在内存中加载默认模板,不写回磁盘, + // 避免被显式删除的方案被自动重建。 + _currentProfile = ConfigureFileHelper.CopyObject(DefaultProfile); + _currentProfile.Name = profileName; + } + else + { + _currentProfile = ConfigureFileHelper.LoadConfig(path); + _currentProfile.Name = profileName; + } + + _currentProfileName = profileName; + } + + /// + /// 保存当前方案 + /// + public void SaveProfile() + { + var path = GetProfilePath(_currentProfileName); + ConfigureFileHelper.SaveConfig(path, _currentProfile); + } + + /// + /// 创建新方案,基于当前方案或默认方案 + /// + public string CreateProfile(string? name = null) + { + var baseName = name?.Trim(); + if (string.IsNullOrWhiteSpace(baseName)) + { + baseName = $"Profile {GetProfileNames().Count + 1}"; + } + + var profileName = baseName; + var counter = 1; + while (File.Exists(GetProfilePath(profileName))) + { + profileName = $"{baseName} ({counter})"; + counter++; + } + + var newProfile = ConfigureFileHelper.CopyObject(_currentProfile); + newProfile.Name = profileName; + + var path = GetProfilePath(profileName); + ConfigureFileHelper.SaveConfig(path, newProfile); + + return profileName; + } + + /// + /// 删除指定方案 + /// + public bool RemoveProfile(string profileName) + { + if (string.Equals(profileName, "Default", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var path = GetProfilePath(profileName); + if (!File.Exists(path)) + { + return false; + } + + try + { + File.Delete(path); + return true; + } + catch + { + return false; + } + } + + /// + /// 重命名方案 + /// + public bool RenameProfile(string oldName, string newName) + { + if (string.IsNullOrWhiteSpace(newName) || string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var oldPath = GetProfilePath(oldName); + var newPath = GetProfilePath(newName); + + if (!File.Exists(oldPath) || File.Exists(newPath)) + { + return false; + } + + try + { + File.Move(oldPath, newPath); + if (string.Equals(_currentProfileName, oldName, StringComparison.OrdinalIgnoreCase)) + { + _currentProfileName = newName; + _currentProfile.Name = newName; + } + return true; + } + catch + { + return false; + } + } + + private string GetProfilePath(string profileName) + { + return Path.Combine(_profilesDirectory, $"{profileName}.json"); + } +} diff --git a/ConfigHandlers/MainConfigData.cs b/ConfigHandlers/MainConfigData.cs index 52b04d82..d764aae5 100644 --- a/ConfigHandlers/MainConfigData.cs +++ b/ConfigHandlers/MainConfigData.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; +using ClassIsland.Core.Models.Ruleset; namespace SystemTools.ConfigHandlers; @@ -345,6 +346,57 @@ public int FloatingWindowLayerRecheckMode } } + string _currentFloatingWindowProfile = "Default"; + + [JsonPropertyName("currentFloatingWindowProfile")] + public string CurrentFloatingWindowProfile + { + get => _currentFloatingWindowProfile; + set + { + if (string.Equals(value, _currentFloatingWindowProfile, StringComparison.Ordinal)) return; + _currentFloatingWindowProfile = value; + OnPropertyChanged(); + } + } + + bool _floatingWindowRulesetEnabled = false; + + [JsonPropertyName("floatingWindowRulesetEnabled")] + public bool FloatingWindowRulesetEnabled + { + get => _floatingWindowRulesetEnabled; + set + { + if (value == _floatingWindowRulesetEnabled) return; + _floatingWindowRulesetEnabled = value; + OnPropertyChanged(); + } + } + + bool _floatingWindowDragHandleAlwaysVisible = false; + + [JsonPropertyName("floatingWindowDragHandleAlwaysVisible")] + public bool FloatingWindowDragHandleAlwaysVisible + { + get => _floatingWindowDragHandleAlwaysVisible; + set + { + if (value == _floatingWindowDragHandleAlwaysVisible) return; + _floatingWindowDragHandleAlwaysVisible = value; + OnPropertyChanged(); + } + } + + [JsonPropertyName("floatingWindowRuleset")] + public Ruleset FloatingWindowRuleset { get; set; } = new(); + + [JsonPropertyName("floatingWindowButtonRulesets")] + public Dictionary FloatingWindowButtonRulesets { get; set; } = new(); + + [JsonPropertyName("floatingWindowRowRulesets")] + public List FloatingWindowRowRulesets { get; set; } = new(); + // 行动功能启用状态(Key: 行动ID, Value: 是否启用) [JsonPropertyName("enabledActions")] public Dictionary EnabledActions { get; set; } = new(); diff --git a/ConfigHandlers/RowRulesetConfig.cs b/ConfigHandlers/RowRulesetConfig.cs new file mode 100644 index 00000000..64f157a3 --- /dev/null +++ b/ConfigHandlers/RowRulesetConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using ClassIsland.Core.Models.Ruleset; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SystemTools.ConfigHandlers; + +/// +/// 悬浮窗行的规则集配置 +/// +public partial class RowRulesetConfig : ObservableObject +{ + [ObservableProperty] + [JsonPropertyName("isVisible")] + private bool _isVisible = true; + + [ObservableProperty] + [JsonPropertyName("hideOnRule")] + private bool _hideOnRule; + + [ObservableProperty] + [JsonPropertyName("hidingRules")] + private Ruleset _hidingRules = new(); +} diff --git a/Controls/Components/BetterCarouselContainerComponent.axaml.cs b/Controls/Components/BetterCarouselContainerComponent.axaml.cs index 9f0bd426..a5176608 100644 --- a/Controls/Components/BetterCarouselContainerComponent.axaml.cs +++ b/Controls/Components/BetterCarouselContainerComponent.axaml.cs @@ -22,8 +22,8 @@ namespace SystemTools.Controls.Components; [ComponentInfo("A7C3455E-6A4E-4D4D-9D0D-7C6FCB5E1E3A", "更好的轮播容器", "\uF0DB", "带有可单独设置组件显示时长等高级功能的轮播容器")] public partial class BetterCarouselContainerComponent : ComponentBase, INotifyPropertyChanged { - private readonly ILessonsService _lessonsService; - private readonly IRulesetService _rulesetService; + private readonly IRulesetService? _rulesetService; + private readonly ILessonsService? _lessonsService; private readonly Random _random = new(); private Animation? _slideInAnimation; @@ -83,6 +83,12 @@ public int SelectedIndex public new event PropertyChangedEventHandler? PropertyChanged; + public BetterCarouselContainerComponent() + { + InitializeComponent(); + InitializeAnimations(); + } + public BetterCarouselContainerComponent(IRulesetService rulesetService, ILessonsService lessonsService) { _rulesetService = rulesetService; diff --git a/Controls/Components/ClipboardContentComponent.axaml.cs b/Controls/Components/ClipboardContentComponent.axaml.cs index 45fc118d..40124c47 100644 --- a/Controls/Components/ClipboardContentComponent.axaml.cs +++ b/Controls/Components/ClipboardContentComponent.axaml.cs @@ -81,7 +81,13 @@ private async System.Threading.Tasks.Task RefreshClipboardAsync() { try { - var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + { + return; + } + + var clipboard = topLevel.Clipboard; if (clipboard == null) { return; diff --git a/Controls/Components/LocalQuoteComponent.axaml.cs b/Controls/Components/LocalQuoteComponent.axaml.cs index 5efb7924..fe6a92a4 100644 --- a/Controls/Components/LocalQuoteComponent.axaml.cs +++ b/Controls/Components/LocalQuoteComponent.axaml.cs @@ -30,11 +30,11 @@ public partial class LocalQuoteComponent : ComponentBase, IN { private const double SwapMotionOffset = 20; - private readonly DispatcherTimer _carouselTimer; - private readonly ILessonsService _lessonsService; + private readonly DispatcherTimer? _carouselTimer; + private readonly ILessonsService? _lessonsService; private readonly List _quotes = []; - private readonly Animation _swapOutAnimation; - private readonly Animation _swapInAnimation; + private readonly Animation? _swapOutAnimation; + private readonly Animation? _swapInAnimation; private readonly Random _random = new(); private int _currentIndex = -1; private string _loadedPath = string.Empty; @@ -70,6 +70,11 @@ static LocalQuoteComponent() Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } + public LocalQuoteComponent() + { + InitializeComponent(); + } + public LocalQuoteComponent(ILessonsService lessonsService) { _lessonsService = lessonsService; diff --git a/Controls/Components/LyricsDisplayComponent.axaml.cs b/Controls/Components/LyricsDisplayComponent.axaml.cs index 9c325676..2b4a8796 100644 --- a/Controls/Components/LyricsDisplayComponent.axaml.cs +++ b/Controls/Components/LyricsDisplayComponent.axaml.cs @@ -208,9 +208,11 @@ private void CaptureLyricsWindow() using (Bitmap croppedBmp = originalBmp.Clone(cropArea, PixelFormat.Format32bppArgb)) { ProcessBlackPixels(croppedBmp); - LyricsBitmap = ConvertToAvaloniaBitmap(croppedBmp); - _originalWidth = croppedBmp.Width; - _originalHeight = croppedBmp.Height; + var oldBitmap = _lyricsBitmap; + LyricsBitmap = ConvertToAvaloniaBitmap(croppedBmp); + oldBitmap?.Dispose(); + _originalWidth = croppedBmp.Width; + _originalHeight = croppedBmp.Height; } } @@ -230,8 +232,8 @@ private IntPtr FindWindowByClassPrefix(string classPrefix, string? windowTitle) PInvoke.EnumWindows((hWnd, lParam) => { Span buffer = stackalloc char[256]; - int length=PInvoke.GetClassName(hWnd, buffer); - if (length == 0) return false; + int length = PInvoke.GetClassName(hWnd, buffer); + if (length == 0) return true; string className = new(buffer.Slice(0, length)); if (className.StartsWith(classPrefix)) @@ -239,14 +241,15 @@ private IntPtr FindWindowByClassPrefix(string classPrefix, string? windowTitle) if (windowTitle != null) { Span buffer2 = stackalloc char[256]; - int length2=PInvoke.GetWindowText(hWnd, buffer2); - if (length2 == 0) return false; - string title = new(buffer2.Slice(0, length2)); - - if (title == windowTitle) + int length2 = PInvoke.GetWindowText(hWnd, buffer2); + if (length2 > 0) { - foundHandle = hWnd; - return false; + string title = new(buffer2.Slice(0, length2)); + if (title == windowTitle) + { + foundHandle = hWnd; + return false; + } } } else diff --git a/Controls/Components/NetworkStatusComponent.axaml.cs b/Controls/Components/NetworkStatusComponent.axaml.cs index 47baf0d8..b4f655e3 100644 --- a/Controls/Components/NetworkStatusComponent.axaml.cs +++ b/Controls/Components/NetworkStatusComponent.axaml.cs @@ -85,7 +85,7 @@ private void NetworkStatusComponent_OnUnloaded(object? sender, RoutedEventArgs e { Settings.PropertyChanged -= OnSettingsPropertyChanged; _timer.Stop(); - _httpClient.Dispose(); + try { _httpClient.Dispose(); } catch { } } private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -223,14 +223,22 @@ private async Task TryHttpPingAsync(string url) var stopwatch = Stopwatch.StartNew(); - using var response = await _httpClient.SendAsync( - new HttpRequestMessage(HttpMethod.Head, httpUrl), - HttpCompletionOption.ResponseHeadersRead); + try + { + using var response = await _httpClient.SendAsync( + new HttpRequestMessage(HttpMethod.Head, httpUrl), + HttpCompletionOption.ResponseHeadersRead); - stopwatch.Stop(); - response.EnsureSuccessStatusCode(); + stopwatch.Stop(); + response.EnsureSuccessStatusCode(); - return stopwatch.ElapsedMilliseconds; + return stopwatch.ElapsedMilliseconds; + } + catch + { + stopwatch.Stop(); + throw; + } } private void UpdateStatus(long delay) diff --git a/Controls/Components/NextClassDisplayComponent.axaml.cs b/Controls/Components/NextClassDisplayComponent.axaml.cs index 498106e6..a2f9db16 100644 --- a/Controls/Components/NextClassDisplayComponent.axaml.cs +++ b/Controls/Components/NextClassDisplayComponent.axaml.cs @@ -21,9 +21,9 @@ public partial class NextClassDisplayComponent : ComponentBase 0 && FirstTextBlock.Bounds.Height > 0) break; await Task.Delay(100, token); } @@ -79,6 +81,11 @@ private void UpdateMarquee() double textHeight = FirstTextBlock.Bounds.Height; double maxWidth = Settings.ComponentWidth; + if (textWidth <= 0 || textHeight <= 0) + { + return; + } + double finalWidth = Math.Min(textWidth + 24, maxWidth); LayoutRoot.Width = finalWidth; @@ -114,7 +121,10 @@ private void UpdateMarquee() } } catch (OperationCanceledException) { } - catch (Exception) { } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ScrollingText] UpdateMarquee error: {ex.Message}"); + } }, DispatcherPriority.Loaded); } diff --git a/Controls/KillProcessSettingsControl.cs b/Controls/KillProcessSettingsControl.cs index 888b3c39..75e4c45c 100644 --- a/Controls/KillProcessSettingsControl.cs +++ b/Controls/KillProcessSettingsControl.cs @@ -186,7 +186,7 @@ private async Task ShowProcessListWindow(string processList) }; copyButton.Click += async (s, e) => { - if (TopLevel.GetTopLevel(this) is { } topLevel) + if (TopLevel.GetTopLevel(this) is { } topLevel && topLevel.Clipboard != null) { await topLevel.Clipboard.SetTextAsync(processList); } diff --git a/Controls/ProcessRunningRuleSettingsControl.cs b/Controls/ProcessRunningRuleSettingsControl.cs index 592354dd..0f970e5e 100644 --- a/Controls/ProcessRunningRuleSettingsControl.cs +++ b/Controls/ProcessRunningRuleSettingsControl.cs @@ -152,7 +152,7 @@ private async Task ShowProcessListWindow(string processList) }; copyButton.Click += async (_, _) => { - if (TopLevel.GetTopLevel(this) is { } topLevel) + if (TopLevel.GetTopLevel(this) is { } topLevel && topLevel.Clipboard != null) { await topLevel.Clipboard.SetTextAsync(processList); } diff --git a/Controls/SwitchFloatingWindowThemeSettingsControl.cs b/Controls/SwitchFloatingWindowThemeSettingsControl.cs new file mode 100644 index 00000000..e0ce4fbd --- /dev/null +++ b/Controls/SwitchFloatingWindowThemeSettingsControl.cs @@ -0,0 +1,77 @@ +using Avalonia.Controls; +using ClassIsland.Core.Abstractions.Controls; +using SystemTools.Settings; + +namespace SystemTools.Controls; + +/// +/// 切换悬浮窗主题行动的设置控件 +/// +public class SwitchFloatingWindowThemeSettingsControl : ActionSettingsControlBase +{ + private ComboBox _themeComboBox; + + public SwitchFloatingWindowThemeSettingsControl() + { + var panel = new StackPanel { Spacing = 10, Margin = new(10) }; + + panel.Children.Add(new TextBlock + { + Text = "目标主题:", + FontWeight = Avalonia.Media.FontWeight.Bold + }); + + _themeComboBox = new ComboBox + { + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch + }; + _themeComboBox.Items.Add(new ComboBoxItem { Content = "切换到下一个", Tag = -1 }); + _themeComboBox.Items.Add(new ComboBoxItem { Content = "跟随系统", Tag = 0 }); + _themeComboBox.Items.Add(new ComboBoxItem { Content = "浅色", Tag = 1 }); + _themeComboBox.Items.Add(new ComboBoxItem { Content = "深色", Tag = 2 }); + _themeComboBox.SelectedIndex = 0; + + panel.Children.Add(_themeComboBox); + + panel.Children.Add(new TextBlock + { + Text = "提示:选择\"切换到下一个\"会按 跟随系统→浅色→深色→跟随系统 循环切换,选择具体主题会直接设置到该主题。", + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Opacity = 0.7, + FontSize = 12 + }); + + Content = panel; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + _themeComboBox.SelectionChanged += OnThemeSelectionChanged; + + RestoreSettings(); + } + + private void RestoreSettings() + { + if (Settings == null) return; + + var index = Settings.TargetTheme switch + { + 0 => 1, + 1 => 2, + 2 => 3, + _ => 0 + }; + _themeComboBox.SelectedIndex = index; + } + + private void OnThemeSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_themeComboBox.SelectedItem is ComboBoxItem item && item.Tag is int theme) + { + Settings.TargetTheme = theme; + } + } +} diff --git a/Controls/ToggleFloatingWindowLayerSettingsControl.cs b/Controls/ToggleFloatingWindowLayerSettingsControl.cs new file mode 100644 index 00000000..d4cd22b0 --- /dev/null +++ b/Controls/ToggleFloatingWindowLayerSettingsControl.cs @@ -0,0 +1,75 @@ +using Avalonia.Controls; +using ClassIsland.Core.Abstractions.Controls; +using SystemTools.Settings; + +namespace SystemTools.Controls; + +/// +/// 切换悬浮窗层级行动的设置控件 +/// +public class ToggleFloatingWindowLayerSettingsControl : ActionSettingsControlBase +{ + private ComboBox _layerComboBox; + + public ToggleFloatingWindowLayerSettingsControl() + { + var panel = new StackPanel { Spacing = 10, Margin = new(10) }; + + panel.Children.Add(new TextBlock + { + Text = "目标层级:", + FontWeight = Avalonia.Media.FontWeight.Bold + }); + + _layerComboBox = new ComboBox + { + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch + }; + _layerComboBox.Items.Add(new ComboBoxItem { Content = "切换(置底↔置顶)", Tag = -1 }); + _layerComboBox.Items.Add(new ComboBoxItem { Content = "置底", Tag = 0 }); + _layerComboBox.Items.Add(new ComboBoxItem { Content = "置顶", Tag = 1 }); + _layerComboBox.SelectedIndex = 0; + + panel.Children.Add(_layerComboBox); + + panel.Children.Add(new TextBlock + { + Text = "提示:选择\"切换\"会根据当前状态在置顶和置底之间切换,选择具体层级会直接设置到该层级。", + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Opacity = 0.7, + FontSize = 12 + }); + + Content = panel; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + _layerComboBox.SelectionChanged += OnLayerSelectionChanged; + + RestoreSettings(); + } + + private void RestoreSettings() + { + if (Settings == null) return; + + var index = Settings.TargetLayer switch + { + 0 => 1, + 1 => 2, + _ => 0 + }; + _layerComboBox.SelectedIndex = index; + } + + private void OnLayerSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_layerComboBox.SelectedItem is ComboBoxItem item && item.Tag is int layer) + { + Settings.TargetLayer = layer; + } + } +} diff --git a/Controls/ToggleFloatingWindowProfileSettingsControl.cs b/Controls/ToggleFloatingWindowProfileSettingsControl.cs new file mode 100644 index 00000000..29127e72 --- /dev/null +++ b/Controls/ToggleFloatingWindowProfileSettingsControl.cs @@ -0,0 +1,109 @@ +using Avalonia.Controls; +using ClassIsland.Core.Abstractions.Controls; +using SystemTools.Settings; +using SystemTools.Services; +using ClassIsland.Shared; + +namespace SystemTools.Controls; + +/// +/// 切换悬浮窗配置方案行动的设置控件 +/// +public class ToggleFloatingWindowProfileSettingsControl : ActionSettingsControlBase +{ + private ComboBox _profileComboBox; + + public ToggleFloatingWindowProfileSettingsControl() + { + var panel = new StackPanel { Spacing = 10, Margin = new(10) }; + + panel.Children.Add(new TextBlock + { + Text = "目标配置方案:", + FontWeight = Avalonia.Media.FontWeight.Bold + }); + + _profileComboBox = new ComboBox + { + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch + }; + + LoadProfiles(); + + panel.Children.Add(_profileComboBox); + + panel.Children.Add(new TextBlock + { + Text = "提示:选择\"切换到下一个\"会按顺序循环切换方案,选择具体方案会直接切换到该方案。", + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Opacity = 0.7, + FontSize = 12 + }); + + Content = panel; + } + + private void LoadProfiles() + { + _profileComboBox.Items.Clear(); + _profileComboBox.Items.Add(new ComboBoxItem { Content = "切换到下一个", Tag = null }); + + try + { + var profileManager = IAppHost.GetService().ProfileManager; + var profileNames = profileManager.GetProfileNames(); + + foreach (var name in profileNames) + { + _profileComboBox.Items.Add(new ComboBoxItem + { + Content = name, + Tag = name + }); + } + } + catch + { + // 服务可能尚未初始化 + } + + _profileComboBox.SelectedIndex = 0; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + _profileComboBox.SelectionChanged += OnProfileSelectionChanged; + + RestoreSettings(); + } + + private void RestoreSettings() + { + if (Settings == null) return; + + var targetName = Settings.TargetProfileName; + if (!string.IsNullOrWhiteSpace(targetName)) + { + for (int i = 1; i < _profileComboBox.Items.Count; i++) + { + if (_profileComboBox.Items[i] is ComboBoxItem item && item.Tag is string name && name == targetName) + { + _profileComboBox.SelectedIndex = i; + return; + } + } + } + + _profileComboBox.SelectedIndex = 0; + } + + private void OnProfileSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_profileComboBox.SelectedItem is ComboBoxItem item) + { + Settings.TargetProfileName = item.Tag as string; + } + } +} diff --git a/DriveUtils.cs b/DriveUtils.cs index bf36781d..02b46022 100644 --- a/DriveUtils.cs +++ b/DriveUtils.cs @@ -11,6 +11,10 @@ public static class DriveUtils private static string GetDriveJsonPath() { var pluginDir = Path.GetDirectoryName(typeof(DriveUtils).Assembly.Location); + if (string.IsNullOrWhiteSpace(pluginDir)) + { + pluginDir = AppContext.BaseDirectory; + } return Path.Combine(pluginDir, "drive.json"); } diff --git a/Plugin.cs b/Plugin.cs index ab6d2fd4..65e9624b 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -46,7 +46,6 @@ public partial class Plugin : PluginBase { private ILogger? _logger; private NativeMenuItem? _toggleFloatingWindowMenuItem; - private int _toggleMenuRegisterRetryCount; private bool _faceRecognitionRegistered = false; private bool _ffmpegDisabledDueToMissingDependency; private bool _faceRecognitionDisabledDueToMissingDependency; @@ -68,6 +67,7 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s services.AddLogging(); services.AddSingleton(GlobalConstants.MainConfig); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -76,7 +76,7 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s // ========== 注册可选人脸识别 ========== if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (GlobalConstants.MainConfig.Data.EnableFaceRecognition) + if (GlobalConstants.MainConfig?.Data.EnableFaceRecognition == true) { if (DependencyPaths.HasFaceRecognitionDependencies()) { @@ -93,7 +93,7 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s // ========== 注册设置页面 ========== services.AddSettingsPage(); services.AddSettingsPage(); - if (GlobalConstants.MainConfig.Data.EnableFloatingWindowFeature) + if (GlobalConstants.MainConfig?.Data.EnableFloatingWindowFeature == true) { services.AddSettingsPage(); } @@ -108,11 +108,14 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s RegisterBaseRules(services); RegisterBaseComponents(services); - var experimentalEnabled = GlobalConstants.MainConfig.Data.EnableExperimentalFeatures; - var ffmpegEnabled = GlobalConstants.MainConfig.Data.EnableFfmpegFeatures; + var experimentalEnabled = GlobalConstants.MainConfig?.Data.EnableExperimentalFeatures ?? false; + var ffmpegEnabled = GlobalConstants.MainConfig?.Data.EnableFfmpegFeatures ?? false; AppBase.Current.AppStarted += (o, args) => { + // 迁移旧版悬浮窗配置到文件存储 + IAppHost.GetService().MigrateFromLegacyConfig(GlobalConstants.MainConfig!.Data); + if (GlobalConstants.MainConfig?.Data.EnableFloatingWindowFeature == true) { IAppHost.GetService().Start(); @@ -129,7 +132,7 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s _logger?.LogWarning("[SystemTools]FFmpeg 功能已自动关闭:缺少依赖文件 ffmpeg.exe。"); } - if (GlobalConstants.MainConfig.Data.EnableFaceRecognition) + if (GlobalConstants.MainConfig?.Data.EnableFaceRecognition == true) { if (_faceRecognitionRegistered) { @@ -286,6 +289,12 @@ private void RegisterBaseActions(IServiceCollection services) { RegisterActionIfEnabled(services, config, "SystemTools.ShowFloatingWindow"); + RegisterActionIfEnabled(services, config, + "SystemTools.ToggleFloatingWindowLayer"); + RegisterActionIfEnabled(services, config, + "SystemTools.ToggleFloatingWindowProfile"); + RegisterActionIfEnabled(services, config, + "SystemTools.SwitchFloatingWindowTheme"); } // 其他工具 @@ -525,7 +534,9 @@ private void BuildBaseActionTree() } // 悬浮窗设置 - if (config.EnableFloatingWindowFeature && config.IsActionEnabled("SystemTools.ShowFloatingWindow")) + if (config.EnableFloatingWindowFeature && HasAnyActionEnabled(config, "SystemTools.ShowFloatingWindow", + "SystemTools.ToggleFloatingWindowLayer", "SystemTools.ToggleFloatingWindowProfile", + "SystemTools.SwitchFloatingWindowTheme")) { IActionService.ActionMenuTree["SystemTools 行动"].Add(new ActionMenuTreeGroup("悬浮窗设置…", "\uEA37")); BuildFloatingWindowMenu(config); @@ -720,6 +731,12 @@ private void BuildFloatingWindowMenu(MainConfigData config) if (config.EnableFloatingWindowFeature && config.IsActionEnabled("SystemTools.ShowFloatingWindow")) items.Add(new ActionMenuTreeItem("SystemTools.ShowFloatingWindow", "显示悬浮窗", "\uEA37")); + if (config.EnableFloatingWindowFeature && config.IsActionEnabled("SystemTools.ToggleFloatingWindowLayer")) + items.Add(new ActionMenuTreeItem("SystemTools.ToggleFloatingWindowLayer", "切换悬浮窗层级", "\uE9A8")); + if (config.EnableFloatingWindowFeature && config.IsActionEnabled("SystemTools.ToggleFloatingWindowProfile")) + items.Add(new ActionMenuTreeItem("SystemTools.ToggleFloatingWindowProfile", "切换悬浮窗配置方案", "\uE9A8")); + if (config.EnableFloatingWindowFeature && config.IsActionEnabled("SystemTools.SwitchFloatingWindowTheme")) + items.Add(new ActionMenuTreeItem("SystemTools.SwitchFloatingWindowTheme", "切换悬浮窗主题", "\uE790")); if (items.Count > 0) { @@ -753,7 +770,7 @@ private void BuildClassIslandMenu(MainConfigData config) if (config.IsActionEnabled("SystemTools.OpenClassSwapWindow")) items.Add(new ActionMenuTreeItem("SystemTools.OpenClassSwapWindow", "打开换课窗口", "\uE13B")); if (config.IsActionEnabled("SystemTools.ToggleWorkflow")) - items.Add(new ActionMenuTreeItem("SystemTools.ToggleWorkflow", "开关自动化", "\uE9A8")); + items.Add(new ActionMenuTreeItem("SystemTools.ToggleWorkflow", "开关自动化", "\uE8B8")); if (items.Count > 0) { @@ -829,13 +846,18 @@ private void RegisterOrUpdateFloatingWindowTrayMenu() return; } - data.ShowFloatingWindow = !data.ShowFloatingWindow; + var config = GlobalConstants.MainConfig?.Data; + if (config != null) + { + config.ShowFloatingWindow = !config.ShowFloatingWindow; + GlobalConstants.MainConfig?.Save(); + } IAppHost.GetService().UpdateWindowState(); UpdateFloatingWindowTrayMenuHeader(); - GlobalConstants.MainConfig?.Save(); }; - config.PropertyChanged += OnMainConfigDataPropertyChanged; + // 监听主配置变化以更新托盘菜单 + config.PropertyChanged += OnMainConfigDataPropertyChanged; } if (!config.EnableFloatingWindowFeature) @@ -874,7 +896,7 @@ private void UnregisterFloatingWindowTrayMenu() private void OnMainConfigDataPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is not (nameof(MainConfigData.ShowFloatingWindow) or nameof(MainConfigData.EnableFloatingWindowFeature))) + if (e.PropertyName is not nameof(MainConfigData.EnableFloatingWindowFeature)) { return; } @@ -889,7 +911,8 @@ private void UpdateFloatingWindowTrayMenuHeader() return; } - _toggleFloatingWindowMenuItem.Header = GlobalConstants.MainConfig?.Data.ShowFloatingWindow == true + var config = GlobalConstants.MainConfig?.Data; + _toggleFloatingWindowMenuItem.Header = config is { ShowFloatingWindow: true } ? "隐藏悬浮窗" : "显示悬浮窗"; } diff --git a/Services/FloatingWindowService.cs b/Services/FloatingWindowService.cs index 516a46b6..308db0e5 100644 --- a/Services/FloatingWindowService.cs +++ b/Services/FloatingWindowService.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -7,7 +7,9 @@ using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; +using ClassIsland.Core.Abstractions.Services; using ClassIsland.Core.Controls; +using ClassIsland.Shared; using System; using System.Collections.Generic; using System.Linq; @@ -37,6 +39,7 @@ public class FloatingWindowService private static readonly TimeSpan TouchLikeMouseGracePeriod = TimeSpan.FromMilliseconds(250); private readonly MainConfigHandler _configHandler; + private readonly FloatingWindowProfileManager _profileManager; private readonly Dictionary _entries = new(); private Window? _window; private StackPanel? _stackPanel; @@ -50,6 +53,7 @@ public class FloatingWindowService private readonly Dictionary _buttonWidthCache = new(); private bool _allowWindowClose; private bool _restoringFromMinimized; + private bool _isStopped; private bool _isTouchDeviceDetected; private bool _touchDragAllowed; private PixelPoint _touchDragStartScreenPoint; @@ -61,6 +65,7 @@ public class FloatingWindowService private WinEventProc? _winEventProc; private IntPtr _mouseHook; private LowLevelMouseProc? _lowLevelMouseProc; + private ILessonsService? _lessonsService; private DispatcherTimer LayerRecheck50MsTimer { get; } = new() { Interval = TimeSpan.FromMilliseconds(50) }; private DispatcherTimer LayerRecheck1MsTimer { get; } = new() { Interval = TimeSpan.FromMilliseconds(1) }; @@ -87,17 +92,21 @@ private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPt public event EventHandler? EntriesChanged; - public FloatingWindowService(MainConfigHandler configHandler) + public FloatingWindowService(MainConfigHandler configHandler, FloatingWindowProfileManager profileManager) { _configHandler = configHandler; + _profileManager = profileManager; } public IReadOnlyList Entries => _entries.Values.ToList(); + public FloatingWindowProfileManager ProfileManager => _profileManager; + public void Start() { Dispatcher.UIThread.Post(() => { + _profileManager.LoadProfile(_configHandler.Data.CurrentFloatingWindowProfile); EnsureWindow(); EnsureLayerRecheckHooks(); EnsureGlobalInputHooks(); @@ -111,6 +120,7 @@ public void Start() public void Stop() { + _isStopped = true; Dispatcher.UIThread.Post(() => { if (_window != null) @@ -187,8 +197,10 @@ public void UnregisterTrigger(FloatingWindowTrigger trigger) public void UpdateWindowState() { + if (_isStopped) return; Dispatcher.UIThread.Post(() => { + if (_isStopped) return; ApplyVisibility(); RefreshLayerRecheckMode(); RecheckWindowLayer(); @@ -199,8 +211,10 @@ public void UpdateWindowState() private void NotifyEntriesChanged() { EntriesChanged?.Invoke(this, EventArgs.Empty); + if (_isStopped) return; Dispatcher.UIThread.Post(() => { + if (_isStopped) return; ApplyVisibility(); RecheckWindowLayer(); RefreshWindowButtons(); @@ -253,9 +267,35 @@ private bool IsLightTheme() return ResolveWindowThemeVariant() == ThemeVariant.Light; } + /// + /// 设置悬浮窗主题 + /// + /// 0=跟随系统, 1=浅色, 2=深色 + public void SetWindowTheme(int theme) + { + var normalized = theme is 1 or 2 ? theme : 0; + if (_configHandler.Data.FloatingWindowTheme == normalized) + { + return; + } + + _configHandler.Data.FloatingWindowTheme = normalized; + _configHandler.Save(); + Dispatcher.UIThread.Post(RefreshWindowButtons); + } + + /// + /// 切换到下一个悬浮窗主题 + /// + public void ToggleWindowTheme() + { + var next = (_configHandler.Data.FloatingWindowTheme + 1) % 3; + SetWindowTheme(next); + } + private void EnsureWindow() { - if (_window != null) + if (_window != null || _isStopped) { return; } @@ -264,10 +304,10 @@ private void EnsureWindow() _stackPanel = new StackPanel { Margin = new Thickness(6), Spacing = 6 }; _window = new Window { - Width = 1, - Height = 1, + Width = 64, + Height = 64, ShowActivated = false, - Topmost = _configHandler.Data.FloatingWindowLayer == 1, + Topmost = _profileManager.CurrentProfile.FloatingWindowLayer == 1, SystemDecorations = SystemDecorations.None, Background = Brushes.Transparent, CanResize = false, @@ -275,7 +315,7 @@ private void EnsureWindow() SizeToContent = SizeToContent.WidthAndHeight, Content = _windowContainer = new Border { - Background = new SolidColorBrush(Color.Parse("#CC1F1F1F")), + Background = TryParseColor("#CC1F1F1F") ?? new SolidColorBrush(Color.FromArgb(0xCC, 0x1F, 0x1F, 0x1F)), CornerRadius = new CornerRadius(8), Child = _stackPanel } @@ -290,10 +330,7 @@ private void EnsureWindow() if (!_allowWindowClose) { e.Cancel = true; - if (_window is { IsVisible: false }) - { - _window.Show(); - } + // 不在 Closing 事件中调用 Show(),窗口可能处于关闭过程中 } }; _window.PropertyChanged += OnWindowPropertyChanged; @@ -316,7 +353,7 @@ private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEven private void RestoreWindowFromMinimized() { - if (_window == null || _restoringFromMinimized) + if (_window == null || _restoringFromMinimized || _isStopped) { return; } @@ -327,17 +364,26 @@ private void RestoreWindowFromMinimized() { try { - if (_window == null) + if (_window == null || _isStopped) { return; } if (!_window.IsVisible) { - _window.Show(); + try { _window.Show(); } + catch (InvalidOperationException) + { + _window = null; + _stackPanel = null; + _windowContainer = null; + } } - _window.WindowState = WindowState.Normal; + if (_window != null) + { + _window.WindowState = WindowState.Normal; + } } finally { @@ -353,24 +399,194 @@ private void OnWindowLoaded(object? sender, RoutedEventArgs e) RecheckWindowLayer(); } + private bool _rulesetHidingWindow = false; + private readonly HashSet _rulesetHiddenButtons = new(); + private readonly HashSet _rulesetHiddenRows = new(); + + private void CheckFloatingWindowRuleset() + { + var profile = _profileManager.CurrentProfile; + if (!_configHandler.Data.FloatingWindowRulesetEnabled) + { + if (_rulesetHidingWindow) + { + _rulesetHidingWindow = false; + ApplyVisibility(); + } + return; + } + + var rulesetService = IAppHost.TryGetService(); + if (rulesetService == null) + { + return; + } + + var isSatisfied = rulesetService.IsRulesetSatisfied(_configHandler.Data.FloatingWindowRuleset); + var shouldHide = isSatisfied; + + if (shouldHide != _rulesetHidingWindow) + { + _rulesetHidingWindow = shouldHide; + ApplyVisibility(); + } + } + + private void CheckButtonRulesets() + { + var profile = _profileManager.CurrentProfile; + var rulesetService = IAppHost.TryGetService(); + if (rulesetService == null) + { + return; + } + + var changed = false; + foreach (var entry in _entries.Values) + { + if (!profile.FloatingWindowButtonRulesets.TryGetValue(entry.ButtonId, out var config)) + { + continue; + } + + var shouldHide = false; + if (!config.IsVisible) + { + shouldHide = true; + } + else if (config.HideOnRule) + { + shouldHide = rulesetService.IsRulesetSatisfied(config.HidingRules); + } + + var wasHidden = _rulesetHiddenButtons.Contains(entry.ButtonId); + if (shouldHide != wasHidden) + { + if (shouldHide) + { + _rulesetHiddenButtons.Add(entry.ButtonId); + } + else + { + _rulesetHiddenButtons.Remove(entry.ButtonId); + } + changed = true; + } + } + + if (changed) + { + Dispatcher.UIThread.Post(RefreshWindowButtons); + } + } + + private void CheckRowRulesets() + { + var profile = _profileManager.CurrentProfile; + var rowConfigs = profile.FloatingWindowRowRulesets; + if (rowConfigs == null || rowConfigs.Count == 0) + { + if (_rulesetHiddenRows.Count > 0) + { + _rulesetHiddenRows.Clear(); + Dispatcher.UIThread.Post(RefreshWindowButtons); + } + return; + } + + var rulesetService = IAppHost.TryGetService(); + if (rulesetService == null) + { + return; + } + + var changed = false; + for (int i = 0; i < rowConfigs.Count; i++) + { + var config = rowConfigs[i]; + var shouldHide = false; + if (!config.IsVisible) + { + shouldHide = true; + } + else if (config.HideOnRule) + { + shouldHide = rulesetService.IsRulesetSatisfied(config.HidingRules); + } + + var wasHidden = _rulesetHiddenRows.Contains(i); + if (shouldHide != wasHidden) + { + if (shouldHide) + { + _rulesetHiddenRows.Add(i); + } + else + { + _rulesetHiddenRows.Remove(i); + } + changed = true; + } + } + + if (changed) + { + Dispatcher.UIThread.Post(RefreshWindowButtons); + } + } + private void ApplyVisibility() { + if (_isStopped) return; EnsureWindow(); if (_window == null) { return; } - if (_configHandler.Data.ShowFloatingWindow && _entries.Count > 0) + var profile = _profileManager.CurrentProfile; + var hasVisibleButtons = HasAnyVisibleButton(); + var shouldShow = _configHandler.Data.ShowFloatingWindow && hasVisibleButtons && !_rulesetHidingWindow; + + if (shouldShow) { if (!_window.IsVisible) { - _window.Show(); + try + { + _window.Show(); + } + catch (InvalidOperationException) + { + // 窗口已关闭(被外部关闭或竞态条件),需要重建 + _window = null; + _stackPanel = null; + _windowContainer = null; + if (_isStopped) return; + EnsureWindow(); + if (_window != null) + { + try { _window.Show(); } + catch (InvalidOperationException) { /* 放弃重建 */ } + } + } } } else { - _window.Hide(); + if (_window != null && _window.IsVisible) + { + try + { + _window.Hide(); + } + catch (InvalidOperationException) + { + _window = null; + _stackPanel = null; + _windowContainer = null; + } + } } } @@ -381,10 +597,11 @@ private void RefreshWindowButtons() return; } - var scale = Math.Clamp(_configHandler.Data.FloatingWindowScale, 0.5, 2.0); - var iconSize = Math.Clamp(_configHandler.Data.FloatingWindowIconSize, 15, 50) * scale; - var textSize = Math.Clamp(_configHandler.Data.FloatingWindowTextSize, 8, 30) * scale; - var opacity = Math.Clamp(_configHandler.Data.FloatingWindowOpacity, 10, 100); + var profile = _profileManager.CurrentProfile; + var scale = Math.Clamp(profile.FloatingWindowScale, 0.5, 2.0); + var iconSize = Math.Clamp(profile.FloatingWindowIconSize, 15, 50) * scale; + var textSize = Math.Clamp(profile.FloatingWindowTextSize, 8, 30) * scale; + var opacity = Math.Clamp(profile.FloatingWindowOpacity, 10, 100); var alpha = (byte)Math.Round(255 * (opacity / 100.0)); var isLightTheme = IsLightTheme(); var windowBackground = isLightTheme @@ -395,7 +612,7 @@ private void RefreshWindowButtons() if (_windowContainer != null) { _windowContainer.Background = windowBackground; - _windowContainer.BoxShadow = _configHandler.Data.FloatingWindowShadowEnabled + _windowContainer.BoxShadow = profile.FloatingWindowShadowEnabled ? new BoxShadows(new BoxShadow { OffsetX = 0, @@ -413,19 +630,17 @@ private void RefreshWindowButtons() _stackPanel.HorizontalAlignment = HorizontalAlignment.Center; _stackPanel.Children.Clear(); + _touchDragHandle = null; - if (_isTouchDeviceDetected) - { - _touchDragHandle = CreateTouchDragHandle(scale, contentForeground); - _stackPanel.Children.Add(_touchDragHandle); - } - else - { - _touchDragHandle = null; - } - + int rowIndex = 0; foreach (var rowEntries in GetOrderedRows()) { + if (_rulesetHiddenRows.Contains(rowIndex)) + { + rowIndex++; + continue; + } + var rowPanel = new StackPanel { Orientation = Orientation.Horizontal, @@ -493,11 +708,11 @@ private void RefreshWindowButtons() } else { - // 保持自动布局,允许文本变更/缩放后重新测量自然宽度。 button.Width = double.NaN; } - button.LayoutUpdated += (_, _) => + EventHandler? layoutUpdatedHandler = null; + layoutUpdatedHandler = (_, _) => { if (entry.IsRevertStyleActive) { @@ -508,8 +723,10 @@ private void RefreshWindowButtons() if (width > 0) { _buttonWidthCache[entry.ButtonId] = width; + button.LayoutUpdated -= layoutUpdatedHandler; } }; + button.LayoutUpdated += layoutUpdatedHandler; button.PointerPressed += (_, e) => { @@ -529,62 +746,112 @@ private void RefreshWindowButtons() rowPanel.Children.Add(button); } - if (rowPanel.Children.Count > 0) - { - _stackPanel.Children.Add(rowPanel); - } + _stackPanel.Children.Add(rowPanel); + + rowIndex++; + } + + // 仅在"至少有一个可见按钮"时才显示拖拽把手,避免孤零零一个把手 + var hasVisibleButtons = _stackPanel.Children.Count > 0; + var showDragHandle = (_isTouchDeviceDetected || _profileManager.CurrentProfile.FloatingWindowDragHandleAlwaysVisible) + && hasVisibleButtons; + + if (showDragHandle) + { + _touchDragHandle = CreateTouchDragHandle(scale, contentForeground); + _stackPanel.Children.Insert(0, _touchDragHandle); } } - private List> GetOrderedRows() + /// + /// 判断是否至少有 1 个按钮在"经过规则集过滤后"是可见的。 + /// 用于避免悬浮窗在没有任何可见按钮时(被规则集全部隐藏)仍然显示。 + /// + private bool HasAnyVisibleButton() { - EnsureUniqueButtonIds(); - var values = _entries.Values - .GroupBy(x => x.ButtonId) - .ToDictionary(x => x.Key, x => x.First()); - var order = _configHandler.Data.FloatingWindowButtonOrder ?? []; - - var orderedIds = values.Keys - .OrderBy(id => - { - var index = order.IndexOf(id); - return index < 0 ? int.MaxValue : index; - }) - .ThenBy(id => id) - .ToList(); + if (_entries.Count == 0) + { + return false; + } - var used = new HashSet(); - var rows = new List>(); + var profile = _profileManager.CurrentProfile; + var rowConfigs = profile.FloatingWindowRowRulesets; + var hiddenRowSet = new HashSet(); - foreach (var row in _configHandler.Data.FloatingWindowButtonRows ?? []) + if (rowConfigs != null) { - var items = row - .Where(id => values.ContainsKey(id) && used.Add(id)) - .Select(id => values[id]) - .ToList(); - if (items.Count > 0) + for (int i = 0; i < rowConfigs.Count; i++) { - rows.Add(items); + var cfg = rowConfigs[i]; + var shouldHide = !cfg.IsVisible + || (cfg.HideOnRule && cfg.HidingRules != null + && IAppHost.TryGetService() is { } rs + && rs.IsRulesetSatisfied(cfg.HidingRules)); + if (shouldHide) + { + hiddenRowSet.Add(i); + } } } - var missing = orderedIds - .Where(id => !used.Contains(id)) - .Select(id => values[id]) - .ToList(); - - if (rows.Count == 0) + int rowIndex = 0; + foreach (var row in profile.FloatingWindowButtonRows ?? []) { - rows.Add(missing); + if (!hiddenRowSet.Contains(rowIndex)) + { + foreach (var id in row) + { + if (_rulesetHiddenButtons.Contains(id)) + { + continue; + } + foreach (var entry in _entries.Values) + { + if (string.Equals(entry.ButtonId, id, StringComparison.Ordinal)) + { + return true; + } + } + } + } + rowIndex++; } - else + + return false; + } + + private List> GetOrderedRows() + { + var profile = _profileManager.CurrentProfile; + var validButtonIds = _entries.Values.Select(x => x.ButtonId).ToHashSet(); + + // 清理不存在的按钮ID + if (profile.PruneInvalidButtonIds(validButtonIds)) { - rows[0].AddRange(missing); + _profileManager.SaveProfile(); } - if (rows.Count == 0) + var values = _entries.Values + .Where(x => !_rulesetHiddenButtons.Contains(x.ButtonId)) + .GroupBy(x => x.ButtonId) + .ToDictionary(g => g.Key, g => g.First()); + + var rows = new List>(); + + foreach (var row in profile.FloatingWindowButtonRows ?? []) { - rows.Add([]); + var items = new List(); + foreach (var id in row) + { + if (values.TryGetValue(id, out var entry)) + { + items.Add(entry); + } + } + if (items.Count > 0) + { + rows.Add(items); + } } return rows; @@ -864,7 +1131,6 @@ private IntPtr OnLowLevelMouse(int nCode, IntPtr wParam, IntPtr lParam) } else if (message == WmLButtonDown || message == WmRButtonDown) { - // 仅在明确的鼠标点击操作时切回鼠标模式,避免触屏后被背景鼠标移动事件自动恢复。 SetTouchInputMode(false); } @@ -1003,6 +1269,14 @@ private void EnsureLayerRecheckHooks() LayerRecheck50MsTimer.Tick += OnLayerRecheck50MsTimerTick; LayerRecheck1MsTimer.Tick -= OnLayerRecheck1MsTimerTick; LayerRecheck1MsTimer.Tick += OnLayerRecheck1MsTimerTick; + + // 规则集巡检由 ILessonsService.PostMainTimerTicked 驱动 + _lessonsService ??= IAppHost.TryGetService(); + if (_lessonsService != null) + { + _lessonsService.PostMainTimerTicked -= OnPostMainTimerTicked; + _lessonsService.PostMainTimerTicked += OnPostMainTimerTicked; + } } private void RemoveLayerRecheckHooks() @@ -1018,11 +1292,20 @@ private void RemoveLayerRecheckHooks() UnhookWinEvent(_reorderHook); _reorderHook = default; } + + LayerRecheck50MsTimer.Tick -= OnLayerRecheck50MsTimerTick; + LayerRecheck1MsTimer.Tick -= OnLayerRecheck1MsTimerTick; + + if (_lessonsService != null) + { + _lessonsService.PostMainTimerTicked -= OnPostMainTimerTicked; + } } private void RefreshLayerRecheckMode() { - var mode = _configHandler.Data.FloatingWindowLayerRecheckMode; + var profile = _profileManager.CurrentProfile; + var mode = profile.FloatingWindowLayerRecheckMode; var useReorderHook = mode == 0; var useForegroundHook = mode == 1; @@ -1104,9 +1387,19 @@ private void RemoveReorderHook() _reorderHook = default; } + private void OnPostMainTimerTicked(object? sender, EventArgs e) + { + // 模式 2/3 改回 DispatcherTimer Tick 事件触发,本回调只负责规则集巡检 + CheckFloatingWindowRuleset(); + CheckButtonRulesets(); + CheckRowRulesets(); + // 兜底 ApplyVisibility:避免所有按钮都被隐藏但窗口仍显示 + ApplyVisibility(); + } + private void OnLayerRecheck50MsTimerTick(object? sender, EventArgs e) { - if (_configHandler.Data.FloatingWindowLayerRecheckMode == 2) + if (_profileManager.CurrentProfile.FloatingWindowLayerRecheckMode == 2) { RecheckWindowLayer(); } @@ -1114,7 +1407,7 @@ private void OnLayerRecheck50MsTimerTick(object? sender, EventArgs e) private void OnLayerRecheck1MsTimerTick(object? sender, EventArgs e) { - if (_configHandler.Data.FloatingWindowLayerRecheckMode == 3) + if (_profileManager.CurrentProfile.FloatingWindowLayerRecheckMode == 3) { RecheckWindowLayer(); } @@ -1128,7 +1421,7 @@ private void OnWinEvent(IntPtr hWinEventHook, uint @event, IntPtr hwnd, int idOb return; } - var mode = _configHandler.Data.FloatingWindowLayerRecheckMode; + var mode = _profileManager.CurrentProfile.FloatingWindowLayerRecheckMode; var shouldRecheck = (@event == EventObjectReorder && mode == 0) || (@event == EventSystemForeground && mode == 1); if (!shouldRecheck) @@ -1161,7 +1454,7 @@ private void RecheckWindowLayer() SET_WINDOW_POS_FLAGS.SWP_NOSENDCHANGING; var hwnd = new HWND(handle); - if (_configHandler.Data.FloatingWindowLayer == 0) + if (_profileManager.CurrentProfile.FloatingWindowLayer == 0) { _window.Topmost = false; PInvoke.SetWindowPos(hwnd, HwndBottom, 0, 0, 0, 0, flags); @@ -1172,9 +1465,112 @@ private void RecheckWindowLayer() PInvoke.SetWindowPos(hwnd, HwndTopmost, 0, 0, 0, 0, flags); } + public void ToggleWindowLayer() + { + var profile = _profileManager.CurrentProfile; + profile.FloatingWindowLayer = profile.FloatingWindowLayer == 1 ? 0 : 1; + _profileManager.SaveProfile(); + Dispatcher.UIThread.Post(() => + { + if (_window != null) + { + _window.Topmost = profile.FloatingWindowLayer == 1; + } + RecheckWindowLayer(); + RefreshLayerRecheckMode(); + }); + } + + public void SetWindowLayer(int layer) + { + var profile = _profileManager.CurrentProfile; + profile.FloatingWindowLayer = layer == 1 ? 1 : 0; + _profileManager.SaveProfile(); + Dispatcher.UIThread.Post(() => + { + if (_window != null) + { + _window.Topmost = profile.FloatingWindowLayer == 1; + } + RecheckWindowLayer(); + RefreshLayerRecheckMode(); + }); + } + + public void ToggleWindowProfile() + { + var names = _profileManager.GetProfileNames(); + if (names.Count <= 1) + { + return; + } + + var currentName = _profileManager.CurrentProfileName; + var currentIndex = -1; + for (int i = 0; i < names.Count; i++) + { + if (string.Equals(names[i], currentName, StringComparison.OrdinalIgnoreCase)) + { + currentIndex = i; + break; + } + } + if (currentIndex < 0) + { + currentIndex = 0; + } + + var newIndex = (currentIndex + 1) % names.Count; + var newName = names[newIndex]; + SwitchToProfile(newName); + } + + public void SwitchToProfile(string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return; + } + + var names = _profileManager.GetProfileNames(); + if (!names.Contains(profileName)) + { + return; + } + + // 只在当前方案文件还存在时才保存,避免刚被删除的方案被重新写回磁盘 + if (_profileManager.ProfileFileExists(_profileManager.CurrentProfileName)) + { + _profileManager.SaveProfile(); + } + _profileManager.LoadProfile(profileName); + _configHandler.Data.CurrentFloatingWindowProfile = profileName; + _configHandler.Save(); + + Dispatcher.UIThread.Post(() => + { + RefreshWindowButtons(); + ApplyVisibility(); + RecheckWindowLayer(); + RefreshLayerRecheckMode(); + }); + } + + private static IBrush? TryParseColor(string colorString) + { + try + { + return new SolidColorBrush(Color.Parse(colorString)); + } + catch + { + return null; + } + } + public static string ConvertIcon(string raw) { - if (string.IsNullOrWhiteSpace(raw)) return "?"; + if (string.IsNullOrWhiteSpace(raw)) return "\uEA37"; var v = raw.Trim(); if (v.StartsWith("/u", StringComparison.OrdinalIgnoreCase) || v.StartsWith("\\u", StringComparison.OrdinalIgnoreCase)) { diff --git a/Settings/FloatingWindowTriggerSettings.cs b/Settings/FloatingWindowTriggerSettings.cs index 55e2c3dc..6976a4d3 100644 --- a/Settings/FloatingWindowTriggerSettings.cs +++ b/Settings/FloatingWindowTriggerSettings.cs @@ -109,7 +109,7 @@ private Control BuildIconPickerContent(ObservableCollection rows) VerticalAlignment = VerticalAlignment.Stretch }; - listBox.ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()); + listBox.ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()); listBox.ItemTemplate = new FuncDataTemplate((row, _) => BuildIconRow(row)); listBox.Height = 520; diff --git a/Settings/SwitchFloatingWindowThemeSettings.cs b/Settings/SwitchFloatingWindowThemeSettings.cs new file mode 100644 index 00000000..cf2c251d --- /dev/null +++ b/Settings/SwitchFloatingWindowThemeSettings.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SystemTools.Settings; + +/// +/// 切换悬浮窗主题行动的设置 +/// +public class SwitchFloatingWindowThemeSettings +{ + /// + /// 目标主题。0=跟随系统, 1=浅色, 2=深色。 + /// + [JsonPropertyName("targetTheme")] + public int TargetTheme { get; set; } = -1; +} diff --git a/Settings/ToggleFloatingWindowLayerSettings.cs b/Settings/ToggleFloatingWindowLayerSettings.cs new file mode 100644 index 00000000..28f4908e --- /dev/null +++ b/Settings/ToggleFloatingWindowLayerSettings.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SystemTools.Settings; + +/// +/// 切换悬浮窗层级行动的设置 +/// +public class ToggleFloatingWindowLayerSettings +{ + /// + /// 目标层级。-1 表示切换,0 表示置底,1 表示置顶。 + /// + [JsonPropertyName("targetLayer")] + public int TargetLayer { get; set; } = -1; +} diff --git a/Settings/ToggleFloatingWindowProfileSettings.cs b/Settings/ToggleFloatingWindowProfileSettings.cs new file mode 100644 index 00000000..0c20a256 --- /dev/null +++ b/Settings/ToggleFloatingWindowProfileSettings.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SystemTools.Settings; + +/// +/// 切换悬浮窗配置方案行动的设置 +/// +public class ToggleFloatingWindowProfileSettings +{ + /// + /// 目标配置方案名称。null 表示切换到下一个,其他值表示指定方案名称。 + /// + [JsonPropertyName("targetProfileName")] + public string? TargetProfileName { get; set; } +} diff --git a/SettingsPage/AboutSettingsPage.axaml.cs b/SettingsPage/AboutSettingsPage.axaml.cs index b77c3b85..00ebec4a 100644 --- a/SettingsPage/AboutSettingsPage.axaml.cs +++ b/SettingsPage/AboutSettingsPage.axaml.cs @@ -41,7 +41,10 @@ private void UriNavigationCommands_OnClick(object sender, RoutedEventArgs e) Button s => s.CommandParameter?.ToString(), _ => "classisland://app/test/" }; - IAppHost.TryGetService()?.NavigateWrapped(new Uri(url)); + if (!string.IsNullOrWhiteSpace(url)) + { + IAppHost.TryGetService()?.NavigateWrapped(new Uri(url)); + } } private void CheckAutoSwitchTab() diff --git a/SettingsPage/FloatingWindowEditorSettingsPage.axaml b/SettingsPage/FloatingWindowEditorSettingsPage.axaml index d6a42817..605f167b 100644 --- a/SettingsPage/FloatingWindowEditorSettingsPage.axaml +++ b/SettingsPage/FloatingWindowEditorSettingsPage.axaml @@ -6,38 +6,190 @@ xmlns:ci="http://classisland.tech/schemas/xaml/core" xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:controls1="clr-namespace:SystemTools.Controls" + xmlns:ruleset="clr-namespace:ClassIsland.Core.Controls.Ruleset;assembly=ClassIsland.Core" + xmlns:local="clr-namespace:SystemTools" mc:Ignorable="d" - d:DesignHeight="450" + d:DesignHeight="800" d:DesignWidth="800"> + + + + - + - + 设置 · 悬浮窗 + + + + + + + + + + - \ No newline at end of file + diff --git a/SettingsPage/FloatingWindowEditorSettingsPage.axaml.cs b/SettingsPage/FloatingWindowEditorSettingsPage.axaml.cs index 82ce4366..76091727 100644 --- a/SettingsPage/FloatingWindowEditorSettingsPage.axaml.cs +++ b/SettingsPage/FloatingWindowEditorSettingsPage.axaml.cs @@ -1,17 +1,27 @@ -using System; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics; using System.IO; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Media; using Avalonia.VisualTree; using ClassIsland.Core; using ClassIsland.Core.Abstractions; using ClassIsland.Core.Abstractions.Controls; +using ClassIsland.Core.Abstractions.Services; using ClassIsland.Core.Attributes; +using ClassIsland.Core.Controls; +using ClassIsland.Core.Controls.Ruleset; +using ClassIsland.Core.Models.Ruleset; using ClassIsland.Shared; +using FluentAvalonia.UI.Controls; using SystemTools.ConfigHandlers; using SystemTools.Services; using SystemTools.Shared; @@ -36,13 +46,38 @@ public FloatingWindowEditorSettingsPage() DataContext = this; InitializeComponent(); + ViewModel.RefreshFloatingWindowProfiles(); ViewModel.RefreshFloatingTriggers(); + ViewModel.CurrentFloatingWindowProfile.PropertyChanged += OnProfilePropertyChanged; ViewModel.Settings.PropertyChanged += OnSettingsPropertyChanged; + ViewModel.ProfileChanged += OnViewModelProfileChanged; + + // 注册全局设置变更监听(ShowFloatingWindow 和规则集不随方案切换) + RegisterHidingRulesEvents(); } public SystemToolsSettingsViewModel ViewModel { get; } + private bool _isDisposed; + private Point? _floatingDragStartPoint; + private Border? _floatingDragSourceBorder; + + // ===== 规则集 Drawer 状态 ===== + private enum RulesetTargetType { Button, Row, Window } + private RulesetTargetType _currentRulesetTarget; + private FloatingTriggerItem? _currentButtonTarget; + private FloatingTriggerRow? _currentRowTarget; + + // Drawer 内的控件引用 + private ToggleSwitch? _drawerIsVisibleToggle; + private ToggleSwitch? _drawerHideOnRuleToggle; + private RulesetControl? _drawerRulesetControl; + + // 当前 Drawer 中正在编辑的规则集,用于实时监听其变化 + private Ruleset? _currentDrawerRuleset; + private readonly List _rulesetPropertyListeners = new(); + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); @@ -52,40 +87,195 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e return; } + ViewModel.CurrentFloatingWindowProfile.PropertyChanged -= OnProfilePropertyChanged; ViewModel.Settings.PropertyChanged -= OnSettingsPropertyChanged; + ViewModel.ProfileChanged -= OnViewModelProfileChanged; + + UnregisterHidingRulesEvents(); + DetachRulesetListeners(); + ViewModel.Dispose(); _isDisposed = true; } + private void OnProfilePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(FloatingWindowProfile.FloatingWindowScale) + or nameof(FloatingWindowProfile.FloatingWindowIconSize) + or nameof(FloatingWindowProfile.FloatingWindowTextSize) + or nameof(FloatingWindowProfile.FloatingWindowOpacity) + or nameof(FloatingWindowProfile.FloatingWindowShadowEnabled) + or nameof(FloatingWindowProfile.FloatingWindowLayer) + or nameof(FloatingWindowProfile.FloatingWindowLayerRecheckMode) + or nameof(FloatingWindowProfile.FloatingWindowDragHandleAlwaysVisible) + or nameof(FloatingWindowProfile.FloatingWindowHorizontal)) + { + IAppHost.GetService().ProfileManager.SaveProfile(); + IAppHost.GetService().UpdateWindowState(); + } + } + + /// + /// 重新注册 Profile 属性变更事件监听(切换方案后需要重新注册) + /// + public void ReattachProfilePropertyChanged() + { + ViewModel.CurrentFloatingWindowProfile.PropertyChanged -= OnProfilePropertyChanged; + ViewModel.CurrentFloatingWindowProfile.PropertyChanged += OnProfilePropertyChanged; + + // 重新注册悬浮窗规则集变更监听 + UnregisterHidingRulesEvents(); + RegisterHidingRulesEvents(); + } + + private void RegisterHidingRulesEvents() + { + if (ViewModel.Settings.FloatingWindowRuleset is INotifyPropertyChanged hidingRules) + { + hidingRules.PropertyChanged += OnHidingRulesPropertyChanged; + } + } + + private void UnregisterHidingRulesEvents() + { + if (ViewModel.Settings.FloatingWindowRuleset is INotifyPropertyChanged hidingRules) + { + hidingRules.PropertyChanged -= OnHidingRulesPropertyChanged; + } + } + private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(MainConfigData.ShowFloatingWindow) - or nameof(MainConfigData.FloatingWindowScale) - or nameof(MainConfigData.FloatingWindowIconSize) - or nameof(MainConfigData.FloatingWindowTextSize) - or nameof(MainConfigData.FloatingWindowOpacity) - or nameof(MainConfigData.FloatingWindowTheme) - or nameof(MainConfigData.FloatingWindowShadowEnabled) - or nameof(MainConfigData.FloatingWindowLayer) - or nameof(MainConfigData.FloatingWindowLayerRecheckMode)) + if (e.PropertyName is nameof(MainConfigData.FloatingWindowTheme)) + { + GlobalConstants.MainConfig?.Save(); + IAppHost.GetService().UpdateWindowState(); + } + else if (e.PropertyName is nameof(MainConfigData.ShowFloatingWindow) + or nameof(MainConfigData.FloatingWindowRulesetEnabled)) { + GlobalConstants.MainConfig?.Save(); IAppHost.GetService().UpdateWindowState(); + IAppHost.TryGetService()?.NotifyStatusChanged(); } + else if (e.PropertyName == nameof(MainConfigData.FloatingWindowRuleset)) + { + // Ruleset 对象被替换时,重新注册事件 + UnregisterHidingRulesEvents(); + RegisterHidingRulesEvents(); + GlobalConstants.MainConfig?.Save(); + IAppHost.TryGetService()?.NotifyStatusChanged(); + } + } + + private void OnViewModelProfileChanged(object? sender, EventArgs e) + { + ReattachProfilePropertyChanged(); } - private void OnFloatingWindowConfigChanged(object? sender, RoutedEventArgs e) + private void OnHidingRulesPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (!ViewModel.HasFloatingTriggerEntries) + // 规则集求值时会写入 State(Ruleset/RuleGroup/Rule),避免因此递归触发通知 + if (IsRulesetStateProperty(e.PropertyName)) { - ViewModel.Settings.ShowFloatingWindow = false; + return; } - ViewModel.RefreshFloatingTriggers(); - IAppHost.GetService().UpdateWindowState(); + GlobalConstants.MainConfig?.Save(); + IAppHost.TryGetService()?.NotifyStatusChanged(); } - private Point? _floatingDragStartPoint; - private Border? _floatingDragSourceBorder; + private void OnFloatingWindowVisibleToggleChanged(object? sender, RoutedEventArgs e) + { + if (sender is not ToggleSwitch toggle) + { + return; + } + + var service = IAppHost.GetService(); + var config = ViewModel.Settings; + + // 没有可用按钮时强制隐藏 + var shouldShow = toggle.IsChecked == true && service.Entries.Count > 0; + config.ShowFloatingWindow = shouldShow; + + // 同步 ToggleSwitch 状态(可能被强制隐藏) + if (toggle.IsChecked != shouldShow) + { + toggle.IsChecked = shouldShow; + } + + GlobalConstants.MainConfig?.Save(); + service.UpdateWindowState(); + } + + private void OnFloatingWindowProfileSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (sender is not ComboBox comboBox || comboBox.SelectedItem is not string profileName) + { + return; + } + + ViewModel.SwitchFloatingWindowProfile(profileName); + } + + private async void OnAddFloatingWindowProfileClick(object? sender, RoutedEventArgs e) + { + var textBox = new TextBox { Text = "" }; + var dialogResult = await new ContentDialog + { + Title = "新建悬浮窗配置方案", + DefaultButton = ContentDialogButton.Primary, + PrimaryButtonText = "创建", + SecondaryButtonText = "取消", + Content = new Field + { + Content = textBox, + Label = "配置方案名称", + Suffix = ".json" + } + }.ShowAsync(); + + if (dialogResult != ContentDialogResult.Primary) + { + return; + } + + var createProfileName = textBox.Text?.Trim(); + if (string.IsNullOrWhiteSpace(createProfileName)) + { + return; + } + + var path = Path.Combine(ViewModel.FloatingWindowProfilesDirectory, + createProfileName + ".json"); + if (File.Exists(path)) + { + return; + } + + ViewModel.AddFloatingWindowProfile(createProfileName); + } + + private void OnOpenFloatingWindowProfileFolderClick(object? sender, RoutedEventArgs e) + { + Process.Start(new ProcessStartInfo + { + FileName = Path.GetFullPath(ViewModel.FloatingWindowProfilesDirectory), + UseShellExecute = true + }); + } + + private void OnRemoveCurrentProfileClick(object? sender, RoutedEventArgs e) + { + var currentName = ViewModel.SelectedFloatingWindowProfile; + if (string.IsNullOrWhiteSpace(currentName)) + { + return; + } + + ViewModel.RemoveFloatingWindowProfile(currentName); + } private void OnAddFloatingTriggerRowClick(object? sender, RoutedEventArgs e) { @@ -107,6 +297,298 @@ private void OnRemoveFloatingTriggerRowClick(object? sender, RoutedEventArgs e) _ = ViewModel.RemoveFloatingTriggerRow(row); } + private void ButtonOpenFloatingWindowRuleset_OnClick(object? sender, RoutedEventArgs e) + { + _currentRulesetTarget = RulesetTargetType.Window; + _currentButtonTarget = null; + _currentRowTarget = null; + + var config = ViewModel.Settings; + OpenRulesetDrawer(config.FloatingWindowRuleset, true, config.FloatingWindowRulesetEnabled); + } + + /// + /// 打开规则集 Drawer,包含 IsVisible/HideOnRule 开关和规则集编辑器(参照 ClassIsland) + /// + private void OpenRulesetDrawer(ClassIsland.Core.Models.Ruleset.Ruleset ruleset, bool isVisible, bool hideOnRule) + { + // 先清理上一次 Drawer 的规则集监听,避免内存泄漏和重复通知 + DetachRulesetListeners(); + + // 每次打开时动态构建 Drawer 内容,避免资源单例问题 + var panel = new StackPanel { Spacing = 8, Margin = new Thickness(0, 8, 0, 0) }; + + // Window 目标下,IsVisible 由顶栏的"显示"总开关控制,这里仅显示提示 + if (_currentRulesetTarget == RulesetTargetType.Window) + { + var hint = new TextBlock + { + Text = "此规则集用于控制整窗悬浮窗的隐藏。窗口的“显示 / 隐藏”由设置页顶栏的总开关控制。", + Foreground = TextFillColorSecondaryBrush(), + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Margin = new Thickness(0, 0, 0, 8) + }; + panel.Children.Add(hint); + } + + // 开关面板 + var togglesPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 16, Margin = new Thickness(0, 0, 0, 8) }; + + _drawerIsVisibleToggle = new ToggleSwitch + { + OnContent = "显示", + OffContent = "隐藏", + IsChecked = isVisible, + IsVisible = _currentRulesetTarget != RulesetTargetType.Window + }; + ToolTip.SetTip(_drawerIsVisibleToggle, "控制此项目是否显示"); + _drawerIsVisibleToggle.IsCheckedChanged += OnDrawerIsVisibleChanged; + + _drawerHideOnRuleToggle = new ToggleSwitch + { + OnContent = "按规则隐藏", + OffContent = "禁用规则", + IsChecked = hideOnRule + }; + ToolTip.SetTip(_drawerHideOnRuleToggle, "启用后,满足规则集条件时自动隐藏"); + _drawerHideOnRuleToggle.IsCheckedChanged += OnDrawerHideOnRuleChanged; + + togglesPanel.Children.Add(_drawerIsVisibleToggle); + togglesPanel.Children.Add(_drawerHideOnRuleToggle); + panel.Children.Add(togglesPanel); + + // 规则集编辑器 + _drawerRulesetControl = new RulesetControl { Classes = { "in-drawer" }, Ruleset = ruleset }; + panel.Children.Add(_drawerRulesetControl); + + // 监听规则集内容变化,编辑时实时刷新悬浮窗状态 + AttachRulesetListeners(ruleset); + + // 将内容放入 Resources 并打开 Drawer + this.Resources["RulesetDrawerContent"] = panel; + OpenDrawer("RulesetDrawerContent"); + } + + private IBrush? TextFillColorSecondaryBrush() + { + if (Application.Current?.Resources.TryGetResource("TextFillColorSecondaryBrush", null, out var res) == true + && res is IBrush brush) + { + return brush; + } + return null; + } + + private void OnDrawerIsVisibleChanged(object? sender, RoutedEventArgs e) + { + var value = _drawerIsVisibleToggle?.IsChecked == true; + + switch (_currentRulesetTarget) + { + case RulesetTargetType.Button when _currentButtonTarget != null: + _currentButtonTarget.Config.IsVisible = value; + break; + case RulesetTargetType.Row when _currentRowTarget != null: + _currentRowTarget.RowRuleset.IsVisible = value; + break; + } + + IAppHost.GetService().ProfileManager.SaveProfile(); + IAppHost.GetService().UpdateWindowState(); + NotifyRulesetStatusChanged(); + } + + private void OnDrawerHideOnRuleChanged(object? sender, RoutedEventArgs e) + { + var value = _drawerHideOnRuleToggle?.IsChecked == true; + + switch (_currentRulesetTarget) + { + case RulesetTargetType.Button when _currentButtonTarget != null: + _currentButtonTarget.Config.HideOnRule = value; + break; + case RulesetTargetType.Row when _currentRowTarget != null: + _currentRowTarget.RowRuleset.HideOnRule = value; + break; + case RulesetTargetType.Window: + ViewModel.Settings.FloatingWindowRulesetEnabled = value; + GlobalConstants.MainConfig?.Save(); + break; + } + + IAppHost.GetService().ProfileManager.SaveProfile(); + IAppHost.GetService().UpdateWindowState(); + NotifyRulesetStatusChanged(); + } + + private void NotifyRulesetStatusChanged() + { + IAppHost.TryGetService()?.NotifyStatusChanged(); + } + + private void AttachRulesetListeners(Ruleset ruleset) + { + DetachRulesetListeners(); + _currentDrawerRuleset = ruleset; + + AddRulesetPropertyListener(ruleset); + ruleset.Groups.CollectionChanged += OnRulesetGroupsCollectionChanged; + + foreach (var group in ruleset.Groups) + { + AddRulesetPropertyListener(group); + group.Rules.CollectionChanged += OnRulesetRulesCollectionChanged; + foreach (var rule in group.Rules) + { + AddRulesetPropertyListener(rule); + } + } + } + + private void DetachRulesetListeners() + { + foreach (var listener in _rulesetPropertyListeners) + { + listener.PropertyChanged -= OnRulesetPropertyChanged; + } + _rulesetPropertyListeners.Clear(); + + if (_currentDrawerRuleset != null) + { + _currentDrawerRuleset.Groups.CollectionChanged -= OnRulesetGroupsCollectionChanged; + foreach (var group in _currentDrawerRuleset.Groups) + { + group.Rules.CollectionChanged -= OnRulesetRulesCollectionChanged; + } + _currentDrawerRuleset = null; + } + } + + private void AddRulesetPropertyListener(INotifyPropertyChanged listener) + { + listener.PropertyChanged += OnRulesetPropertyChanged; + _rulesetPropertyListeners.Add(listener); + } + + /// + /// 判断属性名是否为规则集求值写入的 State(避免递归通知) + /// + private static bool IsRulesetStateProperty(string? propertyName) + { + return propertyName == nameof(Ruleset.State) + || propertyName == nameof(RuleGroup.State) + || propertyName == nameof(Rule.State); + } + + private void OnRulesetPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // 规则集求值时会写入 State(Ruleset/RuleGroup/Rule),避免因此递归触发通知 + if (IsRulesetStateProperty(e.PropertyName)) + { + return; + } + + NotifyRulesetStatusChanged(); + IAppHost.TryGetService()?.UpdateWindowState(); + } + + private void OnRulesetGroupsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_currentDrawerRuleset == null) + { + return; + } + + var ruleset = _currentDrawerRuleset; + DetachRulesetListeners(); + AttachRulesetListeners(ruleset); + + NotifyRulesetStatusChanged(); + IAppHost.TryGetService()?.UpdateWindowState(); + } + + private void OnRulesetRulesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_currentDrawerRuleset == null) + { + return; + } + + var ruleset = _currentDrawerRuleset; + DetachRulesetListeners(); + AttachRulesetListeners(ruleset); + + NotifyRulesetStatusChanged(); + IAppHost.TryGetService()?.UpdateWindowState(); + } + + // ===== 选中状态处理 ===== + + private void OnFloatingTriggerItemSettingsClick(object? sender, RoutedEventArgs e) + { + if (sender is not Control { DataContext: FloatingTriggerItem item }) + { + return; + } + + _currentRulesetTarget = RulesetTargetType.Button; + _currentButtonTarget = item; + _currentRowTarget = null; + + OpenRulesetDrawer(item.Config.HidingRules, item.Config.IsVisible, item.Config.HideOnRule); + } + + private void OnRowRulesetClick(object? sender, RoutedEventArgs e) + { + if (sender is not Control { DataContext: FloatingTriggerRow row }) + { + return; + } + + _currentRulesetTarget = RulesetTargetType.Row; + _currentButtonTarget = null; + _currentRowTarget = row; + + OpenRulesetDrawer(row.RowRuleset.HidingRules, row.RowRuleset.IsVisible, row.RowRuleset.HideOnRule); + } + + private void OnInsertRowBelowClick(object? sender, RoutedEventArgs e) + { + if (sender is not Control { DataContext: FloatingTriggerRow row }) + { + return; + } + + var index = ViewModel.FloatingTriggerRows.IndexOf(row); + if (index < 0) + { + return; + } + + ViewModel.InsertFloatingTriggerRow(index + 1); + } + + private void OnAvailableItemSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (sender is not ListBox listBox || listBox.SelectedItem is not FloatingTriggerItem item) + { + return; + } + + var buttonId = item.ButtonId; + listBox.SelectedItem = null; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (ViewModel.FloatingTriggerRows.Count == 0) + { + ViewModel.AddFloatingTriggerRow(); + } + ViewModel.AddTriggerFromPool(buttonId, 0, ViewModel.FloatingTriggerRows[0].Buttons.Count); + }); + } + private void OnFloatingTriggerItemPointerPressed(object? sender, PointerPressedEventArgs e) { if (sender is not Border border || !e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) @@ -116,11 +598,17 @@ private void OnFloatingTriggerItemPointerPressed(object? sender, PointerPressedE _floatingDragSourceBorder = border; _floatingDragStartPoint = e.GetPosition(border); + // 主动 capture,避免鼠标移出 Border 后丢失 PointerMoved/PointerReleased + e.Pointer.Capture(border); e.Handled = e.Pointer.Type is PointerType.Touch or PointerType.Pen; } private void OnFloatingTriggerItemPointerReleased(object? sender, PointerReleasedEventArgs e) { + if (e.Pointer.Captured == sender) + { + e.Pointer.Capture(null); + } _floatingDragSourceBorder = null; _floatingDragStartPoint = null; } @@ -153,8 +641,10 @@ private async void OnFloatingTriggerItemPointerMoved(object? sender, PointerEven _floatingDragSourceBorder = null; _floatingDragStartPoint = null; + // 在 await 之前缓存 Pointer.Type,避免事件参数被回收后访问 + var isTouchOrPen = e.Pointer.Type is PointerType.Touch or PointerType.Pen; await DragDrop.DoDragDrop(e, data, DragDropEffects.Move); - e.Handled = e.Pointer.Type is PointerType.Touch or PointerType.Pen; + e.Handled = isTouchOrPen; } private static bool TryGetDragButtonId(DragEventArgs e, out string buttonId) @@ -239,6 +729,7 @@ private void OnFloatingTriggerRowDrop(object? sender, DragEventArgs e) var row = ViewModel.FloatingTriggerRows[rowIndex]; var insertIndex = GetRowInsertIndex(senderControl, row, e); ViewModel.MoveFloatingTrigger(buttonId, rowIndex, insertIndex); + e.Handled = true; } private void OnFloatingTriggerItemDragOver(object? sender, DragEventArgs e) @@ -279,5 +770,6 @@ private void OnFloatingTriggerItemDrop(object? sender, DragEventArgs e) } ViewModel.MoveFloatingTrigger(buttonId, rowIndex, targetIndex); + e.Handled = true; } -} \ No newline at end of file +} diff --git a/SettingsPage/SystemToolsSettingsPage.axaml.cs b/SettingsPage/SystemToolsSettingsPage.axaml.cs index 8a2c6101..70e37fca 100644 --- a/SettingsPage/SystemToolsSettingsPage.axaml.cs +++ b/SettingsPage/SystemToolsSettingsPage.axaml.cs @@ -64,11 +64,7 @@ private void OnRestartPropertyChanged(object? sender, EventArgs e) private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(MainConfigData.ShowFloatingWindow) - or nameof(MainConfigData.FloatingWindowScale)) - { - IAppHost.GetService().UpdateWindowState(); - } + // 主设置页面不再直接监听悬浮窗属性变化,由 FloatingWindowEditorSettingsPage 处理 } diff --git a/SettingsPage/SystemToolsSettingsViewModel.cs b/SettingsPage/SystemToolsSettingsViewModel.cs index a127cc33..bad95b66 100644 --- a/SettingsPage/SystemToolsSettingsViewModel.cs +++ b/SettingsPage/SystemToolsSettingsViewModel.cs @@ -7,11 +7,14 @@ using System.Collections.ObjectModel; using System.Collections.Generic; using System; +using System.ComponentModel; using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using SystemTools.Shared; using SystemTools.Services; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Shared; namespace SystemTools; @@ -46,11 +49,30 @@ public partial class FloatingTriggerItem : ObservableObject [ObservableProperty] private string _buttonId = string.Empty; [ObservableProperty] private string _icon = string.Empty; [ObservableProperty] private string _buttonName = string.Empty; + [ObservableProperty] private bool _isRulesetExpanded = false; + [ObservableProperty] private ButtonRulesetConfig _config = new(); + + /// + /// FluentIconSource,供 IconSourceElement 使用 + /// + public ClassIsland.Core.Controls.FluentIconSource? IconSource + { + get + { + if (string.IsNullOrEmpty(Icon)) return null; + return new ClassIsland.Core.Controls.FluentIconSource { Glyph = Icon }; + } + } + + partial void OnIconChanged(string value) { OnPropertyChanged(nameof(IconSource)); } } public partial class FloatingTriggerRow : ObservableObject { [ObservableProperty] private ObservableCollection _buttons = new(); + [ObservableProperty] private int _rowIndex = 0; + [ObservableProperty] private RowRulesetConfig _rowRuleset = new(); + [ObservableProperty] private bool _isRulesetExpanded = false; } public partial class SystemToolsSettingsViewModel : ObservableObject, IDisposable @@ -77,6 +99,14 @@ public partial class SystemToolsSettingsViewModel : ObservableObject, IDisposabl [ObservableProperty] private ObservableCollection _floatingTriggerRows = new(); [ObservableProperty] private bool _hasFloatingTriggerEntries; + // 选中状态 + [ObservableProperty] private FloatingTriggerRow? _selectedFloatingTriggerRow; + [ObservableProperty] private FloatingTriggerItem? _selectedFloatingTriggerItem; + + // 悬浮窗配置方案 + [ObservableProperty] private ObservableCollection _floatingWindowProfileNames = new(); + [ObservableProperty] private string _selectedFloatingWindowProfile = "Default"; + private const string DownloadUrl = "https://livefile.xesimg.com/programme/python_assets/f94fcfa40c9de41d6df09566a51e3130.exe"; private const string ExpectedMd5 = "f94fcfa40c9de41d6df09566a51e3130"; @@ -174,7 +204,7 @@ public void InitializeFeatureItems() ("SystemTools.WindowOperation", "窗口操作", "模拟操作"), ("SystemTools.AltF4", "按下 Alt+F4", "常用模拟键"), ("SystemTools.AltTab", "按下 Alt+Tab", "常用模拟键"), - ("SystemTools.AltTab", "按下 Ctrl+Z", "常用模拟键"), + ("SystemTools.CtrlZ", "按下 Ctrl+Z", "常用模拟键"), ("SystemTools.EnterKey", "按下 Enter 键", "常用模拟键"), ("SystemTools.EscKey", "按下 Esc 键", "常用模拟键"), ("SystemTools.F11Key", "按下 F11 键", "常用模拟键"), @@ -220,6 +250,8 @@ public void InitializeFeatureItems() if (Settings.EnableFloatingWindowFeature) { actions.Add(("SystemTools.ShowFloatingWindow", "显示悬浮窗", "悬浮窗设置")); + actions.Add(("SystemTools.ToggleFloatingWindowLayer", "切换悬浮窗层级", "悬浮窗设置")); + actions.Add(("SystemTools.ToggleFloatingWindowProfile", "切换悬浮窗配置方案", "悬浮窗设置")); } foreach (var (id, name, group) in actions) @@ -259,7 +291,23 @@ public void SaveFeatureSettings() _configHandler.Save(); } + public FloatingWindowProfile CurrentFloatingWindowProfile => _floatingWindowService.ProfileManager.CurrentProfile; + + /// + /// 悬浮窗方案 JSON 文件所在目录,供 UI 层打开文件夹/重名检测使用。 + /// + public string FloatingWindowProfilesDirectory => _floatingWindowService.ProfileManager.ProfilesDirectory; + public void RefreshFloatingWindowProfiles() + { + var names = _floatingWindowService.ProfileManager.GetProfileNames(); + FloatingWindowProfileNames.Clear(); + foreach (var name in names) + { + FloatingWindowProfileNames.Add(name); + } + SelectedFloatingWindowProfile = _floatingWindowService.ProfileManager.CurrentProfileName; + } public void RefreshFloatingTriggers() { @@ -269,74 +317,251 @@ public void RefreshFloatingTriggers() .ToDictionary(x => x.Key, x => x.First()); HasFloatingTriggerEntries = entries.Count > 0; - if (!HasFloatingTriggerEntries && Settings.ShowFloatingWindow) + var profile = CurrentFloatingWindowProfile; + var globalShow = _configHandler.Data.ShowFloatingWindow; + if (!HasFloatingTriggerEntries && globalShow) { - Settings.ShowFloatingWindow = false; + _configHandler.Data.ShowFloatingWindow = false; _configHandler.Save(); + _floatingWindowService.UpdateWindowState(); } - var legacyOrder = Settings.FloatingWindowButtonOrder ?? []; - - var orderedIds = entries.Keys - .OrderBy(id => - { - var i = legacyOrder.IndexOf(id); - return i < 0 ? int.MaxValue : i; - }) - .ThenBy(id => id) - .ToList(); - var used = new HashSet(); - var normalizedRows = new List>(); + // 清理不存在的按钮ID + if (profile.PruneInvalidButtonIds(entries.Keys)) + { + _floatingWindowService.ProfileManager.SaveProfile(); + } - foreach (var row in Settings.FloatingWindowButtonRows ?? []) + // 收集已配置的按钮ID + var configuredIds = new HashSet(); + foreach (var row in profile.FloatingWindowButtonRows ?? []) { - var normalizedRow = row - .Where(id => entries.ContainsKey(id) && used.Add(id)) - .ToList(); - if (normalizedRow.Count > 0) + foreach (var id in row) { - normalizedRows.Add(normalizedRow); + configuredIds.Add(id); } } - var missing = orderedIds.Where(id => !used.Contains(id)).ToList(); - if (normalizedRows.Count == 0) + // 如果没有任何按钮被配置到行中,自动将所有可用按钮添加到第一行 + // 这样用户首次使用或从旧版本迁移时,按钮默认会显示出来 + if (configuredIds.Count == 0 && entries.Count > 0) { - normalizedRows.Add(missing); + var allButtonIds = entries.Values.Select(e => e.ButtonId).ToList(); + if (profile.FloatingWindowButtonRows == null || profile.FloatingWindowButtonRows.Count == 0) + { + profile.FloatingWindowButtonRows = [allButtonIds]; + } + else + { + profile.FloatingWindowButtonRows[0] = allButtonIds; + } + foreach (var id in allButtonIds) + { + configuredIds.Add(id); + } + _floatingWindowService.ProfileManager.SaveProfile(); } - else + + // 新注册且尚未配置的按钮自动追加到第一行 + // 已存在按钮配置(如被用户移除/隐藏)的按钮不再自动添加 + var newButtonIds = entries.Values + .Where(e => !configuredIds.Contains(e.ButtonId)) + .Where(e => !profile.FloatingWindowButtonRulesets.ContainsKey(e.ButtonId)) + .Select(e => e.ButtonId) + .ToList(); + if (newButtonIds.Count > 0) { - normalizedRows[0].AddRange(missing); + if (profile.FloatingWindowButtonRows == null || profile.FloatingWindowButtonRows.Count == 0) + { + profile.FloatingWindowButtonRows = [newButtonIds]; + } + else + { + profile.FloatingWindowButtonRows[0] = [.. profile.FloatingWindowButtonRows[0], .. newButtonIds]; + } + foreach (var id in newButtonIds) + { + configuredIds.Add(id); + } + _floatingWindowService.ProfileManager.SaveProfile(); } - if (normalizedRows.Count == 0) + // 注销旧对象上的事件处理程序,避免重复注册和内存泄漏 + foreach (var oldRow in FloatingTriggerRows) { - normalizedRows.Add([]); + oldRow.RowRuleset.PropertyChanged -= OnRowRulesetPropertyChanged; + if (oldRow.RowRuleset.HidingRules is INotifyPropertyChanged oldRowHidingRules) + { + oldRowHidingRules.PropertyChanged -= OnRowRulesetPropertyChanged; + } + foreach (var oldItem in oldRow.Buttons) + { + oldItem.Config.PropertyChanged -= OnButtonConfigPropertyChanged; + if (oldItem.Config.HidingRules is INotifyPropertyChanged oldBtnHidingRules) + { + oldBtnHidingRules.PropertyChanged -= OnButtonConfigPropertyChanged; + } + } } + // 构建已配置的行显示 FloatingTriggerRows.Clear(); - foreach (var row in normalizedRows) + var rowConfigs = profile.FloatingWindowRowRulesets; + var rowIndex = 0; + var needSave = false; + foreach (var row in profile.FloatingWindowButtonRows ?? []) { - var vmRow = new FloatingTriggerRow(); + while (rowConfigs.Count <= rowIndex) + { + rowConfigs.Add(new RowRulesetConfig()); + needSave = true; + } + var vmRow = new FloatingTriggerRow + { + RowIndex = rowIndex + 1, + RowRuleset = rowConfigs[rowIndex] + }; + vmRow.RowRuleset.PropertyChanged += OnRowRulesetPropertyChanged; + if (vmRow.RowRuleset.HidingRules is INotifyPropertyChanged rowHidingRules) + { + rowHidingRules.PropertyChanged += OnRowRulesetPropertyChanged; + } foreach (var id in row) { - var entry = entries[id]; - vmRow.Buttons.Add(new FloatingTriggerItem + if (!entries.TryGetValue(id, out var entry)) + { + continue; + } + if (!profile.FloatingWindowButtonRulesets.TryGetValue(entry.ButtonId, out var btnConfig)) + { + btnConfig = new ButtonRulesetConfig(); + profile.FloatingWindowButtonRulesets[entry.ButtonId] = btnConfig; + needSave = true; + } + var item = new FloatingTriggerItem { ButtonId = entry.ButtonId, - Icon = entry.Icon, - ButtonName = entry.LayoutName - }); + Icon = FloatingWindowService.ConvertIcon(entry.Icon), + ButtonName = entry.LayoutName, + Config = btnConfig + }; + item.Config.PropertyChanged += OnButtonConfigPropertyChanged; + if (item.Config.HidingRules is INotifyPropertyChanged btnHidingRules) + { + btnHidingRules.PropertyChanged += OnButtonConfigPropertyChanged; + } + vmRow.Buttons.Add(item); } FloatingTriggerRows.Add(vmRow); + rowIndex++; + } + + if (FloatingTriggerRows.Count == 0) + { + if (rowConfigs.Count == 0) + { + rowConfigs.Add(new RowRulesetConfig()); + needSave = true; + } + var emptyRow = new FloatingTriggerRow + { + RowIndex = 1, + RowRuleset = rowConfigs[0] + }; + emptyRow.RowRuleset.PropertyChanged += OnRowRulesetPropertyChanged; + if (emptyRow.RowRuleset.HidingRules is INotifyPropertyChanged emptyRowHidingRules) + { + emptyRowHidingRules.PropertyChanged += OnRowRulesetPropertyChanged; + } + FloatingTriggerRows.Add(emptyRow); + } + + // 如果有新创建的默认配置,确保保存 + if (needSave) + { + _floatingWindowService.ProfileManager.SaveProfile(); + } + + } + + private void OnButtonConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // 规则集求值时会写入 State(Ruleset/RuleGroup/Rule),避免因此递归触发通知 + if (IsRulesetStateProperty(e.PropertyName)) + { + return; } - PersistFloatingTriggerRows(updateWindow: false, forceSave: false); + _floatingWindowService.ProfileManager.SaveProfile(); + _floatingWindowService.UpdateWindowState(); + IAppHost.TryGetService()?.NotifyStatusChanged(); + } + + private void OnRowRulesetPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // 规则集求值时会写入 State(Ruleset/RuleGroup/Rule),避免因此递归触发通知 + if (IsRulesetStateProperty(e.PropertyName)) + { + return; + } + + _floatingWindowService.ProfileManager.SaveProfile(); + _floatingWindowService.UpdateWindowState(); + IAppHost.TryGetService()?.NotifyStatusChanged(); + } + + private static bool IsRulesetStateProperty(string? propertyName) + { + return propertyName == nameof(ClassIsland.Core.Models.Ruleset.Ruleset.State) + || propertyName == nameof(ClassIsland.Core.Models.Ruleset.RuleGroup.State) + || propertyName == nameof(ClassIsland.Core.Models.Ruleset.Rule.State); } public void AddFloatingTriggerRow() { - FloatingTriggerRows.Add(new FloatingTriggerRow()); + var profile = CurrentFloatingWindowProfile; + var rowRulesets = profile.FloatingWindowRowRulesets; + var newRowRuleset = new RowRulesetConfig(); + rowRulesets.Add(newRowRuleset); + var newRow = new FloatingTriggerRow + { + RowIndex = FloatingTriggerRows.Count + 1, + RowRuleset = newRowRuleset + }; + newRow.RowRuleset.PropertyChanged += OnRowRulesetPropertyChanged; + if (newRow.RowRuleset.HidingRules is INotifyPropertyChanged rowHidingRules) + { + rowHidingRules.PropertyChanged += OnRowRulesetPropertyChanged; + } + FloatingTriggerRows.Add(newRow); + PersistFloatingTriggerRows(); + } + + public void InsertFloatingTriggerRow(int insertIndex) + { + var profile = CurrentFloatingWindowProfile; + var rowRulesets = profile.FloatingWindowRowRulesets; + insertIndex = Math.Clamp(insertIndex, 0, FloatingTriggerRows.Count); + var newRowRuleset = new RowRulesetConfig(); + rowRulesets.Insert(insertIndex, newRowRuleset); + var newRow = new FloatingTriggerRow + { + RowIndex = insertIndex + 1, + RowRuleset = newRowRuleset + }; + newRow.RowRuleset.PropertyChanged += OnRowRulesetPropertyChanged; + if (newRow.RowRuleset.HidingRules is INotifyPropertyChanged rowHidingRules) + { + rowHidingRules.PropertyChanged += OnRowRulesetPropertyChanged; + } + FloatingTriggerRows.Insert(insertIndex, newRow); + + // 重新计算后续行的索引 + for (int i = insertIndex; i < FloatingTriggerRows.Count; i++) + { + FloatingTriggerRows[i].RowIndex = i + 1; + } + PersistFloatingTriggerRows(); } @@ -348,13 +573,28 @@ public bool RemoveFloatingTriggerRow(FloatingTriggerRow row) return false; } + // 注销被移除行的事件处理程序 + row.RowRuleset.PropertyChanged -= OnRowRulesetPropertyChanged; + if (row.RowRuleset.HidingRules is INotifyPropertyChanged rowHidingRules) + { + rowHidingRules.PropertyChanged -= OnRowRulesetPropertyChanged; + } + var targetRow = index > 0 ? FloatingTriggerRows[index - 1] : FloatingTriggerRows[index + 1]; foreach (var item in row.Buttons) { + // 按钮的 Config 事件监听保持不变(对象引用不变,事件仍有效) targetRow.Buttons.Add(item); } FloatingTriggerRows.RemoveAt(index); + + // 重新计算行索引 + for (int i = 0; i < FloatingTriggerRows.Count; i++) + { + FloatingTriggerRows[i].RowIndex = i + 1; + } + PersistFloatingTriggerRows(); return true; } @@ -368,9 +608,11 @@ public bool MoveFloatingTrigger(string buttonId, int targetRowIndex, int targetI targetRowIndex = Math.Clamp(targetRowIndex, 0, FloatingTriggerRows.Count - 1); var sourceRow = FloatingTriggerRows.FirstOrDefault(r => r.Buttons.Any(b => b.ButtonId == buttonId)); + + // 如果按钮不在任何行中(如在按钮池中),尝试从按钮池添加 if (sourceRow == null) { - return false; + return AddTriggerFromPool(buttonId, targetRowIndex, targetIndex); } var item = sourceRow.Buttons.First(b => b.ButtonId == buttonId); @@ -401,8 +643,56 @@ public bool MoveFloatingTrigger(string buttonId, int targetRowIndex, int targetI return true; } + /// + /// 将已注册但不在行中的按钮添加到指定行(用于拖拽等场景) + /// + public bool AddTriggerFromPool(string buttonId, int targetRowIndex, int targetIndex) + { + if (string.IsNullOrWhiteSpace(buttonId) || FloatingTriggerRows.Count == 0) + { + return false; + } + + var entry = _floatingWindowService.Entries.FirstOrDefault(e => e.ButtonId == buttonId); + if (entry == null) + { + return false; + } + + targetRowIndex = Math.Clamp(targetRowIndex, 0, FloatingTriggerRows.Count - 1); + var destinationRow = FloatingTriggerRows[targetRowIndex]; + targetIndex = Math.Clamp(targetIndex, 0, destinationRow.Buttons.Count); + + var profile = CurrentFloatingWindowProfile; + if (!profile.FloatingWindowButtonRulesets.TryGetValue(buttonId, out var btnConfig)) + { + btnConfig = new ButtonRulesetConfig(); + profile.FloatingWindowButtonRulesets[buttonId] = btnConfig; + } + // 重新添加到行中时恢复可见 + btnConfig.IsVisible = true; + + var item = new FloatingTriggerItem + { + ButtonId = entry.ButtonId, + Icon = FloatingWindowService.ConvertIcon(entry.Icon), + ButtonName = entry.LayoutName, + Config = btnConfig + }; + item.Config.PropertyChanged += OnButtonConfigPropertyChanged; + if (item.Config.HidingRules is INotifyPropertyChanged btnHidingRules) + { + btnHidingRules.PropertyChanged += OnButtonConfigPropertyChanged; + } + + destinationRow.Buttons.Insert(targetIndex, item); + PersistFloatingTriggerRows(); + return true; + } + public void PersistFloatingTriggerRows(bool updateWindow = true, bool forceSave = true) { + var profile = CurrentFloatingWindowProfile; var newRows = FloatingTriggerRows .Select(row => row.Buttons.Select(x => x.ButtonId).ToList()) .ToList(); @@ -410,22 +700,68 @@ public void PersistFloatingTriggerRows(bool updateWindow = true, bool forceSave .SelectMany(row => row) .ToList(); - var rowsChanged = !AreRowsEqual(Settings.FloatingWindowButtonRows, newRows); - var orderChanged = !(Settings.FloatingWindowButtonOrder ?? []).SequenceEqual(newOrder); + var rowsChanged = !AreRowsEqual(profile.FloatingWindowButtonRows, newRows); + var orderChanged = !(profile.FloatingWindowButtonOrder ?? []).SequenceEqual(newOrder); if (rowsChanged) { - Settings.FloatingWindowButtonRows = newRows; + profile.FloatingWindowButtonRows = newRows; } if (orderChanged) { - Settings.FloatingWindowButtonOrder = newOrder; + profile.FloatingWindowButtonOrder = newOrder; } - if (forceSave && (rowsChanged || orderChanged)) + // 同步行规则集:确保 FloatingWindowRowRulesets 与行数一致 + var rowRulesets = profile.FloatingWindowRowRulesets; + while (rowRulesets.Count < FloatingTriggerRows.Count) { - _configHandler.Save(); + rowRulesets.Add(new RowRulesetConfig()); + } + while (rowRulesets.Count > FloatingTriggerRows.Count) + { + // 注销被移除行规则集的事件 + var removedRowRuleset = rowRulesets[rowRulesets.Count - 1]; + removedRowRuleset.PropertyChanged -= OnRowRulesetPropertyChanged; + if (removedRowRuleset.HidingRules is INotifyPropertyChanged removedHidingRules) + { + removedHidingRules.PropertyChanged -= OnRowRulesetPropertyChanged; + } + rowRulesets.RemoveAt(rowRulesets.Count - 1); + } + // 同步每行的 RowRuleset 引用(确保ViewModel中的修改反映到profile) + for (int i = 0; i < FloatingTriggerRows.Count; i++) + { + var vmRow = FloatingTriggerRows[i]; + if (!ReferenceEquals(vmRow.RowRuleset, rowRulesets[i])) + { + // RowRuleset 引用变更时,重新注册事件 + vmRow.RowRuleset.PropertyChanged -= OnRowRulesetPropertyChanged; + if (vmRow.RowRuleset.HidingRules is INotifyPropertyChanged oldHidingRules) + { + oldHidingRules.PropertyChanged -= OnRowRulesetPropertyChanged; + } + vmRow.RowRuleset = rowRulesets[i]; + vmRow.RowRuleset.PropertyChanged += OnRowRulesetPropertyChanged; + if (vmRow.RowRuleset.HidingRules is INotifyPropertyChanged newHidingRules) + { + newHidingRules.PropertyChanged += OnRowRulesetPropertyChanged; + } + } + } + + // 清理不再使用的按钮规则集配置 + var usedButtonIds = new HashSet(newOrder); + var staleButtonIds = profile.FloatingWindowButtonRulesets.Keys.Where(id => !usedButtonIds.Contains(id)).ToList(); + foreach (var staleId in staleButtonIds) + { + profile.FloatingWindowButtonRulesets.Remove(staleId); + } + + if (forceSave) + { + _floatingWindowService.ProfileManager.SaveProfile(); } if (updateWindow) @@ -434,6 +770,52 @@ public void PersistFloatingTriggerRows(bool updateWindow = true, bool forceSave } } + public void AddFloatingWindowProfile(string? name = null) + { + var newName = _floatingWindowService.ProfileManager.CreateProfile(name); + RefreshFloatingWindowProfiles(); + SelectedFloatingWindowProfile = newName; + SwitchFloatingWindowProfile(newName); + } + + public void RemoveFloatingWindowProfile(string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return; + } + + if (_floatingWindowService.ProfileManager.RemoveProfile(profileName)) + { + RefreshFloatingWindowProfiles(); + // 如果删除的是当前方案,切换到 Default + if (string.Equals(SelectedFloatingWindowProfile, profileName, StringComparison.OrdinalIgnoreCase)) + { + SwitchFloatingWindowProfile("Default"); + } + } + } + + public void SwitchFloatingWindowProfile(string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return; + } + + _floatingWindowService.SwitchToProfile(profileName); + SelectedFloatingWindowProfile = profileName; + RefreshFloatingTriggers(); + + // 通知 UI 重新注册 Profile 属性变更事件监听 + ProfileChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Profile 对象发生变化时触发(切换方案后需要重新注册事件监听) + /// + public event EventHandler? ProfileChanged; + public void Dispose() { _floatingWindowService.EntriesChanged -= _entriesChangedHandler; diff --git a/Triggers/FloatingWindowTrigger.cs b/Triggers/FloatingWindowTrigger.cs index 34776997..51d91e10 100644 --- a/Triggers/FloatingWindowTrigger.cs +++ b/Triggers/FloatingWindowTrigger.cs @@ -104,7 +104,7 @@ public string GetButtonName() public string GetLayoutButtonName() { - return IsRevertEnabled() ? $"{Settings.ButtonName}(启用恢复)" : Settings.ButtonName; + return Settings.ButtonName; } public bool ShouldUseRevertStyle() diff --git a/Triggers/FloatingWindowTriggerConfig.cs b/Triggers/FloatingWindowTriggerConfig.cs index 91f9f706..30fc70d0 100644 --- a/Triggers/FloatingWindowTriggerConfig.cs +++ b/Triggers/FloatingWindowTriggerConfig.cs @@ -1,11 +1,31 @@ using System; +using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; namespace SystemTools.Triggers; -public partial class FloatingWindowTriggerConfig : ObservableRecipient +/// +/// 悬浮窗触发器的配置 +/// +public partial class FloatingWindowTriggerConfig : ObservableObject { - [ObservableProperty] private string _buttonId = Guid.NewGuid().ToString("N"); - [ObservableProperty] private string _icon = "/uEA37"; - [ObservableProperty] private string _buttonName = "触发按钮 1"; + [ObservableProperty] + [JsonPropertyName("buttonId")] + private string _buttonId = Guid.NewGuid().ToString("N"); + + [ObservableProperty] + [JsonPropertyName("icon")] + private string _icon = "/uEA37"; + + [ObservableProperty] + [JsonPropertyName("buttonName")] + private string _buttonName = "触发按钮 1"; + + [ObservableProperty] + [JsonPropertyName("isVisible")] + private bool _isVisible = true; + + [ObservableProperty] + [JsonPropertyName("position")] + private int _position = -1; }