diff --git a/src/ColumnizerLib/IKeywordAction.cs b/src/ColumnizerLib/IKeywordAction.cs index cfd9b36f..f42e4411 100644 --- a/src/ColumnizerLib/IKeywordAction.cs +++ b/src/ColumnizerLib/IKeywordAction.cs @@ -1,11 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace ColumnizerLib; /// -/// Implement this interface to execute a self defined action when LogExpert detects a +/// Implement this interface to execute a self defined action when LogExpert detects a /// keyword on incomig log file content. /// These kind of plugins can be used in the "Highlight and Action Triggers" dialog. /// @@ -21,30 +17,30 @@ public interface IKeywordAction /// The keyword which triggered the call. /// The parameter configured for the plugin launch (in the Highlight dialog). /// A callback which can be used by the plugin. - /// The current columnizer. Can be used to obtain timestamps + /// The current columnizer. Can be used to obtain timestamps /// (if supported by Columnizer) or to split the log line into fields. /// - /// This method is called in a background thread from the process' thread pool (using BeginInvoke()). + /// This method is called in a background thread from the process' thread pool (using BeginInvoke()). /// So you cannot rely on state information retrieved by the given callback. E.g. the line count /// may change during the execution of the method. The only exception from this rule is the current line number /// retrieved from the callback. This is of course the line number of the line that has triggered /// the keyword match. /// - void Execute(string keyword, string param, ILogExpertCallback callback, ILogLineMemoryColumnizer columnizer); + void Execute (string keyword, string param, ILogExpertCallbackMemory callback, ILogLineMemoryColumnizer columnizer); /// - /// Return the name of your plugin here. The returned name is used for displaying the plugin list + /// Return the name of your plugin here. The returned name is used for displaying the plugin list /// in the settings. /// /// The name of the plugin. - string GetName(); + string GetName (); /// /// Return a description of your plugin here. E.g. a short explanation of parameters. The descriptions /// will be displayed in the plugin chooser dialog which is used by the Highlight settings. /// /// The description of the plugin. - string GetDescription(); + string GetDescription (); #endregion } \ No newline at end of file diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index 8ddfb357..12b065f3 100644 --- a/src/CsvColumnizer/CsvColumnizer.cs +++ b/src/CsvColumnizer/CsvColumnizer.cs @@ -187,7 +187,7 @@ public void Selected (ILogLineMemoryColumnizerCallback callback) _columnList.Clear(); var line = _config.HasFieldNames ? _firstLine - : callback.GetLogLine(0); + : callback.GetLogLineMemory(0); if (line != null) { diff --git a/src/DefaultPlugins/ProcessLauncher.cs b/src/DefaultPlugins/ProcessLauncher.cs index 50bc241f..f87fd5cc 100644 --- a/src/DefaultPlugins/ProcessLauncher.cs +++ b/src/DefaultPlugins/ProcessLauncher.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using ColumnizerLib; @@ -15,9 +14,9 @@ internal class ProcessLauncher : IKeywordAction #region IKeywordAction Member - private readonly object _callbackLock = new(); + private readonly Lock _callbackLock = new(); - public void Execute (string keyword, string param, ILogExpertCallback callback, ILogLineMemoryColumnizer columnizer) + public void Execute (string keyword, string param, ILogExpertCallbackMemory callback, ILogLineMemoryColumnizer columnizer) { var start = 0; int end; @@ -46,16 +45,16 @@ public void Execute (string keyword, string param, ILogExpertCallback callback, parameters = parameters.Replace("%K", keyword, StringComparison.Ordinal); var lineNumber = callback.LineNum; //Line Numbers start at 0, but are displayed (+1) - var logline = callback.GetLogLine(lineNumber).FullLine; - parameters = parameters.Replace("%L", string.Empty + lineNumber, System.StringComparison.Ordinal); + var logline = callback.GetLogLineMemory(lineNumber).FullLine; + parameters = parameters.Replace("%L", string.Empty + lineNumber, StringComparison.Ordinal); parameters = parameters.Replace("%T", callback.GetTabTitle(), StringComparison.Ordinal); - parameters = parameters.Replace("%C", logline, StringComparison.Ordinal); + parameters = parameters.Replace("%C", logline.ToString(), StringComparison.Ordinal); Process explorer = new(); explorer.StartInfo.FileName = procName; explorer.StartInfo.Arguments = parameters; explorer.StartInfo.UseShellExecute = false; - explorer.Start(); + _ = explorer.Start(); } } diff --git a/src/FlashIconHighlighter/FlashIconHighlighter.csproj b/src/FlashIconHighlighter/FlashIconHighlighter.csproj index 145ff159..afed49cc 100644 --- a/src/FlashIconHighlighter/FlashIconHighlighter.csproj +++ b/src/FlashIconHighlighter/FlashIconHighlighter.csproj @@ -7,8 +7,14 @@ true $(SolutionDir)..\bin\$(Configuration)\plugins true + FlashIconHighlighter + FlashIconHighlighter + + + + diff --git a/src/FlashIconHighlighter/FlashIconPlugin.cs b/src/FlashIconHighlighter/FlashIconPlugin.cs index 2efaab6b..6e38ac00 100644 --- a/src/FlashIconHighlighter/FlashIconPlugin.cs +++ b/src/FlashIconHighlighter/FlashIconPlugin.cs @@ -1,10 +1,10 @@ -using System; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Windows.Forms; using ColumnizerLib; +using static Vanara.PInvoke.User32; + [assembly: SupportedOSPlatform("windows")] namespace FlashIconHighlighter; @@ -18,29 +18,33 @@ internal class FlashIconPlugin : IKeywordAction #region IKeywordAction Member - public void Execute (string keyword, string param, ILogExpertCallback callback, ILogLineMemoryColumnizer columnizer) + public void Execute (string keyword, string param, ILogExpertCallbackMemory callback, ILogLineMemoryColumnizer columnizer) { var openForms = Application.OpenForms; foreach (Form form in openForms) { if (form.TopLevel && form.Name.Equals("LogTabWindow", StringComparison.OrdinalIgnoreCase) && form.Text.Contains(callback.GetFileName(), StringComparison.Ordinal)) { - form.BeginInvoke(FlashWindow, [form]); + _ = form.BeginInvoke(FlashWindow, [form]); } } } + /// + /// Flash Window http://blogs.x2line.com/al/archive/2008/04/19/3392.aspx + /// + /// private void FlashWindow (Form form) { FLASHWINFO fw = new() { - cbSize = Convert.ToUInt32(Marshal.SizeOf(typeof(FLASHWINFO))), + cbSize = Convert.ToUInt32(Marshal.SizeOf()), hwnd = form.Handle, - dwFlags = 14, + dwFlags = FLASHW.FLASHW_TRAY | FLASHW.FLASHW_CAPTION | FLASHW.FLASHW_TIMER, uCount = 0 }; - Win32Stuff.FlashWindowEx(ref fw); + _ = FlashWindowEx(fw); } public string GetDescription () diff --git a/src/FlashIconHighlighter/Win32Stuff.cs b/src/FlashIconHighlighter/Win32Stuff.cs deleted file mode 100644 index 3f3bd754..00000000 --- a/src/FlashIconHighlighter/Win32Stuff.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Runtime.InteropServices; - -namespace FlashIconHighlighter; - -/* - * Flash stuff stolen from http://blogs.x2line.com/al/archive/2008/04/19/3392.aspx - */ - -[StructLayout(LayoutKind.Sequential)] -public struct FLASHWINFO -{ - public uint cbSize; - public IntPtr hwnd; - public int dwFlags; - public uint uCount; - public int dwTimeout; -} - -public static partial class Win32Stuff -{ - #region Public methods - - [LibraryImport("user32.dll")] - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - public static partial int FlashWindowEx (ref FLASHWINFO pwfi); - - #endregion -} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Filter/FilterPipe.cs b/src/LogExpert.Core/Classes/Filter/FilterPipe.cs index ff166a12..a1867dcf 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterPipe.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterPipe.cs @@ -4,6 +4,7 @@ using ColumnizerLib; using LogExpert.Core.Interface; + using NLog; namespace LogExpert.Core.Classes.Filter; @@ -23,7 +24,7 @@ public class FilterPipe : IDisposable #region cTor - public FilterPipe(FilterParams filterParams, ILogWindow logWindow) + public FilterPipe (FilterParams filterParams, ILogWindow logWindow) { FilterParams = filterParams; LogWindow = logWindow; @@ -68,15 +69,12 @@ public void OpenFile () public void CloseFile () { - if (_writer != null) - { - _writer.Close(); - _writer = null; - } + _writer?.Close(); + _writer = null; } //TOOD: check if the callers are checking for null before calling - public bool WriteToPipe (ILogLine textLine, int orgLineNum) + public bool WriteToPipe (ILogLineMemory textLine, int orgLineNum) { ArgumentNullException.ThrowIfNull(textLine, nameof(textLine)); @@ -88,7 +86,7 @@ public bool WriteToPipe (ILogLine textLine, int orgLineNum) { try { - _writer.WriteLine(textLine.FullLine); + _writer.WriteLine(textLine.FullLine.ToString()); _lineMappingList.Add(orgLineNum); return true; } diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs index 9b81d83e..f8d64146 100644 --- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs +++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs @@ -1277,7 +1277,7 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start } } - AcquireDisposeReaderLock(); + AcquireDisposeLockUpgradableReadLock(); if (logBuffer.IsDisposed) { UpgradeDisposeLockToWriterLock(); @@ -1285,7 +1285,7 @@ private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int start DowngradeDisposeLockFromWriterLock(); } - ReleaseDisposeReaderLock(); + ReleaseDisposeUpgradeableReadLock(); } } finally diff --git a/src/LogExpert.Core/EventArguments/ContextMenuPluginEventArgs.cs b/src/LogExpert.Core/EventArguments/ContextMenuPluginEventArgs.cs index 1fae61df..a642e9a4 100644 --- a/src/LogExpert.Core/EventArguments/ContextMenuPluginEventArgs.cs +++ b/src/LogExpert.Core/EventArguments/ContextMenuPluginEventArgs.cs @@ -2,8 +2,8 @@ namespace LogExpert.Core.EventArguments; -public class ContextMenuPluginEventArgs(IContextMenuEntry entry, IList logLines, ILogLineMemoryColumnizer columnizer, - ILogExpertCallback callback) : System.EventArgs +public class ContextMenuPluginEventArgs (IContextMenuEntry entry, IList logLines, ILogLineMemoryColumnizer columnizer, + ILogExpertCallbackMemory callback) : EventArgs { #region Properties @@ -14,7 +14,7 @@ public class ContextMenuPluginEventArgs(IContextMenuEntry entry, IList logL public ILogLineMemoryColumnizer Columnizer { get; } = columnizer; - public ILogExpertCallback Callback { get; } = callback; + public ILogExpertCallbackMemory Callback { get; } = callback; #endregion } \ No newline at end of file diff --git a/src/LogExpert.Core/Interface/ILogWindow.cs b/src/LogExpert.Core/Interface/ILogWindow.cs index c644e2d3..406ce15a 100644 --- a/src/LogExpert.Core/Interface/ILogWindow.cs +++ b/src/LogExpert.Core/Interface/ILogWindow.cs @@ -199,6 +199,21 @@ public interface ILogWindow /// void WritePipeTab (IList lineEntryList, string title); + /// + /// Creates a new tab containing the specified list of log line entries. + /// + /// + /// A list of objects containing the lines and their + /// original line numbers to display in the new tab. + /// + /// The title to display on the tab. + /// + /// This method is used to pipe filtered or selected content into a new tab + /// without creating a physical file. The new tab maintains references to the + /// original line numbers for context. + /// + void WritePipeTab (IList lineEntryList, string title); + /// /// Activates this log window and brings it to the foreground. /// diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 68f34bbe..06018dff 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1388,6 +1388,33 @@ public static string LogExpert_Common_Error_InsufficientRights_For_Parameter_Err } } + /// + /// Looks up a localized string similar to {0} is already initialized. + /// + public static string LogExpert_Common_Error_Message_ServiceIsAlreadyInitialized { + get { + return ResourceManager.GetString("LogExpert_Common_Error_Message_ServiceIsAlreadyInitialized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} must be created on UI thread. + /// + public static string LogExpert_Common_Error_Message_ServiceMustBeCreatedOnUIThread { + get { + return ResourceManager.GetString("LogExpert_Common_Error_Message_ServiceMustBeCreatedOnUIThread", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} not initialized. + /// + public static string LogExpert_Common_Error_Message_ServiceNotInitialized { + get { + return ResourceManager.GetString("LogExpert_Common_Error_Message_ServiceNotInitialized", resourceCulture); + } + } + /// /// Looks up a localized string similar to &Add. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 4d66d6b2..c1ec0c3d 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -2115,4 +2115,10 @@ Fortfahren? LogExpert neu starten, um die Änderungen zu übernehmen? + + {0} nicht initialisiert + + + {0} ist bereits initialisiert + \ No newline at end of file diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index a9133a4f..efeac89a 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2124,4 +2124,13 @@ Restart LogExpert to apply changes? Locate: {0} + + {0} not initialized + + + {0} is already initialized + + + {0} must be created on UI thread + \ No newline at end of file diff --git a/src/LogExpert.Tests/Services/LedIndicatorServiceTests.cs b/src/LogExpert.Tests/Services/LedIndicatorServiceTests.cs new file mode 100644 index 00000000..9153e21a --- /dev/null +++ b/src/LogExpert.Tests/Services/LedIndicatorServiceTests.cs @@ -0,0 +1,319 @@ +using System.Runtime.Versioning; + +using LogExpert.UI.Services; + +using NUnit.Framework; + +namespace LogExpert.Tests.Services; + +[TestFixture] +[Apartment(ApartmentState.STA)] // Required for UI components +[SupportedOSPlatform("windows")] +public class LedIndicatorServiceTests +{ + private LedIndicatorService? _service; + private ApplicationContext? _appContext; + private WindowsFormsSynchronizationContext? _syncContext; + + [SetUp] + public void Setup () + { + // Ensure we have a WindowsFormsSynchronizationContext for the UI thread + if (SynchronizationContext.Current == null) + { + _syncContext = new WindowsFormsSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(_syncContext); + } + + // Create an application context to ensure we have a proper UI context + _appContext = new ApplicationContext(); + + // Must be created on STA thread with synchronization context + _service = new LedIndicatorService(); + } + + [TearDown] + public void TearDown () + { + _service?.Dispose(); + _appContext?.Dispose(); + _syncContext?.Dispose(); + } + + [Test] + public void Initialize_WithValidColor_Succeeds () + { + // Act + _service!.Initialize(Color.Blue); + + // Assert - no exception thrown + Assert.That(_service, Is.Not.Null); + } + + [Test] + public void Initialize_CalledTwice_ThrowsException () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act & Assert + _ = Assert.Throws(() => _service.Initialize(Color.Red)); + } + + [Test] + public void GetIcon_WithZeroDiff_ReturnsOffLevelIcon () + { + // Arrange + _service!.Initialize(Color.Blue); + var state = new LedState + { + IsDirty = false, + TailState = TailFollowState.On, + SyncState = TimeSyncState.NotSynced + }; + + // Act + var icon = _service.GetIcon(0, state); + + // Assert + Assert.That(icon, Is.Not.Null); + Assert.That(icon.Width, Is.EqualTo(16)); + Assert.That(icon.Height, Is.EqualTo(16)); + } + + [Test] + public void GetIcon_WithMaxDiff_ReturnsHighestLevelIcon () + { + // Arrange + _service!.Initialize(Color.Blue); + var state = new LedState + { + IsDirty = false, + TailState = TailFollowState.On, + SyncState = TimeSyncState.NotSynced + }; + + // Act + var icon = _service.GetIcon(100, state); + + // Assert + Assert.That(icon, Is.Not.Null); + } + + [Test] + public void GetIcon_WithDirtyState_ReturnsDirtyIcon () + { + // Arrange + _service!.Initialize(Color.Blue); + var state = new LedState + { + IsDirty = true, + TailState = TailFollowState.On, + SyncState = TimeSyncState.NotSynced + }; + + // Act + var icon = _service.GetIcon(50, state); + + // Assert + Assert.That(icon, Is.Not.Null); + } + + [Test] + public void GetDeadIcon_ReturnsNonNullIcon () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act + var icon = _service.GetDeadIcon(); + + // Assert + Assert.That(icon, Is.Not.Null); + Assert.That(icon.Width, Is.EqualTo(16)); + } + + [Test] + public void StartStop_DoesNotThrowException () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act + _service.Start(); + Thread.Sleep(500); // Let timer tick a few times + _service.Stop(); + + // Assert - no exception + Assert.That(true, Is.True, "Service started and stopped without exceptions"); + } + + [Test] + public void RegisterWindow_AddsWindowToTracking () + { + // Arrange + _service!.Initialize(Color.Blue); + + // We can't easily mock LogWindow since it has no parameterless constructor + // and is internal, so we just test that registering null throws + // Act & Assert + _ = Assert.Throws(() => _service.RegisterWindow(null!)); + } + + [Test] + public void UpdateWindowActivity_WithoutRegisteringWindow_DoesNotThrow () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act & Assert - Updating an unregistered window should not throw + // (it just won't raise events) + Assert.DoesNotThrow(() => _service.UpdateWindowActivity(null, 10)); + } + + [Test] + public void RegenerateIcons_WithNoWindows_DoesNotThrow () + { + // Arrange + _service!.Initialize(Color.Blue); + + int eventCount = 0; + _service.IconChanged += (s, e) => eventCount++; + + // Act + _service.RegenerateIcons(Color.Red); + + // Assert - No windows registered, so no events should be raised + Assert.That(eventCount, Is.EqualTo(0)); + } + + [Test] + public void Dispose_DisposesAllResources () + { + // Arrange + _service!.Initialize(Color.Blue); + _service.Start(); + + // Act + _service.Dispose(); + + // Assert - After dispose, trying to use the service will throw an exception + var exception = Assert.Catch(() => _service.GetIcon(0, new LedState())); + Assert.That(exception, Is.Not.Null, "Should throw an exception after disposal"); + } + + [Test] + public void GetIcon_WithoutInitialize_ThrowsException () + { + // Arrange - don't initialize + + // Act & Assert + _ = Assert.Throws(() => _service!.GetIcon(0, new LedState())); + } + + [Test] + public void Start_WithoutInitialize_ThrowsException () + { + // Arrange - don't initialize + + // Act & Assert + _ = Assert.Throws(() => _service!.Start()); + } + + [Test] + public void RegisterWindow_WithNullWindow_ThrowsException () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act & Assert + _ = Assert.Throws(() => _service.RegisterWindow(null!)); + } + + [Test] + public void UnregisterWindow_WithNullWindow_DoesNotThrow () + { + // Arrange + _service!.Initialize(Color.Blue); + + // Act & Assert - Unregistering null should not throw + Assert.DoesNotThrow(() => _service.UnregisterWindow(null)); + } + + [Test] + public void CurrentTailColor_AfterInitialize_ReturnsInitializedColor () + { + // Arrange + var expectedColor = Color.FromArgb(50, 100, 200); + _service!.Initialize(expectedColor); + + // Act + var actualColor = _service.CurrentTailColor; + + // Assert + Assert.That(actualColor, Is.EqualTo(expectedColor)); + } + + [Test] + public void CurrentTailColor_AfterRegenerateIcons_ReturnsNewColor () + { + // Arrange + _service!.Initialize(Color.Blue); + var newColor = Color.FromArgb(255, 128, 0); + + // Act + _service.RegenerateIcons(newColor); + + // Assert + Assert.That(_service.CurrentTailColor, Is.EqualTo(newColor)); + } + + [Test] + public void CurrentTailColor_BeforeInitialize_ThrowsException () + { + // Arrange - don't initialize + + // Act & Assert + _ = Assert.Throws(() => _ = _service!.CurrentTailColor); + } + + [Test] + public void CurrentTailColor_AfterDispose_ThrowsObjectDisposedException () + { + // Arrange + _service!.Initialize(Color.Blue); + _service.Dispose(); + + // Act & Assert + _ = Assert.Throws(() => _ = _service.CurrentTailColor); + } + + [Test] + public void GetIcon_WithSyncedState_ReturnsSyncedIcon () + { + // Arrange + _service!.Initialize(Color.Blue); + var stateSynced = new LedState + { + IsDirty = false, + TailState = TailFollowState.On, + SyncState = TimeSyncState.Synced + }; + var stateNotSynced = new LedState + { + IsDirty = false, + TailState = TailFollowState.On, + SyncState = TimeSyncState.NotSynced + }; + + // Act + var iconSynced = _service.GetIcon(50, stateSynced); + var iconNotSynced = _service.GetIcon(50, stateNotSynced); + + // Assert + Assert.That(iconSynced, Is.Not.Null); + Assert.That(iconNotSynced, Is.Not.Null); + // The icons should be different (synced has blue indicator on left side) + Assert.That(iconSynced, Is.Not.EqualTo(iconNotSynced)); + } +} \ No newline at end of file diff --git a/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs b/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs new file mode 100644 index 00000000..678a5ed9 --- /dev/null +++ b/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs @@ -0,0 +1,212 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Config; +using LogExpert.Core.Interface; +using LogExpert.UI.Extensions.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.UI; + +[TestFixture] +[Apartment(ApartmentState.STA)] // Required for WinForms components +public class LogTabWindowResourceTests +{ + [Test] + [Category("Resource")] + [SupportedOSPlatform("windows")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Tests")] + public void Dispose_DisposesAllGdiResources () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + // Create the window using the factory method + ILogTabWindow? window = null; + bool disposedSuccessfully = false; + + try + { + window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + // Give time for initialization + Thread.Sleep(300); + + // Act - Dispose via close (Form.Close calls Dispose internally) + if (window is Form form) + { + form.Close(); + form.Dispose(); + disposedSuccessfully = true; + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + catch (Exception ex) + { + Assert.Fail($"Test failed with exception during disposal: {ex.Message}\n{ex.StackTrace}"); + } + finally + { + // Ensure cleanup even if test fails + if (window is IDisposable disposable && window is Form form && !form.IsDisposed) + { + try + { + disposable.Dispose(); + } + catch + { + // Suppress exceptions in cleanup + } + } + } + + // Assert - If disposal has bugs, we'd get exceptions or access violations + Assert.That(disposedSuccessfully, Is.True, "Window should dispose successfully without exceptions"); + } + + [Test] + [Category("Resource")] + [SupportedOSPlatform("windows")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void Constructor_InitializesSuccessfully () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + ILogTabWindow? window = null; + + try + { + // Act + window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + // Give time for initialization + Thread.Sleep(300); + + // Assert - Verify window was created and basic structure exists + Assert.That(window, Is.Not.Null, "Window should be created"); + + if (window is Form form) + { + Assert.That(form.IsDisposed, Is.False, "Window should not be disposed after creation"); + Assert.That(form.Handle, Is.Not.EqualTo(IntPtr.Zero), "Window should have a valid handle"); + } + else + { + Assert.Fail("Window should be a Form"); + } + } + finally + { + if (window is Form form) + { + form.Close(); + form.Dispose(); + } + } + } + + [Test] + [Category("Resource")] + [SupportedOSPlatform("windows")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Tests")] + public void MultipleCreateDispose_DoesNotLeakResources () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + var exceptions = new List(); + + // Act - Create and dispose multiple windows + // If there's a resource leak, we'll eventually hit system limits or get exceptions + for (int i = 0; i < 5; i++) + { + try + { + var window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + Thread.Sleep(100); // Allow initialization + + if (window is Form form) + { + form.Close(); + form.Dispose(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + Thread.Sleep(200); // Allow final cleanup + + // Assert + Assert.That(exceptions, Is.Empty, + $"Should create and dispose multiple windows without exceptions. " + + $"Exceptions: {string.Join("; ", exceptions.Select(e => e.Message))}"); + } + + [Test] + [Category("Resource")] + [SupportedOSPlatform("windows")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void Dispose_CanBeCalledMultipleTimes () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + var window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + Thread.Sleep(200); + + // Act & Assert - Multiple dispose calls should not throw + if (window is Form form) + { + Assert.DoesNotThrow(() => + { + form.Close(); + form.Dispose(); + form.Dispose(); // Second dispose should be safe + form.Dispose(); // Third dispose should be safe + }, "Multiple Dispose calls should not throw exceptions"); + } + else + { + Assert.Fail("Window should be a Form"); + } + } +} diff --git a/src/LogExpert.Tests/UI/LogTabWindowThreadingTests.cs b/src/LogExpert.Tests/UI/LogTabWindowThreadingTests.cs new file mode 100644 index 00000000..5fb2301e --- /dev/null +++ b/src/LogExpert.Tests/UI/LogTabWindowThreadingTests.cs @@ -0,0 +1,259 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Config; +using LogExpert.Core.Interface; +using LogExpert.UI.Extensions.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.UI; + +[TestFixture] +[Apartment(ApartmentState.STA)] // Required for WinForms components +public class LogTabWindowThreadingTests +{ + [Test] + [Category("Threading")] + [SupportedOSPlatform("windows")] + public void LedThread_WithConcurrentUpdates_NoRaceCondition () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + var window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + Form? windowForm = null; + + // Ensure window handle is created + if (window is Form f) + { + windowForm = f; + _ = windowForm.Handle; // Force handle creation + } + + // Give time for LED thread to start and stabilize + Thread.Sleep(500); + + var exceptions = new List(); + var exceptionLock = new object(); + + try + { + // Act - Simulate concurrent operations on the window + // The LED thread runs in the background and updates icons + // We stress test by performing many concurrent read operations + var tasks = new Task[20]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Run(() => + { + try + { + // Perform safe read operations that might race with LED thread + for (int j = 0; j < 10; j++) + { + if (windowForm != null && !windowForm.IsDisposed && windowForm.IsHandleCreated) + { + // Read operations that don't require Invoke + _ = windowForm.Text; + _ = windowForm.Visible; + _ = windowForm.IsDisposed; + } + + Thread.Sleep(5); // Small delay to increase overlap with LED thread + } + } + catch (ObjectDisposedException) + { + // Expected if window is disposed during test + } + catch (InvalidOperationException ex) when (ex.Message.Contains("disposed", StringComparison.OrdinalIgnoreCase)) + { + // Expected if window is disposed during test + } + catch (Exception ex) + { + lock (exceptionLock) + { + exceptions.Add(ex); + } + } + }); + } + + Task.WaitAll(tasks); + + // Let LED thread process any pending updates + Thread.Sleep(500); + + // Assert - No unexpected exceptions should occur during concurrent access + Assert.That(exceptions, Is.Empty, + $"Race condition detected. Exceptions occurred: " + + $"{string.Join("; ", exceptions.Select(e => $"{e.GetType().Name}: {e.Message}"))}"); + } + finally + { + // Cleanup + if (windowForm != null) + { + windowForm.Close(); + windowForm.Dispose(); + } + } + } + + [Test] + [Category("Threading")] + [SupportedOSPlatform("windows")] + public void LedThread_StartsAndStopsCleanly () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + var window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + bool cleanedUpSuccessfully = false; + + try + { + // Give time for LED thread to start + Thread.Sleep(300); + + // Act - Close window (should stop LED thread gracefully) + if (window is Form form) + { + form.Close(); + form.Dispose(); + } + + // Let thread cleanup complete + Thread.Sleep(200); + + cleanedUpSuccessfully = true; + } + catch (Exception ex) + { + Assert.Fail($"LED thread cleanup failed: {ex.Message}"); + } + + // Assert - If thread cleanup has issues, disposal would throw or hang + Assert.That(cleanedUpSuccessfully, Is.True, "LED thread should start and stop cleanly"); + } + + [Test] + [Category("Threading")] + [SupportedOSPlatform("windows")] + public void MultipleWindows_LedThreadsDoNotInterfere () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + var windows = new List(); + var exceptions = new List(); + + try + { + // Act - Create multiple windows, each with their own LED thread + for (int i = 0; i < 3; i++) + { + try + { + var window = AbstractLogTabWindow.Create( + [], + i + 1, + false, + mockConfigManager.Object + ); + windows.Add(window); + Thread.Sleep(100); // Stagger creation + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + // Let all LED threads run concurrently + Thread.Sleep(800); + + // Assert - All windows should be created and running without interference + Assert.That(exceptions, Is.Empty, + $"Exceptions during window creation: {string.Join("; ", exceptions.Select(e => e.Message))}"); + Assert.That(windows.Count, Is.EqualTo(3), "All windows should be created successfully"); + } + finally + { + // Cleanup all windows + foreach (var window in windows) + { + try + { + if (window is Form form) + { + form.Close(); + form.Dispose(); + } + } + catch + { + // Suppress cleanup exceptions + } + } + } + } + + [Test] + [Category("Threading")] + [SupportedOSPlatform("windows")] + [Repeat(5)] // Run multiple times to catch intermittent race conditions + public void LedThread_RepeatedStartStop_NoMemoryLeak () + { + // Arrange + var mockConfigManager = new Mock(); + _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + + // Act - Create and destroy window multiple times + // Each time starts and stops LED thread + for (int i = 0; i < 5; i++) + { + var window = AbstractLogTabWindow.Create( + [], + 1, + false, + mockConfigManager.Object + ); + + Thread.Sleep(100); // Let LED thread start + + if (window is Form form) + { + form.Close(); + form.Dispose(); + } + + Thread.Sleep(100); // Let LED thread stop + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + // Assert - If there are thread leaks, we'd eventually get exceptions + // The fact that we got here without exceptions means the test passed + Assert.That(true, Is.True, "No threading issues detected during repeated create/dispose cycles"); + } +} diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.cs index 756e98f8..8aa2df6f 100644 --- a/src/LogExpert.UI/Controls/BufferedDataGridView.cs +++ b/src/LogExpert.UI/Controls/BufferedDataGridView.cs @@ -214,7 +214,7 @@ private void PaintOverlays (PaintEventArgs e) { var currentContext = BufferedGraphicsManager.Current; - using var myBuffer = currentContext.Allocate(CreateGraphics(), ClientRectangle); + using var myBuffer = currentContext.Allocate(e.Graphics, ClientRectangle); lock (_overlayList) { _overlayList.Clear(); diff --git a/src/LogExpert.UI/Controls/LogWindow/LogExpertCallback.cs b/src/LogExpert.UI/Controls/LogWindow/LogExpertCallback.cs index 22c6aa9c..0470ddf8 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogExpertCallback.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogExpertCallback.cs @@ -4,7 +4,7 @@ namespace LogExpert.UI.Controls.LogWindow; -internal class LogExpertCallback (LogWindow logWindow) : ColumnizerCallback(logWindow), ILogExpertCallback +internal class LogExpertCallback (LogWindow logWindow) : ColumnizerCallback(logWindow), ILogExpertCallbackMemory { #region Public methods @@ -13,7 +13,7 @@ public void AddTempFileTab (string fileName, string title) logWindow.AddTempFileTab(fileName, title); } - public void AddPipedTab (IList lineEntryList, string title) + public void AddPipedTab (IList lineEntryList, string title) { logWindow.WritePipeTab(lineEntryList, title); } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 1d3fea78..a831ef96 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -698,7 +698,7 @@ private void OnButtonSizeChanged (object sender, EventArgs e) private delegate void PatternStatisticFx (PatternArgs patternArgs); - private delegate void ActionPluginExecuteFx (string keyword, string param, ILogExpertCallback callback, ILogLineMemoryColumnizer columnizer); + private delegate void ActionPluginExecuteFx (string keyword, string param, ILogExpertCallbackMemory callback, ILogLineMemoryColumnizer columnizer); private delegate void PositionAfterReloadFx (ReloadMemento reloadMemento); @@ -735,11 +735,17 @@ void ILogWindow.AddTempFileTab (string fileName, string title) } [SupportedOSPlatform("windows")] - void ILogWindow.WritePipeTab (IList lineEntryList, string title) + void ILogWindow.WritePipeTab (IList lineEntryList, string title) { WritePipeTab(lineEntryList, title); } + [SupportedOSPlatform("windows")] + void ILogWindow.WritePipeTab (IList lineEntryList, string title) + { + //WritePipeTab(lineEntryList, title); + } + #region Event Handlers [SupportedOSPlatform("windows")] @@ -1472,7 +1478,7 @@ private void OnDataGridContextMenuStripOpening (object sender, CancelEventArgs e foreach (var entry in PluginRegistry.PluginRegistry.Instance.RegisteredContextMenuPlugins) { LogExpertCallback callback = new(this); - var menuText = entry.GetMenuText(lines.Count, CurrentColumnizer, callback.GetLogLine(lines[0])); + var menuText = entry.GetMenuText(lines.Count, CurrentColumnizer, callback.GetLogLineMemory(lines[0])); if (menuText != null) { @@ -3164,7 +3170,7 @@ private void CheckFilterAndHighlight (LogEventArgs e) for (var i = startLine; i < e.LineCount; ++i) { - var line = _logFileReader.GetLogLine(i); + var line = _logFileReader.GetLogLineMemory(i); if (line != null) { var matchingList = FindMatchingHilightEntries(line); @@ -3597,25 +3603,22 @@ private static IList MergeHighlightMatchEntries (IList /// Returns the first HighlightEntry that matches the given line /// - //TODO Replace with ItextvalueMemory - private HighlightEntry FindHighlightEntry (ITextValue line) + private HighlightEntry FindHighlightEntry (ITextValueMemory line) { return FindHighlightEntry(line, false); } - //TODO Replace with ItextvalueMemory - private HighlightEntry FindFirstNoWordMatchHighlightEntry (ITextValue line) + private HighlightEntry FindFirstNoWordMatchHighlightEntry (ITextValueMemory line) { return FindHighlightEntry(line, true); } - //TODO Replace with ItextvalueMemory - private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValue column) + private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValueMemory column) { if (entry.IsRegex) { //Regex rex = new Regex(entry.SearchText, entry.IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); - if (entry.Regex.IsMatch(column.Text)) + if (entry.Regex.IsMatch(column.Text.ToString())) { return true; } @@ -3624,14 +3627,14 @@ private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValue c { if (entry.IsCaseSensitive) { - if (column.Text.Contains(entry.SearchText, StringComparison.Ordinal)) + if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.Ordinal)) { return true; } } else { - if (column.Text.ToUpperInvariant().Contains(entry.SearchText.ToUpperInvariant(), StringComparison.OrdinalIgnoreCase)) + if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.OrdinalIgnoreCase)) { return true; } @@ -3644,8 +3647,7 @@ private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValue c /// /// Returns all HilightEntry entries which matches the given line /// - //TODO Replace with ItextvalueMemory - private IList FindMatchingHilightEntries (ITextValue line) + private IList FindMatchingHilightEntries (ITextValueMemory line) { IList resultList = []; if (line != null) @@ -3665,14 +3667,13 @@ private IList FindMatchingHilightEntries (ITextValue line) return resultList; } - //TODO Replace with ItextvalueMemory - private static void GetHighlightEntryMatches (ITextValue line, IList hilightEntryList, IList resultList) + private static void GetHighlightEntryMatches (ITextValueMemory line, IList hilightEntryList, IList resultList) { foreach (var entry in hilightEntryList) { if (entry.IsWordMatch) { - var matches = entry.Regex.Matches(line.Text); + var matches = entry.Regex.Matches(line.Text.ToString()); foreach (Match match in matches) { HighlightMatchEntry me = new() @@ -4095,7 +4096,7 @@ private void SelectPrevHighlightLine () while (lineNum > 0) { lineNum--; - var line = _logFileReader.GetLogLine(lineNum); + var line = _logFileReader.GetLogLineMemory(lineNum); if (line != null) { var entry = FindHighlightEntry(line); @@ -4115,7 +4116,7 @@ private void SelectNextHighlightLine () while (lineNum < _logFileReader.LineCount) { lineNum++; - var line = _logFileReader.GetLogLine(lineNum); + var line = _logFileReader.GetLogLineMemory(lineNum); if (line != null) { var entry = FindHighlightEntry(line); @@ -5019,11 +5020,11 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n break; } - var line = _logFileReader.GetLogLine(i); - if (CurrentColumnizer is ILogLineXmlColumnizer) + var line = _logFileReader.GetLogLineMemory(i); + if (CurrentColumnizer is ILogLineMemoryXmlColumnizer) { callback.LineNum = i; - line = (CurrentColumnizer as ILogLineXmlColumnizer).GetLineTextForClipboard(line, callback); + line = (CurrentColumnizer as ILogLineMemoryXmlColumnizer).GetLineTextForClipboard(line, callback); } _ = pipe.WriteToPipe(line, i); @@ -5046,7 +5047,7 @@ private void WriteFilterToTabFinished (FilterPipe pipe, string name, Persistence { var title = name; ILogLineMemoryColumnizer preProcessColumnizer = null; - if (CurrentColumnizer is not ILogLineXmlColumnizer) + if (CurrentColumnizer is not ILogLineMemoryXmlColumnizer) { preProcessColumnizer = CurrentColumnizer; } @@ -5075,7 +5076,7 @@ private void WriteFilterToTabFinished (FilterPipe pipe, string name, Persistence /// /// [SupportedOSPlatform("windows")] - internal void WritePipeTab (IList lineEntryList, string title) + internal void WritePipeTab (IList lineEntryList, string title) { FilterPipe pipe = new(new FilterParams(), this) { @@ -5150,7 +5151,7 @@ private void ProcessFilterPipes (int lineNum) pipe.LastLinesHistoryList.RemoveAt(0); } - var textLine = _logFileReader.GetLogLine(line); + var textLine = _logFileReader.GetLogLineMemory(line); var fileOk = pipe.WriteToPipe(textLine, line); if (!fileOk) { @@ -6557,44 +6558,6 @@ public void OnDataGridViewCellPainting (object sender, DataGridViewCellPaintingE /// /// /// - public HighlightEntry FindHighlightEntry (ITextValue line, bool noWordMatches) - { - // first check the temp entries - lock (_tempHighlightEntryListLock) - { - foreach (var entry in _tempHighlightEntryList) - { - if (noWordMatches && entry.IsWordMatch) - { - continue; - } - - if (CheckHighlightEntryMatch(entry, line)) - { - return entry; - } - } - } - - lock (_currentHighlightGroupLock) - { - foreach (var entry in _currentHighlightGroup.HighlightEntryList) - { - if (noWordMatches && entry.IsWordMatch) - { - continue; - } - - if (CheckHighlightEntryMatch(entry, line)) - { - return entry; - } - } - - return null; - } - } - public HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatches) { // first check the temp entries @@ -6613,6 +6576,7 @@ public HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatc } } } + lock (_currentHighlightGroupLock) { foreach (var entry in _currentHighlightGroup.HighlightEntryList) @@ -6632,26 +6596,6 @@ public HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatc } } - public IList FindHighlightMatches (ITextValue line) - { - IList resultList = []; - - if (line != null) - { - lock (_currentHighlightGroupLock) - { - GetHighlightEntryMatches(line, _currentHighlightGroup.HighlightEntryList, resultList); - } - - lock (_tempHighlightEntryList) - { - GetHighlightEntryMatches(line, _tempHighlightEntryList, resultList); - } - } - - return resultList; - } - public IList FindHighlightMatches (ITextValueMemory line) { IList resultList = []; diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 85fecd78..edffd6d0 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -23,6 +23,7 @@ using LogExpert.UI.Entities; using LogExpert.UI.Extensions; using LogExpert.UI.Extensions.LogWindow; +using LogExpert.UI.Services; using NLog; @@ -38,46 +39,33 @@ internal partial class LogTabWindow : Form, ILogTabWindow #region Fields private const int MAX_COLUMNIZER_HISTORY = 40; - private const int MAX_COLOR_HISTORY = 40; + //private const int MAX_COLOR_HISTORY = 40; private const int DIFF_MAX = 100; private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + private readonly Icon _deadIcon; + private readonly ILedIndicatorService _ledService; + private readonly Lock _windowListLock = new(); + + private bool _disposed; private readonly Color _defaultTabColor = Color.FromArgb(255, 192, 192, 192); - private readonly Brush _dirtyLedBrush; private readonly int _instanceNumber; - private readonly Brush[] _ledBrushes = new Brush[5]; - private readonly Icon[,,,] _ledIcons = new Icon[6, 2, 4, 2]; - - private readonly Rectangle[] _leds = new Rectangle[5]; private readonly IList _logWindowList = []; - private readonly Brush _offLedBrush; private readonly bool _showInstanceNumbers; private readonly string[] _startupFileNames; - private readonly EventWaitHandle _statusLineEventHandle = new AutoResetEvent(false); - private readonly EventWaitHandle _statusLineEventWakeupHandle = new ManualResetEvent(false); - private readonly Brush _syncLedBrush; - [SupportedOSPlatform("windows")] private readonly StringFormat _tabStringFormat = new(); - private readonly Brush[] _tailLedBrush = new Brush[3]; - private BookmarkWindow _bookmarkWindow; private LogWindow.LogWindow _currentLogWindow; private bool _firstBookmarkWindowShow = true; - private Thread _ledThread; - - //Settings settings; - - private bool _shouldStop; - private bool _skipEvents; private bool _wasMaximized; @@ -115,31 +103,12 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu Rectangle led = new(0, 0, 8, 2); - for (var i = 0; i < _leds.Length; ++i) - { - _leds[i] = led; - led.Offset(0, led.Height + 0); - } - - var grayAlpha = 50; - - _ledBrushes[0] = new SolidBrush(Color.FromArgb(255, 220, 0, 0)); - _ledBrushes[1] = new SolidBrush(Color.FromArgb(255, 220, 220, 0)); - _ledBrushes[2] = new SolidBrush(Color.FromArgb(255, 0, 220, 0)); - _ledBrushes[3] = new SolidBrush(Color.FromArgb(255, 0, 220, 0)); - _ledBrushes[4] = new SolidBrush(Color.FromArgb(255, 0, 220, 0)); + _ledService = new LedIndicatorService(); + _ledService.Initialize(ConfigManager.Settings.Preferences.ShowTailColor); + _ledService.IconChanged += OnLedIconChanged; + _ledService.Start(); - _offLedBrush = new SolidBrush(Color.FromArgb(grayAlpha, 160, 160, 160)); - - _dirtyLedBrush = new SolidBrush(Color.FromArgb(255, 220, 0, 00)); - - _tailLedBrush[0] = new SolidBrush(Color.FromArgb(255, 50, 100, 250)); // Follow tail: blue-ish - _tailLedBrush[1] = new SolidBrush(Color.FromArgb(grayAlpha, 160, 160, 160)); // Don't follow tail: gray - _tailLedBrush[2] = new SolidBrush(Color.FromArgb(255, 220, 220, 0)); // Stop follow tail (trigger): yellow-ish - - _syncLedBrush = new SolidBrush(Color.FromArgb(255, 250, 145, 30)); - - CreateIcons(); + _deadIcon = _ledService.GetDeadIcon(); _tabStringFormat.LineAlignment = StringAlignment.Center; _tabStringFormat.Alignment = StringAlignment.Near; @@ -167,9 +136,6 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu dragControlDateTime.Visible = false; loadProgessBar.Visible = false; - using var bmp = Resources.Deceased; - _deadIcon = Icon.FromHandle(bmp.GetHicon()); - FormClosing += OnLogTabWindowFormClosing; InitToolWindows(); @@ -251,37 +217,6 @@ internal HighlightGroup FindHighlightGroup (string groupName) #endregion - private class LogWindowData - { - #region Fields - - // public MdiTabControl.TabPage tabPage; - - public Color Color { get; set; } = Color.FromKnownColor(KnownColor.Gray); - - public int DiffSum { get; set; } - - public bool Dirty { get; set; } - - // tailState: - /// - /// 0 = on

