From 4485f9df2f3bdcd658877bd46fbedf7bdb59f0f6 Mon Sep 17 00:00:00 2001 From: purr Date: Fri, 10 Apr 2026 19:13:19 +0200 Subject: [PATCH 1/5] Add drag/drop outcome previews, smarter panel restructuring, and safety settings. Introduces visual drop outcome cues, anti-nesting split behavior, drag/drop edge-case fixes, accent-consistent cue rendering, and new settings for auto panel creation, hover action-menu visibility, and preserving window positions on exit, plus resume-time layout stabilization and exact-modifier hotkey handling. --- FancyWM.Tests/TilingWorkspaceTest.cs | 293 +++++- FancyWM.sln | 22 +- .../Controls/NonHitTestableTilingOverlay.xaml | 278 +++++- FancyWM/Controls/TilingNodeCaptionBlock.xaml | 22 +- FancyWM/Controls/TilingOverlay.xaml | 36 +- FancyWM/Controls/TilingWindow.xaml | 2 +- FancyWM/DropZonePreviewState.cs | 24 + FancyWM/Models/Entities.cs | 6 +- FancyWM/Models/Settings.cs | 22 +- FancyWM/Pages/Settings/InteractionPage.xaml | 66 +- FancyWM/Startup.cs | 5 +- FancyWM/TilingOverlayRenderer.cs | 78 +- FancyWM/TilingService.Private.cs | 279 +++++- FancyWM/TilingService.cs | 39 +- FancyWM/TilingWorkspace.cs | 935 ++++++++++++++++-- FancyWM/Utilities/LowLevelHotkey.cs | 45 +- FancyWM/ViewModels/SettingsViewModel.cs | 24 +- FancyWM/ViewModels/TilingOverlayViewModel.cs | 39 +- FancyWM/ViewModels/TilingPanelViewModel.cs | 10 +- FancyWM/ViewModels/TilingWindowViewModel.cs | 30 +- FancyWM/ViewModels/ViewModelBase.cs | 4 +- FancyWM/Windows/OverlayHost.OverlayWindow.cs | 2 +- FancyWM/Windows/OverlayHost.cs | 2 +- 23 files changed, 2099 insertions(+), 164 deletions(-) create mode 100644 FancyWM/DropZonePreviewState.cs diff --git a/FancyWM.Tests/TilingWorkspaceTest.cs b/FancyWM.Tests/TilingWorkspaceTest.cs index 822e5e1..9f85759 100644 --- a/FancyWM.Tests/TilingWorkspaceTest.cs +++ b/FancyWM.Tests/TilingWorkspaceTest.cs @@ -1,7 +1,10 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; +using FancyWM; using FancyWM.Layouts.Tiling; +using FancyWM.Models; using FancyWM.Tests.TestUtilities; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -17,6 +20,17 @@ public class TilingWorkspaceTest private readonly WindowMockFactory m_windowFactory = new(); private readonly Rectangle m_workarea = new(0, 0, 1920, 1080); + /// + /// Point over in the left edge band (outside the center stack zone), so + /// yields and + /// insertion index matches the legacy flat Move() reorder tests. + /// + private static Point SiblingReorderPointOver(WindowNode target) + { + var r = target.ComputedRectangle; + return new Point(r.Left + (int)(r.Width * 0.28), r.Top + r.Height / 2); + } + [TestMethod] public void TestAddRemoveDesktop() { @@ -318,7 +332,7 @@ public void TestMoveNodeFlat() tree.Measure(); tree.Arrange(); - workspace.MoveNode(node1, node2.ComputedRectangle.Center); + workspace.MoveNode(node1, SiblingReorderPointOver(node2)); Assert.AreEqual(node1.Parent.IndexOf(node1), 1); Assert.AreEqual(node2.Parent.IndexOf(node2), 0); @@ -362,25 +376,26 @@ void AssertPositions(params TilingNode[] nodes) AssertPositions(node1, node2, node3, node4, node5); - workspace.MoveNode(node1, node2.ComputedRectangle.Center); + workspace.MoveNode(node1, SiblingReorderPointOver(node2)); tree.Measure(); tree.Arrange(); AssertPositions(node2, node1, node3, node4, node5); - workspace.MoveNode(node3, node2.ComputedRectangle.Center); + workspace.MoveNode(node3, SiblingReorderPointOver(node2)); tree.Measure(); tree.Arrange(); AssertPositions(node3, node2, node1, node4, node5); - workspace.MoveNode(node5, node3.ComputedRectangle.Center); + workspace.MoveNode(node5, SiblingReorderPointOver(node3)); tree.Measure(); tree.Arrange(); AssertPositions(node5, node3, node2, node1, node4); - workspace.MoveNode(node2, node1.ComputedRectangle.Center); + // Swap adjacent siblings (center hit would stack if allowNesting were true) + workspace.MoveNode(node2, node1.ComputedRectangle.Center, allowNesting: false); tree.Measure(); tree.Arrange(); @@ -411,6 +426,272 @@ public void TestMoveWindowFlat() Assert.AreEqual(node2.Parent.IndexOf(node2), 0); } + [TestMethod] + public void TestRegisterWindowOverflowStackCreatesStackPanel() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + var overflowNode = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + + Assert.IsInstanceOfType(overflowNode.Parent, typeof(StackPanelNode)); + } + + [TestMethod] + public void TestRegisterWindowOverflowStackReusesExistingStack() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), 2, OverflowPlacementStrategy.Stack); + + var tree = workspace.GetTree(desktop)!; + var stacks = CollectStackPanelsUnderRoot(tree.Root!).ToList(); + Assert.AreEqual(2, stacks.Count, "with max width 2, overflow stacking should use two stack columns when possible"); + var counts = stacks.Select(s => s.Children.Count(c => c is WindowNode)).OrderBy(x => x).ToArray(); + CollectionAssert.AreEqual(new[] { 2, 2 }, counts, "new windows should spread across stacks (least-loaded first)"); + } + + private static IEnumerable CollectStackPanelsUnderRoot(PanelNode root) + { + foreach (var ch in root.Children) + { + foreach (var sp in EnumerateStackPanelsInSubtree(ch)) + yield return sp; + } + } + + private static IEnumerable EnumerateStackPanelsInSubtree(TilingNode node) + { + if (node is StackPanelNode sp) + { + yield return sp; + yield break; + } + + if (node is PanelNode p) + { + foreach (var c in p.Children) + { + foreach (var nested in EnumerateStackPanelsInSubtree(c)) + yield return nested; + } + } + } + + private static int CountSplitPanels(TilingNode node) + { + var count = node is SplitPanelNode ? 1 : 0; + if (node is PanelNode panel) + { + foreach (var child in panel.Children) + { + count += CountSplitPanels(child); + } + } + + return count; + } + + [TestMethod] + public void TestMoveNodeCenterCreatesStackDrop() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + var source = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var target = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new WinMan.Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(source, target.ComputedRectangle.Center); + + Assert.IsInstanceOfType(source.Parent, typeof(StackPanelNode)); + Assert.AreSame(source.Parent, target.Parent); + } + + [TestMethod] + public void TestMoveNodeCenterReordersWithinSameStack() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + var a = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var b = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new WinMan.Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(a, b.ComputedRectangle.Center); + + var c = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(c, a.ComputedRectangle.Center); + + Assert.IsInstanceOfType(a.Parent, typeof(StackPanelNode)); + var stack = (StackPanelNode)a.Parent!; + Assert.AreEqual(3, stack.Children.Count(x => x is WindowNode)); + Assert.IsInstanceOfType(b.Parent, typeof(StackPanelNode)); + Assert.AreSame(stack, b.Parent); + + workspace.MoveNode(b, a.ComputedRectangle.Center); + + Assert.AreSame(stack, b.Parent); + Assert.AreEqual(3, stack.Children.Count(x => x is WindowNode)); + } + + [TestMethod] + public void TestClassifyDropZoneCornerIsNeutral() + { + var r = new Rectangle(0, 0, 1000, 800); + var corner = (int)(Math.Min(r.Width, r.Height) * TilingWorkspace.DropZoneCornerFraction); + var pt = new Point(r.Left + corner / 2, r.Top + corner / 2); + Assert.AreEqual(TilingWorkspace.DropZone.Neutral, TilingWorkspace.ClassifyDropZone(r, pt)); + } + + [TestMethod] + public void TestMoveNodeCrossParentNeutralCornerKeepsSameSplitParent() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + var w1 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var w2 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + var rootSplit = (SplitPanelNode)w1.Parent!; + Assert.AreSame(rootSplit, w2.Parent); + var r2 = w2.ComputedRectangle; + var corner = (int)(Math.Min(r2.Width, r2.Height) * TilingWorkspace.DropZoneCornerFraction); + var ptNeutral = new Point(r2.Left + corner / 2, r2.Top + corner / 2); + Assert.AreEqual(TilingWorkspace.DropZone.Neutral, TilingWorkspace.ClassifyDropZone(r2, ptNeutral)); + workspace.MoveNode(w1, ptNeutral); + tree.Measure(); + tree.Arrange(); + Assert.AreSame(rootSplit, w1.Parent); + Assert.AreSame(rootSplit, w2.Parent); + } + + [TestMethod] + public void TestMoveNodeFromOneStackToAnotherEdgeDropDoesNotThrow() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + var w1 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var w2 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new WinMan.Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(w1, w2.ComputedRectangle.Center); + tree.Measure(); + tree.Arrange(); + + Assert.IsInstanceOfType(w1.Parent, typeof(StackPanelNode)); + var rootSplit = (SplitPanelNode)w1.Parent!.Parent!; + var w3 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), rootSplit); + var w4 = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow(), rootSplit); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(w3, w4.ComputedRectangle.Center); + tree.Measure(); + tree.Arrange(); + + var stack2 = (StackPanelNode)w3.Parent!; + Assert.IsInstanceOfType(w1.Parent, typeof(StackPanelNode)); + Assert.AreSame(stack2, w4.Parent); + Assert.AreNotSame(w1.Parent, stack2); + + // Edge of target (not center stack band): cross-parent path must not WrapInSplitPanel the stacked target. + workspace.MoveNode(w1, SiblingReorderPointOver(w4)); + tree.Measure(); + tree.Arrange(); + + Assert.AreSame(stack2, w1.Parent); + CollectionAssert.Contains(stack2.Children.ToList(), w1); + } + + [TestMethod] + public void TestMoveNodeDropOnStackPrefersFocusedTabNotAdjacentSoloTile() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + var a = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var b = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new WinMan.Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + + workspace.MoveNode(a, b.ComputedRectangle.Center); + + var solo = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var incoming = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + tree.Measure(); + tree.Arrange(); + + workspace.SetFocus(b); + var pt = b.ComputedRectangle.Center; + workspace.MoveNode(incoming, pt); + + Assert.IsInstanceOfType(incoming.Parent, typeof(StackPanelNode)); + var stack = (StackPanelNode)incoming.Parent!; + Assert.AreSame(a.Parent, incoming.Parent); + Assert.AreEqual(3, stack.Children.Count(static c => c is WindowNode)); + Assert.IsInstanceOfType(solo.Parent, typeof(SplitPanelNode)); + } + + [TestMethod] + public void TestMoveNodeSimpleTwoWindowSplitChainReusesAncestorInsteadOfNesting() + { + var workspace = new TilingWorkspace(); + var desktop = m_desktopFactory.CreateVirtualDesktop(); + workspace.RegisterDesktop(desktop, m_workarea, PanelOrientation.Horizontal); + + var a = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var b = workspace.RegisterWindow(m_windowFactory.CreateExplorerWindow()); + var tree = workspace.GetTree(desktop)!; + tree.WorkArea = new WinMan.Rectangle(0, 0, 2000, 2000); + tree.Measure(); + tree.Arrange(); + + workspace.WrapInSplitPanel(a, vertical: true); + tree.Measure(); + tree.Arrange(); + + var rootSplit = (SplitPanelNode)tree.Root!; + var splitCountBefore = CountSplitPanels(rootSplit); + + workspace.MoveNode(b, SiblingReorderPointOver(a)); + tree.Measure(); + tree.Arrange(); + + var splitCountAfter = CountSplitPanels(rootSplit); + Assert.AreEqual(splitCountBefore, splitCountAfter, "simple two-window split chains should not gain extra nested splits"); + } + [TestMethod] public void TestMoveAfterSameParent() { diff --git a/FancyWM.sln b/FancyWM.sln index df2bd05..a54d3de 100644 --- a/FancyWM.sln +++ b/FancyWM.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -229,35 +229,15 @@ Global {FC48E089-6935-4547-930E-FE23D4AD7BE5}.Release|x86.ActiveCfg = Release|Any CPU {FC48E089-6935-4547-930E-FE23D4AD7BE5}.Release|x86.Build.0 = Release|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM.ActiveCfg = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM.Build.0 = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM.Deploy.0 = Debug|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM64.Build.0 = Debug|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|ARM64.Deploy.0 = Debug|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x64.ActiveCfg = Debug|x64 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x64.Build.0 = Debug|x64 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x64.Deploy.0 = Debug|x64 {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x86.ActiveCfg = Debug|x86 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x86.Build.0 = Debug|x86 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Debug|x86.Deploy.0 = Debug|x86 {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|Any CPU.Build.0 = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|Any CPU.Deploy.0 = Release|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM.ActiveCfg = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM.Build.0 = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM.Deploy.0 = Release|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM64.ActiveCfg = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM64.Build.0 = Release|Any CPU - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|ARM64.Deploy.0 = Release|Any CPU {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x64.ActiveCfg = Release|x64 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x64.Build.0 = Release|x64 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x64.Deploy.0 = Release|x64 {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x86.ActiveCfg = Release|x86 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x86.Build.0 = Release|x86 - {0536B61A-90CF-428D-B4F8-CF011B3A0830}.Release|x86.Deploy.0 = Release|x86 {03851EC5-6CBE-4383-85F1-56E845C039DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {03851EC5-6CBE-4383-85F1-56E845C039DD}.Debug|Any CPU.Build.0 = Debug|Any CPU {03851EC5-6CBE-4383-85F1-56E845C039DD}.Debug|ARM.ActiveCfg = Debug|Any CPU diff --git a/FancyWM/Controls/NonHitTestableTilingOverlay.xaml b/FancyWM/Controls/NonHitTestableTilingOverlay.xaml index c0339b2..e340ada 100644 --- a/FancyWM/Controls/NonHitTestableTilingOverlay.xaml +++ b/FancyWM/Controls/NonHitTestableTilingOverlay.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FancyWM/Controls/TilingNodeCaptionBlock.xaml b/FancyWM/Controls/TilingNodeCaptionBlock.xaml index ee0a3c7..2fb720a 100644 --- a/FancyWM/Controls/TilingNodeCaptionBlock.xaml +++ b/FancyWM/Controls/TilingNodeCaptionBlock.xaml @@ -1,4 +1,4 @@ - - - + + @@ -35,8 +35,8 @@ - - + + @@ -48,7 +48,7 @@ - + @@ -69,8 +69,8 @@ - - + + @@ -79,15 +79,15 @@ - - + + - + diff --git a/FancyWM/Controls/TilingOverlay.xaml b/FancyWM/Controls/TilingOverlay.xaml index 1a496a3..fed3369 100644 --- a/FancyWM/Controls/TilingOverlay.xaml +++ b/FancyWM/Controls/TilingOverlay.xaml @@ -1,4 +1,4 @@ - + + + @@ -69,5 +73,35 @@ + + + + + + + + + + + diff --git a/FancyWM/Controls/TilingWindow.xaml b/FancyWM/Controls/TilingWindow.xaml index f48d4a2..f570621 100644 --- a/FancyWM/Controls/TilingWindow.xaml +++ b/FancyWM/Controls/TilingWindow.xaml @@ -1,4 +1,4 @@ - ReadAllAsync(Stream stream) { byte[] b = new byte[stream.Length - stream.Position]; - await stream.ReadAsync(b); + // Stream.ReadAsync is not guaranteed to fill the buffer in one call. + // ReadExactlyAsync prevents truncated JSON bytes during merge. + await stream.ReadExactlyAsync(b, 0, b.Length); return b; } diff --git a/FancyWM/Models/Settings.cs b/FancyWM/Models/Settings.cs index 7c5f055..9f1f4a6 100644 --- a/FancyWM/Models/Settings.cs +++ b/FancyWM/Models/Settings.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.Json.Serialization; using System.Windows.Media; @@ -7,6 +7,13 @@ namespace FancyWM.Models { + public enum OverflowPlacementStrategy + { + Vertical, + Horizontal, + Stack, + } + public interface ITilingServiceSettings { bool AllocateNewPanelSpace { get; } @@ -14,10 +21,14 @@ public interface ITilingServiceSettings int WindowPadding { get; } int PanelHeight { get; } int AutoSplitCount { get; } + OverflowPlacementStrategy OverflowPlacementStrategy { get; } bool ShowFocus { get; } bool AutoCollapsePanels { get; } bool DelayReposition { get; } bool AutoFloatNewWindows { get; } + bool PreserveWindowPositionsOnExit { get; } + bool EnableDragDropAutoPanelCreation { get; } + bool HideWindowActionMenuOnHover { get; } } [AttributeUsage(AttributeTargets.Field)] @@ -47,9 +58,18 @@ public Settings() public bool AutoCollapsePanels { get; init; } = false; public int AutoSplitCount { get; init; } = 2; + public OverflowPlacementStrategy OverflowPlacementStrategy { get; init; } = OverflowPlacementStrategy.Stack; public bool DelayReposition { get; init; } = true; public bool AutoFloatNewWindows { get; init; } = false; + // Keep current tiled positions when FancyWM is stopped/closed. + // If false, stop() restores pre-tiling window positions. + public bool PreserveWindowPositionsOnExit { get; init; } = true; + + // Governs drag-drop split/stack auto-creation from drop zones. + // Default on to preserve the interactive tiling flow. + public bool EnableDragDropAutoPanelCreation { get; init; } = true; + public bool HideWindowActionMenuOnHover { get; init; } = false; public bool AnimateWindowMovement { get; init; } = true; diff --git a/FancyWM/Pages/Settings/InteractionPage.xaml b/FancyWM/Pages/Settings/InteractionPage.xaml index 3116f99..f02555d 100644 --- a/FancyWM/Pages/Settings/InteractionPage.xaml +++ b/FancyWM/Pages/Settings/InteractionPage.xaml @@ -1,9 +1,10 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -71,6 +114,27 @@ + + + + + + + + + + + + + + diff --git a/FancyWM/Startup.cs b/FancyWM/Startup.cs index 3478908..722edee 100644 --- a/FancyWM/Startup.cs +++ b/FancyWM/Startup.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Linq; @@ -69,6 +69,9 @@ public static int Main(string[] args) OPTIONS: -h, --help Show this help -v, -vv, -vvv Verbose logging (repeat for more) + -v = Debug (tiling/drag details in fancywm.log) + -vv = Verbose + Log file: %AppData%\\FancyWM\\fancywm.log --version Show version info --action NAME Execute specific action directly diff --git a/FancyWM/TilingOverlayRenderer.cs b/FancyWM/TilingOverlayRenderer.cs index 8eaa73e..1764c21 100644 --- a/FancyWM/TilingOverlayRenderer.cs +++ b/FancyWM/TilingOverlayRenderer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Windows; @@ -77,6 +77,19 @@ public Rectangle? PreviewRectangle } } + public DropZonePreviewState? DropZonePreview + { + get => m_dropZonePreview; + set + { + if (!DropZonePreviewEquals(m_dropZonePreview, value)) + { + m_dropZonePreview = value; + ApplyDropZonePreview(value); + } + } + } + public IWindow? IntentSourceWindow { get => m_intentSourceWindow; @@ -100,11 +113,13 @@ public IWindow? IntentSourceWindow private double m_panelHeight = 22.0; private double m_windowPadding = 4.0; private int m_panelFontSize = 12; + private bool m_hideWindowActionMenuOnHover = false; private IReadOnlyCollection m_previousSnapshot = []; private readonly Dictionary m_nodeViewModels = []; private IReadOnlySet m_previewWindows = new HashSet(); private Rectangle? m_focusRectangle; private Rectangle? m_previewRectangle; + private DropZonePreviewState? m_dropZonePreview; private IWindow? m_intentSourceWindow; public TilingOverlayRenderer(IDisplay display, Func overlayAnchorSource) @@ -126,6 +141,7 @@ public TilingOverlayRenderer(IDisplay display, Func overlayAnchorSource) m_panelHeight = settings.PanelHeight; m_windowPadding = settings.WindowPadding; m_panelFontSize = settings.PanelFontSize; + m_hideWindowActionMenuOnHover = settings.HideWindowActionMenuOnHover; UpdateResources(); })); } @@ -366,6 +382,62 @@ private void OnSetPreviewRectangle(Rectangle? oldValue, Rectangle? newValue) m_viewModel.PreviewRectangle = newValue.HasValue ? AdjustForDisplay(newValue.Value) : new Rectangle(); } + private static bool DropZonePreviewEquals(DropZonePreviewState? a, DropZonePreviewState? b) + { + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a is null || b is null) + { + return false; + } + + return a.IsActive == b.IsActive + && a.ActiveZone == b.ActiveZone + && a.Center == b.Center + && a.Left == b.Left + && a.Top == b.Top + && a.Right == b.Right + && a.Bottom == b.Bottom + && a.TargetOutline == b.TargetOutline; + } + + private void ApplyDropZonePreview(DropZonePreviewState? state) + { + if (state is not { IsActive: true }) + { + m_viewModel.IsDropZonePreviewVisible = false; + m_viewModel.DropZoneActiveKind = ""; + m_viewModel.DropZoneOutlineRect = default; + m_viewModel.DropZoneCenterRect = default; + m_viewModel.DropZoneLeftRect = default; + m_viewModel.DropZoneTopRect = default; + m_viewModel.DropZoneRightRect = default; + m_viewModel.DropZoneBottomRect = default; + return; + } + + m_viewModel.IsDropZonePreviewVisible = true; + m_viewModel.DropZoneOutlineRect = AdjustForDisplay(state.TargetOutline); + m_viewModel.DropZoneCenterRect = AdjustForDisplay(state.Center); + m_viewModel.DropZoneLeftRect = AdjustForDisplay(state.Left); + m_viewModel.DropZoneTopRect = AdjustForDisplay(state.Top); + m_viewModel.DropZoneRightRect = AdjustForDisplay(state.Right); + m_viewModel.DropZoneBottomRect = AdjustForDisplay(state.Bottom); + m_viewModel.DropZoneActiveKind = state.ActiveZone switch + { + DropZonePreviewKind.Center => "Center", + DropZonePreviewKind.Left => "Left", + DropZonePreviewKind.Right => "Right", + DropZonePreviewKind.Top => "Top", + DropZonePreviewKind.Bottom => "Bottom", + DropZonePreviewKind.Neutral => "Neutral", + _ => "", + }; + } + private void UpdateViewModel(TilingWindowViewModel vm, WindowNode node, IEnumerable focusedPath) { @@ -379,12 +451,15 @@ private void UpdateViewModel(TilingWindowViewModel vm, WindowNode node, IEnumera vm.CloseCommand = m_panelItemCloseActionCommand; vm.ActionsHeight = m_panelHeight + 4; vm.RevealHighlightRadius = (16 + m_panelHeight + m_windowPadding) * 2; + vm.EnableHoverActionMenu = !m_hideWindowActionMenuOnHover; } private void UpdateViewModel(TilingPanelViewModel vm, PanelNode node, IEnumerable focusedPath) { vm.Overlay = m_viewModel; vm.Node = node; + vm.PanelType = node.Type; + vm.PanelOrientation = node is SplitPanelNode split ? split.Orientation : PanelOrientation.Horizontal; vm.HasFocus = focusedPath.Contains(node); vm.ChildHasDirectFocus = vm.ChildNodes.Select(x => x.Node).Contains(focusedPath.FirstOrDefault()); vm.ComputedBounds = AdjustForDisplay(node.ComputedRectangle); @@ -518,6 +593,7 @@ public void InvalidateView() public void Dispose() #pragma warning restore CA1816 // Dispose methods should call SuppressFinalize { + DropZonePreview = null; if (m_overlay.Content != null) { Draggable.RemoveDragStartedHandler(m_overlay.Content, OnDragStarted); diff --git a/FancyWM/TilingService.Private.cs b/FancyWM/TilingService.Private.cs index 5397544..16bd064 100644 --- a/FancyWM/TilingService.Private.cs +++ b/FancyWM/TilingService.Private.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -61,9 +61,21 @@ private void RestoreOriginalLayout() private TimeSpan m_lastUpdateLayout = TimeSpan.Zero; + private void SyncPanelChromeMetrics(DesktopTree tree) + { + var pad = GetPanelPaddingRect(); + var spacing = GetPanelSpacing(); + foreach (var panel in tree.Root!.Nodes.OfType()) + { + panel.Padding = pad; + panel.Spacing = spacing; + } + } + private void UpdateTree(DesktopTree tree) { tree.WorkArea = m_display.WorkArea; + SyncPanelChromeMetrics(tree); bool constraintsSatisfied = false; while (!constraintsSatisfied) @@ -154,6 +166,7 @@ async ValueTask RepositionAsync() m_gui.UpdateOverlay(snapshot, focusedPath); m_gui.PreviewRectangle = GetPreviewRectangle(); + m_gui.DropZonePreview = GetDropZonePreviewState(); if (m_showPreviewFocus) { @@ -307,51 +320,194 @@ private bool CanShowFocusRectangle() private Rectangle? GetPreviewRectangle() { - if (m_currentInteraction == UserInteraction.Moving && m_delayReposition || m_movingPanelNode != null) + var windowDragPreview = + m_currentInteraction == UserInteraction.Moving + || (m_currentInteraction == UserInteraction.Starting && m_activeDragWindow != null) + || (m_currentInteraction == UserInteraction.None && m_activeDragWindow != null); + if (!windowDragPreview && m_movingPanelNode == null) { - try - { - var isSwapping = IsSwapModifierPressed(); - var pt = m_workspace.CursorLocation; + return null; + } - if (m_movingPanelNode == null) + try + { + var isSwapping = IsSwapModifierPressed(); + var pt = m_workspace.CursorLocation; + // Keep these two controls independent: + // - allowNesting: enables/disables automatic panel creation + // - swapOnDrop: explicit swap gesture (Shift) + // This avoids accidental swap behavior when auto panel creation is disabled. + + if (m_movingPanelNode == null) + { + var window = m_activeDragWindow ?? m_workspace.FocusedWindow; + if (window == null) { - var window = m_workspace.FocusedWindow; - if (window == null) - { - return null; - } + return null; + } - using (m_backendLock.EnterScope()) + using (m_backendLock.EnterScope()) + { + if (m_backend.HasWindow(window)) { - if (m_backend.HasWindow(window)) - { - return m_backend.MockMoveWindow(window, pt, allowNesting: !isSwapping).preArrange; - } + return m_backend.MockMoveWindow( + window, + pt, + allowNesting: m_enableDragDropAutoPanelCreation && !isSwapping, + swapOnDrop: isSwapping).preArrange; } } - else + } + else + { + using (m_backendLock.EnterScope()) { - using (m_backendLock.EnterScope()) - { - var rect = m_backend.MockMoveNode(m_movingPanelNode, pt, allowNesting: !isSwapping).preArrange; - var padding = GetPanelPaddingRect(); - var spacing = GetPanelSpacing(); - return new Rectangle( - rect.Left - padding.Left - spacing / 2, - rect.Top - padding.Top - spacing / 2, - rect.Right + padding.Right + spacing / 2, - rect.Bottom + padding.Bottom + spacing / 2); - } + var rect = m_backend.MockMoveNode( + m_movingPanelNode, + pt, + allowNesting: m_enableDragDropAutoPanelCreation && !isSwapping, + swapOnDrop: isSwapping).preArrange; + var padding = GetPanelPaddingRect(); + var spacing = GetPanelSpacing(); + return new Rectangle( + rect.Left - padding.Left - spacing / 2, + rect.Top - padding.Top - spacing / 2, + rect.Right + padding.Right + spacing / 2, + rect.Bottom + padding.Bottom + spacing / 2); } } - catch (TilingFailedException) + } + catch (TilingFailedException) + { + } + catch (InvalidWindowReferenceException) + { + } + catch (Exception ex) + { + m_logger.Warning(ex, "Failed to compute drag preview rectangle"); + } + + return null; + } + + private HashSet? GetDragExcludeWindows() + { + var set = new HashSet(); + if (m_activeDragWindow != null) + { + set.Add(m_activeDragWindow); + } + + if (m_movingPanelNode != null) + { + foreach (var w in m_movingPanelNode.Windows) { + set.Add(w.WindowReference); } - catch (InvalidWindowReferenceException) + } + + return set.Count > 0 ? set : null; + } + + private DropZonePreviewState? GetDropZonePreviewState() + { + if (m_currentInteraction == UserInteraction.Resizing) + { + return null; + } + + if (!m_enableDragDropAutoPanelCreation) + { + // Drop-zone preview communicates panel-creation outcomes; hide it when + // auto-creation is disabled to keep visual intent aligned with behavior. + return null; + } + + var windowDragPreview = + m_currentInteraction == UserInteraction.Moving + || (m_currentInteraction == UserInteraction.Starting && m_activeDragWindow != null) + || (m_currentInteraction == UserInteraction.None && m_activeDragWindow != null); + if (m_movingPanelNode == null && !windowDragPreview) + { + return null; + } + + try + { + if (IsSwapModifierPressed()) { + return null; } + + var pt = m_workspace.CursorLocation; + using (m_backendLock.EnterScope()) + { + var exclude = GetDragExcludeWindows(); + var targetWindow = m_backend.WindowAtPointForDrag( + m_workspace.VirtualDesktopManager.CurrentDesktop, + pt, + exclude, + m_activeDragWindow); + if (targetWindow == null) + { + return null; + } + + if (m_activeDragWindow != null) + { + var sourceWindow = m_backend.FindWindow(m_activeDragWindow); + // Suppress cues only for same-stack drags. Same split-parent drags can still + // create left/right/top/bottom outcomes and should keep cues visible. + if (sourceWindow != null + && sourceWindow.Parent is StackPanelNode sourceStack + && ReferenceEquals(sourceStack, targetWindow.Parent)) + { + // Same stack drag does not create a new split/stack outcome. + return null; + } + } + + var zone = targetWindow.Parent is StackPanelNode + ? TilingWorkspace.DropZone.Center + : TilingWorkspace.ClassifyDropZone(targetWindow.ComputedRectangle, pt); + TilingWorkspace.GetDropZoneHighlightRects( + targetWindow.ComputedRectangle, + zone, + out var center, + out var left, + out var top, + out var right, + out var bottom); + var previewKind = zone switch + { + TilingWorkspace.DropZone.Center => DropZonePreviewKind.Center, + TilingWorkspace.DropZone.Left => DropZonePreviewKind.Left, + TilingWorkspace.DropZone.Right => DropZonePreviewKind.Right, + TilingWorkspace.DropZone.Top => DropZonePreviewKind.Top, + TilingWorkspace.DropZone.Bottom => DropZonePreviewKind.Bottom, + TilingWorkspace.DropZone.Neutral => DropZonePreviewKind.Neutral, + _ => DropZonePreviewKind.Neutral, + }; + return new DropZonePreviewState( + IsActive: true, + ActiveZone: previewKind, + Center: center, + Left: left, + Top: top, + Right: right, + Bottom: bottom, + TargetOutline: targetWindow.ComputedRectangle); + } + } + catch (InvalidWindowReferenceException) + { } + catch (Exception ex) + { + m_logger.Warning(ex, "Failed to compute drop-zone preview state"); + } + return null; } @@ -561,6 +717,14 @@ private Rectangle GetOptimalRestoredSize(IWindow window) private void OnCursorLocationChanged(object? sender, CursorLocationChangedEventArgs e) { + // Keep drag previews responsive to cursor movement even when some windows + // don't emit position-changed events continuously during title-bar drag. + if (m_currentInteraction != UserInteraction.Resizing + && (m_activeDragWindow != null || m_movingPanelNode != null)) + { + InvalidateLayout(); + } + if (PendingIntent == null) return; @@ -859,7 +1023,11 @@ private void OnTilingPanelMoveRequested(object? sender, PanelNode panel) { return; } - m_backend.MoveNode(panel, pt, allowNesting: !isSwapping); + m_backend.MoveNode( + panel, + pt, + allowNesting: m_enableDragDropAutoPanelCreation && !isSwapping, + swapOnDrop: isSwapping); } InvalidateLayout(); @@ -985,7 +1153,12 @@ private void OnWindowLostFocus(object? sender, WindowFocusChangedEventArgs e) // } // } //}); - m_currentInteraction = UserInteraction.None; + // During move drags, focus can move to hover target windows. Keep drag interaction + // state alive while we still have an active drag source so drop cues don't disappear. + if (m_activeDragWindow == null) + { + m_currentInteraction = UserInteraction.None; + } } private void OnWindowAdded(object? sender, WindowChangedEventArgs e) @@ -1040,7 +1213,7 @@ private void OnWindowAdded(object? sender, WindowChangedEventArgs e) return; } - var node = m_backend.RegisterWindow(e.Source, maxTreeWidth: m_autoSplitCount); + var node = m_backend.RegisterWindow(e.Source, m_autoSplitCount, m_overflowPlacementStrategy); node.Parent!.Padding = GetPanelPaddingRect(); node.Parent!.Spacing = GetPanelSpacing(); } @@ -1111,8 +1284,24 @@ private void DoWindowMove(IWindow window) if (m_backend.HasWindow(window)) { m_logger.Debug("Window {Window} size is unchanged, attempting to insert window at {Position}", window.DebugString(), pt); - m_backend.MoveWindow(window, pt, allowNesting: !isSwapping); - m_backend.SetFocus(window); + try + { + m_backend.MoveWindow( + window, + pt, + allowNesting: m_enableDragDropAutoPanelCreation && !isSwapping, + swapOnDrop: isSwapping); + m_backend.SetFocus(window); + } + catch (TilingFailedException ex) + { + m_logger.Warning( + "MoveWindow tiling failed: {Reason} window={Window} cursor={Cursor}", + ex.FailReason, + window.DebugString(), + pt); + throw; + } } } } @@ -1143,6 +1332,8 @@ private void OnWindowPositionChangeEnd(object? sender, WindowPositionChangedEven { m_ignoreRepositionSet.Remove(e.Source); } + + m_activeDragWindow = null; m_currentInteraction = UserInteraction.None; } @@ -1309,7 +1500,7 @@ void RegisterInTopLevelPanel() { try { - var window = m_backend.RegisterWindow(e.Source, maxTreeWidth: m_autoSplitCount); + var window = m_backend.RegisterWindow(e.Source, m_autoSplitCount, m_overflowPlacementStrategy); window.Parent!.Padding = GetPanelPaddingRect(); window.Parent!.Spacing = GetPanelSpacing(); } @@ -1464,7 +1655,10 @@ private void OnWindowPositionChangeStart(object? sender, WindowPositionChangedEv { m_ignoreRepositionSet.Add(e.Source); } + + m_activeDragWindow = e.Source; m_currentInteraction = UserInteraction.Starting; + InvalidateLayout(); } private void OnTilingNodeFocusRequested(object? sender, TilingNode e) @@ -1568,7 +1762,7 @@ private bool DetectChanges(IWindow window) if (!m_backend.HasWindow(window)) { m_logger.Debug("Window {Window} can be managed, but is not registered with backend, registering now", window.DebugString()); - var newNode = m_backend.RegisterWindow(window, maxTreeWidth: m_autoSplitCount); + var newNode = m_backend.RegisterWindow(window, m_autoSplitCount, m_overflowPlacementStrategy); newNode.Parent!.Padding = GetPanelPaddingRect(); newNode.Parent!.Spacing = GetPanelSpacing(); InvalidateLayout(); @@ -1756,9 +1950,9 @@ private void PropagatePaddingChange() { using (m_backendLock.EnterScope()) { - foreach (var panel in m_backend.Trees.SelectMany(x => x.Root!.Nodes).OfType()) + foreach (var tree in m_backend.Trees) { - panel.Spacing = GetPanelSpacing(); + SyncPanelChromeMetrics(tree); } } UpdateGuiNodeOptions(); @@ -1768,10 +1962,9 @@ private void PropagatePanelHeightChange() { using (m_backendLock.EnterScope()) { - foreach (var panel in m_backend.Trees.SelectMany(x => x.Root!.Nodes).OfType()) + foreach (var tree in m_backend.Trees) { - panel.Padding = GetPanelPaddingRect(); - panel.Spacing = GetPanelSpacing(); + SyncPanelChromeMetrics(tree); } } UpdateGuiNodeOptions(); diff --git a/FancyWM/TilingService.cs b/FancyWM/TilingService.cs index 74e35cf..a353e6b 100644 --- a/FancyWM/TilingService.cs +++ b/FancyWM/TilingService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Windows.Threading; @@ -13,6 +13,7 @@ using System.Threading.Tasks; using System.ComponentModel; using System.Diagnostics; +using Microsoft.Win32; #if DEBUG using Lock = FancyWM.Utilities.DebugLock; @@ -62,9 +63,13 @@ public bool Active private bool m_animateWindowMovement; private int m_autoSplitCount = 100; + private OverflowPlacementStrategy m_overflowPlacementStrategy = OverflowPlacementStrategy.Stack; private bool m_delayReposition = false; private bool m_autoFloatNewWindows = false; + // Runtime cache of user settings used by move/stop handlers. + private bool m_preserveWindowPositionsOnExit = true; + private bool m_enableDragDropAutoPanelCreation = true; private void SetAutoCollapse(bool value) { @@ -181,6 +186,7 @@ public ITilingServiceIntent? PendingIntent private bool m_dirty = true; private UserInteraction m_currentInteraction = UserInteraction.None; private PanelNode? m_movingPanelNode; + private IWindow? m_activeDragWindow; private ITilingServiceIntent? m_pendingIntent; private readonly Counter m_frozen = new(); private readonly Stopwatch m_sw = new(); @@ -192,7 +198,7 @@ public TilingService(IWorkspace workspace, IDisplay display, IAnimationThread an m_workspace = workspace; m_animationThread = animationThread; m_display = display; - m_backend = new TilingWorkspace(); + m_backend = new TilingWorkspace(m_logger); m_gui = new TilingOverlayRenderer(display, GetOverlayAnchor) { PanelSpacing = GetPanelSpacing(), @@ -225,6 +231,7 @@ public TilingService(IWorkspace workspace, IDisplay display, IAnimationThread an m_workspace.VirtualDesktopManager.DesktopRemoved += OnDesktopRemoved; m_workspace.VirtualDesktopManager.CurrentDesktopChanged += OnCurrentDesktopChanged; m_workspace.CursorLocationChanged += OnCursorLocationChanged; + SystemEvents.PowerModeChanged += OnPowerModeChanged; m_display.ScalingChanged += OnDisplayScalingChanged; @@ -261,8 +268,11 @@ private void OnSettingsChanged(ITilingServiceSettings x) m_allocateNewPanelSpace = x.AllocateNewPanelSpace; m_animateWindowMovement = x.AnimateWindowMovement; m_autoSplitCount = x.AutoSplitCount; + m_overflowPlacementStrategy = x.OverflowPlacementStrategy; m_delayReposition = x.DelayReposition; m_autoFloatNewWindows = x.AutoFloatNewWindows; + m_preserveWindowPositionsOnExit = x.PreserveWindowPositionsOnExit; + m_enableDragDropAutoPanelCreation = x.EnableDragDropAutoPanelCreation; SetWindowPadding(x.WindowPadding); SetPanelHeight(x.PanelHeight); SetShowFocus(x.ShowFocus); @@ -280,7 +290,10 @@ public void Start() public void Stop() { m_active = false; - RestoreOriginalLayout(); + if (!m_preserveWindowPositionsOnExit) + { + RestoreOriginalLayout(); + } m_gui.Hide(); } @@ -377,7 +390,7 @@ public bool DiscoverWindows() if (!m_backend.HasWindow(window) && window.State == WindowState.Restored && CanManage(window)) { m_logger.Debug("Discovered window {Window}", window.DebugString()); - var newNode = m_backend.RegisterWindow(window, maxTreeWidth: m_autoSplitCount); + var newNode = m_backend.RegisterWindow(window, m_autoSplitCount, m_overflowPlacementStrategy); newNode.Parent!.Padding = GetPanelPaddingRect(); newNode.Parent!.Spacing = GetPanelSpacing(); InvalidateLayout(); @@ -581,6 +594,7 @@ public void Dispose() m_workspace.VirtualDesktopManager.DesktopRemoved -= OnDesktopRemoved; m_workspace.VirtualDesktopManager.CurrentDesktopChanged -= OnCurrentDesktopChanged; m_workspace.CursorLocationChanged -= OnCursorLocationChanged; + SystemEvents.PowerModeChanged -= OnPowerModeChanged; m_workspace.WindowAdded -= OnWindowAdded; m_workspace.WindowRemoved -= OnWindowRemoved; @@ -679,6 +693,23 @@ public Rectangle GetBounds() return m_display.Bounds; } + private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e) + { + if (e.Mode != PowerModes.Resume) + { + return; + } + + _ = m_dispatcher.RunAsync(async () => + { + // Let Windows finish monitor/session restoration first; running + // refresh immediately can race with transient display/work-area changes. + await Task.Delay(750); + Refresh(); + InvalidateLayout(); + }); + } + public IWindow? FindClosest(Point center) { static double Distance(Point point1, Point point2) diff --git a/FancyWM/TilingWorkspace.cs b/FancyWM/TilingWorkspace.cs index 8f7126f..5589710 100644 --- a/FancyWM/TilingWorkspace.cs +++ b/FancyWM/TilingWorkspace.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -7,12 +7,15 @@ using FancyWM.Layouts; using FancyWM.Layouts.Tiling; +using FancyWM.Models; using FancyWM.Utilities; using Windows.Devices.Enumeration; using WinMan; +using Serilog; + namespace FancyWM { @@ -113,15 +116,31 @@ public void RemoveState(IVirtualDesktop virtualDesktop) internal class TilingWorkspace { + internal enum DropZone + { + Center, + Left, + Right, + Top, + Bottom, + /// Corner bands: insert/reorder without wrapping the target in a new split/stack. + Neutral, + } + + /// Fraction of min(w,h) for corner (matches highlight geometry). + internal const double DropZoneCornerFraction = 0.14; + private readonly TilingWorkspaceState m_states = new(); private readonly Dictionary m_originalPositions = []; + private readonly ILogger? m_logger; public IEnumerable Trees => m_states.States.Select(x => x.DesktopTree); public bool AutoCollapse { get; set; } = false; - public TilingWorkspace() + public TilingWorkspace(ILogger? logger = null) { + m_logger = logger; } public PanelNode CreateRoot(PanelOrientation orientation) @@ -150,12 +169,15 @@ public void UnregisterDesktop(IVirtualDesktop virtualDesktop) m_states.RemoveState(virtualDesktop); } - public WindowNode RegisterWindow(IWindow window, int maxTreeWidth = 100) + public WindowNode RegisterWindow( + IWindow window, + int maxTreeWidth = 100, + OverflowPlacementStrategy overflowStrategy = OverflowPlacementStrategy.Stack) { var state = GetValidatedState(window); var focusedNode = state.FocusedNode; var parent = ResolveParent(state, focusedNode); - parent = ResolveParentWithWidthConstraint(parent, focusedNode, window, maxTreeWidth); + parent = ResolveParentForOverflow(state, parent, focusedNode, window, maxTreeWidth, overflowStrategy); return RegisterWindow(window, parent, focusedNode as WindowNode); } @@ -186,18 +208,28 @@ private static PanelNode ResolveParent(DesktopState state, TilingNode? focusedNo ? focusedWindow.Parent ?? state.DesktopTree.Root! : state.DesktopTree.Root!; - private PanelNode ResolveParentWithWidthConstraint( - PanelNode parent, TilingNode? focusedNode, IWindow window, int maxTreeWidth) + private PanelNode ResolveParentForOverflow( + DesktopState state, + PanelNode parent, + TilingNode? focusedNode, + IWindow window, + int maxTreeWidth, + OverflowPlacementStrategy overflowStrategy) { - if (!IsAtMaxWidth(parent, maxTreeWidth) || parent is not SplitPanelNode parentSplit) + var shouldAttemptOverflowPlacement = IsAtMaxWidth(parent, maxTreeWidth) || !CanFitLossy(parent, window); + if (!shouldAttemptOverflowPlacement) + { return parent; + } var nodeToSplit = SelectNodeToSplit(parent, focusedNode); - - if (nodeToSplit is WindowNode) - TrySplitNode(nodeToSplit, parentSplit, window); - - return nodeToSplit.Parent!; + return overflowStrategy switch + { + OverflowPlacementStrategy.Stack => ResolveStackOverflowParent(state, parent, focusedNode, nodeToSplit, window, maxTreeWidth), + OverflowPlacementStrategy.Vertical => ResolveSplitOverflowParent(nodeToSplit, vertical: true, window), + OverflowPlacementStrategy.Horizontal => ResolveSplitOverflowParent(nodeToSplit, vertical: false, window), + _ => parent, + }; } private static bool IsAtMaxWidth(PanelNode parent, int maxTreeWidth) @@ -206,13 +238,160 @@ private static bool IsAtMaxWidth(PanelNode parent, int maxTreeWidth) private static TilingNode SelectNodeToSplit(PanelNode parent, TilingNode? focusedNode) => parent.Children.Contains(focusedNode) ? focusedNode! : parent.Children.Last(); - private void TrySplitNode(TilingNode nodeToSplit, SplitPanelNode parentSplit, IWindow window) + private PanelNode ResolveSplitOverflowParent(TilingNode nodeToSplit, bool vertical, IWindow window) { - WrapInSplitPanel(nodeToSplit, vertical: parentSplit.Orientation == PanelOrientation.Horizontal); + if (nodeToSplit is not WindowNode) + { + return nodeToSplit.Parent!; + } + + WrapInSplitPanel(nodeToSplit, vertical); ArrangeWithFallback(nodeToSplit); if (!CanFitLossy(nodeToSplit.Parent!, window)) + { nodeToSplit.Parent!.CollapseIfSingle(); + } + + return nodeToSplit.Parent!; + } + + private PanelNode ResolveStackOverflowParent( + DesktopState state, + PanelNode originalParent, + TilingNode? focusedNode, + TilingNode nodeToStack, + IWindow window, + int maxStackPanels) + { + var stacks = CollectStackPanels(state).ToList(); + + if (stacks.Count == 0) + { + if (nodeToStack is not WindowNode w0) + { + return originalParent; + } + + try + { + WrapInStackPanel(w0); + ArrangeWithFallback(w0); + if (!CanFitLossy(w0.Parent!, window)) + { + w0.Parent!.CollapseIfSingle(); + return originalParent; + } + } + catch (TilingFailedException) + { + return originalParent; + } + + return w0.Parent!; + } + + // Up to maxStackPanels (same cap as AutoSplitCount): turn other unstacked tiles into stacks so new windows can spread out. + if (stacks.Count < maxStackPanels) + { + var unstacked = CollectUnstackedWindows(originalParent).ToList(); + var preferred = nodeToStack is WindowNode avoidWrap + ? unstacked.FirstOrDefault(w => w != avoidWrap) + : null; + var candidate = preferred ?? unstacked.FirstOrDefault(); + if (candidate != null) + { + try + { + WrapInStackPanel(candidate); + ArrangeWithFallback(candidate); + stacks = CollectStackPanels(state).ToList(); + } + catch (TilingFailedException) + { + // Keep existing stacks only + } + } + } + + stacks = CollectStackPanels(state).ToList(); + var best = stacks + .OrderBy(CountWindowsInStack) + .ThenBy(s => s.GenerationID) + .FirstOrDefault(); + return best ?? originalParent; + } + + private static int CountWindowsInStack(StackPanelNode stack) + => stack.Children.Count(static c => c is WindowNode); + + private static IEnumerable CollectStackPanels(DesktopState state) + { + var root = state.DesktopTree.Root; + if (root == null) + { + yield break; + } + + // Walk children only: root.Nodes flattens the full tree, so each stack would be counted many times. + foreach (var child in root.Children) + { + foreach (var sp in EnumerateStackPanelsDeep(child)) + { + yield return sp; + } + } + } + + private static IEnumerable EnumerateStackPanelsDeep(TilingNode node) + { + if (node is StackPanelNode sp) + { + yield return sp; + yield break; + } + + if (node is PanelNode panel) + { + foreach (var child in panel.Children) + { + foreach (var nested in EnumerateStackPanelsDeep(child)) + { + yield return nested; + } + } + } + } + + private static IEnumerable CollectUnstackedWindows(PanelNode parent) + { + foreach (var child in parent.Children) + { + foreach (var w in EnumerateUnstackedWindowsDeep(child)) + { + yield return w; + } + } + } + + private static IEnumerable EnumerateUnstackedWindowsDeep(TilingNode node) + { + switch (node) + { + case WindowNode w when !w.PathToRoot.OfType().Any(): + yield return w; + yield break; + case PanelNode panel: + foreach (var child in panel.Children) + { + foreach (var nested in EnumerateUnstackedWindowsDeep(child)) + { + yield return nested; + } + } + + break; + } } private static void ArrangeWithFallback(TilingNode node) @@ -303,16 +482,114 @@ public bool HasWindow(IWindow window) return state.DesktopTree.FindNode(window); } - public TilingNode? NodeAtPoint(IVirtualDesktop currentDesktop, Point pt) + public TilingNode? NodeAtPoint(IVirtualDesktop currentDesktop, Point pt, IReadOnlySet? excludeWindows = null) + { + return WindowAtPointForDrag(currentDesktop, pt, excludeWindows, draggedWindow: null); + } + + /// + /// Window under cursor for hit-testing during drag; refines + /// focus tie-breaks in (same logic as ). + /// + public WindowNode? WindowAtPointForDrag(IVirtualDesktop currentDesktop, Point pt, IReadOnlySet? excludeWindows, IWindow? draggedWindow) { if (m_states.GetState(currentDesktop) is not DesktopState state) + { throw new ArgumentException("Desktop not registered with backend!"); + } + + WindowNode? draggedNodeHint = draggedWindow != null + ? state.DesktopTree.FindNode(draggedWindow) as WindowNode + : null; + + static Rectangle ExpandForDragCueHit(WindowNode node) + { + var r = node.ComputedRectangle; + int expandX = Math.Max(8, r.Width / 40); + int expandY = Math.Max(8, r.Height / 40); + + // Include panel chrome/padding area so drag hover over tab/header still resolves a target. + if (node.Parent is PanelNode panel) + { + expandX = Math.Max(expandX, Math.Max(panel.Padding.Left, panel.Padding.Right)); + expandY = Math.Max(expandY, panel.Padding.Top); + } + + return Rectangle.OffsetAndSize( + r.Left - expandX, + r.Top - expandY, + r.Width + expandX * 2, + r.Height + expandY * 2); + } + + var hits = state.DesktopTree.Root!.Windows + .Where(x => excludeWindows == null + || x is not WindowNode wn + || !excludeWindows.Contains(wn.WindowReference)) + .OfType() + .Where(x => ExpandForDragCueHit(x).Contains(pt)) + .ToList(); + + if (hits.Count == 0) + { + return null; + } + + if (hits.Count == 1) + { + return hits[0]; + } + + var focusedHint = state.FocusedNode as WindowNode; + return PickBestHitWindow(hits, focusedHint, draggedNodeHint); + } + + /// + /// Stack members share the same (full stack area). + /// Pick the focused tab when possible; if both stacked and non-stacked windows hit the same point, prefer stacked. + /// + private static WindowNode PickBestHitWindow(IReadOnlyList candidates, WindowNode? focusedHint, WindowNode? draggedNode) + { + if (candidates.Count == 0) + { + throw new ArgumentException(null, nameof(candidates)); + } + + if (candidates.Count == 1) + { + return candidates[0]; + } + + var stackMembers = candidates.Where(static c => c.Parent is StackPanelNode).ToList(); + var narrowed = stackMembers.Count > 0 && stackMembers.Count < candidates.Count + ? stackMembers + : candidates; + + if (narrowed.Count == 1) + { + return narrowed[0]; + } + + var focusForTarget = focusedHint != null && !ReferenceEquals(focusedHint, draggedNode) + ? focusedHint + : null; + if (focusForTarget != null && narrowed.Contains(focusForTarget)) + { + return focusForTarget; + } - return state.DesktopTree.Root!.Windows - .FirstOrDefault(x => x.ComputedRectangle.Contains(pt)); + if (narrowed[0].Parent is StackPanelNode sp && narrowed.All(c => ReferenceEquals(c.Parent, sp))) + { + return narrowed.OrderByDescending(c => sp.IndexOf(c)).First(); + } + + return narrowed + .OrderBy(static c => (long)c.ComputedRectangle.Width * c.ComputedRectangle.Height) + .ThenBy(static c => c.GenerationID) + .First(); } - public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) + public void MoveNode(TilingNode node, Point pt, bool allowNesting = true, bool swapOnDrop = false) { if (node.Parent == null) throw new ArgumentException($"Node cannot be a top-level node!", nameof(node)); @@ -320,20 +597,45 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) if (node.Desktop == null) throw new ArgumentException($"Node must be registered with the backend!", nameof(node)); - var nodeAtPoint = node.Desktop.Root!.Windows + var root = node.Desktop.Root!; + var focusedHint = m_states.GetState(node.Desktop)?.FocusedNode as WindowNode; + var windowHits = root.Windows .Where(x => x != node) - .Concat(node.Desktop.Root.Nodes.Where(x => x.Type == TilingNodeType.Placeholder)) - .FirstOrDefault(x => x.ComputedRectangle.Contains(pt)) ?? node.Desktop.Root!.Nodes - .OfType() - .Where(x => x != node) - .FirstOrDefault(x => Rectangle.OffsetAndSize( - x.ComputedRectangle.Left - x.Padding.Left, - x.ComputedRectangle.Top - x.Padding.Top, - x.ComputedRectangle.Width + x.Padding.Left + x.Padding.Right, - x.Padding.Top).Contains(pt)); + .OfType() + .Where(x => x.ComputedRectangle.Contains(pt)) + .ToList(); + + TilingNode? nodeAtPoint; + if (windowHits.Count > 0) + { + var draggedWin = node is WindowNode wn ? wn : null; + nodeAtPoint = PickBestHitWindow(windowHits, focusedHint, draggedWin); + } + else + { + nodeAtPoint = root.Nodes + .Where(x => x.Type == TilingNodeType.Placeholder) + .FirstOrDefault(x => x.ComputedRectangle.Contains(pt)) + ?? root.Nodes + .OfType() + .Where(x => x != node) + .FirstOrDefault(x => Rectangle.OffsetAndSize( + x.ComputedRectangle.Left - x.Padding.Left, + x.ComputedRectangle.Top - x.Padding.Top, + x.ComputedRectangle.Width + x.Padding.Left + x.Padding.Right, + x.ComputedRectangle.Height + x.Padding.Top + x.Padding.Bottom).Contains(pt)); + } if (nodeAtPoint == null || nodeAtPoint.Parent == null) return; + m_logger?.Debug( + "MoveNode: pt={Pt} windowHitCount={Hits} picked={Pick} pickParent={Parent} allowNesting={Nesting}", + pt, + windowHits.Count, + nodeAtPoint is WindowNode pickedWin ? pickedWin.WindowReference.DebugString() : nodeAtPoint.Type.ToString(), + nodeAtPoint.Parent!.GetType().Name, + allowNesting); + if (nodeAtPoint.PathToRoot.Contains(node)) throw new TilingFailedException(TilingError.CausesRecursiveNesting); @@ -342,10 +644,10 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) if (nodeAtPoint.Type == TilingNodeType.Placeholder) { - var oldParent = node.Parent; - node.Parent.Detach(node); - nodeAtPoint.Parent.Attach(node); - nodeAtPoint.Parent.RemovePlaceholders(); + var oldParent = node.Parent!; + oldParent.Detach(node); + nodeAtPoint.Parent!.Attach(node); + nodeAtPoint.Parent!.RemovePlaceholders(); oldParent.Cleanup(collapse: AutoCollapse); } else @@ -358,14 +660,34 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) var oldParent = node.Parent; try { - if (!MoveNodeTest(node, nodeAtPoint.Parent, pt)) + if (!MoveNodeTest(node, nodeAtPoint.Parent!, pt)) { return; } - node.Parent.Detach(node); - var insertionIndex = FindInsertionIndex(nodeAtPoint, pt); - nodeAtPoint.Parent.Attach(insertionIndex, node); - oldParent.Cleanup(collapse: AutoCollapse); + var dropZone = ClassifyDropZone(nodeAtPoint.ComputedRectangle, pt); + m_logger?.Debug( + "MoveNode cross-parent: zone={Zone} targetParentBeforeDrop={Parent}", + dropZone, + nodeAtPoint.Parent!.GetType().Name); + EnsureDropZoneParent(nodeAtPoint, dropZone, allowFlipInPlace: false); + if (dropZone is DropZone.Left or DropZone.Right or DropZone.Top or DropZone.Bottom + && nodeAtPoint is WindowNode edgeTarget + && edgeTarget.Parent is SplitPanelNode edgeParent) + { + var shouldBeVertical = dropZone is DropZone.Top or DropZone.Bottom; + var desiredOrientation = shouldBeVertical ? PanelOrientation.Vertical : PanelOrientation.Horizontal; + if (edgeParent.Orientation == desiredOrientation && edgeParent.Children.Count >= 2) + { + // Cross-parent edge drop should group source+target into a dedicated panel, + // not just append/reorder among existing siblings. + WrapInSplitPanel(edgeTarget, vertical: shouldBeVertical); + } + } + node.Parent!.Detach(node); + var insertionIndex = FindInsertionIndex(nodeAtPoint, pt, dropZone); + var targetParent = nodeAtPoint.Parent!; + targetParent.Attach(ClampInsertionIndex(targetParent, insertionIndex), node); + CleanupAfterMove(oldParent); node.Parent.RemovePlaceholders(); } catch (UnsatisfiableFlexConstraintsException) @@ -373,18 +695,106 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) throw new TilingFailedException(TilingError.TargetCannotFit); } } - else if (node.Parent is not StackPanelNode) // Do not rearrange items in stack panel + else if (node.Parent is not StackPanelNode) { try { - var newPosition = TransferSize(node.ComputedRectangle, nodeAtPoint.ComputedRectangle); - if (newPosition.Contains(pt)) + if (allowNesting && node is WindowNode && nodeAtPoint is WindowNode wAtPoint) { - // Node moved over another node that IS a sibling - var nodeIndex = node.Parent.Children.IndexOf(node); - var targetIndex = node.Parent.Children.IndexOf(nodeAtPoint); - - node.Parent.Move(nodeIndex, targetIndex); + var siblingDropZone = ClassifyDropZone(wAtPoint.ComputedRectangle, pt); + if (siblingDropZone == DropZone.Center) + { + EnsureDropZoneParent(wAtPoint, DropZone.Center, allowFlipInPlace: true); + var oldParent = node.Parent!; + oldParent.Detach(node); + var stackParent = (StackPanelNode)wAtPoint.Parent!; + stackParent.Attach(stackParent.Children.Count, node); + CleanupAfterMove(oldParent); + stackParent.RemovePlaceholders(); + } + else + { + // Same parent, non-stack: edge/corner zones need EnsureDropZoneParent + insert (like cross-parent), + // not only Move() reorder — otherwise left/right/top/bottom never create splits. + // Require pt on the target tile (parent rect can exclude narrow bands at chrome/gaps). + if (!IsPointInWindowDropTarget(wAtPoint, pt)) + { + return; + } + + m_logger?.Debug( + "MoveNode same-parent split: zone={Zone} parent={Parent}", + siblingDropZone, + node.Parent!.GetType().Name); + var oldParent = node.Parent!; + EnsureDropZoneParent(wAtPoint, siblingDropZone, allowFlipInPlace: true); + // Must compute before Detach: removing a sibling shifts the target's IndexOf. + var insertionIndex = FindInsertionIndex(wAtPoint, pt, siblingDropZone); + oldParent.Detach(node); + var targetParent = wAtPoint.Parent!; + targetParent.Attach(ClampInsertionIndex(targetParent, insertionIndex), node); + CleanupAfterMove(oldParent); + node.Parent!.RemovePlaceholders(); + } + } + else + { + var newPosition = TransferSize(node.ComputedRectangle, nodeAtPoint.ComputedRectangle); + if (newPosition.Contains(pt)) + { + // Node moved over another node that IS a sibling (non-window nodes) + var nodeIndex = node.Parent.Children.IndexOf(node); + var targetIndex = node.Parent.Children.IndexOf(nodeAtPoint); + + node.Parent.Move(nodeIndex, targetIndex); + } + } + } + catch (UnsatisfiableFlexConstraintsException) + { + throw new TilingFailedException(TilingError.Failed); + } + } + else if (node.Parent is StackPanelNode stackSiblingParent) + { + try + { + if (allowNesting && node is WindowNode && nodeAtPoint is WindowNode wAtPoint2) + { + var siblingDropZone = ClassifyDropZone(wAtPoint2.ComputedRectangle, pt); + if (siblingDropZone == DropZone.Center) + { + EnsureDropZoneParent(wAtPoint2, DropZone.Center, allowFlipInPlace: true); + var targetStack = (StackPanelNode)wAtPoint2.Parent!; + var oldParent = node.Parent!; + oldParent.Detach(node); + targetStack.Attach(targetStack.Children.Count, node); + if (!ReferenceEquals(oldParent, targetStack)) + { + CleanupAfterMove(oldParent); + } + else + { + targetStack.RemovePlaceholders(); + } + } + else + { + // Same stack: non-center zones use ClassifyDropZone + FindInsertionIndex (see same-parent split above). + if (!IsPointInWindowDropTarget(wAtPoint2, pt)) + { + return; + } + + var oldParent = node.Parent!; + EnsureDropZoneParent(wAtPoint2, siblingDropZone, allowFlipInPlace: true); + var insertionIndex = FindInsertionIndex(wAtPoint2, pt, siblingDropZone); + oldParent.Detach(node); + var targetParent = wAtPoint2.Parent!; + targetParent.Attach(ClampInsertionIndex(targetParent, insertionIndex), node); + CleanupAfterMove(oldParent); + node.Parent!.RemovePlaceholders(); + } } } catch (UnsatisfiableFlexConstraintsException) @@ -393,7 +803,10 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) } } } - else if (node.Parent != nodeAtPoint) + // Swap behavior is opt-in (Shift modifier). + // Do not tie this to allowNesting=false, because that flag is also used by + // the "disable drag-drop auto panel creation" setting. + else if (swapOnDrop && node.Parent != nodeAtPoint) { try { @@ -404,13 +817,88 @@ public void MoveNode(TilingNode node, Point pt, bool allowNesting = true) throw new TilingFailedException(TilingError.Failed); } } + else + { + try + { + // Non-nesting fallback path: + // preserve intuitive drag behavior (move/reorder) without creating new + // panel structures and without swapping source/target windows. + var oldParent = node.Parent!; + var targetParent = nodeAtPoint.Parent!; + var dropZone = ClassifyDropZone(nodeAtPoint.ComputedRectangle, pt); + var insertionIndex = FindInsertionIndex(nodeAtPoint, pt, dropZone); + + if (!ReferenceEquals(oldParent, targetParent)) + { + if (!MoveNodeTest(node, targetParent, pt)) + { + return; + } + + oldParent.Detach(node); + targetParent.Attach(ClampInsertionIndex(targetParent, insertionIndex), node); + CleanupAfterMove(oldParent); + node.Parent!.RemovePlaceholders(); + } + else + { + var sourceIndex = oldParent.IndexOf(node); + var targetIndex = ClampInsertionIndex(oldParent, insertionIndex); + if (targetIndex > sourceIndex) + { + // Move() uses post-removal indexing semantics. + targetIndex--; + } + + if (sourceIndex != targetIndex) + { + oldParent.Move(sourceIndex, targetIndex); + } + } + } + catch (UnsatisfiableFlexConstraintsException) + { + throw new TilingFailedException(TilingError.Failed); + } + } } } - private static int FindInsertionIndex(TilingNode nodeAtPoint, Point pt) + private static int FindInsertionIndex(TilingNode nodeAtPoint, Point pt, DropZone dropZone) { Debug.Assert(nodeAtPoint.Parent != null); var insertionIndex = nodeAtPoint.Parent!.IndexOf(nodeAtPoint); + if (dropZone == DropZone.Center && nodeAtPoint.Parent is StackPanelNode stackParent) + { + return stackParent.Children.Count; + } + + if (dropZone == DropZone.Neutral) + { + var r = nodeAtPoint.ComputedRectangle; + var midX = r.Left + r.Width / 2; + var midY = r.Top + r.Height / 2; + var dx = pt.X - midX; + var dy = pt.Y - midY; + if (Math.Abs(dx) >= Math.Abs(dy)) + { + return dx < 0 ? insertionIndex : insertionIndex + 1; + } + + return dy < 0 ? insertionIndex : insertionIndex + 1; + } + + if (dropZone == DropZone.Left || dropZone == DropZone.Top) + { + return insertionIndex; + } + + if (dropZone == DropZone.Right || dropZone == DropZone.Bottom) + { + return insertionIndex + 1; + } + if (nodeAtPoint.Parent is GridLikeNode grid) { if (grid.CanResizeInOrientation(PanelOrientation.Horizontal)) @@ -434,6 +922,315 @@ private static int FindInsertionIndex(TilingNode nodeAtPoint, Point pt) return insertionIndex; } + private static int ClampInsertionIndex(PanelNode parent, int insertionIndex) + { + if (insertionIndex < 0) + { + return 0; + } + + if (insertionIndex > parent.Children.Count) + { + return parent.Children.Count; + } + + return insertionIndex; + } + + private static bool IsPointInWindowDropTarget(WindowNode window, Point pt) + { + var r = window.ComputedRectangle; + int expandX = Math.Max(8, r.Width / 40); + int expandY = Math.Max(24, r.Height / 12); + if (window.Parent is PanelNode panel) + { + expandX = Math.Max(panel.Padding.Left, panel.Padding.Right); + expandY = Math.Max(expandY, panel.Padding.Top); + } + + var target = Rectangle.OffsetAndSize( + r.Left - expandX, + r.Top - expandY, + r.Width + expandX * 2, + r.Height + expandY * 2); + return target.Contains(pt); + } + + private static bool IsRedundantSplitWithSingleStackChild(PanelNode panel) + { + return panel is SplitPanelNode + && panel.Children.Count == 1 + && panel.Children[0] is StackPanelNode; + } + + private void CleanupAfterMove(PanelNode oldParent) + { + oldParent.Cleanup(collapse: AutoCollapse); + if (IsRedundantSplitWithSingleStackChild(oldParent)) + { + oldParent.Cleanup(collapse: true); + } + } + + /// + /// Two sibling windows only (no nested panels) — changing orientation in place avoids redundant nesting. + /// + private static bool IsReplaceableSimpleTwoWindowSplit(SplitPanelNode splitParent) + { + return splitParent.Children.Count == 2 + && splitParent.Children[0] is WindowNode + && splitParent.Children[1] is WindowNode; + } + + private static bool IsSimpleTwoWindowSplitSubtree(SplitPanelNode splitRoot) + { + static (bool valid, int windowCount) Walk(TilingNode node) + { + if (node is WindowNode) + { + return (true, 1); + } + + if (node is PlaceholderNode or StackPanelNode) + { + return (false, 0); + } + + if (node is not SplitPanelNode split || split.Children.Count != 2) + { + return (false, 0); + } + + var left = Walk(split.Children[0]); + if (!left.valid) + { + return (false, 0); + } + + var right = Walk(split.Children[1]); + if (!right.valid) + { + return (false, 0); + } + + return (true, left.windowCount + right.windowCount); + } + + var (valid, windowCount) = Walk(splitRoot); + return valid && windowCount == 2; + } + + private bool TryFlipSimpleTwoWindowSplitAncestor(WindowNode nodeAtPoint, PanelOrientation desiredOrientation) + { + SplitPanelNode? candidate = null; + for (var cursor = nodeAtPoint.Parent; cursor is SplitPanelNode split; cursor = split.Parent) + { + if (IsSimpleTwoWindowSplitSubtree(split)) + { + candidate = split; + } + } + + if (candidate == null) + { + return false; + } + + if (candidate.Orientation != desiredOrientation) + { + m_logger?.Debug( + "EnsureDropZone: flip simple split ancestor in place ({From} -> {To}, Gen={Gen})", + candidate.Orientation, + desiredOrientation, + candidate.GenerationID); + candidate.Orientation = desiredOrientation; + } + + return true; + } + + private void EnsureDropZoneParent(TilingNode nodeAtPoint, DropZone dropZone, bool allowFlipInPlace) + { + if (nodeAtPoint is not WindowNode) + { + return; + } + + if (dropZone == DropZone.Neutral) + { + return; + } + + if (dropZone == DropZone.Center) + { + if (nodeAtPoint.Parent is not StackPanelNode) + { + m_logger?.Debug( + "EnsureDropZone: center drop wraps window in new stack (was parent {Parent})", + nodeAtPoint.Parent?.GetType().Name); + WrapInStackPanel(nodeAtPoint); + } + else + { + m_logger?.Debug("EnsureDropZone: center drop joins existing stack (parent Gen {Gen})", nodeAtPoint.Parent!.GenerationID); + } + + return; + } + + // Edge drops on windows already in a stack: insert/reorder via StackPanel + FindInsertionIndex. + // Wrapping here would call WrapInSplitPanel on a stacked window and throw NestingInStackPanel. + if (nodeAtPoint.Parent is StackPanelNode) + { + return; + } + + if (nodeAtPoint.Parent is not SplitPanelNode splitParent) + { + WrapInSplitPanel(nodeAtPoint, vertical: dropZone is DropZone.Top or DropZone.Bottom); + return; + } + + var shouldBeVertical = dropZone is DropZone.Top or DropZone.Bottom; + var desiredOrientation = shouldBeVertical ? PanelOrientation.Vertical : PanelOrientation.Horizontal; + if (splitParent.Orientation != desiredOrientation) + { + if (allowFlipInPlace && IsReplaceableSimpleTwoWindowSplit(splitParent)) + { + m_logger?.Debug( + "EnsureDropZone: flip split orientation in place (two windows, {From} -> {To})", + splitParent.Orientation, + desiredOrientation); + splitParent.Orientation = desiredOrientation; + } + else if (allowFlipInPlace && TryFlipSimpleTwoWindowSplitAncestor((WindowNode)nodeAtPoint, desiredOrientation)) + { + // Simple two-window split chains are orientation-equivalent; avoid creating + // another nested split wrapper when we can reuse an existing ancestor split. + } + else + { + WrapInSplitPanel(nodeAtPoint, vertical: shouldBeVertical); + } + } + else if (splitParent.Children.Count > 2) + { + // In larger same-orientation splits, edge drop should create a focused sub-panel + // around the target window instead of only reordering siblings. + WrapInSplitPanel(nodeAtPoint, vertical: shouldBeVertical); + } + } + + internal static DropZone ClassifyDropZone(Rectangle targetRect, Point pt) + { + if (targetRect.Width <= 0 || targetRect.Height <= 0) + { + return DropZone.Center; + } + + var corner = (int)(Math.Min(targetRect.Width, targetRect.Height) * DropZoneCornerFraction); + if (corner > 0) + { + var inNw = pt.X < targetRect.Left + corner && pt.Y < targetRect.Top + corner; + var inNe = pt.X > targetRect.Right - corner && pt.Y < targetRect.Top + corner; + var inSw = pt.X < targetRect.Left + corner && pt.Y > targetRect.Bottom - corner; + var inSe = pt.X > targetRect.Right - corner && pt.Y > targetRect.Bottom - corner; + if (inNw || inNe || inSw || inSe) + { + return DropZone.Neutral; + } + } + + var centerLeft = targetRect.Left + (int)(targetRect.Width * 0.30); + var centerRight = targetRect.Right - (int)(targetRect.Width * 0.30); + var centerTop = targetRect.Top + (int)(targetRect.Height * 0.30); + var centerBottom = targetRect.Bottom - (int)(targetRect.Height * 0.30); + if (pt.X >= centerLeft && pt.X <= centerRight && pt.Y >= centerTop && pt.Y <= centerBottom) + { + return DropZone.Center; + } + + var midX = targetRect.Left + targetRect.Width / 2; + var midY = targetRect.Top + targetRect.Height / 2; + var dx = pt.X - midX; + var dy = pt.Y - midY; + if (Math.Abs(dx) >= Math.Abs(dy)) + { + return dx < 0 ? DropZone.Left : DropZone.Right; + } + + return dy < 0 ? DropZone.Top : DropZone.Bottom; + } + + private static Rectangle Inset(Rectangle r, int margin) + { + if (r.Width <= margin * 2 || r.Height <= margin * 2) + { + return r; + } + + return Rectangle.OffsetAndSize( + r.Left + margin, + r.Top + margin, + r.Width - margin * 2, + r.Height - margin * 2); + } + + /// + /// Preview regions representing resulting layout after drop. + /// Left/Right/Top/Bottom render split outcome panes; Center renders stack glow area. + /// + internal static void GetDropZoneHighlightRects( + Rectangle targetRect, + DropZone activeZone, + out Rectangle center, + out Rectangle left, + out Rectangle top, + out Rectangle right, + out Rectangle bottom) + { + center = default; + left = default; + top = default; + right = default; + bottom = default; + if (targetRect.Width <= 0 || targetRect.Height <= 0) + { + return; + } + + var inset = Inset(targetRect, margin: 8); + var midX = inset.Left + inset.Width / 2; + var midY = inset.Top + inset.Height / 2; + + switch (activeZone) + { + case DropZone.Center: + center = inset; + break; + case DropZone.Left: + left = Inset(Rectangle.OffsetAndSize(inset.Left, inset.Top, midX - inset.Left, inset.Height), 4); + right = Inset(Rectangle.OffsetAndSize(midX, inset.Top, inset.Right - midX, inset.Height), 4); + break; + case DropZone.Right: + left = Inset(Rectangle.OffsetAndSize(inset.Left, inset.Top, midX - inset.Left, inset.Height), 4); + right = Inset(Rectangle.OffsetAndSize(midX, inset.Top, inset.Right - midX, inset.Height), 4); + break; + case DropZone.Top: + top = Inset(Rectangle.OffsetAndSize(inset.Left, inset.Top, inset.Width, midY - inset.Top), 4); + bottom = Inset(Rectangle.OffsetAndSize(inset.Left, midY, inset.Width, inset.Bottom - midY), 4); + break; + case DropZone.Bottom: + top = Inset(Rectangle.OffsetAndSize(inset.Left, inset.Top, inset.Width, midY - inset.Top), 4); + bottom = Inset(Rectangle.OffsetAndSize(inset.Left, midY, inset.Width, inset.Bottom - midY), 4); + break; + case DropZone.Neutral: + // Keep a subtle cue visible in corner/neutral regions too. + center = Inset(inset, 6); + break; + } + } + internal void WrapInSplitPanel(TilingNode node, bool vertical) { node.Parent?.RemovePlaceholders(); @@ -556,12 +1353,30 @@ private bool MoveNodeTest(TilingNode node, PanelNode newParentNode, Point pt) return true; } - newParentClone.Attach(nodeClone); + var nodeAtPointClone = rootClone.Windows + .Where(x => x != nodeClone) + .OfType() + .FirstOrDefault(x => IsPointInWindowDropTarget(x, pt)); + if (nodeAtPointClone != null) + { + var dropZone = ClassifyDropZone(nodeAtPointClone.ComputedRectangle, pt); + var insertionIndex = FindInsertionIndex(nodeAtPointClone, pt, dropZone); + newParentClone.Attach(ClampInsertionIndex(newParentClone, insertionIndex), nodeClone); + } + else + { + newParentClone.Attach(nodeClone); + } newParentClone.RemovePlaceholders(); testTree.Measure(); testTree.Arrange(); - return newParentClone.ComputedRectangle.Contains(pt); + var targetRect = Rectangle.OffsetAndSize( + newParentClone.ComputedRectangle.Left - newParentClone.Padding.Left, + newParentClone.ComputedRectangle.Top - newParentClone.Padding.Top, + newParentClone.ComputedRectangle.Width + newParentClone.Padding.Left + newParentClone.Padding.Right, + newParentClone.ComputedRectangle.Height + newParentClone.Padding.Top + newParentClone.Padding.Bottom); + return targetRect.Contains(pt); } private static Rectangle TransferSize(Rectangle a, Rectangle b) @@ -573,22 +1388,22 @@ private static Rectangle TransferSize(Rectangle a, Rectangle b) return new Rectangle(newCenter.X - width / 2, newCenter.Y - height / 2, newCenter.X + width / 2, newCenter.Y + height / 2); } - public void MoveWindow(IWindow window, Point pt, bool allowNesting) + public void MoveWindow(IWindow window, Point pt, bool allowNesting, bool swapOnDrop = false) { var state = m_states.FindByTree(window) ?? throw new ArgumentException($"Window must be registered with the backend!", nameof(window)); var sourceNode = state.DesktopTree.FindNode(window) ?? throw new ArgumentException($"Window must be registered with the backend!", nameof(window)); - MoveNode(sourceNode, pt, allowNesting); + MoveNode(sourceNode, pt, allowNesting, swapOnDrop); } - public (Rectangle preArrange, Rectangle postArrange) MockMoveWindow(IWindow window, Point pt, bool allowNesting) + public (Rectangle preArrange, Rectangle postArrange) MockMoveWindow(IWindow window, Point pt, bool allowNesting, bool swapOnDrop = false) { var state = m_states.FindByTree(window) ?? throw new ArgumentException($"Window must be registered with the backend!", nameof(window)); var sourceNode = state.DesktopTree.FindNode(window) ?? throw new ArgumentException($"Window must be registered with the backend!", nameof(window)); - return MockMoveNode(sourceNode, pt, allowNesting); + return MockMoveNode(sourceNode, pt, allowNesting, swapOnDrop); } - public (Rectangle preArrange, Rectangle postArrange) MockMoveNode(TilingNode sourceNode, Point pt, bool allowNesting) + public (Rectangle preArrange, Rectangle postArrange) MockMoveNode(TilingNode sourceNode, Point pt, bool allowNesting, bool swapOnDrop = false) { var desktop = sourceNode.Desktop!; var rootClone = (PanelNode)desktop.Root!.Clone(); @@ -600,7 +1415,7 @@ public void MoveWindow(IWindow window, Point pt, bool allowNesting) WorkArea = desktop.WorkArea, }; - MoveNode(sourceNodeClone, pt, allowNesting); + MoveNode(sourceNodeClone, pt, allowNesting, swapOnDrop); var unconstrainedParentClone = (PanelNode)sourceNodeClone.Parent!.Clone(); @@ -620,7 +1435,15 @@ public void MoveWindow(IWindow window, Point pt, bool allowNesting) unconstrainedParentClone.Padding = new(); try { - unconstrainedParentClone.Arrange(new RectangleF(unconstrainedParentClone.ComputedRectangle)); + // unconstrainedParentClone was cloned before Arrange(); its ComputedRectangle is stale. + // Use the arranged parent's bounds from the test tree (required for Flex.SetContainerWidth >= 1). + var parentBounds = new RectangleF(sourceNodeClone.Parent!.ComputedRectangle); + if (parentBounds.Width < 1 || parentBounds.Height < 1) + { + throw new TilingFailedException(TilingError.NoValidPlacementExists); + } + + unconstrainedParentClone.Arrange(parentBounds); } catch (UnsatisfiableFlexConstraintsException) { diff --git a/FancyWM/Utilities/LowLevelHotkey.cs b/FancyWM/Utilities/LowLevelHotkey.cs index 1e8590b..b9e954c 100644 --- a/FancyWM/Utilities/LowLevelHotkey.cs +++ b/FancyWM/Utilities/LowLevelHotkey.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Windows.Threading; @@ -20,6 +20,7 @@ internal class LowLevelHotkey : IDisposable private readonly KeyCode[] m_modifiers; private readonly bool[] m_pressedModifiers; + private readonly HashSet m_pressedModifierSet = []; private bool m_keyDirty = false; public LowLevelHotkey(LowLevelKeyboardHook keyboardHook, IReadOnlyCollection modifierKeys, KeyCode key) @@ -56,7 +57,9 @@ private void OnLowLevelKeyStateChanged(object? sender, ref LowLevelKeyboardHook. bool Scan(ref LowLevelKeyboardHook.KeyStateChangedEventArgs e) { - if (m_pressedModifiers.All(x => x) && inputKeyCode == mainKeyCode) + if (m_pressedModifiers.All(x => x) + && RequiredModifiersContainPressedSet() + && inputKeyCode == mainKeyCode) { Dispatcher.BeginInvoke(() => { @@ -78,24 +81,27 @@ bool Scan(ref LowLevelKeyboardHook.KeyStateChangedEventArgs e) e.Handled = true; } - int modifierIndex = Array.IndexOf(m_modifiers, e.KeyCode); + int modifierIndex = GetModifierIndex(inputKeyCode); if (modifierIndex != -1) { m_pressedModifiers[modifierIndex] = true; + m_pressedModifierSet.Add(inputKeyCode); } else if (inputKeyCode != mainKeyCode && ClearModifiersOnMiss) { // A non-modifier, non-main key was pressed, in which case // we reset the state, to allow other hotkeys to trigger. Array.Fill(m_pressedModifiers, false); + m_pressedModifierSet.Clear(); } } else { - int modifierIndex = Array.IndexOf(m_modifiers, e.KeyCode); + int modifierIndex = GetModifierIndex(inputKeyCode); if (modifierIndex != -1) { m_pressedModifiers[modifierIndex] = false; + m_pressedModifierSet.Remove(inputKeyCode); } // Handle the dirty key. @@ -114,6 +120,37 @@ bool Scan(ref LowLevelKeyboardHook.KeyStateChangedEventArgs e) } } + private int GetModifierIndex(KeyCode remappedKey) + { + for (int i = 0; i < m_modifiers.Length; i++) + { + if (RemapKeyCode(m_modifiers[i]) == remappedKey) + { + return i; + } + } + + return -1; + } + + private bool RequiredModifiersContainPressedSet() + { + if (m_pressedModifierSet.Count != m_modifiers.Length) + { + return false; + } + + for (int i = 0; i < m_modifiers.Length; i++) + { + if (!m_pressedModifierSet.Contains(RemapKeyCode(m_modifiers[i]))) + { + return false; + } + } + + return true; + } + public void Dispose() { Pressed = null; diff --git a/FancyWM/ViewModels/SettingsViewModel.cs b/FancyWM/ViewModels/SettingsViewModel.cs index f871051..8c1deb2 100644 --- a/FancyWM/ViewModels/SettingsViewModel.cs +++ b/FancyWM/ViewModels/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; @@ -38,8 +38,16 @@ public sealed class SettingsViewModel : ViewModelBase public bool AllocateNewPanelSpace { get => m_allocateNewPanelSpace; set => SetField(ref m_allocateNewPanelSpace, value); } public bool AutoCollapsePanels { get => m_autoCollapsePanels; set => SetField(ref m_autoCollapsePanels, value); } public int AutoSplitCount { get => m_autoSplitCount; set => SetField(ref m_autoSplitCount, value); } + public OverflowPlacementStrategy OverflowPlacementStrategy + { + get => m_overflowPlacementStrategy; + set => SetField(ref m_overflowPlacementStrategy, value); + } public bool DelayReposition { get => m_delayReposition; set => SetField(ref m_delayReposition, value); } + public bool PreserveWindowPositionsOnExit { get => m_preserveWindowPositionsOnExit; set => SetField(ref m_preserveWindowPositionsOnExit, value); } + public bool EnableDragDropAutoPanelCreation { get => m_enableDragDropAutoPanelCreation; set => SetField(ref m_enableDragDropAutoPanelCreation, value); } + public bool HideWindowActionMenuOnHover { get => m_hideWindowActionMenuOnHover; set => SetField(ref m_hideWindowActionMenuOnHover, value); } public bool AnimateWindowMovement { get => m_animateWindowMovement; set => SetField(ref m_animateWindowMovement, value); } public bool MouseAutoFocus { @@ -201,7 +209,11 @@ public ObservableCollection? Keybindings private bool m_allocateNewPanelSpace; private bool m_autoCollapsePanels; private int m_autoSplitCount; + private OverflowPlacementStrategy m_overflowPlacementStrategy; private bool m_delayReposition; + private bool m_preserveWindowPositionsOnExit; + private bool m_enableDragDropAutoPanelCreation; + private bool m_hideWindowActionMenuOnHover; private bool m_customAccentColor; private bool m_animateWindowMovement; private bool m_mouseAutoFocus; @@ -244,7 +256,11 @@ public SettingsViewModel(IObservableFileEntity observable) AllocateNewPanelSpace = settings.AllocateNewPanelSpace; AutoCollapsePanels = settings.AutoCollapsePanels; AutoSplitCount = settings.AutoSplitCount; + OverflowPlacementStrategy = settings.OverflowPlacementStrategy; DelayReposition = settings.DelayReposition; + PreserveWindowPositionsOnExit = settings.PreserveWindowPositionsOnExit; + EnableDragDropAutoPanelCreation = settings.EnableDragDropAutoPanelCreation; + HideWindowActionMenuOnHover = settings.HideWindowActionMenuOnHover; AnimateWindowMovement = settings.AnimateWindowMovement; ModifierMoveWindow = settings.ModifierMoveWindow; ModifierMoveWindowAutoFocus = settings.ModifierMoveWindowAutoFocus; @@ -343,6 +359,8 @@ protected override void NotifyPropertyChanged([CallerMemberName] string? propert if (!m_isInit) { + // Avoid persisting partial values while the VM is being hydrated + // from Settings; only persist user-initiated updates afterwards. return; } @@ -369,7 +387,11 @@ private void SaveChanges() AllocateNewPanelSpace = AllocateNewPanelSpace, AutoCollapsePanels = AutoCollapsePanels, AutoSplitCount = AutoSplitCount, + OverflowPlacementStrategy = OverflowPlacementStrategy, DelayReposition = DelayReposition, + PreserveWindowPositionsOnExit = PreserveWindowPositionsOnExit, + EnableDragDropAutoPanelCreation = EnableDragDropAutoPanelCreation, + HideWindowActionMenuOnHover = HideWindowActionMenuOnHover, AnimateWindowMovement = AnimateWindowMovement, ModifierMoveWindow = ModifierMoveWindow, ModifierMoveWindowAutoFocus = ModifierMoveWindowAutoFocus, diff --git a/FancyWM/ViewModels/TilingOverlayViewModel.cs b/FancyWM/ViewModels/TilingOverlayViewModel.cs index 6e52095..4089241 100644 --- a/FancyWM/ViewModels/TilingOverlayViewModel.cs +++ b/FancyWM/ViewModels/TilingOverlayViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Windows; using WinMan; @@ -12,6 +12,14 @@ public class TilingOverlayViewModel : ViewModelBase private ObservableCollection m_windowElements = []; private Rectangle m_focusRectangle; private Rectangle m_previewRectangle; + private bool m_isDropZonePreviewVisible; + private string m_dropZoneActiveKind = ""; + private Rectangle m_dropZoneOutlineRect; + private Rectangle m_dropZoneCenterRect; + private Rectangle m_dropZoneLeftRect; + private Rectangle m_dropZoneTopRect; + private Rectangle m_dropZoneRightRect; + private Rectangle m_dropZoneBottomRect; private double m_displayScaling; private double m_fontSize; private double m_iconSize; @@ -31,11 +39,36 @@ public class TilingOverlayViewModel : ViewModelBase public Rectangle FocusRectangle { get => m_focusRectangle; set => SetField(ref m_focusRectangle, value); } [DerivedProperty(nameof(FocusRectangle))] - public bool IsFocusRectangleVisible => m_focusRectangle.Width == 0; + public bool IsFocusRectangleVisible => m_focusRectangle.Width != 0; public Rectangle PreviewRectangle { get => m_previewRectangle; set => SetField(ref m_previewRectangle, value); } [DerivedProperty(nameof(PreviewRectangle))] - public bool IsPreviewRectangleVisible => m_previewRectangle.Width == 0; + public bool IsPreviewRectangleVisible => m_previewRectangle.Width != 0; + + public bool IsDropZonePreviewVisible + { + get => m_isDropZonePreviewVisible; + set => SetField(ref m_isDropZonePreviewVisible, value); + } + + /// Center, Left, Right, Top, Bottom, Neutral, or empty when hidden. + public string DropZoneActiveKind + { + get => m_dropZoneActiveKind; + set => SetField(ref m_dropZoneActiveKind, value); + } + + public Rectangle DropZoneOutlineRect { get => m_dropZoneOutlineRect; set => SetField(ref m_dropZoneOutlineRect, value); } + + public Rectangle DropZoneCenterRect { get => m_dropZoneCenterRect; set => SetField(ref m_dropZoneCenterRect, value); } + + public Rectangle DropZoneLeftRect { get => m_dropZoneLeftRect; set => SetField(ref m_dropZoneLeftRect, value); } + + public Rectangle DropZoneTopRect { get => m_dropZoneTopRect; set => SetField(ref m_dropZoneTopRect, value); } + + public Rectangle DropZoneRightRect { get => m_dropZoneRightRect; set => SetField(ref m_dropZoneRightRect, value); } + + public Rectangle DropZoneBottomRect { get => m_dropZoneBottomRect; set => SetField(ref m_dropZoneBottomRect, value); } } } diff --git a/FancyWM/ViewModels/TilingPanelViewModel.cs b/FancyWM/ViewModels/TilingPanelViewModel.cs index 09bd601..263f8e6 100644 --- a/FancyWM/ViewModels/TilingPanelViewModel.cs +++ b/FancyWM/ViewModels/TilingPanelViewModel.cs @@ -1,4 +1,6 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; + +using FancyWM.Layouts.Tiling; using WinMan; @@ -12,6 +14,8 @@ public class TilingPanelViewModel : TilingNodeViewModel private bool m_isMoving; private bool m_childHasDirectFocus; private double m_tabWidth; + private TilingNodeType m_panelType; + private PanelOrientation m_panelOrientation; public double TabWidth { get => m_tabWidth; set => SetField(ref m_tabWidth, value); } @@ -24,5 +28,9 @@ public class TilingPanelViewModel : TilingNodeViewModel public bool IsMoving { get => m_isMoving; set => SetField(ref m_isMoving, value); } public bool ChildHasDirectFocus { get => m_childHasDirectFocus; set => SetField(ref m_childHasDirectFocus, value); } + + public TilingNodeType PanelType { get => m_panelType; set => SetField(ref m_panelType, value); } + + public PanelOrientation PanelOrientation { get => m_panelOrientation; set => SetField(ref m_panelOrientation, value); } } } diff --git a/FancyWM/ViewModels/TilingWindowViewModel.cs b/FancyWM/ViewModels/TilingWindowViewModel.cs index c9732dc..37461e7 100644 --- a/FancyWM/ViewModels/TilingWindowViewModel.cs +++ b/FancyWM/ViewModels/TilingWindowViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Input; @@ -36,6 +36,25 @@ private enum RevealState public double RevealHighlightRadius { get => m_revealHighlightRadius; set => SetField(ref m_revealHighlightRadius, value); } public double RevealHighlightOpacity { get => m_revealHighlightOpacity; set => SetField(ref m_revealHighlightOpacity, value); } + public bool EnableHoverActionMenu + { + get => m_enableHoverActionMenu; + set + { + if (m_enableHoverActionMenu == value) + { + return; + } + + SetField(ref m_enableHoverActionMenu, value); + if (!value) + { + m_actionsRevealState = RevealState.Hidden; + RevealHighlightOpacity = 0; + ActionsVisibility = Visibility.Collapsed; + } + } + } public bool IsActionActive { get => m_isActionActive; set => SetField(ref m_isActionActive, value); } public bool IsPreviewVisible { get => m_isPreviewVisible; set => SetField(ref m_isPreviewVisible, value); } @@ -47,6 +66,7 @@ private enum RevealState private RevealState m_actionsRevealState = RevealState.Hidden; private double m_revealHighlightOpacity = 0; private double m_revealHighlightRadius = 64; + private bool m_enableHoverActionMenu = true; private bool m_isMoving = false; private bool m_isPreviewVisible = false; private bool m_isActionActive = false; @@ -167,6 +187,14 @@ private void OnCursorLocationChanged(object? sender, CursorLocationChangedEventA { if (Node is WindowNode node) { + if (!EnableHoverActionMenu) + { + RevealHighlightOpacity = 0; + ActionsVisibility = Visibility.Collapsed; + m_actionsRevealState = RevealState.Hidden; + return; + } + if (m_isActionActive) { RevealHighlightOpacity = 0; diff --git a/FancyWM/ViewModels/ViewModelBase.cs b/FancyWM/ViewModels/ViewModelBase.cs index 0aa8d9e..a7fefcb 100644 --- a/FancyWM/ViewModels/ViewModelBase.cs +++ b/FancyWM/ViewModels/ViewModelBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; @@ -59,7 +59,7 @@ private ViewModelInfo(Type type) } else { - m_dependedBy[dep] = [dep]; + m_dependedBy[dep] = [prop]; } } } diff --git a/FancyWM/Windows/OverlayHost.OverlayWindow.cs b/FancyWM/Windows/OverlayHost.OverlayWindow.cs index 59164b6..e044ab6 100644 --- a/FancyWM/Windows/OverlayHost.OverlayWindow.cs +++ b/FancyWM/Windows/OverlayHost.OverlayWindow.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Reactive.Linq; using System.Windows; diff --git a/FancyWM/Windows/OverlayHost.cs b/FancyWM/Windows/OverlayHost.cs index b685ffc..d4ed893 100644 --- a/FancyWM/Windows/OverlayHost.cs +++ b/FancyWM/Windows/OverlayHost.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Runtime.InteropServices; using System.Threading.Tasks; using System.Windows; From 2422dc33b549118823e935c51aa02baa6f35b436 Mon Sep 17 00:00:00 2001 From: purr Date: Sat, 11 Apr 2026 11:22:02 +0200 Subject: [PATCH 2/5] Add border resize gesture handling to TilingService Introduces a new boolean field to track if the current gesture is a border resize, suppressing drag-drop preview cues accordingly. Updates related methods to account for this new behavior, enhancing user interaction during window resizing. --- FancyWM.DllImports/NativeMethods.txt | 2 + FancyWM/TilingService.Private.cs | 16 +++++++- FancyWM/TilingService.cs | 6 +++ FancyWM/Utilities/NcHitTest.cs | 55 ++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 FancyWM/Utilities/NcHitTest.cs diff --git a/FancyWM.DllImports/NativeMethods.txt b/FancyWM.DllImports/NativeMethods.txt index d67a918..86d6bde 100644 --- a/FancyWM.DllImports/NativeMethods.txt +++ b/FancyWM.DllImports/NativeMethods.txt @@ -29,6 +29,7 @@ PeekMessage AllowSetForegroundWindow ZeroMemory SendInput +SendMessage SendMessageTimeout CreateWindowEx GetMessage @@ -55,6 +56,7 @@ WM_SYSKEYUP WM_QUIT WM_TIMER WM_COPYDATA +WM_NCHITTEST WM_LBUTTONDOWN WM_LBUTTONUP WM_RBUTTONDOWN diff --git a/FancyWM/TilingService.Private.cs b/FancyWM/TilingService.Private.cs index 16bd064..fdcaee7 100644 --- a/FancyWM/TilingService.Private.cs +++ b/FancyWM/TilingService.Private.cs @@ -320,6 +320,10 @@ private bool CanShowFocusRectangle() private Rectangle? GetPreviewRectangle() { + // WM_NCHITTEST classified this gesture as a border resize, not a move. + if (m_borderResizeGesture) + return null; + var windowDragPreview = m_currentInteraction == UserInteraction.Moving || (m_currentInteraction == UserInteraction.Starting && m_activeDragWindow != null) @@ -412,7 +416,9 @@ private bool CanShowFocusRectangle() private DropZonePreviewState? GetDropZonePreviewState() { - if (m_currentInteraction == UserInteraction.Resizing) + // Suppress cues when WM_NCHITTEST told us this is a border resize, + // or when the size-changed heuristic flagged it as Resizing. + if (m_borderResizeGesture || m_currentInteraction == UserInteraction.Resizing) { return null; } @@ -719,7 +725,9 @@ private void OnCursorLocationChanged(object? sender, CursorLocationChangedEventA { // Keep drag previews responsive to cursor movement even when some windows // don't emit position-changed events continuously during title-bar drag. - if (m_currentInteraction != UserInteraction.Resizing + // Skip when border-resize gesture is active (WM_NCHITTEST classified it). + if (!m_borderResizeGesture + && m_currentInteraction != UserInteraction.Resizing && (m_activeDragWindow != null || m_movingPanelNode != null)) { InvalidateLayout(); @@ -1334,6 +1342,7 @@ private void OnWindowPositionChangeEnd(object? sender, WindowPositionChangedEven } m_activeDragWindow = null; + m_borderResizeGesture = false; m_currentInteraction = UserInteraction.None; } @@ -1657,6 +1666,9 @@ private void OnWindowPositionChangeStart(object? sender, WindowPositionChangedEv } m_activeDragWindow = e.Source; + // Classify gesture at start: WM_NCHITTEST tells us if the cursor is + // over a sizing border, so we can suppress drag-drop cues during resize. + m_borderResizeGesture = NcHitTest.IsBorderResize(e.Source.Handle); m_currentInteraction = UserInteraction.Starting; InvalidateLayout(); } diff --git a/FancyWM/TilingService.cs b/FancyWM/TilingService.cs index a353e6b..228c499 100644 --- a/FancyWM/TilingService.cs +++ b/FancyWM/TilingService.cs @@ -187,6 +187,12 @@ public ITilingServiceIntent? PendingIntent private UserInteraction m_currentInteraction = UserInteraction.None; private PanelNode? m_movingPanelNode; private IWindow? m_activeDragWindow; + /// + /// True when WM_NCHITTEST at gesture start returned a border/sizing hit-test + /// code, meaning the user is edge-resizing rather than title-bar-moving. + /// Suppresses drag-drop preview cues for the duration of the gesture. + /// + private bool m_borderResizeGesture; private ITilingServiceIntent? m_pendingIntent; private readonly Counter m_frozen = new(); private readonly Stopwatch m_sw = new(); diff --git a/FancyWM/Utilities/NcHitTest.cs b/FancyWM/Utilities/NcHitTest.cs new file mode 100644 index 0000000..5fbddb8 --- /dev/null +++ b/FancyWM/Utilities/NcHitTest.cs @@ -0,0 +1,55 @@ +using System; +using System.Runtime.InteropServices; + +namespace FancyWM.Utilities +{ + /// + /// Thin wrapper around WM_NCHITTEST for classifying whether a window + /// considers the current cursor position a sizing border or not. + /// Used at gesture start to distinguish edge-resize from title-bar-move, + /// since WinMan fires the same EVENT_SYSTEM_MOVESIZESTART for both. + /// + internal static class NcHitTest + { + private const int WM_NCHITTEST = 0x0084; + + private const int HTLEFT = 10; + private const int HTRIGHT = 11; + private const int HTTOP = 12; + private const int HTTOPLEFT = 13; + private const int HTTOPRIGHT = 14; + private const int HTBOTTOM = 15; + private const int HTBOTTOMLEFT = 16; + private const int HTBOTTOMRIGHT = 17; + private const int HTBORDER = 18; + private const int HTSIZE = 4; // aka HTGROWBOX + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern nint SendMessage(IntPtr hWnd, int msg, nint wParam, nint lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out POINT pt); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X, Y; } + + private static nint MakeLParam(int x, int y) + => (x & 0xFFFF) | ((y & 0xFFFF) << 16); + + /// + /// Returns true if the window reports that the current cursor position + /// is over a sizing border or corner (HTLEFT..HTBOTTOMRIGHT, HTBORDER, HTSIZE). + /// + public static bool IsBorderResize(IntPtr hwnd) + { + if (!GetCursorPos(out var cursor)) + return false; + nint result = SendMessage(hwnd, WM_NCHITTEST, 0, MakeLParam(cursor.X, cursor.Y)); + int code = (int)(result & 0xFFFF); + return code is HTLEFT or HTRIGHT or HTTOP or HTTOPLEFT or HTTOPRIGHT + or HTBOTTOM or HTBOTTOMLEFT or HTBOTTOMRIGHT + or HTBORDER or HTSIZE; + } + } +} From ab8edd12683967764e01450eabbce7ab9a5f0861 Mon Sep 17 00:00:00 2001 From: purr Date: Sun, 12 Apr 2026 14:29:26 +0200 Subject: [PATCH 3/5] Implement retry mechanism for auto-floated windows after display reconnects Enhances MultiDisplayTilingService and TilingService to track windows that were auto-floated due to transient placement failures. Introduces a scheduled retry for these windows to re-attempt tiling once display geometry stabilizes, improving layout consistency after events like hibernation or display changes. --- FancyWM/MultiDisplayTilingService.cs | 20 ++++++++ FancyWM/TilingService.Private.cs | 77 ++++++++++++++++++++++++++++ FancyWM/TilingService.cs | 18 +++++++ 3 files changed, 115 insertions(+) diff --git a/FancyWM/MultiDisplayTilingService.cs b/FancyWM/MultiDisplayTilingService.cs index 0ceeb47..6eb030b 100644 --- a/FancyWM/MultiDisplayTilingService.cs +++ b/FancyWM/MultiDisplayTilingService.cs @@ -156,6 +156,26 @@ private void OnDisplayAdded(object? sender, DisplayChangedEventArgs e) m_tilingServices.Add(e.Source, tiling); } UpdateActiveDisplay(reason: $"display {e.Source} was added"); + + // After a display reconnect, existing services may have windows + // that were auto-floated due to transient constraint failures. + // Retry after a delay so display geometry / window metrics settle. + ScheduleRetryForAllDisplays(); + } + }); + } + + private void ScheduleRetryForAllDisplays() + { + _ = Dispatcher.InvokeAsync(async () => + { + await System.Threading.Tasks.Task.Delay(2000); + lock (m_syncRoot) + { + foreach (var tiling in m_tilingServices.Values) + { + tiling.RetryFailedPlacements(); + } } }); } diff --git a/FancyWM/TilingService.Private.cs b/FancyWM/TilingService.Private.cs index fdcaee7..028d1d2 100644 --- a/FancyWM/TilingService.Private.cs +++ b/FancyWM/TilingService.Private.cs @@ -94,6 +94,12 @@ private void UpdateTree(DesktopTree tree) { m_floatingSet.Add(largestWindow.WindowReference); } + // Track for retry — flex constraints are often transient after + // display reconnect / hibernation resume. + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Add(largestWindow.WindowReference); + } DetectChanges(largestWindow.WindowReference); PlacementFailed?.Invoke(this, new TilingFailedEventArgs(TilingError.NoValidPlacementExists, largestWindow.WindowReference)); } @@ -439,6 +445,17 @@ private bool CanShowFocusRectangle() return null; } + // Safety net: suppress cues if the drag source became floating mid-drag + // (e.g. via hotkey or exclusion-list update while dragging). + if (m_activeDragWindow != null) + { + using (m_floatingSetLock.EnterScope()) + { + if (m_floatingSet.Contains(m_activeDragWindow)) + return null; + } + } + try { if (IsSwapModifierPressed()) @@ -631,6 +648,12 @@ private void ToggleFloat(IWindow window) m_floatingSet.Add(window); } } + // User explicitly toggled float — remove from retry tracking so + // RetryFailedPlacements() won't override the user's intent. + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Remove(window); + } DetectChanges(window); if (floated) { @@ -664,10 +687,51 @@ private void OnPlacementFailed(object? sender, TilingFailedEventArgs e) { m_floatingSet.Add(e.FailSource); } + // Mark as auto-floated so RetryFailedPlacements() can re-attempt + // once transient constraints (e.g. post-hibernation) resolve. + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Add(e.FailSource); + } OnWindowFloated(e.FailSource); } } + /// Re-attempts tiling for windows that were auto-floated due to transient + /// constraint failures (e.g. stale min/max sizes right after hibernation + /// resume or display reconnect). Called on a delay to give Windows time to + /// stabilize display geometry and window metrics. + internal void RetryFailedPlacements() + { + List candidates; + using (m_placementFailedSetLock.EnterScope()) + { + candidates = [.. m_placementFailedSet]; + } + + if (candidates.Count == 0) + return; + + m_logger.Information("Retrying placement for {Count} auto-floated window(s)", candidates.Count); + + foreach (var window in candidates) + { + // Un-float so DetectChanges → CanManage → RegisterWindow path runs. + using (m_floatingSetLock.EnterScope()) + { + m_floatingSet.Remove(window); + } + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Remove(window); + } + + // DetectChanges will re-register if constraints now permit it. + // If it still fails, OnPlacementFailed re-adds to both sets. + DetectChanges(window); + } + } + private void OnWindowFloated(IWindow window) { Rectangle? originalPosition; @@ -1273,6 +1337,10 @@ private void OnWindowRemoved(object? sender, WindowChangedEventArgs e) { m_floatingSet.Remove(e.Source); } + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Remove(e.Source); + } using (m_newWindowSetLock.EnterScope()) { m_newWindowSet.Remove(e.Source); @@ -1665,6 +1733,15 @@ private void OnWindowPositionChangeStart(object? sender, WindowPositionChangedEv m_ignoreRepositionSet.Add(e.Source); } + // Floating/exempt windows must not trigger drag-drop zone cues on tiled + // windows — they can never participate in panel creation. Skip setting + // m_activeDragWindow so GetDropZonePreviewState() stays inert. + using (m_floatingSetLock.EnterScope()) + { + if (m_floatingSet.Contains(e.Source)) + return; + } + m_activeDragWindow = e.Source; // Classify gesture at start: WM_NCHITTEST tells us if the cursor is // over a sizing border, so we can suppress drag-drop cues during resize. diff --git a/FancyWM/TilingService.cs b/FancyWM/TilingService.cs index 228c499..d5276a5 100644 --- a/FancyWM/TilingService.cs +++ b/FancyWM/TilingService.cs @@ -169,6 +169,13 @@ public ITilingServiceIntent? PendingIntent private readonly HashSet m_floatingSet = []; private readonly Utilities.DebugLock m_floatingSetLock = new(LockThreshold); + /// Windows auto-floated due to transient placement failure (e.g. after + /// hibernation resume). Tracked separately so RetryFailedPlacements() can + /// attempt to re-tile them once constraints stabilize, without touching + /// user-intentionally-floated windows. + private readonly HashSet m_placementFailedSet = []; + private readonly Utilities.DebugLock m_placementFailedSetLock = new(LockThreshold); + private readonly HashSet m_ignoreRepositionSet = []; private readonly Utilities.DebugLock m_ignoreRepositionSetLock = new(LockThreshold); @@ -713,6 +720,17 @@ private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e) await Task.Delay(750); Refresh(); InvalidateLayout(); + + // Secondary pass: windows that failed placement above due to stale + // min/max constraints get another chance now that displays have + // had more time to settle. Three retries spaced 2s apart cover + // slow monitor wake-up scenarios. + for (int i = 0; i < 3; i++) + { + await Task.Delay(2000); + RetryFailedPlacements(); + InvalidateLayout(); + } }); } From 4b7462c8884fc3759715ffc458eed009cbefdf33 Mon Sep 17 00:00:00 2001 From: purr Date: Sun, 12 Apr 2026 14:35:05 +0200 Subject: [PATCH 4/5] Enhance error handling for virtual desktop interactions Improves the robustness of the TilingService by adding error handling for COM exceptions during virtual desktop operations. This includes safe defaults for window management when pin state cannot be determined, and handling transient failures when retrieving the current desktop after sleep/wake events. These changes aim to prevent crashes and improve stability during display changes. --- FancyWM/TilingService.Private.cs | 52 ++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/FancyWM/TilingService.Private.cs b/FancyWM/TilingService.Private.cs index 028d1d2..232558b 100644 --- a/FancyWM/TilingService.Private.cs +++ b/FancyWM/TilingService.Private.cs @@ -600,7 +600,18 @@ private void WrapInStackPanel(TilingNode node) private IntPtr GetOverlayAnchor() { - var desktop = m_workspace.VirtualDesktopManager.CurrentDesktop; + // COM VD class factory can fail after sleep/wake while Explorer + // re-registers (REGDB_E_CLASSNOTREG — GitHub #447). Return no + // anchor; the overlay loop will retry on the next tick. + IVirtualDesktop desktop; + try + { + desktop = m_workspace.VirtualDesktopManager.CurrentDesktop; + } + catch (System.Runtime.InteropServices.COMException) + { + return new IntPtr(0); + } using (m_backendLock.EnterScope()) { try @@ -716,19 +727,26 @@ internal void RetryFailedPlacements() foreach (var window in candidates) { - // Un-float so DetectChanges → CanManage → RegisterWindow path runs. - using (m_floatingSetLock.EnterScope()) + try { - m_floatingSet.Remove(window); + // Un-float so DetectChanges → CanManage → RegisterWindow path runs. + using (m_floatingSetLock.EnterScope()) + { + m_floatingSet.Remove(window); + } + using (m_placementFailedSetLock.EnterScope()) + { + m_placementFailedSet.Remove(window); + } + + // DetectChanges will re-register if constraints now permit it. + // If it still fails, OnPlacementFailed re-adds to both sets. + DetectChanges(window); } - using (m_placementFailedSetLock.EnterScope()) + catch (InvalidWindowReferenceException) { - m_placementFailedSet.Remove(window); + // Window was destroyed between scheduling the retry and now. } - - // DetectChanges will re-register if constraints now permit it. - // If it still fails, OnPlacementFailed re-adds to both sets. - DetectChanges(window); } } @@ -1949,8 +1967,18 @@ bool IsFloating() return false; } - // Virtual Desktop stuff is very expensive - if (m_workspace.VirtualDesktopManager.IsWindowPinned(x)) + // Virtual Desktop stuff is very expensive. + // The COM VD service can throw transiently during input-sync calls, + // display changes, or hibernation resume (GitHub #450, #457). + // Safe default: don't manage the window when we can't determine pin state. + try + { + if (m_workspace.VirtualDesktopManager.IsWindowPinned(x)) + { + return false; + } + } + catch (System.Runtime.InteropServices.COMException) { return false; } From 4336329a4158fca3154402b51ce5043770c27799 Mon Sep 17 00:00:00 2001 From: purr Date: Sun, 12 Apr 2026 15:26:36 +0200 Subject: [PATCH 5/5] Refactor window style management in OverlayHost Improves the handling of window styles in the OverlayHost class by separating the logic for enabling and disabling windows. Adds comments to clarify the impact of WS_EX_TRANSPARENT on hit-testing and the decision to avoid using WDA_EXCLUDEFROMCAPTURE to preserve overlay visibility in screenshots. This enhances the clarity and maintainability of the code. --- FancyWM/Windows/OverlayHost.cs | 36 ++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/FancyWM/Windows/OverlayHost.cs b/FancyWM/Windows/OverlayHost.cs index d4ed893..25d323f 100644 --- a/FancyWM/Windows/OverlayHost.cs +++ b/FancyWM/Windows/OverlayHost.cs @@ -111,6 +111,10 @@ public OverlayHost(IDisplay display) DisableWindow(m_hwnd); DisableWindow(m_nonHitTestableHwnd); + + // Do not use WDA_EXCLUDEFROMCAPTURE: it strips overlay pixels from screen + // capture, so focus rings and panel chrome would disappear from screenshots. + UpdatePositions(); display.Workspace.CursorLocationChanged += OnCursorLocationChanged; @@ -228,21 +232,37 @@ protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) private static void EnableWindow(IntPtr hwnd) { - var oldValue = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE); - var newValue = oldValue & ~(int)WINDOWS_STYLE.WS_DISABLED; - if (oldValue != newValue) + var oldStyle = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE); + var newStyle = oldStyle & ~(int)WINDOWS_STYLE.WS_DISABLED; + if (oldStyle != newStyle) + { + _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE, newStyle); + } + // Remove WS_EX_TRANSPARENT so the window can receive input and is + // visible to WindowFromPoint (needed for interactive overlay controls). + var oldEx = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_EXSTYLE); + var newEx = oldEx & ~(int)WINDOWS_EX_STYLE.WS_EX_TRANSPARENT; + if (oldEx != newEx) { - _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE, newValue); + _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_EXSTYLE, newEx); } } private static void DisableWindow(IntPtr hwnd) { - var oldValue = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE); - var newValue = oldValue | (int)WINDOWS_STYLE.WS_DISABLED; - if (oldValue != newValue) + var oldStyle = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE); + var newStyle = oldStyle | (int)WINDOWS_STYLE.WS_DISABLED; + if (oldStyle != newStyle) + { + _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE, newStyle); + } + // WS_EX_TRANSPARENT: hit-test APIs skip this hwnd when the cursor is not + // over interactive overlay visuals (see OnCursorLocationChanged). + var oldEx = PInvoke.GetWindowLong(new(hwnd), GetWindowLongPtr_nIndex.GWL_EXSTYLE); + var newEx = oldEx | (int)WINDOWS_EX_STYLE.WS_EX_TRANSPARENT; + if (oldEx != newEx) { - _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_STYLE, newValue); + _ = SetWindowLongPtr(new(hwnd), GetWindowLongPtr_nIndex.GWL_EXSTYLE, newEx); } }