Skip to content

Commit 53d15dd

Browse files
authored
Merge pull request #712 from LogExperts/feature/27-log-marker-bar
Add configurable log overview marker bar
2 parents a8a45a9 + 782c0ff commit 53d15dd

65 files changed

Lines changed: 3354 additions & 43 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CONTEXT.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ meaning; do not redefine them locally.
2323
construction, since the bulk `HighlightBookmarkScanner` has no access to
2424
the side-effecting triggers.
2525

26+
## Marker Bar
27+
28+
- **Marker Bar** — A compact overview beside a Log Window that shows where
29+
highlights, bookmarks, Log Search hits, and Window Filter hits occur across
30+
the loaded file.
31+
- **Marker** — An indicator in the Marker Bar representing one or more matching
32+
Log Lines. Markers are grouped by category; they do not create or replace
33+
Bookmarks.
34+
- **Marker Snapshot** — An immutable collection of highlight or Log Search
35+
matches discovered for the Marker Bar, together with how many Log Lines
36+
have been scanned and any discovery failure.
37+
- **Bookmark** — A user- or auto-generated annotation attached to a specific
38+
Log Line, carrying optional comment text and an overlay. A Bookmark can be
39+
represented by a Marker in the bookmark category; the two terms are not synonyms.
40+
41+
*Avoid*: "marker" when referring to the underlying Bookmark annotation;
42+
bare "snapshot" when the distinction between **Marker Snapshot**,
43+
**Columnizer Snapshot**, and **Session Snapshot** matters.
44+
2645
## Audio alerts
2746

2847
- **Audio Alert** — A sound played when a tail-only highlight match occurs.
@@ -293,6 +312,9 @@ layer).
293312
line into columns. Each loaded log window has exactly one active
294313
columnizer at a time. The set of available columnizers is owned by
295314
`PluginRegistry`.
315+
- **Columnizer Snapshot** — An independent Columnizer with the active
316+
Columnizer's configuration and detected column layout, ready for Marker Bar
317+
discovery. Its parsing state is separate from the active Columnizer's.
296318
- **Columnizer Mask Entry** (`ColumnizerMaskEntry`) — One user-configured
297319
row on the Settings → Columnizers tab. Pairs a **Mask**, a **Mask Type**,
298320
and a **Columnizer Name**. Stored in `Preferences.ColumnizerMaskList`.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace ColumnizerLib;
2+
3+
/// <summary>
4+
/// Optional capability for capturing a columnizer's current parsing state for background use.
5+
/// </summary>
6+
/// <remarks>
7+
/// The snapshot must be initialized with the current configuration and detected column layout,
8+
/// without sharing mutable parsing state with the original columnizer. The Marker Bar captures
9+
/// the snapshot on the UI thread and uses it on a worker thread without calling
10+
/// <see cref="IInitColumnizerMemory.Selected"/>. Capture must be quick and perform no file I/O.
11+
/// </remarks>
12+
public interface IColumnizerSnapshotMemory
13+
{
14+
/// <summary>
15+
/// Creates an independent, initialized columnizer with the current configuration and detected layout.
16+
/// </summary>
17+
/// <returns>A columnizer ready to parse log lines on a worker thread.</returns>
18+
ILogLineMemoryColumnizer CreateSnapshot ();
19+
}

‎src/CsvColumnizer/CsvColumnizer.cs‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace CsvColumnizer;
1717
/// The IPreProcessColumnizer is implemented to read field names from the very first line of the file. Then
1818
/// the line is dropped. So it's not seen by LogExpert. The field names will be used as column names.
1919
/// </summary>
20-
public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory
20+
public class CsvColumnizer : ILogLineMemoryColumnizer, IInitColumnizerMemory, IColumnizerConfiguratorMemory, IPreProcessColumnizerMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
2121
{
2222
#region Fields
2323

@@ -220,6 +220,35 @@ public void DeSelected (ILogLineMemoryColumnizerCallback callback)
220220
// nothing to do
221221
}
222222

