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.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/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/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..232558b 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)
@@ -82,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));
}
@@ -154,6 +172,7 @@ async ValueTask RepositionAsync()
m_gui.UpdateOverlay(snapshot, focusedPath);
m_gui.PreviewRectangle = GetPreviewRectangle();
+ m_gui.DropZonePreview = GetDropZonePreviewState();
if (m_showPreviewFocus)
{
@@ -307,51 +326,211 @@ private bool CanShowFocusRectangle()
private Rectangle? GetPreviewRectangle()
{
- if (m_currentInteraction == UserInteraction.Moving && m_delayReposition || m_movingPanelNode != null)
+ // 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)
+ || (m_currentInteraction == UserInteraction.None && m_activeDragWindow != null);
+ if (!windowDragPreview && m_movingPanelNode == null)
{
- try
- {
- var isSwapping = IsSwapModifierPressed();
- var pt = m_workspace.CursorLocation;
+ return 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)
+ 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);
+ }
+ }
+
+ return set.Count > 0 ? set : null;
+ }
+
+ private DropZonePreviewState? GetDropZonePreviewState()
+ {
+ // 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;
+ }
+
+ 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;
+ }
+
+ // 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())
{
+ return null;
}
- catch (InvalidWindowReferenceException)
+
+ 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;
}
@@ -421,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
@@ -469,6 +659,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)
{
@@ -502,10 +698,58 @@ 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)
+ {
+ try
+ {
+ // 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);
+ }
+ catch (InvalidWindowReferenceException)
+ {
+ // Window was destroyed between scheduling the retry and now.
+ }
+ }
+ }
+
private void OnWindowFloated(IWindow window)
{
Rectangle? originalPosition;
@@ -561,6 +805,16 @@ 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.
+ // 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();
+ }
+
if (PendingIntent == null)
return;
@@ -859,7 +1113,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 +1243,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 +1303,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();
}
@@ -1092,6 +1355,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);
@@ -1111,8 +1378,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 +1426,9 @@ private void OnWindowPositionChangeEnd(object? sender, WindowPositionChangedEven
{
m_ignoreRepositionSet.Remove(e.Source);
}
+
+ m_activeDragWindow = null;
+ m_borderResizeGesture = false;
m_currentInteraction = UserInteraction.None;
}
@@ -1309,7 +1595,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 +1750,22 @@ 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.
+ m_borderResizeGesture = NcHitTest.IsBorderResize(e.Source.Handle);
m_currentInteraction = UserInteraction.Starting;
+ InvalidateLayout();
}
private void OnTilingNodeFocusRequested(object? sender, TilingNode e)
@@ -1568,7 +1869,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();
@@ -1666,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;
}
@@ -1756,9 +2067,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 +2079,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..d5276a5 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)
{
@@ -164,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);
@@ -181,6 +193,13 @@ public ITilingServiceIntent? PendingIntent
private bool m_dirty = true;
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();
@@ -192,7 +211,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 +244,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 +281,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 +303,10 @@ public void Start()
public void Stop()
{
m_active = false;
- RestoreOriginalLayout();
+ if (!m_preserveWindowPositionsOnExit)
+ {
+ RestoreOriginalLayout();
+ }
m_gui.Hide();
}
@@ -377,7 +403,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 +607,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 +706,34 @@ 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();
+
+ // 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();
+ }
+ });
+ }
+
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/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;
+ }
+ }
+}
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..25d323f 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;
@@ -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);
}
}