diff --git a/CONTEXT.md b/CONTEXT.md
index ee1364669..f203b80b0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -23,6 +23,25 @@ meaning; do not redefine them locally.
construction, since the bulk `HighlightBookmarkScanner` has no access to
the side-effecting triggers.
+## Marker Bar
+
+- **Marker Bar** — A compact overview beside a Log Window that shows where
+ highlights, bookmarks, Log Search hits, and Window Filter hits occur across
+ the loaded file.
+- **Marker** — An indicator in the Marker Bar representing one or more matching
+ Log Lines. Markers are grouped by category; they do not create or replace
+ Bookmarks.
+- **Marker Snapshot** — An immutable collection of highlight or Log Search
+ matches discovered for the Marker Bar, together with how many Log Lines
+ have been scanned and any discovery failure.
+- **Bookmark** — A user- or auto-generated annotation attached to a specific
+ Log Line, carrying optional comment text and an overlay. A Bookmark can be
+ represented by a Marker in the bookmark category; the two terms are not synonyms.
+
+*Avoid*: "marker" when referring to the underlying Bookmark annotation;
+bare "snapshot" when the distinction between **Marker Snapshot**,
+**Columnizer Snapshot**, and **Session Snapshot** matters.
+
## Audio alerts
- **Audio Alert** — A sound played when a tail-only highlight match occurs.
@@ -293,6 +312,9 @@ layer).
line into columns. Each loaded log window has exactly one active
columnizer at a time. The set of available columnizers is owned by
`PluginRegistry`.
+- **Columnizer Snapshot** — An independent Columnizer with the active
+ Columnizer's configuration and detected column layout, ready for Marker Bar
+ discovery. Its parsing state is separate from the active Columnizer's.
- **Columnizer Mask Entry** (`ColumnizerMaskEntry`) — One user-configured
row on the Settings → Columnizers tab. Pairs a **Mask**, a **Mask Type**,
and a **Columnizer Name**. Stored in `Preferences.ColumnizerMaskList`.
diff --git a/src/ColumnizerLib/IColumnizerSnapshotMemory.cs b/src/ColumnizerLib/IColumnizerSnapshotMemory.cs
new file mode 100644
index 000000000..83662ec2a
--- /dev/null
+++ b/src/ColumnizerLib/IColumnizerSnapshotMemory.cs
@@ -0,0 +1,19 @@
+namespace ColumnizerLib;
+
+///
+/// Optional capability for capturing a columnizer's current parsing state for background use.
+///
+///
+/// The snapshot must be initialized with the current configuration and detected column layout,
+/// without sharing mutable parsing state with the original columnizer. The Marker Bar captures
+/// the snapshot on the UI thread and uses it on a worker thread without calling
+/// . Capture must be quick and perform no file I/O.
+///
+public interface IColumnizerSnapshotMemory
+{
+ ///
+ /// Creates an independent, initialized columnizer with the current configuration and detected layout.
+ ///
+ /// A columnizer ready to parse log lines on a worker thread.
+ ILogLineMemoryColumnizer CreateSnapshot ();
+}
\ No newline at end of file
diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs
index afce24e8b..e780fd2c7 100644
--- a/src/CsvColumnizer/CsvColumnizer.cs
+++ b/src/CsvColumnizer/CsvColumnizer.cs
@@ -17,7 +17,7 @@ namespace CsvColumnizer;
/// The IPreProcessColumnizer is implemented to read field names from the very first line of the file. Then
/// the line is dropped. So it's not seen by LogExpert. The field names will be used as column names.
///
-public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory
+public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
{
#region Fields
@@ -220,6 +220,35 @@ public void DeSelected (ILogLineMemoryColumnizerCallback callback)
// nothing to do
}
+ public ILogLineMemoryColumnizer CreateSnapshot ()
+ {
+ CsvColumnizerConfig config = new()
+ {
+ CommentChar = _config.CommentChar,
+ DelimiterChar = _config.DelimiterChar,
+ EscapeChar = _config.EscapeChar,
+ HasFieldNames = _config.HasFieldNames,
+ MinColumns = _config.MinColumns,
+ QuoteChar = _config.QuoteChar,
+ VersionBuild = _config.VersionBuild
+ };
+ config.ConfigureReaderConfiguration();
+
+ CsvColumnizer clone = new()
+ {
+ _config = config,
+ _isValidCsv = _isValidCsv,
+ _firstLine = _firstLine == null ? null : new CsvLogLine(_firstLine.FullLine.ToString(), _firstLine.LineNumber)
+ };
+
+ foreach (var column in _columnList)
+ {
+ clone._columnList.Add(new CsvColumn(column.Name));
+ }
+
+ return clone;
+ }
+
public void Configure (ILogLineMemoryColumnizerCallback callback, string configDir)
{
var configPath = configDir + "\\" + CONFIGFILENAME;
diff --git a/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs b/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs
index ffd8f5ced..7e7765d21 100644
--- a/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs
+++ b/src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs
@@ -11,7 +11,7 @@
[assembly: SupportedOSPlatform("windows")]
namespace Log4jXmlColumnizer;
-public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory
+public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
{
#region Fields
@@ -278,6 +278,24 @@ public void LoadConfig (string configDir)
}
}
+ public ILogLineMemoryColumnizer CreateSnapshot ()
+ {
+ Log4jXmlColumnizer clone = new()
+ {
+ _config = new Log4jXmlColumnizerConfig(GetAllColumnNames())
+ {
+ LocalTimestamps = _config.LocalTimestamps,
+ ColumnList = [.. _config.ColumnList.Select(entry => new Log4jColumnEntry(entry.ColumnName, entry.ColumnIndex, entry.MaxLen)
+ {
+ Visible = entry.Visible
+ })]
+ },
+ _timeOffset = _timeOffset
+ };
+
+ return clone;
+ }
+
public Priority GetPriority (string fileName, IEnumerable samples)
{
ArgumentNullException.ThrowIfNull(fileName);
diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
index 7432df739..a89db391a 100644
--- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
+++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
@@ -85,6 +85,14 @@ public int GetBookmarkIndexForLine (int lineNum)
}
}
+ public int[] GetBookmarkLineNumbers ()
+ {
+ lock (_bookmarkListLock)
+ {
+ return [.. BookmarkList.Keys];
+ }
+ }
+
public Entities.Bookmark GetBookmarkForLine (int lineNum)
{
lock (_bookmarkListLock)
@@ -148,19 +156,22 @@ public bool ConvertToManualBookmark (int lineNum)
public void ShiftBookmarks (int offset)
{
- SortedList newBookmarkList = [];
-
- foreach (var bookmark in BookmarkList.Values)
+ lock (_bookmarkListLock)
{
- var line = bookmark.LineNum - offset;
- if (line >= 0)
+ SortedList newBookmarkList = [];
+
+ foreach (var bookmark in BookmarkList.Values)
{
- bookmark.LineNum = line;
- newBookmarkList.Add(line, bookmark);
+ var line = bookmark.LineNum - offset;
+ if (line >= 0)
+ {
+ bookmark.LineNum = line;
+ newBookmarkList.Add(line, bookmark);
+ }
}
- }
- BookmarkList = newBookmarkList;
+ BookmarkList = newBookmarkList;
+ }
}
public int FindPrevBookmarkIndex (int lineNum)
diff --git a/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs b/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs
index fffd3d3d8..05b658ac8 100644
--- a/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs
+++ b/src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs
@@ -13,7 +13,7 @@ namespace LogExpert.Core.Classes.Columnizer;
/// memory-efficient log line processing and columnizer prioritization, making it suitable for integration with log
/// viewers or analysis tools that require flexible column extraction.
///
-public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory
+public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
{
#region ILogLineMemoryColumnizer implementation
@@ -41,6 +41,19 @@ public SquareBracketColumnizer (int columnCount, bool isTimeExists) : this()
}
}
+ ///
+ /// Creates an independent copy of the detected column layout and time offset.
+ ///
+ public ILogLineMemoryColumnizer CreateSnapshot ()
+ {
+ return new SquareBracketColumnizer
+ {
+ _columnCount = _columnCount,
+ _isTimeExists = _isTimeExists,
+ _timeOffset = _timeOffset
+ };
+ }
+
///
/// Determines whether timeshift functionality is implemented.
///
diff --git a/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs
new file mode 100644
index 000000000..20f73dd79
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Marker/MarkerBucket.cs
@@ -0,0 +1,57 @@
+namespace LogExpert.Core.Classes.Marker;
+
+/// A populated vertical pixel, including its logical range and navigation target.
+public readonly record struct MarkerBucket (int Pixel, int FirstLine, int LastLine, int Count, int TargetLine, int ColorArgb)
+{
+ /// Aggregates a line-ordered index without sampling and resolves inherited foregrounds; duplicate lines count only once.
+ public static IReadOnlyList Aggregate (IEnumerable matches, int lineCount, int height, int defaultForegroundArgb)
+ {
+ ArgumentNullException.ThrowIfNull(matches);
+ if (lineCount <= 0 || height <= 0)
+ {
+ return [];
+ }
+
+ var buckets = new MarkerBucket[height];
+ var priorities = new int[height];
+ Array.Fill(priorities, int.MaxValue);
+ var previousLine = -1;
+ foreach (var match in matches)
+ {
+ var line = match.LineNumber;
+ if (line < 0 || line >= lineCount)
+ {
+ continue;
+ }
+
+ var pixel = lineCount == 1 ? 0 : (int)((long)line * (height - 1) / (lineCount - 1));
+ var bucket = buckets[pixel];
+ // Invert the floor mapping above: a bucket starts at ceil(pixel * (lines - 1) / (height - 1))
+ // and ends immediately before the next bucket starts. The final pixel includes the last line.
+ var first = height == 1 ? 0 : (int)(((long)pixel * (lineCount - 1) + height - 2) / (height - 1));
+ var last = height == 1 || pixel == height - 1 || lineCount == 1 ? lineCount - 1
+ : (int)(((long)(pixel + 1) * (lineCount - 1) + height - 2) / (height - 1)) - 1;
+ var target = bucket.Count == 0 ? line : bucket.TargetLine;
+ // Double distances to compare against the midpoint without rounding half-line ties.
+ var distance = Math.Abs(2L * line - first - last);
+ var targetDistance = Math.Abs(2L * target - first - last);
+ if (distance < targetDistance || (distance == targetDistance && line < target))
+ {
+ target = line;
+ }
+
+ var color = bucket.ColorArgb;
+ if (match.Priority < priorities[pixel])
+ {
+ priorities[pixel] = match.Priority;
+ color = match.ColorArgb ?? defaultForegroundArgb;
+ }
+
+ buckets[pixel] = new MarkerBucket(pixel, first, last,
+ bucket.Count + (line == previousLine ? 0 : 1), target, color);
+ previousLine = line;
+ }
+
+ return buckets.Where(bucket => bucket.Count > 0).ToArray();
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs b/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs
new file mode 100644
index 000000000..2901d6b91
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Marker/MarkerCriteria.cs
@@ -0,0 +1,121 @@
+using System.Drawing;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Entities;
+
+namespace LogExpert.Core.Classes.Marker;
+
+/// Snapshot of the matching and visual portions of marker criteria. Never invokes triggers.
+public sealed class MarkerCriteria
+{
+ private readonly HighlightEntry[] _entries;
+ private HighlightEntry? _search;
+
+ public bool IsEmpty => _search == null && !_entries.Any(IsVisual);
+
+ private MarkerCriteria (HighlightEntry[] entries)
+ {
+ _entries = entries;
+ }
+
+ public static MarkerCriteria ForHighlights (IEnumerable entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ return new MarkerCriteria(entries.Select(entry => (HighlightEntry)entry.Clone()).ToArray());
+ }
+
+ public static MarkerCriteria ForSearch (SearchParams search, int colorArgb)
+ {
+ ArgumentNullException.ThrowIfNull(search);
+ return new MarkerCriteria([])
+ {
+ _search = string.IsNullOrEmpty(search.SearchText) ? null : new HighlightEntry
+ {
+ SearchText = search.SearchText,
+ IsRegex = search.IsRegex,
+ IsCaseSensitive = search.IsCaseSensitive,
+ BackgroundColor = Color.FromArgb(colorArgb)
+ }
+ };
+ }
+
+ public MarkerLine? Match (int lineNumber, ILogLineMemory line,
+ Func>? getColumns = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(line);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (_search != null)
+ {
+ var matched = _search.IsRegex
+ ? _search.Regex.IsMatch(line.FullLine.Span)
+ : line.FullLine.Span.Contains(_search.SearchText,
+ _search.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
+ return matched ? new MarkerLine(lineNumber, _search.BackgroundColor.ToArgb()) : null;
+ }
+
+ IReadOnlyList? columns = null;
+ for (var priority = 0; priority < _entries.Length; priority++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var entry = _entries[priority];
+ if (!IsVisual(entry))
+ {
+ continue;
+ }
+
+ bool matched;
+ if (entry.IsWordMatch)
+ {
+ columns ??= getColumns?.Invoke(lineNumber, line)
+ ?? [new Column { FullValue = line.FullLine }];
+ matched = columns.Any(column => HasVisibleWordMatch(entry, column));
+ }
+ else
+ {
+ matched = HighlightEvaluator.IsMatch(entry, line);
+ }
+
+ if (matched)
+ {
+ int? color = HasBackground(entry) ? entry.BackgroundColor.ToArgb()
+ : HasForeground(entry) ? entry.ForegroundColor.ToArgb() : null;
+ return new MarkerLine(lineNumber, color, priority);
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsVisual (HighlightEntry entry)
+ {
+ return !entry.IsSearchHit && (HasBackground(entry)
+ || HasForeground(entry) || entry.IsBold);
+ }
+
+ private static bool HasBackground (HighlightEntry entry)
+ {
+ return (!entry.IsWordMatch || !entry.NoBackground) && entry.BackgroundColor.A > 0;
+ }
+
+ private static bool HasForeground (HighlightEntry entry)
+ {
+ return entry.ForegroundColor.A > 0;
+ }
+
+ private static bool HasVisibleWordMatch (HighlightEntry entry, ITextValueMemory column)
+ {
+ // Word highlighting uses Regex even for literal text, against each displayed column.
+ foreach (var match in entry.Regex.EnumerateMatches(column.Text.Span))
+ {
+ if (match.Length > 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs b/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs
new file mode 100644
index 000000000..23c7d910f
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Marker/MarkerIndex.cs
@@ -0,0 +1,206 @@
+using System.Globalization;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Interfaces;
+
+namespace LogExpert.Core.Classes.Marker;
+
+/// Owns a cancellable background index for one matching source in one Log Window.
+public sealed class MarkerIndex : IDisposable
+{
+ private const int READ_BATCH_SIZE = 256;
+ private const int MATCH_CHUNK_SIZE = 4096;
+ private readonly Lock _gate = new();
+ private ILogfileReader? _reader;
+ private MarkerCriteria? _criteria;
+ private Func>? _getColumns;
+ private MarkerSnapshot _snapshot = new([], 0);
+ private CancellationTokenSource? _scanCts;
+ private Task? _work;
+ private int _generation;
+ private bool _disposed;
+
+ public MarkerSnapshot Snapshot
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _snapshot;
+ }
+ }
+ }
+
+ public bool IsScanning
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _work != null;
+ }
+ }
+ }
+
+ public void Reset (ILogfileReader? reader, MarkerCriteria? criteria,
+ Func>? getColumns = null)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _generation++;
+ _scanCts?.Cancel();
+ _scanCts = null;
+ _work = null;
+ _reader = reader;
+ _criteria = criteria;
+ _getColumns = getColumns;
+ _snapshot = new MarkerSnapshot([], 0);
+ }
+ }
+
+ public Task UpdateAsync (int lineCount, bool lastLineChanged = false)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _reader == null || _criteria == null)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (_work != null)
+ {
+ return _work;
+ }
+
+ lineCount = Math.Max(0, lineCount);
+ if (lineCount < _snapshot.ScannedLineCount)
+ {
+ _snapshot = new MarkerSnapshot([], 0);
+ }
+
+ if (_snapshot.Error != null || (!lastLineChanged && lineCount == _snapshot.ScannedLineCount))
+ {
+ return Task.CompletedTask;
+ }
+
+ if (_criteria.IsEmpty)
+ {
+ _snapshot = new MarkerSnapshot([], lineCount);
+ return Task.CompletedTask;
+ }
+
+ var reader = _reader;
+ var criteria = _criteria;
+ var columns = _getColumns;
+ var previous = _snapshot;
+ var generation = _generation;
+ var cts = new CancellationTokenSource();
+ _scanCts = cts;
+ _work = Task.Run(() => Scan(reader, criteria, columns, previous, lineCount, generation, cts));
+ return _work;
+ }
+ }
+
+ public void Dispose ()
+ {
+ lock (_gate)
+ {
+ _disposed = true;
+ _generation++;
+ _scanCts?.Cancel();
+ _scanCts = null;
+ _work = null;
+ _reader = null;
+ _criteria = null;
+ _getColumns = null;
+ _snapshot = new MarkerSnapshot([], 0);
+ }
+ }
+
+ private void Scan (ILogfileReader reader, MarkerCriteria criteria,
+ Func>? columns,
+ MarkerSnapshot previous, int lineCount, int generation, CancellationTokenSource cancellationSource)
+ {
+ using var scanCancellation = cancellationSource;
+ var start = Math.Max(0, previous.ScannedLineCount - 1);
+ List chunks = [];
+ List pending = new(MATCH_CHUNK_SIZE);
+ var scanned = start;
+ Exception? error = null;
+ try
+ {
+ // Retain completed chunks; only the last line can change during an append.
+ foreach (var chunk in previous.Chunks)
+ {
+ if (chunk[^1].LineNumber < start)
+ {
+ chunks.Add(chunk);
+ }
+ else
+ {
+ pending.AddRange(chunk.TakeWhile(match => match.LineNumber < start));
+ }
+ }
+
+ for (var batchStart = start; batchStart < lineCount;)
+ {
+ scanCancellation.Token.ThrowIfCancellationRequested();
+ var batchEnd = (int)Math.Min((long)batchStart + READ_BATCH_SIZE, lineCount);
+ // Pin before reading and release after this small batch, never for the whole file.
+ using var pin = (reader as IBufferPinning)?.PinRange(batchStart, batchEnd - 1);
+ for (var lineNumber = batchStart; lineNumber < batchEnd; lineNumber++)
+ {
+ scanCancellation.Token.ThrowIfCancellationRequested();
+ var line = reader.GetLogLineMemory(lineNumber)
+ ?? throw new IOException(string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_LineUnavailable, lineNumber + 1));
+ var match = criteria.Match(lineNumber, line, columns, scanCancellation.Token);
+ if (match.HasValue)
+ {
+ pending.Add(match.Value);
+ if (pending.Count >= MATCH_CHUNK_SIZE)
+ {
+ chunks.Add(pending.ToArray());
+ pending.Clear();
+ }
+ }
+
+ scanned = lineNumber + 1;
+ }
+
+ batchStart = batchEnd;
+ }
+ }
+ catch (OperationCanceledException) when (scanCancellation.IsCancellationRequested)
+ {
+ // Reset/disposal owns invalidation; the generation check below rejects this result.
+ }
+ catch (Exception exception)
+ {
+ // Background boundary: report regex, reader and columnizer failures as an outcome.
+ error = exception;
+ }
+ finally
+ {
+ if (pending.Count > 0)
+ {
+ chunks.Add(pending.ToArray());
+ }
+
+ lock (_gate)
+ {
+ if (!_disposed && generation == _generation)
+ {
+ _snapshot = new MarkerSnapshot(chunks.ToArray(), scanned, error);
+ _work = null;
+ _scanCts = null;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Core/Classes/Marker/MarkerLine.cs b/src/LogExpert.Core/Classes/Marker/MarkerLine.cs
new file mode 100644
index 000000000..3b691dfd1
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Marker/MarkerLine.cs
@@ -0,0 +1,5 @@
+namespace LogExpert.Core.Classes.Marker;
+
+/// One matching logical line. Lower priorities precede higher priorities in the Highlight Group.
+/// The rule color, or null to inherit the foreground supplied during aggregation.
+public readonly record struct MarkerLine (int LineNumber, int? ColorArgb, int Priority = 0);
\ No newline at end of file
diff --git a/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs b/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs
new file mode 100644
index 000000000..0245120d2
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Marker/MarkerSnapshot.cs
@@ -0,0 +1,20 @@
+namespace LogExpert.Core.Classes.Marker;
+
+/// Immutable scan result. Match arrays are shared between append-only revisions.
+public sealed class MarkerSnapshot
+{
+ internal MarkerSnapshot (MarkerLine[][] chunks, int scannedLineCount, Exception? error = null)
+ {
+ Chunks = chunks;
+ ScannedLineCount = scannedLineCount;
+ Error = error;
+ }
+
+ internal MarkerLine[][] Chunks { get; }
+
+ public int ScannedLineCount { get; }
+
+ public Exception? Error { get; }
+
+ public IEnumerable Matches => Chunks.SelectMany(chunk => chunk);
+}
\ No newline at end of file
diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs
index 8e70cc8a5..75a121367 100644
--- a/src/LogExpert.Core/Config/Preferences.cs
+++ b/src/LogExpert.Core/Config/Preferences.cs
@@ -156,6 +156,16 @@ public bool? DarkMode
public bool ShowBubbles { get; set; } = true;
+ public bool ShowMarkerBar { get; set; }
+
+ public bool ShowHighlightMarkers { get; set; } = true;
+
+ public bool ShowBookmarkMarkers { get; set; } = true;
+
+ public bool ShowSearchMarkers { get; set; } = true;
+
+ public bool ShowFilterMarkers { get; set; } = true;
+
public bool ShowColumnFinder { get; set; }
public Color ShowTailColor { get; set; } = Color.FromKnownColor(KnownColor.Blue);
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index c3ee90d1e..a97e02a26 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -408,6 +408,15 @@ public static string ColorComboBox_UI_ColorComboBox_Text_Custom {
}
}
+ ///
+ /// Looks up a localized string similar to Could not create a parsing snapshot for columnizer '{0}'..
+ ///
+ public static string Columnizer_SnapshotUnavailable {
+ get {
+ return ResourceManager.GetString("Columnizer_SnapshotUnavailable", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Timestamp selector.
///
@@ -4146,6 +4155,96 @@ public static string LogWindow_UI_WriteFilterToTab_NamePrefix_ForFilter {
}
}
+ ///
+ /// Looks up a localized string similar to Bookmarks.
+ ///
+ public static string MarkerBar_Bookmarks {
+ get {
+ return ResourceManager.GetString("MarkerBar_Bookmarks", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Clear search.
+ ///
+ public static string MarkerBar_ClearSearch {
+ get {
+ return ResourceManager.GetString("MarkerBar_ClearSearch", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Discovering markers….
+ ///
+ public static string MarkerBar_Discovering {
+ get {
+ return ResourceManager.GetString("MarkerBar_Discovering", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Filter hits.
+ ///
+ public static string MarkerBar_FilterHits {
+ get {
+ return ResourceManager.GetString("MarkerBar_FilterHits", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Highlights.
+ ///
+ public static string MarkerBar_Highlights {
+ get {
+ return ResourceManager.GetString("MarkerBar_Highlights", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Could not read log line {0} while discovering markers..
+ ///
+ public static string MarkerBar_LineUnavailable {
+ get {
+ return ResourceManager.GetString("MarkerBar_LineUnavailable", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Marker discovery failed: {0}.
+ ///
+ public static string MarkerBar_ScanFailed {
+ get {
+ return ResourceManager.GetString("MarkerBar_ScanFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Search hits.
+ ///
+ public static string MarkerBar_SearchHits {
+ get {
+ return ResourceManager.GetString("MarkerBar_SearchHits", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Marker bar.
+ ///
+ public static string MarkerBar_Title {
+ get {
+ return ResourceManager.GetString("MarkerBar_Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}: lines {1}–{2}, {3} matching lines.
+ ///
+ public static string MarkerBar_ToolTip {
+ get {
+ return ResourceManager.GetString("MarkerBar_ToolTip", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Close existing tabs.
///
@@ -5481,6 +5580,15 @@ public static string SettingsDialog_UI_CheckBox_checkBoxSaveSessions {
}
}
+ ///
+ /// Looks up a localized string similar to Show bookmark markers.
+ ///
+ public static string SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Show Error Message?.
///
@@ -5490,6 +5598,42 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowErrorMessageOnlyOneI
}
}
+ ///
+ /// Looks up a localized string similar to Show filter markers.
+ ///
+ public static string SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Show highlight markers.
+ ///
+ public static string SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Show marker bar.
+ ///
+ public static string SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Show search markers.
+ ///
+ public static string SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Allow only 1 Instance.
///
@@ -6400,6 +6544,15 @@ public static string SettingsDialog_UI_TabPage_tabPageHighlightMask {
}
}
+ ///
+ /// Looks up a localized string similar to Marker bar.
+ ///
+ public static string SettingsDialog_UI_TabPage_tabPageMarkerBar {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageMarkerBar", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Memory/CPU.
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index fc9f21325..ff1360843 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -2297,4 +2297,55 @@ LogExpert neu starten, um die Änderungen zu übernehmen?
{0:N0}–{1:N0} von {2:N0} Treffern
+
+ Markierungsleiste
+
+
+ Markierungsleiste anzeigen
+
+
+ Hervorhebungsmarkierungen anzeigen
+
+
+ Lesezeichenmarkierungen anzeigen
+
+
+ Suchtreffermarkierungen anzeigen
+
+
+ Filtertreffermarkierungen anzeigen
+
+
+ Für den Columnizer „{0}“ konnte kein Snapshot des Parsing-Zustands erstellt werden.
+
+
+ Die Protokollzeile {0} konnte beim Ermitteln der Markierungen nicht gelesen werden.
+
+
+ Hervorhebungen
+
+
+ Lesezeichen
+
+
+ Suchtreffer
+
+
+ Filtertreffer
+
+
+ Suche löschen
+
+
+ Markierungen werden ermittelt…
+
+
+ {0}: Zeilen {1}–{2}, {3} passende Zeilen
+
+
+ Die Ermittlung der Markierungen ist fehlgeschlagen: {0}
+
+
+ Markierungsleiste
+
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 6398c8c6d..a275cbde2 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -2331,4 +2331,37 @@ Restart LogExpert to apply changes?
{0:N0}–{1:N0} of {2:N0} matches
+
+ Marker bar
+
+
+ Show marker bar
+
+
+ Show highlight markers
+
+
+ Show bookmark markers
+
+
+ Show search markers
+
+
+ Show filter markers
+
+
+ Could not create a parsing snapshot for columnizer '{0}'.
+
+
+ Could not read log line {0} while discovering markers.
+
+ Highlights
+ Bookmarks
+ Search hits
+ Filter hits
+ Clear search
+ Discovering markers…
+ {0}: lines {1}–{2}, {3} matching lines
+ Marker discovery failed: {0}
+ Marker bar
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 75efafc36..32e963ba2 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -2191,4 +2191,55 @@ YY[YY] = 年
显示第 {0:N0}–{1:N0} 个,共 {2:N0} 个匹配文件
+
+ 标记栏
+
+
+ 显示标记栏
+
+
+ 显示高亮标记
+
+
+ 显示书签标记
+
+
+ 显示搜索匹配标记
+
+
+ 显示过滤匹配标记
+
+
+ 无法为列分隔器 '{0}' 创建解析状态快照。
+
+
+ 查找标记时无法读取日志行 {0}。
+
+
+ 高亮
+
+
+ 书签
+
+
+ 搜索匹配
+
+
+ 过滤匹配
+
+
+ 清除搜索
+
+
+ 正在查找标记…
+
+
+ {0}:第 {1}–{2} 行,{3} 个匹配行
+
+
+ 标记查找失败:{0}
+
+
+ 标记栏
+
\ No newline at end of file
diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs
new file mode 100644
index 000000000..4ce80d1cd
--- /dev/null
+++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMarkerBarTests.cs
@@ -0,0 +1,58 @@
+using LogExpert.Core.Config;
+
+using Newtonsoft.Json;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.ConfigManagerTests;
+
+[TestFixture]
+public class PreferencesMarkerBarTests
+{
+ [Test]
+ public void Defaults_EnableMarkerBarOnDemand_AndShowAllMarkerKinds ()
+ {
+ var preferences = new Preferences();
+
+ Assert.That(preferences.ShowMarkerBar, Is.False);
+ Assert.That(preferences.ShowHighlightMarkers, Is.True);
+ Assert.That(preferences.ShowBookmarkMarkers, Is.True);
+ Assert.That(preferences.ShowSearchMarkers, Is.True);
+ Assert.That(preferences.ShowFilterMarkers, Is.True);
+ }
+
+ [Test]
+ public void RoundTrip_PreservesMarkerBarPreferences ()
+ {
+ var original = new Preferences
+ {
+ ShowMarkerBar = true,
+ ShowHighlightMarkers = false,
+ ShowBookmarkMarkers = false,
+ ShowSearchMarkers = false,
+ ShowFilterMarkers = true
+ };
+
+ var restored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original));
+
+ Assert.That(restored, Is.Not.Null);
+ Assert.That(restored!.ShowMarkerBar, Is.True);
+ Assert.That(restored.ShowHighlightMarkers, Is.False);
+ Assert.That(restored.ShowBookmarkMarkers, Is.False);
+ Assert.That(restored.ShowSearchMarkers, Is.False);
+ Assert.That(restored.ShowFilterMarkers, Is.True);
+ }
+
+ [Test]
+ public void Deserialize_LegacyJson_UsesMarkerBarDefaults ()
+ {
+ var restored = JsonConvert.DeserializeObject("{\"MaxLineLength\":1000}");
+
+ Assert.That(restored, Is.Not.Null);
+ Assert.That(restored!.ShowMarkerBar, Is.False);
+ Assert.That(restored.ShowHighlightMarkers, Is.True);
+ Assert.That(restored.ShowBookmarkMarkers, Is.True);
+ Assert.That(restored.ShowSearchMarkers, Is.True);
+ Assert.That(restored.ShowFilterMarkers, Is.True);
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/LogExpert.Tests.csproj b/src/LogExpert.Tests/LogExpert.Tests.csproj
index addb6a7a1..1faf8be1e 100644
--- a/src/LogExpert.Tests/LogExpert.Tests.csproj
+++ b/src/LogExpert.Tests/LogExpert.Tests.csproj
@@ -19,6 +19,7 @@
+
@@ -29,8 +30,6 @@
-
-
diff --git a/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs b/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs
new file mode 100644
index 000000000..48e6ba4f6
--- /dev/null
+++ b/src/LogExpert.Tests/Marker/BookmarkMarkerTests.cs
@@ -0,0 +1,48 @@
+using LogExpert.Core.Classes.Bookmark;
+
+using NUnit.Framework;
+
+using BookmarkEntry = LogExpert.Core.Entities.Bookmark;
+
+namespace LogExpert.Tests.Marker;
+
+[TestFixture]
+public class BookmarkMarkerTests
+{
+ [Test]
+ public void GetBookmarkLineNumbers_ReturnsImmutableSnapshotAcrossBookmarkChanges ()
+ {
+ var provider = new BookmarkDataProvider();
+ provider.SetBookmarks(new SortedList
+ {
+ { 10, new BookmarkEntry(10) },
+ { 20, BookmarkEntry.CreateAutoGenerated(20, "auto", "hit") }
+ });
+
+ var snapshot = provider.GetBookmarkLineNumbers();
+
+ provider.AddBookmark(new BookmarkEntry(30));
+ provider.RemoveBookmarkForLine(10);
+ _ = provider.AddBookmarks([new BookmarkEntry(40), BookmarkEntry.CreateAutoGenerated(50, "auto", "hit")]);
+ Assert.That(provider.GetBookmarkLineNumbers(), Is.EqualTo(new[] { 20, 30, 40, 50 }));
+ provider.SetBookmarks(new SortedList
+ {
+ { 60, new BookmarkEntry(60) },
+ { 70, BookmarkEntry.CreateAutoGenerated(70, "auto", "hit") }
+ });
+ provider.ClearAllBookmarks();
+ Assert.That(provider.GetBookmarkLineNumbers(), Is.Empty);
+ provider.SetBookmarks(new SortedList
+ {
+ { 80, new BookmarkEntry(80) },
+ { 90, BookmarkEntry.CreateAutoGenerated(90, "auto", "hit") }
+ });
+ provider.ShiftBookmarks(10);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(provider.GetBookmarkLineNumbers(), Is.EqualTo(new[] { 70, 80 }));
+ Assert.That(snapshot, Is.EqualTo(new[] { 10, 20 }));
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Marker/MarkerBucketTests.cs b/src/LogExpert.Tests/Marker/MarkerBucketTests.cs
new file mode 100644
index 000000000..b9eb79234
--- /dev/null
+++ b/src/LogExpert.Tests/Marker/MarkerBucketTests.cs
@@ -0,0 +1,95 @@
+using System.Drawing;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Marker;
+
+[TestFixture]
+public class MarkerBucketTests
+{
+ [Test]
+ public void Aggregate_BoldOnlyHighlightResolvesForegroundBeforeRendering ()
+ {
+ var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", IsBold = true }]);
+ var match = criteria.Match(0, new LogLine("hit", 0));
+ Assert.That(match, Is.Not.Null);
+
+ var buckets = MarkerBucket.Aggregate([match!.Value], 1, 1, Color.Green.ToArgb());
+
+ Assert.That(buckets.Single().ColorArgb, Is.EqualTo(Color.Green.ToArgb()));
+ }
+
+ [Test]
+ public void Aggregate_ChangedForegroundRecolorsOnlyInheritedMatches ()
+ {
+ MarkerLine[] matches = [new(0, null), new(1, Color.Red.ToArgb())];
+
+ var light = MarkerBucket.Aggregate(matches, 2, 2, Color.Black.ToArgb());
+ var dark = MarkerBucket.Aggregate(matches, 2, 2, Color.White.ToArgb());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(light.Select(bucket => bucket.ColorArgb), Is.EqualTo(new[] { Color.Black.ToArgb(), Color.Red.ToArgb() }));
+ Assert.That(dark.Select(bucket => bucket.ColorArgb), Is.EqualTo(new[] { Color.White.ToArgb(), Color.Red.ToArgb() }));
+ });
+ }
+
+ [Test]
+ public void Aggregate_FirstAndLastLinesReachBothEndsOfATallBar ()
+ {
+ var buckets = MarkerBucket.Aggregate([new MarkerLine(0, 10), new MarkerLine(2, 20)], 3, 400, Color.Black.ToArgb());
+ Assert.That(buckets.Select(bucket => bucket.Pixel), Is.EqualTo(new[] { 0, 399 }));
+ }
+ [Test]
+ public void Aggregate_DenseMatchesPreserveIsolatedLinesAndChooseNearestMidpoint ()
+ {
+ MarkerLine[] matches = [new(0, 10), new(6, 20, 2), new(9, 30, 1), new(99, 40)];
+
+ var buckets = MarkerBucket.Aggregate(matches, 100, 10, Color.Black.ToArgb());
+
+ Assert.That(buckets, Is.EqualTo(new MarkerBucket[]
+ {
+ new(0, 0, 10, 3, 6, 10),
+ new(9, 99, 99, 1, 99, 40)
+ }));
+ }
+
+ [Test]
+ public void Aggregate_TieChoosesLowerLineAndHighestPriorityColor ()
+ {
+ MarkerLine[] matches = [new(4, 10, 3), new(4, 10, 3), new(5, 20, 1)];
+
+ Assert.That(MarkerBucket.Aggregate(matches, 10, 1, Color.Black.ToArgb()),
+ Is.EqualTo(new MarkerBucket[] { new(0, 0, 9, 2, 4, 20) }));
+ }
+
+ [TestCase(0, 10)]
+ [TestCase(10, 0)]
+ [TestCase(10, -1)]
+ public void Aggregate_EmptyFileOrNoPixels_HasNoClickTargets (int lineCount, int height)
+ {
+ Assert.That(MarkerBucket.Aggregate([new MarkerLine(0, 10)], lineCount, height, Color.Black.ToArgb()), Is.Empty);
+ }
+
+ [Test]
+ public void Aggregate_SingleLineAndResizeKeepTheSameMatch ()
+ {
+ MarkerLine[] matches = [new(0, 10)];
+ Assert.That(MarkerBucket.Aggregate(matches, 1, 1, Color.Black.ToArgb()),
+ Is.EqualTo(new MarkerBucket[] { new(0, 0, 0, 1, 0, 10) }));
+ Assert.That(MarkerBucket.Aggregate(matches, 1, 300, Color.Black.ToArgb()),
+ Is.EqualTo(new MarkerBucket[] { new(0, 0, 0, 1, 0, 10) }));
+ }
+
+ [Test]
+ public void Aggregate_HighLineNumbersDoNotOverflow ()
+ {
+ Assert.That(MarkerBucket.Aggregate([new MarkerLine(int.MaxValue - 1, 10)], int.MaxValue, 2, Color.Black.ToArgb()),
+ Is.EqualTo(new MarkerBucket[] { new(1, 2147483646, 2147483646, 1, 2147483646, 10) }));
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs
new file mode 100644
index 000000000..2e297f43b
--- /dev/null
+++ b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs
@@ -0,0 +1,66 @@
+using System.Drawing;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+using LogExpert.Core.Entities;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Marker;
+
+[TestFixture]
+public class MarkerCriteriaTests
+{
+ [TestCase(false, false)]
+ [TestCase(true, true)]
+ public void Highlights_OnlyWordRulesMatchDisplayedColumns (bool wordMatch, bool expected)
+ {
+ var criteria = MarkerCriteria.ForHighlights([
+ new HighlightEntry { SearchText = "^error$", IsRegex = true, IsWordMatch = wordMatch, BackgroundColor = Color.Yellow }
+ ]);
+ var match = criteria.Match(0, new LogLine("{\"message\":\"error\"}", 0), (_, _) => [new LogLine("error", 0)]);
+ Assert.That(match.HasValue, Is.EqualTo(expected));
+ }
+
+ [TestCase(false, false, "ERROR", true)]
+ [TestCase(false, true, "ERROR", false)]
+ [TestCase(true, true, "^error$", true)]
+ [TestCase(false, false, "^error$", false)]
+ public void Search_UsesExecutedCriteriaSnapshot (bool regex, bool caseSensitive, string text, bool expected)
+ {
+ var search = new SearchParams { SearchText = text, IsRegex = regex, IsCaseSensitive = caseSensitive };
+ var criteria = MarkerCriteria.ForSearch(search, Color.Blue.ToArgb());
+ search.SearchText = "changed";
+ Assert.That(criteria.Match(3, new LogLine("error", 3)).HasValue, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Highlights_UseDisplayedWordColumnsAndExcludeSearchEntries ()
+ {
+ var criteria = MarkerCriteria.ForHighlights([
+ new HighlightEntry { SearchText = "raw", IsSearchHit = true, BackgroundColor = Color.Red },
+ new HighlightEntry { SearchText = "display", IsWordMatch = true, ForegroundColor = Color.Green }
+ ]);
+ var result = criteria.Match(0, new LogLine("raw", 0), (_, _) => [new LogLine("display", 0)]);
+ Assert.That(result, Is.EqualTo(new MarkerLine(0, Color.Green.ToArgb(), 1)));
+ Assert.That(criteria.Match(0, new LogLine("raw", 0)), Is.Null);
+ }
+
+ [Test]
+ public void Highlights_FirstVisualRuleWinsAndCriteriaAreSnapshotted ()
+ {
+ var trigger = new HighlightEntry { SearchText = "error", IsSetBookmark = true, AlertOnHit = true };
+ var word = new HighlightEntry { SearchText = "error", IsWordMatch = true, NoBackground = true,
+ ForegroundColor = Color.Red, BackgroundColor = Color.Yellow };
+ var wholeLine = new HighlightEntry { SearchText = "error", BackgroundColor = Color.Blue };
+ var criteria = MarkerCriteria.ForHighlights([trigger, word, wholeLine]);
+ word.SearchText = "changed";
+ word.ForegroundColor = Color.Green;
+
+ var match = criteria.Match(7, new LogLine("ERROR occurred", 7));
+
+ Assert.That(match, Is.EqualTo(new MarkerLine(7, Color.Red.ToArgb(), 1)));
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Marker/MarkerIndexTests.cs b/src/LogExpert.Tests/Marker/MarkerIndexTests.cs
new file mode 100644
index 000000000..e16c0ab05
--- /dev/null
+++ b/src/LogExpert.Tests/Marker/MarkerIndexTests.cs
@@ -0,0 +1,149 @@
+using System.Drawing;
+using System.Text.RegularExpressions;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+using LogExpert.Core.Interfaces;
+
+using Moq;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Marker;
+
+[TestFixture]
+public class MarkerIndexTests
+{
+ [Test]
+ public async Task Search_RegexTimeoutIsReportedAndStopsDiscovery ()
+ {
+ using var index = new MarkerIndex();
+ index.Reset(ReaderOf(new string('a', 50_000) + "!"), MarkerCriteria.ForSearch(
+ new Core.Entities.SearchParams { SearchText = "^(a+)+$", IsRegex = true }, Color.Blue.ToArgb()));
+ await index.UpdateAsync(1).ConfigureAwait(false);
+ Assert.That(index.Snapshot.Error, Is.InstanceOf());
+ Assert.That(index.IsScanning, Is.False);
+ }
+
+ [Test]
+ public async Task Update_AppendsMatchesWithoutReadingTheOldPrefixAgain ()
+ {
+ List lines = ["hit", "miss", "hit"];
+ List reads = [];
+ var reader = new Mock();
+ _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny())).Returns((int line) =>
+ {
+ reads.Add(line);
+ return new LogLine(lines[line], line);
+ });
+ using var index = new MarkerIndex();
+ index.Reset(reader.Object, MarkerCriteria.ForHighlights(
+ [new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }]));
+
+ await index.UpdateAsync(3).ConfigureAwait(false);
+ var first = index.Snapshot;
+ lines.Add("hit");
+ reads.Clear();
+ await index.UpdateAsync(4).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(first.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0, 2 }));
+ Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0, 2, 3 }));
+ Assert.That(reads, Is.EqualTo(new[] { 2, 3 }), "Only the unfinished last line and appended lines need reevaluation.");
+ });
+ }
+
+ [Test]
+ public async Task Reset_AnOldBlockedScanCannotReplaceTheNewFilesMarkers ()
+ {
+ using var entered = new ManualResetEventSlim();
+ using var release = new ManualResetEventSlim();
+ var oldReader = new Mock();
+ _ = oldReader.Setup(reader => reader.GetLogLineMemory(0)).Returns(() =>
+ {
+ entered.Set();
+ _ = release.Wait(TimeSpan.FromSeconds(10));
+ return new LogLine("hit", 0);
+ });
+ using var index = new MarkerIndex();
+ var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }]);
+ index.Reset(oldReader.Object, criteria);
+ var oldScan = index.UpdateAsync(1);
+ try
+ {
+ Assert.That(entered.Wait(TimeSpan.FromSeconds(5)), Is.True);
+ index.Reset(ReaderOf("miss", "hit"), criteria);
+ await index.UpdateAsync(2).ConfigureAwait(false);
+ }
+ finally
+ {
+ release.Set();
+ await oldScan.ConfigureAwait(false);
+ }
+
+ Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 1 }));
+ }
+
+ [Test]
+ public async Task Update_TruncationRebuildsAndLastLineEditsReplaceOldMatches ()
+ {
+ List lines = ["hit", "hit", "hit"];
+ var reader = new Mock();
+ _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny()))
+ .Returns((int line) => new LogLine(lines[line], line));
+ using var index = new MarkerIndex();
+ index.Reset(reader.Object, MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }]));
+ await index.UpdateAsync(3).ConfigureAwait(false);
+ lines[0] = "miss";
+ await index.UpdateAsync(1).ConfigureAwait(false);
+ Assert.That(index.Snapshot.Matches, Is.Empty);
+
+ lines[0] = "hit";
+ await index.UpdateAsync(1, lastLineChanged: true).ConfigureAwait(false);
+ Assert.That(index.Snapshot.Matches.Select(match => match.LineNumber), Is.EqualTo(new[] { 0 }));
+ }
+
+ [Test]
+ public async Task Update_InvalidRegexReportsFailureAndCanRecoverAfterCriteriaChange ()
+ {
+ using var index = new MarkerIndex();
+ var reader = ReaderOf("hit");
+ index.Reset(reader, MarkerCriteria.ForHighlights(
+ [new HighlightEntry { SearchText = "[", IsRegex = true, BackgroundColor = Color.Red }]));
+ await index.UpdateAsync(1).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(index.Snapshot.Error, Is.InstanceOf());
+ Assert.That(index.IsScanning, Is.False);
+ Assert.That(index.Snapshot.Matches, Is.Empty);
+ });
+
+ index.Reset(reader, MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", BackgroundColor = Color.Red }]));
+ await index.UpdateAsync(1).ConfigureAwait(false);
+ Assert.That(index.Snapshot.Matches.Count(), Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task Reset_DisabledSourceAndDisposedIndexNeverReadTheFile ()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ using var index = new MarkerIndex();
+ index.Reset(reader.Object, null);
+ await index.UpdateAsync(100).ConfigureAwait(false);
+ index.Dispose();
+ index.Reset(reader.Object, MarkerCriteria.ForHighlights([]));
+ await index.UpdateAsync(100).ConfigureAwait(false);
+ Assert.That(index.Snapshot.Matches, Is.Empty);
+ }
+
+ private static ILogfileReader ReaderOf (params string[] lines)
+ {
+ var reader = new Mock();
+ _ = reader.Setup(source => source.GetLogLineMemory(It.IsAny()))
+ .Returns((int line) => new LogLine(lines[line], line));
+ return reader.Object;
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs b/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs
new file mode 100644
index 000000000..438299e0d
--- /dev/null
+++ b/src/LogExpert.Tests/Marker/MarkerPerformanceTests.cs
@@ -0,0 +1,182 @@
+using System.Diagnostics;
+using System.Drawing;
+using System.Text;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+using LogExpert.Core.Entities;
+using LogExpert.Core.EventArguments;
+using LogExpert.Core.Interfaces;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Marker;
+
+[TestFixture]
+public class MarkerPerformanceTests
+{
+ private const int INITIAL_LINE_COUNT = 500_000;
+ private const int APPENDED_LINE_COUNT = 2_000;
+
+ [Test, Explicit("Opt-in marker indexing performance experiment")]
+ public async Task MarkerIndex_ScanAndAppend_DenseMatchesReportPerformanceAndCorrectness ()
+ {
+ // This uses an in-memory reader seam to keep the experiment independent of file watcher timing and buffering.
+ var reader = new InMemoryBenchmarkReader(INITIAL_LINE_COUNT);
+ using var index = new MarkerIndex();
+ index.Reset(reader, MarkerCriteria.ForHighlights(
+ [
+ new HighlightEntry { SearchText = "^never-match-a$", IsRegex = true, BackgroundColor = System.Drawing.Color.Red },
+ new HighlightEntry { SearchText = "^never-match-b$", IsRegex = true, BackgroundColor = System.Drawing.Color.Blue },
+ new HighlightEntry { SearchText = "\\babsent\\b", IsRegex = true, BackgroundColor = System.Drawing.Color.Green },
+ new HighlightEntry { SearchText = "COMMON-\\d+", IsRegex = true, BackgroundColor = System.Drawing.Color.Yellow }
+ ]));
+
+ var scanMemoryBefore = GC.GetTotalMemory(true);
+ var scanAllocatedBefore = GC.GetTotalAllocatedBytes(true);
+ var scanTimer = Stopwatch.StartNew();
+ await index.UpdateAsync(INITIAL_LINE_COUNT).ConfigureAwait(false);
+ scanTimer.Stop();
+ var scanMemoryAfter = GC.GetTotalMemory(true);
+ var scanAllocatedAfter = GC.GetTotalAllocatedBytes(true);
+
+ var initialSnapshot = index.Snapshot;
+ var initialMatches = initialSnapshot.Matches.ToArray();
+ var initialBuckets = MarkerBucket.Aggregate(initialMatches, INITIAL_LINE_COUNT, 2_000, Color.Black.ToArgb());
+
+ reader.Append(APPENDED_LINE_COUNT);
+ var appendMemoryBefore = GC.GetTotalMemory(true);
+ var appendAllocatedBefore = GC.GetTotalAllocatedBytes(true);
+ var appendTimer = Stopwatch.StartNew();
+ await index.UpdateAsync(reader.LineCount).ConfigureAwait(false);
+ appendTimer.Stop();
+ var appendMemoryAfter = GC.GetTotalMemory(true);
+ var appendAllocatedAfter = GC.GetTotalAllocatedBytes(true);
+
+ var appendedSnapshot = index.Snapshot;
+ var appendedMatches = appendedSnapshot.Matches.ToArray();
+ var appendedBuckets = MarkerBucket.Aggregate(appendedMatches, reader.LineCount, 2_000, Color.Black.ToArgb());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(initialSnapshot.Error, Is.Null);
+ Assert.That(initialSnapshot.ScannedLineCount, Is.EqualTo(INITIAL_LINE_COUNT));
+ Assert.That(initialMatches, Has.Length.EqualTo(INITIAL_LINE_COUNT), "Every dense line must match.");
+ Assert.That(appendedSnapshot.Error, Is.Null);
+ Assert.That(appendedSnapshot.ScannedLineCount, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT));
+ Assert.That(appendedMatches, Has.Length.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT));
+ Assert.That(appendedMatches[^1].LineNumber, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT - 1));
+ Assert.That(reader.LineCount, Is.EqualTo(INITIAL_LINE_COUNT + APPENDED_LINE_COUNT));
+ Assert.That(reader.FileSize, Is.GreaterThan(0));
+ Assert.That(initialBuckets, Is.Not.Empty);
+ Assert.That(appendedBuckets, Is.Not.Empty);
+ Assert.That(initialBuckets.Sum(bucket => bucket.Count), Is.EqualTo(initialMatches.Length));
+ Assert.That(appendedBuckets.Sum(bucket => bucket.Count), Is.EqualTo(appendedMatches.Length));
+ });
+
+ await TestContext.Progress.WriteLineAsync(
+ $"Marker experiment: initial lines={INITIAL_LINE_COUNT}, appended={APPENDED_LINE_COUNT}, " +
+ $"initial bytes={reader.InitialFileSize}, final bytes={reader.FileSize}, " +
+ $"initial matches={initialMatches.Length}, final matches={appendedMatches.Length}, " +
+ $"initial buckets={initialBuckets.Count}, final buckets={appendedBuckets.Count}, " +
+ $"scan elapsed={scanTimer.Elapsed}, append elapsed={appendTimer.Elapsed}, " +
+ $"scan managed delta={scanMemoryAfter - scanMemoryBefore}, append managed delta={appendMemoryAfter - appendMemoryBefore}, " +
+ $"scan allocations={scanAllocatedAfter - scanAllocatedBefore}, append allocations={appendAllocatedAfter - appendAllocatedBefore}").ConfigureAwait(false);
+ }
+
+ private sealed class InMemoryBenchmarkReader : ILogfileReader
+ {
+ private readonly List _lines;
+
+ public InMemoryBenchmarkReader (int lineCount)
+ {
+ _lines = Enumerable.Range(0, lineCount).Select(CreateLine).ToList();
+ InitialFileSize = FileSize;
+ }
+
+ public event EventHandler FileSizeChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler LoadFile
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler LoadingStarted
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler LoadingFinished
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler FileNotFound
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler Respawned
+ {
+ add { }
+ remove { }
+ }
+
+ public int LineCount => _lines.Count;
+
+ public bool IsMultiFile => false;
+
+ public Encoding CurrentEncoding => Encoding.UTF8;
+
+ // The model reports UTF-8 content with a two-byte CRLF terminator per line.
+ public long FileSize => _lines.Sum(line => (long)CurrentEncoding.GetByteCount(line) + 2);
+
+ public long InitialFileSize { get; }
+
+ public ILogLineMemory GetLogLineMemory (int lineNum)
+ {
+ return lineNum >= 0 && lineNum < _lines.Count ? new LogLine(_lines[lineNum], lineNum) : null!;
+ }
+
+ public Task GetLogLineMemoryWithWait (int lineNum)
+ {
+ return Task.FromResult(GetLogLineMemory(lineNum));
+ }
+
+ public ILogLineMemory[] GetLogLineMemories (int startLine, int count)
+ {
+ return Enumerable.Range(startLine, count)
+ .Select(GetLogLineMemory)
+ .Where(line => line != null)
+ .ToArray();
+ }
+
+ public void StartMonitoring () { }
+
+ public void StopMonitoring () { }
+
+ public void StopMonitoringAsync () { }
+
+ public void DeleteAllContent () => _lines.Clear();
+
+ public void Dispose () { }
+
+ public void Append (int count)
+ {
+ var start = _lines.Count;
+ _lines.AddRange(Enumerable.Range(start, count).Select(CreateLine));
+ }
+
+ private static string CreateLine (int lineNumber) => $"COMMON-{lineNumber:D6} dense marker";
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs
new file mode 100644
index 000000000..897079176
--- /dev/null
+++ b/src/LogExpert.Tests/UI/MarkerColumnizerSnapshotTests.cs
@@ -0,0 +1,169 @@
+using ColumnizerLib;
+
+using CsvColumnizer;
+
+using LogExpert.Core.Classes.Columnizer;
+using LogExpert.Core.Entities;
+
+using Moq;
+
+using Newtonsoft.Json;
+
+using NUnit.Framework;
+
+using RegexColumnizer;
+
+namespace LogExpert.Tests.UI;
+
+[TestFixture]
+public class MarkerColumnizerSnapshotTests
+{
+ [Test]
+ public void CsvSnapshot_SplitLinePreservesInitializedParseAfterOriginalConfigChanges ()
+ {
+ var directory = CreateTempDirectory();
+ try
+ {
+ File.WriteAllText(Path.Join(directory, "csvcolumnizer.json"), JsonConvert.SerializeObject(new
+ {
+ DelimiterChar = ",",
+ EscapeChar = "\"",
+ QuoteChar = "\"",
+ CommentChar = "#",
+ HasFieldNames = true,
+ MinColumns = 0
+ }));
+ var original = new CsvColumnizer.CsvColumnizer();
+ original.LoadConfig(directory);
+ _ = original.PreProcessLine("name,age".AsMemory(), 0, 0);
+ original.Selected(new Mock().Object);
+
+ var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot();
+ File.WriteAllText(Path.Join(directory, "csvcolumnizer.json"), JsonConvert.SerializeObject(new
+ {
+ DelimiterChar = ";",
+ EscapeChar = "\"",
+ QuoteChar = "\"",
+ CommentChar = "#",
+ HasFieldNames = true,
+ MinColumns = 0
+ }));
+ original.LoadConfig(directory);
+
+ var result = snapshot.SplitLine(null, new CsvLogLine("Alice,42", 1));
+
+ Assert.That(result.ColumnValues.Select(column => column.FullValue.ToString()), Is.EqualTo(["Alice", "42"]));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void RegexSnapshot_SplitLinePreservesInitializedParseAfterOriginalConfigChanges ()
+ {
+ var directory = CreateTempDirectory();
+ try
+ {
+ WriteRegexConfig(directory, "(?[^:]+):(?.+)");
+ var original = new Regex1Columnizer();
+ original.LoadConfig(directory);
+ var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot();
+ WriteRegexConfig(directory, "(?.+)");
+ original.LoadConfig(directory);
+
+ var result = snapshot.SplitLine(new Mock().Object, new LogLine("Alice:42", 1));
+
+ Assert.That(result.ColumnValues.Select(column => column.FullValue.ToString()), Is.EqualTo(["Alice", "42"]));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void Log4jSnapshot_SplitLinePreservesInitializedParseAndTimeOffsetAfterOriginalConfigChanges ()
+ {
+ var directory = CreateTempDirectory();
+ try
+ {
+ WriteLog4jConfig(directory, true);
+ var original = new Log4jXmlColumnizer.Log4jXmlColumnizer();
+ original.LoadConfig(directory);
+ original.SetTimeOffset(123);
+ var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot();
+ WriteLog4jConfig(directory, false);
+ original.LoadConfig(directory);
+
+ var line = new LogLine("1700000000000�INFO�Logger�Thread�Class�Method�File�12�Message", 1);
+ var result = snapshot.SplitLine(new Mock().Object, line);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.ColumnValues, Has.Length.EqualTo(9));
+ Assert.That(result.ColumnValues[1].FullValue.ToString(), Is.EqualTo("INFO"));
+ Assert.That(result.ColumnValues[8].FullValue.ToString(), Is.EqualTo("Message"));
+ Assert.That(snapshot.GetTimeOffset(), Is.EqualTo(123));
+ });
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void SquareBracketSnapshot_PreservesDetectedLayoutAndTimeOffsetAfterOriginalChanges ()
+ {
+ var line = new LogLine("2022-03-21 11:34:34.505[one][two][three][four][five][six]Message", 0);
+ var original = new SquareBracketColumnizer();
+ _ = original.GetPriority("square.log", new ILogLineMemory[] { line });
+ original.SetTimeOffset(123);
+ var snapshot = ((IColumnizerSnapshotMemory)original).CreateSnapshot();
+ _ = original.GetPriority("other.log", new ILogLineMemory[] { new LogLine("[Other]Changed", 0) });
+ original.SetTimeOffset(456);
+
+ var result = snapshot.SplitLine(null, line);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(snapshot.GetColumnNames(), Is.EqualTo(new[] { "Date", "Time", "Level", "Source", "Source1", "Source2", "Source3", "Source4", "Message" }));
+ Assert.That(result.ColumnValues, Has.Length.EqualTo(9));
+ Assert.That(result.ColumnValues[0].FullValue.ToString(), Is.EqualTo("2022-03-21"));
+ Assert.That(result.ColumnValues[1].FullValue.ToString(), Is.EqualTo("11:34:34.628"));
+ Assert.That(snapshot.GetTimeOffset(), Is.EqualTo(123));
+ });
+ }
+
+ private static string CreateTempDirectory ()
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertMarkerColumnizerTests", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ private static void WriteRegexConfig (string directory, string expression)
+ {
+ File.WriteAllText(Path.Join(directory, "Regex1Columnizer.json"), JsonConvert.SerializeObject(new
+ {
+ Expression = expression,
+ Name = "Regex1",
+ CustomName = "Regex1"
+ }));
+ }
+
+ private static void WriteLog4jConfig (string directory, bool visible)
+ {
+ var names = new[] { "Timestamp", "Level", "Logger", "Thread", "Class", "Method", "File", "Line", "Message" };
+ var columns = names
+ .Select((name, index) => new Log4jXmlColumnizer.Log4jColumnEntry(name, index, 0) { Visible = visible });
+ File.WriteAllText(Path.Join(directory, "log4jxmlcolumnizer.json"), JsonConvert.SerializeObject(new
+ {
+ columnNames = names,
+ ColumnList = columns,
+ LocalTimestamps = true
+ }));
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/CommandLine/LineNavigationTests.cs b/src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs
similarity index 99%
rename from src/LogExpert.Tests/CommandLine/LineNavigationTests.cs
rename to src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs
index ccf340e66..f57ec929f 100644
--- a/src/LogExpert.Tests/CommandLine/LineNavigationTests.cs
+++ b/src/LogExpert.UI.Tests/CommandLine/LineNavigationTests.cs
@@ -16,7 +16,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.CommandLine;
+namespace LogExpert.UI.Tests.CommandLine;
[TestFixture]
[Apartment(ApartmentState.STA)]
diff --git a/src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
similarity index 99%
rename from src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
rename to src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
index 9f28b24ef..bae722bb1 100644
--- a/src/LogExpert.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
+++ b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
@@ -10,7 +10,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Controls;
+namespace LogExpert.UI.Tests.Controls;
[TestFixture]
[Apartment(ApartmentState.STA)]
diff --git a/src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs b/src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs
similarity index 98%
rename from src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs
rename to src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs
index da54921c7..32bdbe02a 100644
--- a/src/LogExpert.Tests/Controls/LogWindowPluginRegistryTests.cs
+++ b/src/LogExpert.UI.Tests/Controls/LogWindowPluginRegistryTests.cs
@@ -10,7 +10,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Controls;
+namespace LogExpert.UI.Tests.Controls;
[TestFixture]
[Apartment(ApartmentState.STA)]
diff --git a/src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs b/src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs
similarity index 99%
rename from src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs
rename to src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs
index c0c7b53ef..7d2edc8d2 100644
--- a/src/LogExpert.Tests/Dialogs/FolderDropDialogTests.cs
+++ b/src/LogExpert.UI.Tests/Dialogs/FolderDropDialogTests.cs
@@ -5,7 +5,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Dialogs;
+namespace LogExpert.UI.Tests.Dialogs;
[TestFixture]
[Apartment(ApartmentState.STA)]
diff --git a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs b/src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs
similarity index 98%
rename from src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs
rename to src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs
index 5e9341bbf..fae20c469 100644
--- a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs
+++ b/src/LogExpert.UI.Tests/Dialogs/RegexHelperDialogTests.cs
@@ -2,7 +2,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Dialogs;
+namespace LogExpert.UI.Tests.Dialogs;
///
/// Regression tests for the Regex Helper dialog's OK handler. Both combo boxes are
diff --git a/src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs
similarity index 98%
rename from src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs
rename to src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs
index c01871722..e55731c63 100644
--- a/src/LogExpert.Tests/Dialogs/SettingsDialogEncodingListTests.cs
+++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogEncodingListTests.cs
@@ -5,7 +5,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Dialogs;
+namespace LogExpert.UI.Tests.Dialogs;
///
/// The Preferences encoding combo box, which is the only way to set Preferences.DefaultEncoding.
diff --git a/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs
new file mode 100644
index 000000000..97f0a1345
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogMarkerBarTests.cs
@@ -0,0 +1,53 @@
+using LogExpert.Core.Config;
+using LogExpert.Core.Interfaces;
+using LogExpert.Dialogs;
+
+using Moq;
+
+using NUnit.Framework;
+
+using UIStrings = LogExpert.Resources;
+
+namespace LogExpert.UI.Tests.Dialogs;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+public class SettingsDialogMarkerBarTests
+{
+ [Test]
+ public void MarkerBarTab_LocalizesVisibleOptions_AndSavesCheckboxValues ()
+ {
+ var preferences = new Preferences();
+ var configManager = new Mock();
+ _ = configManager.SetupGet(manager => manager.Settings).Returns(new Settings());
+
+ using var dialog = new SettingsDialog(preferences, null!, 0, configManager.Object);
+ var tab = dialog.Controls.Find("tabControlSettings", true).Single().Controls.Find("tabPageMarkerBar", true).Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tab.Text, Is.EqualTo(UIStrings.SettingsDialog_UI_TabPage_tabPageMarkerBar));
+ Assert.That(tab.Controls.Find("checkBoxShowMarkerBar", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar));
+ Assert.That(tab.Controls.Find("checkBoxShowHighlightMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers));
+ Assert.That(tab.Controls.Find("checkBoxShowBookmarkMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers));
+ Assert.That(tab.Controls.Find("checkBoxShowSearchMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers));
+ Assert.That(tab.Controls.Find("checkBoxShowFilterMarkers", true).Single().Text, Is.EqualTo(UIStrings.SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers));
+ });
+
+ ((CheckBox)tab.Controls.Find("checkBoxShowMarkerBar", true).Single()).Checked = true;
+ ((CheckBox)tab.Controls.Find("checkBoxShowHighlightMarkers", true).Single()).Checked = false;
+ ((CheckBox)tab.Controls.Find("checkBoxShowBookmarkMarkers", true).Single()).Checked = false;
+ ((CheckBox)tab.Controls.Find("checkBoxShowSearchMarkers", true).Single()).Checked = false;
+ ((CheckBox)tab.Controls.Find("checkBoxShowFilterMarkers", true).Single()).Checked = true;
+ dialog.SaveMarkerBarTab();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(preferences.ShowMarkerBar, Is.True);
+ Assert.That(preferences.ShowHighlightMarkers, Is.False);
+ Assert.That(preferences.ShowBookmarkMarkers, Is.False);
+ Assert.That(preferences.ShowSearchMarkers, Is.False);
+ Assert.That(preferences.ShowFilterMarkers, Is.True);
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs
similarity index 99%
rename from src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs
rename to src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs
index b23b8a8a4..4738cedb5 100644
--- a/src/LogExpert.Tests/Dialogs/SettingsDialogPortableModeTests.cs
+++ b/src/LogExpert.UI.Tests/Dialogs/SettingsDialogPortableModeTests.cs
@@ -8,7 +8,7 @@
using UIStrings = LogExpert.Resources;
-namespace LogExpert.Tests.Dialogs;
+namespace LogExpert.UI.Tests.Dialogs;
///
/// Regression tests for issue #658: populating the settings dialog from preferences set the
diff --git a/src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs b/src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs
similarity index 99%
rename from src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs
rename to src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs
index a78fa74bf..ef7c7e571 100644
--- a/src/LogExpert.Tests/Encodings/OfferedEncodingsTests.cs
+++ b/src/LogExpert.UI.Tests/Encodings/OfferedEncodingsTests.cs
@@ -6,7 +6,7 @@
using NUnit.Framework;
-namespace LogExpert.Tests.Encodings;
+namespace LogExpert.UI.Tests.Encodings;
///
/// is the one list of encodings a user can pick: the
diff --git a/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj b/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj
new file mode 100644
index 000000000..49febab0b
--- /dev/null
+++ b/src/LogExpert.UI.Tests/LogExpert.UI.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0-windows
+ true
+
+ true
+ true
+ true
+ true
+ LogExpert.UI.Tests
+ LogExpert.UI.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/LogExpert.UI.Tests/MarkerBarTests.cs b/src/LogExpert.UI.Tests/MarkerBarTests.cs
new file mode 100644
index 000000000..1009da4be
--- /dev/null
+++ b/src/LogExpert.UI.Tests/MarkerBarTests.cs
@@ -0,0 +1,236 @@
+using System.Globalization;
+using System.Reflection;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+using LogExpert.UI.Controls.LogWindow;
+
+using NUnit.Framework;
+
+using Vanara.PInvoke;
+
+namespace LogExpert.UI.Tests;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+[System.Runtime.Versioning.SupportedOSPlatform("windows")]
+public class MarkerBarTests
+{
+ [TestCase(96, 10, 2)]
+ [TestCase(144, 15, 3)]
+ [TestCase(192, 20, 4)]
+ public void Paint_DiscoveryIndicatorScalesToDeviceDpi (int dpi, int expectedWidth, int expectedHeight)
+ {
+ using var bar = CreateBar(80, 40);
+ bar.TopInset = 20;
+ bar.BackColor = Color.White;
+ bar.ForeColor = Color.Green;
+ bar.SetBuckets(new Dictionary>(), bar.BucketHeight, true);
+ // WinForms accepts a nonzero wParam on BEFOREPARENT specifically for DPI tests.
+ _ = User32.SendMessage(bar.Handle, User32.WindowMessage.WM_DPICHANGED_BEFOREPARENT, (nint)dpi, nint.Zero);
+ _ = User32.SendMessage(bar.Handle, User32.WindowMessage.WM_DPICHANGED_AFTERPARENT, nint.Zero, nint.Zero);
+ Assert.That(bar.DeviceDpi, Is.EqualTo(dpi));
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+
+ var indicatorPixels = new List();
+ for (var y = 0; y < bar.TopInset; y++)
+ {
+ for (var x = 0; x < bar.Width; x++)
+ {
+ if (image.GetPixel(x, y).ToArgb() == Color.Green.ToArgb())
+ {
+ indicatorPixels.Add(new Point(x, y));
+ }
+ }
+ }
+
+ Assert.That(indicatorPixels, Is.Not.Empty);
+ var width = indicatorPixels.Max(point => point.X) - indicatorPixels.Min(point => point.X) + 1;
+ var height = indicatorPixels.Max(point => point.Y) - indicatorPixels.Min(point => point.Y) + 1;
+ Assert.Multiple(() =>
+ {
+ // GDI ellipse rasterization may differ by one edge pixel at fractional scaling.
+ Assert.That(width, Is.EqualTo(expectedWidth).Within(1));
+ Assert.That(height, Is.EqualTo(expectedHeight).Within(1));
+ });
+ }
+
+ [Test]
+ public void Paint_BoldOnlyHighlightUsesTheCurrentForeground ()
+ {
+ var criteria = MarkerCriteria.ForHighlights([new HighlightEntry { SearchText = "hit", IsBold = true }]);
+ var match = criteria.Match(0, new LogLine("hit", 0));
+ Assert.That(match, Is.Not.Null);
+ using var bar = CreateBar(40, 10);
+ bar.BackColor = Color.White;
+ bar.ForeColor = Color.Green;
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = MarkerBucket.Aggregate([match!.Value], 1, 10, bar.ForeColor.ToArgb()) }, 10, false);
+
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+
+ Assert.That(image.GetPixel(5, 0).ToArgb(), Is.EqualTo(Color.Green.ToArgb()));
+ }
+
+ [Test]
+ public void Paint_RendersEachLaneBucketWithItsColor ()
+ {
+ using var bar = CreateBar(40, 10);
+ bar.SetBuckets(new Dictionary>
+ {
+ [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 12, Color.Red.ToArgb())],
+ [MarkerCategory.Bookmarks] = [new MarkerBucket(2, 20, 29, 1, 22, Color.Green.ToArgb())],
+ [MarkerCategory.Search] = [new MarkerBucket(2, 30, 39, 1, 32, Color.Blue.ToArgb())],
+ [MarkerCategory.Filter] = [new MarkerBucket(2, 40, 49, 1, 42, Color.Purple.ToArgb())]
+ }, 10, discovering: false);
+
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(image.GetPixel(5, 2).ToArgb(), Is.EqualTo(Color.Red.ToArgb()));
+ Assert.That(image.GetPixel(15, 2).ToArgb(), Is.EqualTo(Color.Green.ToArgb()));
+ Assert.That(image.GetPixel(25, 2).ToArgb(), Is.EqualTo(Color.Blue.ToArgb()));
+ Assert.That(image.GetPixel(35, 2).ToArgb(), Is.EqualTo(Color.Purple.ToArgb()));
+ });
+ }
+
+ [Test]
+ public void MouseUp_OnBucketRaisesTargetLine_AndEmptyClickDoesNothing ()
+ {
+ using var bar = CreateBar(40, 10);
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 3, 14, Color.Red.ToArgb())] }, 10, false);
+ var selected = new List();
+ bar.LineSelected += (_, args) => selected.Add(args.Line);
+
+ RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 5, 2, 0));
+ RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 5, 3, 0));
+
+ Assert.That(selected, Is.EqualTo([14]));
+ }
+
+ [Test]
+ public void MouseMove_TooltipIncludesCategoryRangeAndCount ()
+ {
+ using var bar = CreateBar(40, 10);
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 3, 14, Color.Red.ToArgb())] }, 10, false);
+
+ RaiseMouse(bar, "OnMouseMove", new MouseEventArgs(MouseButtons.None, 0, 5, 2, 0));
+
+ var toolTip = (ToolTip)typeof(MarkerBar).GetField("_toolTip", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(bar)!;
+ var expected = string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ToolTip, Resources.MarkerBar_Highlights, 11, 20, 3);
+ Assert.That(toolTip.GetToolTip(bar), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void SmallOddWidth_AndResizeWithStaleHeight_DoNotSelectBuckets ()
+ {
+ using var bar = CreateBar(5, 10);
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 14, Color.Red.ToArgb())] }, 10, false);
+ var selected = new List();
+ bar.LineSelected += (_, args) => selected.Add(args.Line);
+
+ RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 0, 2, 0));
+ Assert.That(selected, Is.EqualTo([14]));
+
+ bar.Height = 12;
+ RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, 1, 2, 0));
+
+ Assert.That(selected, Is.EqualTo([14]));
+ }
+
+ [Test]
+ public void SmallOddWidth_RendersEachLaneWithinItsVisibleColumn ()
+ {
+ using var bar = CreateBar(5, 10);
+ bar.SetBuckets(new Dictionary>
+ {
+ [MarkerCategory.Highlights] = [new MarkerBucket(2, 10, 19, 1, 10, Color.Red.ToArgb())],
+ [MarkerCategory.Bookmarks] = [new MarkerBucket(2, 20, 29, 1, 20, Color.Green.ToArgb())],
+ [MarkerCategory.Search] = [new MarkerBucket(2, 30, 39, 1, 30, Color.Blue.ToArgb())],
+ [MarkerCategory.Filter] = [new MarkerBucket(2, 40, 49, 1, 40, Color.Purple.ToArgb())]
+ }, 10, false);
+
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(image.GetPixel(0, 2).ToArgb(), Is.EqualTo(Color.Red.ToArgb()));
+ Assert.That(image.GetPixel(1, 2).ToArgb(), Is.EqualTo(Color.Green.ToArgb()));
+ Assert.That(image.GetPixel(2, 2).ToArgb(), Is.EqualTo(Color.Blue.ToArgb()));
+ Assert.That(image.GetPixel(3, 2).ToArgb(), Is.EqualTo(SystemColors.ControlDark.ToArgb()));
+ Assert.That(image.GetPixel(4, 2).ToArgb(), Is.EqualTo(Color.Purple.ToArgb()));
+ });
+ }
+
+ [TestCase("light", 255, 255, 255)]
+ [TestCase("dark", 32, 32, 32)]
+ public void Paint_UsesExplicitBackgroundForLightAndDarkModes (string _, int red, int green, int blue)
+ {
+ using var bar = CreateBar(40, 10);
+ bar.BackColor = Color.FromArgb(red, green, blue);
+ bar.ForeColor = Color.White;
+ bar.SetBuckets(new Dictionary>(), 10, false);
+
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+
+ Assert.That(image.GetPixel(0, 0).ToArgb(), Is.EqualTo(bar.BackColor.ToArgb()));
+ }
+
+ [TestCase(28, 400, 4, 8)]
+ [TestCase(42, 600, 6, 12)]
+ [TestCase(56, 800, 8, 16)]
+ public void ScaledGeometry_PaintsAndSelectsTheLastBucket (int width, int height, int topInset, int bottomInset)
+ {
+ using var bar = CreateBar(width, height);
+ bar.TopInset = topInset;
+ bar.BottomInset = bottomInset;
+ var bucketHeight = height - topInset - bottomInset;
+ var pixel = bucketHeight - 1;
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Filter] = [new MarkerBucket(pixel, 90, 99, 1, 95, Color.Orange.ToArgb())] }, bucketHeight, false);
+ var selected = new List();
+ bar.LineSelected += (_, args) => selected.Add(args.Line);
+
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+ RaiseMouse(bar, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, width - 1, topInset + pixel, 0));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(image.GetPixel(width - 1, topInset + pixel).ToArgb(), Is.EqualTo(Color.Orange.ToArgb()));
+ Assert.That(selected, Is.EqualTo([95]));
+ });
+ }
+
+ [Test]
+ public void DrawToBitmap_ProducesMarkerBarArtifact ()
+ {
+ using var bar = CreateBar(80, 16);
+ bar.SetBuckets(new Dictionary> { [MarkerCategory.Highlights] = [new MarkerBucket(4, 20, 29, 2, 24, Color.Orange.ToArgb())] }, 16, false);
+ using var image = new Bitmap(bar.Width, bar.Height);
+ bar.DrawToBitmap(image, bar.ClientRectangle);
+ var path = Path.Join(TestContext.CurrentContext.WorkDirectory, "marker-bar.png");
+ image.Save(path, System.Drawing.Imaging.ImageFormat.Png);
+ TestContext.AddTestAttachment(path);
+ Assert.That(File.Exists(path), Is.True);
+ }
+
+ private static MarkerBar CreateBar (int width, int height)
+ {
+ var bar = new MarkerBar { Size = new Size(width, height) };
+ _ = bar.Handle;
+ return bar;
+ }
+
+ private static void RaiseMouse (MarkerBar bar, string methodName, MouseEventArgs args)
+ {
+ var method = typeof(Control).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)!;
+ _ = method.Invoke(bar, [args]);
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.UI.Tests/MarkerWindowTests.cs b/src/LogExpert.UI.Tests/MarkerWindowTests.cs
new file mode 100644
index 000000000..bc31009f6
--- /dev/null
+++ b/src/LogExpert.UI.Tests/MarkerWindowTests.cs
@@ -0,0 +1,543 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Runtime.Versioning;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Columnizer;
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Classes.Marker;
+using LogExpert.Core.Classes.Persister;
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+using LogExpert.UI.Controls.LogTabWindow;
+using LogExpert.UI.Controls.LogWindow;
+using LogExpert.UI.Controls;
+
+using Moq;
+
+using NUnit.Framework;
+
+using Vanara.PInvoke;
+
+using WeifenLuo.WinFormsUI.Docking;
+
+namespace LogExpert.UI.Tests;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+[NonParallelizable]
+[SupportedOSPlatform("windows")]
+public sealed class MarkerWindowTests : IDisposable
+{
+ private string _directory = null!;
+ private string _fileName = null!;
+ private Settings _settings = null!;
+ private Mock _config = null!;
+ private LogTabWindow? _window;
+ private Exception? _uiException;
+ private SystemColorMode _originalColorMode;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ _originalColorMode = Application.ColorMode;
+ _uiException = null;
+ _directory = Path.Join(Path.GetTempPath(), "LogExpertMarkerTests", Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(_directory);
+ _fileName = Path.Join(_directory, "markers.log");
+ File.WriteAllLines(_fileName, Enumerable.Range(0, 100).Select(line => line % 10 == 0 || line == 99 ? "ERROR" : "INFO"));
+ _settings = new Settings();
+ _settings.Preferences.MultiFileOptions = new MultiFileOptions();
+ _settings.Preferences.FollowTail = true;
+ _settings.Preferences.AskForClose = false;
+ _settings.Preferences.AutoPick = false;
+ _settings.Preferences.OpenLastFiles = false;
+ _settings.Preferences.SaveSessions = false;
+ _settings.Preferences.SaveLocation = SessionSaveLocation.SameDir;
+ _settings.Preferences.ShowMarkerBar = true;
+ _settings.Preferences.HighlightGroupList = [new HighlightGroup
+ {
+ GroupName = "markers",
+ HighlightEntryList = [new HighlightEntry { SearchText = "ERROR", BackgroundColor = Color.Red }]
+ }];
+ _config = new Mock();
+ _ = _config.Setup(config => config.Settings).Returns(_settings);
+ _ = _config.Setup(config => config.ActiveConfigDir).Returns(_directory);
+ _ = _config.Setup(config => config.ActiveSessionDir).Returns(_directory);
+ _ = PluginRegistry.PluginRegistry.Create(_directory, 50);
+ Application.ThreadException += OnUiException;
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ _settings.Preferences.SaveSessions = false;
+ try
+ {
+ if (_window != null)
+ {
+ _window.LogExpertProxy = null;
+ _window.Close();
+ _window.Dispose();
+ _window = null;
+ }
+ }
+ finally
+ {
+ Application.ThreadException -= OnUiException;
+ if (Application.ColorMode != _originalColorMode)
+ {
+ Application.SetColorMode(_originalColorMode);
+ }
+ }
+
+ Directory.Delete(_directory, true);
+ }
+
+ // The bar is 28 logical pixels wide: 100%, 150%, and 200% scaling have independent expected widths.
+ [TestCase(96, 28)]
+ [TestCase(144, 42)]
+ [TestCase(192, 56)]
+ // Synthetic DPI notifications must not leave state on the STA used by subsequent docking tests.
+ [RequiresThread(ApartmentState.STA)]
+ public void DockedDpiChange_ScalesMarkerBarWidth (int dpi, int expectedWidth)
+ {
+ var log = Open();
+ var bar = Find(log, "markerBar");
+ var controls = new List { _window! };
+ for (var index = 0; index < controls.Count; index++)
+ {
+ controls.AddRange(controls[index].Controls.Cast().Where(control => control.IsHandleCreated));
+ }
+
+ // PMv2 delivers BEFOREPARENT bottom-up, changes the top-level form, then delivers AFTERPARENT top-down.
+ foreach (var control in controls.Skip(1).Reverse())
+ {
+ _ = User32.SendMessage(control.Handle, User32.WindowMessage.WM_DPICHANGED_BEFOREPARENT, (nint)dpi, nint.Zero);
+ }
+
+ var bounds = _window!.Bounds;
+ var suggestedBounds = new RECT(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
+ _ = User32.SendMessage(_window.Handle, User32.WindowMessage.WM_DPICHANGED,
+ (nint)(dpi | (dpi << 16)), ref suggestedBounds);
+ foreach (var control in controls.Skip(1))
+ {
+ _ = User32.SendMessage(control.Handle, User32.WindowMessage.WM_DPICHANGED_AFTERPARENT, nint.Zero, nint.Zero);
+ }
+
+ Assert.That(log.DeviceDpi, Is.EqualTo(dpi));
+ Assert.That(bar.DeviceDpi, Is.EqualTo(dpi));
+ bar.Parent!.PerformLayout();
+ Assert.That(bar.Width, Is.EqualTo(expectedWidth));
+ }
+
+ [TestCase(SystemColorMode.Classic)]
+ [TestCase(SystemColorMode.Dark)]
+ public void Theme_BoldOnlyMarkersRenderTheLogWindowForeground (SystemColorMode colorMode)
+ {
+ Assert.That(Application.OpenForms, Is.Empty, "Theme checks must start without live windows from another test.");
+ Application.SetColorMode(colorMode);
+ Assert.That(Application.IsDarkModeEnabled, Is.EqualTo(colorMode == SystemColorMode.Dark));
+ _settings.Preferences.HighlightGroupList[0].HighlightEntryList =
+ [new HighlightEntry { SearchText = "ERROR", IsBold = true }];
+ var log = Open();
+ var bar = Find(log, "markerBar");
+ var grid = Find(log, "dataGridView");
+ WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true);
+ var expected = colorMode == SystemColorMode.Dark ? Color.White : Color.Black;
+ Assert.That(grid.ForeColor.ToArgb(), Is.EqualTo(expected.ToArgb()));
+ Assert.That(MarkerColor(bar, MarkerCategory.Highlights, 20, 100), Is.EqualTo(expected.ToArgb()));
+
+ grid.ForeColor = Color.Yellow;
+ ApplyPreferences(log);
+ PumpUntil(() => MarkerColor(bar, MarkerCategory.Highlights, 20, 100) == Color.Yellow.ToArgb());
+ }
+
+ [Test]
+ public void Markers_NavigateStopTailAndRefreshRulesVisibilityAndBookmarks ()
+ {
+ var log = Open();
+ var bar = Find(log, "markerBar");
+ WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true);
+ log.ToggleBookmark(99);
+ WaitForMarker(bar, MarkerCategory.Bookmarks, 99, 100, true);
+ log.FollowTailChanged(true, false);
+ Click(bar, MarkerCategory.Bookmarks, 99, 100);
+ Assert.That(log.CurrentLineNum, Is.EqualTo(99));
+ Assert.That(log.GatherSessionSnapshot().FollowTail, Is.False, "Clicking the last line must stop tail too.");
+ Click(bar, MarkerCategory.Highlights, 20, 100);
+ Assert.That(log.CurrentLineNum, Is.EqualTo(20));
+ Assert.That(log.GatherSessionSnapshot().FirstDisplayedLine, Is.LessThanOrEqualTo(20));
+ log.ToggleBookmark(99);
+ WaitForMarker(bar, MarkerCategory.Bookmarks, 99, 100, false);
+
+ var rule = _settings.Preferences.HighlightGroupList[0].HighlightEntryList[0];
+ rule.SearchText = "INFO";
+ log.SetCurrentHighlightGroup("markers");
+ WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true);
+ WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, false);
+
+ var table = (TableLayoutPanel)bar.Parent!;
+ log.ForceColumnizer(new TimestampColumnizer());
+ _settings.Preferences.ShowTimeSpread = true;
+ ApplyPreferences(log);
+ table.PerformLayout();
+ var timeSpread = table.GetControlFromPosition(1, 1);
+ Assert.That(timeSpread, Is.Not.Null);
+ Assert.That(timeSpread!.Visible, Is.True);
+ Assert.That(bar.Visible, Is.True);
+ Assert.That(timeSpread.Bounds.IntersectsWith(bar.Bounds), Is.False);
+ var timeSpreadWidth = table.ColumnStyles[1].Width;
+ Assert.That(timeSpreadWidth, Is.GreaterThan(0));
+ _settings.Preferences.ShowMarkerBar = false;
+ ApplyPreferences(log);
+ Assert.That(table.ColumnStyles[2].Width, Is.Zero);
+ Assert.That(table.ColumnStyles[1].Width, Is.EqualTo(timeSpreadWidth));
+ _settings.Preferences.ShowMarkerBar = true;
+ ApplyPreferences(log);
+ WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true);
+
+ _window!.Size = new Size(800, 600);
+ WaitForMarker(bar, MarkerCategory.Highlights, 21, 100, true);
+ using var image = new Bitmap(log.Width, log.Height);
+ log.DrawToBitmap(image, log.ClientRectangle);
+ var path = Path.Join(TestContext.CurrentContext.WorkDirectory, "marker-window.png");
+ image.Save(path);
+ TestContext.AddTestAttachment(path);
+ }
+
+ [Test]
+ public void HiddenLogWindow_ProcessesAppendedMarkersAfterRestore ()
+ {
+ var log = Open();
+ var bar = Find(log, "markerBar");
+ WaitForMarker(bar, MarkerCategory.Highlights, 20, 100, true);
+
+ log.Hide();
+ Assert.Multiple(() =>
+ {
+ Assert.That(log.DockState, Is.EqualTo(DockState.Hidden));
+ Assert.That(log.Visible, Is.False);
+ });
+
+ File.AppendAllText(_fileName, "ERROR\r\n");
+ PumpUntil(() => log.GatherSessionSnapshot().LineCount == 101);
+
+ log.Show(Find(_window!, "dockPanel"));
+ PumpUntil(() => log.Visible && log.DockState != DockState.Hidden);
+ WaitForMarker(bar, MarkerCategory.Highlights, 100, 101, true);
+ var expectedTooltip = string.Format(CultureInfo.CurrentCulture, Resources.MarkerBar_ToolTip,
+ Resources.MarkerBar_Highlights, 101, 101, 1);
+ // The previous final-line marker occupies the same pixel until the batched refresh publishes.
+ PumpUntil(() => HoverMarker(bar, MarkerCategory.Highlights, 100, 101).StartsWith(expectedTooltip, StringComparison.Ordinal));
+ Click(bar, MarkerCategory.Highlights, 100, 101);
+ Assert.That(log.CurrentLineNum, Is.EqualTo(100));
+ }
+
+ [Test]
+ public void SearchAndFilter_TrackExecutedCriteriaClearAndTailWithoutSpreadLines ()
+ {
+ var log = Open();
+ var bar = Find(log, "markerBar");
+ _window!.SearchParams.SearchText = "ERROR";
+ log.StartSearch();
+ WaitForMarker(bar, MarkerCategory.Search, 20, 100, true);
+ ((ToolStripMenuItem)bar.ContextMenuStrip!.Items[0]).PerformClick();
+ WaitForMarker(bar, MarkerCategory.Search, 20, 100, false);
+ _window.SearchParams.IsFindNext = true;
+ log.StartSearch();
+ PumpFor(TimeSpan.FromMilliseconds(600));
+ Assert.That(HasMarker(bar, MarkerCategory.Search, 20, 100), Is.False, "F3 must not restore a cleared marker search.");
+ _window.SearchParams.IsFindNext = false;
+ _window.SearchParams.SearchText = "INFO";
+ log.StartSearch();
+ WaitForMarker(bar, MarkerCategory.Search, 21, 100, true);
+ WaitForMarker(bar, MarkerCategory.Search, 20, 100, false);
+
+ if (!Find