223+
public ILogLineMemoryColumnizer CreateSnapshot ()
224+
{
225+
CsvColumnizerConfig config = new()
226+
{
227+
CommentChar = _config.CommentChar,
228+
DelimiterChar = _config.DelimiterChar,
229+
EscapeChar = _config.EscapeChar,
230+
HasFieldNames = _config.HasFieldNames,
231+
MinColumns = _config.MinColumns,
232+
QuoteChar = _config.QuoteChar,
233+
VersionBuild = _config.VersionBuild
234+
};
235+
config.ConfigureReaderConfiguration();
236+
237+
CsvColumnizer clone = new()
238+
{
239+
_config = config,
240+
_isValidCsv = _isValidCsv,
241+
_firstLine = _firstLine == null ? null : new CsvLogLine(_firstLine.FullLine.ToString(), _firstLine.LineNumber)
242+
};
243+
244+
foreach (var column in _columnList)
245+
{
246+
clone._columnList.Add(new CsvColumn(column.Name));
247+
}
248+
249+
return clone;
250+
}
251+
223252
public void Configure (ILogLineMemoryColumnizerCallback callback, string configDir)
224253
{
225254
var configPath = configDir + "\\" + CONFIGFILENAME;

‎src/Log4jXmlColumnizer/Log4jXmlColumnizer.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
[assembly: SupportedOSPlatform("windows")]
1212
namespace Log4jXmlColumnizer;
1313

14-
public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory
14+
public class Log4jXmlColumnizer : ILogLineMemoryXmlColumnizer, IColumnizerConfiguratorMemory, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
1515
{
1616
#region Fields
1717

@@ -278,6 +278,24 @@ public void LoadConfig (string configDir)
278278
}
279279
}
280280

281+
public ILogLineMemoryColumnizer CreateSnapshot ()
282+
{
283+
Log4jXmlColumnizer clone = new()
284+
{
285+
_config = new Log4jXmlColumnizerConfig(GetAllColumnNames())
286+
{
287+
LocalTimestamps = _config.LocalTimestamps,
288+
ColumnList = [.. _config.ColumnList.Select(entry => new Log4jColumnEntry(entry.ColumnName, entry.ColumnIndex, entry.MaxLen)
289+
{
290+
Visible = entry.Visible
291+
})]
292+
},
293+
_timeOffset = _timeOffset
294+
};
295+
296+
return clone;
297+
}
298+
281299
public Priority GetPriority (string fileName, IEnumerable<ILogLineMemory> samples)
282300
{
283301
ArgumentNullException.ThrowIfNull(fileName);

‎src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ public int GetBookmarkIndexForLine (int lineNum)
8585
}
8686
}
8787

88+
public int[] GetBookmarkLineNumbers ()
89+
{
90+
lock (_bookmarkListLock)
91+
{
92+
return [.. BookmarkList.Keys];
93+
}
94+
}
95+
8896
public Entities.Bookmark GetBookmarkForLine (int lineNum)
8997
{
9098
lock (_bookmarkListLock)
@@ -148,19 +156,22 @@ public bool ConvertToManualBookmark (int lineNum)
148156

149157
public void ShiftBookmarks (int offset)
150158
{
151-
SortedList<int, Entities.Bookmark> newBookmarkList = [];
152-
153-
foreach (var bookmark in BookmarkList.Values)
159+
lock (_bookmarkListLock)
154160
{
155-
var line = bookmark.LineNum - offset;
156-
if (line >= 0)
161+
SortedList<int, Entities.Bookmark> newBookmarkList = [];
162+
163+
foreach (var bookmark in BookmarkList.Values)
157164
{
158-
bookmark.LineNum = line;
159-
newBookmarkList.Add(line, bookmark);
165+
var line = bookmark.LineNum - offset;
166+
if (line >= 0)
167+
{
168+
bookmark.LineNum = line;
169+
newBookmarkList.Add(line, bookmark);
170+
}
160171
}
161-
}
162172

163-
BookmarkList = newBookmarkList;
173+
BookmarkList = newBookmarkList;
174+
}
164175
}
165176

166177
public int FindPrevBookmarkIndex (int lineNum)

