diff --git a/src/Earmark.App/App.xaml b/src/Earmark.App/App.xaml index 51f8592..1c7a79b 100644 --- a/src/Earmark.App/App.xaml +++ b/src/Earmark.App/App.xaml @@ -25,7 +25,6 @@ - diff --git a/src/Earmark.App/Controls/DeviceCardView.xaml b/src/Earmark.App/Controls/DeviceCardView.xaml new file mode 100644 index 0000000..0eeb573 --- /dev/null +++ b/src/Earmark.App/Controls/DeviceCardView.xaml @@ -0,0 +1,654 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 14 + 14 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Earmark.App/Controls/DeviceCardView.xaml.cs b/src/Earmark.App/Controls/DeviceCardView.xaml.cs new file mode 100644 index 0000000..33d688e --- /dev/null +++ b/src/Earmark.App/Controls/DeviceCardView.xaml.cs @@ -0,0 +1,295 @@ +using Earmark.App.ViewModels; +using Earmark.App.Views; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +using Windows.ApplicationModel.DataTransfer; +using Windows.System; +using Windows.UI.Core; + +namespace Earmark.App.Controls; + +// XAML event handlers and x:Bind function bindings must be instance members even when the body +// doesn't touch instance state. +#pragma warning disable CA1822 + +/// +/// The reusable device card body: tile/name/chips, volume + peak meter, rules, and app chips. Hosted +/// by the Devices page (inside a drag/drop ) and by the Quick Controls flyout. The +/// page-level gestures (block reorder, app-chip drop target) live on the host's wrapping Border; this +/// control owns the per-card interactions and reaches the singleton directly. +/// +public sealed partial class DeviceCardView : UserControl +{ + private readonly HomeViewModel _viewModel; + private readonly RulesViewModel _rulesViewModel; + private readonly MainWindow _mainWindow; + private readonly ILogger? _logger; + private readonly Dictionary _sliderDragStart = new(); + + public DeviceCardView() + { + var services = App.Current.Services; + _viewModel = services.GetRequiredService(); + _rulesViewModel = services.GetRequiredService(); + _mainWindow = services.GetRequiredService(); + _logger = services.GetService>(); + InitializeComponent(); + } + + public static readonly DependencyProperty CardProperty = DependencyProperty.Register( + nameof(Card), typeof(DeviceCard), typeof(DeviceCardView), new PropertyMetadata(null)); + + public DeviceCard? Card + { + get => (DeviceCard?)GetValue(CardProperty); + set => SetValue(CardProperty, value); + } + + /// When false (the Quick Controls flyout), the rules divider / section / "no rules" + /// message never render. Constant per host. + public static readonly DependencyProperty ShowRulesProperty = DependencyProperty.Register( + nameof(ShowRules), typeof(bool), typeof(DeviceCardView), new PropertyMetadata(true)); + + public bool ShowRules + { + get => (bool)GetValue(ShowRulesProperty); + set => SetValue(ShowRulesProperty, value); + } + + /// x:Bind function: a rule element shows only when rules are enabled for this host AND the + /// card wants it. Re-evaluates when either argument changes, replacing the old collapse watchdog. + public Visibility RuleVis(bool showRules, bool cardWants) => + showRules && cardWants ? Visibility.Visible : Visibility.Collapsed; + + /// Raised when "Rename group" is picked from a card/app-chip menu, so the host can focus + /// the group's title editor (which lives in the page's tree, not here). + public event EventHandler? RenameGroupRequested; + + private DeviceGroupCard? FindGroupOf(DeviceCard card) + { + foreach (var block in _viewModel.Blocks) + { + if (block is DeviceGroupCard group && group.Members.Contains(card)) return group; + } + return null; + } + + private void OnMuteToggleClicked(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: DeviceCard card }) return; + var prevVolume = card.Volume; + var prevMuted = card.IsMuted; + card.ToggleMuteCommand.Execute(null); + _viewModel.RecordVolumeMuteUndo(card, prevVolume, prevMuted); + } + + private void OnRuleChipClicked(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { DataContext: RuleSummary summary }) return; + _rulesViewModel.RequestFocusRule(summary.RuleId); + _mainWindow.NavigateByTag("Rules"); + } + + // ---- Volume slider interaction (captures pre-drag state for a single Ctrl+Z entry) ---- + + private void OnSliderPointerPressed(object sender, PointerRoutedEventArgs e) + { + if (sender is Slider { Tag: DeviceCard card } slider) + { + _sliderDragStart[slider] = (card.Volume, card.IsMuted); + } + } + + private void OnSliderReleased(object sender, PointerRoutedEventArgs e) + { + if (sender is not Slider { Tag: DeviceCard card } slider) return; + FinaliseSliderInteraction(slider, card); + card.PlayPing(); + } + + private void OnSliderKeyDown(object sender, KeyRoutedEventArgs e) + { + if (!IsSliderNudgeKey(e.Key)) return; + if (sender is Slider { Tag: DeviceCard card } slider && !_sliderDragStart.ContainsKey(slider)) + { + _sliderDragStart[slider] = (card.Volume, card.IsMuted); + } + } + + private void OnSliderKeyUp(object sender, KeyRoutedEventArgs e) + { + if (!IsSliderNudgeKey(e.Key)) return; + if (sender is not Slider { Tag: DeviceCard card } slider) return; + FinaliseSliderInteraction(slider, card); + card.PlayPing(); + } + + private void OnSliderLostFocus(object sender, RoutedEventArgs e) + { + if (sender is Slider { Tag: DeviceCard card } slider) FinaliseSliderInteraction(slider, card); + } + + private void FinaliseSliderInteraction(Slider slider, DeviceCard card) + { + if (!_sliderDragStart.TryGetValue(slider, out var start)) return; + _sliderDragStart.Remove(slider); + _viewModel.RecordVolumeMuteUndo(card, start.Volume, start.Muted); + } + + private static bool IsSliderNudgeKey(VirtualKey key) => + key is VirtualKey.Left or VirtualKey.Right + or VirtualKey.Up or VirtualKey.Down + or VirtualKey.PageUp or VirtualKey.PageDown + or VirtualKey.Home or VirtualKey.End; + + private void OnLockedSliderTapped(object sender, TappedRoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: DeviceCard card }) card.PlayPing(); + } + + private void OnLockedSliderPointerPressed(object sender, PointerRoutedEventArgs e) => + (sender as UIElement)?.CapturePointer(e.Pointer); + + private void OnLockedSliderPointerReleased(object sender, PointerRoutedEventArgs e) => + (sender as UIElement)?.ReleasePointerCapture(e.Pointer); + + // ---- App chip drag source + animation ---- + + private const string DragPayloadPrefix = "earmark:chip:"; + + private void OnAppChipLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement border) return; + if (VisualTreeHelper.GetParent(border) is not UIElement container) return; + + var visual = ElementCompositionPreview.GetElementVisual(container); + var compositor = visual.Compositor; + var offset = compositor.CreateVector3KeyFrameAnimation(); + offset.Target = "Offset"; + offset.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offset.Duration = TimeSpan.FromMilliseconds(220); + var implicits = compositor.CreateImplicitAnimationCollection(); + implicits["Offset"] = offset; + visual.ImplicitAnimations = implicits; + } + + private void OnAppChipDragStarting(UIElement sender, DragStartingEventArgs args) + { + if (sender is not FrameworkElement { Tag: AppChip chip }) return; + if (!chip.CanDrag) + { + args.Cancel = true; + return; + } + + args.Data.SetText($"{DragPayloadPrefix}{chip.ProcessId}|{chip.SourceEndpointId}"); + args.Data.RequestedOperation = DataPackageOperation.Move; + SetDragInProgress(true); + } + + private void OnAppChipDropCompleted(UIElement sender, DropCompletedEventArgs args) => SetDragInProgress(false); + + /// Reveals every group container's dotted outline while a drag is in flight. + private void SetDragInProgress(bool active) + { + foreach (var block in _viewModel.Blocks) + { + if (block is DeviceGroupCard group) group.ShowOutline = active; + } + } + + /// Reveals the chip's "Terminate this app" item only while Shift is held as the menu opens. + private void OnAppChipFlyoutOpening(object sender, object e) + { + if (sender is not MenuFlyout flyout) return; + var shiftDown = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift) + .HasFlag(CoreVirtualKeyStates.Down); + foreach (var item in flyout.Items) + { + if (item is MenuFlyoutItem { Tag: AppChip chip } terminate) + { + terminate.Visibility = shiftDown && chip.ShowProcessActions + ? Visibility.Visible + : Visibility.Collapsed; + } + } + } + + // ---- Context-menu actions (the device + app-chip menus share these) ---- + + private async void OnDeviceVisibilityClicked(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: DeviceCard card }) return; + if (!card.IsEffectivelyHidden && card.IsQuickPinned) + { + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Hide pinned device?", + Content = "This device is pinned to Quick Controls. Hiding it will remove it from Quick Controls.", + PrimaryButtonText = "Hide and unpin", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + HomeViewModel.HideAndUnpin(card); + return; + } + + card.ToggleUserVisibilityCommand.Execute(null); + } + + private void OnForgetDeviceClicked(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: DeviceCard card }) _viewModel.ForgetDevice(card); + } + + private void OnUngroupDeviceClicked(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: DeviceCard card }) _viewModel.UngroupDevice(card.DeviceKey); + } + + private void OnUngroupAllClicked(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: DeviceCard card } && FindGroupOf(card) is { } group) + { + _viewModel.UngroupAll(group.Id); + } + } + + private void OnRenameGroupClicked(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: DeviceCard card } && FindGroupOf(card) is { } group) + { + group.IsEditingTitle = true; + RenameGroupRequested?.Invoke(this, group); + } + } + + private void OnCustomiseClicked(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: DeviceCard card }) return; + // Defer so the context MenuFlyout finishes dismissing before the dialog opens. + DispatcherQueue.TryEnqueue(async () => + { + try + { + var dialog = HomePage.BuildCustomiseDialog(card); + dialog.XamlRoot = XamlRoot; + await dialog.ShowAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Customise: dialog threw"); + } + }); + } +} diff --git a/src/Earmark.App/Converters/Converters.cs b/src/Earmark.App/Converters/Converters.cs index ac32c89..8b99106 100644 --- a/src/Earmark.App/Converters/Converters.cs +++ b/src/Earmark.App/Converters/Converters.cs @@ -40,13 +40,6 @@ public object ConvertBack(object value, Type targetType, object parameter, strin throw new NotSupportedException(); } -public sealed class EnumToStringConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) => value?.ToString() ?? string.Empty; - - public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); -} - public sealed class SatisfiedGlyphConverter : IValueConverter { // Segoe MDL2 Assets: CheckMark (E73E), Cancel (E711). diff --git a/src/Earmark.App/Hosting/HostBuilderExtensions.cs b/src/Earmark.App/Hosting/HostBuilderExtensions.cs index 696efa8..36e45ce 100644 --- a/src/Earmark.App/Hosting/HostBuilderExtensions.cs +++ b/src/Earmark.App/Hosting/HostBuilderExtensions.cs @@ -57,7 +57,6 @@ public static HostApplicationBuilder ConfigureEarmark(this HostApplicationBuilde builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Earmark.App/Services/DeviceDefaultsService.cs b/src/Earmark.App/Services/DeviceDefaultsService.cs index a9c5c4b..0473f0f 100644 --- a/src/Earmark.App/Services/DeviceDefaultsService.cs +++ b/src/Earmark.App/Services/DeviceDefaultsService.cs @@ -119,16 +119,9 @@ private List ApplyDefaultDeviceLayout() } var orderHead = new List(); - var defaultGroup = AddGroup(s, orderHead, DefaultDevicesGroupTitle, group0Members); + AddGroup(s, orderHead, DefaultDevicesGroupTitle, group0Members); PinAll(s, group0Members); - if (defaultGroup is not null) - { - defaultGroup.PinnedToQuickControls = true; - } - else - { - QuickPinAll(s, group0Members); - } + QuickPinAll(s, group0Members); // ---- Group 1: Wave Link channels (render/output-only virtual channels), minus group 0 ---- var channelMap = WaveLinkChannelMap.Build(snapshot, combined); @@ -171,9 +164,9 @@ private List ApplyDefaultDeviceLayout() /// Adds a group for (>=2 members) and pushes its id onto the /// block-order head. Fewer than two members can't render as a group, so it's skipped (members are /// still pinned separately). - private static DeviceGroup? AddGroup(AppSettings s, List orderHead, string title, List memberIds) + private static void AddGroup(AppSettings s, List orderHead, string title, List memberIds) { - if (memberIds.Count < 2) return null; + if (memberIds.Count < 2) return; var group = new DeviceGroup { Id = Guid.NewGuid().ToString("N"), @@ -182,7 +175,6 @@ private List ApplyDefaultDeviceLayout() }; s.DeviceGroups.Add(group); orderHead.Add(group.Id); - return group; } private static void PinAll(AppSettings s, IEnumerable ids) diff --git a/src/Earmark.App/Services/QuickControlsService.cs b/src/Earmark.App/Services/QuickControlsService.cs index fda66f5..6bc89e7 100644 --- a/src/Earmark.App/Services/QuickControlsService.cs +++ b/src/Earmark.App/Services/QuickControlsService.cs @@ -25,30 +25,15 @@ internal sealed class QuickControlsService : IQuickControlsService private const int OverlayGap = 8; private const int MinimumOverflowHeight = 220; private const int WarmWindowPoolSize = 6; - private const int WhKeyboardLl = 13; - private const int WhMouseLl = 14; - private const int WmKeydown = 0x0100; - private const int WmSyskeydown = 0x0104; - private const int WmLbuttondown = 0x0201; - private const int WmRbuttondown = 0x0204; - private const int WmMbuttondown = 0x0207; - private const int WmXbuttondown = 0x020B; - private const uint VkEscape = 0x1B; private readonly IGlobalHotkeyService _hotkey; private readonly HomeViewModel _viewModel; - private readonly HomePage _homePage; private readonly ISettingsService _settings; private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; private readonly IDispatcherQueueProvider _dispatcher; private readonly List _windows = new(); private readonly Dictionary _windowsByBlockKey = new(StringComparer.OrdinalIgnoreCase); - private readonly List<(string Key, IReadOnlyList Blocks, QuickControlsWindow Window)> _activeWindows = new(); - private LowLevelKeyboardProc? _escapeProc; - private LowLevelMouseProc? _mouseProc; - private nint _escapeHook; - private nint _mouseHook; + private readonly List<(string Key, QuickControlsWindow Window)> _activeWindows = new(); private bool _started; private bool _isOpen; private bool _refreshQueued; @@ -56,17 +41,14 @@ internal sealed class QuickControlsService : IQuickControlsService public QuickControlsService( IGlobalHotkeyService hotkey, HomeViewModel viewModel, - HomePage homePage, ISettingsService settings, ILoggerFactory loggerFactory, IDispatcherQueueProvider dispatcher) { _hotkey = hotkey; _viewModel = viewModel; - _homePage = homePage; _settings = settings; _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(); _dispatcher = dispatcher; } @@ -74,11 +56,6 @@ public void Start() { if (_started) return; _started = true; - _logger.LogDebug("QuickControls service start: enabled={Enabled} hotkey='{Hotkey}' display={Display} backdrop={Backdrop}", - _settings.Current.QuickControlsEnabled, - _settings.Current.QuickControlsHotkey, - _settings.Current.QuickControlsDisplay, - _settings.Current.QuickControlsBackdrop); _hotkey.HotkeyPressed += OnHotkeyPressed; _viewModel.QuickControlBlocks.CollectionChanged += OnQuickControlBlocksChanged; _hotkey.Start(); @@ -93,71 +70,39 @@ public void Start() public void Toggle() { - if (_isOpen) - { - _logger.LogDebug("QuickControls toggle: hide requested"); - Hide(); - } - else - { - _logger.LogDebug("QuickControls toggle: show requested"); - Show(); - } + if (_isOpen) Hide(); + else Show(); } private void Show() { Hide(); var blocks = _viewModel.QuickControlBlocks.ToList(); - _logger.LogDebug("QuickControls show: blocks={BlockCount} blocks=[{Blocks}]", blocks.Count, FormatBlocks(blocks)); - if (blocks.Count == 0) - { - _logger.LogDebug("QuickControls show aborted: no quick control blocks"); - return; - } + if (blocks.Count == 0) return; RenderBlocks(blocks); _isOpen = true; _viewModel.ResumePeakPollingForQuickControls(); - StartEscapeHook(); - StartMouseHook(); } private void RenderBlocks(List blocks) { _activeWindows.Clear(); var workArea = ResolveWorkArea().WorkArea; - var blockItems = blocks.Select(block => (Key: GetBlockKey(block), Blocks: (IReadOnlyList)[block], Window: GetWindowForBlock(block))).ToList(); - _logger.LogDebug( - "QuickControls render start: workArea=({X},{Y},{Width},{Height}) blocks={BlockCount} activeWindows={ActiveWindows} totalWindows={TotalWindows}", - workArea.X, - workArea.Y, - workArea.Width, - workArea.Height, - blocks.Count, - _activeWindows.Count, - _windows.Count); + var blockItems = blocks + .Select(block => (Key: GetBlockKey(block), Block: block, Window: GetWindowForBlock(block))) + .ToList(); EnsureWindowPool(blockItems.Count); - var heights = MeasureBlockHeights(blockItems, workArea); + var heights = blockItems.Select(item => item.Window.MeasureBlocksHeight([item.Block], workArea)).ToList(); var desiredHeights = heights.ToList(); - var measuredHeights = string.Join(", ", heights); FitBlockHeights(heights, Math.Max(1, workArea.Height - (OverlayMargin * 2) - (OverlayGap * Math.Max(0, heights.Count - 1)))); - _logger.LogDebug("QuickControls render heights: measured=[{MeasuredHeights}] fitted=[{FittedHeights}]", measuredHeights, string.Join(", ", heights)); var bottom = workArea.Y + workArea.Height - OverlayMargin; for (var index = 0; index < blockItems.Count; index++) { - _logger.LogDebug("QuickControls render prepare window: index={Index} bottom={Bottom} maxHeight={MaxHeight} block=[{Block}]", - index, - bottom, - heights[index], - FormatBlocks(blockItems[index].Blocks)); - var height = PrepareWindow(blockItems[index].Window, blockItems[index].Key, workArea, bottom, heights[index], desiredHeights[index]); - _logger.LogDebug("QuickControls render prepared window: index={Index} actualHeight={Height} nextBottom={NextBottom}", - index, - height, - bottom - height - OverlayGap); + _activeWindows.Add((blockItems[index].Key, blockItems[index].Window)); + var height = blockItems[index].Window.PrepareMeasuredBlocks(workArea, bottom, heights[index], desiredHeights[index]); bottom -= height + OverlayGap; } @@ -165,24 +110,8 @@ private void RenderBlocks(List blocks) for (var i = _activeWindows.Count - 1; i >= 0; i--) { - _logger.LogDebug("QuickControls render show prepared window: index={Index} key='{Key}'", i, _activeWindows[i].Key); _activeWindows[i].Window.ShowPrepared(); } - - _logger.LogDebug("QuickControls render complete: activeWindows={ActiveWindows} totalWindows={TotalWindows}", _activeWindows.Count, _windows.Count); - } - - private List MeasureBlockHeights(List<(string Key, IReadOnlyList Blocks, QuickControlsWindow Window)> blockItems, RectInt32 workArea) - { - var heights = new List(blockItems.Count); - for (var i = 0; i < blockItems.Count; i++) - { - var height = blockItems[i].Window.MeasureBlocksHeight(blockItems[i].Blocks, workArea); - _logger.LogDebug("QuickControls measured block: index={Index} key='{Key}' height={Height} block=[{Block}]", i, blockItems[i].Key, height, FormatBlocks(blockItems[i].Blocks)); - heights.Add(height); - } - - return heights; } private static void FitBlockHeights(List heights, int availableHeight) @@ -222,10 +151,8 @@ private void RefreshOpenStack() if (!_isOpen) return; var blocks = _viewModel.QuickControlBlocks.ToList(); - _logger.LogDebug("QuickControls refresh open stack: blocks={BlockCount} blocks=[{Blocks}]", blocks.Count, FormatBlocks(blocks)); if (blocks.Count == 0) { - _logger.LogDebug("QuickControls refresh hides stack: no blocks remain"); Hide(); return; } @@ -234,13 +161,6 @@ private void RefreshOpenStack() _isOpen = true; } - private int PrepareWindow(QuickControlsWindow window, string key, RectInt32 workArea, int bottom, int maxHeight, int desiredHeight) - { - _activeWindows.Add((key, [], window)); - _logger.LogDebug("QuickControls prepare selected window: activeIndex={Index} key='{Key}' hwnd={Hwnd} totalWindows={WindowCount}", _activeWindows.Count - 1, key, window.Hwnd, _windows.Count); - return window.PrepareMeasuredBlocks(workArea, bottom, maxHeight, desiredHeight); - } - private QuickControlsWindow GetWindowForBlock(object block) { var key = GetBlockKey(block); @@ -248,7 +168,6 @@ private QuickControlsWindow GetWindowForBlock(object block) window = _windows.FirstOrDefault(candidate => !_windowsByBlockKey.ContainsValue(candidate)) ?? CreateWindow(); _windowsByBlockKey[key] = window; - _logger.LogDebug("QuickControls assign window: key='{Key}' hwnd={Hwnd}", key, window.Hwnd); return window; } @@ -269,26 +188,24 @@ private void EnsureWindowPool(int count) private QuickControlsWindow CreateWindow() { - var window = new QuickControlsWindow(_viewModel, _homePage, _settings, _loggerFactory.CreateLogger()); + var window = new QuickControlsWindow(_viewModel, _settings, _loggerFactory.CreateLogger()); + window.DismissRequested += OnWindowDismissRequested; + window.Activated += OnWindowActivated; _windows.Add(window); - _logger.LogDebug("QuickControls create window: index={Index} hwnd={Hwnd} totalWindows={WindowCount}", _windows.Count - 1, window.Hwnd, _windows.Count); return window; } private void HideUnusedWindows() { var visible = _activeWindows.Select(item => item.Window).ToHashSet(); - for (var i = 0; i < _windows.Count; i++) + foreach (var window in _windows) { - if (visible.Contains(_windows[i])) continue; - _logger.LogDebug("QuickControls hide unused window: index={Index} hwnd={Hwnd}", i, _windows[i].Hwnd); - _windows[i].Hide(); + if (!visible.Contains(window)) window.Hide(); } } private void Hide() { - _logger.LogDebug("QuickControls hide: windows={WindowCount} activeWindows={ActiveWindowCount} wasOpen={WasOpen}", _windows.Count, _activeWindows.Count, _isOpen); foreach (var window in _windows) { window.Hide(); @@ -296,47 +213,44 @@ private void Hide() _activeWindows.Clear(); _isOpen = false; - StopEscapeHook(); - StopMouseHook(); _viewModel.PausePeakPollingForQuickControls(); } private void OnHotkeyPressed(object? sender, EventArgs e) { - _logger.LogDebug("QuickControls hotkey pressed: windows={WindowCount} isOpen={IsOpen}", _windows.Count, _isOpen); - if (_windows.FirstOrDefault() is { } window) - { - window.DispatcherQueue.TryEnqueue(Toggle); - } - else - { - _dispatcher.Enqueue(Toggle); - } + if (_windows.FirstOrDefault() is { } window) window.DispatcherQueue.TryEnqueue(Toggle); + else _dispatcher.Enqueue(Toggle); } - private void OnQuickControlBlocksChanged(object? sender, NotifyCollectionChangedEventArgs e) + private void OnWindowDismissRequested(object? sender, EventArgs e) { - _logger.LogDebug( - "QuickControls blocks changed: action={Action} newCount={NewCount} oldCount={OldCount} newIndex={NewIndex} oldIndex={OldIndex} isOpen={IsOpen}", - e.Action, - e.NewItems?.Count ?? 0, - e.OldItems?.Count ?? 0, - e.NewStartingIndex, - e.OldStartingIndex, - _isOpen); - QueueRefresh(); + if (_isOpen) Hide(); } + // Dismiss on outside interaction: when one of our windows loses activation, defer a check to the + // next tick (so a click that just moved focus to a sibling panel has registered) and hide only if + // the foreground window is no longer one of ours. Replaces the old global low-level mouse hook. + private void OnWindowActivated(object sender, WindowActivatedEventArgs args) + { + if (!_isOpen || args.WindowActivationState != WindowActivationState.Deactivated) return; + _dispatcher.Queue.TryEnqueue(() => + { + if (!_isOpen) return; + var foreground = GetForegroundWindow(); + if (!_windows.Any(window => window.Hwnd == foreground)) Hide(); + }); + } + + private void OnQuickControlBlocksChanged(object? sender, NotifyCollectionChangedEventArgs e) => QueueRefresh(); + private void QueueRefresh() { if (_refreshQueued) return; _refreshQueued = true; - _logger.LogDebug("QuickControls queue refresh: isOpen={IsOpen}", _isOpen); _dispatcher.Queue.TryEnqueue(() => { _refreshQueued = false; - _logger.LogDebug("QuickControls run queued refresh: isOpen={IsOpen}", _isOpen); if (_isOpen) { RefreshOpenStack(); @@ -348,111 +262,15 @@ private void QueueRefresh() }); } - private void StartEscapeHook() - { - if (_escapeHook != 0) return; - _escapeProc = EscapeHookProc; - _escapeHook = SetWindowsHookEx(WhKeyboardLl, _escapeProc, GetModuleHandle(null), 0); - _logger.LogDebug("QuickControls escape hook start: handle={Handle}", _escapeHook); - } - - private void StopEscapeHook() - { - if (_escapeHook == 0) return; - UnhookWindowsHookEx(_escapeHook); - _logger.LogDebug("QuickControls escape hook stop: handle={Handle}", _escapeHook); - _escapeHook = 0; - _escapeProc = null; - } - - private void StartMouseHook() - { - if (_mouseHook != 0) return; - _mouseProc = MouseHookProc; - _mouseHook = SetWindowsHookEx(WhMouseLl, _mouseProc, GetModuleHandle(null), 0); - _logger.LogDebug("QuickControls mouse hook start: handle={Handle}", _mouseHook); - } - - private void StopMouseHook() - { - if (_mouseHook == 0) return; - UnhookWindowsHookEx(_mouseHook); - _logger.LogDebug("QuickControls mouse hook stop: handle={Handle}", _mouseHook); - _mouseHook = 0; - _mouseProc = null; - } - - private nint EscapeHookProc(int code, nint wParam, nint lParam) - { - if (code >= 0 && _windows.Count > 0 && _isOpen && (wParam == WmKeydown || wParam == WmSyskeydown)) - { - var data = Marshal.PtrToStructure(lParam); - if (data.vkCode == VkEscape) - { - _logger.LogDebug("QuickControls escape pressed: hiding stack"); - _windows[0].DispatcherQueue.TryEnqueue(Hide); - return 1; - } - } - - return CallNextHookEx(_escapeHook, code, wParam, lParam); - } - - private nint MouseHookProc(int code, nint wParam, nint lParam) - { - if (code >= 0 && _isOpen && _windows.Count > 0 && IsMouseDownMessage(wParam.ToInt32())) - { - var data = Marshal.PtrToStructure(lParam); - var hwnd = WindowFromPoint(data.pt); - var rootHwnd = GetAncestor(hwnd, GA_ROOT); - if (!_windows.Any(window => window.Hwnd == hwnd || window.Hwnd == rootHwnd || IsChild(window.Hwnd, hwnd))) - { - _logger.LogDebug("QuickControls outside click: hwnd={Hwnd} root={RootHwnd} point=({X},{Y})", hwnd, rootHwnd, data.pt.X, data.pt.Y); - _windows[0].DispatcherQueue.TryEnqueue(Hide); - } - } - - return CallNextHookEx(_mouseHook, code, wParam, lParam); - } - - private static bool IsMouseDownMessage(int message) => - message is WmLbuttondown or WmRbuttondown or WmMbuttondown or WmXbuttondown; - private DisplayArea ResolveWorkArea() { if (_settings.Current.QuickControlsDisplay == QuickControlsDisplayMode.CurrentlyActive && GetCursorPos(out var point)) { - var area = DisplayArea.GetFromPoint(new PointInt32(point.X, point.Y), DisplayAreaFallback.Primary); - _logger.LogDebug("QuickControls resolve work area: mode={Mode} cursor=({X},{Y}) workArea=({AreaX},{AreaY},{AreaWidth},{AreaHeight})", - _settings.Current.QuickControlsDisplay, - point.X, - point.Y, - area.WorkArea.X, - area.WorkArea.Y, - area.WorkArea.Width, - area.WorkArea.Height); - return area; + return DisplayArea.GetFromPoint(new PointInt32(point.X, point.Y), DisplayAreaFallback.Primary); } - var primary = DisplayArea.Primary; - _logger.LogDebug("QuickControls resolve work area: mode={Mode} workArea=({AreaX},{AreaY},{AreaWidth},{AreaHeight})", - _settings.Current.QuickControlsDisplay, - primary.WorkArea.X, - primary.WorkArea.Y, - primary.WorkArea.Width, - primary.WorkArea.Height); - return primary; + return DisplayArea.Primary; } - private static string FormatBlocks(IEnumerable blocks) => - string.Join(" | ", blocks.Select(FormatBlock)); - - private static string FormatBlock(object block) => block switch - { - DeviceCard card => $"card:'{card.DisplayName}' key='{card.DeviceKey}' quick={card.IsQuickPinned} apps={card.Apps.Count}", - DeviceGroupCard group => $"group:'{group.Title}' id='{group.Id}' members=[{string.Join(", ", group.Members.Select(card => $"'{card.DisplayName}' key='{card.DeviceKey}' quick={card.IsQuickPinned} apps={card.Apps.Count}"))}]", - _ => block.GetType().Name, - }; - public void Dispose() { _hotkey.HotkeyPressed -= OnHotkeyPressed; @@ -460,6 +278,8 @@ public void Dispose() Hide(); foreach (var window in _windows) { + window.DismissRequested -= OnWindowDismissRequested; + window.Activated -= OnWindowActivated; window.CloseFlyout(); } _windows.Clear(); @@ -472,55 +292,9 @@ private struct POINT public int Y; } - [StructLayout(LayoutKind.Sequential)] - private struct KbdLlHookStruct - { - public uint vkCode; - public uint scanCode; - public uint flags; - public uint time; - public nint dwExtraInfo; - } - - [StructLayout(LayoutKind.Sequential)] - private struct MouseLlHookStruct - { - public POINT pt; - public uint mouseData; - public uint flags; - public uint time; - public nint dwExtraInfo; - } - - private delegate nint LowLevelKeyboardProc(int nCode, nint wParam, nint lParam); - private delegate nint LowLevelMouseProc(int nCode, nint wParam, nint lParam); - - private const uint GA_ROOT = 2; - [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); [DllImport("user32.dll")] - private static extern nint WindowFromPoint(POINT point); - - [DllImport("user32.dll")] - private static extern nint GetAncestor(nint hwnd, uint flags); - - [DllImport("user32.dll")] - private static extern bool IsChild(nint parent, nint child); - - [DllImport("user32.dll", SetLastError = true)] - private static extern nint SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, nint hMod, uint dwThreadId); - - [DllImport("user32.dll", SetLastError = true)] - private static extern nint SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, nint hMod, uint dwThreadId); - - [DllImport("user32.dll", SetLastError = true)] - private static extern bool UnhookWindowsHookEx(nint hhk); - - [DllImport("user32.dll")] - private static extern nint CallNextHookEx(nint hhk, int nCode, nint wParam, nint lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern nint GetModuleHandle(string? lpModuleName); + private static extern nint GetForegroundWindow(); } diff --git a/src/Earmark.App/Settings/AppSettings.cs b/src/Earmark.App/Settings/AppSettings.cs index 553fcc1..7a5d55a 100644 --- a/src/Earmark.App/Settings/AppSettings.cs +++ b/src/Earmark.App/Settings/AppSettings.cs @@ -304,8 +304,6 @@ public sealed class DeviceGroup public string Title { get; set; } = string.Empty; - public bool PinnedToQuickControls { get; set; } - /// Member keys, in left-to-right member order. public List MemberIds { get; set; } = new(); } diff --git a/src/Earmark.App/ViewModels/DeviceGroupCard.cs b/src/Earmark.App/ViewModels/DeviceGroupCard.cs index d0b328d..585cacf 100644 --- a/src/Earmark.App/ViewModels/DeviceGroupCard.cs +++ b/src/Earmark.App/ViewModels/DeviceGroupCard.cs @@ -20,14 +20,12 @@ public partial class DeviceGroupCard : ObservableObject, IBlockLayoutInfo { private readonly Action? _onChanged; private readonly bool _hideEmptyTitleBand; - private readonly bool _insetContent; private bool _suppressChanged; - public DeviceGroupCard(string id, string title, Action? onChanged, bool hideEmptyTitleBand = false, bool insetContent = false) + public DeviceGroupCard(string id, string title, Action? onChanged, bool hideEmptyTitleBand = false) { Id = id; _hideEmptyTitleBand = hideEmptyTitleBand; - _insetContent = insetContent; _suppressChanged = true; Title = title; _suppressChanged = false; @@ -59,19 +57,13 @@ public DeviceGroupCard(string id, string title, Action? onChang public partial string Title { get; set; } public bool HasTitle => !string.IsNullOrWhiteSpace(Title); - public bool ShowTitleBand => !_hideEmptyTitleBand || HasTitle || IsEditingTitle; - public GridLength TitleRowHeight => ShowTitleBand ? new GridLength(28) : new GridLength(0); - - [ObservableProperty] - public partial bool IsQuickPinned { get; set; } - public string QuickPinToggleLabel => IsQuickPinned ? "Unpin from Quick Controls" : "Pin to Quick Controls"; - public string QuickPinToggleGlyph => IsQuickPinned ? new string((char)0xE840, 1) : new string((char)0xE718, 1); + /// Title visibility as an enum so the Quick Controls flyout can bind it without a + /// converter (x:Bind converters can't resolve against a Window-hosted DataTemplate). + public Visibility TitleVisibility => HasTitle ? Visibility.Visible : Visibility.Collapsed; - [ObservableProperty] - public partial bool IsPointerOver { get; set; } - - public bool ShowQuickPinAffordance => HasTitle && IsPointerOver && !IsEditingTitle; + public bool ShowTitleBand => !_hideEmptyTitleBand || HasTitle || IsEditingTitle; + public GridLength TitleRowHeight => ShowTitleBand ? new GridLength(28) : new GridLength(0); /// True while the title is being edited (double-tap / Rename): the read-only label swaps /// to a text box. The label is the group's drag handle, so editing is entered explicitly. @@ -84,11 +76,8 @@ partial void OnIsEditingTitleChanged(bool value) { OnPropertyChanged(nameof(ShowTitleBand)); OnPropertyChanged(nameof(ShowTitleLabel)); - OnPropertyChanged(nameof(ShowQuickPinAffordance)); } - partial void OnIsPointerOverChanged(bool value) => OnPropertyChanged(nameof(ShowQuickPinAffordance)); - /// Shows the container's dotted outline. The page flips this on every group while a drag /// is in flight, so groups read as transparent at rest and reveal their bounds only while dragging. [ObservableProperty] @@ -112,25 +101,7 @@ partial void OnIsEditingTitleChanged(bool value) /// Inset applied to the members while a drag is in flight, so the dotted outline (drawn /// at the group-box bounds) has breathing room around the cards instead of hugging them. Left / /// right / bottom only - the title band already supplies the top gap. Zero at rest. - public Thickness ContentPadding - { - get - { - var left = _insetContent ? 12 : 0; - var top = _insetContent ? 8 : 0; - var right = _insetContent ? 12 : 0; - var bottom = _insetContent ? 12 : 0; - - if (ShowOutline) - { - left += 8; - right += 8; - bottom += 8; - } - - return new Thickness(left, top, right, bottom); - } - } + public Thickness ContentPadding => ShowOutline ? new Thickness(8, 0, 8, 8) : new Thickness(0); partial void OnShowOutlineChanged(bool value) { @@ -142,28 +113,20 @@ partial void OnShowOutlineChanged(bool value) /// Refreshes the title from the persisted record without firing the change callback /// (used when reconciling existing group cards on a rebuild). - public void SyncFrom(string title, bool isQuickPinned) + public void SyncFrom(string title) { _suppressChanged = true; Title = title; - IsQuickPinned = isQuickPinned; _suppressChanged = false; } partial void OnTitleChanged(string value) { OnPropertyChanged(nameof(HasTitle)); + OnPropertyChanged(nameof(TitleVisibility)); OnPropertyChanged(nameof(TitleRowHeight)); OnPropertyChanged(nameof(ShowTitleBand)); OnPropertyChanged(nameof(ShowTitleLabel)); - OnPropertyChanged(nameof(ShowQuickPinAffordance)); - if (!_suppressChanged) _onChanged?.Invoke(this); - } - - partial void OnIsQuickPinnedChanged(bool value) - { - OnPropertyChanged(nameof(QuickPinToggleLabel)); - OnPropertyChanged(nameof(QuickPinToggleGlyph)); if (!_suppressChanged) _onChanged?.Invoke(this); } } diff --git a/src/Earmark.App/ViewModels/HomeViewModel.cs b/src/Earmark.App/ViewModels/HomeViewModel.cs index a5c10c1..eabb91b 100644 --- a/src/Earmark.App/ViewModels/HomeViewModel.cs +++ b/src/Earmark.App/ViewModels/HomeViewModel.cs @@ -89,7 +89,6 @@ public partial class HomeViewModel : ObservableObject, IDisposable private bool _homePageVisible = true; private bool _quickControlsVisible; private bool _quickControlProjectionQueued; - private string _quickControlProjectionReason = string.Empty; public HomeViewModel( IRulesService rules, @@ -212,19 +211,7 @@ public HomeViewModel( /// bindings (section visibility, layout opt-out, dividers). Called wherever a chip is added / /// removed. The resulting reflow is animated by the page's always-on block slide, so there's no /// signal to raise here. - private void NotifyCardApps(DeviceCard card) - { - card.NotifyAppsChanged(); - if (card.IsQuickPinned) - { - _logger.LogDebug( - "QuickControls content changed: card='{Card}' key='{Key}' appCount={AppCount} hasApps={HasApps}", - card.DisplayName, - card.DeviceKey, - card.Apps.Count, - card.HasApps); - } - } + private static void NotifyCardApps(DeviceCard card) => card.NotifyAppsChanged(); /// Group container VMs by id, reused across rebuilds so an in-progress title edit and /// the member card instances survive. @@ -852,7 +839,7 @@ private void MaybeSeedQuickControlsPins() { var s = _settings.Current; if (s.SettingsSchemaVersion >= AppSettings.QuickControlsSeedSchemaVersion) return; - if (s.DeviceGroups.Any(g => g.PinnedToQuickControls) || s.Devices.Values.Any(d => d.PinnedToQuickControls == true)) + if (s.Devices.Values.Any(d => d.PinnedToQuickControls == true)) { s.SettingsSchemaVersion = AppSettings.QuickControlsSeedSchemaVersion; QueueSettingsSave(); @@ -1520,17 +1507,10 @@ private void SyncBlocks() gc = new DeviceGroupCard(group.Id, group.Title, OnGroupCardChanged); _groupCards[group.Id] = gc; } - if (group.PinnedToQuickControls) + else { - group.PinnedToQuickControls = false; - foreach (var member in members) - { - member.IsQuickPinned = true; - UpdateDeviceConfig(member); - } - QueueSettingsSave(); + gc.SyncFrom(group.Title); } - gc.SyncFrom(group.Title, isQuickPinned: false); liveGroupCardById[group.Id] = gc; desiredMembers[gc] = members; } @@ -1597,22 +1577,11 @@ private void SyncBlocks() SyncQuickControlBlocks(); } + // Projects the Devices-page block tree onto the Quick Controls overlay: pinned group members keep + // their group; pinned lone cards are bundled into an untitled pseudo-group so adjacent cards share + // one flyout panel. Quick group cards are cached in _quickGroupCards and reused across rebuilds. private void SyncQuickControlBlocks() { - var startedAt = DateTime.UtcNow; - var debug = _logger.IsEnabled(LogLevel.Debug); - if (debug) - { - _logger.LogDebug( - "QuickControls projection start: blocks={BlockCount} quickBlocks={QuickBlockCount} cachedGroups={CachedGroupCount} visibleCards={VisibleCardCount} pinnedCards={PinnedCardCount} input=[{InputBlocks}]", - Blocks.Count, - QuickControlBlocks.Count, - _quickGroupCards.Count, - _visibleCards.Count, - _visibleCards.Count(card => card.IsQuickPinned), - FormatQuickControlBlocks(Blocks)); - } - var desired = new List(); var desiredMembers = new Dictionary>(); var liveQuickGroups = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -1637,14 +1606,6 @@ void FlushUngroupedCards() liveQuickGroups.Add(key); } - if (debug && pendingUngroupedCards.Count > 0) - { - _logger.LogDebug( - "QuickControls projection flush: pendingUngrouped={Count} emitted={Emitted}", - pendingUngroupedCards.Count, - desired.Count > 0 ? FormatQuickControlBlock(desired[^1]) : "none"); - } - pendingUngroupedCards.Clear(); } @@ -1655,20 +1616,9 @@ void FlushUngroupedCards() case DeviceGroupCard group: FlushUngroupedCards(); var pinnedMembers = group.Members - .Where(card => card.IsQuickPinned && TryUseQuickCard(card, $"group '{group.Title}'")) + .Where(card => card.IsQuickPinned && TryUseQuickCard(card)) .DistinctBy(card => card.DeviceKey, StringComparer.OrdinalIgnoreCase) .ToList(); - if (debug) - { - _logger.LogDebug( - "QuickControls projection group: title='{Title}' id='{Id}' members={MemberCount} pinnedMembers={PinnedCount} pinned=[{PinnedMembers}]", - group.Title, - group.Id, - group.Members.Count, - pinnedMembers.Count, - FormatQuickControlCards(pinnedMembers)); - } - if (pinnedMembers.Count > 0) { var quickGroup = GetQuickControlGroup($"group:{group.Id}", group.Title, hideEmptyTitleBand: false); @@ -1677,47 +1627,14 @@ void FlushUngroupedCards() liveQuickGroups.Add($"group:{group.Id}"); } break; - case DeviceCard card when card.IsQuickPinned: - if (!TryUseQuickCard(card, "ungrouped")) - { - break; - } - - if (debug) - { - _logger.LogDebug( - "QuickControls projection ungrouped pinned card: '{Card}' key='{Key}' flow={Flow} apps={AppCount}", - card.DisplayName, - card.DeviceKey, - card.Endpoint.Flow, - card.Apps.Count); - } - + case DeviceCard card when card.IsQuickPinned && TryUseQuickCard(card): pendingUngroupedCards.Add(card); break; - case DeviceCard card when debug: - _logger.LogDebug( - "QuickControls projection skip unpinned card: '{Card}' key='{Key}' hidden={Hidden} groupMember={GroupMember}", - card.DisplayName, - card.DeviceKey, - card.IsEffectivelyHidden, - card.IsGroupMember); - break; } } FlushUngroupedCards(); - if (debug) - { - _logger.LogDebug( - "QuickControls projection desired: desiredBlocks={DesiredCount} desired=[{DesiredBlocks}] groups=[{Groups}] oldQuick=[{OldQuickBlocks}]", - desired.Count, - FormatQuickControlBlocks(desired), - FormatQuickControlGroups(desiredMembers), - FormatQuickControlBlocks(QuickControlBlocks)); - } - foreach (var group in _quickGroupCards.Values) { group.Members.Clear(); @@ -1739,44 +1656,20 @@ void FlushUngroupedCards() foreach (var goneId in _quickGroupCards.Keys.Where(id => !liveQuickGroups.Contains(id)).ToList()) { - if (debug) - { - _logger.LogDebug("QuickControls projection remove cached group: key='{Key}'", goneId); - } - _quickGroupCards.Remove(goneId); } - if (debug) - { - _logger.LogDebug( - "QuickControls projection complete: elapsedMs={ElapsedMs} quickBlocks={QuickBlockCount} cachedGroups={CachedGroupCount} quick=[{QuickBlocks}]", - (DateTime.UtcNow - startedAt).TotalMilliseconds, - QuickControlBlocks.Count, - _quickGroupCards.Count, - FormatQuickControlBlocks(QuickControlBlocks)); - LogQuickControlDuplicateCards(); - } - OnPropertyChanged(nameof(HasQuickControlBlocks)); - bool TryUseQuickCard(DeviceCard card, string location) + bool TryUseQuickCard(DeviceCard card) { if (usedQuickCardKeys.Add(card.DeviceKey)) return true; - - _logger.LogWarning( - "QuickControls projection skipped duplicate card: '{Card}' key='{Key}' duplicateLocation={Location}", - card.DisplayName, - card.DeviceKey, - location); + _logger.LogWarning("QuickControls projection skipped duplicate card '{Card}' ({Key})", card.DisplayName, card.DeviceKey); return false; } } - private DeviceGroupCard GetQuickControlGroup( - string key, - string title, - bool hideEmptyTitleBand) + private DeviceGroupCard GetQuickControlGroup(string key, string title, bool hideEmptyTitleBand) { if (!_quickGroupCards.TryGetValue(key, out var group)) { @@ -1784,62 +1677,10 @@ private DeviceGroupCard GetQuickControlGroup( _quickGroupCards[key] = group; } - group.SyncFrom(title, isQuickPinned: false); + group.SyncFrom(title); return group; } - private void LogQuickControlDuplicateCards() - { - var seen = new Dictionary(); - foreach (var block in QuickControlBlocks) - { - switch (block) - { - case DeviceCard card: - Add(card, "top-level"); - break; - case DeviceGroupCard group: - foreach (var member in group.Members) - { - Add(member, $"group '{group.Title}' ({group.Id})"); - } - break; - } - } - - void Add(DeviceCard card, string location) - { - if (seen.TryGetValue(card, out var firstLocation)) - { - _logger.LogWarning( - "QuickControls projection duplicate card: '{Card}' key='{Key}' first={FirstLocation} duplicate={DuplicateLocation}", - card.DisplayName, - card.DeviceKey, - firstLocation, - location); - return; - } - - seen[card] = location; - } - } - - private static string FormatQuickControlBlocks(IEnumerable blocks) => - string.Join(" | ", blocks.Select(FormatQuickControlBlock)); - - private static string FormatQuickControlBlock(object block) => block switch - { - DeviceCard card => $"card:'{card.DisplayName}' key='{card.DeviceKey}' quick={card.IsQuickPinned} apps={card.Apps.Count}", - DeviceGroupCard group => $"group:'{group.Title}' id='{group.Id}' members=[{FormatQuickControlCards(group.Members)}]", - _ => block.GetType().Name, - }; - - private static string FormatQuickControlGroups(Dictionary> groups) => - string.Join(" | ", groups.Select(pair => $"'{pair.Key.Title}'/{pair.Key.Id} -> [{FormatQuickControlCards(pair.Value)}]")); - - private static string FormatQuickControlCards(IEnumerable cards) => - string.Join(", ", cards.Select(card => $"'{card.DisplayName}' key='{card.DeviceKey}' quick={card.IsQuickPinned} apps={card.Apps.Count}")); - /// Removal half of the two-phase reconcile: drops from any item /// not in , plus any duplicate occurrences (keeps one). Running all removals /// before any additions keeps a card from sitting in two bound collections at once. @@ -2224,46 +2065,26 @@ private void OnCardVisibilityToggled(DeviceCard card, DeviceCard.VisibilityState private void OnCardQuickPinToggled(DeviceCard card) { - _logger.LogDebug( - "QuickControls pin toggled: card='{Card}' key='{Key}' quickPinned={QuickPinned} hidden={Hidden} groupMember={GroupMember} appCount={AppCount}", - card.DisplayName, - card.DeviceKey, - card.IsQuickPinned, - card.IsEffectivelyHidden, - card.IsGroupMember, - card.Apps.Count); UpdateDeviceConfig(card); QueueSettingsSave(); - QueueQuickControlProjectionSync($"quick pin toggled: {card.DeviceKey}"); + QueueQuickControlProjectionSync(); } - private void QueueQuickControlProjectionSync(string reason) + // Coalesces multiple pin toggles in one tick into a single projection pass. + private void QueueQuickControlProjectionSync() { - _quickControlProjectionReason = string.IsNullOrWhiteSpace(_quickControlProjectionReason) - ? reason - : $"{_quickControlProjectionReason}; {reason}"; - - if (_quickControlProjectionQueued) - { - _logger.LogDebug("QuickControls deferred projection already queued: reason='{Reason}'", _quickControlProjectionReason); - return; - } + if (_quickControlProjectionQueued) return; _quickControlProjectionQueued = true; - _logger.LogDebug("QuickControls deferred projection queued: reason='{Reason}'", _quickControlProjectionReason); _dispatcher.Queue.TryEnqueue(() => { - var queuedReason = _quickControlProjectionReason; - _quickControlProjectionReason = string.Empty; _quickControlProjectionQueued = false; - _logger.LogDebug("QuickControls deferred projection running: reason='{Reason}'", queuedReason); SyncQuickControlBlocks(); }); } - public void HideAndUnpin(DeviceCard card) + public static void HideAndUnpin(DeviceCard card) { - _ = _settings; if (!card.IsQuickPinned || card.IsEffectivelyHidden) { card.ToggleUserVisibilityCommand.Execute(null); @@ -2401,23 +2222,9 @@ public void UndoVisibilityChange() private void PersistAndResync(DeviceCard card) { - var startedAt = DateTime.UtcNow; - _logger.LogDebug( - "PersistAndResync start: card='{Card}' key='{Key}' hidden={Hidden} pinned={Pinned} quickPinned={QuickPinned} volumeControlsHidden={VolumeControlsHidden}", - card.DisplayName, - card.DeviceKey, - card.IsHiddenByUser, - card.IsPinnedByUser, - card.IsQuickPinned, - card.IsVolumeControlsHiddenByUser); UpdateDeviceConfig(card); QueueSettingsSave(); SyncBlocks(); - _logger.LogDebug( - "PersistAndResync complete: card='{Card}' key='{Key}' elapsedMs={ElapsedMs}", - card.DisplayName, - card.DeviceKey, - (DateTime.UtcNow - startedAt).TotalMilliseconds); } /// Writes the card's current per-device flags into the @@ -2436,18 +2243,6 @@ private void UpdateDeviceConfig(DeviceCard card) }; if (cfg.IsDefault) map.Remove(card.DeviceKey); else map[card.DeviceKey] = cfg; - _logger.LogDebug( - "Device config updated: card='{Card}' key='{Key}' default={Default} hidden={Hidden} pinned={Pinned} quickPinned={QuickPinned} volumeControlsHidden={VolumeControlsHidden} glyph='{Glyph}' accent='{Accent}' stored={Stored}", - card.DisplayName, - card.DeviceKey, - cfg.IsDefault, - cfg.Hidden, - cfg.Pinned, - cfg.PinnedToQuickControls, - cfg.VolumeControlsHidden, - cfg.Glyph, - cfg.AccentColour, - !cfg.IsDefault); } public void Dispose() diff --git a/src/Earmark.App/Views/HomePage.xaml b/src/Earmark.App/Views/HomePage.xaml index 597a83b..4e2e1b5 100644 --- a/src/Earmark.App/Views/HomePage.xaml +++ b/src/Earmark.App/Views/HomePage.xaml @@ -13,40 +13,9 @@ - - - - - + container's inner member repeater, so both render identically. The card body is the + reusable DeviceCardView; this Border adds the drop target + drag source chrome. --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 14 - 14 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -850,8 +61,6 @@ Background="{ThemeResource GroupTitleBandBackgroundBrush}" CanDrag="True" Tag="{x:Bind}" - PointerEntered="OnGroupPointerEntered" - PointerExited="OnGroupPointerExited" DragStarting="OnGroupHeaderDragStarting" DropCompleted="OnGroupHeaderDropCompleted" DoubleTapped="OnGroupTitleDoubleTapped" diff --git a/src/Earmark.App/Views/HomePage.xaml.cs b/src/Earmark.App/Views/HomePage.xaml.cs index 226fb3e..b162ca9 100644 --- a/src/Earmark.App/Views/HomePage.xaml.cs +++ b/src/Earmark.App/Views/HomePage.xaml.cs @@ -27,21 +27,10 @@ namespace Earmark.App.Views; public sealed partial class HomePage : Page { private readonly ILogger? _logger; - private readonly RulesViewModel _rulesViewModel; - private readonly MainWindow _mainWindow; - /// - /// Pre-drag volume / mute captured per slider. Indexed by the Slider instance because - /// the same DeviceCard could theoretically host concurrent interactions; in practice this - /// also dodges any "card replaced mid-drag" edge cases by keying off the live control. - /// - private readonly Dictionary _sliderDragStart = new(); - - public HomePage(HomeViewModel viewModel, RulesViewModel rulesViewModel, MainWindow mainWindow) + public HomePage(HomeViewModel viewModel) { ViewModel = viewModel; - _rulesViewModel = rulesViewModel; - _mainWindow = mainWindow; InitializeComponent(); _logger = App.Current.Services.GetService>(); @@ -258,81 +247,22 @@ private void OnUngroupAllClicked(object sender, RoutedEventArgs e) _ => null, }; - private void OnUngroupDeviceClicked(object sender, RoutedEventArgs e) - { - if (sender is FrameworkElement { Tag: DeviceCard card }) - { - ViewModel.UngroupDevice(card.DeviceKey); - } - } - - private void OnForgetDeviceClicked(object sender, RoutedEventArgs e) + private void OnCardRenameGroupRequested(object? sender, DeviceGroupCard group) { - if (sender is FrameworkElement { Tag: DeviceCard card }) - { - ViewModel.ForgetDevice(card); - } - } - - private async void OnDeviceVisibilityClicked(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement { Tag: DeviceCard card }) return; - if (!card.IsEffectivelyHidden && card.IsQuickPinned) + // The card/app-chip "Rename group" already flipped IsEditingTitle; focus the title editor, + // which lives in the group header's tree on this page. + _editingGroup = group; + var idx = ViewModel.Blocks.IndexOf(group); + if (idx >= 0 && DevicesRepeater.TryGetElement(idx) is FrameworkElement blockEl) { - var dialog = new ContentDialog - { - XamlRoot = XamlRoot, - Title = "Hide pinned device?", - Content = "This device is pinned to Quick Controls. Hiding it will remove it from Quick Controls.", - PrimaryButtonText = "Hide and unpin", - CloseButtonText = "Cancel", - DefaultButton = ContentDialogButton.Close, - }; - - if (await dialog.ShowAsync() != ContentDialogResult.Primary) - { - return; - } - - ViewModel.HideAndUnpin(card); - return; + FocusTitleEditor(blockEl); } - - card.ToggleUserVisibilityCommand.Execute(null); - } - - private void OnGroupPointerEntered(object sender, PointerRoutedEventArgs e) - { - if (sender is FrameworkElement { Tag: DeviceGroupCard group }) group.IsPointerOver = true; - } - - private void OnGroupPointerExited(object sender, PointerRoutedEventArgs e) - { - if (sender is FrameworkElement { Tag: DeviceGroupCard group }) group.IsPointerOver = false; - } - - private void OnCustomiseClicked(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement { Tag: DeviceCard card }) return; - // Defer so the context MenuFlyout finishes dismissing before the dialog opens. - DispatcherQueue.TryEnqueue(async () => - { - try - { - var dialog = BuildCustomiseDialog(card); - dialog.XamlRoot = XamlRoot; - await dialog.ShowAsync(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "Customise: dialog threw"); - } - }); } private const double CustomiseWidth = 280; - private static ContentDialog BuildCustomiseDialog(DeviceCard card) + // Built here (shared static) and also invoked by DeviceCardView's card context menu. + internal static ContentDialog BuildCustomiseDialog(DeviceCard card) { // Snapshot the saved state. The dialog edits a PENDING copy and only writes it back to the // card on Save - the card and its tile are never touched mid-edit, so Cancel is a no-op and @@ -1197,140 +1127,12 @@ private void OnBlockElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPr () => ApplyReorderAnimation(element, true)); // on once placed, for every later move } - /// Attaches a Composition implicit Offset animation to an app chip's container the first - /// time it renders, so a re-sort (active/idle tiering) or a sibling appearing/leaving slides the - /// chips to their new spots instead of popping. Offset ONLY - no opacity, so a recycled container - /// can't come back stuck transparent (the bug an opacity hide animation caused). Attached after the - /// first arrange (Loaded), so a chip's first appearance is instant with no slide-from-origin. The - /// animation lives on the container the WrapPanel arranges, not the template root. - private void OnAppChipLoaded(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement border) return; - if (VisualTreeHelper.GetParent(border) is not UIElement container) return; - - var visual = ElementCompositionPreview.GetElementVisual(container); - var compositor = visual.Compositor; - - var offset = compositor.CreateVector3KeyFrameAnimation(); - offset.Target = "Offset"; - offset.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); - offset.Duration = TimeSpan.FromMilliseconds(220); - - var implicits = compositor.CreateImplicitAnimationCollection(); - implicits["Offset"] = offset; - visual.ImplicitAnimations = implicits; - } - private void OnUndoInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) { ViewModel.UndoVisibilityChangeCommand.Execute(null); args.Handled = true; } - private void OnMuteToggleClicked(object sender, RoutedEventArgs e) - { - // ItemsRepeater doesn't propagate DataContext to x:Bind templates - the button - // carries the DeviceCard via Tag="{x:Bind}" instead. - if (sender is not FrameworkElement { Tag: DeviceCard card }) return; - - var prevVolume = card.Volume; - var prevMuted = card.IsMuted; - card.ToggleMuteCommand.Execute(null); - // Mute icon clicks only change IsMuted; carry the unchanged volume so Ctrl+Z - // restores both together as one entry. - ViewModel.RecordVolumeMuteUndo(card, prevVolume, prevMuted); - } - - private void OnRuleChipClicked(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement { DataContext: RuleSummary summary }) return; - - _rulesViewModel.RequestFocusRule(summary.RuleId); - _mainWindow.NavigateByTag("Rules"); - } - - // CA1822 suppressed: XAML event hookup requires instance methods even when the body - // doesn't touch instance state. -#pragma warning disable CA1822 - - private void OnSliderPointerPressed(object sender, PointerRoutedEventArgs e) - { - if (sender is Slider { Tag: DeviceCard card } slider) - { - _sliderDragStart[slider] = (card.Volume, card.IsMuted); - } - } - - private void OnSliderReleased(object sender, PointerRoutedEventArgs e) - { - if (sender is not Slider { Tag: DeviceCard card } slider) return; - - FinaliseSliderInteraction(slider, card); - card.PlayPing(); - } - - private void OnSliderKeyDown(object sender, KeyRoutedEventArgs e) - { - if (!IsSliderNudgeKey(e.Key)) return; - if (sender is Slider { Tag: DeviceCard card } slider && - !_sliderDragStart.ContainsKey(slider)) - { - _sliderDragStart[slider] = (card.Volume, card.IsMuted); - } - } - - private void OnSliderKeyUp(object sender, KeyRoutedEventArgs e) - { - if (!IsSliderNudgeKey(e.Key)) return; - if (sender is not Slider { Tag: DeviceCard card } slider) return; - - FinaliseSliderInteraction(slider, card); - card.PlayPing(); - } - - private void OnSliderLostFocus(object sender, RoutedEventArgs e) - { - // Belt-and-suspenders: if focus moves away mid-interaction (e.g. window deactivated), - // commit whatever change we have so the undo entry isn't lost. - if (sender is Slider { Tag: DeviceCard card } slider) - { - FinaliseSliderInteraction(slider, card); - } - } - - private void FinaliseSliderInteraction(Slider slider, DeviceCard card) - { - if (!_sliderDragStart.TryGetValue(slider, out var start)) return; - _sliderDragStart.Remove(slider); - ViewModel.RecordVolumeMuteUndo(card, start.Volume, start.Muted); - } - - private static bool IsSliderNudgeKey(VirtualKey key) => - key is VirtualKey.Left or VirtualKey.Right - or VirtualKey.Up or VirtualKey.Down - or VirtualKey.PageUp or VirtualKey.PageDown - or VirtualKey.Home or VirtualKey.End; - - private void OnLockedSliderTapped(object sender, TappedRoutedEventArgs e) - { - if (sender is FrameworkElement { DataContext: DeviceCard card }) - { - card.PlayPing(); - } - } - - // A rule-locked (disabled) slider doesn't capture the pointer the way an enabled one does, so - // a press-drag over it would otherwise bubble to the card's CanDrag and start a reorder. The - // transparent lock overlay captures the pointer on press (mirroring the enabled slider) to keep - // the gesture off the card; the tooltip and tap-to-ping still work. - private void OnLockedSliderPointerPressed(object sender, PointerRoutedEventArgs e) => - (sender as UIElement)?.CapturePointer(e.Pointer); - - private void OnLockedSliderPointerReleased(object sender, PointerRoutedEventArgs e) => - (sender as UIElement)?.ReleasePointerCapture(e.Pointer); - -#pragma warning restore CA1822 - // ---- App chip drag / drop ---- // // In-process drag of an AppChip onto a render DeviceCard rebinds the session's per-app @@ -1344,54 +1146,6 @@ private void OnLockedSliderPointerReleased(object sender, PointerRoutedEventArgs private const string DragPayloadPrefix = "earmark:chip:"; - private void OnAppChipDragStarting(UIElement sender, DragStartingEventArgs args) - { - if (sender is not FrameworkElement { Tag: AppChip chip }) return; - if (!chip.CanDrag) - { - args.Cancel = true; - return; - } - - // Payload is parsed in OnDeviceCardDrop. Keep it small; the AppChip itself doesn't - // have to round-trip - the page resolves PID + source endpoint back to the live chip - // via the HomeViewModel's card list, which is the source of truth. - var payload = $"{DragPayloadPrefix}{chip.ProcessId}|{chip.SourceEndpointId}"; - args.Data.SetText(payload); - args.Data.RequestedOperation = DataPackageOperation.Move; - - SetDragInProgress(true); - } - - private void OnAppChipDropCompleted(UIElement sender, DropCompletedEventArgs args) - { - SetDragInProgress(false); - } - - /// Reveals the chip's "Terminate this app" item only while Shift is held as the context - /// menu opens - an Explorer-style hidden power action. The terminate item is the one carrying the - /// AppChip as its Tag; its base availability is gated by - /// so a System Sounds or closed chip never exposes it even with Shift down. Shift state is read at - /// the current input message, which is the right-click that opened the menu. - // CA1822 suppressed: XAML event hookup requires an instance method even though the body is static. -#pragma warning disable CA1822 - private void OnAppChipFlyoutOpening(object sender, object e) - { - if (sender is not MenuFlyout flyout) return; - var shiftDown = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift) - .HasFlag(CoreVirtualKeyStates.Down); - foreach (var item in flyout.Items) - { - if (item is MenuFlyoutItem { Tag: AppChip chip } terminate) - { - terminate.Visibility = shiftDown && chip.ShowProcessActions - ? Visibility.Visible - : Visibility.Collapsed; - } - } - } -#pragma warning restore CA1822 - /// Reveals every group container's dotted outline while a drag is in flight, so groups /// read as transparent at rest and show their bounds only while dragging. private void SetDragInProgress(bool active) diff --git a/src/Earmark.App/Views/QuickControlsWindow.xaml b/src/Earmark.App/Views/QuickControlsWindow.xaml index fb781ae..94c2ce1 100644 --- a/src/Earmark.App/Views/QuickControlsWindow.xaml +++ b/src/Earmark.App/Views/QuickControlsWindow.xaml @@ -3,15 +3,42 @@ x:Class="Earmark.App.Views.QuickControlsWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vm="using:Earmark.App.ViewModels" xmlns:controls="using:Earmark.App.Controls" Title="Quick Controls"> - - - + + + + + + + + + + + + + + + + + + + + @@ -25,8 +52,7 @@ HorizontalScrollBarVisibility="Disabled" IsScrollInertiaEnabled="False" Background="Transparent"> - + diff --git a/src/Earmark.App/Views/QuickControlsWindow.xaml.cs b/src/Earmark.App/Views/QuickControlsWindow.xaml.cs index 1e036f2..981f399 100644 --- a/src/Earmark.App/Views/QuickControlsWindow.xaml.cs +++ b/src/Earmark.App/Views/QuickControlsWindow.xaml.cs @@ -1,6 +1,5 @@ using System.Runtime.InteropServices; -using Earmark.App.Controls; using Earmark.App.Settings; using Earmark.App.ViewModels; @@ -11,9 +10,11 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Windows.Graphics; +using Windows.System; using WinRT; using WinRT.Interop; @@ -27,34 +28,24 @@ public sealed partial class QuickControlsWindow : Window private const int OverlayMargin = 8; private const int ScrollOverflowTolerance = 16; private const int OverlayWidth = OverlayContentWidth + (OverlayPadding * 2); - private static readonly HashSet QuickControlsHiddenElementNames = new(StringComparer.Ordinal) - { - "RulesDivider", - "RulesSection", - "NoRulesMessage", - }; private readonly ILogger _logger; private readonly ISettingsService _settings; private readonly nint _hwnd; - private readonly Dictionary _hiddenElementCallbacks = new(); private ISystemBackdropControllerWithTargets? _backdropController; private readonly SystemBackdropConfiguration _backdropConfig = new() { IsInputActive = true }; private BackdropMode? _appliedBackdrop; - public QuickControlsWindow(HomeViewModel viewModel, HomePage homePage, ISettingsService settings, ILogger logger) + public QuickControlsWindow(HomeViewModel viewModel, ISettingsService settings, ILogger logger) { ViewModel = viewModel; _settings = settings; _logger = logger; InitializeComponent(); Root.DataContext = ViewModel; - var selector = (BlockTemplateSelector)Root.Resources["QuickBlockTemplateSelector"]; - selector.CardTemplate = (DataTemplate)homePage.Resources["DeviceCardTemplate"]; - selector.GroupTemplate = (DataTemplate)homePage.Resources["DeviceGroupCardTemplate"]; _hwnd = WindowNative.GetWindowHandle(this); - ExtendsContentIntoTitleBar = true; SystemBackdrop = null; AppWindow.Title = "Quick Controls"; + AddEscapeAccelerator(); ApplySettings(); ConfigureWindow(); Hide(); @@ -70,6 +61,21 @@ public QuickControlsWindow(HomeViewModel viewModel, HomePage homePage, ISettings public bool IsOpen { get; private set; } + /// Raised when the user presses Escape while a flyout panel has focus. The owning service + /// hides the whole stack. + public event EventHandler? DismissRequested; + + private void AddEscapeAccelerator() + { + var escape = new KeyboardAccelerator { Key = VirtualKey.Escape }; + escape.Invoked += (_, args) => + { + args.Handled = true; + DismissRequested?.Invoke(this, EventArgs.Empty); + }; + Root.KeyboardAccelerators.Add(escape); + } + public int MeasureBlocksHeight(IReadOnlyList blocks, RectInt32 workArea) { ArgumentNullException.ThrowIfNull(blocks); @@ -85,25 +91,10 @@ public int MeasureBlocksHeight(IReadOnlyList blocks, RectInt32 workArea) AppWindow.Resize(new SizeInt32(width, Math.Max(1, workArea.Height - (OverlayMargin * 2)))); AppWindow.Move(new PointInt32(-40000, -40000)); - Root.UpdateLayout(); - var collapsed = CollapseQuickControlsOnlyElements(Root); Root.UpdateLayout(); Repeater.Measure(new Windows.Foundation.Size(contentWidth, double.PositiveInfinity)); - var desiredHeight = Math.Max(OverlayPadding * 2, (int)Math.Ceiling(Repeater.DesiredSize.Height) + (OverlayPadding * 2)); - _logger.LogDebug( - "QuickControls window measure: hwnd={Hwnd} blocks={BlockCount} workArea=({X},{Y},{Width},{Height}) width={OverlayWidth} contentWidth={ContentWidth} desiredHeight={DesiredHeight} collapsedRuleElements={Collapsed}", - _hwnd, - blocks.Count, - workArea.X, - workArea.Y, - workArea.Width, - workArea.Height, - width, - contentWidth, - desiredHeight, - collapsed); - return desiredHeight; + return Math.Max(OverlayPadding * 2, (int)Math.Ceiling(Repeater.DesiredSize.Height) + (OverlayPadding * 2)); } public int PrepareMeasuredBlocks(RectInt32 workArea, int bottom, int maxHeight, int desiredHeight) @@ -119,7 +110,6 @@ public int PrepareMeasuredBlocks(RectInt32 workArea, int bottom, int maxHeight, Math.Max(workArea.X + OverlayMargin, workArea.X + workArea.Width - width - OverlayMargin)); ConfigureWindow(); - var collapsedBeforeResize = CollapseQuickControlsOnlyElements(Root); var height = Math.Min(desiredHeight, boundedMaxHeight); Scroller.VerticalScrollBarVisibility = desiredHeight - boundedMaxHeight > ScrollOverflowTolerance ? ScrollBarVisibility.Auto @@ -127,97 +117,21 @@ public int PrepareMeasuredBlocks(RectInt32 workArea, int bottom, int maxHeight, AppWindow.Resize(new SizeInt32(width, height)); AppWindow.Move(new PointInt32(left, Math.Clamp(boundedBottom - height, workTop, workBottom - height))); - var collapsedAfterResize = CollapseQuickControlsOnlyElements(Root); - DispatcherQueue.TryEnqueue(() => CollapseQuickControlsOnlyElements(Root)); Root.UpdateLayout(); - _logger.LogDebug( - "QuickControls window prepare: hwnd={Hwnd} workArea=({X},{Y},{Width},{Height}) requestedBottom={Bottom} boundedBottom={BoundedBottom} maxHeight={MaxHeight} boundedMaxHeight={BoundedMaxHeight} desiredHeight={DesiredHeight} actualHeight={Height} left={Left} top={Top} scrollbar={Scrollbar} collapsedBefore={CollapsedBefore} collapsedAfter={CollapsedAfter}", - _hwnd, - workArea.X, - workArea.Y, - workArea.Width, - workArea.Height, - bottom, - boundedBottom, - maxHeight, - boundedMaxHeight, - desiredHeight, - height, - left, - Math.Clamp(boundedBottom - height, workTop, workBottom - height), - Scroller.VerticalScrollBarVisibility, - collapsedBeforeResize, - collapsedAfterResize); return height; } - private void OnRepeaterElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) - { - var collapsed = CollapseQuickControlsOnlyElements(args.Element); - if (collapsed > 0) - { - _logger.LogDebug("QuickControls repeater prepared element: collapsedRuleElements={Collapsed}", collapsed); - } - DispatcherQueue.TryEnqueue(() => CollapseQuickControlsOnlyElements(args.Element)); - } - - private int CollapseQuickControlsOnlyElements(DependencyObject root) - { - var collapsed = 0; - var count = VisualTreeHelper.GetChildrenCount(root); - for (var i = 0; i < count; i++) - { - var child = VisualTreeHelper.GetChild(root, i); - if (child is FrameworkElement element && QuickControlsHiddenElementNames.Contains(element.Name)) - { - HideQuickControlsOnlyElement(element); - collapsed++; - } - - collapsed += CollapseQuickControlsOnlyElements(child); - } - - return collapsed; - } - - private void HideQuickControlsOnlyElement(FrameworkElement element) - { - if (!_hiddenElementCallbacks.ContainsKey(element)) - { - var token = element.RegisterPropertyChangedCallback(UIElement.VisibilityProperty, (_, _) => - { - if (element.Visibility != Visibility.Collapsed) - { - element.Visibility = Visibility.Collapsed; - } - }); - _hiddenElementCallbacks[element] = token; - } - - if (element.Visibility != Visibility.Collapsed) - { - element.Visibility = Visibility.Collapsed; - } - } - public void ShowPrepared() { AppWindow.Show(); + // Give the panel focus so the Escape accelerator has an active focus scope (the window has no + // title bar to take focus on its own). Pointer focus state avoids drawing the focus rectangle. + Root.Focus(FocusState.Pointer); IsOpen = true; - CollapseRulesAfterLayoutPasses(); - _logger.LogDebug("QuickControls window show prepared: hwnd={Hwnd}", _hwnd); - } - - private void CollapseRulesAfterLayoutPasses(int remainingPasses = 3) - { - CollapseQuickControlsOnlyElements(Root); - if (remainingPasses <= 0) return; - DispatcherQueue.TryEnqueue(() => CollapseRulesAfterLayoutPasses(remainingPasses - 1)); } public void Hide() { - _logger.LogDebug("QuickControls window hide: hwnd={Hwnd} wasOpen={WasOpen}", _hwnd, IsOpen); AppWindow.Hide(); Repeater.ItemsSource = null; IsOpen = false;