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); } }