From 0e5df275e98dffd1b52b7f22548e287c197f10b7 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 2 Oct 2026 19:31:09 +0200 Subject: [PATCH] Extract Marker Bar coordination into MarkerBarController (#719) LogWindow.MarkerBar.cs kept ~20 marker state fields on LogWindow itself. MarkerBarController now owns the marker indexes, timer, counters and frame state behind a small IMarkerBarHost; LogWindow keeps only the wiring, the table-layout column width and its OnDpiChangedAfterParent override. Behaviour-preserving: the moved logic is unchanged apart from renames. Harmless deviations: - the filter-hit lock is only taken when the filter lane is visible - the marker column width is set after the bar's visibility and colours - marker warnings are logged under MarkerBarController instead of LogWindow HiddenLinesWindowTests reaches the click handler on the controller now; its assertions are unchanged. New MarkerBarControllerTests drive the controller through a fake host. --- .../HiddenLinesWindowTests.cs | 5 +- .../MarkerBarControllerTests.cs | 206 ++++++++ .../Controls/LogWindow/LogWindow.MarkerBar.cs | 393 --------------- .../Controls/LogWindow/LogWindow.cs | 115 +++-- .../Controls/LogWindow/MarkerBarController.cs | 459 ++++++++++++++++++ 5 files changed, 755 insertions(+), 423 deletions(-) create mode 100644 src/LogExpert.UI.Tests/MarkerBarControllerTests.cs delete mode 100644 src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs create mode 100644 src/LogExpert.UI/Controls/LogWindow/MarkerBarController.cs diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs index cbdaf137..ec2f121c 100644 --- a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -612,8 +612,9 @@ public void MarkerClick_OnHiddenLine_RevealsIt () var bar = Find(log, "markerBar"); // The bar raises LineSelected with the original line of the clicked marker; 11 is beyond the 10 visible rows. - _ = typeof(LogWindow).GetMethod("OnMarkerLineSelected", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! - .Invoke(log, [bar, new Core.EventArguments.SelectLineEventArgs(11)]); + var markers = typeof(LogWindow).GetField("_markerController", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(log)!; + _ = typeof(MarkerBarController).GetMethod("OnLineSelected", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(markers, [bar, new Core.EventArguments.SelectLineEventArgs(11)]); Assert.That(log.CurrentLineNum, Is.EqualTo(11)); Assert.That(log.ShowHiddenLines, Is.True); diff --git a/src/LogExpert.UI.Tests/MarkerBarControllerTests.cs b/src/LogExpert.UI.Tests/MarkerBarControllerTests.cs new file mode 100644 index 00000000..c9e3890b --- /dev/null +++ b/src/LogExpert.UI.Tests/MarkerBarControllerTests.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Config; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.UI.Tests; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[SupportedOSPlatform("windows")] +public sealed class MarkerBarControllerTests : IDisposable +{ + private const int LINE_COUNT = 10; + private const int HIGHLIGHT_LANE = 0; + private const int BOOKMARK_LANE = 1; + + private MarkerBarController? _controller; + + [TearDown] + public void TearDown () + { + Dispose(); + } + + public void Dispose () + { + _controller?.Dispose(); + _controller?.Bar.Dispose(); + _controller = null; + } + + [Test] + public void Update_FrameAggregatedBeforeADataChange_IsDiscarded () + { + using var host = new FakeHost(ReaderOf(_ => "ERROR")); + var controller = Create(host); + UpdateUntil(controller, () => HasMarkers(controller.Bar, HIGHLIGHT_LANE)); + + host.Bookmarks.Add(5); + controller.MarkDataChanged(); + host.FilterGate.Reset(); + var update = controller.UpdateAsync(); + controller.MarkDataChanged(); + host.FilterGate.Set(); + Complete(update); + + Assert.That(HasMarkers(controller.Bar, BOOKMARK_LANE), Is.False, "The frame was aggregated before the last data change."); + UpdateUntil(controller, () => HasMarkers(controller.Bar, BOOKMARK_LANE)); + } + + [Test] + public void Update_WhileARolloverIsPending_RendersNothing () + { + using var host = new FakeHost(ReaderOf(_ => "ERROR")); + var controller = Create(host); + UpdateUntil(controller, () => HasMarkers(controller.Bar, HIGHLIGHT_LANE)); + + controller.BeginRollover(); + for (var tick = 0; tick < 5; tick++) + { + Update(controller); + } + + Assert.That(HasMarkers(controller.Bar, HIGHLIGHT_LANE), Is.False); + controller.EndRollover(); + UpdateUntil(controller, () => HasMarkers(controller.Bar, HIGHLIGHT_LANE)); + } + + [Test] + public void Update_DiscoveryError_IsReportedOncePerSnapshot () + { + using var host = new FakeHost(ReaderOf(_ => throw new IOException("unreadable"))); + var controller = Create(host); + UpdateUntil(controller, () => host.Errors.Count > 0); + + for (var tick = 0; tick < 5; tick++) + { + Update(controller); + } + + Assert.That(host.Errors, Has.Count.EqualTo(1)); + Assert.That(host.Errors[0], Does.Contain("unreadable")); + } + + private MarkerBarController Create (FakeHost host) + { + var controller = _controller = new MarkerBarController(host); + controller.Bar.Size = new Size(80, 120); + controller.ApplyPreferences(new Preferences { ShowMarkerBar = true }, Color.White, Color.Black); + return controller; + } + + private static ILogfileReader ReaderOf (Func line) + { + var reader = new Mock(); + _ = reader.Setup(source => source.LineCount).Returns(LINE_COUNT); + _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny())).Returns((int number) => new LogLine(line(number), number)); + return reader.Object; + } + + // Lanes are a quarter of the width each; the discovery dots sit in the middle, clear of a lane's left edge. + private static bool HasMarkers (MarkerBar bar, int lane) + { + using var bitmap = new Bitmap(bar.Width, bar.Height); + bar.DrawToBitmap(bitmap, bar.ClientRectangle); + var x = lane * bar.Width / 4 + 3; + var background = bar.BackColor.ToArgb(); + return Enumerable.Range(bar.TopInset, bar.BucketHeight).Any(y => bitmap.GetPixel(x, y).ToArgb() != background); + } + + private static void Update (MarkerBarController controller) + { + Complete(controller.UpdateAsync()); + } + + private static void Complete (Task update) + { + var timer = Stopwatch.StartNew(); + while (!update.IsCompleted && timer.Elapsed < TimeSpan.FromSeconds(10)) + { + Application.DoEvents(); + Thread.Sleep(1); + } + + Assert.That(update.IsCompleted, Is.True, "Timed out waiting for the marker update."); + update.GetAwaiter().GetResult(); + } + + private static void UpdateUntil (MarkerBarController controller, Func condition) + { + var timer = Stopwatch.StartNew(); + while (!condition() && timer.Elapsed < TimeSpan.FromSeconds(10)) + { + Update(controller); + Thread.Sleep(5); + } + + Assert.That(condition(), Is.True, "Timed out waiting for the marker bar."); + } + + private sealed class FakeHost (ILogfileReader reader) : IMarkerBarHost, IDisposable + { + public ManualResetEventSlim FilterGate { get; } = new(true); + + public List Bookmarks { get; } = []; + + public List Errors { get; } = []; + + public ILogfileReader? Reader => reader; + + public string FileName => "markers.log"; + + public ILogLineMemoryColumnizer Columnizer { get; } = new Mock().Object; + + public string ConfigDir => string.Empty; + + public bool IsContentUnavailable => false; + + public bool IsClosed => false; + + public int ColumnHeaderHeight => 0; + + public int NavigableLineCount => LINE_COUNT; + + public MarkerCriteria GetHighlightCriteria () + { + return MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "ERROR", BackgroundColor = Color.Red }]); + } + + public int[] GetBookmarkLineNumbers () + { + return [.. Bookmarks]; + } + + // Runs inside the off-UI-thread aggregation: holding the gate pins an update between snapshot and render. + public int[] GetFilterHits () + { + _ = FilterGate.Wait(TimeSpan.FromSeconds(10)); + return []; + } + + public void StatusLineError (string text) + { + Errors.Add(text); + } + + public void RequestGotoLine (int targetLine) + { + } + + public void Dispose () + { + FilterGate.Dispose(); + } + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs deleted file mode 100644 index 5a900400..00000000 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs +++ /dev/null @@ -1,393 +0,0 @@ -using System.Globalization; - -using ColumnizerLib; - -using LogExpert.Core.Callback; -using LogExpert.Core.Classes.Columnizer; -using LogExpert.Core.Classes.Marker; -using LogExpert.Core.Entities; -using LogExpert.Core.EventArguments; -using LogExpert.Core.Interfaces; - -namespace LogExpert.UI.Controls.LogWindow; - -internal partial class LogWindow -{ - private readonly MarkerIndex _highlightMarkers = new(); - private readonly MarkerIndex _searchMarkers = new(); - private readonly Lock _markerStateLock = new(); - private readonly MarkerBar _markerBar = new() { Name = "markerBar" }; - private readonly System.Windows.Forms.Timer _markerTimer = new() { Interval = 250 }; - private SearchParams? _markerSearch; - private bool _searchMarkersCleared; - private (bool Bar, bool Highlights, bool Bookmarks, bool Search, bool Filter) _markerVisibility; - private MarkerScanSource _markerRebuild = MarkerScanSource.All; - // Criteria/file generation, rendered data changes, and edits to the unfinished last line are distinct. - private int _markerGeneration; - private int _markerRevision; - private int _markerContentRevision; - private int _markerHighlightContentRevision = -1; - private int _markerSearchContentRevision = -1; - private int _markerTailPending; - private MarkerFrame? _markerFrame; - private MarkerSnapshot? _markerReportedHighlightError; - private MarkerSnapshot? _markerReportedSearchError; - private bool _markerRendering; - private bool _markersDisposed; - - private void InitializeMarkerBar () - { - tableLayoutPanel1.ColumnCount = 3; - _ = tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 0)); - tableLayoutPanel1.Controls.Add(_markerBar, 2, 1); - _markerBar.LineSelected += OnMarkerLineSelected; - _markerBar.ClearSearchRequested += OnClearSearchMarkers; - _markerTimer.Tick += OnMarkerTimerTick; - ApplyMarkerPreferences(); - _markerTimer.Start(); - } - - protected override void OnDpiChangedAfterParent (EventArgs e) - { - base.OnDpiChangedAfterParent(e); - ApplyMarkerPreferences(); - } - - private void ApplyMarkerPreferences () - { - var visibility = (Preferences.ShowMarkerBar, Preferences.ShowHighlightMarkers, - Preferences.ShowBookmarkMarkers, Preferences.ShowSearchMarkers, Preferences.ShowFilterMarkers); - if (_markerVisibility != visibility) - { - var sources = _markerVisibility.Bar != visibility.ShowMarkerBar ? MarkerScanSource.All - : (_markerVisibility.Highlights != visibility.ShowHighlightMarkers ? MarkerScanSource.Highlights : MarkerScanSource.None) - | (_markerVisibility.Search != visibility.ShowSearchMarkers ? MarkerScanSource.Search : MarkerScanSource.None); - _markerVisibility = visibility; - InvalidateMarkerCriteria(sources); - _markerBar.ClearBuckets(); - } - - tableLayoutPanel1.ColumnStyles[2].Width = visibility.ShowMarkerBar ? (int)Math.Round(28d * DeviceDpi / 96d) : 0; - _markerBar.Visible = visibility.ShowMarkerBar; - _markerBar.BackColor = dataGridView.BackgroundColor; - _markerBar.ForeColor = dataGridView.ForeColor; - } - - // May be called by the reader or tail worker: invalidate before any queued UI work. - private void InvalidateMarkerCriteria (MarkerScanSource sources) - { - lock (_markerStateLock) - { - Interlocked.Increment(ref _markerGeneration); - if ((sources & MarkerScanSource.Highlights) != 0) - { - _highlightMarkers.Reset(null, null); - } - - if ((sources & MarkerScanSource.Search) != 0) - { - _searchMarkers.Reset(null, null); - } - - _markerRebuild |= sources; - } - MarkMarkerDataChanged(); - } - - private void MarkMarkerDataChanged () - { - Interlocked.Increment(ref _markerRevision); - } - - private void TrackMarkerSearch (SearchParams search) - { - var sameCriteria = _markerSearch != null - && _markerSearch.SearchText == search.SearchText - && _markerSearch.IsRegex == search.IsRegex - && _markerSearch.IsCaseSensitive == search.IsCaseSensitive; - if (search.IsFindNext && sameCriteria) - { - return; - } - - _markerSearch = new SearchParams(); - _markerSearch.CopyFrom(search); - _searchMarkersCleared = false; - InvalidateMarkerCriteria(MarkerScanSource.Search); - _markerBar.ClearBuckets(); - } - - private void OnClearSearchMarkers (object? sender, EventArgs eventArgs) - { - _searchMarkersCleared = true; - InvalidateMarkerCriteria(MarkerScanSource.Search); - _markerBar.ClearBuckets(); - } - - private void ConfigureMarkerIndexes () - { - MarkerScanSource sources; - int generation; - lock (_markerStateLock) - { - sources = _markerRebuild; - _markerRebuild = MarkerScanSource.None; - generation = _markerGeneration; - } - if (sources == MarkerScanSource.None) - { - return; - } - - var reader = _logFileReader; - MarkerCriteria? highlightCriteria = null; - Func>? columns = null; - if ((sources & MarkerScanSource.Highlights) != 0 && _markerVisibility.Bar && _markerVisibility.Highlights && reader != null) - { - lock (_currentHighlightGroupLock) - { - highlightCriteria = MarkerCriteria.ForHighlights(_currentHighlightGroup.HighlightEntryList); - } - - if (!highlightCriteria.IsEmpty) - { - columns = CaptureMarkerColumns(reader); - } - } - - var searchCriteria = _markerVisibility.Bar && _markerVisibility.Search && !_searchMarkersCleared && _markerSearch != null - ? MarkerCriteria.ForSearch(_markerSearch, Color.DodgerBlue.ToArgb()) : null; - lock (_markerStateLock) - { - if (generation != _markerGeneration) - { - _markerRebuild |= sources; - return; - } - - if ((sources & MarkerScanSource.Highlights) != 0) - { - _highlightMarkers.Reset(reader, highlightCriteria, columns); - _markerHighlightContentRevision = -1; - _markerReportedHighlightError = null; - } - - if ((sources & MarkerScanSource.Search) != 0) - { - _searchMarkers.Reset(reader, searchCriteria); - _markerSearchContentRevision = -1; - _markerReportedSearchError = null; - } - } - } - - private Func> CaptureMarkerColumns (ILogfileReader reader) - { - var template = CurrentColumnizer; - var snapshot = (template as IColumnizerSnapshotMemory)?.CreateSnapshot(); - var directory = ConfigManager.ActiveConfigDir; - var offset = template.IsTimeshiftImplemented() ? template.GetTimeOffset() : 0; - var callback = new ColumnizerCallback(new MarkerLineSource(reader, FileName)); - var parser = new Lazy(() => - { - // Initialization may read file headers. Run it on the discovery worker, never the UI thread. - var workerColumnizer = snapshot ?? ColumnizerPicker.CloneMemoryColumnizer(template, directory) - ?? throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, - Resources.Columnizer_SnapshotUnavailable, template.GetName())); - if (snapshot == null) - { - (workerColumnizer as IInitColumnizerMemory)?.Selected(callback); - } - - if (workerColumnizer.IsTimeshiftImplemented()) - { - workerColumnizer.SetTimeOffset(offset); - } - - return workerColumnizer; - }); - return (lineNumber, line) => - { - callback.LineNum = lineNumber; - return parser.Value.SplitLine(callback, line).ColumnValues - .Select(column => (ITextValueMemory)new LogLine(column.Text.ToString(), lineNumber)).ToArray(); - }; - } - - private async void OnMarkerTimerTick (object? sender, EventArgs eventArgs) - { - if (_markersDisposed || _isClosing || IsDisposed || _markerRendering) - { - return; - } - - if (_isLoading || _isDeadFile || Volatile.Read(ref _markerTailPending) != 0) - { - _markerBar.ClearBuckets(); - return; - } - - _markerRendering = true; - try - { - ConfigureMarkerIndexes(); - if (!_markerVisibility.Bar || _logFileReader == null) - { - return; - } - - _markerBar.TopInset = dataGridView.ColumnHeadersVisible ? dataGridView.ColumnHeadersHeight : 0; - _markerBar.BottomInset = SystemInformation.HorizontalScrollBarHeight; - var lineCount = _logFileReader.LineCount; - var contentRevision = Volatile.Read(ref _markerContentRevision); - if (!_highlightMarkers.IsScanning) - { - _ = _highlightMarkers.UpdateAsync(lineCount, contentRevision != _markerHighlightContentRevision); - _markerHighlightContentRevision = contentRevision; - } - - if (!_searchMarkers.IsScanning) - { - _ = _searchMarkers.UpdateAsync(lineCount, contentRevision != _markerSearchContentRevision); - _markerSearchContentRevision = contentRevision; - } - - MarkerSnapshot highlights; - MarkerSnapshot search; - int generation; - lock (_markerStateLock) - { - // A snapshot and its file/criteria generation must be captured atomically with invalidation. - generation = _markerGeneration; - highlights = _highlightMarkers.Snapshot; - search = _searchMarkers.Snapshot; - } - var discovering = _highlightMarkers.IsScanning || _searchMarkers.IsScanning; - _markerBar.SetDiscovering(discovering); - ReportMarkerError(highlights, ref _markerReportedHighlightError); - ReportMarkerError(search, ref _markerReportedSearchError); - var height = _markerBar.BucketHeight; - var revision = Volatile.Read(ref _markerRevision); - var foregroundArgb = _markerBar.ForeColor.ToArgb(); - var frame = new MarkerFrame(generation, revision, lineCount, height, foregroundArgb, highlights, search); - if (_markerFrame == frame) - { - return; - } - - var visibility = _markerVisibility; - // Capture bookmarks on the UI thread: legacy CSV import also writes the provider there. - var bookmarks = visibility.Bookmarks ? _bookmarkProvider.GetBookmarkLineNumbers() : []; - // Copy only existing hit numbers under short locks. Pixel aggregation never runs on the UI thread. - var buckets = await Task.Run(() => - { - int[] filterHits; - lock (_filterHitLock) - { - filterHits = visibility.Filter ? _filterHitList.ToArray() : []; - } - - return new Dictionary> - { - [MarkerCategory.Highlights] = MarkerBucket.Aggregate(highlights.Matches, lineCount, height, foregroundArgb), - [MarkerCategory.Bookmarks] = MarkerBucket.Aggregate(bookmarks.Select(line => new MarkerLine(line, Color.OrangeRed.ToArgb())), lineCount, height, foregroundArgb), - [MarkerCategory.Search] = MarkerBucket.Aggregate(search.Matches, lineCount, height, foregroundArgb), - [MarkerCategory.Filter] = MarkerBucket.Aggregate(filterHits.Order().Select(line => new MarkerLine(line, Color.MediumSeaGreen.ToArgb())), lineCount, height, foregroundArgb) - }; - }).ConfigureAwait(true); - lock (_markerStateLock) - { - if (_markersDisposed || _isClosing || IsDisposed || generation != _markerGeneration - || Volatile.Read(ref _markerTailPending) != 0 || _markerRebuild != MarkerScanSource.None - || foregroundArgb != _markerBar.ForeColor.ToArgb() - || revision != Volatile.Read(ref _markerRevision)) - { - return; - } - - _markerFrame = frame; - _markerBar.SetBuckets(buckets, height, discovering); - } - } - catch (Exception exception) - { - if (!_markersDisposed && !_isClosing && !IsDisposed) - { - _logger.Warn(exception, "Marker update failed"); - StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ScanFailed, exception.Message)); - } - } - finally - { - _markerRendering = false; - } - } - - private void ReportMarkerError (MarkerSnapshot snapshot, ref MarkerSnapshot? reported) - { - if (snapshot.Error != null && !ReferenceEquals(snapshot, reported)) - { - reported = snapshot; - _logger.Warn(snapshot.Error, "Marker discovery failed"); - StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ScanFailed, snapshot.Error.Message)); - } - } - - private void OnMarkerLineSelected (object? sender, SelectLineEventArgs eventArgs) - { - // The tracker's map, not the displayed one: that is empty during the first scan, when clicks must be queued. - if (_markerFrame?.Generation == Volatile.Read(ref _markerGeneration) - && eventArgs.Line >= 0 && eventArgs.Line < _lineVisibility.Map.LineCount) - { - RequestGotoLine(eventArgs.Line + 1); - } - } - - private void DisposeMarkers () - { - if (_markersDisposed) - { - return; - } - - _markersDisposed = true; - _markerTimer.Stop(); - _markerTimer.Dispose(); - _highlightMarkers.Dispose(); - _searchMarkers.Dispose(); - _markerFrame = null; - _markerReportedHighlightError = null; - _markerReportedSearchError = null; - if (!_markerBar.IsDisposed) - { - _markerBar.ClearBuckets(); - } - } - - [Flags] - private enum MarkerScanSource - { - None = 0, - Highlights = 1, - Search = 2, - All = Highlights | Search - } - - private sealed record MarkerFrame (int Generation, int Revision, int LineCount, int Height, int ForegroundArgb, - MarkerSnapshot Highlights, MarkerSnapshot Search); - - private sealed class MarkerLineSource (ILogfileReader reader, string fileName) : ILogLineSource - { - public int LineCount => reader.LineCount; - - public ILogLineMemory GetLineMemory (int lineNum) - { - return reader.GetLogLineMemory(lineNum); - } - - public string GetCurrentFileName (int lineNum) - { - return reader is IMultiFileNavigation navigation ? navigation.GetLogFileNameForLine(lineNum) : fileName; - } - } -} \ No newline at end of file diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 6cf91223..63d42349 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -16,6 +16,7 @@ using LogExpert.Core.Classes.Filter; using LogExpert.Core.Classes.Highlight; using LogExpert.Core.Classes.Log; +using LogExpert.Core.Classes.Marker; using LogExpert.Core.Classes.Persister; using LogExpert.Core.Classes.Search; using LogExpert.Core.Classes.Tail; @@ -42,7 +43,7 @@ namespace LogExpert.UI.Controls.LogWindow; [SupportedOSPlatform("windows")] -internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow, ITimestampSource, ITailFollowSink, IVisibleRowsHost +internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow, ITimestampSource, ITailFollowSink, IVisibleRowsHost, IMarkerBarHost { #region Fields @@ -138,6 +139,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private readonly HiddenLinesBar _hiddenLinesBar = new(); private LineVisibilityTracker _lineVisibility; private VisibleRows _visibleRows; + private readonly MarkerBarController _markerController; private PendingNavigation? _pendingNavigation; private bool _isSearching; @@ -179,6 +181,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL [SupportedOSPlatform("windows")] public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, bool isTempFile, bool forcePersistenceLoading, IConfigManager configManager, IPluginRegistry pluginRegistry) { + _markerController = new MarkerBarController(this); SuspendLayout(); //HighDPI Functionality must be called before all UI Elements are initialized, to make sure they work as intended @@ -422,6 +425,45 @@ public bool IsMultiFile #endregion + #region IMarkerBarHost + + ILogfileReader IMarkerBarHost.Reader => _logFileReader; + string IMarkerBarHost.FileName => FileName; + ILogLineMemoryColumnizer IMarkerBarHost.Columnizer => CurrentColumnizer; + string IMarkerBarHost.ConfigDir => ConfigManager.ActiveConfigDir; + bool IMarkerBarHost.IsContentUnavailable => _isLoading || _isDeadFile; + bool IMarkerBarHost.IsClosed => _isClosing || IsDisposed; + int IMarkerBarHost.ColumnHeaderHeight => dataGridView.ColumnHeadersVisible ? dataGridView.ColumnHeadersHeight : 0; + int IMarkerBarHost.NavigableLineCount => _lineVisibility.Map.LineCount; + + MarkerCriteria IMarkerBarHost.GetHighlightCriteria () + { + lock (_currentHighlightGroupLock) + { + return MarkerCriteria.ForHighlights(_currentHighlightGroup.HighlightEntryList); + } + } + + int[] IMarkerBarHost.GetBookmarkLineNumbers () + { + return _bookmarkProvider.GetBookmarkLineNumbers(); + } + + int[] IMarkerBarHost.GetFilterHits () + { + lock (_filterHitLock) + { + return _filterHitList.ToArray(); + } + } + + void IMarkerBarHost.StatusLineError (string text) + { + StatusLineError(text); + } + + #endregion + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public bool ForcePersistenceLoading { get; set; } @@ -670,6 +712,12 @@ protected override string GetPersistString () return "LogWindow#" + FileName; } + protected override void OnDpiChangedAfterParent (EventArgs e) + { + base.OnDpiChangedAfterParent(e); + ApplyMarkerPreferences(); + } + #endregion [SupportedOSPlatform("windows")] @@ -774,7 +822,7 @@ protected void OnFilterListChanged (LogWindow source) protected void OnCurrentHighlightListChanged () { - InvalidateMarkerCriteria(MarkerScanSource.Highlights); + _markerController.InvalidateCriteria(MarkerScanSource.Highlights); RebuildLineVisibility(); CurrentHighlightGroupChanged?.Invoke(this, new CurrentHighlightGroupChangedEventArgs(this, _currentHighlightGroup)); } @@ -782,7 +830,7 @@ protected void OnCurrentHighlightListChanged () //TODO Double Check why the bookmark Providers have the Event and the LogWindow, and if it still necessary protected void OnBookmarksChanged () { - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); BookmarksChanged?.Invoke(this, EventArgs.Empty); } @@ -793,7 +841,7 @@ protected void OnBookmarkTextChanged (Bookmark bookmark) protected void OnColumnizerChanged (ILogLineMemoryColumnizer columnizer) { - InvalidateMarkerCriteria(MarkerScanSource.Highlights); + _markerController.InvalidateCriteria(MarkerScanSource.Highlights); ColumnizerChanged?.Invoke(this, new ColumnizerEventArgs(columnizer)); } @@ -825,7 +873,7 @@ private void OnLogWindowLoad (object sender, EventArgs e) [SupportedOSPlatform("windows")] private void OnLogWindowDisposed (object sender, EventArgs e) { - DisposeMarkers(); + _markerController.Dispose(); _lineVisibility.Dispose(); _waitingForClose = true; CancelPendingLineNavigation(); @@ -872,7 +920,7 @@ private void OnLogFileReaderFinishedLoading (object sender, EventArgs e) _ = Invoke(new MethodInvoker(RunHighlightBookmarkScan)); Invoke(() => { - InvalidateMarkerCriteria(MarkerScanSource.All); + _markerController.InvalidateCriteria(MarkerScanSource.All); _isLoadComplete = true; UpdateLineNavigationReadiness(); }); @@ -927,7 +975,7 @@ private void OnLogFileReaderLoadFile (object sender, LoadFileEventArgs e) { if (e.NewFile) { - InvalidateMarkerCriteria(MarkerScanSource.All); + _markerController.InvalidateCriteria(MarkerScanSource.All); // File was new created (e.g. rollover) _isDeadFile = false; UnRegisterLogFileReaderEvents(); @@ -945,8 +993,7 @@ private void OnFileSizeChanged (object sender, LogEventArgs e) { if (e.IsRollover || e.LineCount < e.PrevLineCount) { - Interlocked.Increment(ref _markerTailPending); - InvalidateMarkerCriteria(MarkerScanSource.All); + _markerController.BeginRollover(); } _tailFollowEngine.Post(e); @@ -1087,7 +1134,7 @@ private void OnDataGridViewCellValuePushed (object sender, DataGridViewCellValue var newValue = (string)e.Value; CurrentColumnizer.PushValue(ColumnizerCallbackObject, e.ColumnIndex - 2, newValue, oldValue); - InvalidateMarkerCriteria(MarkerScanSource.Highlights); + _markerController.InvalidateCriteria(MarkerScanSource.Highlights); dataGridView.Refresh(); TimeSpan timeSpan = new(CurrentColumnizer.GetTimeOffset() * TimeSpan.TicksPerMillisecond); @@ -2397,7 +2444,7 @@ private void OnColumnComboBoxPreviewKeyDown (object sender, PreviewKeyDownEventA [SupportedOSPlatform("windows")] private void OnBookmarkProviderBookmarkRemoved (object sender, EventArgs e) { - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); if (!_isLoading) { dataGridView.Refresh(); @@ -2408,7 +2455,7 @@ private void OnBookmarkProviderBookmarkRemoved (object sender, EventArgs e) [SupportedOSPlatform("windows")] private void OnBookmarkProviderBookmarkAdded (object sender, EventArgs e) { - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); if (!_isLoading) { dataGridView.Refresh(); @@ -2418,7 +2465,7 @@ private void OnBookmarkProviderBookmarkAdded (object sender, EventArgs e) private void OnBookmarkProviderAllBookmarksRemoved (object sender, EventArgs e) { - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); } private void OnLogWindowLeave (object sender, EventArgs e) @@ -2481,6 +2528,21 @@ internal Task WhenLineVisibilityIdle () return _lineVisibility.WhenIdle(); } + private void InitializeMarkerBar () + { + tableLayoutPanel1.ColumnCount = 3; + _ = tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 0)); + tableLayoutPanel1.Controls.Add(_markerController.Bar, 2, 1); + ApplyMarkerPreferences(); + _markerController.Start(); + } + + private void ApplyMarkerPreferences () + { + _markerController.ApplyPreferences(Preferences, dataGridView.BackgroundColor, dataGridView.ForeColor); + tableLayoutPanel1.ColumnStyles[2].Width = Preferences.ShowMarkerBar ? (int)Math.Round(28d * DeviceDpi / 96d) : 0; + } + private void InitializeLineVisibility () { _lineVisibility = new LineVisibilityTracker( @@ -3015,8 +3077,7 @@ private void EnterLoadFileStatus () _isReadyForLineNavigation = false; _isLoading = true; - InvalidateMarkerCriteria(MarkerScanSource.All); - _markerBar.ClearBuckets(); + _markerController.Clear(); FireCancelHandlers(); // reload cancels the jobs of the old content, not the window lifetime _searchCts?.Cancel(); ClearFilterList(); @@ -3038,8 +3099,7 @@ private void PositionAfterReload (ReloadMemento reloadMemento) [SupportedOSPlatform("windows")] private void LogfileDead () { - InvalidateMarkerCriteria(MarkerScanSource.All); - _markerBar.ClearBuckets(); + _markerController.Clear(); CancelPendingLineNavigation(); _isDeadFile = true; @@ -3313,15 +3373,14 @@ void ITailFollowSink.OnTailLines (LogEventArgs e) { if (e.IsRollover || e.LineCount < e.PrevLineCount) { - Interlocked.Decrement(ref _markerTailPending); + _markerController.EndRollover(); } } } void ITailFollowSink.OnLineCountChanged (int lineCount) { - Interlocked.Increment(ref _markerContentRevision); - MarkMarkerDataChanged(); + _markerController.MarkContentChanged(); _timeSpreadCalc.SetLineCount(lineCount); } @@ -4667,7 +4726,7 @@ private async void FilterSearch (string text) FilterSpread.TrimHistory(_lastFilterLinesList); } - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); // Closing the window cancels the run (linked token), so this continuation also fires // mid-close — with the handle already destroyed, narration and BeginInvoke would throw. @@ -4717,7 +4776,7 @@ private void AddFilterLine (int lineNum, bool immediate) } } - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); if (immediate) { @@ -4889,7 +4948,7 @@ private void ClearFilterList () { _filterHitList = []; } - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); //this.filterGridView.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.DisplayedCells); filterGridView.ResumeLayout(); } @@ -4922,7 +4981,7 @@ private void ShiftFilterLines (int offset) { _filterHitList = FilterSpread.ShiftLines(_filterHitList, offset); } - MarkMarkerDataChanged(); + _markerController.MarkDataChanged(); _lastFilterLinesList = FilterSpread.RebuildHistory(_filterResultList); TriggerFilterLineGuiUpdate(); @@ -6208,7 +6267,7 @@ public void Close (bool dontAsk) public void CloseLogWindow () { _isClosing = true; - DisposeMarkers(); + _markerController.Dispose(); CancelPendingLineNavigation(); CancelHighlightBookmarkScan(); @@ -6789,7 +6848,7 @@ public void StartSearch () : CurrentLineNum - 1; _currentSearchParams = searchParams; // remember for async "not found" messages - TrackMarkerSearch(searchParams); + _markerController.TrackSearch(searchParams); _isSearching = true; StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel); @@ -7544,7 +7603,7 @@ or ArgumentOutOfRangeException CurrentColumnizer.SetTimeOffset(0); } - InvalidateMarkerCriteria(MarkerScanSource.Highlights); + _markerController.InvalidateCriteria(MarkerScanSource.Highlights); dataGridView.Refresh(); filterGridView.Refresh(); if (CurrentColumnizer.IsTimeshiftImplemented()) @@ -8019,7 +8078,7 @@ public void SetCurrentHighlightGroup (string groupName) _guiStateArgs.HighlightGroupName = _currentHighlightGroup.GroupName; } - InvalidateMarkerCriteria(MarkerScanSource.Highlights); + _markerController.InvalidateCriteria(MarkerScanSource.Highlights); RebuildLineVisibility(); SendGuiStateUpdate(); diff --git a/src/LogExpert.UI/Controls/LogWindow/MarkerBarController.cs b/src/LogExpert.UI/Controls/LogWindow/MarkerBarController.cs new file mode 100644 index 00000000..aeecadad --- /dev/null +++ b/src/LogExpert.UI/Controls/LogWindow/MarkerBarController.cs @@ -0,0 +1,459 @@ +using System.Globalization; + +using ColumnizerLib; + +using LogExpert.Core.Callback; +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Classes.Marker; +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.EventArguments; +using LogExpert.Core.Interfaces; + +using NLog; + +namespace LogExpert.UI.Controls.LogWindow; + +/// What a reads from, and reports to, its Log Window. +internal interface IMarkerBarHost +{ + ILogfileReader? Reader { get; } + + string FileName { get; } + + ILogLineMemoryColumnizer Columnizer { get; } + + string ConfigDir { get; } + + /// Loading or dead file: the bar shows nothing. + bool IsContentUnavailable { get; } + + bool IsClosed { get; } + + int ColumnHeaderHeight { get; } + + /// The line visibility tracker's line count; clicks beyond it are ignored. + int NavigableLineCount { get; } + + /// Taken under the window's highlight-group lock. + MarkerCriteria GetHighlightCriteria (); + + /// Called on the UI thread. + int[] GetBookmarkLineNumbers (); + + /// Called on a worker thread. + int[] GetFilterHits (); + + void StatusLineError (string text); + + void RequestGotoLine (int targetLine); +} + +[Flags] +internal enum MarkerScanSource +{ + None = 0, + Highlights = 1, + Search = 2, + All = Highlights | Search +} + +/// +/// Coordinates a Log Window's : configures the highlight and Log Search es, +/// and every 250 ms takes their snapshots and renders a frame unless the file, criteria or data changed meanwhile. +/// UI thread only, except , , , +/// and . +/// +internal sealed class MarkerBarController : IDisposable +{ + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private readonly IMarkerBarHost _host; + private readonly MarkerIndex _highlightMarkers = new(); + private readonly MarkerIndex _searchMarkers = new(); + private readonly Lock _stateLock = new(); + private readonly System.Windows.Forms.Timer _timer = new() { Interval = 250 }; + private SearchParams? _search; + private bool _searchMarkersCleared; + private (bool Bar, bool Highlights, bool Bookmarks, bool Search, bool Filter) _visibility; + private MarkerScanSource _rebuild = MarkerScanSource.All; + // Criteria/file generation, rendered data changes, and edits to the unfinished last line are distinct. + private int _generation; + private int _revision; + private int _contentRevision; + private int _highlightContentRevision = -1; + private int _searchContentRevision = -1; + private int _rolloverPending; + private MarkerFrame? _frame; + private MarkerSnapshot? _reportedHighlightError; + private MarkerSnapshot? _reportedSearchError; + private bool _rendering; + private bool _disposed; + + public MarkerBarController (IMarkerBarHost host) + { + _host = host; + Bar.LineSelected += OnLineSelected; + Bar.ClearSearchRequested += OnClearSearchRequested; + _timer.Tick += OnTimerTick; + } + + public MarkerBar Bar { get; } = new() { Name = "markerBar" }; + + public void Start () + { + _timer.Start(); + } + + public void ApplyPreferences (Preferences preferences, Color backColor, Color foreColor) + { + var visibility = (preferences.ShowMarkerBar, preferences.ShowHighlightMarkers, + preferences.ShowBookmarkMarkers, preferences.ShowSearchMarkers, preferences.ShowFilterMarkers); + if (_visibility != visibility) + { + var sources = _visibility.Bar != visibility.ShowMarkerBar ? MarkerScanSource.All + : (_visibility.Highlights != visibility.ShowHighlightMarkers ? MarkerScanSource.Highlights : MarkerScanSource.None) + | (_visibility.Search != visibility.ShowSearchMarkers ? MarkerScanSource.Search : MarkerScanSource.None); + _visibility = visibility; + InvalidateCriteria(sources); + Bar.ClearBuckets(); + } + + Bar.Visible = visibility.ShowMarkerBar; + Bar.BackColor = backColor; + Bar.ForeColor = foreColor; + } + + // May be called by the reader or tail worker: invalidate before any queued UI work. + public void InvalidateCriteria (MarkerScanSource sources) + { + lock (_stateLock) + { + Interlocked.Increment(ref _generation); + if ((sources & MarkerScanSource.Highlights) != 0) + { + _highlightMarkers.Reset(null, null); + } + + if ((sources & MarkerScanSource.Search) != 0) + { + _searchMarkers.Reset(null, null); + } + + _rebuild |= sources; + } + MarkDataChanged(); + } + + /// Invalidates every source and blanks the bar until the next frame. + public void Clear () + { + InvalidateCriteria(MarkerScanSource.All); + Bar.ClearBuckets(); + } + + public void MarkDataChanged () + { + Interlocked.Increment(ref _revision); + } + + /// The line count changed: the unfinished last line may have been edited. + public void MarkContentChanged () + { + Interlocked.Increment(ref _contentRevision); + MarkDataChanged(); + } + + /// A rollover or truncation is pending; the bar stays blank until the matching . + public void BeginRollover () + { + Interlocked.Increment(ref _rolloverPending); + InvalidateCriteria(MarkerScanSource.All); + } + + public void EndRollover () + { + Interlocked.Decrement(ref _rolloverPending); + } + + public void TrackSearch (SearchParams search) + { + var sameCriteria = _search != null + && _search.SearchText == search.SearchText + && _search.IsRegex == search.IsRegex + && _search.IsCaseSensitive == search.IsCaseSensitive; + if (search.IsFindNext && sameCriteria) + { + return; + } + + _search = new SearchParams(); + _search.CopyFrom(search); + _searchMarkersCleared = false; + InvalidateCriteria(MarkerScanSource.Search); + Bar.ClearBuckets(); + } + + /// One timer tick: rebuilds invalidated indexes, advances their scans and renders a frame if anything changed. + internal async Task UpdateAsync () + { + if (_disposed || _host.IsClosed || _rendering) + { + return; + } + + if (_host.IsContentUnavailable || Volatile.Read(ref _rolloverPending) != 0) + { + Bar.ClearBuckets(); + return; + } + + _rendering = true; + try + { + ConfigureIndexes(); + if (!_visibility.Bar || _host.Reader == null) + { + return; + } + + Bar.TopInset = _host.ColumnHeaderHeight; + Bar.BottomInset = SystemInformation.HorizontalScrollBarHeight; + var lineCount = _host.Reader.LineCount; + var contentRevision = Volatile.Read(ref _contentRevision); + if (!_highlightMarkers.IsScanning) + { + _ = _highlightMarkers.UpdateAsync(lineCount, contentRevision != _highlightContentRevision); + _highlightContentRevision = contentRevision; + } + + if (!_searchMarkers.IsScanning) + { + _ = _searchMarkers.UpdateAsync(lineCount, contentRevision != _searchContentRevision); + _searchContentRevision = contentRevision; + } + + MarkerSnapshot highlights; + MarkerSnapshot search; + int generation; + lock (_stateLock) + { + // A snapshot and its file/criteria generation must be captured atomically with invalidation. + generation = _generation; + highlights = _highlightMarkers.Snapshot; + search = _searchMarkers.Snapshot; + } + var discovering = _highlightMarkers.IsScanning || _searchMarkers.IsScanning; + Bar.SetDiscovering(discovering); + ReportError(highlights, ref _reportedHighlightError); + ReportError(search, ref _reportedSearchError); + var height = Bar.BucketHeight; + var revision = Volatile.Read(ref _revision); + var foregroundArgb = Bar.ForeColor.ToArgb(); + var frame = new MarkerFrame(generation, revision, lineCount, height, foregroundArgb, highlights, search); + if (_frame == frame) + { + return; + } + + var visibility = _visibility; + // Capture bookmarks on the UI thread: legacy CSV import also writes the provider there. + var bookmarks = visibility.Bookmarks ? _host.GetBookmarkLineNumbers() : []; + // Copy only existing hit numbers under short locks. Pixel aggregation never runs on the UI thread. + var buckets = await Task.Run(() => + { + var filterHits = visibility.Filter ? _host.GetFilterHits() : []; + + return new Dictionary> + { + [MarkerCategory.Highlights] = MarkerBucket.Aggregate(highlights.Matches, lineCount, height, foregroundArgb), + [MarkerCategory.Bookmarks] = MarkerBucket.Aggregate(bookmarks.Select(line => new MarkerLine(line, Color.OrangeRed.ToArgb())), lineCount, height, foregroundArgb), + [MarkerCategory.Search] = MarkerBucket.Aggregate(search.Matches, lineCount, height, foregroundArgb), + [MarkerCategory.Filter] = MarkerBucket.Aggregate(filterHits.Order().Select(line => new MarkerLine(line, Color.MediumSeaGreen.ToArgb())), lineCount, height, foregroundArgb) + }; + }).ConfigureAwait(true); + lock (_stateLock) + { + if (_disposed || _host.IsClosed || generation != _generation + || Volatile.Read(ref _rolloverPending) != 0 || _rebuild != MarkerScanSource.None + || foregroundArgb != Bar.ForeColor.ToArgb() + || revision != Volatile.Read(ref _revision)) + { + return; + } + + _frame = frame; + Bar.SetBuckets(buckets, height, discovering); + } + } + catch (Exception exception) + { + if (!_disposed && !_host.IsClosed) + { + _logger.Warn(exception, "Marker update failed"); + _host.StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ScanFailed, exception.Message)); + } + } + finally + { + _rendering = false; + } + } + + public void Dispose () + { + if (_disposed) + { + return; + } + + _disposed = true; + _timer.Stop(); + _timer.Dispose(); + _highlightMarkers.Dispose(); + _searchMarkers.Dispose(); + _frame = null; + _reportedHighlightError = null; + _reportedSearchError = null; + if (!Bar.IsDisposed) + { + Bar.ClearBuckets(); + } + } + + private void OnClearSearchRequested (object? sender, EventArgs eventArgs) + { + _searchMarkersCleared = true; + InvalidateCriteria(MarkerScanSource.Search); + Bar.ClearBuckets(); + } + + private void ConfigureIndexes () + { + MarkerScanSource sources; + int generation; + lock (_stateLock) + { + sources = _rebuild; + _rebuild = MarkerScanSource.None; + generation = _generation; + } + if (sources == MarkerScanSource.None) + { + return; + } + + var reader = _host.Reader; + MarkerCriteria? highlightCriteria = null; + Func>? columns = null; + if ((sources & MarkerScanSource.Highlights) != 0 && _visibility.Bar && _visibility.Highlights && reader != null) + { + highlightCriteria = _host.GetHighlightCriteria(); + if (!highlightCriteria.IsEmpty) + { + columns = CaptureColumns(reader); + } + } + + var searchCriteria = _visibility.Bar && _visibility.Search && !_searchMarkersCleared && _search != null + ? MarkerCriteria.ForSearch(_search, Color.DodgerBlue.ToArgb()) : null; + lock (_stateLock) + { + if (generation != _generation) + { + _rebuild |= sources; + return; + } + + if ((sources & MarkerScanSource.Highlights) != 0) + { + _highlightMarkers.Reset(reader, highlightCriteria, columns); + _highlightContentRevision = -1; + _reportedHighlightError = null; + } + + if ((sources & MarkerScanSource.Search) != 0) + { + _searchMarkers.Reset(reader, searchCriteria); + _searchContentRevision = -1; + _reportedSearchError = null; + } + } + } + + private Func> CaptureColumns (ILogfileReader reader) + { + var template = _host.Columnizer; + var snapshot = (template as IColumnizerSnapshotMemory)?.CreateSnapshot(); + var directory = _host.ConfigDir; + var offset = template.IsTimeshiftImplemented() ? template.GetTimeOffset() : 0; + var callback = new ColumnizerCallback(new MarkerLineSource(reader, _host.FileName)); + var parser = new Lazy(() => + { + // Initialization may read file headers. Run it on the discovery worker, never the UI thread. + var workerColumnizer = snapshot ?? ColumnizerPicker.CloneMemoryColumnizer(template, directory) + ?? throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, + Resources.Columnizer_SnapshotUnavailable, template.GetName())); + if (snapshot == null) + { + (workerColumnizer as IInitColumnizerMemory)?.Selected(callback); + } + + if (workerColumnizer.IsTimeshiftImplemented()) + { + workerColumnizer.SetTimeOffset(offset); + } + + return workerColumnizer; + }); + return (lineNumber, line) => + { + callback.LineNum = lineNumber; + return parser.Value.SplitLine(callback, line).ColumnValues + .Select(column => (ITextValueMemory)new LogLine(column.Text.ToString(), lineNumber)).ToArray(); + }; + } + + private async void OnTimerTick (object? sender, EventArgs eventArgs) + { + await UpdateAsync().ConfigureAwait(true); + } + + private void ReportError (MarkerSnapshot snapshot, ref MarkerSnapshot? reported) + { + if (snapshot.Error != null && !ReferenceEquals(snapshot, reported)) + { + reported = snapshot; + _logger.Warn(snapshot.Error, "Marker discovery failed"); + _host.StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ScanFailed, snapshot.Error.Message)); + } + } + + private void OnLineSelected (object? sender, SelectLineEventArgs eventArgs) + { + // The tracker's map, not the displayed one: that is empty during the first scan, when clicks must be queued. + if (_frame?.Generation == Volatile.Read(ref _generation) + && eventArgs.Line >= 0 && eventArgs.Line < _host.NavigableLineCount) + { + _host.RequestGotoLine(eventArgs.Line + 1); + } + } + + private sealed record MarkerFrame (int Generation, int Revision, int LineCount, int Height, int ForegroundArgb, + MarkerSnapshot Highlights, MarkerSnapshot Search); + + private sealed class MarkerLineSource (ILogfileReader reader, string fileName) : ILogLineSource + { + public int LineCount => reader.LineCount; + + public ILogLineMemory GetLineMemory (int lineNum) + { + return reader.GetLogLineMemory(lineNum); + } + + public string GetCurrentFileName (int lineNum) + { + return reader is IMultiFileNavigation navigation ? navigation.GetLogFileNameForLine(lineNum) : fileName; + } + } +}