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);