- /// 1 = off

- /// 2 = off by Trigger

- ///
- public int TailState { get; set; } - - public ToolTip ToolTip { get; set; } - - /// - /// 0 = off

- /// 1 = timeSynced - ///
- public int SyncMode { get; set; } - - #endregion - } - #region Public methods [SupportedOSPlatform("windows")] @@ -721,7 +656,7 @@ public void ScrollAllTabsToTimestamp (DateTime timestamp, LogWindow.LogWindow se { if (logWindow.ScrollToTimestamp(timestamp, false, false)) { - ShowLedPeak(logWindow); + _ledService.UpdateWindowActivity(logWindow, DIFF_MAX); } } } @@ -802,15 +737,15 @@ public void FollowTailChanged (LogWindow.LogWindow logWindow, bool isEnabled, bo return; } - data.TailState = isEnabled - ? 0 + data.LedState.TailState = isEnabled + ? TailFollowState.On : offByTrigger - ? 2 - : 1; + ? TailFollowState.Paused + : TailFollowState.Off; if (Preferences.ShowTailState) { - var icon = GetLedIcon(data.DiffSum, data); + var icon = GetLedIcon(data.LedState.DiffSum, data); _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), logWindow, icon); } } @@ -842,6 +777,29 @@ public IList GetListOfOpenFiles () #region Private Methods + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose (bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing && (components != null)) + { + _ledService?.Stop(); + _ledService?.Dispose(); + components.Dispose(); + _tabStringFormat?.Dispose(); + } + + _disposed = true; + base.Dispose(disposing); + } + /// /// Creates a temp file with the text content of the clipboard and opens the temp file in a new tab. /// @@ -999,12 +957,12 @@ private void AddLogWindow (LogWindow.LogWindow logWindow, string title, bool doN LogWindowData data = new() { - DiffSum = 0 + LedState = new LedState() }; logWindow.Tag = data; - lock (_logWindowList) + lock (_windowListLock) { _logWindowList.Add(logWindow); } @@ -1019,6 +977,8 @@ private void AddLogWindow (LogWindow.LogWindow logWindow, string title, bool doN logWindow.SyncModeChanged += OnLogWindowSyncModeChanged; logWindow.Visible = true; + + _ledService.RegisterWindow(logWindow); } [SupportedOSPlatform("windows")] @@ -1084,9 +1044,10 @@ private void FillHistoryMenu () [SupportedOSPlatform("windows")] private void RemoveLogWindow (LogWindow.LogWindow logWindow) { - lock (_logWindowList) + lock (_windowListLock) { _ = _logWindowList.Remove(logWindow); + _ledService.UnregisterWindow(logWindow); } DisconnectEventHandlers(logWindow); @@ -1473,103 +1434,6 @@ private void StatusLineEventWorker (StatusLineEventArgs e) } } - // tailState: 0,1,2 = on/off/off by Trigger - // syncMode: 0 = normal (no), 1 = time synced - [SupportedOSPlatform("windows")] - private Icon CreateLedIcon (int level, bool dirty, int tailState, int syncMode) - { - var iconRect = _leds[0]; - iconRect.Height = 16; // (DockPanel's damn hardcoded height) // this.leds[this.leds.Length - 1].Bottom; - iconRect.Width = iconRect.Right + 6; - Bitmap bmp = new(iconRect.Width, iconRect.Height); - var gfx = Graphics.FromImage(bmp); - - var offsetFromTop = 4; - - for (var i = 0; i < _leds.Length; ++i) - { - var ledRect = _leds[i]; - ledRect.Offset(0, offsetFromTop); - - if (level >= _leds.Length - i) - { - gfx.FillRectangle(_ledBrushes[i], ledRect); - } - else - { - gfx.FillRectangle(_offLedBrush, ledRect); - } - } - - var ledSize = 3; - var ledGap = 1; - var lastLed = _leds[^1]; - Rectangle dirtyLed = new(lastLed.Right + 2, lastLed.Bottom - ledSize, ledSize, ledSize); - Rectangle tailLed = new(dirtyLed.Location, dirtyLed.Size); - tailLed.Offset(0, -(ledSize + ledGap)); - Rectangle syncLed = new(tailLed.Location, dirtyLed.Size); - syncLed.Offset(0, -(ledSize + ledGap)); - - syncLed.Offset(0, offsetFromTop); - tailLed.Offset(0, offsetFromTop); - dirtyLed.Offset(0, offsetFromTop); - - if (dirty) - { - gfx.FillRectangle(_dirtyLedBrush, dirtyLed); - } - else - { - gfx.FillRectangle(_offLedBrush, dirtyLed); - } - - // tailMode 4 means: don't show - if (tailState < 3) - { - gfx.FillRectangle(_tailLedBrush[tailState], tailLed); - } - - if (syncMode == 1) - { - gfx.FillRectangle(_syncLedBrush, syncLed); - } - //else - //{ - // gfx.FillRectangle(this.offLedBrush, syncLed); - //} - - // see http://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=345656 - // GetHicon() creates an unmanaged handle which must be destroyed. The Clone() workaround creates - // a managed copy of icon. then the unmanaged win32 handle is destroyed - var iconHandle = bmp.GetHicon(); - var icon = Icon.FromHandle(iconHandle).Clone() as Icon; - _ = Vanara.PInvoke.User32.DestroyIcon(iconHandle); - - gfx.Dispose(); - bmp.Dispose(); - return icon; - } - - [SupportedOSPlatform("windows")] - private void CreateIcons () - { - for (var syncMode = 0; syncMode <= 1; syncMode++) // LED indicating time synced tabs - { - for (var tailMode = 0; tailMode < 4; tailMode++) - { - for (var i = 0; i < 6; ++i) - { - _ledIcons[i, 0, tailMode, syncMode] = CreateLedIcon(i, false, tailMode, syncMode); - } - - for (var i = 0; i < 6; ++i) - { - _ledIcons[i, 1, tailMode, syncMode] = CreateLedIcon(i, true, tailMode, syncMode); - } - } - } - } - [SupportedOSPlatform("windows")] private void FileNotFound (LogWindow.LogWindow logWin) { @@ -1582,80 +1446,11 @@ private void FileNotFound (LogWindow.LogWindow logWin) private void FileRespawned (LogWindow.LogWindow logWin) { var data = logWin.Tag as LogWindowData; + data.LedState.DiffSum = 0; var icon = GetLedIcon(0, data); _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), logWin, icon); } - [SupportedOSPlatform("windows")] - private void ShowLedPeak (LogWindow.LogWindow logWin) - { - var data = logWin.Tag as LogWindowData; - lock (data) - { - data.DiffSum = DIFF_MAX; - } - - var icon = GetLedIcon(data.DiffSum, data); - _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), logWin, icon); - } - - private static int GetLevelFromDiff (int diff) - { - if (diff > 60) - { - diff = 60; - } - - var level = diff / 10; - if (diff > 0 && level == 0) - { - level = 2; - } - else if (level == 0) - { - level = 1; - } - - return level - 1; - } - - [SupportedOSPlatform("windows")] - //TODO Task based - private void LedThreadProc () - { - Thread.CurrentThread.Name = "LED Thread"; - while (!_shouldStop) - { - try - { - Thread.Sleep(200); - } - catch - { - return; - } - - lock (_logWindowList) - { - foreach (var logWindow in _logWindowList) - { - var data = logWindow.Tag as LogWindowData; - if (data.DiffSum > 0) - { - data.DiffSum -= 10; - if (data.DiffSum < 0) - { - data.DiffSum = 0; - } - - var icon = GetLedIcon(data.DiffSum, data); - _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), logWindow, icon); - } - } - } - } - } - [SupportedOSPlatform("windows")] private void SetTabIcon (LogWindow.LogWindow logWindow, Icon icon) { @@ -1666,14 +1461,15 @@ private void SetTabIcon (LogWindow.LogWindow logWindow, Icon icon) } } - private Icon GetLedIcon (int diff, LogWindowData data) + /// + /// Gets the appropriate LED icon based on the difference sum and LED state. + /// + /// The difference sum value used to determine the icon state. + /// The log window data containing the LED state information. + /// An representing the current LED state. + private Icon GetLedIcon (int diffSum, LogWindowData data) { - var icon = - _ledIcons[ - GetLevelFromDiff(diff), data.Dirty ? 1 : 0, Preferences.ShowTailState ? data.TailState : 3, - data.SyncMode - ]; - return icon; + return _ledService.GetIcon(diffSum, data.LedState); } [SupportedOSPlatform("windows")] @@ -1791,14 +1587,13 @@ private void ApplySettings (Settings settings, SettingsFlags flags) [SupportedOSPlatform("windows")] private void SetTabIcons (Preferences preferences) { - _tailLedBrush[0] = new SolidBrush(preferences.ShowTailColor); - CreateIcons(); + _ledService.RegenerateIcons(preferences.ShowTailColor); lock (_logWindowList) { foreach (var logWindow in _logWindowList) { var data = logWindow.Tag as LogWindowData; - var icon = GetLedIcon(data.DiffSum, data); + var icon = GetLedIcon(data.LedState.DiffSum, data); _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), logWindow, icon); } } @@ -2254,12 +2049,6 @@ private void OnLogTabWindowLoad (object sender, EventArgs e) LoadFiles(_startupFileNames, false); } - _ledThread = new Thread(LedThreadProc) - { - IsBackground = true - }; - _ledThread.Start(); - FillHighlightComboBox(); FillToolLauncherBar(); @@ -2273,11 +2062,6 @@ private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e) { try { - _shouldStop = true; - _ = _statusLineEventHandle.Set(); - _ = _statusLineEventWakeupHandle.Set(); - _ledThread.Join(); - IList deleteLogWindowList = []; ConfigManager.Settings.AlwaysOnTop = TopMost && ConfigManager.Settings.Preferences.AllowOnlyOneInstance; SaveLastOpenFilesList(); @@ -2620,36 +2404,23 @@ private void OnAlwaysOnTopToolStripMenuItemClick (object sender, EventArgs e) private void OnFileSizeChanged (object sender, LogEventArgs e) { - if (sender.GetType().IsAssignableFrom(typeof(LogWindow.LogWindow))) + if (sender is not LogWindow.LogWindow logWindow) { - var diff = e.LineCount - e.PrevLineCount; - if (diff < 0) - { - return; - } - - if (((LogWindow.LogWindow)sender).Tag is LogWindowData data) - { - lock (data) - { - data.DiffSum += diff; - if (data.DiffSum > DIFF_MAX) - { - data.DiffSum = DIFF_MAX; - } - } + return; + } - //if (this.dockPanel.ActiveContent != null && - // this.dockPanel.ActiveContent != sender || data.tailState != 0) - if (CurrentLogWindow != null && CurrentLogWindow != sender || data.TailState != 0) - { - data.Dirty = true; - } + if (logWindow.Tag is not LogWindowData data) + { + return; + } - var icon = GetLedIcon(diff, data); - _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), (LogWindow.LogWindow)sender, icon); - } + var diff = e.LineCount - e.PrevLineCount; + if (diff < 0) + { + return; } + + _ledService.UpdateWindowActivity(logWindow, diff); } private void OnLogWindowFileNotFound (object sender, EventArgs e) @@ -2697,8 +2468,8 @@ private void OnTailFollowed (object sender, EventArgs e) if (dockPanel.ActiveContent == sender) { var data = ((LogWindow.LogWindow)sender).Tag as LogWindowData; - data.Dirty = false; - var icon = GetLedIcon(data.DiffSum, data); + data.LedState.IsDirty = false; + var icon = GetLedIcon(data.LedState.DiffSum, data); _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), (LogWindow.LogWindow)sender, icon); } } @@ -2710,14 +2481,13 @@ private void OnLogWindowSyncModeChanged (object sender, SyncModeEventArgs e) if (!Disposing) { var data = ((LogWindow.LogWindow)sender).Tag as LogWindowData; - data.SyncMode = e.IsTimeSynced ? 1 : 0; - var icon = GetLedIcon(data.DiffSum, data); + data.LedState.SyncState = e.IsTimeSynced + ? TimeSyncState.Synced + : TimeSyncState.NotSynced; + + var icon = GetLedIcon(data.LedState.DiffSum, data); _ = BeginInvoke(new SetTabIconDelegate(SetTabIcon), (LogWindow.LogWindow)sender, icon); } - //else - //{ - // _logger.Warn("Received SyncModeChanged event while disposing. Event ignored."); - //} } [SupportedOSPlatform("windows")] @@ -3180,6 +2950,11 @@ private void OnThrowExceptionBackgroundThreadToolStripMenuItemClick (object send thread.Start(); } + private void OnLedIconChanged (object sender, IconChangedEventArgs e) + { + SetTabIcon(e.Window, e.NewIcon); + } + private void OnWarnToolStripMenuItemClick (object sender, EventArgs e) { //_logger.GetLogger().LogLevel = _logger.Level.WARN; @@ -3309,4 +3084,19 @@ private void OnTabRenameToolStripMenuItemClick (object sender, EventArgs e) } #endregion + + private class LogWindowData + { + #region Fields + + // public MdiTabControl.TabPage tabPage; + + public LedState LedState { get; set; } = new(); + + public Color Color { get; set; } = Color.FromKnownColor(KnownColor.Gray); + + public ToolTip ToolTip { get; set; } + + #endregion + } } diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index 19ed773d..f173f5c7 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs @@ -11,20 +11,7 @@ partial class LogTabWindow /// Required designer variable. /// private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - + #region Windows Form Designer generated code /// diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs index a31f8d32..2993618f 100644 --- a/src/LogExpert.UI/Entities/PaintHelper.cs +++ b/src/LogExpert.UI/Entities/PaintHelper.cs @@ -330,10 +330,9 @@ private static void PaintCell (ILogPaintContextUI logPaintCtx, DataGridViewCellP [SupportedOSPlatform("windows")] private static void PaintHighlightedCell (ILogPaintContextUI logPaintCtx, DataGridViewCellPaintingEventArgs e, HighlightEntry groundEntry) { - //TODO Refactor if possible since Column is ITextValue var value = e.Value ?? string.Empty; - var matchList = logPaintCtx.FindHighlightMatches(value as ITextValue); + var matchList = logPaintCtx.FindHighlightMatches(value as ITextValueMemory); // too many entries per line seem to cause problems with the GDI while (matchList.Count > 50) { diff --git a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs index 349e1036..b95af50d 100644 --- a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs +++ b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs @@ -33,12 +33,8 @@ internal interface ILogPaintContextUI : ILogPaintContext Bookmark GetBookmarkForLine (int lineNum); - HighlightEntry FindHighlightEntry (ITextValue line, bool noWordMatches); - HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatches); - IList FindHighlightMatches (ITextValue line); - IList FindHighlightMatches (ITextValueMemory line); #endregion diff --git a/src/LogExpert.UI/Properties/AssemblyInfo.cs b/src/LogExpert.UI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..635f1c8a --- /dev/null +++ b/src/LogExpert.UI/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LogExpert.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100619e9beea345a3bb5e15f55b29ddf40d96e9bb473ae58304fc63dfb3e9c94d8944bb7e45324ee0bef3e345dccba79b0bf64b85a128a7f261861899add639218ddaeb2acc6fcc746d6acb5bb212d375a0967756af192cfdb6cf0bff666a0fe535600abda860d3eafaff4ef1c9b5710181f72d996ca9c29ed64bae4a5fd916dea5")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/LogExpert.UI/Services/ILedIndicatorService.cs b/src/LogExpert.UI/Services/ILedIndicatorService.cs new file mode 100644 index 00000000..d6520a09 --- /dev/null +++ b/src/LogExpert.UI/Services/ILedIndicatorService.cs @@ -0,0 +1,79 @@ +using LogExpert.UI.Controls.LogWindow; + +namespace LogExpert.UI.Services; + +/// +/// Service for managing LED indicator icons on log window tabs +/// +/// +/// This service is thread-safe and can be called from any thread. +/// Icon updates are automatically marshaled to the UI thread via events. +/// +internal interface ILedIndicatorService : IDisposable +{ + /// + /// Initializes the LED service with the specified tail color + /// + /// Color to use for the tail-follow indicator + /// Thrown if already initialized + void Initialize (Color tailColor); + + /// + /// Gets the appropriate icon for the specified diff level and state + /// + /// Activity level (0-100) + /// Current LED state (dirty, tail, sync) + /// Icon representing the current state + Icon GetIcon (int diffLevel, LedState state); + + /// + /// Gets the "dead" icon shown when a file is missing + /// + /// Dead file icon + Icon GetDeadIcon (); + + /// + /// Starts the LED animation thread + /// + void Start (); + + /// + /// Stops the LED animation thread + /// + void Stop (); + + /// + /// Registers a window for LED state tracking + /// + /// LogWindow to track + void RegisterWindow (LogWindow window); + + /// + /// Unregisters a window from LED state tracking + /// + /// LogWindow to stop tracking + void UnregisterWindow (LogWindow window); + + /// + /// Updates the activity level for a window + /// + /// Window to update + /// Number of new lines added + void UpdateWindowActivity (LogWindow window, int lineDiff); + + /// + /// Regenerates all icons with new color + /// + /// New tail color + void RegenerateIcons (Color tailColor); + + /// + /// Gets the current tail color used for LED indicators + /// + Color CurrentTailColor { get; } + + /// + /// Event fired when a window's icon should be updated + /// + event EventHandler IconChanged; +} diff --git a/src/LogExpert.UI/Services/IconChangedEventArgs.cs b/src/LogExpert.UI/Services/IconChangedEventArgs.cs new file mode 100644 index 00000000..79962397 --- /dev/null +++ b/src/LogExpert.UI/Services/IconChangedEventArgs.cs @@ -0,0 +1,13 @@ +using LogExpert.UI.Controls.LogWindow; + +namespace LogExpert.UI.Services; + +/// +/// Event arguments for icon change notifications +/// +internal class IconChangedEventArgs (LogWindow window, Icon newIcon) : EventArgs +{ + public LogWindow Window { get; } = window; + + public Icon NewIcon { get; } = newIcon; +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/LedIndicatorService.cs b/src/LogExpert.UI/Services/LedIndicatorService.cs new file mode 100644 index 00000000..ad241ed3 --- /dev/null +++ b/src/LogExpert.UI/Services/LedIndicatorService.cs @@ -0,0 +1,626 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Globalization; +using System.Runtime.Versioning; + +using LogExpert.UI.Controls.LogWindow; + +using NLog; + +namespace LogExpert.UI.Services; + +[SupportedOSPlatform("windows")] +internal sealed class LedIndicatorService : ILedIndicatorService, IDisposable +{ + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + // Constants + private const int DIFF_MAX = 100; + private const int LED_DECAY_RATE = 10; + private const int ANIMATION_INTERVAL_MS = 200; + private const int ICON_SIZE = 16; + private const int ICON_LEVELS = 6; // Activity levels 0-5 + private const int ICON_DIRTY_STATES = 2; // Clean/Dirty + private const int ICON_TAIL_STATES = 4; // On/Off/Paused/Hidden + private const int ICON_SYNC_STATES = 2; // Not synced/Synced + + // Icon cache: [level][dirty][tail][sync] + private Icon[][][][] _iconCache; + private Icon _deadIcon; + + // Drawing resources + private readonly Rectangle[] _leds = new Rectangle[5]; + private Brush[] _ledBrushes; + private Brush[] _tailLedBrush; + private Brush _offLedBrush; + private Brush _dirtyLedBrush; + private Brush _syncLedBrush; + + // Window tracking + private readonly Dictionary _windowStates = []; + private readonly Lock _stateLock = new(); + + // Animation + private System.Windows.Forms.Timer _animationTimer; + private readonly SynchronizationContext _uiContext; + private bool _isInitialized; + private bool _disposed; + + public event EventHandler IconChanged; + + /// + /// Gets the current tail color used for LED indicators + /// + public Color CurrentTailColor + { + get + { + ObjectDisposedException.ThrowIf(_disposed, nameof(LedIndicatorService)); + + return !_isInitialized + ? throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceNotInitialized, nameof(LedIndicatorService))) + : (field); + } + + private set; + } + + public LedIndicatorService () + { + _uiContext = SynchronizationContext.Current + ?? throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceMustBeCreatedOnUIThread, nameof(LedIndicatorService))); + + InitializeLedRectangles(); + } + + /// + /// Initializes LED rectangles for icon generation + /// + private void InitializeLedRectangles () + { + _leds[0] = new Rectangle(0, 0, 3, 3); + _leds[1] = new Rectangle(3, 0, 3, 3); + _leds[2] = new Rectangle(6, 0, 3, 3); + _leds[3] = new Rectangle(9, 0, 3, 3); + _leds[4] = new Rectangle(12, 0, 3, 3); + } + + /// + /// Disposes all resources + /// + public void Dispose () + { + if (_disposed) + { + return; + } + + _logger.Info("Disposing LedIndicatorService"); + + Stop(); + + lock (_stateLock) + { + DisposeBrushes(); + DisposeIcons(); + _windowStates.Clear(); + } + + _disposed = true; + } + + /// + /// Disposes all brushes + /// + private void DisposeBrushes () + { + if (_ledBrushes != null) + { + foreach (var brush in _ledBrushes.Where(b => b != null)) + { + brush.Dispose(); + } + + _ledBrushes = null; + } + + if (_tailLedBrush != null) + { + foreach (var brush in _tailLedBrush.Where(b => b != null)) + { + brush.Dispose(); + } + + _tailLedBrush = null; + } + + _offLedBrush?.Dispose(); + _offLedBrush = null; + + _dirtyLedBrush?.Dispose(); + _dirtyLedBrush = null; + + _syncLedBrush?.Dispose(); + _syncLedBrush = null; + } + + /// + /// Disposes all icons + /// + private void DisposeIcons () + { + if (_iconCache != null) + { + for (int level = 0; level < ICON_LEVELS; level++) + { + if (_iconCache[level] == null) + { + continue; + } + + for (int dirty = 0; dirty < ICON_DIRTY_STATES; dirty++) + { + if (_iconCache[level][dirty] == null) + { + continue; + } + + for (int tail = 0; tail < ICON_TAIL_STATES; tail++) + { + if (_iconCache[level][dirty][tail] == null) + { + continue; + } + + for (int sync = 0; sync < ICON_SYNC_STATES; sync++) + { + _iconCache[level][dirty][tail][sync]?.Dispose(); + _iconCache[level][dirty][tail][sync] = null; + } + } + } + } + + _iconCache = null; + } + + _deadIcon?.Dispose(); + _deadIcon = null; + } + + /// + /// Gets the dead icon + /// + public Icon GetDeadIcon () + { + ObjectDisposedException.ThrowIf(_disposed, nameof(LedIndicatorService)); + + return !_isInitialized + ? throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceNotInitialized, nameof(LedIndicatorService))) + : _deadIcon; + } + + /// + /// Gets the appropriate icon for the specified state + /// + public Icon GetIcon (int diffLevel, LedState state) + { + ObjectDisposedException.ThrowIf(_disposed, nameof(LedIndicatorService)); + + if (!_isInitialized) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceNotInitialized, nameof(LedIndicatorService))); + } + + int level = GetLevelFromDiff(diffLevel); + int dirty = state.IsDirty ? 1 : 0; + int tail = (int)state.TailState; + int sync = (int)state.SyncState; + + return _iconCache[level][dirty][tail][sync]; + } + + public void Initialize (Color tailColor) + { + if (_isInitialized) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceIsAlreadyInitialized, nameof(LedIndicatorService))); + } + + _logger.Info("Initializing LedIndicatorService with tail color: {Color}", tailColor); + + CurrentTailColor = tailColor; + + // Create brushes + CreateBrushes(tailColor); + + // Generate all icons + GenerateIcons(); + + // Create dead icon + CreateDeadIcon(); + + _isInitialized = true; + _logger.Info("LedIndicatorService initialized successfully"); + } + + /// + /// Creates all brushes needed for icon generation + /// + private void CreateBrushes (Color tailColor) + { + _ledBrushes = new Brush[5]; + _ledBrushes[0] = new SolidBrush(Color.FromArgb(255, 0, 0)); // Red (highest activity) + _ledBrushes[1] = new SolidBrush(Color.FromArgb(255, 100, 0)); // Orange + _ledBrushes[2] = new SolidBrush(Color.FromArgb(255, 190, 0)); // Yellow + _ledBrushes[3] = new SolidBrush(Color.FromArgb(190, 255, 0)); // Yellow-green + _ledBrushes[4] = new SolidBrush(Color.FromArgb(0, 255, 0)); // Green (lowest activity) + + _tailLedBrush = new Brush[3]; + _tailLedBrush[0] = new SolidBrush(tailColor); // Tail on + _tailLedBrush[1] = new SolidBrush(Color.FromArgb(160, 160, 160)); // Tail off + _tailLedBrush[2] = new SolidBrush(Color.FromArgb(220, 220, 0)); // Tail trigger + + _offLedBrush = new SolidBrush(Color.FromArgb(100, 100, 100)); + _dirtyLedBrush = new SolidBrush(Color.FromArgb(220, 0, 220)); // Magenta for dirty + _syncLedBrush = new SolidBrush(Color.FromArgb(0, 100, 220)); // Blue for synced + } + + /// + /// Generates all possible icon combinations + /// + private void GenerateIcons () + { + _logger.Debug("Generating LED icon cache"); + + // Initialize jagged array + _iconCache = new Icon[ICON_LEVELS][][][]; + + int iconCount = 0; + for (int level = 0; level < ICON_LEVELS; level++) + { + _iconCache[level] = new Icon[ICON_DIRTY_STATES][][]; + + for (int dirty = 0; dirty < ICON_DIRTY_STATES; dirty++) + { + _iconCache[level][dirty] = new Icon[ICON_TAIL_STATES][]; + + for (int tail = 0; tail < ICON_TAIL_STATES; tail++) + { + _iconCache[level][dirty][tail] = new Icon[ICON_SYNC_STATES]; + + for (int sync = 0; sync < ICON_SYNC_STATES; sync++) + { + _iconCache[level][dirty][tail][sync] = CreateLedIcon( + level, + dirty == 1, + (TailFollowState)tail, + (TimeSyncState)sync); + iconCount++; + } + } + } + } + + _logger.Info("Generated {Count} LED icons", iconCount); + } + + /// + /// Creates a single LED icon with the specified state + /// + /// Activity level (0-5) + /// Whether content has changed + /// Tail follow state + /// Time synchronization state + /// The generated icon + private Icon CreateLedIcon (int level, bool dirty, TailFollowState tailState, TimeSyncState syncState) + { + using var bmp = new Bitmap(ICON_SIZE, ICON_SIZE, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + + // Draw 5 LED segments + for (int i = 0; i < 5; i++) + { + Brush brush = i < level + ? _ledBrushes[i] + : _offLedBrush; + g.FillRectangle(brush, _leds[i]); + } + + // Draw dirty indicator (top-right pixel) + if (dirty) + { + g.FillRectangle(_dirtyLedBrush, new Rectangle(13, 0, 3, 3)); + } + + // Draw tail indicator (bottom row) - hidden if state is Hidden + if (tailState != TailFollowState.Hidden) + { + for (int i = 0; i < 5; i++) + { + g.FillRectangle(_tailLedBrush[(int)tailState], new Rectangle(i * 3, 13, 3, 3)); + } + } + + // Draw sync indicator (left side) + if (syncState == TimeSyncState.Synced) + { + g.FillRectangle(_syncLedBrush, new Rectangle(0, 4, 2, 9)); + } + } + + // Convert to icon + IntPtr hIcon = bmp.GetHicon(); + try + { + var icon = (Icon)Icon.FromHandle(hIcon).Clone(); + return icon; + } + finally + { + // Destroy native icon handle + _ = Vanara.PInvoke.User32.DestroyIcon(hIcon); + } + } + + /// + /// Creates the "dead" icon for missing files + /// + private void CreateDeadIcon () + { + using var bmp = new Bitmap(ICON_SIZE, ICON_SIZE, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + + // Draw X in red + using var pen = new Pen(Color.Red, 2); + g.DrawLine(pen, 2, 2, 14, 14); + g.DrawLine(pen, 14, 2, 2, 14); + } + + IntPtr hIcon = bmp.GetHicon(); + try + { + //using var bmp = Resources.Deceased; + _deadIcon = (Icon)Icon.FromHandle(hIcon).Clone(); + } + finally + { + _ = Vanara.PInvoke.User32.DestroyIcon(hIcon); + } + } + + /// + /// Calculates LED level from diff value + /// + private static int GetLevelFromDiff (int diff) + { + return diff < 1 + ? 0 + : diff < 10 + ? 1 + : diff < 20 + ? 2 + : diff < 40 + ? 3 + : diff < 80 + ? 4 + : 5; + } + + /// + /// Regenerates all icons with new color + /// + public void RegenerateIcons (Color tailColor) + { + _logger.Info("Regenerating icons with new tail color: {Color}", tailColor); + + lock (_stateLock) + { + // Dispose old resources + DisposeBrushes(); + DisposeIcons(); + + // Create new ones + CurrentTailColor = tailColor; + CreateBrushes(tailColor); + GenerateIcons(); + CreateDeadIcon(); + + // Update all windows + var updates = new List<(LogWindow window, Icon icon)>(); + foreach (var kvp in _windowStates) + { + var icon = GetIcon(kvp.Value.DiffSum, kvp.Value); + updates.Add((kvp.Key, icon)); + } + + // Raise events outside lock on UI thread + foreach (var (window, icon) in updates) + { + OnIconChanged(window, icon); + } + } + } + + /// + /// Registers a window for tracking + /// + public void RegisterWindow (LogWindow window) + { + ArgumentNullException.ThrowIfNull(window); + + lock (_stateLock) + { + if (!_windowStates.ContainsKey(window)) + { + _windowStates[window] = new LedState(); + _logger.Debug("Registered window for LED tracking: {Window}", window.Text); + } + } + } + + /// + /// Starts the LED animation timer + /// + public void Start () + { + if (!_isInitialized) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.LogExpert_Common_Error_Message_ServiceNotInitialized, nameof(LedIndicatorService))); + } + + if (_animationTimer != null) + { + _logger.Warn("Animation timer already started"); + return; + } + + _logger.Info("Starting LED animation timer"); + + _animationTimer = new System.Windows.Forms.Timer + { + Interval = ANIMATION_INTERVAL_MS + }; + + _animationTimer.Tick += OnAnimationTick; + _animationTimer.Start(); + } + + /// + /// Stops the LED animation timer + /// + public void Stop () + { + if (_animationTimer == null) + { + return; + } + + _logger.Info("Stopping LED animation timer"); + + _animationTimer.Stop(); + _animationTimer.Tick -= OnAnimationTick; + _animationTimer.Dispose(); + _animationTimer = null; + } + + /// + /// Animation tick handler - decrements activity levels + /// + private void OnAnimationTick (object sender, EventArgs e) + { + if (_disposed) + { + return; + } + + List<(LogWindow window, Icon icon)> updates = []; + + lock (_stateLock) + { + foreach (var kvp in _windowStates.ToList()) + { + var window = kvp.Key; + var state = kvp.Value; + + if (state.DiffSum > 0) + { + state.DiffSum -= LED_DECAY_RATE; + if (state.DiffSum < 0) + { + state.DiffSum = 0; + } + + var icon = GetIcon(state.DiffSum, state); + updates.Add((window, icon)); + } + } + } + + foreach (var (window, icon) in updates) + { + OnIconChanged(window, icon); + } + } + + /// + /// Unregisters a window from tracking + /// + public void UnregisterWindow (LogWindow window) + { + if (window == null) + { + return; + } + + lock (_stateLock) + { + if (_windowStates.Remove(window)) + { + _logger.Debug("Unregistered window from LED tracking: {Window}", window.Text); + } + } + } + + /// + /// Updates window activity level + /// + public void UpdateWindowActivity (LogWindow window, int lineDiff) + { + if (window == null || lineDiff < 0) + { + return; + } + + Icon newIcon = null; + + lock (_stateLock) + { + if (_windowStates.TryGetValue(window, out var state)) + { + state.DiffSum += lineDiff; + if (state.DiffSum > DIFF_MAX) + { + state.DiffSum = DIFF_MAX; + } + + newIcon = GetIcon(state.DiffSum, state); + } + } + + if (newIcon != null) + { + OnIconChanged(window, newIcon); + } + } + + /// + /// Raises the IconChanged event on the UI thread + /// + /// The window whose icon changed + /// The new icon + private void OnIconChanged (LogWindow window, Icon icon) + { + if (_disposed || IconChanged == null) + { + return; + } + + var args = new IconChangedEventArgs(window, icon); + + // Marshal to UI thread if needed + if (SynchronizationContext.Current != _uiContext) + { + _uiContext.Post(_ => IconChanged?.Invoke(this, args), null); + } + else + { + // Already on UI thread + IconChanged.Invoke(this, args); + } + } +} diff --git a/src/LogExpert.UI/Services/LedState.cs b/src/LogExpert.UI/Services/LedState.cs new file mode 100644 index 00000000..14a648eb --- /dev/null +++ b/src/LogExpert.UI/Services/LedState.cs @@ -0,0 +1,41 @@ +namespace LogExpert.UI.Services; + +/// +/// LED state information +/// +public class LedState +{ + /// + /// Current activity level (0-100) + /// + public int DiffSum { get; set; } + + /// + /// Whether the tab content has changed since last viewed + /// + public bool IsDirty { get; set; } + + /// + /// Tail follow state + /// + public TailFollowState TailState { get; set; } + + /// + /// Time synchronization state + /// + public TimeSyncState SyncState { get; set; } + + /// + /// Creates a copy of this state + /// + public LedState Clone () + { + return new LedState + { + DiffSum = DiffSum, + IsDirty = IsDirty, + TailState = TailState, + SyncState = SyncState + }; + } +} diff --git a/src/LogExpert.UI/Services/TailFollowState.cs b/src/LogExpert.UI/Services/TailFollowState.cs new file mode 100644 index 00000000..080d59c1 --- /dev/null +++ b/src/LogExpert.UI/Services/TailFollowState.cs @@ -0,0 +1,27 @@ +namespace LogExpert.UI.Services; + +/// +/// Represents the tail follow state for a log window +/// +public enum TailFollowState +{ + /// + /// Tail following is active + /// + On = 0, + + /// + /// Tail following is disabled + /// + Off = 1, + + /// + /// Tail following is paused (e.g., by trigger) + /// + Paused = 2, + + /// + /// Tail state indicator is hidden (not shown in icon) + /// + Hidden = 3 +} diff --git a/src/LogExpert.UI/Services/TimeSyncState.cs b/src/LogExpert.UI/Services/TimeSyncState.cs new file mode 100644 index 00000000..1ebdc147 --- /dev/null +++ b/src/LogExpert.UI/Services/TimeSyncState.cs @@ -0,0 +1,17 @@ +namespace LogExpert.UI.Services; + +/// +/// Represents the time synchronization state for a log window +/// +public enum TimeSyncState +{ + /// + /// Time synchronization is not active (normal mode) + /// + NotSynced = 0, + + /// + /// Time synchronization is active (synced with other windows) + /// + Synced = 1 +} diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 7a878dc0..2749eb7b 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2025-12-16 15:51:20 UTC + /// Generated: 2026-01-07 12:21:59 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "FBFCB4C9FEBF8DA0DDB1822BDF7CFCADF1185574917D8705C597864D1ACBFE0C", + ["AutoColumnizer.dll"] = "5DAC7B256A2513C787AB6E86AA4AA1F1C4785C8F1F14DDAFE17DCF1E116ECBD1", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "672B48664FDBDB7E71A01AEE6704D34544257B6A977E01EC0A514FAEE030E982", - ["CsvColumnizer.dll (x86)"] = "672B48664FDBDB7E71A01AEE6704D34544257B6A977E01EC0A514FAEE030E982", - ["DefaultPlugins.dll"] = "53FA9690E55C7A1C90601CCCCA4FFC808BB783DFAA1FDF42EB924DF3CDB65D40", - ["FlashIconHighlighter.dll"] = "AC101CC62538DB565C462D5A9A6EAA57D85AEA4954FAAEF271FEAC7F98FD1CB1", - ["GlassfishColumnizer.dll"] = "915BDD327C531973F3355BC5B24AE70B0EA618269E9367DA4FA919B4BA08D171", - ["JsonColumnizer.dll"] = "8A8B8021A4D146424947473A5FC2F0C07248BD4FAC079C00E586ADAA153CA9ED", - ["JsonCompactColumnizer.dll"] = "6B26775A81C6FC4232C08BBAFDCE35A0EFA80A367FC00271DF00E89AF2F74802", - ["Log4jXmlColumnizer.dll"] = "B645973F639B23A51D066DC283A92E3BDAF058934C6C6378B6F13247B75077CB", - ["LogExpert.Core.dll"] = "A370D1AEF2A790D16622CD15C67270799A87555996A7A0D0A4D98B950B9C3CB7", - ["LogExpert.Resources.dll"] = "1D761591625ED49FD0DE29ABDB2800D865BD99B359397CF7790004B3CC9E6738", + ["CsvColumnizer.dll"] = "B80D07374268B2B3E0EE0DF8347CFC4664ABE8E3E3C68A34A5814512E6F48ACD", + ["CsvColumnizer.dll (x86)"] = "B80D07374268B2B3E0EE0DF8347CFC4664ABE8E3E3C68A34A5814512E6F48ACD", + ["DefaultPlugins.dll"] = "EF3682CFB968FE6E3FFA4906E01335E6FC827BCF2DE161D1F3D6870138B0A819", + ["FlashIconHighlighter.dll"] = "C0A02F519C9E2AB6047DEFE6059BE2A5F8A3A419FA0E9FEF5E6AD4E677AA47AD", + ["GlassfishColumnizer.dll"] = "D8C77BA6D373D5200900110F86919B03182CB66A8B577E6724813FD53C23F017", + ["JsonColumnizer.dll"] = "A19E95BB92F0C98970A6B6CDC0C878A4B3E7B8A26F1E1AA19B05FE5CD12F5073", + ["JsonCompactColumnizer.dll"] = "2DC5FAB57725A6F87DC4CC7CD3400108116B5547979E6D594DD47367D7E1D183", + ["Log4jXmlColumnizer.dll"] = "ED382EACD39DF7097D4922A739B751CE85158F6EE75DA259B0FCAFADE1E043BA", + ["LogExpert.Core.dll"] = "A0C9CC6735C00A94D62467D7EE5183A2EE33BE758717B387396D2E7614F82C9C", + ["LogExpert.Resources.dll"] = "F4B788E1EDD4F0CFDB21232DEC3111FD3F5AC11E67A31D52AA463960F0B18DF0", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "DD44507671520B3E2CD93311A651EFC7632C757FDA85750470A6FFCB74F1AB65", - ["SftpFileSystem.dll"] = "16818A6B8B7178372CCB1CD6ED8C4B0542FDD7D10C8C9AAE76704F0ED2CC4362", - ["SftpFileSystem.dll (x86)"] = "94B725554D2BBF79BBB0B4776C01AE0D1E8F06C158F5D234C2D94F5E62A900BC", - ["SftpFileSystem.Resources.dll"] = "FEFD8C274032A70FDB03EA8363D7F877BEBAF2C454238820D6D17ECEF79FC8A4", - ["SftpFileSystem.Resources.dll (x86)"] = "FEFD8C274032A70FDB03EA8363D7F877BEBAF2C454238820D6D17ECEF79FC8A4", + ["RegexColumnizer.dll"] = "1EBFD7BBE46F4C24A289D0690E57D7C83B2554FF7C73F6718D169BC2CFB51676", + ["SftpFileSystem.dll"] = "27138E267FD711D1C71C427958032BE3B9EB7ECBAC8E18B16349D2D8191ACBCB", + ["SftpFileSystem.dll (x86)"] = "6D90ED0359F5D8FADB243E40C69D0B065E6A7B654C1452D948968C89062859D8", + ["SftpFileSystem.Resources.dll"] = "BD74E37E1043811A19A00F312FAC93BFE163ABC45AF874ADA85206CE8F2B4045", + ["SftpFileSystem.Resources.dll (x86)"] = "BD74E37E1043811A19A00F312FAC93BFE163ABC45AF874ADA85206CE8F2B4045", }; }