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
? activeFormGetter = null) + => new(_controller, modifierKeysGetter, activeFormGetter ?? (() => _controller.DetachedFloat)); + + private FloatWindow FloatWindowOf (LogWindow content) + { + foreach (FloatWindow floatWindow in _dockPanel.FloatWindows) + { + foreach (DockPane pane in floatWindow.NestedPanes) + { + if (pane.Contents.Contains(content)) + { + return floatWindow; + } + } + } + + return null; + } + + private List AddWindows (params string[] fileNames) + { + var windows = fileNames.Select(CreateLogWindow).ToList(); + + foreach (var (window, fileName) in windows.Zip(fileNames)) + { + window.CloseButton = true; + _tabController.AddWindow(window, fileName); + } + + return windows; + } + + private static LogWindow CreateLogWindow (string fileName) + { + var coordinatorMock = new Mock(); + _ = coordinatorMock.Setup(c => c.ResolveHighlightGroup(It.IsAny(), It.IsAny())).Returns(new HighlightGroup()); + _ = coordinatorMock.SetupGet(c => c.SearchParams).Returns(new SearchParams()); + + var configManagerMock = new Mock(); + + // Without these stubs the mock returns null and LogWindow's session save on Dispose + // throws ArgumentNullException — surfaced as a modal MessageBox that blocks the test host. + _ = configManagerMock.SetupGet(c => c.ActiveSessionDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_DetachModeTests")); + _ = configManagerMock.SetupGet(c => c.ActiveConfigDir).Returns(Path.Combine(Path.GetTempPath(), "LogExpert_DetachModeTests")); + _ = configManagerMock.SetupGet(c => c.Settings).Returns(new Settings()); + + var pluginRegistryMock = new Mock(); + _ = pluginRegistryMock.SetupGet(p => p.RegisteredColumnizers).Returns([new DefaultLogfileColumnizer()]); + + return new LogWindow(coordinatorMock.Object, fileName, false, false, configManagerMock.Object, pluginRegistryMock.Object); + } +} diff --git a/src/LogExpert.UI.Tests/Services/TabControllerTests.cs b/src/LogExpert.UI.Tests/Services/TabControllerTests.cs index 667c694c..2925ce1c 100644 --- a/src/LogExpert.UI.Tests/Services/TabControllerTests.cs +++ b/src/LogExpert.UI.Tests/Services/TabControllerTests.cs @@ -413,6 +413,54 @@ 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 SwitchToNextWindow Tests [Test] @@ -450,6 +498,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 3b1218b3..235b6bd3 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 keeps the active log window floating; + /// F11 leaves the mode again. + /// + 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; @@ -462,6 +471,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; @@ -518,6 +528,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; @@ -757,6 +768,7 @@ protected override void Dispose (bool disposing) _tabStringFormat?.Dispose(); _menuToolbarController?.Dispose(); _toolWindowCoordinator?.Dispose(); + _detachModeController?.Dispose(); // Dispose TabController after FileOperationService is no longer reachable. // FileOperationService holds a reference to _tabController but does not own it; // after Dispose(), no caller invokes the service, so stale references are harmless. @@ -837,6 +849,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); + } } } @@ -1476,6 +1495,9 @@ 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 so the loaded layout describes the normal window. + _detachModeController.Exit(); + var outcome = _sessionHandler.LoadSession(sessionFileName); bool openedTabs = false; @@ -1750,8 +1772,19 @@ private void OnLogTabWindowLoad (object sender, EventArgs e) #endif } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Shutdown must continue even when detach mode cannot be restored.")] private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e) { + // Leave detach mode first so the saved layout describes the normal main window. + try + { + _detachModeController.Exit(); + } + catch (Exception ex) + { + _logger.Error(ex, "Leaving detach mode while closing failed"); + } + CancelPendingDrop(); try { @@ -1794,6 +1827,9 @@ private void OnLogWindowDisposed (object sender, EventArgs e) RemoveLogWindow(logWindow); logWindow.Tag = null; + + // Closing the detached window ends detach mode and brings the main window back. + _detachModeController.OnWindowDisposed(logWindow); } private void OnExitToolStripMenuItemClick (object sender, EventArgs e) @@ -2064,6 +2100,54 @@ private void OnFollowTailCheckBoxClick (object sender, EventArgs e) CurrentLogWindow?.FollowTailChanged(checkBoxFollowTail.Checked, false); } + /// + /// Takes F11 for detach mode before the key reaches the log window. While detached the main + /// window is hidden and 's message filter serves the key. + /// + protected override bool ProcessCmdKey (ref Message msg, Keys keyData) + { + 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)) + { + EnterDetachMode(); + } + + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + + /// + /// Tells the repeated WM_KEYDOWN messages Windows sends while a key is held down from the + /// single message of an actual press. + /// + private static bool IsAutoRepeat (Message msg) + { + return msg.Msg == LogExpert.UI.Extensions.NativeMethods.WM_KEYDOWN + && ((long)msg.LParam & LogExpert.UI.Extensions.NativeMethods.KEYDOWN_AUTOREPEAT_FLAG) != 0; + } + + [SupportedOSPlatform("windows")] + private void EnterDetachMode () + { + if (!_detachModeController.IsActive && WindowState == FormWindowState.Minimized) + { + WindowState = FormWindowState.Normal; + } + + _detachModeController.Enter(); + } + + private void OnDetachModeToolStripMenuItemClick (object sender, EventArgs e) + { + EnterDetachMode(); + } + private void OnLogTabWindowKeyDown (object sender, KeyEventArgs e) { if (e.KeyCode == Keys.W && e.Control) @@ -2374,8 +2458,8 @@ private void OnCloseOtherTabsToolStripMenuItemClick (object sender, EventArgs e) { var activeWindow = _tabController.GetActiveWindow(); var closeList = _tabController.GetAllWindowsFromDockPanel() - .Where(window => window != activeWindow) - .ToList(); + .Where(window => window != activeWindow) + .ToList(); foreach (var logWindow in closeList) { diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index 9da5eea6..dd59e933 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"; @@ -472,6 +474,20 @@ private void InitializeComponent () copyMarkedLinesIntoNewTabToolStripMenuItem.ToolTipText = "Copies all selected lines into a new tab page"; copyMarkedLinesIntoNewTabToolStripMenuItem.Click += OnCopyMarkedLinesIntoNewTabToolStripMenuItemClick; // + // ToolStripSeparator12 + // + ToolStripSeparator12.Name = "ToolStripSeparator12"; + ToolStripSeparator12.Size = new Size(174, 6); + // + // logOnlyViewToolStripMenuItem + // + logOnlyViewToolStripMenuItem.Name = "logOnlyViewToolStripMenuItem"; + logOnlyViewToolStripMenuItem.ShortcutKeyDisplayString = "F11"; + logOnlyViewToolStripMenuItem.Size = new Size(177, 22); + logOnlyViewToolStripMenuItem.Text = "Detach Mode"; + logOnlyViewToolStripMenuItem.ToolTipText = "Shows the active log alone in its own window and hides the main window"; + logOnlyViewToolStripMenuItem.Click += OnDetachModeToolStripMenuItemClick; + // // optionToolStripMenuItem // optionToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { columnizerToolStripMenuItem, hilightingToolStripMenuItem, ToolStripSeparator7, settingsToolStripMenuItem, pluginTrustManagementToolStripMenuItem, ToolStripSeparator9, cellSelectModeToolStripMenuItem, alwaysOnTopToolStripMenuItem, hideLineColumnToolStripMenuItem, ToolStripSeparator8, lockInstanceToolStripMenuItem }); @@ -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/Extensions/NativeMethods.cs b/src/LogExpert.UI/Extensions/NativeMethods.cs index c6cfd8a2..e4b772f0 100644 --- a/src/LogExpert.UI/Extensions/NativeMethods.cs +++ b/src/LogExpert.UI/Extensions/NativeMethods.cs @@ -39,6 +39,24 @@ internal static partial class NativeMethods #endregion + #region Win32 constants shared by keyboard handling + + /// Posted to the window with the keyboard focus when a non-system key is pressed. + public const int WM_KEYDOWN = 0x0100; + + /// The lParam bit Windows sets on every but the first while a key is held. + public const long KEYDOWN_AUTOREPEAT_FLAG = 1L << 30; + + #endregion + + #region P/Invoke + + [System.Runtime.InteropServices.DllImport("dwmapi.dll")] + [System.Runtime.InteropServices.DefaultDllImportSearchPaths(System.Runtime.InteropServices.DllImportSearchPath.System32)] + public static extern int DwmSetWindowAttribute (IntPtr hwnd, int attribute, ref int value, int size); + + #endregion + #region Helper methods public static Icon LoadIconFromExe (string fileName, int index) diff --git a/src/LogExpert.UI/Interface/ITabController.cs b/src/LogExpert.UI/Interface/ITabController.cs index 496b8749..2ab2fa12 100644 --- a/src/LogExpert.UI/Interface/ITabController.cs +++ b/src/LogExpert.UI/Interface/ITabController.cs @@ -65,6 +65,26 @@ internal interface ITabController : IDisposable ///
void SwitchToPreviousWindow (); + /// + /// Resolves the LogWindow that follows in the tab order, without + /// activating it. + /// + /// + /// The window to move on from. Pass null to use the active window. + /// + /// The next LogWindow, or null when no window is tracked. + LogWindow GetNextWindow (LogWindow current = null); + + /// + /// Resolves the LogWindow that precedes in the tab order, without + /// activating it. + /// + /// + /// The window to move back from. Pass null to use the active window. + /// + /// The previous LogWindow, or null when no window is tracked. + LogWindow GetPreviousWindow (LogWindow current = null); + /// /// Finds a LogWindow by its associated file name. /// diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs b/src/LogExpert.UI/Services/DetachModeService/DetachFloatWindowHook.cs new file mode 100644 index 00000000..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 _activeFormGetter; + + public DetachMessageFilter ( + DetachModeController controller, + Func? modifierKeysGetter = null, + Func? activeFormGetter = null) + { + ArgumentNullException.ThrowIfNull(controller); + _controller = controller; + _modifierKeysGetter = modifierKeysGetter ?? (() => Control.ModifierKeys); + _activeFormGetter = activeFormGetter ?? (() => Form.ActiveForm); + } + + public bool PreFilterMessage (ref Message m) + { + if (m.Msg != NativeMethods.WM_KEYDOWN || !_controller.IsActive) + { + return false; + } + + // Only the detached float owns these keys; a modal dialog on top of it must keep F11 and + // Ctrl+Tab for itself. + if (_activeFormGetter() != _controller.DetachedFloat) + { + 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() & NativeMethods.KEYDOWN_AUTOREPEAT_FLAG) == 0) + { + _controller.Exit(); + } + + return true; + + case VK_TAB when _modifierKeysGetter().HasFlag(Keys.Control): + _controller.SwitchWindow(_modifierKeysGetter().HasFlag(Keys.Shift)); + return true; + + default: + return false; + } + } +} diff --git a/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs b/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs new file mode 100644 index 00000000..eb95b78f --- /dev/null +++ b/src/LogExpert.UI/Services/DetachModeService/DetachModeController.cs @@ -0,0 +1,421 @@ +using System.Runtime.Versioning; + +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Extensions; +using LogExpert.UI.Interface; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Services.DetachModeService; + +/// +/// Owns detach mode: F11 hides the main window and keeps the active log window floating; leaving +/// the mode restores its original place and shows the main window again. +/// +[SupportedOSPlatform("windows")] +internal sealed class DetachModeController : IDisposable +{ + private readonly Form _mainForm; + private readonly DockPanel _dockPanel; + private readonly ITabController _tabController; + private readonly DetachMessageFilter _messageFilter; + + private DetachFloatWindowHook _captionHook; + private bool _filterInstalled; + private bool _disposed; + + private LogWindow _detachedWindow; + + private DockState _originalDockState = DockState.Unknown; + private DockPane _originalPane; + private int _originalContentIndex; + + private ChromeSnapshot _originalChrome; + + 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); + } + + public bool IsActive => _detachedWindow != null; + + public FloatWindow DetachedFloat => _captionHook?.FloatWindow; + + public bool Enter () + { + if (IsActive) + { + return true; + } + + var active = _dockPanel.ActiveContent as LogWindow ?? _tabController.GetActiveWindow(); + + if (active == null || active.IsDisposed) + { + return false; + } + + Detach(active); + InstallFilter(); + _mainForm.Hide(); + return true; + } + + public void Exit () + { + if (!IsActive) + { + return; + } + + var window = _detachedWindow; + + _detachedWindow = null; + ReleaseFloatWindow(); + UninstallFilter(); + + RestoreWindow(window); + ClearOriginalPlacement(); + RestoreMainForm(); + } + + private void ClearOriginalPlacement () + { + _originalDockState = DockState.Unknown; + _originalPane = null; + _originalContentIndex = -1; + } + + private void RestoreMainFormLater () + { + try + { + _mainForm.BeginInvoke(new Action(RestoreMainForm)); + } + catch (InvalidOperationException) + { + RestoreMainForm(); + } + } + + public void SwitchWindow (bool shiftPressed) + { + if (!IsActive) + { + return; + } + + var target = shiftPressed + ? _tabController.GetPreviousWindow(_detachedWindow) + : _tabController.GetNextWindow(_detachedWindow); + + if (target == null || target == _detachedWindow) + { + return; + } + + RestoreWindow(_detachedWindow); + Detach(target); + } + + public void OnWindowAdded (LogWindow window) + { + if (!IsActive || window == null || window.IsDisposed) + { + return; + } + + RestoreWindow(_detachedWindow); + Detach(window); + } + + public void OnWindowDisposed (LogWindow window) + { + if (!IsActive || window != _detachedWindow) + { + return; + } + + _detachedWindow = null; + ReleaseFloatWindow(restoreChrome: false); + UninstallFilter(); + ClearOriginalPlacement(); + RestoreMainFormLater(); + } + + private void Detach (LogWindow window) + { + ReleaseFloatWindow(); + + // Snapshot before Show(): the window reports Float afterwards. + _originalDockState = window.DockState; + _originalPane = window.DockHandler.Pane; + _originalContentIndex = _originalPane?.Contents.IndexOf(window) ?? -1; + + if (window.DockState != DockState.Float) + { + window.Show(_dockPanel, DockState.Float); + } + + var floatWindow = FindFloatWindow(window); + + if (floatWindow == null) + { + // The suite refused to float the window; stay out of the mode instead of half-entering it. + _originalDockState = DockState.Unknown; + _originalPane = null; + return; + } + + _originalChrome = CaptureChrome(floatWindow); + PatchFloatWindow(floatWindow); + _captionHook = DetachFloatWindowHook.Attach(floatWindow); + _captionHook.CaptionDoubleClicked += ToggleMaximize; + _detachedWindow = window; + window.Activate(); + RepaintCaptionLater(floatWindow); + } + + private void RestoreWindow (LogWindow window) + { + if (window == null || window.IsDisposed) + { + return; + } + + if (_originalDockState == DockState.Float) + { + // Was already floating; only the chrome was patched. + return; + } + + if (_originalPane != null && !_originalPane.IsDisposed && _originalPane.DockState == _originalDockState) + { + // Put it back into its original pane and tab index so split panes and tab order survive. + window.Show(_originalPane, null); + + if (_originalContentIndex >= 0 && _originalContentIndex < _originalPane.Contents.Count) + { + _originalPane.SetContentIndex(window, _originalContentIndex); + } + + return; + } + + // Pane disappeared (split closed, session load): fall back to dock-state-only placement. + window.Show(_dockPanel, ToRestorable(_originalDockState)); + } + + private void PatchFloatWindow (FloatWindow floatWindow) + { + // The suite shares the log window's Icon, whose handle is replaced on every tail refresh; + // the main form's icon lives as long as the process. + floatWindow.Icon = _mainForm.Icon; + + // A float is owned by the main form, which is hidden while detached: cut the ownership. + floatWindow.Owner = null; + floatWindow.FormBorderStyle = FormBorderStyle.Sizable; + floatWindow.MaximizeBox = true; + floatWindow.MinimizeBox = true; + floatWindow.ShowInTaskbar = true; + + // The suite's caption handler would start a dock drag; leave the caption to the native move. + floatWindow.AllowEndUserDocking = false; + + // The style changes above recreated the handle, so the dark title bar flag has to be set again. + ApplyDarkTitleBar(floatWindow); + } + + private static ChromeSnapshot CaptureChrome (FloatWindow floatWindow) + { + return new ChromeSnapshot( + floatWindow.Owner, + floatWindow.FormBorderStyle, + floatWindow.MaximizeBox, + floatWindow.MinimizeBox, + floatWindow.ShowInTaskbar, + floatWindow.AllowEndUserDocking); + } + + private static void RestoreChrome (FloatWindow floatWindow, ChromeSnapshot chrome) + { + floatWindow.Owner = chrome.Owner; + floatWindow.FormBorderStyle = chrome.FormBorderStyle; + floatWindow.MaximizeBox = chrome.MaximizeBox; + floatWindow.MinimizeBox = chrome.MinimizeBox; + floatWindow.ShowInTaskbar = chrome.ShowInTaskbar; + floatWindow.AllowEndUserDocking = chrome.AllowEndUserDocking; + + ApplyDarkTitleBar(floatWindow); + } + + private static void RepaintCaptionLater (FloatWindow floatWindow) + { + if (floatWindow.IsDisposed) + { + return; + } + + try + { + floatWindow.BeginInvoke(new Action(() => RepaintCaption(floatWindow))); + } + catch (InvalidOperationException) + { + } + } + + private static void RepaintCaption (FloatWindow floatWindow) + { + if (floatWindow.IsDisposed) + { + return; + } + + ApplyDarkTitleBar(floatWindow); + + if (floatWindow.WindowState != FormWindowState.Normal) + { + return; + } + + var bounds = floatWindow.Bounds; + + floatWindow.SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height + 1); + floatWindow.SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height); + } + + private static void ApplyDarkTitleBar (FloatWindow floatWindow) + { + if (!Application.IsDarkModeEnabled || floatWindow is not { IsHandleCreated: true, IsDisposed: false }) + { + return; + } + + int value = 1; + _ = NativeMethods.DwmSetWindowAttribute(floatWindow.Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int)); + } + + private void ToggleMaximize (object sender, EventArgs e) + { + if (_captionHook == null || _captionHook.FloatWindow.IsDisposed) + { + return; + } + + _captionHook.FloatWindow.WindowState = _captionHook.FloatWindow.WindowState == FormWindowState.Maximized + ? FormWindowState.Normal + : FormWindowState.Maximized; + } + + private void ReleaseFloatWindow (bool restoreChrome = true) + { + if (_captionHook == null) + { + return; + } + + var floatWindow = _captionHook.FloatWindow; + + _captionHook.CaptionDoubleClicked -= ToggleMaximize; + _captionHook.Dispose(); + _captionHook = null; + + if (restoreChrome && floatWindow is { IsDisposed: false }) + { + // Restore the suite's tool-window chrome (owned border, no taskbar button, end-user docking). + RestoreChrome(floatWindow, _originalChrome); + } + } + + 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; + } + + private static DockState ToRestorable (DockState state) + { + return state is DockState.Unknown or DockState.Hidden ? DockState.Document : state; + } + + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + public void Dispose () + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (IsActive) + { + try + { + Exit(); + } + catch (ObjectDisposedException) + { + // Shutdown races: the panel or the window is already gone, nothing left to restore. + } + } + + ReleaseFloatWindow(); + UninstallFilter(); + } + + private readonly record struct ChromeSnapshot ( + Form Owner, + FormBorderStyle FormBorderStyle, + bool MaximizeBox, + bool MinimizeBox, + bool ShowInTaskbar, + bool AllowEndUserDocking); +} diff --git a/src/LogExpert.UI/Services/TabControllerService/TabController.cs b/src/LogExpert.UI/Services/TabControllerService/TabController.cs index ee514749..8dbb36cd 100644 --- a/src/LogExpert.UI/Services/TabControllerService/TabController.cs +++ b/src/LogExpert.UI/Services/TabControllerService/TabController.cs @@ -263,41 +263,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 +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 () {