diff --git a/CHANGES.md b/CHANGES.md index 8e3baf1..9d5ed3c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,14 +1,15 @@ -## Release Notes - Serilog.Sinks.RichTextBox.WinForms.Colored v3.0.1 +## Release Notes - Serilog.Sinks.RichTextBox.WinForms.Colored v3.1.0 -### Minor Release +### Feature Release -This minor release focuses on config improvements and performance optimization. +This release introduces the ability to clear and restore the log view in a non-destructive way. ### What Changed -- Adjusted MaxLogLines limit From 512 to 2048 lines. -- Fixed a bug where the RichTextBox would not persist the zoom factor. -- Optimized the Concurrent Circular Buffer +- Added `Clear()` and `Restore()` methods to the sink, allowing you to hide all current log entries from the view without deleting them from memory. New log events after clearing are still shown. You can restore the view to show all buffered events at any time (unless overwritten by new logs). +- You can now use `.WriteTo.RichTextBox(richTextBox, out sink, ...)` to capture the sink instance and call `Clear()`/`Restore()` directly. + +**Note:** The buffer is still a fixed size (default up to 2048 lines). If you clear and then log enough new events to fill the buffer, the oldest (hidden) events will be overwritten and cannot be restored. ### Resources diff --git a/Demo/Form1.cs b/Demo/Form1.cs index f83a72d..e361f55 100644 --- a/Demo/Form1.cs +++ b/Demo/Form1.cs @@ -33,6 +33,7 @@ namespace Demo public partial class Form1 : Form { private RichTextBoxSinkOptions? _options; + private RichTextBoxSink? _sink; private bool _toolbarsVisible = true; public Form1() @@ -47,10 +48,11 @@ private void Initialize() outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", formatProvider: new CultureInfo("en-US")); - var sink = new RichTextBoxSink(richTextBox1, _options); Log.Logger = new LoggerConfiguration() .MinimumLevel.Verbose() - .WriteTo.Sink(sink, LogEventLevel.Verbose) + .WriteTo.RichTextBox(richTextBox1, out _sink, theme: ThemePresets.Literate, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", + formatProvider: new CultureInfo("en-US")) .CreateLogger(); Log.Debug("Started logger."); @@ -317,6 +319,16 @@ private void btnReset_Click(object sender, EventArgs e) Initialize(); } + private void ClearLog() + { + _sink?.Clear(); + } + + private void RestoreLog() + { + _sink?.Restore(); + } + private void btnAutoScroll_Click(object sender, EventArgs e) { if (_options == null) @@ -336,6 +348,18 @@ private void Form1_KeyDown(object sender, KeyEventArgs e) toolStrip1.Visible = _toolbarsVisible; toolStrip2.Visible = _toolbarsVisible; } + else if (e.Control && e.KeyCode == Keys.L) + { + // Ctrl+L: Clear the log display + ClearLog(); + Log.Information("Log display cleared (Ctrl+L). Use Ctrl+R to restore."); + } + else if (e.Control && e.KeyCode == Keys.R) + { + // Ctrl+R: Restore the log display + RestoreLog(); + Log.Information("Log display restored (Ctrl+R)."); + } } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/ClearFunctionalityTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/ClearFunctionalityTests.cs new file mode 100644 index 0000000..f516a9d --- /dev/null +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/ClearFunctionalityTests.cs @@ -0,0 +1,200 @@ +using Serilog.Events; +using Serilog.Parsing; +using Xunit; + +namespace Serilog.Tests.Integration +{ + public class ClearFunctionalityTests : RichTextBoxSinkTestBase + { + [Fact] + public void Clear_HidesExistingLogEntries_ButShowsNewOnes() + { + var template1 = _parser.Parse("First message"); + var logEvent1 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template1, + Array.Empty()); + + var template2 = _parser.Parse("Second message"); + var logEvent2 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template2, + Array.Empty()); + + _sink.Emit(logEvent1); + _sink.Emit(logEvent2); + + // Wait with timeout + var timeout = TimeSpan.FromSeconds(5); + var startTime = DateTime.UtcNow; + while (string.IsNullOrWhiteSpace(_richTextBox.Text) && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var initialText = _richTextBox.Text; + Assert.Contains("First message", initialText); + Assert.Contains("Second message", initialText); + + _sink.Clear(); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (_richTextBox.Text.Contains("First message") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var clearedText = _richTextBox.Text; + Assert.DoesNotContain("First message", clearedText); + Assert.DoesNotContain("Second message", clearedText); + + var template3 = _parser.Parse("Third message after clear"); + var logEvent3 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template3, + Array.Empty()); + + _sink.Emit(logEvent3); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (!_richTextBox.Text.Contains("Third message after clear") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var afterClearText = _richTextBox.Text; + Assert.DoesNotContain("First message", afterClearText); + Assert.DoesNotContain("Second message", afterClearText); + Assert.Contains("Third message after clear", afterClearText); + } + + [Fact] + public void Restore_RestoresAllLogEntries_AfterClear() + { + var template1 = _parser.Parse("First message"); + var logEvent1 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template1, + Array.Empty()); + + var template2 = _parser.Parse("Second message"); + var logEvent2 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template2, + Array.Empty()); + + _sink.Emit(logEvent1); + _sink.Emit(logEvent2); + + // Wait with timeout + var timeout = TimeSpan.FromSeconds(5); + var startTime = DateTime.UtcNow; + while (string.IsNullOrWhiteSpace(_richTextBox.Text) && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + _sink.Clear(); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (_richTextBox.Text.Contains("First message") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var template3 = _parser.Parse("Third message after clear"); + var logEvent3 = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template3, + Array.Empty()); + + _sink.Emit(logEvent3); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (!_richTextBox.Text.Contains("Third message after clear") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + _sink.Restore(); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (!_richTextBox.Text.Contains("First message") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var restoredText = _richTextBox.Text; + Assert.Contains("First message", restoredText); + Assert.Contains("Second message", restoredText); + Assert.Contains("Third message after clear", restoredText); + } + + [Fact] + public void Clear_WithoutAnyMessages_DoesNotThrow() + { + _sink.Clear(); + + // Wait with timeout + var timeout = TimeSpan.FromSeconds(5); + var startTime = DateTime.UtcNow; + while (_richTextBox.Text.Length > 0 && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + Assert.True(string.IsNullOrWhiteSpace(_richTextBox.Text)); + } + + [Fact] + public void Restore_WithoutPreviousClear_DoesNotThrow() + { + var template = _parser.Parse("Test message"); + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + Array.Empty()); + + _sink.Emit(logEvent); + + // Wait with timeout + var timeout = TimeSpan.FromSeconds(5); + var startTime = DateTime.UtcNow; + while (!_richTextBox.Text.Contains("Test message") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + _sink.Restore(); + + // Wait with timeout + startTime = DateTime.UtcNow; + while (!_richTextBox.Text.Contains("Test message") && DateTime.UtcNow - startTime < timeout) + { + Thread.Sleep(50); + } + + var text = _richTextBox.Text; + Assert.Contains("Test message", text); + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs index 28e6efd..c0dd92f 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs @@ -47,13 +47,27 @@ protected string RenderAndGetText(LogEvent logEvent, string outputTemplate, IFor public virtual void Dispose() { - GC.SuppressFinalize(this); - if (!_disposed) + if (_disposed) return; + + _disposed = true; + + try { + // Dispose sink first to stop background processing _sink?.Dispose(); - _richTextBox?.Dispose(); - _disposed = true; + + // Then dispose the RichTextBox + if (_richTextBox != null && !_richTextBox.IsDisposed) + { + _richTextBox.Dispose(); + } } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error during test cleanup: {ex}"); + } + + GC.SuppressFinalize(this); } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs index d4d45e0..ff667ae 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs @@ -62,5 +62,39 @@ public static LoggerConfiguration RichTextBox( var sink = new RichTextBoxSink(richTextBoxControl, options, renderer); return sinkConfiguration.Sink(sink, minimumLogEventLevel, levelSwitch); } + + /// + /// Adds a sink that writes log events to the specified Windows Forms + /// using colour-coded rich-text formatting, returning the sink instance for direct access. + /// + /// The logger sink configuration this extension method operates on. + /// The target instance that will display the log output. + /// Outputs the created sink instance, which can be used to call Clear() and Restore() methods. + /// Optional theme controlling colours of individual message tokens. When null, is used. + /// When true (default) the control automatically scrolls to the newest log entry. + /// Maximum number of log events retained in the circular buffer and rendered in the control. + /// Message template that controls the textual representation of each log event. + /// Culture-specific or custom formatting provider, or null to use the invariant culture. + /// Minimum level below which events are ignored by this sink. + /// Optional switch allowing the minimum log level to be changed at runtime. + /// A object that can be further configured. + public static LoggerConfiguration RichTextBox( + this LoggerSinkConfiguration sinkConfiguration, + RichTextBox richTextBoxControl, + out RichTextBoxSink sink, + Theme? theme = null, + bool autoScroll = true, + int maxLogLines = 256, + string outputTemplate = OutputTemplate, + IFormatProvider? formatProvider = null, + LogEventLevel minimumLogEventLevel = LogEventLevel.Verbose, + LoggingLevelSwitch? levelSwitch = null) + { + var appliedTheme = theme ?? ThemePresets.Literate; + var renderer = new TemplateRenderer(appliedTheme, outputTemplate, formatProvider); + var options = new RichTextBoxSinkOptions(appliedTheme, autoScroll, maxLogLines, outputTemplate, formatProvider); + sink = new RichTextBoxSink(richTextBoxControl, options, renderer); + return sinkConfiguration.Sink(sink, minimumLogEventLevel, levelSwitch); + } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj b/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj index 4ed5008..43ef78a 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj @@ -22,11 +22,12 @@ net462;net471;net6.0-windows;net8.0-windows;net9.0-windows;netcoreapp3.0-windows;netcoreapp3.1-windows true true - 3.0.1 + 3.1.0 - - Adjusted MaxLogLines constraint from 512 to 2048 lines. - - Fixed bug where the RTB control would not persist the zoom factor. - - Minor improvements to the concurrent circular buffer. + - Added `Clear()` and `Restore()` methods to hide or show buffered log entries in the view without deleting them. + - You can now access the sink instance via `.WriteTo.RichTextBox(richTextBox, out sink, ...)` to call these methods. + + **Note:** The buffer is fixed size; old entries may be lost if overwritten after clearing. See repository for more information: https://github.com/vonhoff/Serilog.Sinks.RichTextBox.WinForms.Colored diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Collections/ConcurrentCircularBuffer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Collections/ConcurrentCircularBuffer.cs index 3dc9924..bf0c696 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Collections/ConcurrentCircularBuffer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Collections/ConcurrentCircularBuffer.cs @@ -9,11 +9,13 @@ internal sealed class ConcurrentCircularBuffer private readonly int _capacity; private int _head; private int _count; + private int _clearIndex; public ConcurrentCircularBuffer(int capacity) { _capacity = capacity > 0 ? capacity : 1; _buffer = new T[_capacity]; + _clearIndex = 0; } public void Add(T item) @@ -47,7 +49,15 @@ public void TakeSnapshot(List target) { target.Clear(); - for (var i = 0; i < _count; ++i) + var startIndex = _clearIndex; + var itemsToShow = _count - startIndex; + + if (startIndex >= _count) + { + return; + } + + for (var i = startIndex; i < _count; ++i) { var index = _head + i; if (index >= _capacity) @@ -59,5 +69,21 @@ public void TakeSnapshot(List target) } } } + + public void Clear() + { + lock (_sync) + { + _clearIndex = _count; + } + } + + public void Restore() + { + lock (_sync) + { + _clearIndex = 0; + } + } } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Extensions/RichTextBoxExtensions.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Extensions/RichTextBoxExtensions.cs index 7d8d8fe..818c3db 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Extensions/RichTextBoxExtensions.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Extensions/RichTextBoxExtensions.cs @@ -58,7 +58,21 @@ public static void SetRtf(this RichTextBox richTextBox, string rtf, bool autoScr { if (richTextBox.InvokeRequired) { - richTextBox.BeginInvoke(new Action(() => SetRtfInternal(richTextBox, rtf, autoScroll))); + try + { + // Use a timeout to prevent deadlocks + var asyncResult = richTextBox.BeginInvoke(new Action(() => SetRtfInternal(richTextBox, rtf, autoScroll))); + + // Wait for the invoke to complete with a timeout + if (!asyncResult.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(10))) + { + System.Diagnostics.Debug.WriteLine("Warning: SetRtf BeginInvoke timed out"); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in SetRtf BeginInvoke: {ex}"); + } return; } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs index 09fad68..419d38d 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs @@ -66,10 +66,22 @@ public void Dispose() { if (_disposed) return; - _disposed = true; + lock (this) + { + if (_disposed) return; + _disposed = true; + } + _tokenSource.Cancel(); _signal.Set(); - _processingTask.Wait(); + + // Add timeout to prevent indefinite blocking + if (!_processingTask.Wait(TimeSpan.FromSeconds(5))) + { + // If the task doesn't complete within 5 seconds, log a warning + System.Diagnostics.Debug.WriteLine("Warning: Processing task did not complete within timeout during disposal"); + } + _signal.Dispose(); _tokenSource.Dispose(); GC.SuppressFinalize(this); @@ -82,6 +94,20 @@ public void Emit(LogEvent logEvent) _signal.Set(); } + public void Clear() + { + _buffer.Clear(); + Interlocked.Exchange(ref _hasNewMessages, 1); + _signal.Set(); + } + + public void Restore() + { + _buffer.Restore(); + Interlocked.Exchange(ref _hasNewMessages, 1); + _signal.Set(); + } + private void ProcessMessages(CancellationToken token) { var builder = new RtfBuilder(_options.Theme); @@ -91,32 +117,41 @@ private void ProcessMessages(CancellationToken token) while (!token.IsCancellationRequested) { - _signal.WaitOne(); - - if (Interlocked.CompareExchange(ref _hasNewMessages, 0, 1) == 1) + try { - var now = DateTime.UtcNow; - var elapsed = now - lastFlush; - if (elapsed < flushInterval) + _signal.WaitOne(); + + if (Interlocked.CompareExchange(ref _hasNewMessages, 0, 1) == 1) { - var remaining = flushInterval - elapsed; - if (remaining > TimeSpan.Zero) + var now = DateTime.UtcNow; + var elapsed = now - lastFlush; + if (elapsed < flushInterval) { - Thread.Sleep(remaining); + var remaining = flushInterval - elapsed; + if (remaining > TimeSpan.Zero) + { + Thread.Sleep(remaining); + } } - } - - Interlocked.Exchange(ref _hasNewMessages, 0); - // Take a snapshot of the current buffer - _buffer.TakeSnapshot(snapshot); - builder.Clear(); - foreach (var evt in snapshot) - { - _renderer.Render(evt, builder); + _buffer.TakeSnapshot(snapshot); + builder.Clear(); + foreach (var evt in snapshot) + { + _renderer.Render(evt, builder); + } + _richTextBox.SetRtf(builder.Rtf, _options.AutoScroll); + lastFlush = DateTime.UtcNow; } - _richTextBox.SetRtf(builder.Rtf, _options.AutoScroll); - lastFlush = DateTime.UtcNow; + } + catch (Exception ex) + { + // Log the exception but don't let it crash the processing thread + // In a production environment, you might want to use a proper logging mechanism + System.Diagnostics.Debug.WriteLine($"Error in ProcessMessages: {ex}"); + + // Reset the flag to prevent infinite loops + Interlocked.Exchange(ref _hasNewMessages, 0); } } }