From 3a7286932c500b1d807fb3128695925dbc2e1e03 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 23 Oct 2026 18:43:25 +0800
Subject: [PATCH 1/4] Replace compact mode with F11 detach mode (Log Only View)
F11 now detaches the active log window into its own top-level window and
hides the main window; F11 or ESC returns to the normal mode. The float
window gets min/max buttons, a resizable border, a taskbar button, its
own stable icon and a maximizable double-click-title behaviour; dark
title bars are re-applied after the style-driven handle recreation.
TabController gains GetPlacedWindows/GetNextWindow/GetPreviousWindow and
a guarded CloseAllExcept; the View menu offers the toggle as "Log Only
View" (en/de/zh-CN) with F11 as a display-only shortcut hint. DetachMode
service, message filter and a NativeWindow caption hook live in
DetachModeService; 17 controller tests plus 6 filter tests cover it.
---
src/LogExpert.Resources/Resources.Designer.cs | 18 +
src/LogExpert.Resources/Resources.de.resx | 6 +
src/LogExpert.Resources/Resources.resx | 6 +
src/LogExpert.Resources/Resources.zh-CN.resx | 6 +
.../Services/DetachModeControllerTests.cs | 542 ++++++++++++++++++
.../Services/TabControllerTests.cs | 117 ++++
.../Dialogs/LogTabWindow/LogTabWindow.cs | 100 +++-
.../LogTabWindow/LogTabWindow.designer.cs | 20 +-
src/LogExpert.UI/Interface/ITabController.cs | 45 +-
.../DetachFloatWindowHook.cs | 106 ++++
.../DetachModeService/DetachMessageFilter.cs | 75 +++
.../DetachModeService/DetachModeController.cs | 389 +++++++++++++
.../TabControllerService/TabController.cs | 100 +++-
13 files changed, 1502 insertions(+), 28 deletions(-)
create mode 100644 src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
create mode 100644 src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs
create mode 100644 src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs
create mode 100644 src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 6bf4905df..e7b3db0de 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2652,6 +2652,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_loadProjectToolStripMenuI
return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_loadProjectToolStripMenuItem", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Log Only View.
+ ///
+ 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.
@@ -2951,6 +2960,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 67ef82bf3..d0f937b79 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
+
+ Nur Log-Ansicht
+
Berechne Zeitstreuungsanzeige...
@@ -1369,6 +1372,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 bd9b3a535..b0ca8fa6c 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -1215,6 +1215,9 @@ Checked tools will appear in the icon bar. All other tools are available in the
Copy to Tab
+
+ Log Only View
+
Options
@@ -1380,6 +1383,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 9b875814b..a27d2f7e5 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1085,6 +1085,9 @@
复制到标签页
+
+ 仅日志视图
+
选项
@@ -1247,6 +1250,9 @@
将所有选中行复制到新标签页
+
+ 在独立窗口中仅显示当前日志,并隐藏主窗口
+
将各类日志文件拆分为固定列
diff --git a/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
new file mode 100644
index 000000000..0cb5444c7
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
@@ -0,0 +1,542 @@
+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)]
+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_WithOtherWindows_DetachesTheNextOne ()
+ {
+ // 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 — the surviving window takes the detached float over
+ var other = windows.Single(window => window != active);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_controller.IsActive, Is.True);
+ Assert.That(other.DockState, Is.EqualTo(DockState.Float));
+ Assert.That(_form.Visible, Is.False);
+ });
+ }
+
+ [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_EscWithTextInputFocused_PassesThroughAndStaysInMode ()
+ {
+ // Arrange — the log window's search box owns the focus
+ AddWindows("first.log");
+ _controller.Enter();
+ var filter = CreateFilter();
+ filter._isTextInputFocused = () => true;
+
+ var msg = new Message
+ {
+ Msg = 0x0100,
+ WParam = (IntPtr)0x1B, // VK_ESCAPE
+ LParam = 0
+ };
+
+ // Act
+ var swallowed = filter.PreFilterMessage(ref msg);
+
+ // Assert — ESC belongs to the text input, e.g. canceling a search
+ Assert.Multiple(() =>
+ {
+ Assert.That(swallowed, Is.False);
+ Assert.That(_controller.IsActive, Is.True);
+ });
+ }
+
+ [Test]
+ public void PreFilterMessage_EscWithoutTextInput_LeavesTheMode ()
+ {
+ // Arrange — no text input holds the focus
+ AddWindows("first.log");
+ _controller.Enter();
+ var filter = CreateFilter();
+ filter._isTextInputFocused = () => false;
+
+ var msg = new Message
+ {
+ Msg = 0x0100,
+ WParam = (IntPtr)0x1B, // VK_ESCAPE
+ LParam = 0
+ };
+
+ // Act
+ var swallowed = filter.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_CtrlTabWhileDetached_SwitchesToTheNextWindow ()
+ {
+ // Arrange
+ var windows = AddWindows("first.log", "second.log");
+ _controller.Enter(); // detaches the active window, the last added one
+ var filter = CreateFilter();
+ filter._modifierKeys = () => 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();
+ filter._modifierKeys = () => 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 () => new(_controller);
+
+ 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))
+ {
+ _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 667c694c3..c32d41959 100644
--- a/src/LogExpert.UI.Tests/Services/TabControllerTests.cs
+++ b/src/LogExpert.UI.Tests/Services/TabControllerTests.cs
@@ -236,6 +236,49 @@ public void GetAllWindows_WhenEmpty_ReturnsEmptyList ()
#endregion
+ #region GetPlacedWindows Tests
+
+ [Test]
+ public void GetPlacedWindows_ExcludesWindowsThatWereNeverPlaced ()
+ {
+ // Arrange
+ using var placed = CreateLogWindow("placed.log");
+ using var deferred = CreateLogWindow("deferred.log");
+
+ _tabController.AddWindow(placed, "placed.log");
+ _tabController.AddWindow(deferred, "deferred.log", doNotAddToDockPanel: true);
+
+ // Act
+ var result = _tabController.GetPlacedWindows();
+
+ // Assert — a window a session layout restore has not placed yet cannot be saved or closed
+ Assert.That(result, Is.EqualTo([placed]));
+ }
+
+ [Test]
+ public void GetPlacedWindows_IncludesHiddenWindowsThatTheDockPanelNoLongerDisplays ()
+ {
+ // Arrange
+ using var first = CreateLogWindow("first.log");
+ using var second = CreateLogWindow("second.log");
+
+ _tabController.AddWindow(first, "first.log");
+ _tabController.AddWindow(second, "second.log");
+
+ second.Hide();
+
+ // Act
+ var placed = _tabController.GetPlacedWindows();
+ var displayed = _tabController.GetAllWindowsFromDockPanel();
+
+ // Assert — this is why a session save and "close other tabs" cannot use the DockPanel query
+ // when windows are hidden
+ Assert.That(placed, Is.EqualTo([first, second]));
+ Assert.That(displayed, Is.EqualTo([first]));
+ }
+
+ #endregion
+
#region GetWindowCount Tests
[Test]
@@ -413,6 +456,75 @@ 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_WhenEmpty_ReturnsNull ()
+ {
+ // Act & Assert
+ Assert.That(_tabController.GetNextWindow(null), Is.Null);
+ Assert.That(_tabController.GetPreviousWindow(null), Is.Null);
+ }
+
+ #endregion
+
+ #region CloseAllExcept Tests
+
+ [Test]
+ public void CloseAllExcept_WithNullWindow_LeavesEveryWindowOpen ()
+ {
+ // Arrange
+ using var first = CreateLogWindow("first.log");
+ using var second = CreateLogWindow("second.log");
+
+ _tabController.AddWindow(first, "first.log");
+ _tabController.AddWindow(second, "second.log");
+
+ // Act
+ _tabController.CloseAllExcept(null);
+
+ // Assert
+ Assert.That(_tabController.GetWindowCount(), Is.EqualTo(2));
+ }
+
+ #endregion
+
#region SwitchToNextWindow Tests
[Test]
@@ -450,6 +562,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 5ec992ae2..034bcf327 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;
@@ -80,6 +81,12 @@ internal partial class LogTabWindow : Form, ILogTabWindow
private bool _wasMaximized;
+ ///
+ /// Owns detach mode: F11 hides the main window and turns the active log window's float into a
+ /// stand-alone viewer window; F11 or ESC brings everything back.
+ ///
+ private readonly DetachModeController _detachModeController;
+
#endregion
#region cTor
@@ -107,6 +114,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;
@@ -461,6 +470,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;
+ logOnlyViewToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_logOnlyViewToolStripMenuItem;
//Options menu
optionToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_optionToolStripMenuItem;
@@ -517,6 +527,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;
+ logOnlyViewToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_logOnlyViewToolStripMenuItem;
columnizerToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_columnizerToolStripMenuItem;
cellSelectModeToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_cellSelectModeToolStripMenuItem;
lockInstanceToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_lockInstanceToolStripMenuItem;
@@ -836,6 +847,13 @@ private void AddLogWindow (LogWindow.LogWindow logWindow, string title, bool doN
if (!doNotAddToPanel)
{
logWindow.Visible = true;
+
+ if (_detachModeController.IsActive)
+ {
+ // While detached the main window is hidden, so the newly opened window is the one
+ // the user expects to see: it replaces the detached one.
+ _detachModeController.OnWindowAdded(logWindow);
+ }
}
}
@@ -1475,6 +1493,10 @@ 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)
{
+ // Leave detach mode first: the loaded layout must describe the normal window, and the
+ // windows a session replaces must not be referenced by the mode.
+ _detachModeController.Exit();
+
var outcome = _sessionHandler.LoadSession(sessionFileName);
bool openedTabs = false;
@@ -1751,6 +1773,18 @@ private void OnLogTabWindowLoad (object sender, EventArgs e)
private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e)
{
+ // Leave detach mode first: the saved window position and layout must describe the normal
+ // window. Guarded on purpose — the panel is already tearing down here, so a failure must not
+ // abort the shutdown sequence below.
+ try
+ {
+ _detachModeController.Exit();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Leaving detach mode while closing failed");
+ }
+
CancelPendingDrop();
try
{
@@ -1793,6 +1827,10 @@ private void OnLogWindowDisposed (object sender, EventArgs e)
RemoveLogWindow(logWindow);
logWindow.Tag = null;
+
+ // Closing the detached window through its X button hands the detached float to the next
+ // window, or ends the mode when it was the last one.
+ _detachModeController.OnWindowDisposed(logWindow);
}
private void OnExitToolStripMenuItemClick (object sender, EventArgs e)
@@ -2063,6 +2101,58 @@ 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 its key handling is served by 's
+ /// message filter instead; the mode is left from there. The View menu offers the same toggle
+ /// as "Log Only View"; it only displays F11 as a hint, the key itself is taken here so there
+ /// is a single code path.
+ ///
+ protected override bool ProcessCmdKey (ref Message msg, Keys keyData)
+ {
+ if (keyData == Keys.F11)
+ {
+ // Only the key-down of the first press counts: Windows repeats WM_KEYDOWN while F11 is
+ // held down (lParam bit 30), and honouring those repeats would flip the mode back and
+ // forth. A second press after release leaves the mode again.
+ if (!IsAutoRepeat(msg))
+ {
+ ToggleDetachMode();
+ }
+
+ return true;
+ }
+
+ return base.ProcessCmdKey(ref msg, keyData);
+ }
+
+ ///
+ /// Tells the repeated WM_KEYDOWN messages Windows sends while a key is held down (lParam bit 30)
+ /// from the single message of an actual press.
+ ///
+ private static bool IsAutoRepeat (Message msg)
+ {
+ const int WM_KEYDOWN = 0x0100;
+
+ return msg.Msg == WM_KEYDOWN && ((long)msg.LParam & (1L << 30)) != 0;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void ToggleDetachMode ()
+ {
+ if (!_detachModeController.IsActive && WindowState == FormWindowState.Minimized)
+ {
+ WindowState = FormWindowState.Normal;
+ }
+
+ _detachModeController.Enter();
+ }
+
+ private void OnLogOnlyViewToolStripMenuItemClick (object sender, EventArgs e)
+ {
+ ToggleDetachMode();
+ }
+
private void OnLogTabWindowKeyDown (object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.W && e.Control)
@@ -2372,14 +2462,14 @@ private void OnCloseThisTabToolStripMenuItemClick (object sender, EventArgs e)
private void OnCloseOtherTabsToolStripMenuItemClick (object sender, EventArgs e)
{
var activeWindow = _tabController.GetActiveWindow();
- var closeList = _tabController.GetAllWindowsFromDockPanel()
- .Where(window => window != activeWindow)
- .ToList();
- foreach (var logWindow in closeList)
+ // Nothing to keep: without an active window there is no "other" tab to close.
+ if (activeWindow == null)
{
- logWindow.Close();
+ return;
}
+
+ _tabController.CloseAllExcept(activeWindow);
}
[SupportedOSPlatform("windows")]
diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs
index 9da5eea6f..77ec16aa1 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();
+ logOnlyViewToolStripMenuItem = 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, logOnlyViewToolStripMenuItem });
viewNavigateToolStripMenuItem.Name = "viewNavigateToolStripMenuItem";
viewNavigateToolStripMenuItem.Size = new Size(96, 19);
viewNavigateToolStripMenuItem.Text = "View/Navigate";
@@ -471,6 +473,20 @@ private void InitializeComponent ()
copyMarkedLinesIntoNewTabToolStripMenuItem.Text = "Copy to Tab";
copyMarkedLinesIntoNewTabToolStripMenuItem.ToolTipText = "Copies all selected lines into a new tab page";
copyMarkedLinesIntoNewTabToolStripMenuItem.Click += OnCopyMarkedLinesIntoNewTabToolStripMenuItemClick;
+ //
+ // ToolStripSeparator12
+ //
+ ToolStripSeparator12.Name = "ToolStripSeparator12";
+ ToolStripSeparator12.Size = new Size(174, 6);
+ //
+ // logOnlyViewToolStripMenuItem
+ //
+ logOnlyViewToolStripMenuItem.Name = "logOnlyViewToolStripMenuItem";
+ logOnlyViewToolStripMenuItem.ShortcutKeyDisplayString = "F11";
+ logOnlyViewToolStripMenuItem.Size = new Size(177, 22);
+ logOnlyViewToolStripMenuItem.Text = "Log Only View";
+ logOnlyViewToolStripMenuItem.ToolTipText = "Shows the active log alone in its own window and hides the main window";
+ logOnlyViewToolStripMenuItem.Click += OnLogOnlyViewToolStripMenuItemClick;
//
// optionToolStripMenuItem
//
@@ -1142,6 +1158,8 @@ private void InitializeComponent ()
private ToolStripMenuItem columnFinderToolStripMenuItem;
private DockPanel dockPanel;
private ToolStripMenuItem tabRenameToolStripMenuItem;
+ private ToolStripMenuItem logOnlyViewToolStripMenuItem;
+ private ToolStripSeparator ToolStripSeparator12;
private ToolStripSeparator lineToolStripSeparatorExtension1;
private ToolStripSeparator lineToolStripSeparatorExtension2;
private ToolStripSeparator ToolStripSeparator1;
diff --git a/src/LogExpert.UI/Interface/ITabController.cs b/src/LogExpert.UI/Interface/ITabController.cs
index 496b8749b..05b9491f2 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.
///
@@ -78,6 +98,18 @@ internal interface ITabController : IDisposable
/// A read-only list of all tracked LogWindows.
IReadOnlyList GetAllWindows ();
+ ///
+ /// Gets all tracked LogWindows that are (or were) placed in the dock panel, in tracking order.
+ /// Windows a session layout restore has not placed yet () are
+ /// excluded. Hidden windows are included.
+ ///
+ ///
+ /// This is the query for operations that must cover every window an ongoing session should
+ /// keep — saving a session, closing every other tab — even when a window is currently hidden.
+ ///
+ /// A read-only list of the placed LogWindows.
+ IReadOnlyList GetPlacedWindows ();
+
///
/// Gets all LogWindow instances from the DockPanel's Contents collection.
/// This returns windows that are currently displayed in the DockPanel,
@@ -85,17 +117,10 @@ internal interface ITabController : IDisposable
/// (e.g., windows restored from layout serialization).
///
///
- /// Use this method when you need to iterate over all visible LogWindows,
- /// particularly for operations like:
- /// - Saving project/session data
- /// - Saving last open files list
- /// - Closing all tabs
- /// - Applying settings to all windows
- ///
- /// For most other operations, prefer which
- /// returns only explicitly tracked windows.
+ /// Only displayed windows are returned: a window hidden with drops
+ /// out of the result. Use when hidden windows must be included.
///
- /// Read-only list of all LogWindows in the DockPanel.
+ /// Read-only list of all displayed LogWindows in the DockPanel.
IReadOnlyList GetAllWindowsFromDockPanel ();
///
diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs
new file mode 100644
index 000000000..5e858dd45
--- /dev/null
+++ b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs
@@ -0,0 +1,106 @@
+using System.Runtime.Versioning;
+
+namespace LogExpert.UI.Services.DetachModeService;
+
+///
+/// Win32 subclass for a detached . The DockPanel
+/// suite handles a caption double click by docking the window back into the panel (and its public
+/// DoubleClickTitleBarToDock switch does not disable that), so the message has to be caught
+/// before it reaches the suite: this hook turns a caption double click into a maximize/restore
+/// toggle instead.
+///
+///
+/// Subclassing replaces the window procedure, so every message the hook does not intercept is
+/// forwarded to the procedure captured before - that is the
+/// WinForms procedure which finally reaches the suite's FloatWindow.WndProc. Messages
+/// swallowed here never arrive in the suite.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class DetachFloatWindowHook : NativeWindow, IDisposable
+{
+ private const int GWL_WNDPROC = -4;
+ 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 only after every
+ /// style-changing property (border style, taskbar flag) has been applied: those recreate the
+ /// window handle, and the hook would be detached by the recreation.
+ ///
+ 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 (dock back) and plain DefWindowProc (maximize)
+ // have a fixed idea of what a caption double click means. The controller decides.
+ CaptionDoubleClicked?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
+ m.Result = 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 IntPtr.Size == 8
+ ? GetWindowLongPtr64(handle, GWL_WNDPROC)
+ : new IntPtr(GetWindowLong32(handle, GWL_WNDPROC));
+ }
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
+ private static extern IntPtr GetWindowLongPtr64 (IntPtr hWnd, int nIndex);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongW")]
+ private static extern int GetWindowLong32 (IntPtr hWnd, int nIndex);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern IntPtr CallWindowProc (IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+}
diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs
new file mode 100644
index 000000000..5b915e523
--- /dev/null
+++ b/src/LogExpert.UI/Services/DetachModeService/DetachMessageFilter.cs
@@ -0,0 +1,75 @@
+using System.Runtime.Versioning;
+
+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 and ESC leave 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 WM_KEYDOWN = 0x0100;
+ private const int VK_F11 = 0x7A;
+ private const int VK_ESCAPE = 0x1B;
+ private const int VK_TAB = 0x09;
+
+ /// The lParam bit Windows sets on every WM_KEYDOWN but the first while a key is held.
+ private const long AUTOREPEAT_FLAG = 1L << 30;
+
+ private readonly DetachModeController _controller;
+
+ // Test seams: both read live machine state a unit test cannot control (the keyboard modifiers
+ // and the input focus), so their source is injectable. Production always uses the defaults.
+
+ internal Func _modifierKeys = static () => Control.ModifierKeys;
+
+ // The detached float window is the active form while the mode lasts, so its active control is
+ // where the keyboard is. Null when another window holds the focus.
+ internal Func _isTextInputFocused = static () => Form.ActiveForm?.ActiveControl is TextBoxBase;
+
+ public DetachMessageFilter (DetachModeController controller)
+ {
+ _controller = controller;
+ }
+
+ public bool PreFilterMessage (ref Message m)
+ {
+ if (m.Msg != WM_KEYDOWN || !_controller.IsActive)
+ {
+ return false;
+ }
+
+ switch (m.WParam.ToInt32())
+ {
+ case VK_F11:
+ // Block the whole burst: acting on auto-repeat would flip the mode on every repeat.
+ if ((m.LParam.ToInt64() & AUTOREPEAT_FLAG) == 0)
+ {
+ _controller.Exit();
+ }
+
+ return true;
+
+ case VK_ESCAPE:
+ // The log window uses ESC to cancel a search or clear search highlighting, so leave
+ // the mode only when the focus is not in a text input.
+ if (_isTextInputFocused())
+ {
+ return false;
+ }
+
+ _controller.Exit();
+ return true;
+
+ case VK_TAB when _modifierKeys().HasFlag(Keys.Control):
+ _controller.SwitchWindow(_modifierKeys().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 000000000..47dede288
--- /dev/null
+++ b/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs
@@ -0,0 +1,389 @@
+using System.Runtime.Versioning;
+
+using LogExpert.UI.Controls.LogWindow;
+using LogExpert.UI.Interface;
+
+using WeifenLuo.WinFormsUI.Docking;
+
+namespace LogExpert.UI.Services.DetachModeService;
+
+///
+/// Owns detach mode: F11 hides the main window and turns the active log window's float into a
+/// stand-alone viewer window; leaving the mode puts the window back where it was and brings the
+/// main window back.
+///
+///
+/// The detached window stays a regular DockPanel float — no content is moved out of the suite — so
+/// layout, session persistence and the close button keep working untouched. Three things are added
+/// on top while the mode lasts: the float's owner link to the main form is cut (a hidden owner would
+/// hide the float with it, a closing one would take it down), the tool-window chrome is replaced by
+/// a normal border with minimize/maximize buttons and a taskbar button, and a caption double click
+/// maximizes/restores instead of docking the window back into the panel.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class DetachModeController : IDisposable
+{
+ private readonly Form _mainForm;
+ private readonly DockPanel _dockPanel;
+ private readonly ITabController _tabController;
+ private readonly DetachMessageFilter _messageFilter;
+
+ private DetachFloatWindowHook _captionHook;
+ private bool _filterInstalled;
+ private bool _disposed;
+
+ /// The window currently detached, or null while the mode is off.
+ private LogWindow _detachedWindow;
+
+ ///
+ /// The dock state the detached window came from. Only valid while the mode is on; a window
+ /// switched away from is put back into this state before the next one is detached, so a single
+ /// field is enough to restore every window on exit.
+ ///
+ private DockState _originalDockState = DockState.Unknown;
+
+ public DetachModeController (Form mainForm, DockPanel dockPanel, ITabController tabController)
+ {
+ ArgumentNullException.ThrowIfNull(mainForm);
+ ArgumentNullException.ThrowIfNull(dockPanel);
+ ArgumentNullException.ThrowIfNull(tabController);
+
+ _mainForm = mainForm;
+ _dockPanel = dockPanel;
+ _tabController = tabController;
+ _messageFilter = new DetachMessageFilter(this);
+ }
+
+ /// True while a log window is detached from the main window.
+ public bool IsActive => _detachedWindow != null;
+
+ ///
+ /// Detaches the active log window and hides the main window. Does nothing when no log window is
+ /// open; returns whether the mode was entered.
+ ///
+ public bool Enter ()
+ {
+ if (IsActive)
+ {
+ return true;
+ }
+
+ // The DockPanel's own active content is what is really on screen; the shell's current window
+ // can lag a step behind it during activation.
+ var active = _dockPanel.ActiveContent as LogWindow ?? _tabController.GetActiveWindow();
+
+ if (active == null || active.IsDisposed)
+ {
+ return false;
+ }
+
+ Detach(active);
+ InstallFilter();
+ _mainForm.Hide();
+ return true;
+ }
+
+ ///
+ /// Ends the mode: the detached window goes back to the dock state it came from and the main
+ /// window becomes visible again. Safe to call when the detached window is already gone.
+ ///
+ public void Exit ()
+ {
+ if (!IsActive)
+ {
+ return;
+ }
+
+ var window = _detachedWindow;
+ var originalDockState = _originalDockState;
+
+ _detachedWindow = null;
+ _originalDockState = DockState.Unknown;
+ ReleaseFloatWindow();
+ UninstallFilter();
+
+ if (window is { IsDisposed: false } && originalDockState != DockState.Float)
+ {
+ window.Show(_dockPanel, ToRestorable(originalDockState));
+ }
+
+ RestoreMainForm();
+ }
+
+ ///
+ /// Ctrl+Tab while detached: the tab strip is gone with the main window, so switching means
+ /// replacing which window the detached float shows. The window that was detached returns to its
+ /// original dock state (inside the hidden main window) and keeps it for the final restore.
+ ///
+ /// True to move backwards in the tab order.
+ public void SwitchWindow (bool shiftPressed)
+ {
+ if (!IsActive)
+ {
+ return;
+ }
+
+ var target = shiftPressed
+ ? _tabController.GetPreviousWindow(_detachedWindow)
+ : _tabController.GetNextWindow(_detachedWindow);
+
+ if (target == null || target == _detachedWindow)
+ {
+ return;
+ }
+
+ if (_detachedWindow is { IsDisposed: false } && _originalDockState != DockState.Float)
+ {
+ _detachedWindow.Show(_dockPanel, ToRestorable(_originalDockState));
+ }
+
+ Detach(target);
+ }
+
+ ///
+ /// A window opened while detached (file drop, command line, session) becomes the detached one;
+ /// the DockPanel has already activated it.
+ ///
+ /// The window that was just added.
+ public void OnWindowAdded (LogWindow window)
+ {
+ if (!IsActive || window == null || window.IsDisposed)
+ {
+ return;
+ }
+
+ if (_detachedWindow is { IsDisposed: false } && _originalDockState != DockState.Float)
+ {
+ _detachedWindow.Show(_dockPanel, ToRestorable(_originalDockState));
+ }
+
+ Detach(window);
+ }
+
+ ///
+ /// Reacts to the detached window being closed through the float's X button: another window takes
+ /// its place, or — when it was the last one — the mode ends and the main window comes back.
+ ///
+ /// The window being disposed.
+ public void OnWindowDisposed (LogWindow window)
+ {
+ if (!IsActive || window != _detachedWindow)
+ {
+ return;
+ }
+
+ _detachedWindow = null;
+ _originalDockState = DockState.Unknown;
+ ReleaseFloatWindow();
+
+ var next = _tabController.GetPlacedWindows()
+ .FirstOrDefault(candidate => candidate != window && !candidate.IsDisposed);
+
+ if (next == null)
+ {
+ UninstallFilter();
+ RestoreMainForm();
+ return;
+ }
+
+ // The DockPanel is still tearing the closed window down here; detaching the next window
+ // would re-enter that teardown.
+ try
+ {
+ _mainForm.BeginInvoke(new Action(() =>
+ {
+ if (!IsActive && !next.IsDisposed)
+ {
+ Detach(next);
+ }
+ }));
+ }
+ catch (InvalidOperationException)
+ {
+ // The main window lost its handle while closing; the shutdown sequence owns the rest.
+ }
+ }
+
+ ///
+ /// Turns into the detached one: floats it if needed, upgrades its
+ /// float window to a normal window and hooks the caption double click.
+ ///
+ private void Detach (LogWindow window)
+ {
+ ReleaseFloatWindow();
+
+ // Captured before Show(): afterwards the window reports Float.
+ _originalDockState = window.DockState;
+
+ if (window.DockState != DockState.Float)
+ {
+ window.Show(_dockPanel, DockState.Float);
+ }
+
+ var floatWindow = FindFloatWindow(window);
+
+ if (floatWindow == null)
+ {
+ // The suite refused to float the window (should not happen for a log window); leaving
+ // _detachedWindow unset keeps the mode off instead of a half-entered state.
+ _originalDockState = DockState.Unknown;
+ return;
+ }
+
+ PatchFloatWindow(floatWindow);
+ _captionHook = DetachFloatWindowHook.Attach(floatWindow);
+ _captionHook.CaptionDoubleClicked += ToggleMaximize;
+ _detachedWindow = window;
+ window.Activate();
+ }
+
+ ///
+ /// Replaces the tool-window chrome with a normal window chrome. All style-affecting properties
+ /// are set before the caption hook is attached: changing them recreates the window handle.
+ ///
+ private void PatchFloatWindow (FloatWindow floatWindow)
+ {
+ // The suite's SetText() makes the float share the detached log window's Icon object, and
+ // the shell replaces that object on every tail refresh while disposing the old one - the
+ // float then holds a disposed icon, and the handle recreation from the style changes below
+ // reads it and throws. The main form's icon lives as long as the process, so use it.
+ floatWindow.Icon = _mainForm.Icon;
+
+ // A float is an owned window of the main form: a hidden owner hides it, a closing owner
+ // closes it. Detached mode hides the main window, so the ownership is cut for the time
+ // being. The suite recreates ownership on the next float anyway.
+ floatWindow.Owner = null;
+ floatWindow.FormBorderStyle = FormBorderStyle.Sizable;
+ floatWindow.MaximizeBox = true;
+ floatWindow.MinimizeBox = true;
+ floatWindow.ShowInTaskbar = true;
+
+ // The suite's caption left-button handler would start a dock drag; with end-user docking
+ // off the caption falls through to the native move-drag and the double click arrives at the
+ // hook as a normal window's caption message.
+ floatWindow.AllowEndUserDocking = false;
+
+ // .NET 10 applies the dark title bar once, when the window first becomes visible
+ // (Control.SetVisibleCore). The style changes above recreated the window handle and the
+ // DWM flag died with the old one, so it has to be set again here (newer WinForms builds
+ // restore it themselves on handle recreation).
+ if (Application.IsDarkModeEnabled && floatWindow.IsHandleCreated)
+ {
+ SetImmersiveDarkTitleBar(floatWindow.Handle, dark: true);
+ }
+ }
+
+ 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 ()
+ {
+ if (_captionHook == null)
+ {
+ return;
+ }
+
+ _captionHook.CaptionDoubleClicked -= ToggleMaximize;
+ _captionHook.Dispose();
+ _captionHook = null;
+ }
+
+ private void InstallFilter ()
+ {
+ if (_filterInstalled)
+ {
+ return;
+ }
+
+ Application.AddMessageFilter(_messageFilter);
+ _filterInstalled = true;
+ }
+
+ private void UninstallFilter ()
+ {
+ if (!_filterInstalled)
+ {
+ return;
+ }
+
+ Application.RemoveMessageFilter(_messageFilter);
+ _filterInstalled = false;
+ }
+
+ private void RestoreMainForm ()
+ {
+ _mainForm.Show();
+ _mainForm.Activate();
+ }
+
+ 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;
+ }
+
+ ///
+ /// Maps a recorded dock state to one
+ /// accepts. A window recorded while hidden or not yet placed cannot come back that way and lands
+ /// in the document area instead.
+ ///
+ 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;
+
+ private static void SetImmersiveDarkTitleBar (IntPtr handle, bool dark)
+ {
+ int value = dark ? 1 : 0;
+ _ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
+ }
+
+ [System.Runtime.InteropServices.DllImport("dwmapi.dll")]
+ private static extern int DwmSetWindowAttribute (IntPtr hwnd, int attribute, ref int value, int size);
+
+ public void Dispose ()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (IsActive)
+ {
+ try
+ {
+ Exit();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Shutdown races: the panel or the window is already gone, nothing left to restore.
+ }
+ }
+
+ ReleaseFloatWindow();
+ UninstallFilter();
+ }
+}
diff --git a/src/LogExpert.UI/Services/TabControllerService/TabController.cs b/src/LogExpert.UI/Services/TabControllerService/TabController.cs
index ee5147499..bd1cc8bd3 100644
--- a/src/LogExpert.UI/Services/TabControllerService/TabController.cs
+++ b/src/LogExpert.UI/Services/TabControllerService/TabController.cs
@@ -208,12 +208,18 @@ public void CloseAllWindows ()
}
///
- /// Closes all windows except the specified one
+ /// Closes every placed window except the specified one. Windows that are not placed in the
+ /// dock panel yet (see ) are left alone.
///
/// Window to keep open
public void CloseAllExcept (LogWindow window)
{
- var windowsToClose = GetAllWindows()
+ if (window == null)
+ {
+ return;
+ }
+
+ var windowsToClose = GetPlacedWindows()
.Where(w => w != window)
.ToList();
@@ -263,41 +269,83 @@ public LogWindow GetActiveWindow ()
/// Switches to the next window in the tab order (Ctrl+Tab behavior)
///
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 +355,7 @@ public void SwitchToPreviousWindow ()
previousIndex = windows.Count - 1;
}
- windows[previousIndex].Activate();
+ return windows[previousIndex];
}
}
@@ -366,6 +414,28 @@ public IReadOnlyList GetAllWindows ()
}
}
+ ///
+ /// Gets all tracked windows that are (or were) placed in the dock panel, in tracking order.
+ /// A window that a session layout restore has not placed yet reports
+ /// and is skipped: it cannot be written to a layout, and closing
+ /// it would abort the restore.
+ ///
+ ///
+ /// Unlike this also returns windows that are currently
+ /// hidden, which is what a session save and "close other tabs" need when any window is hidden.
+ ///
+ /// Read-only list of the placed LogWindows.
+ public IReadOnlyList GetPlacedWindows ()
+ {
+ lock (_windowsLock)
+ {
+ return _windows.Keys
+ .Where(window => window.DockState != DockState.Unknown)
+ .ToList()
+ .AsReadOnly();
+ }
+ }
+
///
/// Gets the count of tracked windows
///
@@ -458,6 +528,12 @@ 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.
+ /// Use when hidden windows have to be included, as in a session
+ /// save or "close other tabs".
+ ///
/// Read-only list of all LogWindows in the DockPanel
public IReadOnlyList GetAllWindowsFromDockPanel ()
{
From e78c47f14857a9d8e3e5586b70afbcd415a39794 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Sat, 24 Oct 2026 12:43:19 +0800
Subject: [PATCH 2/4] Add F11 Detach Mode and address review feedback
Adds F11 detach mode: F11 hides the main window and keeps the active log
window floating as a stand-alone viewer. F11 brings the main window back and
restores the window to its original pane and tab index.
Behaviour:
- F11 enters and leaves the mode. ESC is not part of detach mode and keeps its
normal behaviour (cancelling a search on the grid, editing in text boxes,
dismissing dialogs).
- Ctrl+Tab / Ctrl+Shift+Tab cycle through log windows while detached.
- Opening a file while detached replaces the detached window.
- Closing the detached float (X) ends the mode and returns to the main window.
- A window that was already floating keeps its original chrome on exit; a
docked window is restored to its original pane and tab position.
- The keyboard filter only reacts while the detached float is the active form,
so modal dialogs keep F11 and Ctrl+Tab for themselves.
Review follow-up:
- Removed the ESC exit path and the old text-input guard; the mode is left
with F11 only.
- Restore the original dock pane and content index instead of docking to
DockState.Document.
- Restore the patched chrome for windows that were already floating on entry.
- Make DetachModeController IDisposable and dispose it next to TabController;
the message filter is removed in Dispose as well.
- Revert the "Close other tabs" change: it was not part of this feature and is
now identical to Development.
- Clear the new analyzer warnings: remove the unused GetFocus/SetFocus
P/Invokes, add DefaultDllImportSearchPaths for the DWM call, and suppress
CA1303, CA1031 and CA1001 with justifications.
- Share the WM_KEYDOWN and autorepeat constants through NativeMethods and keep
a single restore-dock-state block.
- Rename ToggleDetachMode to EnterDetachMode, trim comments to the non-obvious
"why", and use the concept name "Detach Mode" in code, designer text and all
three resource files.
- Use constructor injection for the keyboard filter test seams.
Tests: 9 controller, 8 filter and 3 TabController tests. Full
LogExpert.UI.Tests suite: 391 passed, 0 failed.
Note: the previous description said "Replace compact mode", but Development has
no compact mode; this change adds F11 Detach Mode. The earlier test count and
the Ctrl+Tab, file-replacement and X-button behaviour were missing from the
description and are documented above.
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com>
---
src/LogExpert.Resources/Resources.Designer.cs | 2 +-
src/LogExpert.Resources/Resources.de.resx | 2 +-
src/LogExpert.Resources/Resources.resx | 2 +-
src/LogExpert.Resources/Resources.zh-CN.resx | 2 +-
.../Services/DetachModeControllerTests.cs | 43 ++--
.../Services/TabControllerTests.cs | 64 ------
.../Dialogs/LogTabWindow/LogTabWindow.cs | 48 ++--
.../LogTabWindow/LogTabWindow.designer.cs | 12 +-
src/LogExpert.UI/Extensions/NativeMethods.cs | 18 ++
src/LogExpert.UI/Interface/ITabController.cs | 25 +-
.../DetachFloatWindowHook.cs | 41 ++--
.../DetachModeService/DetachMessageFilter.cs | 58 ++---
.../DetachModeService/DetachModeController.cs | 217 ++++++++----------
.../TabControllerService/TabController.cs | 34 +--
14 files changed, 212 insertions(+), 356 deletions(-)
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index e46382a0d..44df92258 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2663,7 +2663,7 @@ public static string LogTabWindow_UI_ToolStripMenuItem_loadProjectToolStripMenuI
}
///
- /// Looks up a localized string similar to Log Only View.
+ /// Looks up a localized string similar to Detach Mode.
///
public static string LogTabWindow_UI_ToolStripMenuItem_logOnlyViewToolStripMenuItem {
get {
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 8bf8e30dd..a5db8d2e8 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -1177,7 +1177,7 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools
Zu Tab kopieren
- Nur Log-Ansicht
+ Detach-Modus
Berechne Zeitstreuungsanzeige...
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 7a33a3dae..c5dd8c054 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -1219,7 +1219,7 @@ Checked tools will appear in the icon bar. All other tools are available in the
Copy to Tab
- Log Only View
+ Detach Mode
Options
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 964f5f26d..8fb1eece7 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1089,7 +1089,7 @@
复制到标签页
- 仅日志视图
+ 分离模式
选项
diff --git a/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
index 0cb5444c7..0cafa009b 100644
--- a/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
+++ b/src/LogExpert.UI.Tests/Services/DetachModeControllerTests.cs
@@ -24,6 +24,7 @@ namespace LogExpert.UI.Tests.Services;
[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;
@@ -226,7 +227,7 @@ public void OnWindowDisposed_LastWindow_EndsTheModeAndShowsTheMainForm ()
}
[Test]
- public void OnWindowDisposed_WithOtherWindows_DetachesTheNextOne ()
+ public void OnWindowDisposed_WithOtherWindows_EndsTheModeAndRestoresMainForm ()
{
// Arrange
var windows = AddWindows("first.log", "second.log");
@@ -241,14 +242,15 @@ public void OnWindowDisposed_WithOtherWindows_DetachesTheNextOne ()
_controller.OnWindowDisposed(active);
Application.DoEvents();
- // Assert — the surviving window takes the detached float over
+ // 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.True);
- Assert.That(other.DockState, Is.EqualTo(DockState.Float));
- Assert.That(_form.Visible, Is.False);
+ Assert.That(_controller.IsActive, Is.False);
+ Assert.That(_form.Visible, Is.True);
+ Assert.That(other.DockState, Is.EqualTo(DockState.Document));
});
}
@@ -349,13 +351,12 @@ public void PreFilterMessage_F11AutoRepeat_IsSwallowedWithoutLeavingTheMode ()
}
[Test]
- public void PreFilterMessage_EscWithTextInputFocused_PassesThroughAndStaysInMode ()
+ public void PreFilterMessage_Esc_PassesThroughAndStaysInMode ()
{
- // Arrange — the log window's search box owns the focus
+ // 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();
- filter._isTextInputFocused = () => true;
var msg = new Message
{
@@ -367,7 +368,7 @@ public void PreFilterMessage_EscWithTextInputFocused_PassesThroughAndStaysInMode
// Act
var swallowed = filter.PreFilterMessage(ref msg);
- // Assert — ESC belongs to the text input, e.g. canceling a search
+ // Assert — ESC passes through untouched
Assert.Multiple(() =>
{
Assert.That(swallowed, Is.False);
@@ -376,18 +377,17 @@ public void PreFilterMessage_EscWithTextInputFocused_PassesThroughAndStaysInMode
}
[Test]
- public void PreFilterMessage_EscWithoutTextInput_LeavesTheMode ()
+ public void PreFilterMessage_F11WhileAnotherFormIsActive_DoesNotLeaveTheMode ()
{
- // Arrange — no text input holds the focus
+ // Arrange — a modal dialog or any other window holds the focus
AddWindows("first.log");
_controller.Enter();
- var filter = CreateFilter();
- filter._isTextInputFocused = () => false;
+ var filter = CreateFilter(activeFormGetter: () => _form);
var msg = new Message
{
Msg = 0x0100,
- WParam = (IntPtr)0x1B, // VK_ESCAPE
+ WParam = (IntPtr)0x7A, // VK_F11
LParam = 0
};
@@ -397,9 +397,9 @@ public void PreFilterMessage_EscWithoutTextInput_LeavesTheMode ()
// Assert
Assert.Multiple(() =>
{
- Assert.That(swallowed, Is.True);
- Assert.That(_controller.IsActive, Is.False);
- Assert.That(_form.Visible, Is.True);
+ 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);
});
}
@@ -409,8 +409,7 @@ 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();
- filter._modifierKeys = () => Keys.Control;
+ var filter = CreateFilter(() => Keys.Control);
var msg = new Message
{
@@ -438,8 +437,7 @@ public void PreFilterMessage_CtrlShiftTabWhileDetached_SwitchesToThePreviousWind
// Arrange
var windows = AddWindows("first.log", "second.log", "third.log");
_controller.Enter(); // detaches the active window, the last added one
- var filter = CreateFilter();
- filter._modifierKeys = () => Keys.Control | Keys.Shift;
+ var filter = CreateFilter(() => Keys.Control | Keys.Shift);
var msg = new Message
{
@@ -490,7 +488,8 @@ public void PreFilterMessage_OtherKeysWhileDetached_PassThrough ()
/// A fresh filter wired to the controller, so PreFilterMessage can be driven without a
/// running message pump.
///
- private DetachMessageFilter CreateFilter () => new(_controller);
+ private DetachMessageFilter CreateFilter (Func? modifierKeysGetter = null, Func