diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 5e9f174d..44df9225 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2661,6 +2661,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_loadProjectToolStripMenuI
return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_loadProjectToolStripMenuItem", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Detach Mode.
+ ///
+ public static string LogTabWindow_UI_ToolStripMenuItem_logOnlyViewToolStripMenuItem {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_logOnlyViewToolStripMenuItem", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to Lock instance.
@@ -2960,6 +2969,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_ToolTip_lockInstanceToolS
return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_ToolTip_lockInstanceToolStripMenuItem", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Shows the active log alone in its own window and hides the main window.
+ ///
+ public static string LogTabWindow_UI_ToolStripMenuItem_ToolTip_logOnlyViewToolStripMenuItem {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_ToolTip_logOnlyViewToolStripMenuItem", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to Treat multiple files as one large file (e.g. data.log, data.log.1, data.log.2,...).
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 736bc8a4..a5db8d2e 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -1176,6 +1176,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools
Zu Tab kopieren
+
+ Detach-Modus
+
Berechne Zeitstreuungsanzeige...
@@ -1372,6 +1375,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 441e4b87..c5dd8c05 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -1218,6 +1218,9 @@ Checked tools will appear in the icon bar. All other tools are available in the
Copy to Tab
+
+ Detach Mode
+
Options
@@ -1383,6 +1386,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 58f62e03..8fb1eece 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1088,6 +1088,9 @@
复制到标签页
+
+ 分离模式
+
选项
@@ -1250,6 +1253,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..a3e332af
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
@@ -0,0 +1,567 @@
+using System.Runtime.Versioning;
+
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+using LogExpert.UI.Controls.LogWindow;
+using LogExpert.UI.Interface;
+using LogExpert.UI.Services.DetachModeService;
+using LogExpert.UI.Services.TabControllerService;
+
+using Moq;
+
+using NUnit.Framework;
+
+using WeifenLuo.WinFormsUI.Docking;
+
+namespace LogExpert.UI.Tests.Services;
+
+///
+/// Unit tests for DetachModeController. Every test drives a real DockPanel holding real LogWindows,
+/// because the behaviour under test is the dock-state bookkeeping and the float-window chrome the
+/// DockPanel suite performs.
+///
+[TestFixture]
+[SupportedOSPlatform("windows")]
+[Apartment(ApartmentState.STA)]
+[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "NUnit disposes the fields in TearDown.")]
+internal class DetachModeControllerTests
+{
+ private Form _form;
+ private DockPanel _dockPanel;
+ private TabController _tabController;
+ private DetachModeController _controller;
+
+ [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();
+ }
+
+ [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 — the suite's SetText() assigns the log window's icon object to the float, but
+ // the shell disposes and replaces that object on every tail refresh, which would leave the
+ // float holding a disposed icon and crash the next handle recreation.
+ 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 — no log windows at all
+
+ // 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;
+ _controller.Enter();
+
+ // Act
+ _controller.Exit();
+
+ // Assert — the window is back in the document pane, the others were never touched
+ Assert.Multiple(() =>
+ {
+ Assert.That(_controller.IsActive, Is.False);
+ Assert.That(_form.Visible, Is.True);
+ Assert.That(active.DockState, Is.EqualTo(DockState.Document));
+ Assert.That(windows.Count(window => window.DockState == DockState.Document), Is.EqualTo(windows.Count));
+ });
+ }
+
+ [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), "the float should be the active document");
+
+ _controller.Enter();
+
+ // Act
+ _controller.Exit();
+
+ // Assert — it came from a float, so it goes back to one
+ Assert.That(floating.DockState, Is.EqualTo(DockState.Float));
+ }
+
+ [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 — each switch detaches the next window and puts the previous one back
+ 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 OnWindowDisposed_LastWindow_EndsTheModeAndShowsTheMainForm ()
+ {
+ // Arrange
+ var windows = AddWindows("only.log");
+ var active = _dockPanel.ActiveContent as LogWindow;
+ _controller.Enter();
+
+ // Act — closing the detached window is deferred through BeginInvoke
+ 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 — closing the detached window ends detach mode and brings back the main form.
+ // Other log windows remain where they were.
+ 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 = 0x0100, // WM_KEYDOWN
+ WParam = (IntPtr)0x7A, // 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 — mode is off
+ var msg = new Message
+ {
+ Msg = 0x0100,
+ WParam = (IntPtr)0x7A, // 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 = 0x0100,
+ WParam = (IntPtr)0x7A, // 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 — ESC is not handled by the filter at all; the grid or text input handles it
+ AddWindows("first.log");
+ _controller.Enter();
+ var filter = CreateFilter();
+
+ var msg = new Message
+ {
+ Msg = 0x0100,
+ WParam = (IntPtr)0x1B, // VK_ESCAPE
+ LParam = 0
+ };
+
+ // Act
+ var swallowed = filter.PreFilterMessage(ref msg);
+
+ // Assert — ESC passes through untouched
+ Assert.Multiple(() =>
+ {
+ Assert.That(swallowed, Is.False);
+ Assert.That(_controller.IsActive, Is.True);
+ });
+ }
+
+ [Test]
+ public void PreFilterMessage_F11WhileAnotherFormIsActive_DoesNotLeaveTheMode ()
+ {
+ // Arrange — a modal dialog or any other window holds the focus
+ AddWindows("first.log");
+ _controller.Enter();
+ var filter = CreateFilter(activeFormGetter: () => _form);
+
+ var msg = new Message
+ {
+ Msg = 0x0100,
+ WParam = (IntPtr)0x7A, // 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 = 0x0100,
+ WParam = (IntPtr)0x09, // 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 = 0x0100,
+ WParam = (IntPtr)0x09, // 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 = 0x0100,
+ WParam = (IntPtr)0x41, // 'A' — not one of the detach mode keys
+ 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
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..91991388
--- /dev/null
+++ b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs
@@ -0,0 +1,91 @@
+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.
+///
+///
+/// Messages the hook does not intercept are forwarded to the procedure captured before
+/// .
+///
+[SupportedOSPlatform("windows")]
+internal sealed class DetachFloatWindowHook : NativeWindow, IDisposable
+{
+ private const int WM_NCLBUTTONDBLCLK = 0x00A3;
+
+ private readonly WeifenLuo.WinFormsUI.Docking.FloatWindow _floatWindow;
+ private readonly IntPtr _originalProc;
+ 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;
+
+ // Must be captured before AssignHandle replaces the procedure with this hook.
+ _originalProc = GetWindowProc(floatWindow.Handle);
+ 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 == WM_NCLBUTTONDBLCLK)
+ {
+ // Swallowed on purpose: both the suite and DefWindowProc act on a caption double click.
+ CaptionDoubleClicked?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
+ m.Result = User32.CallWindowProc(_originalProc, m.HWnd, (uint)m.Msg, m.WParam, m.LParam);
+ }
+
+ public void Dispose ()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // ReleaseHandle restores the window procedure that AssignHandle replaced.
+ if (Handle != IntPtr.Zero)
+ {
+ ReleaseHandle();
+ }
+
+ CaptionDoubleClicked = null;
+ }
+
+ private static IntPtr GetWindowProc (IntPtr handle)
+ {
+ return User32.GetWindowLongPtr(handle, User32.WindowLongFlags.GWL_WNDPROC);
+ }
+}
diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs
new file mode 100644
index 00000000..9b94dd12
--- /dev/null
+++ b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs
@@ -0,0 +1,67 @@
+using System.Runtime.Versioning;
+
+using LogExpert.UI.Extensions;
+
+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 const int VK_F11 = 0x7A;
+ private const int VK_TAB = 0x09;
+
+ private readonly DetachModeController _controller;
+ private readonly Func _modifierKeysGetter;
+ private readonly Func
public void SwitchToNextWindow ()
+ {
+ var next = GetNextWindow();
+
+ if (next != null)
+ {
+ next.Activate();
+ }
+ }
+
+ ///
+ /// Switches to the previous window in the tab order (Ctrl+Shift+Tab behavior)
+ ///
+ public void SwitchToPreviousWindow ()
+ {
+ var previous = GetPreviousWindow();
+
+ if (previous != null)
+ {
+ previous.Activate();
+ }
+ }
+
+ ///
+ /// Resolves the window that follows in the tracking order, without
+ /// activating it. Callers that display windows differently from the DockPanel need the target
+ /// window rather than an Activate().
+ ///
+ ///
+ /// 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)
{
lock (_windowsLock)
{
if (_windows.Count == 0)
{
- return;
+ return null;
}
var windows = _windows.Keys.ToList();
- var currentIndex = _activeWindow != null
- ? windows.IndexOf(_activeWindow)
+ var anchor = current ?? _activeWindow;
+ var currentIndex = anchor != null
+ ? windows.IndexOf(anchor)
: -1;
// Move forward, wrap around to beginning if at end
var nextIndex = (currentIndex + 1) % windows.Count;
- windows[nextIndex].Activate();
+ return windows[nextIndex];
}
}
///
- /// Switches to the previous window in the tab order (Ctrl+Shift+Tab behavior)
+ /// Resolves the window that precedes in the tracking 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)
{
if (_windows.Count == 0)
{
- return;
+ return null;
}
var windows = _windows.Keys.ToList();
- var currentIndex = _activeWindow != null
- ? windows.IndexOf(_activeWindow)
+ 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;
// Move backward, wrap around to end if at beginning
@@ -307,7 +349,7 @@ public void SwitchToPreviousWindow ()
previousIndex = windows.Count - 1;
}
- windows[previousIndex].Activate();
+ return windows[previousIndex];
}
}
@@ -458,6 +500,10 @@ protected virtual void Dispose (bool disposing)
/// Gets all LogWindow instances currently held by the DockPanel's panes, in the order their tab strips display
/// them. Panes of every dock state are enumerated, so floating windows are included.
///
+ ///
+ /// Only windows whose pane currently displays them are returned: a window hidden with
+ /// drops out of the enumeration even though its pane still exists.
+ ///
/// Read-only list of all LogWindows in the DockPanel
public IReadOnlyList GetAllWindowsFromDockPanel ()
{