diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 0b38e7b0..e877eb2e 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -2617,6 +2617,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_debugToolStripMenuItem { } } + /// + /// 查找类似 Detach Mode 的本地化字符串。 + /// + public static string LogTabWindow_UI_ToolStripMenuItem_detachModeToolStripMenuItem { + get { + return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_detachModeToolStripMenuItem", resourceCulture); + } + } + /// /// 查找类似 Disable word highlight mode 的本地化字符串。 /// @@ -3060,6 +3069,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_ToolTip_copyPathToClipboa } } + /// + /// 查找类似 Shows the active log alone in its own window and hides the main window 的本地化字符串。 + /// + public static string LogTabWindow_UI_ToolStripMenuItem_ToolTip_detachModeToolStripMenuItem { + get { + return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_ToolTip_detachModeToolStripMenuItem", resourceCulture); + } + } + /// /// 查找类似 Opens an Explorer window and selects the log file 的本地化字符串。 /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 9217bb50..30ddae55 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1148,6 +1148,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Zu Tab kopieren + + Detach-Modus + Berechne Zeitstreuungsanzeige... @@ -1344,6 +1347,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Kopiert alle selektierten Zeilen in einen neuen Tab + + Zeigt das aktive Protokoll allein in einem eigenen Fenster an und blendet das Hauptfenster aus + Splittet diverse Logdateien in fixierte Spalten diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index c74d625a..078363a3 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1229,6 +1229,9 @@ Checked tools will appear in the icon bar. All other tools are available in the Copy to Tab + + Detach Mode + Options @@ -1394,6 +1397,9 @@ Checked tools will appear in the icon bar. All other tools are available in the Copies all selected lines into a new tab page + + Shows the active log alone in its own window and hides the main window + Splits various kinds of logfiles into fixed columns diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index fb379214..9ed0b1dd 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1100,6 +1100,9 @@ 复制到标签页 + + 分离模式 + 选项 @@ -1262,6 +1265,9 @@ 将所有选中行复制到新标签页 + + 在独立窗口中仅显示当前日志,并隐藏主窗口 + 将各类日志文件拆分为固定列 diff --git a/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs new file mode 100644 index 00000000..be80eb2c --- /dev/null +++ b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs @@ -0,0 +1,775 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Extensions; +using LogExpert.UI.Interface; +using LogExpert.UI.Services.DetachModeService; +using LogExpert.UI.Services.TabControllerService; + +using Moq; + +using NUnit.Framework; + +using Vanara.PInvoke; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Tests.Services; + +[TestFixture] +[SupportedOSPlatform("windows")] +[Apartment(ApartmentState.STA)] +internal class DetachModeControllerTests : IDisposable +{ + private Form _form; + private DockPanel _dockPanel; + private TabController _tabController; + private DetachModeController _controller; + private bool _disposed; + + [SetUp] + public void Setup () + { + _form = new Form(); + _dockPanel = new DockPanel + { + Dock = DockStyle.Fill, + DocumentStyle = DocumentStyle.DockingWindow, + Theme = new VS2015LightTheme() + }; + + _form.Controls.Add(_dockPanel); + _form.Show(); + + _tabController = new TabController(_dockPanel); + _controller = new DetachModeController(_form, _dockPanel, _tabController); + } + + [TearDown] + public void TearDown () + { + _controller?.Dispose(); + _tabController?.Dispose(); + _form?.Close(); + _form?.Dispose(); + } + + #region IDisposable Implementation + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose (bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _controller?.Dispose(); + _tabController?.Dispose(); + _form?.Dispose(); + } + + _disposed = true; + } + + #endregion + + [Test] + public void Enter_DetachesTheActiveWindowAsAWindowLikeFloat () + { + // Arrange + AddWindows("first.log", "second.log"); + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null, "the dock panel should have an active document"); + + // Act + var entered = _controller.Enter(); + + // Assert + Assert.Multiple(() => + { + Assert.That(entered, Is.True); + Assert.That(_controller.IsActive, Is.True); + Assert.That(active.DockState, Is.EqualTo(DockState.Float)); + Assert.That(_form.Visible, Is.False, "the main window is hidden while detached"); + + var floatWindow = FloatWindowOf(active); + + Assert.That(floatWindow, Is.Not.Null); + Assert.That(floatWindow.Owner, Is.Null, "a hidden owner would take the float down with it"); + Assert.That(floatWindow.FormBorderStyle, Is.EqualTo(FormBorderStyle.Sizable)); + Assert.That(floatWindow.MaximizeBox, Is.True); + Assert.That(floatWindow.MinimizeBox, Is.True); + Assert.That(floatWindow.ShowInTaskbar, Is.True); + }); + } + + [Test] + public void Enter_GivesTheFloatItsOwnIconInsteadOfTheLogWindowSharedOne () + { + // Arrange + var windows = AddWindows("first.log"); + windows[0].Icon = SystemIcons.Information; + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null); + + // Act + _controller.Enter(); + + // Assert + var floatWindow = FloatWindowOf(active); + + Assert.Multiple(() => + { + Assert.That(floatWindow, Is.Not.Null); + Assert.That(floatWindow.Icon, Is.Not.EqualTo(windows[0].Icon), "the float must not share the log window's icon object"); + }); + } + + [Test] + public void Enter_DoesNothingWhenNoWindowIsOpen () + { + // Arrange + + // Act + var entered = _controller.Enter(); + + // Assert + Assert.Multiple(() => + { + Assert.That(entered, Is.False); + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + }); + } + + [Test] + public void Exit_RestoresTheWindowAndTheMainForm () + { + // Arrange + var windows = AddWindows("first.log", "second.log"); + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null); + + var originalPane = active.DockHandler.Pane; + var originalIndex = originalPane.Contents.IndexOf(active); + + _controller.Enter(); + + // Act + _controller.Exit(); + + // Assert + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + Assert.That(active.DockState, Is.EqualTo(DockState.Document)); + Assert.That(active.DockHandler.Pane, Is.EqualTo(originalPane)); + Assert.That(originalPane.Contents.IndexOf(active), Is.EqualTo(originalIndex)); + Assert.That(windows.Count(window => window.DockState == DockState.Document), Is.EqualTo(windows.Count)); + }); + } + + [Test] + public void Exit_WithoutRestoringTheMainForm_DocksTheWindowBackAndLeavesTheFormHidden () + { + // Arrange + AddWindows("first.log", "second.log"); + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null); + + _controller.Enter(); + + Assert.That(_form.Visible, Is.False); + + // Act + _controller.Exit(restoreMainForm: false); + + // Assert + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.False, "a main form that is closing must not be shown again"); + Assert.That(active.DockState, Is.EqualTo(DockState.Document), "the window is still docked back"); + }); + } + + [Test] + public void Exit_KeepsAnAlreadyFloatingWindowFloating () + { + // Arrange + var windows = AddWindows("first.log", "floating.log"); + var floating = windows[1]; + + floating.Show(_dockPanel, DockState.Float); + Assert.That(_dockPanel.ActiveContent as LogWindow, Is.EqualTo(floating)); + + var floatWindow = FloatWindowOf(floating); + + Assert.That(floatWindow, Is.Not.Null); + + var originalOwner = floatWindow.Owner; + var originalShowInTaskbar = floatWindow.ShowInTaskbar; + var originalBorderStyle = floatWindow.FormBorderStyle; + + _controller.Enter(); + + // Act + _controller.Exit(); + + // Assert + Assert.Multiple(() => + { + Assert.That(floating.DockState, Is.EqualTo(DockState.Float)); + Assert.That(floatWindow.Owner, Is.EqualTo(originalOwner)); + Assert.That(floatWindow.ShowInTaskbar, Is.EqualTo(originalShowInTaskbar)); + Assert.That(floatWindow.FormBorderStyle, Is.EqualTo(originalBorderStyle)); + }); + } + + [Test] + public void Exit_RestoresTheWindowStateChangedWhileDetached () + { + // Arrange + var windows = AddWindows("first.log", "floating.log"); + var floating = windows[1]; + + floating.Show(_dockPanel, DockState.Float); + + var floatWindow = FloatWindowOf(floating); + + Assert.That(floatWindow, Is.Not.Null); + + _controller.Enter(); + floatWindow.WindowState = FormWindowState.Maximized; + + // Act + _controller.Exit(); + + // Assert + Assert.That(floatWindow.WindowState, Is.EqualTo(FormWindowState.Normal)); + } + + [Test] + public void SwitchWindow_MovesTheDetachedFloatThroughEveryWindow () + { + // Arrange + var windows = AddWindows("first.log", "second.log", "third.log"); + var start = _dockPanel.ActiveContent as LogWindow; + + Assert.That(start, Is.Not.Null, "the dock panel should have an active document"); + + _controller.Enter(); + + // Act & Assert + var visited = new List(); + + for (var turn = 0; turn < windows.Count; turn++) + { + _controller.SwitchWindow(shiftPressed: false); + + var detached = windows.Single(window => window.DockState == DockState.Float); + visited.Add(detached); + + Assert.That(windows.Count(window => window.DockState == DockState.Document), Is.EqualTo(windows.Count - 1)); + Assert.That(_form.Visible, Is.False); + } + + Assert.Multiple(() => + { + Assert.That(visited.Distinct().Count(), Is.EqualTo(windows.Count), "every window is reachable with Ctrl+Tab"); + Assert.That(visited[0], Is.Not.EqualTo(start), "the first switch moves off the window the mode started on"); + Assert.That(visited[^1], Is.EqualTo(start), "the cycle wraps around to where it started"); + }); + } + + [Test] + public void SwitchWindow_WhenTheNextWindowCannotBeDetached_DocksItBackAndShowsTheMainForm () + { + // Arrange + var windows = AddWindows("first.log", "second.log"); + var previous = _dockPanel.ActiveContent as LogWindow; + + Assert.That(previous, Is.Not.Null); + + // The first attach (Enter) succeeds, the one for the next window fails after the chrome patch. + using var controller = CreateControllerFailingToHookAfter(1); + Assert.That(controller.Enter(), Is.True); + + var target = windows.Single(window => window != previous); + + // Act + controller.SwitchWindow(shiftPressed: false); + + // Assert + Assert.Multiple(() => + { + Assert.That(controller.IsActive, Is.False, "a failed detach must not leave the mode on without a float"); + Assert.That(_form.Visible, Is.True, "the main window has to come back when the switch fails"); + Assert.That(previous.DockState, Is.EqualTo(DockState.Document), "the window the switch moved off is docked back"); + Assert.That(target.DockState, Is.EqualTo(DockState.Document), "the half-detached target is docked back"); + }); + } + + [Test] + public void OnWindowAdded_WhenTheNewWindowCannotBeDetached_DocksItBackAndShowsTheMainForm () + { + // Arrange + var windows = AddWindows("first.log"); + var detached = windows[0]; + + // The first attach (Enter) succeeds, the one for the added window fails after the chrome patch. + using var controller = CreateControllerFailingToHookAfter(1); + Assert.That(controller.Enter(), Is.True); + + using var added = CreateLogWindow("added.log"); + _tabController.AddWindow(added, "added.log"); + + // Act + controller.OnWindowAdded(added); + + // Assert + Assert.Multiple(() => + { + Assert.That(controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True, "the main window has to come back when the detach fails"); + Assert.That(detached.DockState, Is.EqualTo(DockState.Document), "the window the mode moved off is docked back"); + Assert.That(added.DockState, Is.EqualTo(DockState.Document), "the half-detached new window is docked back"); + }); + } + + [Test] + public void Enter_WhenTheFloatCannotBeHooked_RestoresTheChromeOfAnAlreadyFloatingWindow () + { + // Arrange + var windows = AddWindows("first.log", "floating.log"); + var floating = windows[1]; + + floating.Show(_dockPanel, DockState.Float); + + Assert.That(_dockPanel.ActiveContent as LogWindow, Is.EqualTo(floating)); + + var floatWindow = FloatWindowOf(floating); + + Assert.That(floatWindow, Is.Not.Null); + + var originalOwner = floatWindow.Owner; + var originalBorderStyle = floatWindow.FormBorderStyle; + var originalShowInTaskbar = floatWindow.ShowInTaskbar; + var originalAllowEndUserDocking = floatWindow.AllowEndUserDocking; + + // The window is already floating, so the rollback cannot dock it back: only the chrome can + // show that the patch was undone. + using var controller = CreateControllerFailingToHookAfter(0); + + // Act + var entered = controller.Enter(); + + // Assert + Assert.Multiple(() => + { + Assert.That(entered, Is.False, "a float without a caption hook must not start the mode"); + Assert.That(controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + Assert.That(floating.DockState, Is.EqualTo(DockState.Float), "the window was floating before the mode"); + + Assert.That(floatWindow.Owner, Is.EqualTo(originalOwner), "the patched chrome has to be rolled back"); + Assert.That(floatWindow.FormBorderStyle, Is.EqualTo(originalBorderStyle)); + Assert.That(floatWindow.ShowInTaskbar, Is.EqualTo(originalShowInTaskbar)); + Assert.That(floatWindow.AllowEndUserDocking, Is.EqualTo(originalAllowEndUserDocking)); + }); + } + + [Test] + public void OnWindowDisposed_LastWindow_EndsTheModeAndShowsTheMainForm () + { + // Arrange + var windows = AddWindows("only.log"); + var active = _dockPanel.ActiveContent as LogWindow; + _controller.Enter(); + + // Act + active.Dispose(); + _controller.OnWindowDisposed(active); + Application.DoEvents(); + + // Assert + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True, "the last window is gone, so the main window has to come back"); + }); + } + + [Test] + public void OnWindowDisposed_LastWindow_LeavesNoDocumentInThePanel () + { + // Arrange + AddWindows("only.log"); + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null, "the dock panel should have an active document"); + + _controller.Enter(); + + // Act + active.Dispose(); + _controller.OnWindowDisposed(active); + Application.DoEvents(); + + // Assert + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + Assert.That(_dockPanel.Contents.Count, Is.EqualTo(0), "closing the float must not leave an empty document tab"); + }); + } + + [Test] + public void OnWindowDisposed_WithOtherWindows_EndsTheModeAndRestoresMainForm () + { + // Arrange + var windows = AddWindows("first.log", "second.log"); + var active = _dockPanel.ActiveContent as LogWindow; + + Assert.That(active, Is.Not.Null, "the dock panel should have an active document"); + + _controller.Enter(); + + // Act + active.Dispose(); + _controller.OnWindowDisposed(active); + Application.DoEvents(); + + // Assert + var other = windows.Single(window => window != active); + + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + Assert.That(other.DockState, Is.EqualTo(DockState.Document)); + }); + } + + [Test] + public void OnWindowAdded_MakesTheNewWindowTheDetachedOne () + { + // Arrange + var windows = AddWindows("first.log"); + _controller.Enter(); + var detached = _dockPanel.ActiveContent as LogWindow; + + using var added = CreateLogWindow("added.log"); + _tabController.AddWindow(added, "added.log"); + + // Act + _controller.OnWindowAdded(added); + + // Assert + Assert.Multiple(() => + { + Assert.That(_controller.IsActive, Is.True); + Assert.That(added.DockState, Is.EqualTo(DockState.Float)); + Assert.That(detached.DockState, Is.EqualTo(DockState.Document), "the previous detached window keeps its place in the main window"); + + _controller.Exit(); + + Assert.That(added.DockState, Is.EqualTo(DockState.Document), "the added window is restored like any other"); + }); + } + + [Test] + public void PreFilterMessage_F11WhileDetached_LeavesTheMode () + { + // Arrange + AddWindows("first.log"); + _controller.Enter(); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_F11), + LParam = 0 // first press, no auto-repeat bit + }; + + // Act + var swallowed = CreateFilter().PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.True); + Assert.That(_controller.IsActive, Is.False); + Assert.That(_form.Visible, Is.True); + }); + } + + [Test] + public void PreFilterMessage_WhileNotDetached_PassesEveryKeyThrough () + { + // Arrange + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_F11), + LParam = 0 + }; + + // Act + var swallowed = CreateFilter().PreFilterMessage(ref msg); + + // Assert + Assert.That(swallowed, Is.False); + } + + [Test] + public void PreFilterMessage_F11AutoRepeat_IsSwallowedWithoutLeavingTheMode () + { + // Arrange + AddWindows("first.log"); + _controller.Enter(); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_F11), + LParam = (IntPtr)(1L << 30) // auto-repeat bit set by Windows while the key is held + }; + + // Act + var swallowed = CreateFilter().PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.True, "the whole key burst is swallowed"); + Assert.That(_controller.IsActive, Is.True, "a repeat must not toggle the mode again"); + }); + } + + [Test] + public void PreFilterMessage_Esc_PassesThroughAndStaysInMode () + { + // Arrange + AddWindows("first.log"); + _controller.Enter(); + var filter = CreateFilter(); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_ESCAPE), + LParam = 0 + }; + + // Act + var swallowed = filter.PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.False); + Assert.That(_controller.IsActive, Is.True); + }); + } + + [Test] + public void PreFilterMessage_F11WhileAnotherFormIsActive_DoesNotLeaveTheMode () + { + // Arrange + AddWindows("first.log"); + _controller.Enter(); + var filter = CreateFilter(activeFormGetter: () => _form); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_F11), + LParam = 0 + }; + + // Act + var swallowed = filter.PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.False); + Assert.That(_controller.IsActive, Is.True, "the key belongs to the form in front"); + Assert.That(_form.Visible, Is.False); + }); + } + + [Test] + public void PreFilterMessage_CtrlTabWhileDetached_SwitchesToTheNextWindow () + { + // Arrange + var windows = AddWindows("first.log", "second.log"); + _controller.Enter(); // detaches the active window, the last added one + var filter = CreateFilter(() => Keys.Control); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_TAB), + LParam = 0 + }; + + // Act + var swallowed = filter.PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.True); + Assert.That(_controller.IsActive, Is.True); + Assert.That(windows[0].DockState, Is.EqualTo(DockState.Float), "the next window takes over"); + Assert.That(windows[1].DockState, Is.EqualTo(DockState.Document), "the previous detached window keeps its place"); + }); + } + + [Test] + public void PreFilterMessage_CtrlShiftTabWhileDetached_SwitchesToThePreviousWindow () + { + // Arrange + var windows = AddWindows("first.log", "second.log", "third.log"); + _controller.Enter(); // detaches the active window, the last added one + var filter = CreateFilter(() => Keys.Control | Keys.Shift); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_TAB), + LParam = 0 + }; + + // Act + var swallowed = filter.PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.True); + Assert.That(_controller.IsActive, Is.True); + Assert.That(windows[1].DockState, Is.EqualTo(DockState.Float), "the previous window takes over"); + Assert.That(windows[2].DockState, Is.EqualTo(DockState.Document), "the previous detached window keeps its place"); + }); + } + + [Test] + public void PreFilterMessage_OtherKeysWhileDetached_PassThrough () + { + // Arrange + AddWindows("first.log"); + _controller.Enter(); + + var msg = new Message + { + Msg = NativeMethods.WM_KEYDOWN, + WParam = Key(User32.VK.VK_A), + LParam = 0 + }; + + // Act + var swallowed = CreateFilter().PreFilterMessage(ref msg); + + // Assert + Assert.Multiple(() => + { + Assert.That(swallowed, Is.False); + Assert.That(_controller.IsActive, Is.True); + }); + } + + /// + /// A fresh filter wired to the controller, so PreFilterMessage can be driven without a + /// running message pump. + /// + private DetachMessageFilter CreateFilter (Func? modifierKeysGetter = null, Func? activeFormGetter = null) + => new(_controller, modifierKeysGetter, activeFormGetter ?? (() => _controller.DetachedFloat)); + + /// + /// A controller whose caption hook fails, so a detach can be failed at the one point that is + /// otherwise out of reach: after the float chrome was already patched. + /// + private DetachModeController CreateControllerFailingToHookAfter (int successfulAttaches) + { + var remaining = successfulAttaches; + + return new DetachModeController( + _form, + _dockPanel, + _tabController, + floatWindow => remaining-- > 0 + ? DetachFloatWindowHook.Attach(floatWindow) + : throw new InvalidOperationException("caption hook failed")); + } + + private static IntPtr Key (User32.VK virtualKey) => (IntPtr)(int)virtualKey; + + private FloatWindow FloatWindowOf (LogWindow content) + { + foreach (FloatWindow floatWindow in _dockPanel.FloatWindows) + { + foreach (DockPane pane in floatWindow.NestedPanes) + { + if (pane.Contents.Contains(content)) + { + return floatWindow; + } + } + } + + return null; + } + + private List AddWindows (params string[] fileNames) + { + var windows = fileNames.Select(CreateLogWindow).ToList(); + + foreach (var (window, fileName) in windows.Zip(fileNames)) + { + window.CloseButton = true; + _tabController.AddWindow(window, fileName); + } + + return windows; + } + + private static LogWindow CreateLogWindow (string fileName) + { + var coordinatorMock = new Mock(); + _ = coordinatorMock.Setup(c => c.ResolveHighlightGroup(It.IsAny(), It.IsAny())).Returns(new HighlightGroup()); + _ = coordinatorMock.SetupGet(c => c.SearchParams).Returns(new SearchParams()); + + var configManagerMock = new Mock(); + + // Without these stubs the mock returns null and LogWindow's session save on Dispose + // throws ArgumentNullException — surfaced as a modal MessageBox that blocks the test host. + _ = configManagerMock.SetupGet(c => c.ActiveSessionDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_DetachModeTests")); + _ = configManagerMock.SetupGet(c => c.ActiveConfigDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_DetachModeTests")); + _ = configManagerMock.SetupGet(c => c.Settings).Returns(new Settings()); + + var pluginRegistryMock = new Mock(); + _ = pluginRegistryMock.SetupGet(p => p.RegisteredColumnizers).Returns([new DefaultLogfileColumnizer()]); + + return new LogWindow(coordinatorMock.Object, fileName, false, false, configManagerMock.Object, pluginRegistryMock.Object); + } +} diff --git a/src/LogExpert.UI.Tests/Services/TabControllerTests.cs b/src/LogExpert.UI.Tests/Services/TabControllerTests.cs index 667c694c..2bbc2bd3 100644 --- a/src/LogExpert.UI.Tests/Services/TabControllerTests.cs +++ b/src/LogExpert.UI.Tests/Services/TabControllerTests.cs @@ -413,6 +413,77 @@ public void ActivateWindow_WithNullWindow_DoesNotThrow () #endregion + #region GetNextWindow / GetPreviousWindow Tests + + [Test] + public void GetNextWindow_FromAnyWindow_MovesForwardAndWrapsAround () + { + // Arrange + using var first = CreateLogWindow("first.log"); + using var second = CreateLogWindow("second.log"); + using var third = CreateLogWindow("third.log"); + + _tabController.AddWindow(first, "first.log"); + _tabController.AddWindow(second, "second.log"); + _tabController.AddWindow(third, "third.log"); + + // Act & Assert + Assert.That(_tabController.GetNextWindow(first), Is.EqualTo(second)); + Assert.That(_tabController.GetNextWindow(second), Is.EqualTo(third)); + Assert.That(_tabController.GetNextWindow(third), Is.EqualTo(first)); + } + + [Test] + public void GetPreviousWindow_FromAnyWindow_MovesBackwardAndWrapsAround () + { + // Arrange + using var first = CreateLogWindow("first.log"); + using var second = CreateLogWindow("second.log"); + using var third = CreateLogWindow("third.log"); + + _tabController.AddWindow(first, "first.log"); + _tabController.AddWindow(second, "second.log"); + _tabController.AddWindow(third, "third.log"); + + // Act & Assert + Assert.That(_tabController.GetPreviousWindow(first), Is.EqualTo(third)); + Assert.That(_tabController.GetPreviousWindow(second), Is.EqualTo(first)); + Assert.That(_tabController.GetPreviousWindow(third), Is.EqualTo(second)); + } + + [Test] + public void GetNextWindow_FollowsTheTabStripOrderNotTheAddOrder () + { + // Arrange + using var first = CreateLogWindow("first.log"); + using var second = CreateLogWindow("second.log"); + using var third = CreateLogWindow("third.log"); + + _tabController.AddWindow(first, "first.log"); + _tabController.AddWindow(second, "second.log"); + _tabController.AddWindow(third, "third.log"); + + // Tab strip is now second, first, third. + second.DockHandler.Pane.SetContentIndex(second, 0); + + // Act & Assert + Assert.Multiple(() => + { + Assert.That(_tabController.GetNextWindow(second), Is.EqualTo(first)); + Assert.That(_tabController.GetPreviousWindow(second), Is.EqualTo(third)); + }); + } + + [Test] + public void GetNextWindow_WhenEmpty_ReturnsNull () + { + // Act & Assert + Assert.That(_tabController.GetNextWindow(null), Is.Null); + Assert.That(_tabController.GetPreviousWindow(null), Is.Null); + } + + #endregion + #region SwitchToNextWindow Tests [Test] @@ -450,6 +521,11 @@ private static LogWindow CreateLogWindow (string fileName) var configManagerMock = new Mock(); _ = configManagerMock.SetupGet(configManager => configManager.Settings).Returns(new Settings()); + // Without these stubs the mock returns null and LogWindow's session save on Dispose + // throws ArgumentNullException — surfaced as a modal MessageBox that blocks the test host. + _ = configManagerMock.SetupGet(configManager => configManager.ActiveSessionDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_TabControllerTests")); + _ = configManagerMock.SetupGet(configManager => configManager.ActiveConfigDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_TabControllerTests")); + var pluginRegistryMock = new Mock(); _ = pluginRegistryMock.SetupGet(pluginRegistry => pluginRegistry.RegisteredColumnizers).Returns([new DefaultLogfileColumnizer()]); diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 26d5f5f5..798fc001 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -21,6 +21,7 @@ using LogExpert.UI.Entities; using LogExpert.UI.Extensions; using LogExpert.UI.Extensions.LogWindow; +using LogExpert.UI.Services.DetachModeService; using LogExpert.UI.Services.FileOperationService; using LogExpert.UI.Services.LedService; using LogExpert.UI.Services.LogWindowCoordinatorService; @@ -86,6 +87,8 @@ internal partial class LogTabWindow : Form, ILogTabWindow private readonly List _pendingUnresolvedShortcuts = []; private bool _unresolvedShortcutMessageQueued; + private readonly DetachModeController _detachModeController; + #endregion #region cTor @@ -114,6 +117,8 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu _toolWindowCoordinator = new ToolWindowCoordinator(configManager); + _detachModeController = new DetachModeController(this, dockPanel, _tabController); + _ledService = new LedIndicatorService(); _ledService.Initialize(ConfigManager.Settings.Preferences.ShowTailColor); _ledService.IconChanged += OnLedIconChanged; @@ -554,6 +559,7 @@ private void ApplyContextMenuResources () timeshiftToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_timeshiftToolStripMenuItem; timeshiftToolStripTextBox.Text = Resources.LogTabWindow_UI_ToolStripTextBox_timeshiftToolStripTextBox; copyMarkedLinesIntoNewTabToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_copyMarkedLinesIntoNewTabToolStripMenuItem; + detachModeToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_detachModeToolStripMenuItem; //Options menu optionToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_optionToolStripMenuItem; @@ -610,6 +616,7 @@ private void ApplyToolTips () saveSessionToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_saveProjectToolStripMenuItem; timeshiftToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_timeshiftToolStripMenuItem; copyMarkedLinesIntoNewTabToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_copyMarkedLinesIntoNewTabToolStripMenuItem; + detachModeToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_detachModeToolStripMenuItem; columnizerToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_columnizerToolStripMenuItem; cellSelectModeToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_cellSelectModeToolStripMenuItem; lockInstanceToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_lockInstanceToolStripMenuItem; @@ -849,6 +856,7 @@ protected override void Dispose (bool disposing) _tabStringFormat?.Dispose(); _menuToolbarController?.Dispose(); _toolWindowCoordinator?.Dispose(); + _detachModeController?.Dispose(); // Dispose TabController after FileOperationService is no longer reachable. // FileOperationService holds a reference to _tabController but does not own it; // after Dispose(), no caller invokes the service, so stale references are harmless. @@ -929,6 +937,11 @@ private void AddLogWindow (LogWindow.LogWindow logWindow, string title, bool doN if (!doNotAddToPanel) { logWindow.Visible = true; + + if (_detachModeController.IsActive) + { + _detachModeController.OnWindowAdded(logWindow); + } } } @@ -1568,6 +1581,8 @@ private void CloseAllTabs () [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0010:Add missing cases", Justification = "no need for the other switch cases")] private void LoadSession (string sessionFileName, bool restoreLayout) { + _detachModeController.Exit(); + var outcome = _sessionHandler.LoadSession(sessionFileName); bool openedTabs = false; @@ -1842,8 +1857,13 @@ private void OnLogTabWindowLoad (object sender, EventArgs e) #endif } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Shutdown must continue even when the session cannot be saved.")] private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e) { + // The form is going away: showing it again would make it flash during shutdown. Exit() does + // not throw, so this needs no guard of its own. + _detachModeController.Exit(restoreMainForm: false); + CancelPendingDrop(); try { @@ -1886,6 +1906,7 @@ private void OnLogWindowDisposed (object sender, EventArgs e) RemoveLogWindow(logWindow); logWindow.Tag = null; + _detachModeController.OnWindowDisposed(logWindow); } private void OnExitToolStripMenuItemClick (object sender, EventArgs e) @@ -2156,6 +2177,63 @@ private void OnFollowTailCheckBoxClick (object sender, EventArgs e) CurrentLogWindow?.FollowTailChanged(checkBoxFollowTail.Checked, false); } + /// + /// Takes F11 for detach mode before the key reaches the log window. While detached the main + /// window is hidden and 's message filter serves the key. + /// + protected override bool ProcessCmdKey (ref Message msg, Keys keyData) + { + // A failed enter leaves the key alone instead of swallowing it without any effect. + if (keyData == Keys.F11 && !NativeMethods.IsAutoRepeat(msg) && EnterDetachMode()) + { + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + + /// + /// Enters detach mode, restoring a minimized window first so the float gets sane bounds. + /// + /// true when the mode is active afterwards; false when it could not be entered. + [SupportedOSPlatform("windows")] + private bool EnterDetachMode () + { + if (_detachModeController.IsActive) + { + return true; + } + + var wasMinimized = WindowState == FormWindowState.Minimized; + var wasMaximized = _wasMaximized; + + if (wasMinimized) + { + WindowState = FormWindowState.Normal; + } + + if (_detachModeController.Enter()) + { + return true; + } + + // Nothing was detached, so leave the window exactly as it was found. Going through Normal + // let OnLogTabWindowSizeChanged overwrite the flag SetForeground() restores with. + if (wasMinimized) + { + WindowState = FormWindowState.Minimized; + } + + _wasMaximized = wasMaximized; + + return false; + } + + private void OnDetachModeToolStripMenuItemClick (object sender, EventArgs e) + { + EnterDetachMode(); + } + private void OnLogTabWindowKeyDown (object sender, KeyEventArgs e) { if (e.KeyCode == Keys.W && e.Control) diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index b834d5a6..8bc9fb22 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs @@ -47,6 +47,8 @@ private void InitializeComponent () lastUsedToolStripMenuItem = new ToolStripMenuItem(); exitToolStripMenuItem = new ToolStripMenuItem(); viewNavigateToolStripMenuItem = new ToolStripMenuItem(); + detachModeToolStripMenuItem = new ToolStripMenuItem(); + ToolStripSeparator12 = new ToolStripSeparator(); goToLineToolStripMenuItem = new ToolStripMenuItem(); searchToolStripMenuItem = new ToolStripMenuItem(); filterToolStripMenuItem = new ToolStripMenuItem(); @@ -333,7 +335,7 @@ private void InitializeComponent () // // viewNavigateToolStripMenuItem // - viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem }); + viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem, ToolStripSeparator12, detachModeToolStripMenuItem }); viewNavigateToolStripMenuItem.Name = "viewNavigateToolStripMenuItem"; viewNavigateToolStripMenuItem.Size = new Size(96, 19); viewNavigateToolStripMenuItem.Text = "View/Navigate"; @@ -472,6 +474,20 @@ private void InitializeComponent () copyMarkedLinesIntoNewTabToolStripMenuItem.ToolTipText = "Copies all selected lines into a new tab page"; copyMarkedLinesIntoNewTabToolStripMenuItem.Click += OnCopyMarkedLinesIntoNewTabToolStripMenuItemClick; // + // ToolStripSeparator12 + // + ToolStripSeparator12.Name = "ToolStripSeparator12"; + ToolStripSeparator12.Size = new Size(174, 6); + // + // detachModeToolStripMenuItem + // + detachModeToolStripMenuItem.Name = "detachModeToolStripMenuItem"; + detachModeToolStripMenuItem.ShortcutKeyDisplayString = "F11"; + detachModeToolStripMenuItem.Size = new Size(177, 22); + detachModeToolStripMenuItem.Text = "Detach Mode"; + detachModeToolStripMenuItem.ToolTipText = "Shows the active log alone in its own window and hides the main window"; + detachModeToolStripMenuItem.Click += OnDetachModeToolStripMenuItemClick; + // // optionToolStripMenuItem // optionToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { columnizerToolStripMenuItem, hilightingToolStripMenuItem, ToolStripSeparator7, settingsToolStripMenuItem, pluginTrustManagementToolStripMenuItem, ToolStripSeparator9, cellSelectModeToolStripMenuItem, alwaysOnTopToolStripMenuItem, hideLineColumnToolStripMenuItem, ToolStripSeparator8, lockInstanceToolStripMenuItem }); @@ -1141,6 +1157,8 @@ private void InitializeComponent () private ToolStripMenuItem columnFinderToolStripMenuItem; private DockPanel dockPanel; private ToolStripMenuItem tabRenameToolStripMenuItem; + private ToolStripMenuItem detachModeToolStripMenuItem; + private ToolStripSeparator ToolStripSeparator12; private ToolStripSeparator lineToolStripSeparatorExtension1; private ToolStripSeparator lineToolStripSeparatorExtension2; private ToolStripSeparator ToolStripSeparator1; diff --git a/src/LogExpert.UI/Extensions/NativeMethods.cs b/src/LogExpert.UI/Extensions/NativeMethods.cs index c6cfd8a2..c4d51f38 100644 --- a/src/LogExpert.UI/Extensions/NativeMethods.cs +++ b/src/LogExpert.UI/Extensions/NativeMethods.cs @@ -39,8 +39,34 @@ internal static partial class NativeMethods #endregion + #region Win32 constants shared by keyboard handling + + public const int WM_KEYDOWN = 0x0100; + + public const long KEYDOWN_AUTOREPEAT_FLAG = 1L << 30; + + #endregion + + #region P/Invoke + + // DwmApi is the one place that does not go through Vanara: the generated P/Invoke there carries + // no DefaultDllImportSearchPaths, and dwmapi.dll is not a KnownDLL, so dropping the System32 + // restriction would reopen a search-order hijack. + [System.Runtime.InteropServices.DllImport("dwmapi.dll")] + [System.Runtime.InteropServices.DefaultDllImportSearchPaths(System.Runtime.InteropServices.DllImportSearchPath.System32)] + public static extern int DwmSetWindowAttribute (IntPtr hwnd, int attribute, ref int value, int size); + + #endregion + #region Helper methods + // Tells the repeated WM_KEYDOWN messages Windows sends while a key is held down from the single + // message of an actual press. + public static bool IsAutoRepeat (Message msg) + { + return msg.Msg == WM_KEYDOWN && (msg.LParam.ToInt64() & KEYDOWN_AUTOREPEAT_FLAG) != 0; + } + public static Icon LoadIconFromExe (string fileName, int index) { diff --git a/src/LogExpert.UI/Interface/ITabController.cs b/src/LogExpert.UI/Interface/ITabController.cs index 496b8749..2350cf0e 100644 --- a/src/LogExpert.UI/Interface/ITabController.cs +++ b/src/LogExpert.UI/Interface/ITabController.cs @@ -65,6 +65,26 @@ internal interface ITabController : IDisposable /// void SwitchToPreviousWindow (); + /// + /// Resolves the LogWindow that follows in the tab order, without + /// activating it. + /// + /// + /// The window to move on from. Pass null to use the active window. + /// + /// The next LogWindow, or null when no window is tracked. + LogWindow? GetNextWindow (LogWindow? current = null); + + /// + /// Resolves the LogWindow that precedes in the tab order, without + /// activating it. + /// + /// + /// The window to move back from. Pass null to use the active window. + /// + /// The previous LogWindow, or null when no window is tracked. + LogWindow? GetPreviousWindow (LogWindow? current = null); + /// /// Finds a LogWindow by its associated file name. /// diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs new file mode 100644 index 00000000..bd1b1bc1 --- /dev/null +++ b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs @@ -0,0 +1,76 @@ +using System.Runtime.Versioning; + +using Vanara.PInvoke; + +namespace LogExpert.UI.Services.DetachModeService; + +/// +/// Win32 subclass for a detached : it turns a +/// caption double click into a maximize/restore toggle, because the suite docks the window back on +/// that message. +/// +[SupportedOSPlatform("windows")] +internal sealed class DetachFloatWindowHook : NativeWindow, IDisposable +{ + private readonly WeifenLuo.WinFormsUI.Docking.FloatWindow _floatWindow; + private bool _disposed; + + /// Raised when the user double clicks the caption of the hooked window. + public event EventHandler CaptionDoubleClicked; + + private DetachFloatWindowHook (WeifenLuo.WinFormsUI.Docking.FloatWindow floatWindow) + { + _floatWindow = floatWindow; + AssignHandle(floatWindow.Handle); + } + + /// + /// Starts watching the caption of . Call this after every + /// style-changing property: those recreate the handle and would detach the hook. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Internal invariant message, not user-facing.")] + public static DetachFloatWindowHook Attach (WeifenLuo.WinFormsUI.Docking.FloatWindow floatWindow) + { + ArgumentNullException.ThrowIfNull(floatWindow); + + if (!floatWindow.IsHandleCreated) + { + throw new InvalidOperationException("The float window handle must be created before hooking its caption."); + } + + return new DetachFloatWindowHook(floatWindow); + } + + /// The window this hook is attached to. + public WeifenLuo.WinFormsUI.Docking.FloatWindow FloatWindow => _floatWindow; + + protected override void WndProc (ref Message m) + { + if (m.Msg == (int)User32.WindowMessage.WM_NCLBUTTONDBLCLK) + { + // Swallowed on purpose: both the suite and DefWindowProc act on a caption double click. + CaptionDoubleClicked?.Invoke(this, EventArgs.Empty); + return; + } + + base.WndProc(ref m); + } + + public void Dispose () + { + if (_disposed) + { + return; + } + + _disposed = true; + + // ReleaseHandle restores the window procedure that AssignHandle replaced. + if (Handle != IntPtr.Zero) + { + ReleaseHandle(); + } + + CaptionDoubleClicked = null; + } +} diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs new file mode 100644 index 00000000..fa8a23b6 --- /dev/null +++ b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs @@ -0,0 +1,78 @@ +using System.Runtime.Versioning; + +using LogExpert.UI.Extensions; + +using Vanara.PInvoke; + +namespace LogExpert.UI.Services.DetachModeService; + +/// +/// Serves the keyboard of detach mode while the main window is hidden. The main form is not in +/// the focus chain then, so its ProcessCmdKey never fires and the keys are taken from the +/// message stream instead: F11 leaves the mode, Ctrl+Tab / Ctrl+Shift+Tab move to the previous or +/// next log window. +/// +[SupportedOSPlatform("windows")] +internal sealed class DetachMessageFilter : IMessageFilter +{ + private readonly DetachModeController _controller; + private readonly Func _modifierKeysGetter; + private readonly Func _activeFormGetter; + + public DetachMessageFilter ( + DetachModeController controller, + Func? modifierKeysGetter = null, + Func? activeFormGetter = null) + { + ArgumentNullException.ThrowIfNull(controller); + _controller = controller; + _modifierKeysGetter = modifierKeysGetter ?? (() => Control.ModifierKeys); + _activeFormGetter = activeFormGetter ?? (() => Form.ActiveForm); + } + + public bool PreFilterMessage (ref Message m) + { + if (m.Msg != NativeMethods.WM_KEYDOWN || !_controller.IsActive) + { + return false; + } + + // Only the detached float owns these keys; a modal dialog on top of it must keep F11 and + // Ctrl+Tab for itself. The float has to exist first: between releasing the hook and + // detaching the next window both it and ActiveForm are null, and comparing them would let + // the keys through to a controller that is mid-switch. + if (_controller.DetachedFloat is not { } floatWindow || _activeFormGetter() != floatWindow) + { + return false; + } + + switch ((User32.VK)m.WParam.ToInt32()) + { + case User32.VK.VK_F11: + // Block the whole burst: acting on auto-repeat would flip the mode on every repeat. + if (!NativeMethods.IsAutoRepeat(m)) + { + _controller.Exit(); + } + + return true; + + case User32.VK.VK_TAB: + { + // Read once: both flags must come from the same snapshot of the key state. + var modifiers = _modifierKeysGetter(); + + if (!modifiers.HasFlag(Keys.Control)) + { + return false; + } + + _controller.SwitchWindow(modifiers.HasFlag(Keys.Shift)); + return true; + } + + default: + return false; + } + } +} diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs b/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs new file mode 100644 index 00000000..9789e794 --- /dev/null +++ b/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs @@ -0,0 +1,670 @@ +using System.Runtime.Versioning; + +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Extensions; +using LogExpert.UI.Interface; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Services.DetachModeService; + +/// +/// Owns detach mode: F11 hides the main window and keeps the active log window floating; leaving +/// the mode restores its original place and shows the main window again. +/// +[SupportedOSPlatform("windows")] +internal sealed class DetachModeController : IDisposable +{ + private readonly Form _mainForm; + private readonly DockPanel _dockPanel; + private readonly ITabController _tabController; + private readonly DetachMessageFilter _messageFilter; + private readonly Func _attachCaptionHook; + + private DetachFloatWindowHook? _captionHook; + private bool _filterInstalled; + private bool _disposed; + + private LogWindow? _detachedWindow; + + private PlacementSnapshot _originalPlacement; + private ChromeSnapshot? _originalChrome; + + public DetachModeController ( + Form mainForm, + DockPanel dockPanel, + ITabController tabController, + Func? attachCaptionHook = null) + { + ArgumentNullException.ThrowIfNull(mainForm); + ArgumentNullException.ThrowIfNull(dockPanel); + ArgumentNullException.ThrowIfNull(tabController); + + _mainForm = mainForm; + _dockPanel = dockPanel; + _tabController = tabController; + _messageFilter = new DetachMessageFilter(this); + + // Overridable so a test can fail the attach after the chrome has already been patched. + _attachCaptionHook = attachCaptionHook ?? DetachFloatWindowHook.Attach; + } + + public bool IsActive => _detachedWindow != null; + + public FloatWindow? DetachedFloat => _captionHook?.FloatWindow; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "A mode that could not be entered has to be left again, whatever went wrong.")] + public bool Enter () + { + if (IsActive) + { + return true; + } + + try + { + var active = _dockPanel.ActiveContent as LogWindow ?? _tabController.GetActiveWindow(); + + if (active == null || active.IsDisposed) + { + return false; + } + + if (!TryDetach(active)) + { + // Hiding the main form without a float would leave no window to press F11 in. + return false; + } + + InstallFilter(); + _mainForm.Hide(); + return true; + } + catch (Exception) + { + // A main form that stayed visible with the mode still on could not be left with F11: + // ProcessCmdKey would just re-enter the mode. + Exit(); + return false; + } + } + + /// + /// Leaves the mode: releases the float, restores the window and brings the main form back. + /// + /// + /// Pass false when the caller is closing the main form: showing it again would make it + /// flash during shutdown. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exit() runs from the message filter, from session load, from Enter()'s failure path and from Dispose().")] + public void Exit (bool restoreMainForm = true) + { + var window = _detachedWindow; + + _detachedWindow = null; + + try + { + if (window != null && restoreMainForm) + { + // Show the owner first: assigning it to a hidden main form would hide the float + // before its content is docked back. + RestoreMainForm(); + } + + // Also releases a hook a half-entered mode left behind without a detached window. + try + { + ReleaseFloatWindow(); + } + catch (Exception) + { + // A hook that cannot be released must not keep the window floating as well. + } + + RestoreWindow(window); + } + catch (Exception) + { + // The form was already brought back before the throw; the finally only has to clean up. + } + finally + { + try + { + UninstallFilter(); + } + catch (Exception) + { + // A filter that cannot be removed is inert once the mode is off. + } + + ClearSnapshots(); + } + } + + private void ClearSnapshots () + { + _originalPlacement = default; + _originalChrome = null; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "This only runs while a failure is already being handled.")] + private void LeaveDetachedState (LogWindow? window) + { + _detachedWindow = null; + + // Show the form before the float takes its owner back, or the restored chrome hides it. + RestoreMainForm(); + + try + { + UninstallFilter(); + } + catch (Exception) + { + // Best effort: the caller is already ending the mode. + } + + try + { + ReleaseFloatWindow(); + } + catch (Exception) + { + // The hook is dropped as the release starts, so a failed dispose cannot revive it. + } + + // Only with a live snapshot: after a rollback the window is already back, and showing it + // through the fallback would move a window that was originally floating or docked. + if (_originalPlacement.DockState != DockState.Unknown) + { + try + { + RestoreWindow(window); + } + catch (Exception) + { + // A window that cannot be docked back stays where it is; the main form still returns. + } + } + + ClearSnapshots(); + } + + private void RestoreMainFormLater () + { + try + { + _mainForm.BeginInvoke(new Action(RestoreMainForm)); + } + catch (InvalidOperationException) + { + RestoreMainForm(); + } + } + + public void SwitchWindow (bool shiftPressed) + { + if (_detachedWindow is not { } previous) + { + return; + } + + SwitchTo(previous, () => shiftPressed + ? _tabController.GetPreviousWindow(previous) + : _tabController.GetNextWindow(previous)); + } + + public void OnWindowAdded (LogWindow window) + { + if (_detachedWindow is not { } previous || window.IsDisposed) + { + return; + } + + SwitchTo(previous, () => window); + } + + /// + /// Swaps the detached window for another one. The current float is released before + /// runs, because the next/previous walk needs it back in the tab + /// strip. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "A failed switch must end in a visible window rather than an exception.")] + private void SwitchTo (LogWindow previous, Func resolveNext) + { + try + { + ReleaseAndRestore(previous); + + if (!TryDetach(resolveNext() ?? previous)) + { + LeaveDetachedState(previous); + } + } + catch (Exception) + { + // Nothing may escape while the main form is hidden. + LeaveDetachedState(previous); + } + } + + // Puts the floating window back into the tab strip before the next one is detached: the float + // is not part of the strip, so the walk needs the full order to stay stable. + private void ReleaseAndRestore (LogWindow? window) + { + ReleaseFloatWindow(); + RestoreWindow(window); + + // The old placement is consumed: a later failure must not move the window a second time. + ClearSnapshots(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "A failed detach falls back to a visible window instead of propagating.")] + private bool TryDetach (LogWindow window) + { + // Detach() snapshots afresh, so a throw before that point must not roll back to the old place. + ClearSnapshots(); + + try + { + return Detach(window); + } + catch (Exception) + { + // The caller ends the mode when this returns false. + RollBackFailedDetach(window); + return false; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The rollback runs while the main form is hidden and must not throw in turn.")] + private void RollBackFailedDetach (LogWindow window) + { + // Detach() assigns this before Activate(), which can throw on a torn-down window. + _detachedWindow = null; + + var floatWindow = _captionHook?.FloatWindow ?? FindFloatWindow(window); + + try + { + ReleaseFloatWindow(); + } + catch (Exception) + { + // The hook is dropped as the release starts, so a failed dispose cannot revive it. + } + + // Kept apart from the placement restore so a chrome failure cannot keep the window floating. + try + { + // PatchFloatWindow() can run without a hook to release it, so nothing else would restore the chrome. + RestorePatchedChrome(floatWindow); + } + catch (Exception) + { + // The chrome is cosmetic; docking the window back matters more. + } + + if (_originalPlacement.DockState != DockState.Unknown) + { + try + { + RestoreWindow(window); + } + catch (Exception) + { + // A window that cannot be docked back stays where it is; the main form still returns. + } + } + + ClearSnapshots(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The detached window is already gone; nothing may escape into its dispose path.")] + public void OnWindowDisposed (LogWindow window) + { + if (!IsActive || window != _detachedWindow) + { + return; + } + + _detachedWindow = null; + + // Uninstall first and on its own: a filter left behind keeps this controller alive, and it + // can never fire again once the mode is off. + try + { + UninstallFilter(); + } + catch (Exception) + { + // A filter that cannot be removed is inert while the mode is off. + } + + try + { + ReleaseFloatWindow(restoreChrome: false); + } + catch (Exception) + { + // The float is being torn down with its content. + } + + ClearSnapshots(); + RestoreMainFormLater(); + } + + private bool Detach (LogWindow window) + { + // Snapshot before Show(): the window reports Float afterwards. + var pane = window.DockHandler.Pane; + _originalPlacement = new PlacementSnapshot(window.DockState, pane, pane?.Contents.IndexOf(window) ?? -1); + + if (window.DockState != DockState.Float) + { + window.Show(_dockPanel, DockState.Float); + } + + var floatWindow = FindFloatWindow(window); + + if (floatWindow == null) + { + // The suite refused to float the window; put it back instead of half-entering the mode. + RollBackFailedDetach(window); + return false; + } + + // The snapshot has to exist before the first style change: everything after it can throw, + // and only the snapshot can put the suite's chrome back. + _originalChrome = CaptureChrome(floatWindow); + PatchFloatWindow(floatWindow); + + var hook = _attachCaptionHook(floatWindow); + hook.CaptionDoubleClicked += ToggleMaximize; + _captionHook = hook; + _detachedWindow = window; + + window.Activate(); + RepaintCaptionLater(floatWindow); + return true; + } + + private void RestoreWindow (LogWindow? window) + { + if (window == null || window.IsDisposed) + { + return; + } + + if (_originalPlacement.DockState == DockState.Float) + { + // Was already floating; only the chrome was patched, and that is restored separately. + return; + } + + if (_originalPlacement.Pane is { IsDisposed: false } pane && pane.DockState == _originalPlacement.DockState) + { + // Put it back into its original pane and tab index so split panes and tab order survive. + window.Show(pane, null); + + if (_originalPlacement.ContentIndex >= 0 && _originalPlacement.ContentIndex < pane.Contents.Count) + { + pane.SetContentIndex(window, _originalPlacement.ContentIndex); + } + + return; + } + + // Pane disappeared (split closed, session load): fall back to dock-state-only placement. + window.Show(_dockPanel, ToRestorable(_originalPlacement.DockState)); + } + + private void PatchFloatWindow (FloatWindow floatWindow) + { + // The suite shares the log window's Icon, whose handle is replaced on every tail refresh; + // the main form's icon lives as long as the process. + floatWindow.Icon = _mainForm.Icon; + + // A float is owned by the main form, which is hidden while detached: cut the ownership. + floatWindow.Owner = null; + floatWindow.FormBorderStyle = FormBorderStyle.Sizable; + floatWindow.MaximizeBox = true; + floatWindow.MinimizeBox = true; + floatWindow.ShowInTaskbar = true; + + // The suite's caption handler would start a dock drag; leave the caption to the native move. + floatWindow.AllowEndUserDocking = false; + + // The style changes above recreated the handle, so the dark title bar flag has to be set again. + ApplyDarkTitleBar(floatWindow); + } + + private static ChromeSnapshot CaptureChrome (FloatWindow floatWindow) + { + return new ChromeSnapshot( + floatWindow.Owner, + floatWindow.FormBorderStyle, + floatWindow.WindowState, + floatWindow.MaximizeBox, + floatWindow.MinimizeBox, + floatWindow.ShowInTaskbar, + floatWindow.AllowEndUserDocking); + } + + private static void RestoreChrome (FloatWindow floatWindow, ChromeSnapshot chrome) + { + floatWindow.Owner = chrome.Owner; + floatWindow.FormBorderStyle = chrome.FormBorderStyle; + floatWindow.MaximizeBox = chrome.MaximizeBox; + floatWindow.MinimizeBox = chrome.MinimizeBox; + floatWindow.ShowInTaskbar = chrome.ShowInTaskbar; + floatWindow.AllowEndUserDocking = chrome.AllowEndUserDocking; + + // WinForms normalizes a maximized window as soon as the maximize box goes away, so the + // state is applied last to bring back exactly what the suite had. + floatWindow.WindowState = chrome.WindowState; + + // Restoring the styles recreated the handle: reapply the dark flag, then force a repaint. + ApplyDarkTitleBar(floatWindow); + RepaintCaptionLater(floatWindow); + } + + private void RestorePatchedChrome (FloatWindow? floatWindow) + { + if (_originalChrome is not { } chrome) + { + return; + } + + if (floatWindow is not { IsDisposed: false }) + { + // The float is gone; there is nothing left to restore the chrome on. + _originalChrome = null; + return; + } + + RestoreChrome(floatWindow, chrome); + + // Cleared only after the restore succeeded, so a throw leaves the snapshot in place. + _originalChrome = null; + } + + private static void RepaintCaptionLater (FloatWindow floatWindow) + { + if (floatWindow.IsDisposed) + { + return; + } + + try + { + floatWindow.BeginInvoke(new Action(() => RepaintCaption(floatWindow))); + } + catch (InvalidOperationException) + { + } + } + + private static void RepaintCaption (FloatWindow floatWindow) + { + if (floatWindow.IsDisposed) + { + return; + } + + ApplyDarkTitleBar(floatWindow); + + if (floatWindow.WindowState != FormWindowState.Normal) + { + return; + } + + var bounds = floatWindow.Bounds; + + floatWindow.SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height + 1); + floatWindow.SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height); + } + + private static void ApplyDarkTitleBar (FloatWindow floatWindow) + { + if (!Application.IsDarkModeEnabled || floatWindow is not { IsHandleCreated: true, IsDisposed: false }) + { + return; + } + + int value = 1; + _ = NativeMethods.DwmSetWindowAttribute(floatWindow.Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int)); + } + + private void ToggleMaximize (object? sender, EventArgs e) + { + if (_captionHook == null || _captionHook.FloatWindow.IsDisposed) + { + return; + } + + _captionHook.FloatWindow.WindowState = _captionHook.FloatWindow.WindowState == FormWindowState.Maximized + ? FormWindowState.Normal + : FormWindowState.Maximized; + } + + private void ReleaseFloatWindow (bool restoreChrome = true) + { + if (_captionHook is not { } hook) + { + return; + } + + _captionHook = null; + + try + { + hook.CaptionDoubleClicked -= ToggleMaximize; + hook.Dispose(); + } + finally + { + // A hook that fails to dispose must not leave the patched chrome behind. + if (restoreChrome) + { + RestorePatchedChrome(hook.FloatWindow); + } + } + } + + private void InstallFilter () + { + if (_filterInstalled) + { + return; + } + + Application.AddMessageFilter(_messageFilter); + _filterInstalled = true; + } + + private void UninstallFilter () + { + if (!_filterInstalled) + { + return; + } + + Application.RemoveMessageFilter(_messageFilter); + _filterInstalled = false; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "This is the last-resort path back to a visible window.")] + private void RestoreMainForm () + { + // Dispose() leaves the mode through Exit(); showing the form on that path would run while + // the form is being torn down. + if (_disposed || _mainForm.IsDisposed) + { + return; + } + + try + { + _mainForm.Show(); + _mainForm.Activate(); + } + catch (Exception) + { + // Nothing else can bring the form back; the caller is already on its fallback path. + } + } + + private FloatWindow? FindFloatWindow (LogWindow content) + { + foreach (FloatWindow floatWindow in _dockPanel.FloatWindows) + { + foreach (DockPane pane in floatWindow.NestedPanes) + { + if (pane.Contents.Contains(content)) + { + return floatWindow; + } + } + } + + return null; + } + + private static DockState ToRestorable (DockState state) + { + return state is DockState.Unknown or DockState.Hidden ? DockState.Document : state; + } + + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + public void Dispose () + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Exit() is the single way out: it releases the float, uninstalls the filter and brings the + // main form back. + Exit(); + } + + private readonly record struct PlacementSnapshot ( + DockState DockState, + DockPane? Pane, + int ContentIndex); + + private readonly record struct ChromeSnapshot ( + Form? Owner, + FormBorderStyle FormBorderStyle, + FormWindowState WindowState, + bool MaximizeBox, + bool MinimizeBox, + bool ShowInTaskbar, + bool AllowEndUserDocking); +} diff --git a/src/LogExpert.UI/Services/TabControllerService/TabController.cs b/src/LogExpert.UI/Services/TabControllerService/TabController.cs index ee514749..daf69467 100644 --- a/src/LogExpert.UI/Services/TabControllerService/TabController.cs +++ b/src/LogExpert.UI/Services/TabControllerService/TabController.cs @@ -264,51 +264,106 @@ public LogWindow GetActiveWindow () /// public void SwitchToNextWindow () { - lock (_windowsLock) + var next = GetNextWindow(); + + if (next != null) { - if (_windows.Count == 0) - { - return; - } + next.Activate(); + } + } + + /// + /// Switches to the previous window in the tab order (Ctrl+Shift+Tab behavior) + /// + public void SwitchToPreviousWindow () + { + var previous = GetPreviousWindow(); - var windows = _windows.Keys.ToList(); - var currentIndex = _activeWindow != null - ? windows.IndexOf(_activeWindow) - : -1; + if (previous != null) + { + previous.Activate(); + } + } - // Move forward, wrap around to beginning if at end - var nextIndex = (currentIndex + 1) % windows.Count; + /// + /// Resolves the window that follows in the tab order, without + /// activating it. + /// + /// + /// The window to move on from. Pass null to use the active window. + /// + /// The next window, or null when no window is tracked. + public LogWindow? GetNextWindow (LogWindow? current = null) + { + var windows = GetWindowsInTabOrder(); - windows[nextIndex].Activate(); + if (windows.Count == 0) + { + return null; } + + var anchor = current ?? _activeWindow; + var currentIndex = anchor != null + ? windows.IndexOf(anchor) + : -1; + + var nextIndex = (currentIndex + 1) % windows.Count; + + return windows[nextIndex]; } /// - /// Switches to the previous window in the tab order (Ctrl+Shift+Tab behavior) + /// Resolves the window that precedes in the tab order, without + /// activating it. /// - public void SwitchToPreviousWindow () + /// + /// The window to move back from. Pass null to use the active window. + /// + /// The previous window, or null when no window is tracked. + public LogWindow? GetPreviousWindow (LogWindow? current = null) { - lock (_windowsLock) + var windows = GetWindowsInTabOrder(); + + if (windows.Count == 0) { - if (_windows.Count == 0) - { - return; - } + return null; + } + + var anchor = current ?? _activeWindow; + + // Without an anchor the walk starts at the first window, so "previous" wraps to the last + // one ("next" without an anchor lands on the first one the same way). + var currentIndex = anchor != null + ? windows.IndexOf(anchor) + : 0; + + var previousIndex = currentIndex - 1; - var windows = _windows.Keys.ToList(); - var currentIndex = _activeWindow != null - ? windows.IndexOf(_activeWindow) - : 0; + if (previousIndex < 0) + { + previousIndex = windows.Count - 1; + } + + return windows[previousIndex]; + } + + private List GetWindowsInTabOrder () + { + var windows = GetAllWindowsFromDockPanel().ToList(); - // Move backward, wrap around to end if at beginning - var previousIndex = currentIndex - 1; - if (previousIndex < 0) + // Tracked windows the panel is not displaying right now stay reachable at the end. + lock (_windowsLock) + { + foreach (var window in _windows.Keys) { - previousIndex = windows.Count - 1; + if (!windows.Contains(window)) + { + windows.Add(window); + } } - - windows[previousIndex].Activate(); } + + return windows; } ///