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