diff --git a/CONTEXT.md b/CONTEXT.md
index f203b80b..481992c7 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -23,6 +23,35 @@ meaning; do not redefine them locally.
construction, since the bulk `HighlightBookmarkScanner` has no access to
the side-effecting triggers.
+## Line hiding
+
+- **Hide-line rule** — A Highlight Entry with `IsHideLine` set. A line is a
+ **Hidden Line** when any matching hide-line rule of the active Highlight Group
+ matches it (`HighlightEvaluator.IsHidden`); no other entry can cancel that,
+ whatever the order. Display state only: hidden lines stay in the reader and
+ remain eligible for Log Search, the Window Filter, Filter Pipes and triggers.
+ Temporary search-hit entries never hide. Rules match the whole line, as for
+ triggers, even when word mode paints only matches inside columns.
+- **Original logical line** — A line index of the Logfile Reader, before hiding.
+ Reader access, bookmarks, timestamps, row heights, sessions and markers use it.
+- **Visible row** — A row of the Log Window's main grid after hiding. Only grid
+ code deals in rows; convert with `RowToLine` / `LineToRow`.
+- **Line Visibility Map** (`LineVisibilityMap`) — The immutable mapping between
+ visible rows and original logical lines, owned by the **Line Visibility
+ Tracker** (`LineVisibilityTracker`), which rebuilds it on a cancellable
+ background scan and extends it on the tail path. Visibility scans never fire
+ triggers.
+- **Show hidden lines** — The per-window, transient override that displays
+ hidden lines without changing any rule. Explicit navigation to a hidden line
+ (Go to Line, bookmarks, Log Search, filter results, markers, the command-line
+ target, the window a time sync starts from) turns it on; tailing, Stop Tail,
+ windows following a time sync and restoring saved positions never do — they
+ select the nearest visible line instead. Explicit navigation made before the
+ load's first visibility scan finishes waits for it.
+
+*Avoid*: "filtered out" for hidden lines (filtering is the **Window Filter**),
+"row" when an original logical line is meant.
+
## Marker Bar
- **Marker Bar** — A compact overview beside a Log Window that shows where
diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs
index 976e772a..16fcbb29 100644
--- a/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs
+++ b/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs
@@ -97,6 +97,12 @@ public Regex Regex
public bool NoBackground { get; set; }
+ ///
+ /// When true, lines matching this entry are removed from the Log Window's main grid. Display state only:
+ /// the lines stay in the reader and remain eligible for search, filtering and triggers.
+ ///
+ public bool IsHideLine { get; set; }
+
public object Clone ()
{
var highLightEntry = new HighlightEntry
@@ -118,6 +124,7 @@ public object Clone ()
CooldownSeconds = CooldownSeconds,
BookmarkComment = BookmarkComment,
NoBackground = NoBackground,
+ IsHideLine = IsHideLine,
IsSearchHit = IsSearchHit
};
diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs
index 6f24ec38..94971dd7 100644
--- a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs
+++ b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs
@@ -59,6 +59,25 @@ public static IList FindMatchingEntries (IEnumerable
+ /// Returns whether any hide-line entry matches the line. Other matching entries cannot cancel the decision,
+ /// whatever their order.
+ ///
+ public static bool IsHidden (IEnumerable entries, ITextValueMemory line)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ ArgumentNullException.ThrowIfNull(line);
+
+ return entries.Any(e => IsHideRule(e) && IsMatch(e, line));
+ }
+
+ /// Returns whether the entry hides the lines it matches; temporary search-hit entries never do.
+ public static bool IsHideRule (HighlightEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ return entry.IsHideLine && !entry.IsSearchHit;
+ }
+
///
/// Classifies the non-plugin trigger actions implied by a set of matching entries: whether to
/// suppress the dirty LED, stop tailing, set a bookmark, and the concatenated bookmark comment.
diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs
new file mode 100644
index 00000000..79d65876
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs
@@ -0,0 +1,222 @@
+namespace LogExpert.Core.Classes.Highlight;
+
+///
+/// Immutable mapping between the rows of a Log Window's main grid and the original logical lines of its
+/// Logfile Reader, after highlight-based hiding. Memory is proportional to the number of hidden lines.
+///
+/// shares storage with the map it extends, so appending tail lines is cheap. The storage is
+/// append-only: a map only ever reads the prefix it was created with, so an older map stays valid.
+///
+///
+public sealed class LineVisibilityMap
+{
+ private readonly HiddenLineStore _store;
+
+ private LineVisibilityMap (HiddenLineStore store, int lineCount, int hiddenCount)
+ {
+ _store = store;
+ LineCount = lineCount;
+ HiddenCount = hiddenCount;
+ }
+
+ /// A new map covering no lines. Not a shared instance, so unrelated appends never share storage.
+ public static LineVisibilityMap Empty => Identity(0);
+
+ /// Total number of original logical lines covered by the map.
+ public int LineCount { get; }
+
+ public int HiddenCount { get; }
+
+ public int VisibleCount => LineCount - HiddenCount;
+
+ /// A map in which every line is visible.
+ public static LineVisibilityMap Identity (int lineCount)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(lineCount);
+ return new LineVisibilityMap(new HiddenLineStore([], 0, lineCount), lineCount, 0);
+ }
+
+ /// Returns the original line displayed in , or -1 when there is no such row.
+ public int RowToLine (int row)
+ {
+ if (row < 0 || row >= VisibleCount)
+ {
+ return -1;
+ }
+
+ if (HiddenCount == 0)
+ {
+ return row;
+ }
+
+ // hidden[i] - i is non-decreasing: count the hidden lines that lie before the row's line.
+ var items = _store.Items;
+ int low = 0, high = HiddenCount;
+ while (low < high)
+ {
+ var mid = (low + high) >>> 1;
+ if (items[mid] - mid <= row)
+ {
+ low = mid + 1;
+ }
+ else
+ {
+ high = mid;
+ }
+ }
+
+ return row + low;
+ }
+
+ /// Returns the row displaying , or -1 when the line is hidden or out of range.
+ public int LineToRow (int line)
+ {
+ if (line < 0 || line >= LineCount)
+ {
+ return -1;
+ }
+
+ var index = Array.BinarySearch(_store.Items, 0, HiddenCount, line);
+ return index >= 0 ? -1 : line - ~index;
+ }
+
+ public bool IsHidden (int line)
+ {
+ return line >= 0 && line < LineCount && Array.BinarySearch(_store.Items, 0, HiddenCount, line) >= 0;
+ }
+
+ ///
+ /// Returns the row of when it is visible; otherwise the row of the next visible line,
+ /// falling back to the previous one. Returns -1 when no line is visible.
+ ///
+ public int NearestRow (int line)
+ {
+ if (VisibleCount == 0)
+ {
+ return -1;
+ }
+
+ if (line >= LineCount)
+ {
+ return VisibleCount - 1;
+ }
+
+ line = Math.Max(line, 0);
+ var index = Array.BinarySearch(_store.Items, 0, HiddenCount, line);
+ var hiddenBefore = index >= 0 ? index : ~index;
+
+ // For a hidden line, line - hiddenBefore is the row of the next visible line, if there is one.
+ return Math.Min(line - hiddenBefore, VisibleCount - 1);
+ }
+
+ ///
+ /// Returns a map covering lines, in which
+ /// (ascending, all within the appended range) are hidden in addition to this map's hidden lines.
+ ///
+ public LineVisibilityMap Append (int lineCount, IReadOnlyList hiddenLines)
+ {
+ ArgumentNullException.ThrowIfNull(hiddenLines);
+ ArgumentOutOfRangeException.ThrowIfLessThan(lineCount, LineCount);
+
+ var previous = LineCount - 1;
+ // Hidden lines must be ascending and within the appended range.
+ foreach (var line in hiddenLines)
+ {
+ if (line <= previous || line >= lineCount)
+ {
+ throw new ArgumentOutOfRangeException(nameof(hiddenLines), line, null);
+ }
+
+ previous = line;
+ }
+
+ var store = _store.TryAppend(LineCount, HiddenCount, lineCount, hiddenLines)
+ ?? HiddenLineStore.CopyAppend(_store.Items, HiddenCount, lineCount, hiddenLines);
+
+ return new LineVisibilityMap(store, lineCount, HiddenCount + hiddenLines.Count);
+ }
+
+ /// Drops the first lines (rollover) and renumbers the rest.
+ public LineVisibilityMap Shift (int offset)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+
+ var items = _store.Items;
+ var first = Array.BinarySearch(items, 0, HiddenCount, offset);
+ first = first >= 0 ? first : ~first;
+
+ var shifted = new int[HiddenCount - first];
+ for (var i = 0; i < shifted.Length; i++)
+ {
+ shifted[i] = items[first + i] - offset;
+ }
+
+ var lineCount = Math.Max(0, LineCount - offset);
+ return new LineVisibilityMap(new HiddenLineStore(shifted, shifted.Length, lineCount), lineCount, shifted.Length);
+ }
+
+ ///
+ /// True when this map extends without changing any of its lines, so rows that
+ /// displays keep their row index.
+ ///
+ public bool IsAppendOf (LineVisibilityMap other)
+ {
+ ArgumentNullException.ThrowIfNull(other);
+ return ReferenceEquals(_store, other._store) && other.LineCount <= LineCount && other.HiddenCount <= HiddenCount;
+ }
+
+ ///
+ /// Append-only storage of hidden line numbers shared by a chain of appended maps. It is only extended in place
+ /// by the map at its committed end; any other append copies, so no map ever observes a changed prefix.
+ ///
+ private sealed class HiddenLineStore (int[] items, int committedHidden, int committedLines)
+ {
+ private readonly Lock _lock = new();
+ private int _committedHidden = committedHidden;
+ private int _committedLines = committedLines;
+ private volatile int[] _items = items;
+
+ public int[] Items => _items;
+
+ public HiddenLineStore? TryAppend (int fromLines, int fromHidden, int lineCount, IReadOnlyList hiddenLines)
+ {
+ lock (_lock)
+ {
+ if (_committedLines != fromLines || _committedHidden != fromHidden)
+ {
+ return null;
+ }
+
+ var required = fromHidden + hiddenLines.Count;
+ var target = _items;
+ if (required > target.Length)
+ {
+ target = new int[Math.Max(required, Math.Max(16, target.Length * 2))];
+ Array.Copy(_items, target, fromHidden);
+ }
+
+ for (var i = 0; i < hiddenLines.Count; i++)
+ {
+ target[fromHidden + i] = hiddenLines[i];
+ }
+
+ _items = target;
+ _committedHidden = required;
+ _committedLines = lineCount;
+ return this;
+ }
+ }
+
+ public static HiddenLineStore CopyAppend (int[] items, int hiddenCount, int lineCount, IReadOnlyList hiddenLines)
+ {
+ var copy = new int[hiddenCount + hiddenLines.Count];
+ Array.Copy(items, copy, hiddenCount);
+ for (var i = 0; i < hiddenLines.Count; i++)
+ {
+ copy[hiddenCount + i] = hiddenLines[i];
+ }
+
+ return new HiddenLineStore(copy, copy.Length, lineCount);
+ }
+ }
+}
diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs
new file mode 100644
index 00000000..1d736e3f
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs
@@ -0,0 +1,375 @@
+using ColumnizerLib;
+
+namespace LogExpert.Core.Classes.Highlight;
+
+///
+/// Keeps a Log Window's up to date with the hide-line rules of its Highlight Group.
+/// Lines are never read while the lock is held: a reader call may wait for the UI thread, which takes the lock too.
+///
+public sealed class LineVisibilityTracker : IDisposable
+{
+ private const int READ_BATCH_SIZE = 256;
+
+ private readonly Func _getLine;
+ private readonly Func _pinRange;
+ private readonly Lock _lock = new();
+
+ private volatile LineVisibilityMap _map = LineVisibilityMap.Empty;
+ private volatile HighlightEntry[]? _pendingRules;
+ private volatile bool _loadPending;
+ private HighlightEntry[] _rules = [];
+ private CancellationTokenSource? _scanCts;
+ private Task _scanTask = Task.CompletedTask;
+ private int _generation;
+ private int _version;
+ private bool _disposed;
+
+ /// Reads an original line; read live, since the reader is replaced on reload.
+ /// Pins the buffers of an inclusive line range before it is read, so they can't be evicted mid-read.
+ public LineVisibilityTracker (Func getLine, Func pinRange)
+ {
+ ArgumentNullException.ThrowIfNull(getLine);
+ ArgumentNullException.ThrowIfNull(pinRange);
+ _getLine = getLine;
+ _pinRange = pinRange;
+ }
+
+ ///
+ /// Raised on a worker thread when a background scan replaces the map, or when evaluation failed and every line
+ /// was made visible ( set).
+ ///
+ public event EventHandler? Changed;
+
+ public LineVisibilityMap Map => _map;
+
+ public bool IsScanning => _pendingRules != null;
+
+ /// The scan started by has not finished: the map does not reflect the rules yet.
+ public bool IsLoadPending => _loadPending;
+
+ /// New content was loaded: every line is visible until the scan with completes.
+ public void Load (int lineCount, IEnumerable entries)
+ {
+ var rules = Snapshot(entries);
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ SetStateLocked(LineVisibilityMap.Identity(lineCount), []);
+ StartOrStopScanLocked(rules);
+ _loadPending = rules.Length > 0;
+ }
+ }
+
+ /// The Highlight Group or its entries changed. Unchanged hide rules keep the current map.
+ public void Rebuild (IEnumerable entries)
+ {
+ var rules = Snapshot(entries);
+ LineVisibilityMap? cleared = null;
+ lock (_lock)
+ {
+ if (_disposed || SameRules(_pendingRules ?? _rules, rules))
+ {
+ return;
+ }
+
+ StartOrStopScanLocked(rules);
+ if (rules.Length == 0 && _map.HiddenCount > 0)
+ {
+ cleared = LineVisibilityMap.Identity(_map.LineCount);
+ SetStateLocked(cleared, []);
+ }
+ }
+
+ if (cleared != null)
+ {
+ Changed?.Invoke(this, new LineVisibilityChangedEventArgs(cleared, null));
+ }
+ }
+
+ /// Tail path: evaluates lines appended up to .
+ public void Extend (int lineCount)
+ {
+ EvaluateAndCommit(lineCount, replace: false);
+ }
+
+ /// Tail path, rollover: the first lines were dropped.
+ public void Shift (int offset)
+ {
+ lock (_lock)
+ {
+ if (!_disposed)
+ {
+ SetStateLocked(_map.Shift(offset), _rules);
+ RestartPendingScanLocked();
+ }
+ }
+ }
+
+ /// Tail path, truncation: the content was replaced and is re-evaluated up to .
+ public void Replace (int lineCount)
+ {
+ EvaluateAndCommit(lineCount, replace: true);
+ }
+
+ ///
+ /// Evaluates against a snapshot outside the lock, then commits only if the state is unchanged, retrying otherwise.
+ /// re-evaluates from the first line and restarts a pending scan on the new content.
+ ///
+ private void EvaluateAndCommit (int lineCount, bool replace)
+ {
+ while (true)
+ {
+ LineVisibilityMap from;
+ HighlightEntry[] rules;
+ int version;
+ lock (_lock)
+ {
+ if (_disposed || (!replace && lineCount <= _map.LineCount))
+ {
+ return;
+ }
+
+ (from, rules, version) = (replace ? LineVisibilityMap.Empty : _map, _rules, _version);
+ }
+
+ var (map, error) = TryEvaluate(from, rules, lineCount);
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (version != _version)
+ {
+ continue;
+ }
+
+ SetStateLocked(map, error == null ? rules : []);
+ if (replace)
+ {
+ RestartPendingScanLocked();
+ }
+ }
+
+ if (error != null)
+ {
+ Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error));
+ }
+
+ return;
+ }
+ }
+
+ /// Completes once no scan is running (including scans restarted meanwhile).
+ public async Task WhenIdle ()
+ {
+ while (true)
+ {
+ Task task;
+ lock (_lock)
+ {
+ task = _scanTask;
+ }
+
+ await task.ConfigureAwait(false);
+
+ lock (_lock)
+ {
+ if (task == _scanTask)
+ {
+ return;
+ }
+ }
+ }
+ }
+
+ public void Dispose ()
+ {
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _generation++;
+ _version++;
+ _pendingRules = null;
+ _loadPending = false;
+ _scanCts?.Cancel();
+ _scanCts?.Dispose();
+ _scanCts = null;
+ }
+ }
+
+ private void SetStateLocked (LineVisibilityMap map, HighlightEntry[] rules)
+ {
+ _map = map;
+ _rules = rules;
+ _version++;
+ }
+
+ private void StartOrStopScanLocked (HighlightEntry[] rules)
+ {
+ _generation++;
+ _scanCts?.Cancel();
+ _scanCts?.Dispose();
+ _scanCts = null;
+ _pendingRules = null;
+
+ if (rules.Length == 0)
+ {
+ _loadPending = false;
+ return;
+ }
+
+ var cts = new CancellationTokenSource();
+ var generation = _generation;
+ var lineCount = _map.LineCount;
+ _scanCts = cts;
+ _pendingRules = rules;
+ _scanTask = Task.Run(() => Scan(rules, lineCount, generation, cts.Token), CancellationToken.None);
+ }
+
+ private void RestartPendingScanLocked ()
+ {
+ if (_pendingRules != null)
+ {
+ StartOrStopScanLocked(_pendingRules);
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Any evaluation failure falls back to showing every line and is reported")]
+ private void Scan (HighlightEntry[] rules, int lineCount, int generation, CancellationToken token)
+ {
+ LineVisibilityMap map;
+ Exception? error = null;
+ try
+ {
+ map = Evaluate(LineVisibilityMap.Empty, rules, lineCount, token);
+
+ // Catch up with lines the tail appended while the scan ran, outside the lock, until none are left.
+ while (true)
+ {
+ int known;
+ lock (_lock)
+ {
+ if (generation != _generation)
+ {
+ return;
+ }
+
+ known = _map.LineCount;
+ if (known <= map.LineCount)
+ {
+ SetStateLocked(map, rules);
+ _pendingRules = null;
+ _loadPending = false;
+ break;
+ }
+ }
+
+ map = Evaluate(map, rules, known, token);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ lock (_lock)
+ {
+ if (generation != _generation)
+ {
+ return;
+ }
+
+ map = LineVisibilityMap.Identity(_map.LineCount);
+ SetStateLocked(map, []);
+ _pendingRules = null;
+ _loadPending = false;
+ error = ex;
+ }
+ }
+
+ Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error));
+ }
+
+ private (LineVisibilityMap Map, Exception? Error) TryEvaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount)
+ {
+ try
+ {
+ return (Evaluate(from, rules, lineCount, CancellationToken.None), null);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Never publish a partial state: fall back to showing every line.
+ return (LineVisibilityMap.Identity(lineCount), ex);
+ }
+ }
+
+ private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount, CancellationToken token)
+ {
+ if (lineCount <= from.LineCount)
+ {
+ return from;
+ }
+
+ List hidden = [];
+ if (rules.Length > 0)
+ {
+ for (var batchStart = from.LineCount; batchStart < lineCount; batchStart += READ_BATCH_SIZE)
+ {
+ var batchEnd = Math.Min(batchStart + READ_BATCH_SIZE, lineCount);
+ using var pin = _pinRange(batchStart, batchEnd - 1);
+ for (var i = batchStart; i < batchEnd; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ if (IsHiddenLine(rules, i))
+ {
+ hidden.Add(i);
+ }
+ }
+ }
+ }
+
+ return from.Append(lineCount, hidden);
+ }
+
+ private bool IsHiddenLine (HighlightEntry[] rules, int lineNum)
+ {
+ // An unreadable line cannot be classified; it stays visible.
+ var line = _getLine(lineNum);
+ return line != null && HighlightEvaluator.IsHidden(rules, line);
+ }
+
+ private static HighlightEntry[] Snapshot (IEnumerable entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ return [.. entries.Where(HighlightEvaluator.IsHideRule).Select(e => (HighlightEntry)e.Clone())];
+ }
+
+ // Matching depends only on these fields (see HighlightEvaluator.IsMatch and HighlightEntry.Regex).
+ private static bool SameRules (HighlightEntry[] current, HighlightEntry[] next)
+ {
+ return current.Length == next.Length
+ && current.Zip(next).All(pair => string.Equals(pair.First.SearchText, pair.Second.SearchText, StringComparison.Ordinal)
+ && pair.First.IsRegex == pair.Second.IsRegex
+ && pair.First.IsCaseSensitive == pair.Second.IsCaseSensitive);
+ }
+}
+
+public sealed class LineVisibilityChangedEventArgs (LineVisibilityMap map, Exception? error) : EventArgs
+{
+ public LineVisibilityMap Map { get; } = map;
+
+ /// Set when evaluation failed; the map then shows every line.
+ public Exception? Error { get; } = error;
+}
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index a97e02a2..ee588c9a 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -1126,6 +1126,15 @@ public static string HighlightDialog_UI_CheckBox_DontDirtyLed {
}
}
+ ///
+ /// Looks up a localized string similar to Hide line.
+ ///
+ public static string HighlightDialog_UI_CheckBox_HideLine {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_CheckBox_HideLine", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to No Background.
///
@@ -3267,6 +3276,15 @@ public static string LogWindow_UI_CheckBox_RangeSearch {
}
}
+ ///
+ /// Looks up a localized string similar to Show hidden lines.
+ ///
+ public static string LogWindow_UI_CheckBox_ShowHiddenLines {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_CheckBox_ShowHiddenLines", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Hides the filter list after loading a filter.
///
@@ -3521,6 +3539,15 @@ public static string LogWindow_UI_Label_Fuzzyness {
}
}
+ ///
+ /// Looks up a localized string similar to {0:N0} lines hidden by highlight rules.
+ ///
+ public static string LogWindow_UI_Label_HiddenLines {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_Label_HiddenLines", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Text &filter:.
///
@@ -3576,6 +3603,15 @@ public static string LogWindow_UI_StatusLineError_FilterFailed {
}
}
+ ///
+ /// Looks up a localized string similar to Hide rules could not be applied, all lines are shown: {0}.
+ ///
+ public static string LogWindow_UI_StatusLineError_HideRulesFailed {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_StatusLineError_HideRulesFailed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Invalid regular expression.
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index e203113f..acf8879f 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -2352,4 +2352,16 @@ LogExpert neu starten, um die Änderungen zu übernehmen?
Markierungsleiste
+
+ Zeile ausblenden
+
+
+ {0:N0} Zeilen durch Hervorhebungsregeln ausgeblendet
+
+
+ Ausgeblendete Zeilen anzeigen
+
+
+ Ausblendregeln konnten nicht angewendet werden, alle Zeilen werden angezeigt: {0}
+
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 6521310d..10f5d9a9 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -2368,4 +2368,16 @@ Restart LogExpert to apply changes?
{0}: lines {1}–{2}, {3} matching lines
Marker discovery failed: {0}
Marker bar
+
+ Hide line
+
+
+ {0:N0} lines hidden by highlight rules
+
+
+ Show hidden lines
+
+
+ Hide rules could not be applied, all lines are shown: {0}
+
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index c94a9515..d8127cc8 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -2246,4 +2246,16 @@ YY[YY] = 年
标记栏
+
+ 隐藏行
+
+
+ {0:N0} 行已被高亮规则隐藏
+
+
+ 显示隐藏的行
+
+
+ 无法应用隐藏规则,显示所有行:{0}
+
\ No newline at end of file
diff --git a/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs
index a4fa4cb7..a6ffa31e 100644
--- a/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs
+++ b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs
@@ -1,6 +1,7 @@
using System.Reflection;
using LogExpert.Core.Classes.Filter;
+using LogExpert.Core.Classes.Highlight;
using LogExpert.Core.Config;
using LogExpert.Core.Entities;
@@ -1061,5 +1062,49 @@ public void LoadOrCreateNew_BothPropertiesInJSON_NoDuplicates ()
Assert.That(loadResult.Settings.Preferences.HighlightGroupList[0].GroupName, Is.EqualTo("Group1"));
}
+ [Test]
+ [Category("BackwardCompatibility")]
+ public void HighlightExportImport_KeepsHideLineFlag ()
+ {
+ FileInfo exportFile = new(Path.Join(_testDir, "highlights.json"));
+ _configManager.Settings.Preferences.HighlightGroupList =
+ [
+ new HighlightGroup
+ {
+ GroupName = "HideGroup",
+ HighlightEntryList = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }, new HighlightEntry { SearchText = "INFO" }]
+ }
+ ];
+
+ _configManager.Export(exportFile, SettingsFlags.HighlightSettings);
+ _configManager.Settings.Preferences.HighlightGroupList.Clear();
+ _configManager.ImportHighlightSettings(exportFile, ExportImportFlags.HighlightSettings);
+
+ var entries = _configManager.Settings.Preferences.HighlightGroupList.Single(group => group.GroupName == "HideGroup").HighlightEntryList;
+ Assert.That(entries.Select(entry => entry.IsHideLine), Is.EqualTo(new[] { true, false }));
+ }
+
+ [Test]
+ [Category("BackwardCompatibility")]
+ public void HighlightImport_LegacyFileWithoutHideLine_LoadsAsNotHidden ()
+ {
+ string legacyJson = @"[
+ {
+ ""GroupName"": ""LegacyGroup"",
+ ""HighlightEntryList"": [
+ { ""SearchText"": ""DEBUG"", ""IsRegex"": false, ""IsSetBookmark"": true }
+ ]
+ }
+]";
+ FileInfo importFile = new(Path.Join(_testDir, "legacy_highlights.json"));
+ File.WriteAllText(importFile.FullName, legacyJson);
+
+ _configManager.ImportHighlightSettings(importFile, ExportImportFlags.HighlightSettings);
+
+ var entry = _configManager.Settings.Preferences.HighlightGroupList.Single(group => group.GroupName == "LegacyGroup").HighlightEntryList.Single();
+ Assert.That(entry.IsSetBookmark, Is.True);
+ Assert.That(entry.IsHideLine, Is.False);
+ }
+
#endregion
}
diff --git a/src/LogExpert.Tests/Controls/ColumnCacheTests.cs b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs
index 55270c2c..b1e6c564 100644
--- a/src/LogExpert.Tests/Controls/ColumnCacheTests.cs
+++ b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs
@@ -140,4 +140,42 @@ public void MarkPrefetchStale_KeepsBuffersPinned_While_InvalidateReleasesThem ()
cache.InvalidatePrefetch();
Assert.That(buffer.IsPinned, Is.False, "InvalidatePrefetch must release the pins.");
}
+
+ ///
+ /// Hide-line rules (#338) make visible rows non-contiguous: only the runs of displayed lines are pinned and read,
+ /// never the gap between them, and replacing the prefetch releases the previous pins.
+ ///
+ [Test]
+ public void PrefetchLines_PinsAndReadsOnlyTheRunsOfDisplayedLines ()
+ {
+ var fileInfo = new Mock();
+ _ = fileInfo.Setup(f => f.FullName).Returns("fake.log");
+ var buffers = new List();
+ var readerMock = new Mock();
+ _ = readerMock.Setup(r => r.GetLogLineMemories(It.IsAny(), It.IsAny()))
+ .Returns((int start, int count) => [.. Enumerable.Range(start, count).Select(i => (ILogLineMemory)new LogLine($"line {i}", i))]);
+ _ = readerMock.As()
+ .Setup(p => p.PinRange(It.IsAny(), It.IsAny()))
+ .Returns((int start, int _) =>
+ {
+ var buffer = new LogBuffer(fileInfo.Object, 10) { StartLine = start };
+ buffer.Pin();
+ buffers.Add(buffer);
+ return new PinHandle([buffer]);
+ });
+ var cache = new ColumnCache();
+
+ cache.PrefetchLines(readerMock.Object, [2, 3, 1000, 1001, 1002]);
+
+ readerMock.As().Verify(p => p.PinRange(2, 3), Times.Once);
+ readerMock.As().Verify(p => p.PinRange(1000, 1002), Times.Once);
+ readerMock.Verify(r => r.GetLogLineMemories(2, 2), Times.Once);
+ readerMock.Verify(r => r.GetLogLineMemories(1000, 3), Times.Once);
+ Assert.That(cache.GetPrefetchedLine(1001)?.FullLine.ToString(), Is.EqualTo("line 1001"));
+ Assert.That(cache.GetPrefetchedLine(500), Is.Null);
+
+ cache.Prefetch(readerMock.Object, 0, 1);
+ Assert.That(buffers.Take(2).All(b => !b.IsPinned), Is.True, "the sparse pins must be released once replaced");
+ Assert.That(cache.GetPrefetchedLine(1001), Is.Null);
+ }
}
diff --git a/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs b/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs
new file mode 100644
index 00000000..94e99982
--- /dev/null
+++ b/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs
@@ -0,0 +1,113 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.Core.Entities;
+
+using Newtonsoft.Json;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Highlight;
+
+[TestFixture]
+public class HideLineRuleTests
+{
+ private sealed class TestLine (string text) : ITextValueMemory
+ {
+ public ReadOnlyMemory Text => text.AsMemory();
+ }
+
+ private static ITextValueMemory Line (string text) => new TestLine(text);
+
+ [Test]
+ public void IsHideLine_DefaultsToFalse ()
+ {
+ Assert.That(new HighlightEntry().IsHideLine, Is.False);
+ }
+
+ [Test]
+ public void Clone_PreservesIsHideLine ()
+ {
+ var clone = (HighlightEntry)new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }.Clone();
+
+ Assert.That(clone.IsHideLine, Is.True);
+ }
+
+ [Test]
+ public void Json_RoundTripsIsHideLine ()
+ {
+ var groups = new List
+ {
+ new() { GroupName = "g", HighlightEntryList = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }] }
+ };
+
+ var restored = JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(groups));
+
+ Assert.That(restored[0].HighlightEntryList[0].IsHideLine, Is.True);
+ }
+
+ [Test]
+ public void Json_LegacyEntryWithoutTheFlag_ReadsAsNotHidden ()
+ {
+ const string legacy = """[{"GroupName":"g","HighlightEntryList":[{"SearchText":"DEBUG","IsStopTail":false}]}]""";
+
+ var restored = JsonConvert.DeserializeObject>(legacy);
+
+ Assert.That(restored[0].HighlightEntryList[0].IsHideLine, Is.False);
+ }
+
+ [Test]
+ public void IsHidden_MatchingHideRule_HidesLine ()
+ {
+ HighlightEntry[] entries = [new HighlightEntry { SearchText = "debug", IsHideLine = true }];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("2026 DEBUG noise")), Is.True);
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("2026 INFO start")), Is.False);
+ }
+
+ [Test]
+ public void IsHidden_OnlyColoringRulesMatch_LineStaysVisible ()
+ {
+ HighlightEntry[] entries = [new HighlightEntry { SearchText = "ERROR", BackgroundColor = Color.Red }];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("ERROR boom")), Is.False);
+ }
+
+ [Test]
+ public void IsHidden_EarlierColoringRuleMatches_CannotCancelLaterHideRule ()
+ {
+ HighlightEntry[] entries =
+ [
+ new HighlightEntry { SearchText = "heartbeat", BackgroundColor = Color.Yellow },
+ new HighlightEntry { SearchText = "heart", IsHideLine = true }
+ ];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("heartbeat ok")), Is.True);
+ }
+
+ [Test]
+ public void IsHidden_WordMatchHideRule_HidesTheWholeLine ()
+ {
+ HighlightEntry[] entries = [new HighlightEntry { SearchText = "ping", IsWordMatch = true, IsHideLine = true }];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("host ping 3ms")), Is.True);
+ }
+
+ [Test]
+ public void IsHidden_RegexAndCaseSensitivity_UseHighlightSemantics ()
+ {
+ HighlightEntry[] entries = [new HighlightEntry { SearchText = "^TRACE\\b", IsRegex = true, IsCaseSensitive = true, IsHideLine = true }];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("TRACE enter")), Is.True);
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("trace enter")), Is.False);
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("x TRACE")), Is.False);
+ }
+
+ [Test]
+ public void IsHidden_SearchHitEntries_NeverHide ()
+ {
+ HighlightEntry[] entries = [new HighlightEntry { SearchText = "x", IsHideLine = true, IsSearchHit = true }];
+
+ Assert.That(HighlightEvaluator.IsHidden(entries, Line("x")), Is.False);
+ }
+}
diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs
new file mode 100644
index 00000000..b4f0bd14
--- /dev/null
+++ b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs
@@ -0,0 +1,130 @@
+using LogExpert.Core.Classes.Highlight;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Highlight;
+
+[TestFixture]
+public class LineVisibilityMapTests
+{
+ private static LineVisibilityMap Map (int lineCount, params int[] hidden)
+ {
+ return LineVisibilityMap.Empty.Append(lineCount, hidden);
+ }
+
+ [Test]
+ public void RowToLine_SkipsHiddenLines ()
+ {
+ var map = Map(6, 1, 2, 4);
+
+ Assert.That(map.VisibleCount, Is.EqualTo(3));
+ Assert.That(map.HiddenCount, Is.EqualTo(3));
+ Assert.That(Enumerable.Range(0, 3).Select(map.RowToLine), Is.EqualTo(new[] { 0, 3, 5 }));
+ }
+
+ [Test]
+ public void LineToRow_VisibleLine_ReturnsItsRow_HiddenLine_ReturnsMinusOne ()
+ {
+ var map = Map(6, 1, 2, 4);
+
+ Assert.That(map.LineToRow(0), Is.EqualTo(0));
+ Assert.That(map.LineToRow(3), Is.EqualTo(1));
+ Assert.That(map.LineToRow(5), Is.EqualTo(2));
+ Assert.That(map.LineToRow(1), Is.EqualTo(-1));
+ Assert.That(map.LineToRow(4), Is.EqualTo(-1));
+ }
+
+ [TestCase(-1)]
+ [TestCase(6)]
+ public void OutOfRange_ReturnsMinusOne (int index)
+ {
+ var map = Map(6, 1);
+
+ Assert.That(map.LineToRow(index), Is.EqualTo(-1));
+ Assert.That(map.RowToLine(index), Is.EqualTo(-1));
+ }
+
+ [Test]
+ public void HiddenAtStartAndEnd_MapsMiddleOnly ()
+ {
+ var map = Map(5, 0, 1, 4);
+
+ Assert.That(map.VisibleCount, Is.EqualTo(2));
+ Assert.That(map.RowToLine(0), Is.EqualTo(2));
+ Assert.That(map.RowToLine(1), Is.EqualTo(3));
+ Assert.That(map.RowToLine(2), Is.EqualTo(-1));
+ }
+
+ [Test]
+ public void AllLinesHidden_HasNoRows ()
+ {
+ var map = Map(3, 0, 1, 2);
+
+ Assert.That(map.VisibleCount, Is.Zero);
+ Assert.That(map.RowToLine(0), Is.EqualTo(-1));
+ Assert.That(map.NearestRow(1), Is.EqualTo(-1));
+ }
+
+ [Test]
+ public void Identity_MapsEveryLineToItself ()
+ {
+ var map = LineVisibilityMap.Identity(4);
+
+ Assert.That(map.VisibleCount, Is.EqualTo(4));
+ Assert.That(map.HiddenCount, Is.Zero);
+ Assert.That(map.RowToLine(3), Is.EqualTo(3));
+ Assert.That(map.LineToRow(2), Is.EqualTo(2));
+ }
+
+ [Test]
+ public void NearestRow_HiddenLine_PrefersNextVisible_ThenPrevious ()
+ {
+ var map = Map(8, 2, 3, 6, 7);
+
+ Assert.That(map.NearestRow(4), Is.EqualTo(2), "visible line keeps its own row");
+ Assert.That(map.NearestRow(2), Is.EqualTo(2), "hidden line → next visible line 4");
+ Assert.That(map.NearestRow(6), Is.EqualTo(3), "no later visible line → previous visible line 5");
+ Assert.That(map.NearestRow(100), Is.EqualTo(3), "beyond the end → last visible row");
+ }
+
+ [Test]
+ public void Append_ExtendsWithNewHiddenLines_AndKeepsOldMapUnchanged ()
+ {
+ var before = Map(4, 1);
+
+ var after = before.Append(7, [4, 6]);
+
+ Assert.That(before.LineCount, Is.EqualTo(4));
+ Assert.That(before.VisibleCount, Is.EqualTo(3));
+ Assert.That(after.LineCount, Is.EqualTo(7));
+ Assert.That(Enumerable.Range(0, after.VisibleCount).Select(after.RowToLine), Is.EqualTo(new[] { 0, 2, 3, 5 }));
+ Assert.That(after.IsAppendOf(before), Is.True);
+ }
+
+ [Test]
+ public void Append_TwiceFromSameBase_DoesNotCorruptEitherBranch ()
+ {
+ var baseMap = Map(2);
+ var first = baseMap.Append(4, [2]);
+ var second = baseMap.Append(4, [3]);
+
+ Assert.That(first.IsHidden(2), Is.True);
+ Assert.That(first.IsHidden(3), Is.False);
+ Assert.That(second.IsHidden(2), Is.False);
+ Assert.That(second.IsHidden(3), Is.True);
+ }
+
+ [Test]
+ public void Shift_DropsLeadingLines_AndRenumbersTheRest ()
+ {
+ var map = Map(10, 1, 5, 8);
+
+ var shifted = map.Shift(3);
+
+ Assert.That(shifted.LineCount, Is.EqualTo(7));
+ Assert.That(shifted.IsHidden(2), Is.True, "old line 5");
+ Assert.That(shifted.IsHidden(5), Is.True, "old line 8");
+ Assert.That(shifted.HiddenCount, Is.EqualTo(2));
+ Assert.That(shifted.IsAppendOf(map), Is.False);
+ }
+}
diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs
new file mode 100644
index 00000000..e8c6aa08
--- /dev/null
+++ b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs
@@ -0,0 +1,380 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Highlight;
+
+[TestFixture]
+public class LineVisibilityTrackerTests
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ private static readonly HighlightEntry[] HideDebug = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }];
+
+ private List _lines = null!;
+ private ManualResetEventSlim _gate = null!;
+ private LineVisibilityTracker _tracker = null!;
+ private int _changedCount;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ _lines = ["INFO a", "DEBUG b", "INFO c", "DEBUG d", "INFO e"];
+ _gate = new ManualResetEventSlim(true);
+ _changedCount = 0;
+ _tracker = new LineVisibilityTracker(GetLine, NoPin);
+ _tracker.Changed += (_, _) => Interlocked.Increment(ref _changedCount);
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ _gate.Set();
+ _tracker.Dispose();
+ _gate.Dispose();
+ }
+
+ private static IDisposable? NoPin (int first, int last) => null;
+
+ private ITextValueMemory? GetLine (int lineNum)
+ {
+ _ = _gate.Wait(Timeout);
+ lock (_lines)
+ {
+ return lineNum < _lines.Count ? new LogLine(_lines[lineNum], lineNum) : null;
+ }
+ }
+
+ private void Idle ()
+ {
+ Assert.That(_tracker.WhenIdle().Wait(Timeout), Is.True, "scan did not finish");
+ }
+
+ private static int[] VisibleLines (LineVisibilityMap map)
+ {
+ return [.. Enumerable.Range(0, map.VisibleCount).Select(map.RowToLine)];
+ }
+
+ [Test]
+ public void Load_WithHideRule_PublishesMapHidingMatchingLines ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 }));
+ Assert.That(_tracker.Map.HiddenCount, Is.EqualTo(2));
+ Assert.That(_changedCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void Load_UntilScanCompletes_EveryLineStaysVisible ()
+ {
+ _gate.Reset();
+
+ _tracker.Load(_lines.Count, HideDebug);
+
+ Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5));
+ Assert.That(_tracker.IsScanning, Is.True);
+ _gate.Set();
+ Idle();
+ Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(3));
+ }
+
+ [Test]
+ public void Load_IsPendingUntilItsScanCompletes_ButARuleRebuildIsNot ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+
+ Assert.That(_tracker.IsLoadPending, Is.True);
+ _gate.Set();
+ Idle();
+ Assert.That(_tracker.IsLoadPending, Is.False);
+
+ _gate.Reset();
+ _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]);
+ Assert.That(_tracker.IsScanning, Is.True);
+ Assert.That(_tracker.IsLoadPending, Is.False, "a rule change keeps showing the previous map");
+ }
+
+ [Test]
+ public void Load_RulesRemovedWhilePending_IsNoLongerPending ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+
+ _tracker.Rebuild([]);
+
+ Assert.That(_tracker.IsLoadPending, Is.False);
+ }
+
+ [Test]
+ public void Load_WithoutHideRules_IsImmediatelyComplete ()
+ {
+ _tracker.Load(_lines.Count, [new HighlightEntry { SearchText = "DEBUG" }]);
+
+ Assert.That(_tracker.IsLoadPending, Is.False);
+ Assert.That(_tracker.IsScanning, Is.False);
+ Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void Extend_EvaluatesAppendedLines_AsAnAppendOfTheCurrentMap ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+ var before = _tracker.Map;
+ _lines.AddRange(["DEBUG f", "INFO g"]);
+
+ _tracker.Extend(7);
+ var after = _tracker.Map;
+
+ Assert.That(VisibleLines(after), Is.EqualTo(new[] { 0, 2, 4, 6 }));
+ Assert.That(after.IsAppendOf(before), Is.True);
+ }
+
+ [Test]
+ public void Extend_ToAKnownLineCount_ChangesNothing ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+ var before = _tracker.Map;
+
+ _tracker.Extend(3);
+
+ Assert.That(_tracker.Map, Is.SameAs(before));
+ }
+
+ [Test]
+ public void Extend_WhileScanPending_LinesAreCoveredByThePublishedMap ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+ _lines.Add("DEBUG f");
+
+ var interim = Task.Run(() => _tracker.Extend(6));
+ _gate.Set();
+ Assert.That(interim.Wait(Timeout), Is.True);
+ Idle();
+
+ Assert.That(_tracker.Map.LineCount, Is.EqualTo(6));
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 }));
+ }
+
+ [Test]
+ public void Rebuild_WhileScanPending_OnlyTheLatestRulesArePublished ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+
+ _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]);
+ _gate.Set();
+ Idle();
+
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 1, 3 }));
+ }
+
+ [Test]
+ public void Rebuild_RemovingAllHideRules_ShowsEveryLine ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+
+ _tracker.Rebuild([]);
+ Idle();
+
+ Assert.That(_tracker.Map.HiddenCount, Is.Zero);
+ Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void Rebuild_WithUnchangedHideRules_DoesNotRescan ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+ var before = _tracker.Map;
+
+ _tracker.Rebuild([new HighlightEntry { SearchText = "DEBUG", IsHideLine = true, BackgroundColor = Color.Red }]);
+
+ Assert.That(_tracker.IsScanning, Is.False);
+ Assert.That(_tracker.Map, Is.SameAs(before));
+ Assert.That(_changedCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void Rebuild_SnapshotsRules_LaterEditsDoNotLeakIntoTheScan ()
+ {
+ var rule = new HighlightEntry { SearchText = "DEBUG", IsHideLine = true };
+ _gate.Reset();
+ _tracker.Load(_lines.Count, [rule]);
+
+ rule.SearchText = "INFO";
+ _gate.Set();
+ Idle();
+
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 }));
+ }
+
+ [Test]
+ public void Shift_RenumbersTheCurrentMap ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+
+ _tracker.Shift(2);
+ var shifted = _tracker.Map;
+
+ Assert.That(shifted.LineCount, Is.EqualTo(3));
+ Assert.That(VisibleLines(shifted), Is.EqualTo(new[] { 0, 2 }));
+ }
+
+ [Test]
+ public void Shift_WhileScanPending_RestartsTheScanOnTheNewNumbering ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+ lock (_lines)
+ {
+ _lines.RemoveRange(0, 2);
+ }
+
+ _tracker.Shift(2);
+ _gate.Set();
+ Idle();
+
+ Assert.That(_tracker.Map.LineCount, Is.EqualTo(3));
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2 }));
+ }
+
+ [Test]
+ public void Replace_ReevaluatesEveryLineOfTheNewContent ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+ lock (_lines)
+ {
+ _lines.Clear();
+ _lines.AddRange(["DEBUG x", "INFO y"]);
+ }
+
+ _tracker.Replace(2);
+
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 1 }));
+ }
+
+ [Test]
+ public void EveryLine_IsReadWhileItsBufferIsPinned ()
+ {
+ var pinned = (First: -1, Last: -1);
+ var unpinnedReads = new List();
+ using var tracker = new LineVisibilityTracker(
+ i =>
+ {
+ if (i < pinned.First || i > pinned.Last)
+ {
+ unpinnedReads.Add(i);
+ }
+
+ return new LogLine(i % 2 == 0 ? "DEBUG" : "INFO", i);
+ },
+ (first, last) =>
+ {
+ pinned = (first, last);
+ return new ActionDisposable(() => pinned = (-1, -1));
+ });
+
+ tracker.Load(1000, HideDebug);
+ Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True);
+ tracker.Extend(1100);
+
+ Assert.That(unpinnedReads, Is.Empty);
+ Assert.That(tracker.Map.HiddenCount, Is.EqualTo(550));
+ }
+
+ private sealed class ActionDisposable (Action dispose) : IDisposable
+ {
+ public void Dispose () => dispose();
+ }
+
+ [Test]
+ public void UnreadableLine_StaysVisible ()
+ {
+ _tracker.Load(7, HideDebug);
+ Idle();
+
+ Assert.That(_tracker.Map.LineCount, Is.EqualTo(7));
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4, 5, 6 }));
+ }
+
+ [Test]
+ public void ScanFailure_PublishesAllVisible_AndReportsTheError ()
+ {
+ LineVisibilityChangedEventArgs? reported = null;
+ using var tracker = new LineVisibilityTracker(i => i == 3 ? throw new InvalidOperationException("boom") : new LogLine("DEBUG", i), NoPin);
+ tracker.Changed += (_, e) => reported = e;
+
+ tracker.Load(5, HideDebug);
+ Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True);
+
+ Assert.That(tracker.Map.HiddenCount, Is.Zero);
+ Assert.That(tracker.Map.VisibleCount, Is.EqualTo(5));
+ Assert.That(reported?.Error, Is.InstanceOf());
+ }
+
+ [Test]
+ public void TailFailure_FallsBackToAllVisible ()
+ {
+ var fail = false;
+ using var tracker = new LineVisibilityTracker(i => fail ? throw new InvalidOperationException("boom") : new LogLine(i % 2 == 0 ? "DEBUG" : "INFO", i), NoPin);
+ tracker.Load(4, HideDebug);
+ Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True);
+ fail = true;
+
+ tracker.Extend(6);
+ var map = tracker.Map;
+
+ Assert.That(map.LineCount, Is.EqualTo(6));
+ Assert.That(map.HiddenCount, Is.Zero);
+ }
+
+ [Test]
+ public void ReaderCallInProgress_NeverBlocksTheOtherCallers ()
+ {
+ _tracker.Load(_lines.Count, HideDebug);
+ Idle();
+ _lines.Add("DEBUG f");
+ _gate.Reset();
+ var extend = Task.Run(() => _tracker.Extend(6));
+ Thread.Sleep(50);
+
+ // The UI thread calls these; a reader call may itself wait for the UI thread, so none may wait for it.
+ var others = Task.Run(() =>
+ {
+ _ = _tracker.IsScanning;
+ _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]);
+ _tracker.Shift(0);
+ _tracker.Load(_lines.Count, HideDebug);
+ });
+
+ Assert.That(others.Wait(TimeSpan.FromSeconds(2)), Is.True, "a caller waited for a blocked reader call");
+ _gate.Set();
+ Assert.That(extend.Wait(Timeout), Is.True);
+ Idle();
+ Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 }));
+ }
+
+ [Test]
+ public void Dispose_CancelsAPendingScan_WithoutPublishing ()
+ {
+ _gate.Reset();
+ _tracker.Load(_lines.Count, HideDebug);
+
+ _tracker.Dispose();
+ _gate.Set();
+
+ Assert.That(_tracker.WhenIdle().Wait(Timeout), Is.True);
+ Assert.That(_changedCount, Is.Zero);
+ }
+}
diff --git a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs
index 2e297f43..509289ac 100644
--- a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs
+++ b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs
@@ -63,4 +63,26 @@ public void Highlights_FirstVisualRuleWinsAndCriteriaAreSnapshotted ()
Assert.That(match, Is.EqualTo(new MarkerLine(7, Color.Red.ToArgb(), 1)));
}
+
+ [Test]
+ public void Highlights_HideRuleWithColors_StillProducesAMarker ()
+ {
+ var criteria = MarkerCriteria.ForHighlights([
+ new HighlightEntry { SearchText = "DEBUG", IsHideLine = true, BackgroundColor = Color.Orange }
+ ]);
+
+ Assert.That(criteria.IsEmpty, Is.False);
+ Assert.That(criteria.Match(4, new LogLine("DEBUG 4", 4)), Is.EqualTo(new MarkerLine(4, Color.Orange.ToArgb(), 0)));
+ }
+
+ [Test]
+ public void Highlights_HideRuleWithoutVisualAttributes_ProducesNoMarker ()
+ {
+ var criteria = MarkerCriteria.ForHighlights([
+ new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }
+ ]);
+
+ Assert.That(criteria.IsEmpty, Is.True);
+ Assert.That(criteria.Match(4, new LogLine("DEBUG 4", 4)), Is.Null);
+ }
}
\ No newline at end of file
diff --git a/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs b/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs
new file mode 100644
index 00000000..c146c31f
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs
@@ -0,0 +1,40 @@
+using System.Reflection;
+
+using LogExpert.Core.Classes.Highlight;
+using LogExpert.UI.Dialogs.Highlight;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests.Dialogs;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+public class HighlightEntryDialogHideLineTests
+{
+ [TestCase(true)]
+ [TestCase(false)]
+ public void Load_ShowsTheEntrysHideFlag (bool hidden)
+ {
+ using var dialog = new HighlightEntryDialog(new HighlightEntry { SearchText = "DEBUG", IsHideLine = hidden }, [], false);
+
+ Assert.That(HideCheckBox(dialog).Checked, Is.EqualTo(hidden));
+ }
+
+ [Test]
+ public void Ok_SavesTheHideFlag ()
+ {
+ var entry = new HighlightEntry { SearchText = "DEBUG" };
+ using var dialog = new HighlightEntryDialog(entry, [], false);
+ HideCheckBox(dialog).Checked = true;
+
+ _ = typeof(HighlightEntryDialog).GetMethod("OnOkClick", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(dialog, [dialog, EventArgs.Empty]);
+
+ Assert.That(entry.IsHideLine, Is.True);
+ }
+
+ private static CheckBox HideCheckBox (Form dialog)
+ {
+ return (CheckBox)dialog.Controls.Find("_checkBoxHideLine", true).Single();
+ }
+}
diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs
new file mode 100644
index 00000000..cbdaf137
--- /dev/null
+++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs
@@ -0,0 +1,815 @@
+using System.Diagnostics;
+using System.Runtime.ExceptionServices;
+using System.Runtime.Versioning;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Columnizer;
+using LogExpert.Core.Classes.Highlight;
+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 Moq;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests;
+
+/// Hide-line highlight rules (#338) in a real Log Window: odd lines are "DEBUG" and hidden.
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+[NonParallelizable]
+[SupportedOSPlatform("windows")]
+public sealed class HiddenLinesWindowTests : IDisposable
+{
+ private const int LINE_COUNT = 20;
+ private static readonly DateTime SyncStart = new(2026, 1, 1, 10, 0, 0);
+
+ private string _directory = null!;
+ private string _fileName = null!;
+ private Settings _settings = null!;
+ private Mock _config = null!;
+ private LogTabWindow? _window;
+ private Exception? _uiException;
+
+ private HighlightEntry HideRule => _settings.Preferences.HighlightGroupList[0].HighlightEntryList[0];
+
+ [SetUp]
+ public void SetUp ()
+ {
+ _uiException = null;
+ _directory = Path.Join(Path.GetTempPath(), "LogExpertHiddenLinesTests", Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(_directory);
+ _fileName = Path.Join(_directory, "hidden.log");
+ File.WriteAllLines(_fileName, Enumerable.Range(0, LINE_COUNT).Select(Text));
+ _settings = new Settings();
+ _settings.Preferences.MultiFileOptions = new MultiFileOptions();
+ _settings.Preferences.FollowTail = false;
+ _settings.Preferences.AskForClose = false;
+ _settings.Preferences.AutoPick = false;
+ _settings.Preferences.OpenLastFiles = false;
+ _settings.Preferences.SaveSessions = false;
+ _settings.Preferences.SaveLocation = SessionSaveLocation.SameDir;
+ _settings.Preferences.HighlightGroupList = [new HighlightGroup
+ {
+ GroupName = "hide",
+ HighlightEntryList =
+ [
+ new HighlightEntry { SearchText = "DEBUG", IsHideLine = true },
+ new HighlightEntry { SearchText = "INFO", BackgroundColor = Color.LightGreen }
+ ]
+ }];
+ _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;
+ }
+
+ Directory.Delete(_directory, true);
+ }
+
+ public void Dispose ()
+ {
+ _window?.Dispose();
+ }
+
+ private static string Text (int line) => line % 2 == 1 ? $"DEBUG {line}" : $"INFO {line}";
+
+ [Test]
+ public void Load_HideRule_RemovesMatchingRows_AndKeepsOriginalLineNumbers ()
+ {
+ var log = Open();
+ var grid = Grid(log);
+
+ Assert.That(grid.RowCount, Is.EqualTo(LINE_COUNT / 2));
+ Assert.That(DisplayedLineNumber(grid, 0), Is.EqualTo("1"));
+ Assert.That(DisplayedLineNumber(grid, 1), Is.EqualTo("3"));
+ Assert.That(log.HiddenLineCount, Is.EqualTo(LINE_COUNT / 2));
+ Assert.That(Find
internal void MarkPrefetchStale ()
{
+ _sparseLines = null;
_prefetchStartLine = -1;
_prefetchCount = 0;
_lastLineNumber = -1;
@@ -117,14 +195,7 @@ internal IColumnizedLogLineMemory GetColumnsForLine (ILogfileReader logFileReade
_lastColumnizer = columnizer;
_lastLineNumber = lineNumber;
- ILogLineMemory line = null;
-
- if (_prefetchedLines != null
- && lineNumber >= _prefetchStartLine
- && lineNumber < _prefetchStartLine + _prefetchCount)
- {
- line = _prefetchedLines[lineNumber - _prefetchStartLine];
- }
+ var line = GetPrefetchedLine(lineNumber);
// Fallback: read directly. This is safe because the caller (CellValueNeeded)
// has already called Prefetch/PrefetchFilterVisibleLines which pins the relevant
diff --git a/src/LogExpert.UI/Controls/LogWindow/HiddenLinesBar.cs b/src/LogExpert.UI/Controls/LogWindow/HiddenLinesBar.cs
new file mode 100644
index 00000000..185b453d
--- /dev/null
+++ b/src/LogExpert.UI/Controls/LogWindow/HiddenLinesBar.cs
@@ -0,0 +1,48 @@
+using System.Globalization;
+
+namespace LogExpert.UI.Controls.LogWindow;
+
+/// The notice above the main grid: how many lines hide-line rules remove, and the "Show hidden lines" override.
+internal sealed class HiddenLinesBar : Panel
+{
+ private const int BAR_HEIGHT = 24;
+
+ // Not AutoSize: an auto-sized label keeps its text height and sits at the top instead of centering beside the check box.
+ private readonly Label _label = new() { Name = "hiddenLinesLabel", AutoSize = false, Dock = DockStyle.Left, TextAlign = ContentAlignment.MiddleLeft };
+ private readonly CheckBox _checkBox = new() { Name = "showHiddenLinesCheckBox", AutoSize = true, Dock = DockStyle.Left };
+
+ public HiddenLinesBar ()
+ {
+ Name = "hiddenLinesBar";
+ Dock = DockStyle.Top;
+ Visible = false;
+ Padding = new Padding(4, 0, 4, 0);
+ _checkBox.Text = Resources.LogWindow_UI_CheckBox_ShowHiddenLines;
+ _checkBox.CheckedChanged += (_, _) => ShowHiddenLinesChanged?.Invoke(this, EventArgs.Empty);
+ Controls.Add(_checkBox);
+ Controls.Add(_label);
+ }
+
+ public event EventHandler? ShowHiddenLinesChanged;
+
+ public bool ShowHiddenLines => _checkBox.Checked;
+
+ public int BarHeight => LogicalToDeviceUnits(BAR_HEIGHT);
+
+ /// Shows the count and the override; the bar is visible while lines are hidden or the override is on. Returns whether its visibility changed.
+ public bool SetState (int hiddenCount, bool showHiddenLines)
+ {
+ _label.Text = string.Format(CultureInfo.CurrentCulture, Resources.LogWindow_UI_Label_HiddenLines, hiddenCount);
+ _label.Width = _label.PreferredWidth + LogicalToDeviceUnits(12);
+ _checkBox.Checked = showHiddenLines;
+
+ var visible = hiddenCount > 0 || showHiddenLines;
+ if (Visible == visible)
+ {
+ return false;
+ }
+
+ Visible = visible;
+ return true;
+ }
+}
diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs
index b5a9db3f..5a900400 100644
--- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs
@@ -335,8 +335,9 @@ private void ReportMarkerError (MarkerSnapshot snapshot, ref MarkerSnapshot? rep
private void OnMarkerLineSelected (object? sender, SelectLineEventArgs eventArgs)
{
+ // The tracker's map, not the displayed one: that is empty during the first scan, when clicks must be queued.
if (_markerFrame?.Generation == Volatile.Read(ref _markerGeneration)
- && eventArgs.Line >= 0 && eventArgs.Line < dataGridView.RowCount)
+ && eventArgs.Line >= 0 && eventArgs.Line < _lineVisibility.Map.LineCount)
{
RequestGotoLine(eventArgs.Line + 1);
}
diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
index bd0afb94..6cf91223 100644
--- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
@@ -42,7 +42,7 @@
namespace LogExpert.UI.Controls.LogWindow;
[SupportedOSPlatform("windows")]
-internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow, ITimestampSource, ITailFollowSink
+internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow, ITimestampSource, ITailFollowSink, IVisibleRowsHost
{
#region Fields
@@ -51,6 +51,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private const int FILTER_PANEL2_CONTROL_GAP = 6;
private const int WAIT_TIME = 500;
private const int OVERSCAN = 20;
+ private const int COLUMN_FINDER_HEIGHT = 28;
private const int WORKER_SHUTDOWN_TIMEOUT = 2000; // ms to wait for a worker task to drain during teardown before giving up
private const string FONT_COURIER_NEW = "Courier New";
private const string FONT_VERDANA = "Verdana";
@@ -133,7 +134,11 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private bool _isLoadError;
private bool _isLoading;
private bool _isReadyForLineNavigation;
- private int? _pendingTargetLine;
+ private bool _isLoadComplete;
+ private readonly HiddenLinesBar _hiddenLinesBar = new();
+ private LineVisibilityTracker _lineVisibility;
+ private VisibleRows _visibleRows;
+ private PendingNavigation? _pendingNavigation;
private bool _isSearching;
private List _lastFilterLinesList = [];
@@ -219,6 +224,7 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b
tableLayoutPanel1.ColumnStyles[0].SizeType = SizeType.Percent;
tableLayoutPanel1.ColumnStyles[0].Width = 100;
InitializeMarkerBar();
+ InitializeLineVisibility();
_logWindowCoordinator.HighlightSettingsChanged += OnParentHighlightSettingsChanged;
SetColumnizer(_pluginRegistry.RegisteredColumnizers[0]);
@@ -377,9 +383,8 @@ public bool ShowBookmarkBubbles
}
}
- public int CurrentLineNum => dataGridView.CurrentRow == null
- ? -1
- : dataGridView.CurrentRow.Index;
+ /// Original logical line of the current row, or -1.
+ public int CurrentLineNum => _visibleRows.CurrentLine;
public string FileName { get; private set; }
@@ -653,7 +658,7 @@ internal void ToggleColumnFinder (bool show, bool setFocus)
_ = dataGridView.Focus();
}
- tableLayoutPanel1.RowStyles[0].Height = show ? 28 : 0;
+ UpdateTopRowHeight();
}
#endregion
@@ -703,8 +708,7 @@ private void OnButtonSizeChanged (object sender, EventArgs e)
[SupportedOSPlatform("windows")]
internal void DumpBufferInfo ()
{
- var currentLineNum = dataGridView.CurrentCellAddress.Y;
- _logFileReader.LogBufferInfoForLine(currentLineNum);
+ _logFileReader.LogBufferInfoForLine(CurrentLineNum);
}
internal void DumpBufferDiagnostic ()
@@ -716,7 +720,7 @@ internal void DumpBufferDiagnostic ()
[SupportedOSPlatform("windows")]
void ILineSelectable.SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll)
{
- SelectLine(lineNum, triggerSyncCall, shouldScroll);
+ RevealAndSelectLine(lineNum, triggerSyncCall, shouldScroll);
}
#region Event Handlers
@@ -771,6 +775,7 @@ protected void OnFilterListChanged (LogWindow source)
protected void OnCurrentHighlightListChanged ()
{
InvalidateMarkerCriteria(MarkerScanSource.Highlights);
+ RebuildLineVisibility();
CurrentHighlightGroupChanged?.Invoke(this, new CurrentHighlightGroupChangedEventArgs(this, _currentHighlightGroup));
}
@@ -821,6 +826,7 @@ private void OnLogWindowLoad (object sender, EventArgs e)
private void OnLogWindowDisposed (object sender, EventArgs e)
{
DisposeMarkers();
+ _lineVisibility.Dispose();
_waitingForClose = true;
CancelPendingLineNavigation();
_logWindowCoordinator.HighlightSettingsChanged -= OnParentHighlightSettingsChanged;
@@ -867,8 +873,8 @@ private void OnLogFileReaderFinishedLoading (object sender, EventArgs e)
Invoke(() =>
{
InvalidateMarkerCriteria(MarkerScanSource.All);
- _isReadyForLineNavigation = true;
- ApplyPendingLineNavigation();
+ _isLoadComplete = true;
+ UpdateLineNavigationReadiness();
});
_ = _externaLoadingFinishedEvent.Set();
}
@@ -958,7 +964,10 @@ private void OnDataGridViewCellValueNeeded (object sender, DataGridViewCellValue
PrefetchVisibleLines();
var startCount = CurrentColumnizer?.GetColumnCount() ?? 0;
- e.Value = GetCellValue(e.RowIndex, e.ColumnIndex);
+ var lineNum = RowToLine(e.RowIndex);
+ e.Value = lineNum < 0
+ ? Column.EmptyColumn
+ : GetCellValue(lineNum, e.ColumnIndex);
// The new column could be find dynamically.
// Only support add new columns for now.
@@ -983,10 +992,27 @@ private void PrefetchVisibleLines ()
var firstVisible = dataGridView.FirstDisplayedScrollingRowIndex;
var visibleCount = dataGridView.DisplayedRowCount(includePartialRow: true);
- if (firstVisible >= 0 && visibleCount > 0)
+ if (firstVisible < 0 || visibleCount <= 0)
+ {
+ return;
+ }
+
+ var map = _visibleRows.Map;
+ if (map.HiddenCount == 0)
{
_columnCache.Prefetch(_logFileReader, firstVisible, visibleCount);
+ return;
+ }
+
+ // Visible rows may be far apart in the file: pin only the lines they display.
+ var endRow = Math.Min(firstVisible + visibleCount, map.VisibleCount);
+ var lines = new int[Math.Max(0, endRow - firstVisible)];
+ for (var i = 0; i < lines.Length; i++)
+ {
+ lines[i] = map.RowToLine(firstVisible + i);
}
+
+ _columnCache.PrefetchLines(_logFileReader, lines);
}
private void PrefetchFilterVisibleLines ()
@@ -1040,10 +1066,16 @@ private void OnDataGridViewCellValuePushed (object sender, DataGridViewCellValue
return;
}
- var line = _logFileReader.GetLogLineMemory(e.RowIndex);
+ var lineNum = RowToLine(e.RowIndex);
+ if (lineNum < 0)
+ {
+ return;
+ }
+
+ var line = _logFileReader.GetLogLineMemory(lineNum);
var offset = CurrentColumnizer.GetTimeOffset();
CurrentColumnizer.SetTimeOffset(0);
- ColumnizerCallbackObject.SetLineNum(e.RowIndex);
+ ColumnizerCallbackObject.SetLineNum(lineNum);
var cols = CurrentColumnizer.SplitLine(ColumnizerCallbackObject, line);
CurrentColumnizer.SetTimeOffset(offset);
if (cols.ColumnValues.Length <= e.ColumnIndex - 2)
@@ -1081,7 +1113,7 @@ private void OnDataGridViewCurrentCellChanged (object sender, EventArgs e)
{
if (dataGridView.CurrentRow != null)
{
- _statusEventArgs.CurrentLineNum = dataGridView.CurrentRow.Index + 1;
+ _statusEventArgs.CurrentLineNum = CurrentLineNum + 1;
SendStatusLineUpdate();
if (syncFilterCheckBox.Checked)
{
@@ -1250,7 +1282,7 @@ private void OnFilterGridViewKeyDown (object sender, KeyEventArgs e)
if (filterGridView.CurrentCellAddress.Y >= 0 && filterGridView.CurrentCellAddress.Y < _filterResultList.Count)
{
var lineNum = _filterResultList[filterGridView.CurrentCellAddress.Y];
- SelectLine(lineNum, false, true);
+ RevealAndSelectLine(lineNum, false, true);
e.Handled = true;
}
@@ -1469,7 +1501,7 @@ private void OnDataGridContextMenuStripOpening (object sender, CancelEventArgs e
var lineNum = -1;
if (dataGridView.CurrentRow != null)
{
- lineNum = dataGridView.CurrentRow.Index;
+ lineNum = CurrentLineNum;
}
if (lineNum == -1)
@@ -1636,8 +1668,8 @@ private void OnScrollAllTabsToTimestampToolStripMenuItemClick (object sender, Ev
{
if (CurrentColumnizer.IsTimeshiftImplemented())
{
- var currentLine = dataGridView.CurrentCellAddress.Y;
- if (currentLine > 0 && currentLine < dataGridView.RowCount)
+ var currentLine = CurrentLineNum;
+ if (currentLine > 0)
{
var (timeStamp, _) = GetTimestampForLine(currentLine, false);
if (timeStamp.Equals(DateTime.MinValue)) // means: invalid
@@ -1655,7 +1687,7 @@ private void OnLocateLineInOriginalFileToolStripMenuItemClick (object sender, Ev
{
if (dataGridView.CurrentRow != null && FilterPipe != null)
{
- var lineNum = FilterPipe.GetOriginalLineNum(dataGridView.CurrentRow.Index);
+ var lineNum = FilterPipe.GetOriginalLineNum(CurrentLineNum);
if (lineNum != -1)
{
FilterPipe.OriginWindow.SelectLine(lineNum, false, true);
@@ -1725,7 +1757,7 @@ private void OnDataGridViewCellContextMenuStripNeeded (object sender, DataGridVi
{
if (e.RowIndex >= 0 && e.RowIndex < dataGridView.RowCount && !dataGridView.Rows[e.RowIndex].Selected)
{
- SelectLine(e.RowIndex, false, true);
+ SelectRow(e.RowIndex, false, true);
}
else if (e.RowIndex < 0)
{
@@ -1905,7 +1937,7 @@ private void OnRestoreColumnsToolStripMenuItemClick (object sender, EventArgs e)
[SupportedOSPlatform("windows")]
private void OnTimeSpreadingControlLineSelected (object sender, SelectLineEventArgs e)
{
- SelectLine(e.Line, false, true);
+ RevealAndSelectLine(e.Line, false, true);
}
[SupportedOSPlatform("windows")]
@@ -2422,6 +2454,219 @@ private void MeasureItem (object sender, MeasureItemEventArgs e)
#endregion
+ #region Line Visibility
+
+ /// Number of lines the active hide rules remove, whether or not the override shows them.
+ internal int HiddenLineCount => _lineVisibility.Map.HiddenCount;
+
+ /// The per-window "Show hidden lines" override. Transient: not saved in the Session File.
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ internal bool ShowHiddenLines
+ {
+ get => _visibleRows.ShowHiddenLines;
+ set
+ {
+ if (_visibleRows.ShowHiddenLines == value)
+ {
+ return;
+ }
+
+ _visibleRows.ShowHiddenLines = value;
+ ApplyLineVisibility();
+ }
+ }
+
+ internal Task WhenLineVisibilityIdle ()
+ {
+ return _lineVisibility.WhenIdle();
+ }
+
+ private void InitializeLineVisibility ()
+ {
+ _lineVisibility = new LineVisibilityTracker(
+ line => _logFileReader?.GetLogLineMemory(line),
+ (first, last) => (_logFileReader as IBufferPinning)?.PinRange(first, last));
+ _lineVisibility.Changed += OnLineVisibilityChanged;
+ _visibleRows = new VisibleRows(dataGridView, _lineVisibility, this);
+ _hiddenLinesBar.ShowHiddenLinesChanged += (_, _) => ShowHiddenLines = _hiddenLinesBar.ShowHiddenLines;
+
+ // Row 0 hosts the column finder; the notice bar stacks above it.
+ tableLayoutPanel1.Controls.Remove(columnFinderPanel);
+ var topRow = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) };
+ columnFinderPanel.Dock = DockStyle.Fill;
+ topRow.Controls.Add(columnFinderPanel);
+ topRow.Controls.Add(_hiddenLinesBar);
+ tableLayoutPanel1.Controls.Add(topRow, 0, 0);
+ }
+
+ /// The content is gone (loading, dead file): no rows, no scan, until the next load publishes.
+ private void ResetLineVisibility ()
+ {
+ _isLoadComplete = false;
+ _lineVisibility.Load(0, []);
+ _visibleRows.Reset();
+ UpdateHiddenLinesBar();
+ }
+
+ private void UpdateTopRowHeight ()
+ {
+ var height = _guiStateArgs.ColumnFinderVisible ? COLUMN_FINDER_HEIGHT : 0;
+ if (_hiddenLinesBar.Visible)
+ {
+ _hiddenLinesBar.Height = _hiddenLinesBar.BarHeight;
+ height += _hiddenLinesBar.Height;
+ }
+
+ columnFinderPanel.Visible = _guiStateArgs.ColumnFinderVisible;
+ tableLayoutPanel1.RowStyles[0].Height = height;
+ }
+
+ private void UpdateHiddenLinesBar ()
+ {
+ if (_hiddenLinesBar.SetState(HiddenLineCount, ShowHiddenLines))
+ {
+ UpdateTopRowHeight();
+ }
+ }
+
+ private List CurrentHighlightEntries ()
+ {
+ lock (_currentHighlightGroupLock)
+ {
+ return [.. _currentHighlightGroup.HighlightEntryList];
+ }
+ }
+
+ private void RebuildLineVisibility ()
+ {
+ if (!_isLoading && _logFileReader != null)
+ {
+ _lineVisibility.Rebuild(CurrentHighlightEntries());
+ }
+ }
+
+ private void OnLineVisibilityChanged (object? sender, LineVisibilityChangedEventArgs e)
+ {
+ if (_isClosing || IsDisposed || Disposing)
+ {
+ return;
+ }
+
+ try
+ {
+ // Marshals through a parent's handle when this (background) tab has none yet.
+ _ = BeginInvoke(() =>
+ {
+ if (e.Error != null)
+ {
+ _logger.Warn(e.Error, "Hide rules failed");
+ StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.LogWindow_UI_StatusLineError_HideRulesFailed, e.Error.Message));
+ }
+
+ ApplyLineVisibility();
+ });
+ }
+ catch (InvalidOperationException)
+ {
+ // No handle anywhere up the chain yet: OnHandleCreated applies the current map.
+ }
+ }
+
+ protected override void OnHandleCreated (EventArgs e)
+ {
+ base.OnHandleCreated(e);
+ if (_visibleRows != null && !_visibleRows.IsUpToDate)
+ {
+ _ = BeginInvoke(ApplyLineVisibility);
+ }
+ }
+
+ /// Publishes the tracker's current map to the grid (UI thread).
+ private void ApplyLineVisibility ()
+ {
+ if (_isLoading || _isClosing || _logFileReader == null || IsDisposed)
+ {
+ UpdateHiddenLinesBar();
+ return;
+ }
+
+ if (PublishTrackedMap(0) && _guiStateArgs.FollowTail && dataGridView.RowCount > 0)
+ {
+ _columnCache.MarkPrefetchStale();
+ dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1;
+ }
+
+ dataGridView.Invalidate();
+ UpdateLineNavigationReadiness();
+ }
+
+ /// Returns whether the displayed map changed.
+ private bool PublishTrackedMap (int rolloverOffset)
+ {
+ var changed = _visibleRows.Publish(rolloverOffset);
+ UpdateHiddenLinesBar();
+ return changed;
+ }
+
+ /// Original line displayed in a main-grid row, or -1.
+ private int RowToLine (int row)
+ {
+ return _visibleRows.Map.RowToLine(row);
+ }
+
+ /// Main-grid row of an original line, or -1 when it is hidden or out of range.
+ private int LineToRow (int line)
+ {
+ return _visibleRows.Map.LineToRow(line);
+ }
+
+ ///
+ /// The row to select for an original line, or -1. A line beyond the end resolves to the last row. A hidden line
+ /// turns on "Show hidden lines" when (explicit navigation), else resolves to the nearest
+ /// visible row.
+ ///
+ private int RevealOrNearestRow (int line, bool reveal)
+ {
+ if (line < 0)
+ {
+ return -1;
+ }
+
+ if (reveal && _visibleRows.Map.IsHidden(line))
+ {
+ ShowHiddenLines = true;
+ }
+
+ return _visibleRows.Map.NearestRow(line);
+ }
+
+ /// Line navigation may run once the file is loaded and the first visibility scan is published.
+ private void UpdateLineNavigationReadiness ()
+ {
+ if (_isLoadComplete && !_isReadyForLineNavigation && !_lineVisibility.IsScanning)
+ {
+ _isReadyForLineNavigation = true;
+ ApplyPendingLineNavigation();
+ }
+ }
+
+ private void RestorePositionWithoutTimeSync (GridPosition position)
+ {
+ _shouldCallTimeSync = false;
+ _visibleRows.RestorePosition(position);
+ }
+
+ bool IVisibleRowsHost.IsFollowTail => _guiStateArgs.FollowTail;
+
+ bool IVisibleRowsHost.HasRowHeights => _rowHeightList.Count > 0;
+
+ void IVisibleRowsHost.MarkPrefetchStale ()
+ {
+ _columnCache.MarkPrefetchStale();
+ }
+
+ #endregion
+
#region Private Methods
[SupportedOSPlatform("windows")]
@@ -2642,24 +2887,15 @@ private void LoadPersistenceData ()
_rowHeightList = snapshot.RowHeightList;
try
{
- if (snapshot.CurrentLine >= 0 && snapshot.CurrentLine < dataGridView.RowCount)
- {
- SelectLine(snapshot.CurrentLine, false, true);
- }
- else
- {
- if (_logFileReader.LineCount > 0)
- {
- dataGridView.FirstDisplayedScrollingRowIndex = _logFileReader.LineCount - 1;
- SelectLine(_logFileReader.LineCount - 1, false, true);
- }
- }
-
- if (snapshot.FirstDisplayedLine >= 0 &&
- snapshot.FirstDisplayedLine < dataGridView.RowCount)
- {
- dataGridView.FirstDisplayedScrollingRowIndex = snapshot.FirstDisplayedLine;
- }
+ var lineCount = _logFileReader.LineCount;
+ var currentLine = snapshot.CurrentLine >= 0 && snapshot.CurrentLine < lineCount
+ ? snapshot.CurrentLine
+ : lineCount - 1;
+ var firstLine = snapshot.FirstDisplayedLine >= 0 && snapshot.FirstDisplayedLine < lineCount
+ ? snapshot.FirstDisplayedLine
+ : currentLine;
+ RestorePositionWithoutTimeSync(new GridPosition(currentLine, firstLine));
+ _ = dataGridView.Focus();
// Applied once, here (was double-applied: always pre-load, here only when true).
// Applying true after positioning keeps the jump-to-tail semantics.
@@ -2787,20 +3023,16 @@ private void EnterLoadFileStatus ()
ClearBookmarkList();
dataGridView.ClearSelection();
dataGridView.RowCount = 0;
+ ResetLineVisibility();
}
[SupportedOSPlatform("windows")]
private void PositionAfterReload (ReloadMemento reloadMemento)
{
- if (_reloadMemento.CurrentLine < dataGridView.RowCount && _reloadMemento.CurrentLine >= 0)
- {
- dataGridView.CurrentCell = dataGridView.Rows[_reloadMemento.CurrentLine].Cells[0];
- }
-
- if (_reloadMemento.FirstDisplayedLine < dataGridView.RowCount && _reloadMemento.FirstDisplayedLine >= 0)
- {
- dataGridView.FirstDisplayedScrollingRowIndex = _reloadMemento.FirstDisplayedLine;
- }
+ var lineCount = _logFileReader.LineCount;
+ RestorePositionWithoutTimeSync(new GridPosition(
+ _reloadMemento.CurrentLine < lineCount ? _reloadMemento.CurrentLine : -1,
+ _reloadMemento.FirstDisplayedLine < lineCount ? _reloadMemento.FirstDisplayedLine : -1));
}
[SupportedOSPlatform("windows")]
@@ -2817,6 +3049,7 @@ private void LogfileDead ()
dataGridView.Enabled = false;
dataGridView.RowCount = 0;
+ ResetLineVisibility();
_progressEventArgs.Visible = false;
_progressEventArgs.Value = _progressEventArgs.MaxValue;
SendProgressBarUpdate();
@@ -2928,6 +3161,7 @@ private void ReloadNewFile ()
//_logger.Info($"ReloadNewFile(): counter = {_reloadOverloadCounter}");
if (_reloadOverloadCounter <= 1)
{
+ CancelPendingLineNavigation();
SavePersistenceData(false);
_ = _loadingFinishedEvent.Reset();
_ = _externaLoadingFinishedEvent.Reset();
@@ -3018,7 +3252,8 @@ private void LoadingFinished ()
_logFileReader.FileSizeChanged += OnFileSizeChanged;
_isLoading = false;
dataGridView.SuspendLayout();
- dataGridView.RowCount = _logFileReader.LineCount;
+ _lineVisibility.Load(_logFileReader.LineCount, CurrentHighlightEntries());
+ ApplyLineVisibility();
dataGridView.CurrentCellChanged += OnDataGridViewCurrentCellChanged;
dataGridView.Enabled = true;
dataGridView.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.DisplayedCells);
@@ -3054,12 +3289,23 @@ void ITailFollowSink.OnRolloverShift (int rolloverOffset)
ShiftBookmarks(rolloverOffset);
ShiftRowHeightList(rolloverOffset);
ShiftFilterPipes(rolloverOffset);
+ _lineVisibility.Shift(rolloverOffset);
}
void ITailFollowSink.OnTailLines (LogEventArgs e)
{
try
{
+ // Off the UI thread, before the grid shows the new lines.
+ if (!e.IsRollover && e.LineCount < e.PrevLineCount)
+ {
+ _lineVisibility.Replace(e.LineCount);
+ }
+ else
+ {
+ _lineVisibility.Extend(e.LineCount);
+ }
+
_ = BeginInvoke(UpdateGrid, [e]);
CheckFilterAndHighlight(e);
}
@@ -3105,63 +3351,11 @@ private void OnFileSizeChanged (LogEventArgs logEventArgs)
private void UpdateGrid (LogEventArgs logEventArgs)
{
var oldRowCount = dataGridView.RowCount;
- var firstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex;
-
- if (dataGridView.CurrentCellAddress.Y >= logEventArgs.LineCount)
- {
- //this.dataGridView.Rows[this.dataGridView.CurrentCellAddress.Y].Selected = false;
- //this.dataGridView.CurrentCell = this.dataGridView.Rows[0].Cells[0];
- }
try
{
- if (dataGridView.RowCount > logEventArgs.LineCount)
- {
- var currentLineNum = dataGridView.CurrentCellAddress.Y;
- dataGridView.RowCount = 0;
- dataGridView.RowCount = logEventArgs.LineCount;
- if (!_guiStateArgs.FollowTail)
- {
- if (currentLineNum >= dataGridView.RowCount)
- {
- currentLineNum = dataGridView.RowCount - 1;
- }
-
- dataGridView.CurrentCell = dataGridView.Rows[currentLineNum].Cells[0];
- }
- }
- else
- {
- dataGridView.RowCount = logEventArgs.LineCount;
- }
-
- //_logger.Debug($"UpdateGrid(): new RowCount={dataGridView.RowCount}");
-
- if (logEventArgs.IsRollover)
- {
- // Multifile rollover
- // keep selection and view range, if no follow tail mode
- if (!_guiStateArgs.FollowTail)
- {
- var currentLineNum = dataGridView.CurrentCellAddress.Y;
- currentLineNum -= logEventArgs.RolloverOffset;
- if (currentLineNum < 0)
- {
- currentLineNum = 0;
- }
-
- //_logger.Debug($"UpdateGrid(): Rollover=true, Rollover offset={logEventArgs.RolloverOffset}, currLineNum was {dataGridView.CurrentCellAddress.Y}, new currLineNum={currentLineNum}");
- firstDisplayedLine -= logEventArgs.RolloverOffset;
- if (firstDisplayedLine < 0)
- {
- firstDisplayedLine = 0;
- }
-
- dataGridView.FirstDisplayedScrollingRowIndex = firstDisplayedLine;
- dataGridView.CurrentCell = dataGridView.Rows[currentLineNum].Cells[0];
- dataGridView.Rows[currentLineNum].Selected = true;
- }
- }
+ // The engine's worker thread already applied this event to the tracker.
+ _ = PublishTrackedMap(logEventArgs.IsRollover ? logEventArgs.RolloverOffset : 0);
_statusEventArgs.LineCount = logEventArgs.LineCount;
StatusLineFileSize(logEventArgs.FileSize);
@@ -3263,7 +3457,7 @@ private void CheckFilterAndHighlight (LogEventArgs e)
if (firstStopTail)
{
var capturedLineNum = i;
- _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false));
+ _ = BeginInvoke(() => SelectAndEnsureVisibleRow(RevealOrNearestRow(capturedLineNum, reveal: false), false));
firstStopTail = false;
}
}
@@ -3421,7 +3615,7 @@ private void SetColumnizerInternal (ILogLineMemoryColumnizer columnizer)
if (_logFileReader != null)
{
- dataGridView.RowCount = _logFileReader.LineCount;
+ dataGridView.RowCount = _visibleRows.Map.VisibleCount;
}
if (_filterResultList != null)
@@ -3917,7 +4111,7 @@ private void SyncTimestampDisplay ()
{
if (dataGridView.CurrentRow != null)
{
- SyncTimestampDisplay(dataGridView.CurrentRow.Index);
+ SyncTimestampDisplay(CurrentLineNum);
}
}
}
@@ -3958,7 +4152,7 @@ private void SyncTimestampDisplayWorker ()
// timeout with no new Trigger -> update display
var lineNum = _timeShiftSyncLine;
- if (lineNum >= 0 && lineNum < dataGridView.RowCount)
+ if (lineNum >= 0 && lineNum < _visibleRows.Map.LineCount)
{
var (timeStamp, lineNumber) = GetTimestampForLine(lineNum, true);
lineNum = lineNumber;
@@ -3978,15 +4172,15 @@ private void SyncTimestampDisplayWorker ()
// show time difference between 2 selected lines
if (dataGridView.SelectedRows.Count == 2)
{
- var row1 = dataGridView.SelectedRows[0].Index;
- var row2 = dataGridView.SelectedRows[1].Index;
- if (row1 > row2)
+ var line1 = RowToLine(dataGridView.SelectedRows[0].Index);
+ var line2 = RowToLine(dataGridView.SelectedRows[1].Index);
+ if (line1 > line2)
{
- (row2, row1) = (row1, row2);
+ (line2, line1) = (line1, line2);
}
- var (timeStamp1, _) = GetTimestampForLine(row1, false);
- var (timeStamp2, _) = GetTimestampForLine(row2, false);
+ var (timeStamp1, _) = GetTimestampForLine(line1, false);
+ var (timeStamp2, _) = GetTimestampForLine(line2, false);
//TimeSpan span = TimeSpan.FromTicks(timeStamp2.Ticks - timeStamp1.Ticks);
var diff = timeStamp1.Ticks > timeStamp2.Ticks
? new DateTime(timeStamp1.Ticks - timeStamp2.Ticks)
@@ -4011,7 +4205,7 @@ private void SyncFilterGridPos ()
{
if (_filterResultList.Count > 0)
{
- var index = _filterResultList.BinarySearch(dataGridView.CurrentRow.Index);
+ var index = _filterResultList.BinarySearch(CurrentLineNum);
if (index < 0)
{
index = ~index;
@@ -4084,29 +4278,43 @@ private void ResetProgressBar ()
SendProgressBarUpdate();
}
+ /// Explicit navigation to an original line; a hidden line turns on "Show hidden lines".
+ [SupportedOSPlatform("windows")]
+ private void RevealAndSelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll)
+ {
+ RunWhenNavigable(() => SelectRow(RevealOrNearestRow(lineNum, reveal: true), triggerSyncCall, shouldScroll));
+ }
+
+ /// Selects an original line, or the nearest visible line when it is hidden.
[SupportedOSPlatform("windows")]
- private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll)
+ private void SelectNearestLine (int lineNum, bool triggerSyncCall, bool shouldScroll)
+ {
+ SelectRow(RevealOrNearestRow(lineNum, reveal: false), triggerSyncCall, shouldScroll);
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void SelectRow (int rowNum, bool triggerSyncCall, bool shouldScroll)
{
try
{
_shouldCallTimeSync = triggerSyncCall;
- if (lineNum < 0)
+ if (rowNum < 0)
{
return;
}
// Prevent ArgumentOutOfRangeException
- if (lineNum >= dataGridView.Rows.GetRowCount(DataGridViewElementStates.None))
+ if (rowNum >= dataGridView.Rows.GetRowCount(DataGridViewElementStates.None))
{
- lineNum = dataGridView.Rows.GetRowCount(DataGridViewElementStates.None) - 1;
+ rowNum = dataGridView.Rows.GetRowCount(DataGridViewElementStates.None) - 1;
}
- dataGridView.Rows[lineNum].Selected = true;
+ dataGridView.Rows[rowNum].Selected = true;
if (shouldScroll)
{
- dataGridView.CurrentCell = dataGridView.Rows[lineNum].Cells[0];
+ dataGridView.CurrentCell = dataGridView.Rows[rowNum].Cells[0];
_ = dataGridView.Focus();
}
}
@@ -4161,17 +4369,17 @@ private void UpdateEditColumnDisplay (DataGridViewTextBoxEditingControl editCont
[SupportedOSPlatform("windows")]
private void SelectPrevHighlightLine ()
{
- var lineNum = dataGridView.CurrentCellAddress.Y;
+ var lineNum = CurrentLineNum;
while (lineNum > 0)
{
lineNum--;
- var line = _logFileReader.GetLogLineMemory(lineNum);
+ var line = _visibleRows.Map.IsHidden(lineNum) ? null : _logFileReader.GetLogLineMemory(lineNum);
if (line != null)
{
var entry = FindHighlightEntry(line);
if (entry != null)
{
- SelectLine(lineNum, false, true);
+ RevealAndSelectLine(lineNum, false, true);
break;
}
}
@@ -4181,17 +4389,17 @@ private void SelectPrevHighlightLine ()
[SupportedOSPlatform("windows")]
private void SelectNextHighlightLine ()
{
- var lineNum = dataGridView.CurrentCellAddress.Y;
+ var lineNum = CurrentLineNum;
while (lineNum < _logFileReader.LineCount)
{
lineNum++;
- var line = _logFileReader.GetLogLineMemory(lineNum);
+ var line = _visibleRows.Map.IsHidden(lineNum) ? null : _logFileReader.GetLogLineMemory(lineNum);
if (line != null)
{
var entry = FindHighlightEntry(line);
if (entry != null)
{
- SelectLine(lineNum, false, true);
+ RevealAndSelectLine(lineNum, false, true);
break;
}
}
@@ -4201,7 +4409,7 @@ private void SelectNextHighlightLine ()
[SupportedOSPlatform("windows")]
private int FindNextBookmarkIndex (int lineNum)
{
- if (lineNum >= dataGridView.RowCount)
+ if (lineNum >= _visibleRows.Map.LineCount)
{
lineNum = 0;
}
@@ -4218,7 +4426,7 @@ private int FindPrevBookmarkIndex (int lineNum)
{
if (lineNum <= 0)
{
- lineNum = dataGridView.RowCount - 1;
+ lineNum = _visibleRows.Map.LineCount - 1;
}
else
{
@@ -4277,7 +4485,7 @@ private void LoadFilterPipes ()
if (_filterPipeList.Count > 0)
{
- for (var i = 0; i < dataGridView.RowCount; ++i)
+ for (var i = 0; i < _logFileReader.LineCount; ++i)
{
ProcessFilterPipes(i);
}
@@ -4418,7 +4626,7 @@ private async void FilterSearch (string text)
ClearFilterList();
_progressEventArgs.MinValue = 0;
- _progressEventArgs.MaxValue = dataGridView.RowCount;
+ _progressEventArgs.MaxValue = _logFileReader.LineCount;
_progressEventArgs.Value = 0;
_progressEventArgs.Visible = true;
SendProgressBarUpdate();
@@ -4826,7 +5034,7 @@ private void DisplayCurrentFileOnStatusline ()
{
if (dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index > -1)
{
- var fileName = _logFileReader.GetLogFileNameForLine(dataGridView.CurrentRow.Index);
+ var fileName = _logFileReader.GetLogFileNameForLine(CurrentLineNum);
if (fileName != null)
{
StatusLineText(Util.GetNameFromPath(fileName));
@@ -5181,7 +5389,7 @@ private void CopyMarkedLinesToClipboard ()
{
if (row.Index != -1)
{
- lineNumList.Add(row.Index);
+ lineNumList.Add(RowToLine(row.Index));
}
}
@@ -5269,7 +5477,7 @@ private List GetSelectedContent ()
{
if (row.Index != -1)
{
- lineNumList.Add(row.Index);
+ lineNumList.Add(RowToLine(row.Index));
}
}
@@ -5293,7 +5501,7 @@ private void SetTimestampLimits ()
}
(_guiStateArgs.MinTimestamp, _) = _timestampLocator.FindForward(0, _logFileReader.LineCount, true);
- (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(dataGridView.RowCount - 1, true);
+ (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(_logFileReader.LineCount - 1, true);
SendGuiStateUpdate();
}
@@ -5398,9 +5606,11 @@ private void ChangeRowHeight (bool decrease)
return;
}
+ var lineNum = RowToLine(rowNum);
+
if (decrease)
{
- if (!_rowHeightList.TryGetValue(rowNum, out var entry))
+ if (!_rowHeightList.TryGetValue(lineNum, out var entry))
{
return;
}
@@ -5409,22 +5619,22 @@ private void ChangeRowHeight (bool decrease)
entry.Height -= _lineHeight;
if (entry.Height <= _lineHeight)
{
- _ = _rowHeightList.Remove(rowNum);
+ _ = _rowHeightList.Remove(lineNum);
}
}
}
else
{
RowHeightEntry entry;
- if (!_rowHeightList.TryGetValue(rowNum, out var value))
+ if (!_rowHeightList.TryGetValue(lineNum, out var value))
{
entry = new RowHeightEntry
{
- LineNum = rowNum,
+ LineNum = lineNum,
Height = _lineHeight
};
- _rowHeightList[rowNum] = entry;
+ _rowHeightList[lineNum] = entry;
}
else
{
@@ -5445,7 +5655,8 @@ private void ChangeRowHeight (bool decrease)
private int GetRowHeight (int rowNum)
{
- return _rowHeightList.TryGetValue(rowNum, out var value)
+ // _rowHeightList is keyed by original line (it is saved in the Session File).
+ return _rowHeightList.TryGetValue(RowToLine(rowNum), out var value)
? value.Height
: _lineHeight;
}
@@ -5461,7 +5672,7 @@ private void AddBookmarkAtLineSilently (int lineNum)
[SupportedOSPlatform("windows")]
private void AddBookmarkAndEditComment ()
{
- var lineNum = dataGridView.CurrentCellAddress.Y;
+ var lineNum = CurrentLineNum;
if (!_bookmarkProvider.IsBookmarkAtLine(lineNum))
{
ToggleBookmark();
@@ -5473,7 +5684,7 @@ private void AddBookmarkAndEditComment ()
[SupportedOSPlatform("windows")]
private void AddBookmarkComment (string text)
{
- var lineNum = dataGridView.CurrentCellAddress.Y;
+ var lineNum = CurrentLineNum;
Bookmark bookmark;
if (!_bookmarkProvider.IsBookmarkAtLine(lineNum))
@@ -5497,14 +5708,18 @@ private void MarkCurrentFilterRange ()
_filterParams.RangeSearchText = filterRangeComboBox.Text;
ColumnizerCallback callback = new(this);
RangeFinder rangeFinder = new(_filterParams, callback);
- var range = rangeFinder.FindRange(dataGridView.CurrentCellAddress.Y);
+ var range = rangeFinder.FindRange(CurrentLineNum);
if (range != null)
{
SetCellSelectionMode(false);
_noSelectionUpdates = true;
for (var i = range.StartLine; i <= range.EndLine; ++i)
{
- dataGridView.Rows[i].Selected = true;
+ var row = LineToRow(i);
+ if (row >= 0)
+ {
+ dataGridView.Rows[row].Selected = true;
+ }
}
_noSelectionUpdates = false;
@@ -5600,7 +5815,7 @@ private void AddSlaveToTimesync (LogWindow slave)
TimeSyncList = slave.TimeSyncList;
}
- var currentLineNum = dataGridView.CurrentCellAddress.Y;
+ var currentLineNum = CurrentLineNum;
var (timeStamp, _) = GetTimestampForLine(currentLineNum, true);
if (!timeStamp.Equals(DateTime.MinValue) && !_shouldTimestampDisplaySyncingCancel)
{
@@ -5937,8 +6152,8 @@ public SessionSnapshot GatherSessionSnapshot ()
MultiFile = IsMultiFile,
MultiFilePattern = _multiFileOptions.FormatPattern,
MultiFileMaxDays = _multiFileOptions.MaxDayTry,
- CurrentLine = dataGridView.CurrentCellAddress.Y,
- FirstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex,
+ CurrentLine = CurrentLineNum,
+ FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex),
FilterVisible = !splitContainerLogWindow.Panel2Collapsed,
FilterAdvanced = !advancedFilterSplitContainer.Panel1Collapsed,
FilterPosition = splitContainerLogWindow.SplitterDistance,
@@ -5997,6 +6212,8 @@ public void CloseLogWindow ()
CancelPendingLineNavigation();
CancelHighlightBookmarkScan();
+ _lineVisibility.Changed -= OnLineVisibilityChanged;
+ _lineVisibility.Dispose();
StopTimespreadThread();
StopTimestampSyncThread();
_tailFollowEngine.Stop();
@@ -6097,13 +6314,13 @@ public void SetColumnizer (ILogLineMemoryColumnizer columnizer, BufferedDataGrid
ApplyFrozenState(gridView);
}
- public IColumnMemory GetCellValue (int rowIndex, int columnIndex)
+ public IColumnMemory GetCellValue (int lineNum, int columnIndex)
{
if (columnIndex == 1)
{
return new Column
{
- FullValue = $"{rowIndex + 1}".AsMemory() // line number
+ FullValue = $"{lineNum + 1}".AsMemory() // line number
};
}
@@ -6114,7 +6331,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex)
try
{
- var cols = _columnCache.GetColumnsForLine(_logFileReader, rowIndex, CurrentColumnizer, ColumnizerCallbackObject);
+ var cols = _columnCache.GetColumnsForLine(_logFileReader, lineNum, CurrentColumnizer, ColumnizerCallbackObject);
if (cols != null && cols.ColumnValues != null)
{
if (columnIndex <= cols.ColumnValues.Length + 1)
@@ -6135,7 +6352,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex)
catch (IndexOutOfRangeException ex)
{
- _logger.Warn(ex, "Failed to get cell value due to index error. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex);
+ _logger.Warn(ex, "Failed to get cell value due to index error. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex);
return Column.EmptyColumn;
}
#else
@@ -6149,7 +6366,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex)
catch (ArgumentOutOfRangeException ex)
{
- _logger.Warn(ex, "Failed to get cell value due to argument range error. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex);
+ _logger.Warn(ex, "Failed to get cell value due to argument range error. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex);
return Column.EmptyColumn;
}
#else
@@ -6162,7 +6379,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex)
catch (NullReferenceException ex)
{
- _logger.Warn(ex, "Failed to get cell value due to null state. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex);
+ _logger.Warn(ex, "Failed to get cell value due to null state. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex);
return Column.EmptyColumn;
}
#else
@@ -6262,9 +6479,14 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi
return;
}
- if (isFilteredGridView)
+ var lineNum = isFilteredGridView
+ ? _filterResultList[rowIndex]
+ : RowToLine(rowIndex);
+
+ if (lineNum < 0)
{
- rowIndex = _filterResultList[rowIndex];
+ e.Handled = false;
+ return;
}
// Ensure prefetch is current — CellPainting fires BEFORE CellValueNeeded on scroll jumps,
@@ -6282,8 +6504,8 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi
// Use only prefetched (pinned) data — no unprotected fallback
ILogLineMemory line = !isFilteredGridView
- ? _columnCache.GetPrefetchedLine(rowIndex)
- : _filterColumnCache.GetPrefetchedLine(rowIndex);
+ ? _columnCache.GetPrefetchedLine(lineNum)
+ : _filterColumnCache.GetPrefetchedLine(lineNum);
if (line == null)
{
@@ -6293,13 +6515,13 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi
// very small files with only one visible row), and PrefetchVisibleLines
// therefore short-circuits without pinning anything.
var targetCache = !isFilteredGridView ? _columnCache : _filterColumnCache;
- targetCache.Prefetch(_logFileReader, rowIndex, 1);
- line = targetCache.GetPrefetchedLine(rowIndex);
+ targetCache.Prefetch(_logFileReader, lineNum, 1);
+ line = targetCache.GetPrefetchedLine(lineNum);
}
if (line == null)
{
- _logger.Warn("CellPainting: null line for rowIndex={0}, isFilteredGridView={1}", rowIndex, isFilteredGridView);
+ _logger.Warn("CellPainting: null line for lineNum={0}, isFilteredGridView={1}", lineNum, isFilteredGridView);
// Paint an empty cell with proper colors to prevent white-on-white default rendering
e.Graphics.SetClip(e.CellBounds);
@@ -6337,13 +6559,13 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi
if (columnIndex == 0)
{
- if (_bookmarkProvider.IsBookmarkAtLine(rowIndex))
+ if (_bookmarkProvider.IsBookmarkAtLine(lineNum))
{
//keeping this comment, because it's the original code
// = new Rectangle(e.CellBounds.Left + 2, e.CellBounds.Top + 2, 6, 6);
var rect = e.CellBounds;
rect.Inflate(-2, -2);
- var bookmark = _bookmarkProvider.GetBookmarkForLine(rowIndex);
+ var bookmark = _bookmarkProvider.GetBookmarkForLine(lineNum);
var bookmarkColor = bookmark.IsAutoGenerated ? AutoBookmarkColor : BookmarkColor;
using var brush = new SolidBrush(bookmarkColor);
e.Graphics.FillRectangle(brush, rect);
@@ -6442,12 +6664,13 @@ public void FollowTailChanged (bool isChecked, bool byTrigger)
if (_guiStateArgs.FollowTail && _logFileReader != null)
{
- if (dataGridView.RowCount >= _logFileReader.LineCount && _logFileReader.LineCount > 0)
+ // Follow the last visible row; hidden lines are never revealed by tailing.
+ if (_visibleRows.Map.LineCount >= _logFileReader.LineCount && dataGridView.RowCount > 0)
{
// Mark stale instead of invalidating — keeps old buffers pinned until
// the next Prefetch atomically swaps in new pins.
_columnCache.MarkPrefetchStale();
- dataGridView.FirstDisplayedScrollingRowIndex = _logFileReader.LineCount - 1;
+ dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1;
}
}
@@ -6490,35 +6713,58 @@ public void RequestGotoLine (int targetLine)
return;
}
- _pendingTargetLine = targetLine;
+ RunWhenNavigable(() => GotoRequestedLine(targetLine));
+ }
+
+ private void GotoRequestedLine (int targetLine)
+ {
+ FollowTailChanged(false, false);
+ if (_visibleRows.Map.LineCount > 0)
+ {
+ dataGridView.ClearSelection();
+ GotoLine(targetLine - 1);
+ // Scrolling to the last row can automatically turn follow-tail back on.
+ FollowTailChanged(false, false);
+ }
+ }
+
+ /// Runs the user's line navigation now, or once the load and its first visibility scan are done.
+ private void RunWhenNavigable (Action navigate)
+ {
if (_isReadyForLineNavigation)
{
- ApplyPendingLineNavigation();
+ navigate();
+ }
+ else
+ {
+ SetPendingNavigation(new PendingNavigation(navigate, IsTimeSyncFollow: false));
}
}
- private void ApplyPendingLineNavigation ()
+ /// A newer request replaces a pending one, except that following a time sync never replaces the user's own.
+ private void SetPendingNavigation (PendingNavigation navigation)
{
- var targetLine = _pendingTargetLine;
- _pendingTargetLine = null;
- if (!targetLine.HasValue || IsDisposed || Disposing || _waitingForClose || _isClosing || _isDeadFile || _isLoadError)
+ if (!navigation.IsTimeSyncFollow || _pendingNavigation is not { IsTimeSyncFollow: false })
{
- return;
+ _pendingNavigation = navigation;
}
+ }
- FollowTailChanged(false, false);
- if (dataGridView.RowCount > 0)
+ private void ApplyPendingLineNavigation ()
+ {
+ var navigation = _pendingNavigation;
+ _pendingNavigation = null;
+ if (IsDisposed || Disposing || _waitingForClose || _isClosing || _isDeadFile || _isLoadError)
{
- dataGridView.ClearSelection();
- GotoLine(targetLine.Value - 1);
- // Scrolling to the last row can automatically turn follow-tail back on.
- FollowTailChanged(false, false);
+ return;
}
+
+ navigation?.Navigate();
}
private void CancelPendingLineNavigation ()
{
- _pendingTargetLine = null;
+ _pendingNavigation = null;
_isReadyForLineNavigation = false;
}
@@ -6526,14 +6772,7 @@ public void GotoLine (int line)
{
if (line >= 0)
{
- if (line < dataGridView.RowCount)
- {
- SelectLine(line, false, true);
- }
- else
- {
- SelectLine(dataGridView.RowCount - 1, false, true);
- }
+ RevealAndSelectLine(line, false, true);
_ = dataGridView.Focus();
}
@@ -6546,8 +6785,8 @@ public void StartSearch ()
var searchParams = _logWindowCoordinator.SearchParams;
searchParams.CurrentLine = LogSearcher.ResolveDirection(searchParams) == SearchDirection.Forward
- ? dataGridView.CurrentCellAddress.Y + 1
- : dataGridView.CurrentCellAddress.Y - 1;
+ ? CurrentLineNum + 1
+ : CurrentLineNum - 1;
_currentSearchParams = searchParams; // remember for async "not found" messages
TrackMarkerSearch(searchParams);
@@ -6556,7 +6795,7 @@ public void StartSearch ()
StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel);
_progressEventArgs.MinValue = 0;
- _progressEventArgs.MaxValue = dataGridView.RowCount;
+ _progressEventArgs.MaxValue = _logFileReader.LineCount;
_progressEventArgs.Value = 0;
_progressEventArgs.Visible = true;
SendProgressBarUpdate();
@@ -6598,7 +6837,7 @@ private void SearchComplete (Task task)
{
case SearchOutcome.Found:
StatusLineText(string.Empty);
- _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), result.LineNumber, true);
+ _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => RevealAndSelectLine(line1, triggerSyncCall, true)), result.LineNumber, true);
break;
case SearchOutcome.NotFound:
@@ -6622,31 +6861,42 @@ private void SearchComplete (Task task)
public void SelectLogLine (int lineNumber)
{
- _ = Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), lineNumber, true);
+ _ = Invoke(new SelectLineFx((line1, triggerSyncCall) => RevealAndSelectLine(line1, triggerSyncCall, true)), lineNumber, true);
}
+ /// Explicit navigation to an original line; a hidden line turns on "Show hidden lines".
public void SelectAndEnsureVisible (int line, bool triggerSyncCall)
{
+ RunWhenNavigable(() => SelectAndEnsureVisibleRow(RevealOrNearestRow(line, reveal: true), triggerSyncCall));
+ }
+
+ private void SelectAndEnsureVisibleRow (int row, bool triggerSyncCall)
+ {
+ if (row < 0)
+ {
+ return;
+ }
+
try
{
- SelectLine(line, triggerSyncCall, false);
+ SelectRow(row, triggerSyncCall, false);
//if (!this.dataGridView.CurrentRow.Displayed)
- if (line < dataGridView.FirstDisplayedScrollingRowIndex || line > dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false))
+ if (row < dataGridView.FirstDisplayedScrollingRowIndex || row > dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false))
{
- dataGridView.FirstDisplayedScrollingRowIndex = line;
- for (var i = 0; i < 8 && dataGridView.FirstDisplayedScrollingRowIndex > 0 && line < dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false); ++i)
+ dataGridView.FirstDisplayedScrollingRowIndex = row;
+ for (var i = 0; i < 8 && dataGridView.FirstDisplayedScrollingRowIndex > 0 && row < dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false); ++i)
{
dataGridView.FirstDisplayedScrollingRowIndex -= 1;
}
- if (line >= dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false))
+ if (row >= dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false))
{
dataGridView.FirstDisplayedScrollingRowIndex += 1;
}
}
- dataGridView.CurrentCell = dataGridView.Rows[line].Cells[0];
+ dataGridView.CurrentCell = dataGridView.Rows[row].Cells[0];
}
catch (Exception e)
{
@@ -6699,11 +6949,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e)
}
case Keys.Down when e.Modifiers == Keys.Alt:
{
- var newLine = _logFileReader.GetNextMultiFileLine(dataGridView.CurrentCellAddress.Y);
+ var newLine = _logFileReader.GetNextMultiFileLine(CurrentLineNum);
if (newLine != -1)
{
- SelectLine(newLine, false, true);
+ SelectNearestLine(newLine, false, true);
}
e.Handled = true;
@@ -6712,11 +6962,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e)
}
case Keys.Up when e.Modifiers == Keys.Alt:
{
- var newLine = _logFileReader.GetPrevMultiFileLine(dataGridView.CurrentCellAddress.Y);
+ var newLine = _logFileReader.GetPrevMultiFileLine(CurrentLineNum);
if (newLine != -1)
{
- SelectLine(newLine - 1, false, true);
+ SelectNearestLine(newLine - 1, false, true);
}
e.Handled = true;
@@ -6785,9 +7035,10 @@ public void AddBookmarkOverlays ()
}
}
- if (_bookmarkProvider.IsBookmarkAtLine(i))
+ var lineNum = RowToLine(i);
+ if (_bookmarkProvider.IsBookmarkAtLine(lineNum))
{
- var bookmark = _bookmarkProvider.GetBookmarkForLine(i);
+ var bookmark = _bookmarkProvider.GetBookmarkForLine(lineNum);
if (bookmark.Text.Length > 0)
{
//BookmarkOverlay overlay = new BookmarkOverlay();
@@ -6865,7 +7116,7 @@ public void ToggleBookmark ()
return;
}
- lineNum = dataGridView.CurrentCellAddress.Y;
+ lineNum = CurrentLineNum;
}
ToggleBookmark(lineNum);
@@ -7158,14 +7409,14 @@ public void JumpNextBookmark ()
}
else
{
- var index = FindNextBookmarkIndex(dataGridView.CurrentCellAddress.Y);
+ var index = FindNextBookmarkIndex(CurrentLineNum);
if (index > _bookmarkProvider.Bookmarks.Count - 1)
{
index = 0;
}
var lineNum = _bookmarkProvider.Bookmarks[index].LineNum;
- SelectLine(lineNum, true, true);
+ RevealAndSelectLine(lineNum, true, true);
}
}
}
@@ -7217,14 +7468,14 @@ public void JumpPrevBookmark ()
}
else
{
- var index = FindPrevBookmarkIndex(dataGridView.CurrentCellAddress.Y);
+ var index = FindPrevBookmarkIndex(CurrentLineNum);
if (index < 0)
{
index = _bookmarkProvider.Bookmarks.Count - 1;
}
var lineNum = _bookmarkProvider.Bookmarks[index].LineNum;
- SelectLine(lineNum, false, true);
+ RevealAndSelectLine(lineNum, false, true);
}
}
}
@@ -7373,7 +7624,7 @@ public void CopyMarkedLinesToTab ()
{
if (row.Index != -1)
{
- lineNumList.Add(row.Index);
+ lineNumList.Add(RowToLine(row.Index));
}
}
@@ -7434,8 +7685,8 @@ public void Reload ()
_reloadMemento = new ReloadMemento
{
- CurrentLine = dataGridView.CurrentCellAddress.Y,
- FirstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex
+ CurrentLine = CurrentLineNum,
+ FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex)
};
_forcedColumnizerForLoading = CurrentColumnizer;
@@ -7530,17 +7781,26 @@ public bool ScrollToTimestamp (DateTime timestamp, bool roundToSeconds, bool tri
return ScrollToTimestampWorker(timestamp, roundToSeconds, triggerSyncCall);
}
+ /// Whether it scrolled; false also when the navigation waits for the load's first scan.
public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bool triggerSyncCall)
{
+ if (!_isReadyForLineNavigation)
+ {
+ // A window that doesn't start the sync is following one.
+ var isTimeSyncFollow = !triggerSyncCall;
+ SetPendingNavigation(new PendingNavigation(() => ScrollToTimestampWorker(timestamp, roundToSeconds, triggerSyncCall), isTimeSyncFollow));
+ return false;
+ }
+
var hasScrolled = false;
- if (!CurrentColumnizer.IsTimeshiftImplemented() || dataGridView.RowCount == 0)
+ if (!CurrentColumnizer.IsTimeshiftImplemented() || _visibleRows.Map.LineCount == 0)
{
return false;
}
//this.Cursor = Cursors.WaitCursor;
- var currentLine = dataGridView.CurrentCellAddress.Y;
- if (currentLine < 0 || currentLine >= dataGridView.RowCount)
+ var currentLine = CurrentLineNum;
+ if (currentLine < 0)
{
currentLine = 0;
}
@@ -7548,7 +7808,8 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo
var foundLine = _timestampLocator.FindLine(timestamp, currentLine, _logFileReader.LineCount, roundToSeconds, _cts.Token);
if (foundLine >= 0)
{
- SelectAndEnsureVisible(foundLine, triggerSyncCall);
+ // Windows following a time sync don't reveal hidden lines.
+ SelectAndEnsureVisibleRow(RevealOrNearestRow(foundLine, reveal: triggerSyncCall), triggerSyncCall);
hasScrolled = true;
}
@@ -7588,7 +7849,7 @@ public void AppFocusGained ()
public ILogLineMemory GetCurrentLine ()
{
return dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index != -1
- ? _logFileReader.GetLogLineMemory(dataGridView.CurrentRow.Index)
+ ? _logFileReader.GetLogLineMemory(CurrentLineNum)
: null;
}
@@ -7610,7 +7871,7 @@ public int GetRealLineNum ()
public ILogFileInfo GetCurrentFileInfo ()
{
return dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index != -1
- ? _logFileReader.GetLogFileInfoForLine(dataGridView.CurrentRow.Index)
+ ? _logFileReader.GetLogFileInfoForLine(CurrentLineNum)
: null;
}
@@ -7759,6 +8020,7 @@ public void SetCurrentHighlightGroup (string groupName)
}
InvalidateMarkerCriteria(MarkerScanSource.Highlights);
+ RebuildLineVisibility();
SendGuiStateUpdate();
@@ -7845,4 +8107,7 @@ public void RefreshLogView ()
}
#endregion
+
+ /// Line navigation waiting for the load's first visibility scan.
+ private readonly record struct PendingNavigation (Action Navigate, bool IsTimeSyncFollow);
}
diff --git a/src/LogExpert.UI/Controls/LogWindow/VisibleRows.cs b/src/LogExpert.UI/Controls/LogWindow/VisibleRows.cs
new file mode 100644
index 00000000..ceb26ca6
--- /dev/null
+++ b/src/LogExpert.UI/Controls/LogWindow/VisibleRows.cs
@@ -0,0 +1,177 @@
+using LogExpert.Core.Classes.Highlight;
+
+namespace LogExpert.UI.Controls.LogWindow;
+
+/// The selected and first displayed original lines of the main grid; -1 when there is none.
+internal readonly record struct GridPosition (int CurrentLine, int FirstDisplayedLine);
+
+/// What needs from the Log Window that owns the grid.
+internal interface IVisibleRowsHost
+{
+ bool IsFollowTail { get; }
+
+ bool HasRowHeights { get; }
+
+ void MarkPrefetchStale ();
+}
+
+///
+/// The rows of a Log Window's main grid after hiding: publishes a 's map to the grid
+/// and keeps the selection and scroll position by original line. UI thread only, except .
+///
+internal sealed class VisibleRows (DataGridView grid, LineVisibilityTracker tracker, IVisibleRowsHost host)
+{
+ // Other threads read it once into a local.
+ private volatile LineVisibilityMap _map = LineVisibilityMap.Empty;
+ private LineVisibilityMap _appliedTrackedMap = LineVisibilityMap.Empty;
+ private GridPosition? _pendingPosition;
+
+ /// The map the grid displays.
+ public LineVisibilityMap Map => _map;
+
+ /// The per-window "Show hidden lines" override; takes effect on the next .
+ public bool ShowHiddenLines { get; set; }
+
+ public bool IsUpToDate => ReferenceEquals(_appliedTrackedMap, tracker.Map);
+
+ /// Original line of the current row, or -1.
+ public int CurrentLine => grid.CurrentRow == null ? -1 : _map.RowToLine(grid.CurrentRow.Index);
+
+ /// The content is gone: no rows until the next load publishes.
+ public void Reset ()
+ {
+ _pendingPosition = null;
+ _map = LineVisibilityMap.Empty;
+ _appliedTrackedMap = tracker.Map;
+ }
+
+ ///
+ /// Switches the grid to the tracker's current map, with selected and first displayed lines moved up by
+ /// ; returns whether the displayed map changed.
+ ///
+ public bool Publish (int rolloverOffset)
+ {
+ var tracked = tracker.Map;
+ var newMap = EffectiveMap(tracked);
+ _appliedTrackedMap = tracked;
+
+ var changed = !ReferenceEquals(newMap, _map);
+ if (changed)
+ {
+ SetMap(newMap, rolloverOffset);
+ }
+
+ if (_pendingPosition is { } position && !tracker.IsLoadPending)
+ {
+ _pendingPosition = null;
+ SelectAndScrollTo(position);
+ }
+
+ return changed;
+ }
+
+ /// Restores a saved or reload position, deferred until the load's first scan has published the rows.
+ public void RestorePosition (GridPosition position)
+ {
+ if (tracker.IsLoadPending && !ShowHiddenLines)
+ {
+ _pendingPosition = position;
+ return;
+ }
+
+ SelectAndScrollTo(position);
+ }
+
+ ///
+ /// No rows while the load's first scan runs, the tracked map itself, or, with the override on, an identity map
+ /// that keeps growing in place so tail appends stay appends.
+ ///
+ private LineVisibilityMap EffectiveMap (LineVisibilityMap tracked)
+ {
+ var current = _map;
+ if (!ShowHiddenLines)
+ {
+ if (!tracker.IsLoadPending)
+ {
+ return tracked;
+ }
+
+ return current.LineCount == 0 ? current : LineVisibilityMap.Empty;
+ }
+
+ return current.HiddenCount == 0 && current.LineCount <= tracked.LineCount && tracked.IsAppendOf(_appliedTrackedMap)
+ ? current.Append(tracked.LineCount, [])
+ : LineVisibilityMap.Identity(tracked.LineCount);
+ }
+
+ /// An append only grows the row count; any other change rebuilds the rows and keeps the position.
+ private void SetMap (LineVisibilityMap newMap, int rolloverOffset)
+ {
+ if (newMap.IsAppendOf(_map) && rolloverOffset == 0)
+ {
+ _map = newMap;
+ grid.RowCount = newMap.VisibleCount;
+ return;
+ }
+
+ var position = new GridPosition(CurrentLine, _map.RowToLine(grid.FirstDisplayedScrollingRowIndex));
+
+ grid.RowCount = 0;
+ host.MarkPrefetchStale();
+ _map = newMap;
+ grid.RowCount = newMap.VisibleCount;
+ if (grid.RowCount == 0)
+ {
+ return;
+ }
+
+ if (host.HasRowHeights)
+ {
+ grid.UpdateRowHeightInfo(0, true);
+ }
+
+ var shifted = Shifted(position, rolloverOffset);
+ if (host.IsFollowTail)
+ {
+ ScrollTo(shifted);
+ }
+ else
+ {
+ SelectAndScrollTo(shifted);
+ }
+ }
+
+ /// Selects and scrolls to a position; a hidden line resolves to the nearest visible row.
+ private void SelectAndScrollTo (GridPosition position)
+ {
+ var row = NearestRow(position.CurrentLine);
+ if (row >= 0)
+ {
+ grid.CurrentCell = grid.Rows[row].Cells[0];
+ grid.Rows[row].Selected = true;
+ }
+
+ ScrollTo(position);
+ }
+
+ private void ScrollTo (GridPosition position)
+ {
+ var row = NearestRow(position.FirstDisplayedLine);
+ if (row >= 0)
+ {
+ grid.FirstDisplayedScrollingRowIndex = row;
+ }
+ }
+
+ private int NearestRow (int line)
+ {
+ return line >= 0 ? _map.NearestRow(line) : -1;
+ }
+
+ private static GridPosition Shifted (GridPosition position, int offset)
+ {
+ return new GridPosition(
+ position.CurrentLine < 0 ? -1 : Math.Max(0, position.CurrentLine - offset),
+ position.FirstDisplayedLine < 0 ? -1 : Math.Max(0, position.FirstDisplayedLine - offset));
+ }
+}
diff --git a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs
index f660de5b..0454d00e 100644
--- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs
+++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs
@@ -54,6 +54,7 @@ private void InitializeComponent ()
_btnBookmarkComment = new Button();
_checkBoxStopTail = new CheckBox();
_checkBoxDontDirtyLed = new CheckBox();
+ _checkBoxHideLine = new CheckBox();
_checkBoxPlugin = new CheckBox();
_btnSelectPlugin = new Button();
_checkBoxAlertOnHit = new CheckBox();
@@ -280,6 +281,7 @@ private void InitializeComponent ()
_tabActions.Controls.Add(_labelCooldown);
_tabActions.Controls.Add(_numericCooldownSeconds);
_tabActions.Controls.Add(_labelCooldownSeconds);
+ _tabActions.Controls.Add(_checkBoxHideLine);
_tabActions.Location = new Point(4, 24);
_tabActions.Name = "_tabActions";
_tabActions.Padding = new Padding(8);
@@ -329,6 +331,16 @@ private void InitializeComponent ()
_checkBoxDontDirtyLed.Text = "Dont Dirty Led";
_checkBoxDontDirtyLed.UseVisualStyleBackColor = true;
//
+ // _checkBoxHideLine
+ //
+ _checkBoxHideLine.AutoSize = true;
+ _checkBoxHideLine.Location = new Point(12, 250);
+ _checkBoxHideLine.Name = "_checkBoxHideLine";
+ _checkBoxHideLine.Size = new Size(74, 19);
+ _checkBoxHideLine.TabIndex = 13;
+ _checkBoxHideLine.Text = "Hide line";
+ _checkBoxHideLine.UseVisualStyleBackColor = true;
+ //
// _checkBoxPlugin
//
_checkBoxPlugin.AutoSize = true;
@@ -485,6 +497,7 @@ private void InitializeComponent ()
private System.Windows.Forms.Button _btnBookmarkComment;
private System.Windows.Forms.CheckBox _checkBoxStopTail;
private System.Windows.Forms.CheckBox _checkBoxDontDirtyLed;
+ private System.Windows.Forms.CheckBox _checkBoxHideLine;
private System.Windows.Forms.CheckBox _checkBoxPlugin;
private System.Windows.Forms.Button _btnSelectPlugin;
private System.Windows.Forms.CheckBox _checkBoxAlertOnHit;
diff --git a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
index ad5ecd30..aa667d2c 100644
--- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
+++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
@@ -69,6 +69,7 @@ private void ApplyResources ()
_btnBookmarkComment.Text = Resources.HighlightDialog_UI_Button_BookmarkComment;
_checkBoxStopTail.Text = Resources.HighlightDialog_UI_CheckBox_StopTail;
_checkBoxDontDirtyLed.Text = Resources.HighlightDialog_UI_CheckBox_DontDirtyLed;
+ _checkBoxHideLine.Text = Resources.HighlightDialog_UI_CheckBox_HideLine;
_checkBoxPlugin.Text = Resources.HighlightDialog_UI_CheckBox_Plugin;
_btnSelectPlugin.Text = Resources.HighlightDialog_UI_Button_SelectPlugin;
_checkBoxAlertOnHit.Text = Resources.HighlightDialog_UI_CheckBox_AlertOnHit;
@@ -97,6 +98,7 @@ private void LoadFromEntry ()
_checkBoxBookmark.Checked = _entry.IsSetBookmark;
_checkBoxStopTail.Checked = _entry.IsStopTail;
_checkBoxDontDirtyLed.Checked = _entry.IsLedSwitch;
+ _checkBoxHideLine.Checked = _entry.IsHideLine;
_checkBoxPlugin.Checked = _entry.IsActionEntry;
_checkBoxAlertOnHit.Checked = _entry.AlertOnHit;
@@ -145,6 +147,7 @@ private void SaveToEntry ()
_entry.BookmarkComment = _bookmarkComment;
_entry.IsStopTail = _checkBoxStopTail.Checked;
_entry.IsLedSwitch = _checkBoxDontDirtyLed.Checked;
+ _entry.IsHideLine = _checkBoxHideLine.Checked;
_entry.IsActionEntry = _checkBoxPlugin.Checked;
_entry.ActionEntry = (ActionEntry)_currentActionEntry.Clone();
diff --git a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs
index 3c11641e..2b4be6f8 100644
--- a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs
+++ b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs
@@ -31,7 +31,7 @@ internal interface ILogPaintContextUI : ILogLineSource
ILogLineMemory GetLogLineMemory (int lineNum);
- IColumnMemory GetCellValue (int rowIndex, int columnIndex);
+ IColumnMemory GetCellValue (int lineNum, int columnIndex);
Bookmark GetBookmarkForLine (int lineNum);