‎src/LogExpert.Core/Classes/Columnizer/SquareBracketColumnizer.cs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace LogExpert.Core.Classes.Columnizer;
1313
/// memory-efficient log line processing and columnizer prioritization, making it suitable for integration with log
1414
/// viewers or analysis tools that require flexible column extraction.
1515
/// </remarks>
16-
public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory
16+
public class SquareBracketColumnizer : ILogLineMemoryColumnizer, IColumnizerPriorityMemory, IColumnizerSnapshotMemory
1717
{
1818
#region ILogLineMemoryColumnizer implementation
1919

@@ -41,6 +41,19 @@ public SquareBracketColumnizer (int columnCount, bool isTimeExists) : this()
4141
}
4242
}
4343

44+
/// <summary>
45+
/// Creates an independent copy of the detected column layout and time offset.
46+
/// </summary>
47+
public ILogLineMemoryColumnizer CreateSnapshot ()
48+
{
49+
return new SquareBracketColumnizer
50+
{
51+
_columnCount = _columnCount,
52+
_isTimeExists = _isTimeExists,
53+
_timeOffset = _timeOffset
54+
};
55+
}
56+
4457
/// <summary>
4558
/// Determines whether timeshift functionality is implemented.
4659
/// </summary>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
namespace LogExpert.Core.Classes.Marker;
2+
3+
/// <summary>A populated vertical pixel, including its logical range and navigation target.</summary>
4+
public readonly record struct MarkerBucket (int Pixel, int FirstLine, int LastLine, int Count, int TargetLine, int ColorArgb)
5+
{
6+
/// <summary>Aggregates a line-ordered index without sampling and resolves inherited foregrounds; duplicate lines count only once.</summary>
7+
public static IReadOnlyList<MarkerBucket> Aggregate (IEnumerable<MarkerLine> matches, int lineCount, int height, int defaultForegroundArgb)
8+
{
9+
ArgumentNullException.ThrowIfNull(matches);
10+
if (lineCount <= 0 || height <= 0)
11+
{
12+
return [];
13+
}
14+
15+
var buckets = new MarkerBucket[height];
16+
var priorities = new int[height];
17+
Array.Fill(priorities, int.MaxValue);
18+
var previousLine = -1;
19+
foreach (var match in matches)
20+
{
21+
var line = match.LineNumber;
22+
if (line < 0 || line >= lineCount)
23+
{
24+
continue;
25+
}
26+
27+
var pixel = lineCount == 1 ? 0 : (int)((long)line * (height - 1) / (lineCount - 1));
28+
var bucket = buckets[pixel];
29+
// Invert the floor mapping above: a bucket starts at ceil(pixel * (lines - 1) / (height - 1))
30+
// and ends immediately before the next bucket starts. The final pixel includes the last line.
31+
var first = height == 1 ? 0 : (int)(((long)pixel * (lineCount - 1) + height - 2) / (height - 1));
32+
var last = height == 1 || pixel == height - 1 || lineCount == 1 ? lineCount - 1
33+
: (int)(((long)(pixel + 1) * (lineCount - 1) + height - 2) / (height - 1)) - 1;
34+
var target = bucket.Count == 0 ? line : bucket.TargetLine;
35+
// Double distances to compare against the midpoint without rounding half-line ties.
36+
var distance = Math.Abs(2L * line - first - last);
37+
var targetDistance = Math.Abs(2L * target - first - last);
38+
if (distance < targetDistance || (distance == targetDistance && line < target))
39+
{
40+
target = line;
41+
}
42+
43+
var color = bucket.ColorArgb;
44+
if (match.Priority < priorities[pixel])
45+
{
46+
priorities[pixel] = match.Priority;
47+
color = match.ColorArgb ?? defaultForegroundArgb;
48+
}
49+
50+
buckets[pixel] = new MarkerBucket(pixel, first, last,
51+
bucket.Count + (line == previousLine ? 0 : 1), target, color);
52+
previousLine = line;
53+
}
54+
55+
return buckets.Where(bucket => bucket.Count > 0).ToArray();
56+
}
57+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using System.Drawing;
2+
3+
using ColumnizerLib;
4+
5+
using LogExpert.Core.Classes.Highlight;
6+
using LogExpert.Core.Entities;
7+
8+
namespace LogExpert.Core.Classes.Marker;
9+
10+
/// <summary>Snapshot of the matching and visual portions of marker criteria. Never invokes triggers.</summary>
11+
public sealed class MarkerCriteria
12+
{
13+
private readonly HighlightEntry[] _entries;
14+
private HighlightEntry? _search;
15+
16+
public bool IsEmpty => _search == null && !_entries.Any(IsVisual);
17+
18+
private MarkerCriteria (HighlightEntry[] entries)
19+
{
20+
_entries = entries;
21+
}
22+
23+
public static MarkerCriteria ForHighlights (IEnumerable<HighlightEntry> entries)
24+
{
25+
ArgumentNullException.ThrowIfNull(entries);
26+
return new MarkerCriteria(entries.Select(entry => (HighlightEntry)entry.Clone()).ToArray());
27+
}
28+
29+
public static MarkerCriteria ForSearch (SearchParams search, int colorArgb)
30+
{
31+
ArgumentNullException.ThrowIfNull(search);
32+
return new MarkerCriteria([])
33+
{
34+
_search = string.IsNullOrEmpty(search.SearchText) ? null : new HighlightEntry
35+
{
36+
SearchText = search.SearchText,
37+
IsRegex = search.IsRegex,
38+
IsCaseSensitive = search.IsCaseSensitive,
39+
BackgroundColor = Color.FromArgb(colorArgb)
40+
}
41+
};
42+
}
43+
44+
public MarkerLine? Match (int lineNumber, ILogLineMemory line,
45+
Func<int, ILogLineMemory, IReadOnlyList<ITextValueMemory>>? getColumns = null,
46+
CancellationToken cancellationToken = default)
47+
{
48+
ArgumentNullException.ThrowIfNull(line);
49+
cancellationToken.ThrowIfCancellationRequested();
50+
if (_search != null)
51+
{
52+
var matched = _search.IsRegex
53+
? _search.Regex.IsMatch(line.FullLine.Span)
54+
: line.FullLine.Span.Contains(_search.SearchText,
55+
_search.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
56+
return matched ? new MarkerLine(lineNumber, _search.BackgroundColor.ToArgb()) : null;
57+
}
58+
59+
IReadOnlyList<ITextValueMemory>? columns = null;
60+
for (var priority = 0; priority < _entries.Length; priority++)
61+
{
62+
cancellationToken.ThrowIfCancellationRequested();
63+
var entry = _entries[priority];
64+
if (!IsVisual(entry))
65+
{
66+
continue;
67+
}
68+
69+
bool matched;
70+
if (entry.IsWordMatch)
71+
{
72+
columns ??= getColumns?.Invoke(lineNumber, line)
73+
?? [new Column { FullValue = line.FullLine }];
74+
matched = columns.Any(column => HasVisibleWordMatch(entry, column));
75+
}
76+
else
77+
{
78+
matched = HighlightEvaluator.IsMatch(entry, line);
79+
}
80+
81+
if (matched)
82+
{
83+
int? color = HasBackground(entry) ? entry.BackgroundColor.ToArgb()
84+
: HasForeground(entry) ? entry.ForegroundColor.ToArgb() : null;
85+
return new MarkerLine(lineNumber, color, priority);
86+
}
87+
}
88+
89+
return null;
90+
}
91+
92+
private static bool IsVisual (HighlightEntry entry)
93+
{
94+
return !entry.IsSearchHit && (HasBackground(entry)
95+
|| HasForeground(entry) || entry.IsBold);
96+
}
97+
98+
private static bool HasBackground (HighlightEntry entry)
99+
{
100+
return (!entry.IsWordMatch || !entry.NoBackground) && entry.BackgroundColor.A > 0;
101+
}
102+
103+
private static bool HasForeground (HighlightEntry entry)
104+
{
105+
return entry.ForegroundColor.A > 0;
106+
}
107+
108+
private static bool HasVisibleWordMatch (HighlightEntry entry, ITextValueMemory column)
109+
{
110+
// Word highlighting uses Regex even for literal text, against each displayed column.
111+
foreach (var match in entry.Regex.EnumerateMatches(column.Text.Span))
112+
{
113+
if (match.Length > 0)
114+
{
115+
return true;
116+
}
117+
}
118+
119+
return false;
120+
}
121+
}

0 commit comments

Comments
 (0)