diff --git a/.github/workflows/build_upload.yml b/.github/workflows/build_upload.yml index 28af591..420b48e 100644 --- a/.github/workflows/build_upload.yml +++ b/.github/workflows/build_upload.yml @@ -12,9 +12,9 @@ jobs: steps: # Install the .NET Core workload - name: Install .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: 10.0.x include-prerelease: true # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5a8c104..d11bd12 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,9 +25,9 @@ jobs: # Install the .NET Core workload - name: Install .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: 10.0.x include-prerelease: true # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild diff --git a/.opencode/command/verify.md b/.opencode/command/verify.md new file mode 100644 index 0000000..fa6ce60 --- /dev/null +++ b/.opencode/command/verify.md @@ -0,0 +1,13 @@ +--- +description: Rebuild the whole solution and run all tests +agent: build +--- + +Rebuild `LogGrokCore.sln` and run the full test suite. + +1. Run `dotnet build LogGrokCore.sln -t:Rebuild`. +2. Run `dotnet test LogGrokCore.sln`. +3. Report the number of warnings/errors and the test pass/fail counts. +4. If anything fails, show the relevant compiler or test output and propose a fix. + +The build must end with 0 warnings and 0 errors. diff --git a/.opencode/skills/dotnet/SKILL.md b/.opencode/skills/dotnet/SKILL.md new file mode 100644 index 0000000..60ba2b6 --- /dev/null +++ b/.opencode/skills/dotnet/SKILL.md @@ -0,0 +1,48 @@ +--- +name: dotnet +description: Use when building, testing, running, formatting, or verifying the C#/.NET solution (LogGrokCore.sln), or when editing .cs, .xaml, .csproj, appsettings.yaml, or nlog.config. Covers the dotnet CLI workflow and this repo's warnings-as-errors rules. +--- + +# .NET workflow (LogGrokCore) + +Windows PowerShell shell, repository root `C:\ai-projects\LogGrokCore`. + +## Verify a change + +Always finish code changes with a clean rebuild and the full test run: + +```powershell +dotnet build LogGrokCore.sln -t:Rebuild +dotnet test LogGrokCore.sln +``` + +For a faster inner loop, `dotnet build LogGrokCore.sln` and +`dotnet test LogGrokCore.sln` (incremental) are fine; `-t:Rebuild` before the +final check. + +## Running the app + +```powershell +dotnet run --project LogGrokCore\LogGrokCore.csproj +``` + +The app is a WPF `WinExe`; it opens a window and keeps running. It writes +diagnostic logs to `%LOCALAPPDATA%\LogGrok2\`. If it exits immediately with +code 0, another instance is already running (single-instance manager). + +## Constraints + +- `Nullable=enable` and `WarningsAsErrors` for `NU1605` plus the nullable + `CS86xx` family: a valid change has **0 warnings**. +- Prefer `dotnet build`/`dotnet test` over launching MSBuild or VS. +- Do not use `cd`; pass `workdir` to the shell tool or `--project`. +- Do not add comments to code. + +## When touching configuration + +- `appsettings.yaml` is hot-reloaded at runtime and deserialized into settings + classes; keep YAML keys matching the C# property names. +- `nlog.config` must stay NLog 6 compatible: no `concurrentWrites`, and + `${threadid}` takes no properties. +- AvalonDock layout XML uses the `Dirkster.AvalonDock.Serializer.Xml` package + and the `AvalonDock.Serializer.Xml` namespace. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..75f138a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +## Project + +**LogGrokCore** — a fast WPF log viewer for very large log files. It parses +structured log lines with configurable regex formats, builds in-memory indexes, +and supports search, filtering, colorization and marking while streaming files +that may be many gigabytes. + +- Platform: **Windows only** (WPF). +- Target framework: `net10.0-windows` / `net10.0`. +- SDK pinned by `global.json` (10.0.100, `rollForward: latestMajor`). + +## Commands + +Run from the repository root unless noted. The OS shell is Windows PowerShell. + +```powershell +dotnet restore +dotnet build LogGrokCore.sln +dotnet test LogGrokCore.sln +dotnet run --project LogGrokCore\LogGrokCore.csproj +dotnet format LogGrokCore.sln +``` + +- The build treats several warnings as errors (`NU1605` and the nullable + `CS86xx` family). A clean build must have **0 warnings**. +- Build output is redirected to `bin\Debug\` / `bin\Release\` (not the default + per-project `bin`). +- After changing code, always verify with `dotnet build` and `dotnet test`. +- Do not commit unless the user explicitly asks. + +## Layout + +| Project | Purpose | +| --- | --- | +| `LogGrokCore` | WPF app: views, view models, controls, theming, DI bootstrap. | +| `LogGrokCore.Data` | UI-agnostic core: stream loading, line parsing, indexes, search, virtualization. | +| `LogGrokCore.Tests` | Tests for the UI layer. | +| `LogGrokCore.Data.Tests` | Tests for the core data layer. | + +Key areas in `LogGrokCore.Data`: `Loader`/`LoaderImpl` (buffered line-aware +reader), `RegexBasedLineParser`, `IndexTree`/`LineIndex`/`SearchLineIndex`, +`Search`/`Pipeline`, `Virtualization`. + +The UI layer uses **WPF-UI 4.3.0** (Fluent controls/theming) and +**AvalonDock 5** for docking. Branding/window title is **LogGrok 2.1**. +JSON folding lives in `Controls/TextRender` (`TextView`, +`TextViewSharedFoldingState`, `CollapsibleRegionsMachine`, `FoldingManager`). + +## Conventions + +- `LangVersion=latest`, `Nullable=enable`. Prefer file-scoped namespaces. +- **Do not add comments** unless they are necessary to explain non-obvious code. +- Tests use **MSTest** (`[TestClass]`, `[TestMethod]`, `[DataRow]`), not xUnit or + NUnit. Assertion arguments are `(expected, actual)`. +- Keep changes minimal and mirror the style of neighbouring files. + +## Gotchas + +- **AvalonDock 5**: the XML layout serializer lives in the separate package + `Dirkster.AvalonDock.Serializer.Xml`; its namespace is + `AvalonDock.Serializer.Xml` (not `AvalonDock.Layout.Serialization`). +- **NLog 6**: `nlog.config` must stay compatible with NLog 6 — `concurrentWrites` + was removed, and `${threadid}` accepts no properties. +- **WPF-UI theming**: switch themes through `ApplicationThemeManager` / + `Theming/UiThemeService.cs`. Reference `DynamicResource` brushes + (`ApplicationBackgroundBrush`, `TextFillColorPrimaryBrush`, + `ControlStrokeColorDefaultBrush`, …) instead of hard-coded colors so both + themes work. WPF-UI ships *keyed* styles (e.g. `UiGridViewColumnHeaderStyle`), + so implicit styles are not always picked up — define overrides explicitly in + `Bootstrap/App.xaml`. +- **AvalonDock themes**: the main `DockingManager` uses `Vs2013LightTheme` / + `Vs2013DarkTheme`, but the search pane's inner `DockingManager` merges the + light `AvalonDock.Themes.Metro` theme. Metro keys (e.g. + `AvalonDock_ThemeMetro_BaseColor5`) therefore sometimes need theme-aware + overrides in `Styles/DocumentSearchTemplate.xaml`. +- **JSON folding state** is shared per opened document: `DocumentContainer` + registers one `TextViewSharedFoldingState` (exposed as `FoldingState` on + `LogViewModel` / `SearchDocumentViewModel` / `DocumentViewModel`) and the + templates bind it via `textRender:TextView.SharedFoldingState`. The marked-lines + view must use `Document.FoldingState` and the same `TextModel.UniqueId` as the + grid's JSON component, otherwise expansion falls out of sync. +- The app writes diagnostic logs to `%LOCALAPPDATA%\LogGrok2\`. +- Runtime configuration is `appsettings.yaml` (watched and hot-reloaded), + next to the executable. + +## Verification + +Minimum bar for any change: + +```powershell +dotnet build LogGrokCore.sln -t:Rebuild +dotnet test LogGrokCore.sln +``` + +Both must succeed with 0 warnings and 0 errors. diff --git a/LogGrokCore.Data.Tests/LineRangeIndexedLinesProviderTests.cs b/LogGrokCore.Data.Tests/LineRangeIndexedLinesProviderTests.cs new file mode 100644 index 0000000..62f5121 --- /dev/null +++ b/LogGrokCore.Data.Tests/LineRangeIndexedLinesProviderTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using LogGrokCore.Data.Index; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Data.Tests; + +[TestClass] +public class LineRangeIndexedLinesProviderTests +{ + private static readonly int[] Lines = { 2, 5, 7, 10, 11, 20 }; + + [TestMethod] + public void RestrictsFetchToRequestedLineRange() + { + var provider = new LineRangeIndexedLinesProvider(new FakeIndexedLinesProvider(Lines), 5, 12); + + Assert.AreEqual(4, provider.Count); + CollectionAssert.AreEqual(new[] { 5, 7, 10, 11 }, FetchAll(provider, provider.Count)); + } + + [TestMethod] + public void GetIndexByValueReturnsPositionWithinRange() + { + var provider = new LineRangeIndexedLinesProvider(new FakeIndexedLinesProvider(Lines), 5, 12); + + Assert.AreEqual(0, provider.GetIndexByValue(5)); + Assert.AreEqual(2, provider.GetIndexByValue(10)); + Assert.AreEqual(4, provider.GetIndexByValue(100)); + Assert.AreEqual(0, provider.GetIndexByValue(1)); + } + + [TestMethod] + public void EmptyRangeProducesEmptyProvider() + { + var provider = new LineRangeIndexedLinesProvider(new FakeIndexedLinesProvider(Lines), 30, 40); + + Assert.AreEqual(0, provider.Count); + } + + private static int[] FetchAll(IIndexedLinesProvider provider, int count) + { + var result = new int[count]; + provider.Fetch(0, result); + return result; + } + + private sealed class FakeIndexedLinesProvider : IIndexedLinesProvider + { + private readonly IReadOnlyList _lines; + + public FakeIndexedLinesProvider(IReadOnlyList lines) => _lines = lines; + + public int Count => _lines.Count; + + public void Fetch(int start, Span values) + { + for (var i = 0; i < values.Length; i++) + values[i] = _lines[start + i]; + } + + public int GetIndexByValue(int value) + { + for (var i = 0; i < _lines.Count; i++) + { + if (_lines[i] >= value) + return i; + } + + return _lines.Count - 1; + } + } +} diff --git a/LogGrokCore.Data.Tests/LogGrokCore.Data.Tests.csproj b/LogGrokCore.Data.Tests/LogGrokCore.Data.Tests.csproj index dae742c..6fb1e3d 100644 --- a/LogGrokCore.Data.Tests/LogGrokCore.Data.Tests.csproj +++ b/LogGrokCore.Data.Tests/LogGrokCore.Data.Tests.csproj @@ -5,13 +5,14 @@ enable NU1605, CS8604, CS8625, CS8602, CS8618, CS8601, CS8603, CS8614, CS8600, CS8610 false - net7.0-windows + net10.0-windows - - - + + + + diff --git a/LogGrokCore.Data.Tests/StringTokenizerTests.cs b/LogGrokCore.Data.Tests/StringTokenizerTests.cs index 0d3f4dc..0ca6e25 100644 --- a/LogGrokCore.Data.Tests/StringTokenizerTests.cs +++ b/LogGrokCore.Data.Tests/StringTokenizerTests.cs @@ -12,8 +12,8 @@ public void SimpleTest() var source = "a\r\nb"; var tokens = source.Tokenize().ToList(); Assert.AreEqual(2, tokens.Count); - Assert.AreEqual(tokens[0].ToString(), "a"); - Assert.AreEqual(tokens[1].ToString(), "b"); + Assert.AreEqual("a", tokens[0].ToString()); + Assert.AreEqual("b", tokens[1].ToString()); } [TestMethod] @@ -22,7 +22,7 @@ public void TailingCrLf() var source = "a\r\nb\r\n"; var tokens = source.Tokenize().ToList(); Assert.AreEqual(2, tokens.Count); - Assert.AreEqual(tokens[0].ToString(), "a"); - Assert.AreEqual(tokens[1].ToString(), "b"); + Assert.AreEqual("a", tokens[0].ToString()); + Assert.AreEqual("b", tokens[1].ToString()); } } \ No newline at end of file diff --git a/LogGrokCore.Data.Tests/TimeIndexTests.cs b/LogGrokCore.Data.Tests/TimeIndexTests.cs new file mode 100644 index 0000000..5a8db3c --- /dev/null +++ b/LogGrokCore.Data.Tests/TimeIndexTests.cs @@ -0,0 +1,143 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Data.Tests; + +[TestClass] +public class TimeIndexTests +{ + private static long At(int seconds) => new DateTime(2024, 1, 1).AddSeconds(seconds).Ticks; + + [TestMethod] + public void TracksBoundsForSequence() + { + var index = new TimeIndex(); + index.Add(At(0)); + index.Add(At(10)); + index.Add(At(5)); + + Assert.IsTrue(index.HasTime); + Assert.AreEqual(3, index.Count); + Assert.AreEqual(At(0), index.MinTicks); + Assert.AreEqual(At(10), index.MaxTicks); + } + + [TestMethod] + public void DetectsNonMonotonicSequence() + { + var index = new TimeIndex(); + index.Add(At(10)); + index.Add(At(5)); + + Assert.IsFalse(index.IsMonotonic); + } + + [TestMethod] + public void NormalizesDayRolloverForTimeOfDayLogs() + { + var index = new TimeIndex(); + index.Add(TimeSpan.FromHours(23).Add(TimeSpan.FromMinutes(59)).Ticks); + index.Add(TimeSpan.FromMinutes(1).Ticks); + + Assert.IsTrue(index.HasTime); + Assert.IsTrue(index.IsMonotonic); + Assert.IsTrue(index.MaxTicks > index.MinTicks); + Assert.IsNotNull(index.FindLineRange(index.MinTicks, index.MaxTicks)); + } + + [TestMethod] + public void ClampsSmallOutOfOrderTimeOfDayTicks() + { + var index = new TimeIndex(); + index.Add(TimeSpan.FromSeconds(10).Ticks); + index.Add(TimeSpan.FromSeconds(9).Ticks); + + Assert.IsTrue(index.IsMonotonic); + Assert.AreEqual(TimeSpan.FromSeconds(10).Ticks, index.GetTicksAt(1)); + } + + [TestMethod] + public void TracksDayBoundaryForTimeOfDayRollover() + { + var index = new TimeIndex(); + index.Add(TimeSpan.FromHours(23).Add(TimeSpan.FromMinutes(59)).Ticks); + index.Add(TimeSpan.FromMinutes(1).Ticks); + index.Add(TimeSpan.FromMinutes(2).Ticks); + + Assert.AreEqual(1, index.DayBoundaries.Count); + Assert.AreEqual(1, index.DayBoundaries[0]); + } + + [TestMethod] + public void TracksDayBoundaryForAbsoluteTicks() + { + var index = new TimeIndex(); + index.Add(At(0)); + index.Add(At(86400)); + + Assert.AreEqual(1, index.DayBoundaries.Count); + Assert.AreEqual(1, index.DayBoundaries[0]); + } + + [TestMethod] + public void HasNoDayBoundaryWithinSameDay() + { + var index = new TimeIndex(); + index.Add(TimeSpan.FromHours(8).Ticks); + index.Add(TimeSpan.FromHours(9).Ticks); + + Assert.AreEqual(0, index.DayBoundaries.Count); + } + + [TestMethod] + public void MissingTimestampReusesPreviousValue() + { + var index = new TimeIndex(); + index.Add(At(10)); + index.Add(-1); + index.Add(At(20)); + + Assert.AreEqual(At(10), index.GetTicksAt(1)); + Assert.IsTrue(index.IsMonotonic); + } + + [TestMethod] + public void FindLineRangeReturnsNullWhenUnavailable() + { + var empty = new TimeIndex(); + Assert.IsNull(empty.FindLineRange(0, long.MaxValue)); + + var nonMonotonic = new TimeIndex(); + nonMonotonic.Add(At(10)); + nonMonotonic.Add(At(5)); + Assert.IsNull(nonMonotonic.FindLineRange(At(0), At(20))); + } + + [TestMethod] + public void FindLineRangeReturnsInclusiveBounds() + { + var index = new TimeIndex(); + for (var i = 0; i < 5; i++) + index.Add(At(i * 10)); + + var range = index.FindLineRange(At(20), At(40)); + + Assert.IsNotNull(range); + Assert.AreEqual(2, range.Value.StartLine); + Assert.AreEqual(5, range.Value.EndLine); + } + + [TestMethod] + public void FindLineRangeClampsToAvailableLines() + { + var index = new TimeIndex(); + for (var i = 0; i < 5; i++) + index.Add(At(i * 10)); + + var range = index.FindLineRange(At(-100), At(1000)); + + Assert.IsNotNull(range); + Assert.AreEqual(0, range.Value.StartLine); + Assert.AreEqual(5, range.Value.EndLine); + } +} diff --git a/LogGrokCore.Data.Tests/TimestampParserTests.cs b/LogGrokCore.Data.Tests/TimestampParserTests.cs new file mode 100644 index 0000000..9607aa0 --- /dev/null +++ b/LogGrokCore.Data.Tests/TimestampParserTests.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Data.Tests; + +[TestClass] +public class TimestampParserTests +{ + [TestMethod] + public void ParsesTimeOnlyWithExplicitFormat() + { + var expected = new TimeSpan(0, 12, 34, 56, 789).Ticks; + + var parsed = TimestampParser.TryGetTicks("12:34:56.789", "HH:mm:ss.fff", out var ticks); + + Assert.IsTrue(parsed); + Assert.AreEqual(expected, ticks); + } + + [TestMethod] + public void ParsesFullTimestampWithoutExplicitFormat() + { + var expected = new DateTime(2024, 1, 2, 3, 4, 5, 678).Ticks; + + var parsed = TimestampParser.TryGetTicks("2024-01-02 03:04:05.678", null, out var ticks); + + Assert.IsTrue(parsed); + Assert.AreEqual(expected, ticks); + } + + [TestMethod] + public void ReturnsFalseForInvalidInput() + { + var parsed = TimestampParser.TryGetTicks("not a timestamp", "HH:mm:ss.fff", out var ticks); + + Assert.IsFalse(parsed); + Assert.AreEqual(-1L, ticks); + } + + [TestMethod] + public void FormatsTimeOnly() + { + var ticks = new TimeSpan(0, 12, 34, 56, 789).Ticks; + + Assert.AreEqual("12:34:56.789", TimestampParser.Format(ticks)); + } + + [TestMethod] + public void FormatsFullTimestamp() + { + var ticks = new DateTime(2024, 1, 2, 3, 4, 5, 678).Ticks; + + Assert.AreEqual("2024-01-02 03:04:05.678", TimestampParser.Format(ticks)); + } + + [TestMethod] + public void FormatsEmptyForMissingTicks() + { + Assert.AreEqual(string.Empty, TimestampParser.Format(-1)); + } +} diff --git a/LogGrokCore.Data.Tests/TransformationPerformerTests.cs b/LogGrokCore.Data.Tests/TransformationPerformerTests.cs new file mode 100644 index 0000000..d4d2b8b --- /dev/null +++ b/LogGrokCore.Data.Tests/TransformationPerformerTests.cs @@ -0,0 +1,30 @@ +using System; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Data.Tests; + +[TestClass] +public class TransformationPerformerTests +{ + [TestMethod] + public void Base64DecodeTransformation() + { + var performer = new TransformationPerformer(new[] { @"(?[A-Za-z0-9+/=]+)" }); + + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"a\":1}")); + var result = performer.Transform($"###{base64}"); + + Assert.AreEqual("###{\"a\":1}", result); + } + + [TestMethod] + public void DisabledJsonTransformationIsLeftUntouched() + { + var performer = new TransformationPerformer(new[] { @"^before (?\{.*\}) after$" }); + + var result = performer.Transform("before {\"a\":1,\"b\":[1,2]} after"); + + Assert.AreEqual("before {\"a\":1,\"b\":[1,2]} after", result); + } +} diff --git a/LogGrokCore.Data/ILineParser.cs b/LogGrokCore.Data/ILineParser.cs index a2ee6d8..1192e40 100644 --- a/LogGrokCore.Data/ILineParser.cs +++ b/LogGrokCore.Data/ILineParser.cs @@ -4,7 +4,7 @@ namespace LogGrokCore.Data { public interface ILineParser { - bool TryParse(string input, int beginning, int length, in ParsedLineComponents components); + bool TryParse(string input, int beginning, int length, in ParsedLineComponents components, out long timeTicks); ParseResult Parse(string input); } diff --git a/LogGrokCore.Data/Index/Indexer.cs b/LogGrokCore.Data/Index/Indexer.cs index 986d4b8..aaa4371 100644 --- a/LogGrokCore.Data/Index/Indexer.cs +++ b/LogGrokCore.Data/Index/Indexer.cs @@ -118,4 +118,26 @@ public IEnumerable GetAllComponents(int componentNumber) public event Action<(int compnentNumber, IndexKey key)>? NewComponentAdded; public IndexKeyNum GetIndexKeyNum(int index) => _lineAndKeyIndex[index]; -} \ No newline at end of file + + public bool IsLineIncluded(int lineNumber, IReadOnlyDictionary> excludedComponents) + { + if (excludedComponents.Count == 0) + return true; + + if (lineNumber < 0 || lineNumber >= _lineAndKeyIndex.Count) + return true; + + var key = NumbersToKeys[_lineAndKeyIndex[lineNumber]]; + foreach (var (componentIndex, componentValues) in excludedComponents) + { + var keyComponent = key.GetComponent(componentIndex); + foreach (var componentValue in componentValues) + { + if (keyComponent.SequenceEqual(componentValue)) + return false; + } + } + + return true; + } +} diff --git a/LogGrokCore.Data/Index/LineRangeIndexedLinesProvider.cs b/LogGrokCore.Data/Index/LineRangeIndexedLinesProvider.cs new file mode 100644 index 0000000..621743f --- /dev/null +++ b/LogGrokCore.Data/Index/LineRangeIndexedLinesProvider.cs @@ -0,0 +1,49 @@ +using System; + +namespace LogGrokCore.Data.Index +{ + public sealed class LineRangeIndexedLinesProvider : IIndexedLinesProvider + { + private readonly IIndexedLinesProvider _source; + private readonly int _startPosition; + private readonly int _endPosition; + + public LineRangeIndexedLinesProvider(IIndexedLinesProvider source, int startLine, int endLine) + { + _source = source; + _startPosition = FindLowerBound(startLine); + _endPosition = Math.Max(_startPosition, FindLowerBound(endLine)); + } + + public int Count => _endPosition - _startPosition; + + public void Fetch(int start, Span values) + { + _source.Fetch(_startPosition + start, values); + } + + public int GetIndexByValue(int value) + { + var position = FindLowerBound(value); + return Math.Clamp(position - _startPosition, 0, Count); + } + + private int FindLowerBound(int line) + { + var low = 0; + var high = _source.Count; + Span buffer = stackalloc int[1]; + while (low < high) + { + var mid = low + (high - low) / 2; + _source.Fetch(mid, buffer); + if (buffer[0] < line) + low = mid + 1; + else + high = mid; + } + + return low; + } + } +} diff --git a/LogGrokCore.Data/LineProcessor.cs b/LogGrokCore.Data/LineProcessor.cs index 2d4453e..96bcf7a 100644 --- a/LogGrokCore.Data/LineProcessor.cs +++ b/LogGrokCore.Data/LineProcessor.cs @@ -18,17 +18,20 @@ public class LineProcessor : ILineDataConsumer private readonly ILineParser _parser; private readonly int _componentCount; private readonly ParsedBufferConsumer _parsedBufferConsumer; + private readonly TimeIndex _timeIndex; public LineProcessor(LogFile logFile, LogMetaInformation metaInformation, ILineParser parser, ParsedBufferConsumer parsedBufferConsumer, - StringPool stringPool) + StringPool stringPool, + TimeIndex timeIndex) { _encoding = logFile.Encoding; _componentCount = metaInformation.IndexedFieldNumbers.Length; _parsedBufferConsumer = parsedBufferConsumer; _stringPool = stringPool; + _timeIndex = timeIndex; _parser = parser; } @@ -71,11 +74,12 @@ public unsafe void AddLineData(long lineOffset, Span lineData) LineMetaInformation.Get(stringPointer, _componentCount); if (_parser.TryParse(_currentString, stringFrom, stringLength, - lineMetaInformation.ParsedLineComponents)) + lineMetaInformation.ParsedLineComponents, out var timeTicks)) { lineMetaInformation.LineOffsetFromBufferStart = (int)(lineOffset - _bufferOffset); _currentOffset += lineMetaInformation.TotalSizeWithPayloadCharsAligned; _currentBufferLineCount++; + _timeIndex.Add(timeTicks); return; } diff --git a/LogGrokCore.Data/LineProvider.cs b/LogGrokCore.Data/LineProvider.cs index f4c31ef..4910db1 100644 --- a/LogGrokCore.Data/LineProvider.cs +++ b/LogGrokCore.Data/LineProvider.cs @@ -35,7 +35,7 @@ public void Fetch(int start, Span<(int, string)> values) using var stream = _streamFactory(); stream.Seek(startOffset, SeekOrigin.Begin); - stream.Read(span); + stream.ReadExactly(span); using var poolOwner = MemoryPool<(long, int)>.Shared.Rent(count); var lineIndices = poolOwner.Memory.Span.Slice(0, count); diff --git a/LogGrokCore.Data/LogFormat.cs b/LogGrokCore.Data/LogFormat.cs index 53eaaa3..abdb70f 100644 --- a/LogGrokCore.Data/LogFormat.cs +++ b/LogGrokCore.Data/LogFormat.cs @@ -13,6 +13,10 @@ public class LogFormat public string[] IndexedFields { get; set; } = Array.Empty(); + public string TimeField { get; set; } = string.Empty; + + public string TimeFormat { get; set; } = string.Empty; + public string[] Transformations { get; set; } = Array.Empty(); public byte XorMask { get; set; } = 0; diff --git a/LogGrokCore.Data/LogGrokCore.Data.csproj b/LogGrokCore.Data/LogGrokCore.Data.csproj index 8908b51..09e6ed4 100644 --- a/LogGrokCore.Data/LogGrokCore.Data.csproj +++ b/LogGrokCore.Data/LogGrokCore.Data.csproj @@ -1,7 +1,7 @@  - net7.0 + net10.0 latest enable NU1605, CS8604, CS8625, CS8602, CS8618, CS8601, CS8603, CS8614, CS8600, CS8610 @@ -18,10 +18,11 @@ - + - - + + + diff --git a/LogGrokCore.Data/LogMetaInformation.cs b/LogGrokCore.Data/LogMetaInformation.cs index bd8c08d..75bddcb 100644 --- a/LogGrokCore.Data/LogMetaInformation.cs +++ b/LogGrokCore.Data/LogMetaInformation.cs @@ -31,9 +31,27 @@ public static LogMetaInformation CreateTextFileMetaInformation() public byte XorMask => _logFormat.XorMask; + public string TimeFormat => _logFormat.TimeFormat; + + public int TimeFieldIndex { get; } + + public bool HasTime => TimeFieldIndex >= 0; + + public int TimeGroupNumber => TimeFieldIndex + 1; + public LogMetaInformation(LogFormat logFormat) { _logFormat = logFormat; + TimeFieldIndex = ResolveTimeFieldIndex(); + } + + private int ResolveTimeFieldIndex() + { + if (!string.IsNullOrEmpty(_logFormat.TimeField)) + return GetFieldIndexByName(_logFormat.TimeField); + + return Array.FindIndex(FieldNames, + name => string.Equals(name, "Time", StringComparison.OrdinalIgnoreCase)); } private int GetFieldIndexByName(string fieldName) => Array.IndexOf(FieldNames, fieldName); @@ -44,6 +62,13 @@ public int GetIndexedFieldIndexByName(string fieldName) => public int GetIndexedFieldIndexByFieldIndex(int index) => Array.IndexOf(IndexedFieldNumbers, index); + public string GetFieldNameByIndexedFieldIndex(int indexedFieldIndex) + { + if (indexedFieldIndex < 0 || indexedFieldIndex >= IndexedFieldNumbers.Length) + return string.Empty; + return FieldNames[IndexedFieldNumbers[indexedFieldIndex]]; + } + public bool IsFieldIndexed(string fieldName) { var index = Array.IndexOf(FieldNames, fieldName); diff --git a/LogGrokCore.Data/LogModelFacade.cs b/LogGrokCore.Data/LogModelFacade.cs index e873254..4713351 100644 --- a/LogGrokCore.Data/LogModelFacade.cs +++ b/LogGrokCore.Data/LogModelFacade.cs @@ -47,6 +47,8 @@ public double LoadProgress public LogFile LogFile { get; } + public int LineCount => LineIndex.Count; + public bool IsLoaded => LineIndex.IsFinished; } } \ No newline at end of file diff --git a/LogGrokCore.Data/ParseResult.cs b/LogGrokCore.Data/ParseResult.cs index 0511bfb..6c1139a 100644 --- a/LogGrokCore.Data/ParseResult.cs +++ b/LogGrokCore.Data/ParseResult.cs @@ -14,6 +14,8 @@ internal ParseResult(int componentCount, int[] metaPlaceHolder) _componentCount = componentCount; } + public int ComponentCount => _componentCount; + public LineMetaInformation Get() { return new(_metaPlaceHolder.AsSpan(), _componentCount); diff --git a/LogGrokCore.Data/RegexBasedLineParser.cs b/LogGrokCore.Data/RegexBasedLineParser.cs index 8e0bc8d..814138f 100644 --- a/LogGrokCore.Data/RegexBasedLineParser.cs +++ b/LogGrokCore.Data/RegexBasedLineParser.cs @@ -10,6 +10,8 @@ public class RegexBasedLineParser : ILineParser private readonly Regex _regex; private readonly int _componentCount; private readonly int[] _fieldsToStore; + private readonly int _timeGroupNumber; + private readonly string _timeFormat; public RegexBasedLineParser(LogMetaInformation logMetaInformation, bool onlyIndexed = false) @@ -20,20 +22,25 @@ public RegexBasedLineParser(LogMetaInformation logMetaInformation, _fieldsToStore = onlyIndexed ? logMetaInformation.IndexedFieldNumbers : Enumerable.Range(0, _componentCount).ToArray(); + _timeGroupNumber = logMetaInformation.HasTime ? logMetaInformation.TimeGroupNumber : -1; + _timeFormat = logMetaInformation.TimeFormat; } public ParseResult Parse(string input) { var placeholder = new int[LineMetaInformation.GetSizeInts(_componentCount)]; if (!TryParse(input, 0, input.Length, - new LineMetaInformation(placeholder.AsSpan(), _componentCount).ParsedLineComponents)) + new LineMetaInformation(placeholder.AsSpan(), _componentCount).ParsedLineComponents, + out _)) throw new InvalidOperationException(); return new ParseResult(_componentCount, placeholder); } public bool TryParse(string input, int beginning, int length, - in ParsedLineComponents parsedLineComponents) + in ParsedLineComponents parsedLineComponents, out long timeTicks) { + timeTicks = -1; + var match = _regex.Match(input, beginning, length); if (!match.Success) return false; @@ -68,6 +75,18 @@ public bool TryParse(string input, int beginning, int length, index++; } + if (_timeGroupNumber > 0 && _timeGroupNumber < matchCounts.Length && + matchCounts[_timeGroupNumber] > 0) + { + var timeCapture = caps[_timeGroupNumber]; + if (timeCapture != null && + TimestampParser.TryGetTicks(input.AsSpan(timeCapture[0], timeCapture[1]), + _timeFormat, out var ticks)) + { + timeTicks = ticks; + } + } + return true; } } diff --git a/LogGrokCore.Data/Search/Pipeline.cs b/LogGrokCore.Data/Search/Pipeline.cs index 07a564c..903e3ab 100644 --- a/LogGrokCore.Data/Search/Pipeline.cs +++ b/LogGrokCore.Data/Search/Pipeline.cs @@ -16,6 +16,7 @@ public class Pipeline { private readonly Regex _regex; private readonly LogModelFacade _logModelFacade; + private readonly (int StartLine, int EndLine)? _lineRange; private const int MaxSearchSizeLines = 1024; private readonly int _searchWorkersCount = Math.Max(Environment.ProcessorCount - 1, 1); private readonly StringPool _stringPool = new(); @@ -23,10 +24,12 @@ public class Pipeline private uint? _id; public Pipeline(Regex regex, - LogModelFacade logModelFacade) + LogModelFacade logModelFacade, + (int StartLine, int EndLine)? lineRange = null) { _regex = regex; _logModelFacade = logModelFacade; + _lineRange = lineRange; } private void Trace(string message) @@ -120,10 +123,20 @@ private async Task LoadBuffersWorker(LogModelFacade logModelFacade, Search.Progr await foreach (var (start, count) in sourceLineIndex.FetchRanges(cancellationToken)) { - var current = start; - while (current < start + count && !cancellationToken.IsCancellationRequested) + var segmentStart = start; + var segmentEnd = start + count; + if (_lineRange is { } lineRange) { - var end = Math.Min(current + MaxSearchSizeLines, start + count) - 1; + segmentStart = Math.Max(segmentStart, lineRange.StartLine); + segmentEnd = Math.Min(segmentEnd, lineRange.EndLine); + if (segmentStart >= segmentEnd) + continue; + } + + var current = segmentStart; + while (current < segmentEnd && !cancellationToken.IsCancellationRequested) + { + var end = Math.Min(current + MaxSearchSizeLines, segmentEnd) - 1; var (firstLineOffset, _) = sourceLineIndex.GetLine(current); var (lastLineOffset, lastLineLength) = sourceLineIndex.GetLine(end); diff --git a/LogGrokCore.Data/Search/Search.cs b/LogGrokCore.Data/Search/Search.cs index c5f1721..ccd5b45 100644 --- a/LogGrokCore.Data/Search/Search.cs +++ b/LogGrokCore.Data/Search/Search.cs @@ -52,14 +52,15 @@ private void ReportNewValue(double value) public static (Progress, SubIndexer, SearchLineIndex) CreateSearchIndex( LogModelFacade logModelFacade, Regex regex, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + (int StartLine, int EndLine)? lineRange = null) { var sourceLineIndex = logModelFacade.LineIndex; SearchLineIndex lineIndex = new(sourceLineIndex); // searchResultLineNumber -> originalLogLineNumber mapping var searchIndexer = logModelFacade.Indexer.CreateSubIndexer(); // components -> searchResultLineNumber var progress = new Progress(); - var pipeline = new Pipeline(regex, logModelFacade); + var pipeline = new Pipeline(regex, logModelFacade, lineRange); Task.Run(async () => { try diff --git a/LogGrokCore.Data/TimeIndex.cs b/LogGrokCore.Data/TimeIndex.cs new file mode 100644 index 0000000..af5c8ed --- /dev/null +++ b/LogGrokCore.Data/TimeIndex.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; + +namespace LogGrokCore.Data +{ + public sealed class TimeIndex + { + private readonly List _ticks = new(); + private readonly List _dayBoundaries = new(); + private long _lastTicks; + private long _lastRawTicks; + private long _lastDayNumber = long.MinValue; + private long _dayOffset; + private long _minTicks = long.MaxValue; + private long _maxTicks = long.MinValue; + + public bool HasTime { get; private set; } + + public bool IsMonotonic { get; private set; } = true; + + public int Count => _ticks.Count; + + public IReadOnlyList DayBoundaries => _dayBoundaries; + + public long MinTicks => _minTicks; + + public long MaxTicks => _maxTicks; + + public void Add(long ticks) + { + if (ticks < 0) + { + _ticks.Add(_lastTicks); + return; + } + + var normalized = ticks; + if (ticks < TimeSpan.TicksPerDay) + { + if (_ticks.Count > 0 && ticks < _lastRawTicks && + _lastRawTicks - ticks > TimeSpan.TicksPerHour) + { + _dayOffset += TimeSpan.TicksPerDay; + } + + normalized = ticks + _dayOffset; + if (normalized < _lastTicks) + normalized = _lastTicks; + } + + if (_ticks.Count > 0 && normalized < _lastTicks) + IsMonotonic = false; + + _lastRawTicks = ticks; + _lastTicks = normalized; + HasTime = true; + + var dayNumber = normalized / TimeSpan.TicksPerDay; + if (_ticks.Count > 0 && dayNumber != _lastDayNumber) + _dayBoundaries.Add(_ticks.Count); + _lastDayNumber = dayNumber; + + if (normalized < _minTicks) _minTicks = normalized; + if (normalized > _maxTicks) _maxTicks = normalized; + + _ticks.Add(normalized); + } + + public long GetTicksAt(int index) => _ticks[index]; + + public (int StartLine, int EndLine)? FindLineRange(long fromTicks, long toTicks) + { + if (!HasTime || !IsMonotonic || _ticks.Count == 0) + return null; + + var start = LowerBound(fromTicks); + var end = UpperBound(toTicks); + if (end < start) end = start; + + return (start, end); + } + + private int LowerBound(long ticks) + { + var low = 0; + var high = _ticks.Count; + while (low < high) + { + var mid = low + (high - low) / 2; + if (_ticks[mid] < ticks) + low = mid + 1; + else + high = mid; + } + + return low; + } + + private int UpperBound(long ticks) + { + var low = 0; + var high = _ticks.Count; + while (low < high) + { + var mid = low + (high - low) / 2; + if (_ticks[mid] <= ticks) + low = mid + 1; + else + high = mid; + } + + return low; + } + } +} diff --git a/LogGrokCore.Data/TimestampParser.cs b/LogGrokCore.Data/TimestampParser.cs new file mode 100644 index 0000000..4b2f9d6 --- /dev/null +++ b/LogGrokCore.Data/TimestampParser.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; + +namespace LogGrokCore.Data +{ + public static class TimestampParser + { + public static bool TryGetTicks(ReadOnlySpan text, string? format, out long ticks) + { + ticks = -1; + if (text.IsEmpty) + return false; + + var hasFormat = !string.IsNullOrWhiteSpace(format); + DateTime timestamp; + var parsed = hasFormat + ? DateTime.TryParseExact(text, format!, CultureInfo.InvariantCulture, DateTimeStyles.None, + out timestamp) + : DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp); + + if (!parsed) + return false; + + ticks = hasFormat && IsTimeOnly(format!) + ? timestamp.TimeOfDay.Ticks + : timestamp.Ticks; + + return true; + } + + public static string Format(long ticks) + { + if (ticks < 0) + return string.Empty; + + var timestamp = new DateTime(ticks); + return timestamp.Year <= 1 + ? timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture) + : timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + private static bool IsTimeOnly(string format) => + format.IndexOf('y') < 0 && format.IndexOf('M') < 0 && format.IndexOf('d') < 0; + } +} diff --git a/LogGrokCore.Tests/ColorSettingsTests.cs b/LogGrokCore.Tests/ColorSettingsTests.cs new file mode 100644 index 0000000..3c003f8 --- /dev/null +++ b/LogGrokCore.Tests/ColorSettingsTests.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; +using System.Windows.Media; +using LogGrokCore.Colors; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MediaColors = System.Windows.Media.Colors; + +namespace LogGrokCore.Tests +{ + [TestClass] + public class ColorSettingsTests + { + private static double Luminance(Color color) => + (0.2126 * color.R + 0.7152 * color.G + 0.0722 * color.B) / 255.0; + + private static Color GetColor(Brush brush) + { + Assert.IsNotNull(brush); + return ((SolidColorBrush)brush).Color; + } + + [TestMethod] + public void LightThemeKeepsConfiguredColors() + { + var rule = new ColorSettings.ColorRule(new Regex("IMP"), MediaColors.DarkRed, MediaColors.White); + + Assert.AreEqual(MediaColors.DarkRed, GetColor(rule.GetForegroundBrush(false))); + Assert.AreEqual(MediaColors.White, GetColor(rule.GetBackgroundBrush(false))); + } + + [TestMethod] + public void DarkThemeLightensDarkForeground() + { + var rule = new ColorSettings.ColorRule(new Regex("IMP"), MediaColors.DarkRed, null); + + var dark = GetColor(rule.GetForegroundBrush(true)); + + Assert.IsTrue(Luminance(dark) > Luminance(MediaColors.DarkRed)); + Assert.IsTrue(Luminance(dark) >= 0.5); + } + + [TestMethod] + public void DarkThemeKeepsReadableForeground() + { + var rule = new ColorSettings.ColorRule(new Regex("DBG"), MediaColors.Gray, null); + + Assert.AreEqual(MediaColors.Gray, GetColor(rule.GetForegroundBrush(true))); + } + + [TestMethod] + public void DarkThemeDarkensLightBackground() + { + var rule = new ColorSettings.ColorRule(new Regex("fatal"), null, MediaColors.White); + + var dark = GetColor(rule.GetBackgroundBrush(true)); + + Assert.IsTrue(Luminance(dark) < Luminance(MediaColors.White)); + Assert.IsTrue(Luminance(dark) <= 0.3); + } + } +} diff --git a/LogGrokCore.Tests/FoldingStateSharingTests.cs b/LogGrokCore.Tests/FoldingStateSharingTests.cs new file mode 100644 index 0000000..5f8a95c --- /dev/null +++ b/LogGrokCore.Tests/FoldingStateSharingTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using LogGrokCore.Controls.TextRender; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Tests +{ + [TestClass] + public class FoldingStateSharingTests + { + [TestMethod] + public void CollapseInOneTextViewPropagatesToSharedStateClients() + { + RunOnSta(() => + { + const string json = "{\"a\":{\"b\":1,\"c\":2}}"; + var state = new TextViewSharedFoldingState(); + var first = new TextView(); + var second = new TextView(); + TextView.SetSharedFoldingState(first, state); + TextView.SetSharedFoldingState(second, state); + first.TextModel = new TextModel(42, json); + second.TextModel = new TextModel(42, json); + + Assert.IsTrue(first.FoldingManager!.CollapseRecursivelyCommand!.CanExecute(null)); + Assert.IsTrue(second.FoldingManager!.CollapseRecursivelyCommand!.CanExecute(null)); + + first.FoldingManager.CollapseRecursivelyCommand!.Execute(null); + + Assert.IsFalse(first.FoldingManager.CollapseRecursivelyCommand.CanExecute(null)); + Assert.IsFalse(second.FoldingManager!.CollapseRecursivelyCommand!.CanExecute(null)); + }); + } + + [TestMethod] + public void ToggleInOneTextViewIsNotPropagatedToDifferentUniqueId() + { + RunOnSta(() => + { + const string json = "{\"a\":{\"b\":1,\"c\":2}}"; + var state = new TextViewSharedFoldingState(); + var first = new TextView(); + var other = new TextView(); + TextView.SetSharedFoldingState(first, state); + TextView.SetSharedFoldingState(other, state); + first.TextModel = new TextModel(1, json); + other.TextModel = new TextModel(2, json); + + first.FoldingManager!.CollapseRecursivelyCommand!.Execute(null); + + Assert.IsFalse(first.FoldingManager.CollapseRecursivelyCommand.CanExecute(null)); + Assert.IsTrue(other.FoldingManager!.CollapseRecursivelyCommand!.CanExecute(null)); + }); + } + + private static void RunOnSta(Action action) + { + Exception error = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception e) + { + error = e; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + if (error != null) + throw error; + } + } +} diff --git a/LogGrokCore.Tests/LogGrokCore.Tests.csproj b/LogGrokCore.Tests/LogGrokCore.Tests.csproj index d752a21..b7691d0 100644 --- a/LogGrokCore.Tests/LogGrokCore.Tests.csproj +++ b/LogGrokCore.Tests/LogGrokCore.Tests.csproj @@ -1,21 +1,23 @@ - net7.0-windows + net10.0-windows false latest + $(NoWarn);NU1701 - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/LogGrokCore.Tests/TextModelTests.cs b/LogGrokCore.Tests/TextModelTests.cs new file mode 100644 index 0000000..2f79e13 --- /dev/null +++ b/LogGrokCore.Tests/TextModelTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LogGrokCore.Tests +{ + [TestClass] + public class TextModelTests + { + private const string Json = "{\"a\":1,\"b\":[1,2]}"; + + [TestMethod] + public void ExpandedJsonIsIndented() + { + var model = new TextModel(1, Json); + + var displayed = model.GetDisplayedText(new HashSet()); + + StringAssert.Contains(displayed, "\"a\": 1"); + StringAssert.Contains(displayed, "\"b\": ["); + } + + [TestMethod] + public void CollapsedOuterJsonIsInlined() + { + var model = new TextModel(1, Json); + Assert.IsNotNull(model.CollapsibleRanges); + + var root = model.CollapsibleRanges!.OrderByDescending(r => r.length).First(); + + var displayed = model.GetDisplayedText(new HashSet { root.start }); + + StringAssert.Contains(displayed, "\"a\": 1"); + StringAssert.Contains(displayed, "\"b\": [1,2]"); + Assert.IsFalse(displayed.Contains('\n'), $"Expected inline json but got: {displayed}"); + } + + [TestMethod] + public void CollapsedInnerJsonIsInlined() + { + var model = new TextModel(1, Json); + Assert.IsNotNull(model.CollapsibleRanges); + + var inner = model.CollapsibleRanges!.OrderBy(r => r.length).First(); + + var displayed = model.GetDisplayedText(new HashSet { inner.start }); + + StringAssert.Contains(displayed, "\"b\": [1,2]"); + StringAssert.Contains(displayed, "\"a\": 1"); + } + + [TestMethod] + public void WholeLineAndMessageComponentHaveSameCollapsibleStarts() + { + const string prefix = "2024-01-01 10:00:00 INF app "; + const string json = "{\"a\":{\"b\":1,\"c\":2}}"; + var whole = new TextModel(1, prefix + json); + var component = new TextModel(2, json); + + CollectionAssert.AreEqual( + whole.CollapsibleRanges!.Select(r => r.start).ToList(), + component.CollapsibleRanges!.Select(r => r.start).ToList()); + } + + [TestMethod] + public void NullFoldingStateKeepsExpandedJson() + { + var model = new TextModel(1, Json); + + var displayed = model.GetDisplayedText(null); + + StringAssert.Contains(displayed, "\"a\": 1"); + } + } +} diff --git a/LogGrokCore.Tests/TextOperationsGetBracedGroupsTests.cs b/LogGrokCore.Tests/TextOperationsGetBracedGroupsTests.cs index bb31941..14bd9e3 100644 --- a/LogGrokCore.Tests/TextOperationsGetBracedGroupsTests.cs +++ b/LogGrokCore.Tests/TextOperationsGetBracedGroupsTests.cs @@ -7,7 +7,7 @@ namespace LogGrokCore.Tests; [TestClass] public class TextOperationsGetBracedGroupsTests { - [DataTestMethod] + [TestMethod] [DataRow("{}", 0, 2)] [DataRow("{{}}", 0, 4)] [DataRow("{}{", 0, 2)] @@ -25,7 +25,7 @@ public void TestGetBracedGroups(string source, int start, int length) Assert.AreEqual(length, actualLength); } - [DataTestMethod] + [TestMethod] [DataRow("{}{}", 0, 2, 2, 2)] [DataRow("{}AAA{}", 0, 2, 5, 2)] public void TestGetBracedGroups_TwoGroups(string source, int start1, int length1, int start2, int length2) @@ -42,7 +42,7 @@ public void TestGetBracedGroups_TwoGroups(string source, int start1, int length1 Assert.AreEqual(length2, actualLength2); } - [DataTestMethod] + [TestMethod] [DataRow("{{}{}")] [DataRow("{AAA{}")] [DataRow("")] diff --git a/LogGrokCore.Tests/TextOperationsTests.cs b/LogGrokCore.Tests/TextOperationsTests.cs index 7860d25..3904e2e 100644 --- a/LogGrokCore.Tests/TextOperationsTests.cs +++ b/LogGrokCore.Tests/TextOperationsTests.cs @@ -65,5 +65,22 @@ public void TestTrimSomeLines2() Assert.AreEqual("Hello\r\nworld\r\n", resultString); Assert.AreEqual(1, linesTrimmed); } + + [TestMethod] + public void FormatInlineJsonAlignsJson() + { + var result = TextOperations.FormatInlineJson("prefix {\"a\":1,\"b\":[1,2]} suffix"); + + StringAssert.Contains(result, "\"a\": 1"); + StringAssert.Contains(result, "\"b\": ["); + } + + [TestMethod] + public void FormatInlineJsonLeavesNonJsonUntouched() + { + const string source = "no json here"; + + Assert.AreEqual(source, TextOperations.FormatInlineJson(source)); } + } } \ No newline at end of file diff --git a/LogGrokCore/ApplicationSettings.cs b/LogGrokCore/ApplicationSettings.cs index a107cbd..3b1d31e 100644 --- a/LogGrokCore/ApplicationSettings.cs +++ b/LogGrokCore/ApplicationSettings.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; using LogGrokCore.Colors.Configuration; using LogGrokCore.Controls.ListControls; using LogGrokCore.Data; @@ -35,6 +38,50 @@ public ColumnSettings GetColumnSettings(string logFormat) return columnSettings; } + public void SetTimelineAtTop(bool isAtTop) + { + if (ViewSettings.TimelineAtTop == isAtTop) + return; + + ViewSettings.TimelineAtTop = isAtTop; + SaveTimelineAtTop(isAtTop); + } + + private static void SaveTimelineAtTop(bool isAtTop) + { + try + { + if (!File.Exists(SettingsFileName)) + return; + + var lines = File.ReadAllLines(SettingsFileName).ToList(); + var value = isAtTop ? "true" : "false"; + var keyRegex = new Regex(@"^(\s*)TimelineAtTop\s*:.*$"); + for (var i = 0; i < lines.Count; i++) + { + var match = keyRegex.Match(lines[i]); + if (!match.Success) continue; + lines[i] = $"{match.Groups[1].Value}TimelineAtTop: {value}"; + File.WriteAllLines(SettingsFileName, lines); + return; + } + + var sectionRegex = new Regex(@"^(\s*)ViewSettings\s*:\s*$"); + for (var i = 0; i < lines.Count; i++) + { + var match = sectionRegex.Match(lines[i]); + if (!match.Success) continue; + lines.Insert(i + 1, $"{match.Groups[1].Value} TimelineAtTop: {value}"); + File.WriteAllLines(SettingsFileName, lines); + return; + } + } + catch (Exception) + { + // ignored + } + } + public static ApplicationSettings Instance() { if (instance == null) diff --git a/LogGrokCore/AvalonDockExtensions/DocumentContextMenu.cs b/LogGrokCore/AvalonDockExtensions/DocumentContextMenu.cs index 1ed02bc..01d967a 100644 --- a/LogGrokCore/AvalonDockExtensions/DocumentContextMenu.cs +++ b/LogGrokCore/AvalonDockExtensions/DocumentContextMenu.cs @@ -54,7 +54,8 @@ void UpdateContextMenu(ItemsControl contextMenu) foreach (var item in newItems.Except(oldItems)) { - contextMenuItems.Add(item); + if (!contextMenuItems.Contains(item)) + contextMenuItems.Add(item); } } @@ -65,16 +66,30 @@ void UpdateContextMenu(ItemsControl contextMenu) } else { + ContextMenu? previousContextMenu = null; var descriptor = DependencyPropertyDescriptor.FromProperty( DockingManager.DocumentContextMenuProperty, typeof(DockingManager)); descriptor.AddValueChanged(dockingManager, (o, e) => { ContextMenu? contextMenu = dockingManager.DocumentContextMenu; - if (contextMenu != null) + if (contextMenu == null) + return; + + if (!ReferenceEquals(previousContextMenu, contextMenu)) { - UpdateContextMenu(contextMenu); + if (previousContextMenu != null) + { + foreach (var item in newItems.Where(previousContextMenu.Items.Contains).ToList()) + { + previousContextMenu.Items.Remove(item); + } + } + + previousContextMenu = contextMenu; } + + UpdateContextMenu(contextMenu); }); } } diff --git a/LogGrokCore/BaseLogLineViewModel.cs b/LogGrokCore/BaseLogLineViewModel.cs index e8042f3..9ee34f7 100644 --- a/LogGrokCore/BaseLogLineViewModel.cs +++ b/LogGrokCore/BaseLogLineViewModel.cs @@ -1,5 +1,6 @@ using System; using LogGrokCore.Controls; +using LogGrokCore.Controls.TextRender; using LogGrokCore.MarkedLines; namespace LogGrokCore @@ -19,6 +20,9 @@ protected BaseLogLineViewModel(int index, Selection markedLines) public int Index { get; } public LinePartViewModel IndexViewModel { get; } + + public virtual string GetDisplayText(TextViewSharedFoldingState? foldingState) => + ToString() ?? string.Empty; public bool IsMarked { diff --git a/LogGrokCore/Bootstrap/App.xaml b/LogGrokCore/Bootstrap/App.xaml index bbd95a0..05617e1 100644 --- a/LogGrokCore/Bootstrap/App.xaml +++ b/LogGrokCore/Bootstrap/App.xaml @@ -1,13 +1,69 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:system="clr-namespace:System;assembly=System.Runtime" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"> - - + + pack://application:,,,/LogGrokCore;component/Fonts/#JetBrains Mono + + 12 + 12 + 14 + 24 + + + diff --git a/LogGrokCore/Bootstrap/App.xaml.cs b/LogGrokCore/Bootstrap/App.xaml.cs index 2811643..5814131 100644 --- a/LogGrokCore/Bootstrap/App.xaml.cs +++ b/LogGrokCore/Bootstrap/App.xaml.cs @@ -10,6 +10,7 @@ using LogGrokCore.Diagnostics; using LogGrokCore.MarkedLines; using LogGrokCore.Search; +using LogGrokCore.Theming; using Splat.DryIoc; namespace LogGrokCore.Bootstrap @@ -60,6 +61,8 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + _container.Resolve().ApplySavedTheme(); + var mainWindow = _container.Resolve(); mainWindow.Show(); @@ -69,6 +72,7 @@ protected override void OnStartup(StartupEventArgs e) protected override void OnExit(ExitEventArgs e) { _container.Resolve().Save(); + _container.Resolve().Save(); _container.Dispose(); } @@ -77,6 +81,9 @@ private void RegisterDependencies(IRegistrator container) container.RegisterDelegate(ApplicationSettings.Instance); container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); container.Register(); container.Register(); } diff --git a/LogGrokCore/Colors/ColorSettings.cs b/LogGrokCore/Colors/ColorSettings.cs index 7319fe2..bf77314 100644 --- a/LogGrokCore/Colors/ColorSettings.cs +++ b/LogGrokCore/Colors/ColorSettings.cs @@ -1,4 +1,5 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -27,46 +28,84 @@ public static void SetColorSettings(DependencyObject element, ColorSettings valu public class ColorRule { - public ColorRule(Regex regex, Brush? foreground, Brush? background) + public ColorRule(Regex regex, Color? foreground, Color? background) { Regex = regex; - Foreground = foreground; - Background = background; + ForegroundColor = foreground; + BackgroundColor = background; } - public Brush? Foreground { get; } - public Brush? Background { get; } + public Color? ForegroundColor { get; } + public Color? BackgroundColor { get; } public Regex Regex { get; } public bool IsMatch(string text) { return Regex.IsMatch(text); } + + public Brush? GetForegroundBrush(bool isDark) => GetBrush(ForegroundColor, isDark, false); + + public Brush? GetBackgroundBrush(bool isDark) => GetBrush(BackgroundColor, isDark, true); } public IReadOnlyList Rules { get; } - private static readonly ConcurrentDictionary CachedBruches = new(); + private static readonly ConcurrentDictionary<(Color, bool, bool), Brush> CachedBrushes = new(); private static readonly ConcurrentDictionary CachedRegexes = new(); - + + private static Brush? GetBrush(Color? color, bool isDark, bool isBackground) + { + if (color == null) return null; + return CachedBrushes.GetOrAdd((color.Value, isDark, isBackground), + key => CreateBrush(key.Item1, key.Item2, key.Item3)); + } + + private static Brush CreateBrush(Color color, bool isDark, bool isBackground) + { + var brush = new SolidColorBrush(isDark ? AdjustForDarkTheme(color, isBackground) : color); + brush.Freeze(); + return brush; + } + + private static Color AdjustForDarkTheme(Color color, bool isBackground) + { + if (color.A == 0) return color; + + var luminance = (0.2126 * color.R + 0.7152 * color.G + 0.0722 * color.B) / 255.0; + + if (isBackground) + { + const double target = 0.25; + if (luminance <= target) return color; + var scale = target / luminance; + return Color.FromArgb(color.A, + (byte)Math.Round(color.R * scale), + (byte)Math.Round(color.G * scale), + (byte)Math.Round(color.B * scale)); + } + + const double targetLuminance = 0.5; + if (luminance >= targetLuminance) return color; + var factor = (targetLuminance - luminance) / (1.0 - luminance); + byte Blend(byte c) => (byte)Math.Round(c + (255 - c) * factor); + return Color.FromArgb(color.A, Blend(color.R), Blend(color.G), Blend(color.B)); + } + public ColorSettings(Configuration.ColorSettings colorSettingsConfiguration) { - Brush? CreateBrush(string colorString) + Color? ParseColor(string colorString) { if (string.IsNullOrEmpty(colorString)) return null; - var color = (Color)ColorConverter.ConvertFromString(colorString); - var brush = new SolidColorBrush(color); - - brush.Freeze(); - return brush; + return (Color)ColorConverter.ConvertFromString(colorString); } ColorRule Convert(Configuration.ColorRule rule) { return new ColorRule( CachedRegexes.GetOrAdd(rule.RegexString, s => new Regex(s, RegexOptions.Compiled)), - CachedBruches.GetOrAdd(rule.ForegroundColor, CreateBrush), - CachedBruches.GetOrAdd(rule.BackgroundColor, CreateBrush)); + ParseColor(rule.ForegroundColor), + ParseColor(rule.BackgroundColor)); } Rules = colorSettingsConfiguration.Rules.Select(Convert).ToList(); diff --git a/LogGrokCore/Controls/FormatTextExtension.cs b/LogGrokCore/Controls/FormatTextExtension.cs index 0395b22..2741b16 100644 --- a/LogGrokCore/Controls/FormatTextExtension.cs +++ b/LogGrokCore/Controls/FormatTextExtension.cs @@ -1,21 +1,24 @@ -using System; +using System; using System.Globalization; -using MahApps.Metro.Converters; +using System.Windows.Data; +using System.Windows.Markup; namespace LogGrokCore.Controls { - public class FormatTextExtension : MarkupConverter + public class FormatTextExtension : MarkupExtension, IValueConverter { - protected override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + public override object ProvideValue(IServiceProvider serviceProvider) => this; + + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) { if (value is string text && parameter is string format) return string.Format(format, text); - return value; + return value ?? string.Empty; } - protected override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotSupportedException(); } } -} \ No newline at end of file +} diff --git a/LogGrokCore/Controls/GridView/LogGridViewHeader.xaml b/LogGrokCore/Controls/GridView/LogGridViewHeader.xaml index 130a486..0d5d0dc 100644 --- a/LogGrokCore/Controls/GridView/LogGridViewHeader.xaml +++ b/LogGrokCore/Controls/GridView/LogGridViewHeader.xaml @@ -1,9 +1,9 @@ - @@ -11,10 +11,8 @@ - - @@ -31,7 +29,7 @@ - + @@ -40,14 +38,10 @@ - - - - - + - \ No newline at end of file + diff --git a/LogGrokCore/Controls/GridView/PinGridViewhHeader.xaml b/LogGrokCore/Controls/GridView/PinGridViewhHeader.xaml index b20d263..5a9e7d2 100644 --- a/LogGrokCore/Controls/GridView/PinGridViewhHeader.xaml +++ b/LogGrokCore/Controls/GridView/PinGridViewhHeader.xaml @@ -1,10 +1,10 @@ - - + diff --git a/LogGrokCore/Controls/ListControls/BaseListView.cs b/LogGrokCore/Controls/ListControls/BaseListView.cs index 69c5b40..d472c71 100644 --- a/LogGrokCore/Controls/ListControls/BaseListView.cs +++ b/LogGrokCore/Controls/ListControls/BaseListView.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Windows.Input; +using LogGrokCore.Controls.TextRender; namespace LogGrokCore.Controls.ListControls { @@ -11,20 +12,27 @@ public abstract class BaseListView : System.Windows.Controls.ListView protected BaseListView() { CommandBindings.Add(new CommandBinding(RoutedCommands.CopyToClipboard, - (_, args) => - { - Trace.TraceInformation("CopyToClipboard.Execute"); - CopySelectedItemsToClipboard(); - args.Handled = true; - }, - (_, args) => - { - args.CanExecute = GetSelectedIndices().Any(); - args.Handled = true; - })); + (_, args) => CopyToClipboard("CopyToClipboard", false, args), + CanExecuteCopyToClipboard)); + CommandBindings.Add(new CommandBinding(RoutedCommands.CopyAsDisplayed, + (_, args) => CopyToClipboard("CopyAsDisplayed", true, args), + CanExecuteCopyToClipboard)); + } + + private void CopyToClipboard(string commandName, bool asDisplayed, ExecutedRoutedEventArgs args) + { + Trace.TraceInformation($"{commandName}.Execute"); + CopySelectedItemsToClipboard(asDisplayed); + args.Handled = true; + } + + private void CanExecuteCopyToClipboard(object _, CanExecuteRoutedEventArgs args) + { + args.CanExecute = GetSelectedIndices().Any(); + args.Handled = true; } - private void CopySelectedItemsToClipboard() + private void CopySelectedItemsToClipboard(bool asDisplayed) { var indices = GetSelectedIndices(); @@ -33,10 +41,14 @@ private void CopySelectedItemsToClipboard() .OrderBy(i => i) .Select(i => Items[i]); + var foldingState = asDisplayed ? TextView.GetSharedFoldingState(this) : null; var text = new StringBuilder(); foreach (var line in items) { - _ = text.Append(line.ToString()?.TrimEnd()); + var lineText = asDisplayed && line is BaseLogLineViewModel viewModel + ? viewModel.GetDisplayText(foldingState) + : line.ToString(); + _ = text.Append(lineText?.TrimEnd()); _ = text.Append("\r\n"); } _ = text.Replace("\0", string.Empty); diff --git a/LogGrokCore/Controls/ListControls/BaseLogListViewItem.cs b/LogGrokCore/Controls/ListControls/BaseLogListViewItem.cs index 4821eee..5f450a3 100644 --- a/LogGrokCore/Controls/ListControls/BaseLogListViewItem.cs +++ b/LogGrokCore/Controls/ListControls/BaseLogListViewItem.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using LogGrokCore.Colors; +using Wpf.Ui.Appearance; namespace LogGrokCore.Controls.ListControls { @@ -20,6 +22,9 @@ public ItemActivatedEventArgs(RoutedEvent routedEvent, object source, object ite public class BaseLogListViewItem : ListViewItem { + private static readonly List> RegisteredItems = new(); + private static bool _themeSubscribed; + private readonly ItemsControl _itemsControl; private Brush? _overrideForeground; @@ -33,6 +38,27 @@ public BaseLogListViewItem(ItemsControl itemsControl) { UpdateIsCurrentProperty(); }; + + RegisteredItems.Add(new WeakReference(this)); + EnsureThemeSubscription(); + } + + private static void EnsureThemeSubscription() + { + if (_themeSubscribed) return; + _themeSubscribed = true; + ApplicationThemeManager.Changed += OnApplicationThemeChanged; + } + + private static void OnApplicationThemeChanged(ApplicationTheme theme, Color accent) + { + for (var i = RegisteredItems.Count - 1; i >= 0; i--) + { + if (RegisteredItems[i].TryGetTarget(out var item)) + item.UpdateColorOverride(); + else + RegisteredItems.RemoveAt(i); + } } public static readonly DependencyProperty OnItemActivatedCommandProperty = DependencyProperty.RegisterAttached( @@ -109,7 +135,7 @@ public bool IsCurrentItem private new static readonly DependencyProperty ForegroundProperty = TextElement.ForegroundProperty.AddOwner(typeof(BaseLogListViewItem), new FrameworkPropertyMetadata(SystemColors.ControlTextBrush, - FrameworkPropertyMetadataOptions.None, + FrameworkPropertyMetadataOptions.Inherits, (_, _) => { }, CoerceForegroundProperty)); @@ -134,23 +160,30 @@ protected override void OnContentChanged(object oldContent, object? newContent) { base.OnContentChanged(oldContent, newContent); UpdateIsCurrentProperty(); + UpdateColorOverride(); + } + + private void UpdateColorOverride() + { var colorSettings = ColorSettings.GetColorSettings(this); - if (oldContent == newContent || colorSettings == null) return; - var text = newContent?.ToString(); - if (text == null) return; + var text = Content?.ToString(); ColorSettings.ColorRule? rule = null; - // ReSharper disable once ForCanBeConvertedToForeach - // ReSharper disable once LoopCanBeConvertedToQuery - for (var i = 0; i < colorSettings.Rules.Count; i++) + if (colorSettings != null && text != null) { - var colorSettingsRule = colorSettings.Rules[i]; - if (!colorSettingsRule.IsMatch(text)) continue; - rule = colorSettingsRule; - break; + // ReSharper disable once ForCanBeConvertedToForeach + // ReSharper disable once LoopCanBeConvertedToQuery + for (var i = 0; i < colorSettings.Rules.Count; i++) + { + var colorSettingsRule = colorSettings.Rules[i]; + if (!colorSettingsRule.IsMatch(text)) continue; + rule = colorSettingsRule; + break; + } } - _overrideForeground = rule?.Foreground; - _overrideBackground = rule?.Background; + var isDark = ApplicationThemeManager.GetAppTheme() == ApplicationTheme.Dark; + _overrideForeground = rule?.GetForegroundBrush(isDark); + _overrideBackground = rule?.GetBackgroundBrush(isDark); CoerceValue(ForegroundProperty); CoerceValue(BackgroundProperty); } diff --git a/LogGrokCore/Controls/ListControls/MarkedLinesListView.cs b/LogGrokCore/Controls/ListControls/MarkedLinesListView.cs index 20bd0f9..ce85c20 100644 --- a/LogGrokCore/Controls/ListControls/MarkedLinesListView.cs +++ b/LogGrokCore/Controls/ListControls/MarkedLinesListView.cs @@ -1,8 +1,11 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Windows; using System.Windows.Controls; +using LogGrokCore.Controls.TextRender; +using LogGrokCore.MarkedLines; namespace LogGrokCore.Controls.ListControls { @@ -18,6 +21,34 @@ public IEnumerable ReadonlySelectedItems set => SetValue(ReadonlySelectedItemsProperty, value); } + public DelegateCommand CopyDocumentLinesCommand { get; } + + public MarkedLinesListView() + { + CopyDocumentLinesCommand = new DelegateCommand(CopyDocumentLines); + } + + private void CopyDocumentLines(object parameter) + { + if (parameter is not DocumentViewModel document) + return; + + var foldingState = TextView.GetSharedFoldingState(this); + var text = new StringBuilder(); + text.Append(document.Title); + text.Append(':'); + + foreach (var line in Items.OfType() + .Where(m => m.Document == document) + .OrderBy(m => m.Index)) + { + text.Append("\r\n"); + text.Append(line.GetDisplayText(foldingState).Replace("\0", string.Empty).TrimEnd()); + } + + TextCopy.ClipboardService.SetText(text.ToString()); + } + protected override void OnSelectionChanged(SelectionChangedEventArgs e) { ReadonlySelectedItems = SelectedItems; @@ -39,4 +70,4 @@ protected override IEnumerable GetSelectedIndices() } } } -} \ No newline at end of file +} diff --git a/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.SelectionAndNavigation.cs b/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.SelectionAndNavigation.cs index 4f160ff..8b9d3cd 100644 --- a/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.SelectionAndNavigation.cs +++ b/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.SelectionAndNavigation.cs @@ -18,6 +18,10 @@ public partial class VirtualizingStackPanel public static readonly DependencyProperty IsCurrentItemProperty = DependencyProperty.RegisterAttached( "IsCurrentItem", typeof(bool), typeof(VirtualizingStackPanel), new PropertyMetadata(default(bool))); + public static readonly DependencyProperty ReplaceSelectionOnCurrentPositionProperty = DependencyProperty.Register( + "ReplaceSelectionOnCurrentPosition", typeof(bool), typeof(VirtualizingStackPanel), + new PropertyMetadata(false)); + public static void SetIsCurrentItem(ListBoxItem listViewItem, bool value) { listViewItem.SetValue(IsCurrentItemProperty, value); @@ -32,7 +36,15 @@ private static void OnCurrentPositionChanged(DependencyObject d, DependencyPrope { var panel = (VirtualizingStackPanel) d; var newValue = (int) e.NewValue; - panel._selection.Add(newValue); + var itemCount = panel.ListView.Items.Count; + if (newValue >= itemCount) + newValue = itemCount - 1; + if (newValue < 0) + panel._selection.Clear(); + else if (panel.ReplaceSelectionOnCurrentPosition) + panel._selection.Set(newValue); + else + panel._selection.Add(newValue); panel.UpdateSelection(); foreach (var visibleItem in panel._visibleItems) { @@ -46,6 +58,12 @@ public int CurrentPosition get => (int) GetValue(CurrentPositionProperty); set => SetValue(CurrentPositionProperty, value); } + + public bool ReplaceSelectionOnCurrentPosition + { + get => (bool) GetValue(ReplaceSelectionOnCurrentPositionProperty); + set => SetValue(ReplaceSelectionOnCurrentPositionProperty, value); + } private readonly Selection _selection = new(); private ScrollContentPresenter? _scrollContentPresenter; diff --git a/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.cs b/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.cs index b39a1f8..f71ce21 100644 --- a/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.cs +++ b/LogGrokCore/Controls/ListControls/VirtualizingStackPanel/VirtualizingStackPanel.cs @@ -12,6 +12,10 @@ namespace LogGrokCore.Controls.ListControls.VirtualizingStackPanel { public partial class VirtualizingStackPanel : VirtualizingPanel, IScrollInfo { + public static readonly DependencyProperty FirstVisibleIndexProperty = DependencyProperty.Register( + "FirstVisibleIndex", typeof(int), typeof(VirtualizingStackPanel), + new FrameworkPropertyMetadata(-1, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + private List _visibleItems = new(); private readonly Stack _recycled = new(); @@ -88,6 +92,12 @@ protected override Size MeasureOverride(Size availableSize) public double VisibleItemsMaxWidth { get; private set; } public bool IsViewportIsCompletelyFilled { get; private set; } + + public int FirstVisibleIndex + { + get => (int)GetValue(FirstVisibleIndexProperty); + set => SetValue(FirstVisibleIndexProperty, value); + } protected override Size ArrangeOverride(Size finalSize) { @@ -125,7 +135,13 @@ private void BuildVisibleItems(Size availableSize, double verticalOffset) { var firstVisibleItemIndex = (int) Math.Floor(verticalOffset); var startOffset = firstVisibleItemIndex - verticalOffset; - + + var resolvedFirstVisibleIndex = Items.Count == 0 + ? -1 + : Math.Clamp(firstVisibleItemIndex, 0, Items.Count - 1); + if (FirstVisibleIndex != resolvedFirstVisibleIndex) + FirstVisibleIndex = resolvedFirstVisibleIndex; + if (InternalChildren.Count == 0) _visibleItems.Clear(); diff --git a/LogGrokCore/Controls/LogMinimapControl.cs b/LogGrokCore/Controls/LogMinimapControl.cs new file mode 100644 index 0000000..73ee2d7 --- /dev/null +++ b/LogGrokCore/Controls/LogMinimapControl.cs @@ -0,0 +1,884 @@ +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using LogGrokCore.Data; + +namespace LogGrokCore.Controls +{ + public class LogMinimapControl : FrameworkElement + { + private enum DragHandle + { + None, + Lower, + Upper, + Move + } + + public static readonly DependencyProperty MarkersProperty = DependencyProperty.Register( + nameof(Markers), typeof(IEnumerable), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMarkersChanged)); + + public static readonly DependencyProperty MatchBucketsProperty = DependencyProperty.Register( + nameof(MatchBuckets), typeof(IEnumerable), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMatchBucketsChanged)); + + public static readonly DependencyProperty ItemCountProperty = DependencyProperty.Register( + nameof(ItemCount), typeof(int), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty DurationTextProperty = DependencyProperty.Register( + nameof(DurationText), typeof(string), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MinTextProperty = DependencyProperty.Register( + nameof(MinText), typeof(string), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MaxTextProperty = DependencyProperty.Register( + nameof(MaxText), typeof(string), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MinimumProperty = DependencyProperty.Register( + nameof(Minimum), typeof(double), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register( + nameof(Maximum), typeof(double), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LowerValueProperty = DependencyProperty.Register( + nameof(LowerValue), typeof(double), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + public static readonly DependencyProperty UpperValueProperty = DependencyProperty.Register( + nameof(UpperValue), typeof(double), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + public static readonly DependencyProperty IsRangeEnabledProperty = DependencyProperty.Register( + nameof(IsRangeEnabled), typeof(bool), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty TimeIndexProperty = DependencyProperty.Register( + nameof(TimeIndex), typeof(TimeIndex), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MarkerBrushProperty = DependencyProperty.Register( + nameof(MarkerBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.OrangeRed, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MatchBrushProperty = DependencyProperty.Register( + nameof(MatchBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.DodgerBlue, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty DayBoundaryBrushProperty = DependencyProperty.Register( + nameof(DayBoundaryBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Gray, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MinimapBackgroundProperty = DependencyProperty.Register( + nameof(MinimapBackground), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty SelectionBrushProperty = DependencyProperty.Register( + nameof(SelectionBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty DimBrushProperty = DependencyProperty.Register( + nameof(DimBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty HandleBrushProperty = DependencyProperty.Register( + nameof(HandleBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.DodgerBlue, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty DurationForegroundProperty = DependencyProperty.Register( + nameof(DurationForeground), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.White, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty DurationBackgroundProperty = DependencyProperty.Register( + nameof(DurationBackground), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty UseLineNumbersProperty = DependencyProperty.Register( + nameof(UseLineNumbers), typeof(bool), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty ScrollPositionProperty = DependencyProperty.Register( + nameof(ScrollPosition), typeof(int), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(-1, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty ScrollIndicatorBrushProperty = DependencyProperty.Register( + nameof(ScrollIndicatorBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.LimeGreen, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MatchLineProperty = DependencyProperty.Register( + nameof(MatchLine), typeof(int), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(-1, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MatchLineBrushProperty = DependencyProperty.Register( + nameof(MatchLineBrush), typeof(Brush), typeof(LogMinimapControl), + new FrameworkPropertyMetadata(Brushes.Magenta, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty NavigateCommandProperty = DependencyProperty.Register( + nameof(NavigateCommand), typeof(ICommand), typeof(LogMinimapControl), + new PropertyMetadata(null)); + + private static readonly Typeface LabelTypeface = new("Segoe UI"); + private const double LabelFontSize = 11.0; + private const double HandleHitTolerance = 6.0; + private const double HandleWidth = 5.0; + private const double EdgeLabelPadding = 8.0; + + private INotifyCollectionChanged? _observableMarkers; + private INotifyCollectionChanged? _observableMatchBuckets; + + private DragHandle _dragHandle; + private double _dragLowerValue; + private double _dragUpperValue; + private int _dragStartLine; + private int _dragEndLine; + private double _dragStartX; + + public IEnumerable? Markers + { + get => (IEnumerable?)GetValue(MarkersProperty); + set => SetValue(MarkersProperty, value); + } + + public IEnumerable? MatchBuckets + { + get => (IEnumerable?)GetValue(MatchBucketsProperty); + set => SetValue(MatchBucketsProperty, value); + } + + public int ItemCount + { + get => (int)GetValue(ItemCountProperty); + set => SetValue(ItemCountProperty, value); + } + + public string DurationText + { + get => (string)GetValue(DurationTextProperty); + set => SetValue(DurationTextProperty, value); + } + + public string MinText + { + get => (string)GetValue(MinTextProperty); + set => SetValue(MinTextProperty, value); + } + + public string MaxText + { + get => (string)GetValue(MaxTextProperty); + set => SetValue(MaxTextProperty, value); + } + + public double Minimum + { + get => (double)GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public double Maximum + { + get => (double)GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double LowerValue + { + get => (double)GetValue(LowerValueProperty); + set => SetValue(LowerValueProperty, value); + } + + public double UpperValue + { + get => (double)GetValue(UpperValueProperty); + set => SetValue(UpperValueProperty, value); + } + + public bool IsRangeEnabled + { + get => (bool)GetValue(IsRangeEnabledProperty); + set => SetValue(IsRangeEnabledProperty, value); + } + + public TimeIndex? TimeIndex + { + get => (TimeIndex?)GetValue(TimeIndexProperty); + set => SetValue(TimeIndexProperty, value); + } + + public Brush MarkerBrush + { + get => (Brush)GetValue(MarkerBrushProperty); + set => SetValue(MarkerBrushProperty, value); + } + + public Brush MatchBrush + { + get => (Brush)GetValue(MatchBrushProperty); + set => SetValue(MatchBrushProperty, value); + } + + public Brush DayBoundaryBrush + { + get => (Brush)GetValue(DayBoundaryBrushProperty); + set => SetValue(DayBoundaryBrushProperty, value); + } + + public Brush MinimapBackground + { + get => (Brush)GetValue(MinimapBackgroundProperty); + set => SetValue(MinimapBackgroundProperty, value); + } + + public Brush SelectionBrush + { + get => (Brush)GetValue(SelectionBrushProperty); + set => SetValue(SelectionBrushProperty, value); + } + + public Brush DimBrush + { + get => (Brush)GetValue(DimBrushProperty); + set => SetValue(DimBrushProperty, value); + } + + public Brush HandleBrush + { + get => (Brush)GetValue(HandleBrushProperty); + set => SetValue(HandleBrushProperty, value); + } + + public Brush DurationForeground + { + get => (Brush)GetValue(DurationForegroundProperty); + set => SetValue(DurationForegroundProperty, value); + } + + public Brush DurationBackground + { + get => (Brush)GetValue(DurationBackgroundProperty); + set => SetValue(DurationBackgroundProperty, value); + } + + public bool UseLineNumbers + { + get => (bool)GetValue(UseLineNumbersProperty); + set => SetValue(UseLineNumbersProperty, value); + } + + public int ScrollPosition + { + get => (int)GetValue(ScrollPositionProperty); + set => SetValue(ScrollPositionProperty, value); + } + + public Brush ScrollIndicatorBrush + { + get => (Brush)GetValue(ScrollIndicatorBrushProperty); + set => SetValue(ScrollIndicatorBrushProperty, value); + } + + public int MatchLine + { + get => (int)GetValue(MatchLineProperty); + set => SetValue(MatchLineProperty, value); + } + + public Brush MatchLineBrush + { + get => (Brush)GetValue(MatchLineBrushProperty); + set => SetValue(MatchLineBrushProperty, value); + } + + public ICommand? NavigateCommand + { + get => (ICommand?)GetValue(NavigateCommandProperty); + set => SetValue(NavigateCommandProperty, value); + } + + protected override HitTestResult? HitTestCore(PointHitTestParameters hitTestParameters) + { + var point = hitTestParameters.HitPoint; + return point.X >= 0 && point.X <= ActualWidth && point.Y >= 0 && point.Y <= ActualHeight + ? new PointHitTestResult(this, point) + : null; + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + + var width = ActualWidth; + var height = ActualHeight; + if (width <= 0 || height <= 0) + return; + + var background = MinimapBackground; + if (background != null) + drawingContext.DrawRectangle(background, null, new Rect(0, 0, width, height)); + + DrawMatchBuckets(drawingContext, width, height); + DrawDayBoundaries(drawingContext, width, height); + DrawMarkers(drawingContext, width, height); + + var rangeActive = IsRangeActive(); + var lowerX = 0.0; + var upperX = width; + if (rangeActive) + { + var (startLine, endLine) = GetRangeLines(GetEffectiveLower(), GetEffectiveUpper()); + lowerX = CenterOf(startLine, ItemCount, width); + upperX = CenterOf(endLine, ItemCount, width); + DrawSelection(drawingContext, width, height, lowerX, upperX); + } + + DrawScrollIndicator(drawingContext, width, height); + DrawMatchLine(drawingContext, width, height); + DrawRangeLabels(drawingContext, width, height, rangeActive, lowerX, upperX); + } + + private void DrawMatchLine(DrawingContext drawingContext, double width, double height) + { + var count = ItemCount; + var line = MatchLine; + if (count <= 0 || line < 0 || line >= count) + return; + + var brush = MatchLineBrush; + if (brush == null) + return; + + var center = CenterOf(line, count, width); + var indicatorWidth = 2.0; + var left = Math.Clamp(center - indicatorWidth / 2, 0, Math.Max(0, width - indicatorWidth)); + drawingContext.DrawRectangle(brush, null, new Rect(left, 0, indicatorWidth, height)); + + var half = 5.0; + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(new Point(center - half, 0), true, true); + context.LineTo(new Point(center + half, 0), true, false); + context.LineTo(new Point(center, 9), true, false); + } + + geometry.Freeze(); + drawingContext.DrawGeometry(brush, null, geometry); + } + + private void DrawScrollIndicator(DrawingContext drawingContext, double width, double height) + { + var count = ItemCount; + var position = ScrollPosition; + if (count <= 0 || position < 0 || position >= count) + return; + + var brush = ScrollIndicatorBrush; + if (brush == null) + return; + + var indicatorWidth = 2.0; + var center = CenterOf(position, count, width); + var left = Math.Clamp(center - indicatorWidth / 2, 0, Math.Max(0, width - indicatorWidth)); + drawingContext.DrawRectangle(brush, null, new Rect(left, 0, indicatorWidth, height)); + } + + private void DrawMatchBuckets(DrawingContext drawingContext, double width, double height) + { + var buckets = MatchBuckets; + if (buckets == null) + return; + + var count = 0; + foreach (var _ in buckets) + count++; + + if (count <= 0) + return; + + var barWidth = Math.Max(1.0, width / count); + var i = 0; + foreach (var bucket in buckets) + { + if (bucket is true) + { + var left = i * width / count; + drawingContext.DrawRectangle(MatchBrush, null, new Rect(left, 0, barWidth, height)); + } + + i++; + } + } + + private void DrawDayBoundaries(DrawingContext drawingContext, double width, double height) + { + if (UseLineNumbers) + return; + + var timeIndex = TimeIndex; + var count = ItemCount; + if (timeIndex == null || count <= 0) + return; + + var brush = DayBoundaryBrush; + if (brush == null) + return; + + var lineWidth = 1.0; + foreach (var index in timeIndex.DayBoundaries) + { + if (index < 0 || index >= count) + continue; + + var center = CenterOf(index, count, width); + var left = Math.Clamp(center - lineWidth / 2, 0, Math.Max(0, width - lineWidth)); + drawingContext.DrawRectangle(brush, null, new Rect(left, 0, lineWidth, height)); + + var half = 4.0; + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(new Point(center - half, 0), true, true); + context.LineTo(new Point(center + half, 0), true, false); + context.LineTo(new Point(center, 8), true, false); + } + + geometry.Freeze(); + drawingContext.DrawGeometry(brush, null, geometry); + } + } + + private void DrawMarkers(DrawingContext drawingContext, double width, double height) + { + var markers = Markers; + var count = ItemCount; + if (markers == null || count <= 0) + return; + + var markerWidth = Math.Min(2.0, width); + foreach (var item in markers) + { + if (item is not int index || index < 0 || index >= count) + continue; + + var center = CenterOf(index, count, width); + var left = Math.Clamp(center - markerWidth / 2, 0, Math.Max(0, width - markerWidth)); + drawingContext.DrawRectangle(MarkerBrush, null, new Rect(left, 0, markerWidth, height)); + } + } + private void DrawSelection(DrawingContext drawingContext, double width, double height, double lowerX, double upperX) + { + var dim = DimBrush; + if (dim != null) + { + if (lowerX > 0) + drawingContext.DrawRectangle(dim, null, new Rect(0, 0, lowerX, height)); + if (upperX < width) + drawingContext.DrawRectangle(dim, null, new Rect(upperX, 0, width - upperX, height)); + } + + var selection = SelectionBrush; + if (selection != null && upperX > lowerX) + drawingContext.DrawRectangle(selection, null, new Rect(lowerX, 0, upperX - lowerX, height)); + + var handleBrush = HandleBrush; + var handleHeight = Math.Max(1.0, height - 2); + var top = (height - handleHeight) / 2; + DrawHandle(drawingContext, handleBrush, lowerX, top, handleHeight, width); + DrawHandle(drawingContext, handleBrush, upperX, top, handleHeight, width); + } + + private void DrawHandle(DrawingContext drawingContext, Brush brush, double centerX, double top, double handleHeight, double width) + { + var left = Math.Clamp(centerX - HandleWidth / 2, 0, Math.Max(0, width - HandleWidth)); + drawingContext.DrawRoundedRectangle(brush, null, new Rect(left, top, HandleWidth, handleHeight), 1.5, 1.5); + } + + private void DrawRangeLabels( + DrawingContext drawingContext, + double width, + double height, + bool rangeActive, + double lowerX, + double upperX) + { + if (!rangeActive) + return; + + var foreground = DurationForeground; + var background = DurationBackground; + + DrawLabel(drawingContext, MinText, foreground, background, EdgeLabelPadding, height, FlowDirection.LeftToRight); + + var maxText = CreateText(MaxText, foreground); + if (maxText != null) + { + DrawLabel(drawingContext, maxText, background, + Math.Max(EdgeLabelPadding, width - maxText.Width - EdgeLabelPadding), height); + } + + var duration = GetDurationText(); + if (!string.IsNullOrEmpty(duration)) + { + var text = CreateText(duration, foreground); + if (text != null) + { + var center = (lowerX + upperX) / 2; + var left = Math.Clamp(center - text.Width / 2, EdgeLabelPadding, Math.Max(EdgeLabelPadding, width - text.Width - EdgeLabelPadding)); + DrawLabel(drawingContext, text, background, left, height); + } + } + } + + private void DrawLabel( + DrawingContext drawingContext, + string text, + Brush foreground, + Brush background, + double left, + double height, + FlowDirection flowDirection = FlowDirection.LeftToRight) + { + if (string.IsNullOrEmpty(text)) + return; + + var formatted = CreateText(text, foreground, flowDirection); + if (formatted == null) + return; + + DrawLabel(drawingContext, formatted, background, left, height); + } + + private void DrawLabel(DrawingContext drawingContext, FormattedText formatted, Brush background, double left, double height) + { + var top = Math.Max(0, (height - formatted.Height) / 2); + + if (background != null) + { + var rect = new Rect( + Math.Max(0, left - 2), + top, + Math.Min(ActualWidth, formatted.Width + 4), + formatted.Height); + drawingContext.DrawRectangle(background, null, rect); + } + + drawingContext.DrawText(formatted, new Point(left, top)); + } + + private static FormattedText? CreateText(string text, Brush brush, FlowDirection flowDirection = FlowDirection.LeftToRight) + { + if (string.IsNullOrEmpty(text)) + return null; + + return new FormattedText( + text, + CultureInfo.InvariantCulture, + flowDirection, + LabelTypeface, + LabelFontSize, + brush, + 1.0); + } + + protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) + { + base.OnMouseLeftButtonDown(e); + + if (IsRangeActive()) + { + if (e.ClickCount == 2) + { + ResetRange(); + e.Handled = true; + return; + } + + var positionX = e.GetPosition(this).X; + var (startLine, endLine) = GetRangeLines(GetEffectiveLower(), GetEffectiveUpper()); + var lowerX = CenterOf(startLine, ItemCount, ActualWidth); + var upperX = CenterOf(endLine, ItemCount, ActualWidth); + + if (IsControlPressed() && positionX > lowerX && positionX < upperX) + { + _dragStartX = positionX; + _dragStartLine = startLine; + _dragEndLine = endLine; + _dragLowerValue = LowerValue; + _dragUpperValue = UpperValue; + _dragHandle = DragHandle.Move; + CaptureMouse(); + e.Handled = true; + return; + } + + if (Math.Abs(positionX - lowerX) <= HandleHitTolerance || Math.Abs(positionX - upperX) <= HandleHitTolerance) + { + _dragHandle = Math.Abs(positionX - lowerX) <= Math.Abs(positionX - upperX) + ? DragHandle.Lower + : DragHandle.Upper; + _dragLowerValue = LowerValue; + _dragUpperValue = UpperValue; + CaptureMouse(); + e.Handled = true; + return; + } + } + + NavigateAt(e.GetPosition(this).X); + e.Handled = true; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + + var width = ActualWidth; + if (width <= 0) + return; + + var positionX = e.GetPosition(this).X; + + if (_dragHandle == DragHandle.None) + { + if (IsRangeActive()) + { + var (startLine, endLine) = GetRangeLines(GetEffectiveLower(), GetEffectiveUpper()); + var lowerX = CenterOf(startLine, ItemCount, width); + var upperX = CenterOf(endLine, ItemCount, width); + if (IsControlPressed() && positionX > lowerX && positionX < upperX) + Cursor = Cursors.SizeAll; + else if (Math.Abs(positionX - lowerX) <= HandleHitTolerance || Math.Abs(positionX - upperX) <= HandleHitTolerance) + Cursor = Cursors.SizeWE; + else + Cursor = Cursors.Arrow; + } + + return; + } + + var count = ItemCount; + if (count <= 0) + return; + + if (_dragHandle == DragHandle.Move) + { + var deltaLines = (positionX - _dragStartX) / ActualWidth * count; + var selectionLines = _dragEndLine - _dragStartLine; + var newLowerLine = (int)Math.Clamp( + Math.Round(_dragStartLine + deltaLines), 0, Math.Max(0, count - selectionLines)); + var newUpperLine = newLowerLine + selectionLines; + _dragLowerValue = LineToValue(newLowerLine); + _dragUpperValue = LineToValue(newUpperLine); + InvalidateVisual(); + return; + } + + var value = LineToValue(XToValueIndex(positionX)); + if (_dragHandle == DragHandle.Lower) + _dragLowerValue = Math.Clamp(value, Minimum, Math.Max(Minimum, _dragUpperValue - 1)); + else + _dragUpperValue = Math.Clamp(value, Math.Min(Maximum, _dragLowerValue + 1), Maximum); + + InvalidateVisual(); + } + + private (int startLine, int endLine) GetRangeLines(double lower, double upper) + { + var count = ItemCount; + if (count <= 0) + return (0, 0); + + var timeIndex = TimeIndex; + if (!UseLineNumbers && + timeIndex is { HasTime: true } && + timeIndex.FindLineRange((long)lower, (long)upper) is { } range) + { + var startLine = Math.Clamp(range.StartLine, 0, count); + var endLine = Math.Clamp(range.EndLine, startLine, count); + return (startLine, endLine); + } + + var rangeTicks = Maximum - Minimum; + if (rangeTicks <= 0) + return (0, count); + + var fallbackStart = (int)Math.Clamp((lower - Minimum) / rangeTicks * count, 0, count); + var fallbackEnd = (int)Math.Clamp((upper - Minimum) / rangeTicks * count, fallbackStart, count); + return (fallbackStart, fallbackEnd); + } + + private double LineToValue(int line) + { + var count = ItemCount; + if (count <= 0) + return Minimum; + + if (line >= count) + return Maximum; + + var timeIndex = TimeIndex; + if (!UseLineNumbers && timeIndex is { HasTime: true } && timeIndex.Count > 0) + return timeIndex.GetTicksAt(Math.Clamp(line, 0, timeIndex.Count - 1)); + + return Minimum + (double)line / count * (Maximum - Minimum); + } + + private int XToValueIndex(double x) + { + var count = ItemCount; + if (count <= 0 || ActualWidth <= 0) + return 0; + + return (int)Math.Clamp(Math.Floor(x / ActualWidth * count), 0, count); + } + + protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) + { + base.OnMouseLeftButtonUp(e); + + if (_dragHandle == DragHandle.None) + return; + + var lower = _dragLowerValue; + var upper = _dragUpperValue; + _dragHandle = DragHandle.None; + ReleaseMouseCapture(); + + SetCurrentValue(LowerValueProperty, lower); + SetCurrentValue(UpperValueProperty, upper); + InvalidateVisual(); + e.Handled = true; + } + + protected override void OnMouseLeave(MouseEventArgs e) + { + base.OnMouseLeave(e); + + if (_dragHandle == DragHandle.None) + Cursor = Cursors.Arrow; + } + + private void NavigateAt(double positionX) + { + var command = NavigateCommand; + var count = ItemCount; + var width = ActualWidth; + if (command == null || count <= 0 || width <= 0) + return; + + var markers = Markers; + if (markers != null) + { + var nearest = -1; + var nearestDistance = double.MaxValue; + foreach (var item in markers) + { + if (item is not int index || index < 0 || index >= count) + continue; + + var distance = Math.Abs(CenterOf(index, count, width) - positionX); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = index; + } + } + + if (nearest >= 0 && nearestDistance <= HandleHitTolerance && command.CanExecute(nearest)) + { + command.Execute(nearest); + return; + } + } + + var line = (int)Math.Clamp(Math.Floor(positionX / width * count), 0, count - 1); + if (command.CanExecute(line)) + command.Execute(line); + } + + private void ResetRange() + { + SetCurrentValue(LowerValueProperty, Minimum); + SetCurrentValue(UpperValueProperty, Maximum); + InvalidateVisual(); + } + + private bool IsRangeActive() => IsRangeEnabled && Maximum > Minimum; + + private static bool IsControlPressed() => + (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control; + + private double GetEffectiveLower() => + _dragHandle is DragHandle.Lower or DragHandle.Move ? _dragLowerValue : LowerValue; + + private double GetEffectiveUpper() => + _dragHandle is DragHandle.Upper or DragHandle.Move ? _dragUpperValue : UpperValue; + + private string GetDurationText() + { + if (_dragHandle != DragHandle.None) + { + var size = (long)Math.Max(0, GetEffectiveUpper() - GetEffectiveLower()); + return UseLineNumbers + ? FormatLineCount(size) + : DurationFormatter.Format(size); + } + + return DurationText; + } + + private static string FormatLineCount(long count) => + count == 1 ? "1 line" : $"{count} lines"; + + private static double CenterOf(int index, int count, double width) => + index * width / count; + + private static void OnMarkersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (LogMinimapControl)d; + + if (control._observableMarkers != null) + control._observableMarkers.CollectionChanged -= control.OnMarkersCollectionChanged; + + control._observableMarkers = e.NewValue as INotifyCollectionChanged; + + if (control._observableMarkers != null) + control._observableMarkers.CollectionChanged += control.OnMarkersCollectionChanged; + + control.InvalidateVisual(); + } + + private static void OnMatchBucketsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (LogMinimapControl)d; + + if (control._observableMatchBuckets != null) + control._observableMatchBuckets.CollectionChanged -= control.OnMatchBucketsCollectionChanged; + + control._observableMatchBuckets = e.NewValue as INotifyCollectionChanged; + + if (control._observableMatchBuckets != null) + control._observableMatchBuckets.CollectionChanged += control.OnMatchBucketsCollectionChanged; + + control.InvalidateVisual(); + } + + private void OnMarkersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + InvalidateVisual(); + + private void OnMatchBucketsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + InvalidateVisual(); + } +} diff --git a/LogGrokCore/Controls/TextRender/TextControl.cs b/LogGrokCore/Controls/TextRender/TextControl.cs index 81b94a5..94c4ed3 100644 --- a/LogGrokCore/Controls/TextRender/TextControl.cs +++ b/LogGrokCore/Controls/TextRender/TextControl.cs @@ -58,8 +58,9 @@ protected override void OnRender(DrawingContext drawingContext) if (highlightGeometries != null) { - drawingContext.DrawGeometry(Brushes.Moccasin, - new Pen(Brushes.Moccasin, 0), + var highlightBrush = _textView.HighlightBrush; + drawingContext.DrawGeometry(highlightBrush, + new Pen(highlightBrush, 0), highlightGeometries); } diff --git a/LogGrokCore/Controls/TextRender/TextView.cs b/LogGrokCore/Controls/TextRender/TextView.cs index efca0d7..04298f6 100644 --- a/LogGrokCore/Controls/TextRender/TextView.cs +++ b/LogGrokCore/Controls/TextRender/TextView.cs @@ -53,7 +53,8 @@ public OutlineData(int lineCount, private double _cachedWidth; private TextModel? _cachedTextModel; private bool _isCollapsibleStateDirty; - private TextViewSharedFoldingState? _sharedFoldingState; + private TextViewSharedFoldingState? _registeredFoldingState; + private bool _suppressFoldingNotification; private FrameworkElement? _clippingRectProvider; private UIElementCollection Children @@ -140,11 +141,13 @@ private static void OnTextModelChanged(DependencyObject d, DependencyPropertyCha void SetCollapsibleRanges(List<(int start, int length)>? collapsibleRanges) { + var sharedFoldingState = SharedFoldingState; + UpdateFoldingStateRegistration(collapsibleRanges == null ? null : sharedFoldingState); + if (collapsibleRanges == null) { _outlineData = null; FoldingManager = null; - SharedFoldingState?.Unregister(this); return; } @@ -160,16 +163,59 @@ void SetCollapsibleRanges(List<(int start, int length)>? collapsibleRanges) _isCollapsibleStateDirty = true; InvalidateMeasure(); InvalidateVisual(); + if (!_suppressFoldingNotification) + SharedFoldingState?.NotifyChanged(this); }; - if (SharedFoldingState is not { } sharedFoldingState) + if (sharedFoldingState is not { } state) + { + FoldingManager = null; return; + } - SharedFoldingState.Register(this); FoldingManager = new FoldingManager( _outlineData.CollapsibleRegionsMachine, - sharedFoldingState, - () => sharedFoldingState.GetDefaultFoldingSettings(collapsibleRangesArray, count)); + state, + () => state.GetDefaultFoldingSettings(collapsibleRangesArray, count)); + } + + private void UpdateFoldingStateRegistration(TextViewSharedFoldingState? state) + { + if (ReferenceEquals(_registeredFoldingState, state)) + return; + + _registeredFoldingState?.Unregister(this); + _registeredFoldingState = state; + state?.Register(this); + } + + internal void OnSharedFoldingStateChanged() + { + if (_outlineData is not { } outlineData || TextModel is not { } textModel) + return; + + if (SharedFoldingState is not { } state || state[textModel.UniqueId] is not { } collapsedLines) + return; + + _suppressFoldingNotification = true; + try + { + outlineData.CollapsibleRegionsMachine.UpdateCollapsedLines(collapsedLines); + } + finally + { + _suppressFoldingNotification = false; + } + + _isCollapsibleStateDirty = true; + InvalidateMeasure(); + InvalidateVisual(); + } + + private void OnSharedFoldingStatePropertyChanged() + { + if (TextModel is { } textModel) + SetCollapsibleRanges(textModel.CollapsibleRanges); } #endregion @@ -188,6 +234,21 @@ public Brush? SelectionBrush #endregion + #region HighlightBrush property + + public static readonly DependencyProperty HighlightBrushProperty = DependencyProperty.Register( + nameof(HighlightBrush), typeof(Brush), typeof(TextView), + new FrameworkPropertyMetadata(Brushes.Moccasin, FrameworkPropertyMetadataOptions.AffectsRender, + static (d, _) => (d as TextView)?._textControl.InvalidateVisual())); + + public Brush HighlightBrush + { + get => (Brush)GetValue(HighlightBrushProperty); + set => SetValue(HighlightBrushProperty, value); + } + + #endregion + #region SelectedText property public static readonly DependencyProperty SelectedTextProperty = DependencyProperty.Register( @@ -207,7 +268,8 @@ public string SelectedText public static readonly DependencyProperty SharedFoldingStateProperty = DependencyProperty.RegisterAttached( "SharedFoldingState", typeof(TextViewSharedFoldingState), typeof(TextView), new FrameworkPropertyMetadata(default(TextViewSharedFoldingState), - FrameworkPropertyMetadataOptions.Inherits)); + FrameworkPropertyMetadataOptions.Inherits, + static (d, _) => (d as TextView)?.OnSharedFoldingStatePropertyChanged())); public static void SetSharedFoldingState(DependencyObject element, TextViewSharedFoldingState value) { @@ -219,18 +281,7 @@ public static void SetSharedFoldingState(DependencyObject element, TextViewShare return (TextViewSharedFoldingState)element.GetValue(SharedFoldingStateProperty); } - private TextViewSharedFoldingState? SharedFoldingState - { - get - { - if (_sharedFoldingState == null) - { - _sharedFoldingState = GetSharedFoldingState(this); - } - - return _sharedFoldingState; - } - } + private TextViewSharedFoldingState? SharedFoldingState => GetSharedFoldingState(this); #endregion @@ -253,25 +304,33 @@ public TextView() static TextView() { ForegroundProperty.OverrideMetadata(typeof(TextView), new FrameworkPropertyMetadata(null, - FrameworkPropertyMetadataOptions.AffectsRender, + FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.Inherits, static (d, _) => (d as TextView)?._textControl.InvalidateVisual())); - CommandManager.RegisterClassCommandBinding(typeof(TextView), - new CommandBinding( - RoutedCommands.CopyToClipboard, - (sender, args) => - { - var text = ((TextView)sender).SelectedText; - TextCopy.ClipboardService.SetText(text); - args.Handled = true; - }, - (sender, args) => - { - if (sender is not TextView selectableTextBlock) return; - var haveSelectedText = !string.IsNullOrEmpty(selectableTextBlock.SelectedText); - args.CanExecute = haveSelectedText; - args.Handled = haveSelectedText; - })); + void CopySelectedTextHandler(object sender, ExecutedRoutedEventArgs args) + { + var text = ((TextView)sender).SelectedText; + TextCopy.ClipboardService.SetText(text); + args.Handled = true; + } + + void CanCopySelectedTextHandler(object sender, CanExecuteRoutedEventArgs args) + { + if (sender is not TextView selectableTextBlock) return; + var haveSelectedText = !string.IsNullOrEmpty(selectableTextBlock.SelectedText); + args.CanExecute = haveSelectedText; + args.Handled = haveSelectedText; + } + + foreach (var command in new RoutedCommand[] + { + RoutedCommands.CopyToClipboard, + RoutedCommands.CopyAsDisplayed + }) + { + CommandManager.RegisterClassCommandBinding(typeof(TextView), + new CommandBinding(command, CopySelectedTextHandler, CanCopySelectedTextHandler)); + } } protected override int VisualChildrenCount => _children?.Count ?? 0; @@ -587,7 +646,15 @@ public void ResetFoldingToDefault() SharedFoldingState is not {} sharedFoldingState) return; var defaultSettings = sharedFoldingState.GetDefaultSettings(collapsibleRanges, totalLineCount); - _outlineData?.CollapsibleRegionsMachine.UpdateCollapsedLines(defaultSettings); + _suppressFoldingNotification = true; + try + { + _outlineData?.CollapsibleRegionsMachine.UpdateCollapsedLines(defaultSettings); + } + finally + { + _suppressFoldingNotification = false; + } } public override string ToString() diff --git a/LogGrokCore/Controls/TextRender/TextViewSharedFoldingState.cs b/LogGrokCore/Controls/TextRender/TextViewSharedFoldingState.cs index 5db18b0..a9898bb 100644 --- a/LogGrokCore/Controls/TextRender/TextViewSharedFoldingState.cs +++ b/LogGrokCore/Controls/TextRender/TextViewSharedFoldingState.cs @@ -64,6 +64,15 @@ public void Register(TextView textView) CleanupClientRefs(); } } + + public void NotifyChanged(TextView source) + { + foreach (var (_, clientRef) in _clientRefs) + { + if (clientRef.TryGetTarget(out var textView) && !ReferenceEquals(textView, source)) + textView.OnSharedFoldingStateChanged(); + } + } public void Unregister(TextView textView) { diff --git a/LogGrokCore/DocumentContainer.cs b/LogGrokCore/DocumentContainer.cs index acf39a2..6dff0fa 100644 --- a/LogGrokCore/DocumentContainer.cs +++ b/LogGrokCore/DocumentContainer.cs @@ -5,6 +5,7 @@ using LogGrokCore.Controls; using LogGrokCore.Controls.GridView; using LogGrokCore.Controls.ListControls; +using LogGrokCore.Controls.TextRender; using LogGrokCore.Data; using LogGrokCore.Data.Index; using LogGrokCore.Data.Virtualization; @@ -28,7 +29,9 @@ private enum GridViewType } private readonly Container _container; - public DocumentContainer(string fileName, ApplicationSettings applicationSettings, SearchAutocompleteCache autocompleteCache) + public DocumentContainer(string fileName, ApplicationSettings applicationSettings, + SearchAutocompleteCache autocompleteCache, SavedSearchPatternStore savedSearchPatternStore, + TimelinePlacementService timelinePlacementService) { _container = new Container(rules => rules @@ -39,6 +42,8 @@ public DocumentContainer(string fileName, ApplicationSettings applicationSetting LoggerRegistrationHelper.Register(_container); _container.RegisterInstance(autocompleteCache); + _container.RegisterInstance(savedSearchPatternStore); + _container.RegisterInstance(timelinePlacementService); _container.Register(Reuse.Singleton); _container.Register( made: Parameters.Of.Type(serviceKey: ParserType.Full)); @@ -60,6 +65,7 @@ public DocumentContainer(string fileName, ApplicationSettings applicationSetting Reuse.Singleton); _container.Register(); _container.RegisterMapping(); + _container.Register(Reuse.Singleton); _container.Register(Reuse.Singleton); _container.Register, LineProvider>(); @@ -88,6 +94,7 @@ public DocumentContainer(string fileName, ApplicationSettings applicationSetting .Type(request => request.Container.Resolve())); _container.Register(Reuse.Singleton); + _container.Register(Reuse.Singleton); _container.Register(); _container.Register(); @@ -111,8 +118,10 @@ public DocumentContainer(string fileName, ApplicationSettings applicationSetting var markedLines = r.Resolve(); var columnSettings = r.Resolve(); var transofrmationPerformer = r.Resolve(); + var timeIndex = r.Resolve(); + var foldingState = r.Resolve(); return pattern => new SearchDocumentViewModel(logModelFacade, filterSettings, viewFactory, pattern, - markedLines, columnSettings, transofrmationPerformer); + markedLines, columnSettings, transofrmationPerformer, timeIndex, foldingState); }); // view diff --git a/LogGrokCore/DocumentViewModel.cs b/LogGrokCore/DocumentViewModel.cs index bb17ec0..685f234 100644 --- a/LogGrokCore/DocumentViewModel.cs +++ b/LogGrokCore/DocumentViewModel.cs @@ -1,11 +1,13 @@ using System; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Windows.Input; using LogGrokCore.Colors; using LogGrokCore.Controls; +using LogGrokCore.Controls.TextRender; using LogGrokCore.Data; using LogGrokCore.Search; @@ -16,6 +18,7 @@ public class DocumentViewModel : ViewModelBase private readonly Selection _markedLines; private bool _isCurrentDocument; private readonly LineProvider _lineProvider; + private readonly ILineParser _lineParser; private readonly TransformationPerformer _transformationPerformer; private Stream _fileHolder; @@ -26,7 +29,8 @@ public DocumentViewModel( SearchViewModel searchViewModel, Selection markedLines, ColorSettings colorSettings, - TransformationPerformer transformationPerformer) + TransformationPerformer transformationPerformer, + TextViewSharedFoldingState foldingState) { var logFileFilePath = logModelFacade.LogFile.FilePath; @@ -37,13 +41,18 @@ public DocumentViewModel( LogViewModel = logViewModel; SearchViewModel = searchViewModel; ColorSettings = colorSettings; + FoldingState = foldingState; SearchViewModel.CurrentLineChanged += lineNumber => NavigateTo(lineNumber); SearchViewModel.CurrentSearchChanged += regex => LogViewModel.HighlightRegex = regex; + SearchViewModel.PropertyChanged += OnSearchViewModelPropertyChanged; + LogViewModel.SetSearchMatches(SearchViewModel.CurrentMatchBuckets); + LogViewModel.SetSearchMatchLine(SearchViewModel.CurrentMatchLine); _markedLines = markedLines; _transformationPerformer = transformationPerformer; _lineProvider = lineProvider; + _lineParser = logModelFacade.LineParser; _markedLines.Changed += () => MarkedLinesChanged?.Invoke(); _fileHolder = logModelFacade.LogFile.Open(); @@ -53,9 +62,20 @@ public DocumentViewModel( CopyFilenameToClipboardCommand = new DelegateCommand(() => TextCopy.ClipboardService.SetText(Path.GetFileName(logFileFilePath))); OpenContainingFolderCommand = new DelegateCommand(() => OpenContainingFolder(logFileFilePath)); + FindNextCommand = new DelegateCommand(() => SearchViewModel.FindNext(GetSearchAnchor())); + FindPreviousCommand = new DelegateCommand(() => SearchViewModel.FindPrevious(GetSearchAnchor())); DocumentId = logFileFilePath; } + private int GetSearchAnchor() + { + var selectionLine = LogViewModel.CurrentOriginalLine; + if (selectionLine >= 0) + return selectionLine; + + return SearchViewModel.CurrentMatchLine; + } + public string DocumentId { get; } public event Action? MarkedLinesChanged; @@ -66,10 +86,23 @@ public DocumentViewModel( public ICommand OpenContainingFolderCommand { get; } + public ICommand FindNextCommand { get; } + + public ICommand FindPreviousCommand { get; } + public void NavigateTo(int lineNumber) { LogViewModel.NavigateTo(lineNumber); } + + private void OnSearchViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SearchViewModel.CurrentMatchBuckets)) + LogViewModel.SetSearchMatches(SearchViewModel.CurrentMatchBuckets); + + if (e.PropertyName == nameof(SearchViewModel.CurrentMatchLine)) + LogViewModel.SetSearchMatchLine(SearchViewModel.CurrentMatchLine); + } public ObservableCollection<(int number, string text)> MarkedLineViewModels { @@ -98,6 +131,27 @@ public void NavigateTo(int lineNumber) public SearchViewModel SearchViewModel { get; } public ColorSettings ColorSettings { get; } + + public TextViewSharedFoldingState FoldingState { get; } + + public int GetFoldingComponentIndex(string transformedText) + { + var parseResult = _lineParser.Parse(transformedText); + var lineMeta = parseResult.Get().ParsedLineComponents; + for (var i = 0; i < parseResult.ComponentCount; i++) + { + var length = lineMeta.ComponentLength(i); + if (length <= 0) + continue; + + var componentText = transformedText.Substring(lineMeta.ComponentStart(i), length); + if (TextOperations.GetJsonRanges(componentText).Any()) + return i; + } + + return 0; + } + public bool IsCurrentDocument { get => _isCurrentDocument; diff --git a/LogGrokCore/DurationFormatter.cs b/LogGrokCore/DurationFormatter.cs new file mode 100644 index 0000000..4444ace --- /dev/null +++ b/LogGrokCore/DurationFormatter.cs @@ -0,0 +1,46 @@ +using System; +using System.Globalization; + +namespace LogGrokCore; + +internal static class DurationFormatter +{ + public static string Format(long ticks) + { + if (ticks <= 0) + return "0 ms"; + + if (ticks < TimeSpan.TicksPerSecond) + { + var milliseconds = ticks / TimeSpan.TicksPerMillisecond; + return milliseconds == 1 ? "1 ms" : $"{milliseconds} ms"; + } + + if (ticks < TimeSpan.TicksPerMinute) + { + var seconds = (double)ticks / TimeSpan.TicksPerSecond; + return $"{seconds.ToString("0.#", CultureInfo.InvariantCulture)} sec"; + } + + if (ticks < TimeSpan.TicksPerHour) + { + var seconds = ticks / TimeSpan.TicksPerSecond; + var minutes = seconds / 60; + var remainder = seconds % 60; + return remainder == 0 ? $"{minutes} min" : $"{minutes} min {remainder} sec"; + } + + if (ticks < TimeSpan.TicksPerDay) + { + var minutes = ticks / TimeSpan.TicksPerMinute; + var hours = minutes / 60; + var remainder = minutes % 60; + return remainder == 0 ? $"{hours} h" : $"{hours} h {remainder} min"; + } + + var totalHours = ticks / TimeSpan.TicksPerHour; + var days = totalHours / 24; + var restHours = totalHours % 24; + return restHours == 0 ? $"{days} d" : $"{days} d {restHours} h"; + } +} diff --git a/LogGrokCore/Filter/FilterChipViewModel.cs b/LogGrokCore/Filter/FilterChipViewModel.cs new file mode 100644 index 0000000..307d39d --- /dev/null +++ b/LogGrokCore/Filter/FilterChipViewModel.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using LogGrokCore.Data.Index; + +namespace LogGrokCore.Filter +{ + public class FilterChipViewModel : ViewModelBase + { + private readonly int _indexedComponentIndex; + private readonly FilterSettings _filterSettings; + + public FilterChipViewModel( + string fieldName, + int indexedComponentIndex, + IReadOnlyCollection excludedValues, + FilterSettings filterSettings) + { + FieldName = fieldName; + _indexedComponentIndex = indexedComponentIndex; + _filterSettings = filterSettings; + + DisplayText = excludedValues.Count == 1 + ? $"{fieldName}: {excludedValues.First()}" + : $"{fieldName}: {excludedValues.Count} hidden"; + + RemoveCommand = new DelegateCommand( + () => _filterSettings.SetExclusions(_indexedComponentIndex, Enumerable.Empty())); + } + + public string FieldName { get; } + + public string DisplayText { get; } + + public ICommand RemoveCommand { get; } + } +} diff --git a/LogGrokCore/Filter/FilterSettings.cs b/LogGrokCore/Filter/FilterSettings.cs index ec0cd0b..e54d03f 100644 --- a/LogGrokCore/Filter/FilterSettings.cs +++ b/LogGrokCore/Filter/FilterSettings.cs @@ -11,10 +11,22 @@ public class FilterSettings private readonly Dictionary> _exclusions = new(); private readonly Indexer _indexer; - public bool HaveExclusions => _exclusions.Count > 0; + public bool HaveExclusions => _exclusions.Values.Any(exclusions => exclusions.Count > 0); + + public (long From, long To)? TimeRange { get; private set; } + + public bool HasTimeRange => TimeRange != null; + + public (int From, int To)? LineRange { get; private set; } + + public bool HasLineRange => LineRange != null; public event Action? ExclusionsChanged; + public event Action? TimeRangeChanged; + + public event Action? LineRangeChanged; + public FilterSettings(Indexer indexer, LogMetaInformation metaInformation) { _indexer = indexer; @@ -24,9 +36,11 @@ public IReadOnlyDictionary> Exclusions { get { - return _exclusions.ToDictionary( - kv => kv.Key, - kv => kv.Value as IEnumerable); + return _exclusions + .Where(kv => kv.Value.Count > 0) + .ToDictionary( + kv => kv.Key, + kv => kv.Value as IEnumerable); } } @@ -90,5 +104,33 @@ public void SetExclusions(int indexedComponent, IEnumerable componentVal _exclusions[indexedComponent] = componentValuesToExclude.ToHashSet(); ExclusionsChanged?.Invoke(); } + + public void SetTimeRange(long fromTicks, long toTicks) + { + if (TimeRange == (fromTicks, toTicks)) return; + TimeRange = (fromTicks, toTicks); + TimeRangeChanged?.Invoke(); + } + + public void ClearTimeRange() + { + if (TimeRange == null) return; + TimeRange = null; + TimeRangeChanged?.Invoke(); + } + + public void SetLineRange(int fromLine, int toLine) + { + if (LineRange == (fromLine, toLine)) return; + LineRange = (fromLine, toLine); + LineRangeChanged?.Invoke(); + } + + public void ClearLineRange() + { + if (LineRange == null) return; + LineRange = null; + LineRangeChanged?.Invoke(); + } } } \ No newline at end of file diff --git a/LogGrokCore/Filter/TimeRangeFilterViewModel.cs b/LogGrokCore/Filter/TimeRangeFilterViewModel.cs new file mode 100644 index 0000000..6d5a290 --- /dev/null +++ b/LogGrokCore/Filter/TimeRangeFilterViewModel.cs @@ -0,0 +1,224 @@ +using System; +using System.Globalization; +using System.Windows.Input; +using LogGrokCore.Data; + +namespace LogGrokCore.Filter +{ + public sealed class TimeRangeFilterViewModel : ViewModelBase + { + private readonly TimeIndex _timeIndex; + private readonly FilterSettings _filterSettings; + private readonly LogModelFacade _logModelFacade; + private bool _suppress; + private bool _isAvailable; + private bool _isLineNumberMode; + private double _minimum; + private double _maximum; + private double _lowerValue; + private double _upperValue; + + public TimeRangeFilterViewModel(TimeIndex timeIndex, FilterSettings filterSettings, + LogModelFacade logModelFacade) + { + _timeIndex = timeIndex; + _filterSettings = filterSettings; + _logModelFacade = logModelFacade; + ResetCommand = new DelegateCommand(Reset); + } + + public TimeIndex TimeIndex => _timeIndex; + + public bool IsAvailable + { + get => _isAvailable; + private set => SetAndRaiseIfChanged(ref _isAvailable, value); + } + + public bool IsLineNumberMode + { + get => _isLineNumberMode; + private set => SetAndRaiseIfChanged(ref _isLineNumberMode, value); + } + + public double Minimum + { + get => _minimum; + private set => SetAndRaiseIfChanged(ref _minimum, value); + } + + public double Maximum + { + get => _maximum; + private set => SetAndRaiseIfChanged(ref _maximum, value); + } + + public double LowerValue + { + get => _lowerValue; + set + { + if (Equals(_lowerValue, value)) return; + _lowerValue = value; + InvokePropertyChanged(); + InvokePropertyChanged(nameof(SelectedRangeText)); + InvokePropertyChanged(nameof(DurationText)); + Apply(); + } + } + + public double UpperValue + { + get => _upperValue; + set + { + if (Equals(_upperValue, value)) return; + _upperValue = value; + InvokePropertyChanged(); + InvokePropertyChanged(nameof(SelectedRangeText)); + InvokePropertyChanged(nameof(DurationText)); + Apply(); + } + } + + public string MinText => IsLineNumberMode + ? ((long)_minimum + 1).ToString(CultureInfo.InvariantCulture) + : TimestampParser.Format((long)_minimum); + + public string MaxText => IsLineNumberMode + ? ((long)_maximum).ToString(CultureInfo.InvariantCulture) + : TimestampParser.Format((long)_maximum); + + public string SelectedRangeText + { + get + { + if (IsLineNumberMode) + { + var fromLine = (long)_lowerValue + 1; + var toLine = (long)_upperValue; + var size = Math.Max(0, toLine - fromLine + 1); + return $"{fromLine} - {toLine} ({FormatLineCount(size)})"; + } + + var from = TimestampParser.Format((long)_lowerValue); + var to = TimestampParser.Format((long)_upperValue); + if (string.IsNullOrEmpty(from) || string.IsNullOrEmpty(to)) + return string.Empty; + + var duration = TimeSpan.FromTicks(Math.Max(0, (long)_upperValue - (long)_lowerValue)); + return $"{from} - {to} ({FormatDuration(duration)})"; + } + } + + public string DurationText + { + get + { + if (!IsAvailable) + return string.Empty; + + var size = Math.Max(0, (long)_upperValue - (long)_lowerValue); + return IsLineNumberMode + ? FormatLineCount(size) + : DurationFormatter.Format(size); + } + } + + public ICommand ResetCommand { get; } + + public void Refresh() + { + var hasTime = _timeIndex.HasTime && _timeIndex.IsMonotonic && + _timeIndex.Count > 0 && _timeIndex.MaxTicks > _timeIndex.MinTicks; + var lineCount = _logModelFacade.LineCount; + IsLineNumberMode = !hasTime; + + _suppress = true; + try + { + if (hasTime) + { + Minimum = _timeIndex.MinTicks; + Maximum = _timeIndex.MaxTicks; + } + else + { + Minimum = 0; + Maximum = lineCount; + } + + LowerValue = Minimum; + UpperValue = Maximum; + } + finally + { + _suppress = false; + } + + InvokePropertyChanged(nameof(MinText)); + InvokePropertyChanged(nameof(MaxText)); + InvokePropertyChanged(nameof(SelectedRangeText)); + IsAvailable = hasTime || lineCount > 0; + InvokePropertyChanged(nameof(DurationText)); + } + + private void Apply() + { + if (_suppress || !IsAvailable) return; + + if (IsLineNumberMode) + { + var lineCount = _logModelFacade.LineCount; + var lower = (int)Math.Clamp((long)_lowerValue, 0, lineCount); + var upper = (int)Math.Clamp((long)_upperValue, 0, lineCount); + + _filterSettings.ClearTimeRange(); + if (lower < upper && (lower > 0 || upper < lineCount)) + _filterSettings.SetLineRange(lower, upper); + else + _filterSettings.ClearLineRange(); + + return; + } + + var min = _timeIndex.MinTicks; + var max = _timeIndex.MaxTicks; + var timeLower = Math.Clamp((long)_lowerValue, min, max); + var timeUpper = Math.Clamp((long)_upperValue, min, max); + + _filterSettings.ClearLineRange(); + if (timeLower < timeUpper && (timeLower > min || timeUpper < max)) + _filterSettings.SetTimeRange(timeLower, timeUpper); + else + _filterSettings.ClearTimeRange(); + } + + public void Reset() + { + _suppress = true; + try + { + LowerValue = Minimum; + UpperValue = Maximum; + } + finally + { + _suppress = false; + } + + InvokePropertyChanged(nameof(SelectedRangeText)); + InvokePropertyChanged(nameof(DurationText)); + _filterSettings.ClearTimeRange(); + _filterSettings.ClearLineRange(); + } + + private static string FormatDuration(TimeSpan duration) => + duration.TotalHours >= 1 + ? duration.ToString(@"h\:mm\:ss\.fff", CultureInfo.InvariantCulture) + : duration.ToString(@"m\:ss\.fff", CultureInfo.InvariantCulture); + + private static string FormatLineCount(long count) => + count == 1 ? "1 line" : $"{count} lines"; + } +} diff --git a/LogGrokCore/LineViewModel.cs b/LogGrokCore/LineViewModel.cs index 9523e06..a6217b3 100644 --- a/LogGrokCore/LineViewModel.cs +++ b/LogGrokCore/LineViewModel.cs @@ -1,53 +1,94 @@ -using System; -using LogGrokCore.Controls; -using LogGrokCore.Data; - -namespace LogGrokCore -{ - public class LineViewModel : BaseLogLineViewModel - { - private readonly string _sourceString; - - private readonly ParseResult _parseResult; - private readonly string _transformResult; - - public LineViewModel(int index, string sourceString, ILineParser parser, Selection markedLines, - TransformationPerformer transformationPerformer) - : base(index, markedLines) - { - _sourceString = sourceString; - _transformResult = transformationPerformer.Transform(sourceString); - _parseResult = parser.Parse( - _transformResult); - } - - public LinePartViewModel this[int index] => GetValue(index); - - private LinePartViewModel GetValue(int index) - { - var uniqueId = HashCode.Combine(base.Index, index); - var lineMeta = _parseResult.Get().ParsedLineComponents; - var text = _transformResult.Substring(lineMeta.ComponentStart(index), - lineMeta.ComponentLength(index)); - - return new LinePartViewModel(uniqueId, text); - } - - public override bool Equals(object? o) - { - if (o is LineViewModel other) - return other.Index == Index && other._sourceString == _sourceString; - return false; - } - - public override int GetHashCode() - { - return HashCode.Combine(Index, _sourceString); - } - - public override string ToString() - { - return _transformResult.TrimEnd(); - } - } -} \ No newline at end of file +using System; +using System.Text; +using LogGrokCore.Controls; +using LogGrokCore.Controls.TextRender; +using LogGrokCore.Data; + +namespace LogGrokCore +{ + public class LineViewModel : BaseLogLineViewModel + { + private readonly string _sourceString; + + private readonly ParseResult _parseResult; + private readonly string _transformResult; + + public LineViewModel(int index, string sourceString, ILineParser parser, Selection markedLines, + TransformationPerformer transformationPerformer) + : base(index, markedLines) + { + _sourceString = sourceString; + _transformResult = transformationPerformer.Transform(sourceString); + _parseResult = parser.Parse( + _transformResult); + } + + public LinePartViewModel this[int index] => GetValue(index); + + private LinePartViewModel GetValue(int index) + { + var uniqueId = HashCode.Combine(base.Index, index); + var lineMeta = _parseResult.Get().ParsedLineComponents; + var text = _transformResult.Substring(lineMeta.ComponentStart(index), + lineMeta.ComponentLength(index)); + + return new LinePartViewModel(uniqueId, text); + } + + public override bool Equals(object? o) + { + if (o is LineViewModel other) + return other.Index == Index && other._sourceString == _sourceString; + return false; + } + + public override int GetHashCode() + { + return HashCode.Combine(Index, _sourceString); + } + + public override string ToString() + { + return _transformResult.TrimEnd(); + } + + public override string GetDisplayText(TextViewSharedFoldingState? foldingState) + { + var lineMeta = _parseResult.Get().ParsedLineComponents; + var componentCount = _parseResult.ComponentCount; + var builder = new StringBuilder(); + var offset = 0; + + for (var i = 0; i < componentCount; i++) + { + var start = lineMeta.ComponentStart(i); + var length = lineMeta.ComponentLength(i); + + if (start < offset || start + length > _transformResult.Length) + continue; + + builder.Append(_transformResult, offset, start - offset); + var componentText = _transformResult.Substring(start, length); + builder.Append(GetComponentDisplayText(i, componentText, foldingState)); + offset = start + length; + } + + builder.Append(_transformResult, offset, _transformResult.Length - offset); + return builder.ToString().TrimEnd(); + } + + private string GetComponentDisplayText(int componentIndex, string componentText, + TextViewSharedFoldingState? foldingState) + { + var textModel = new TextModel(HashCode.Combine(Index, componentIndex), componentText); + + if (foldingState == null || textModel.CollapsibleRanges == null) + return textModel.GetDisplayedText(null); + + var collapsedLines = foldingState[textModel.UniqueId] + ?? foldingState.GetDefaultSettings(textModel.CollapsibleRanges, textModel.Count); + + return textModel.GetDisplayedText(collapsedLines); + } + } +} diff --git a/LogGrokCore/LineViewModelCollectionProvider.cs b/LogGrokCore/LineViewModelCollectionProvider.cs index 430eef4..3aa3303 100644 --- a/LogGrokCore/LineViewModelCollectionProvider.cs +++ b/LogGrokCore/LineViewModelCollectionProvider.cs @@ -14,13 +14,15 @@ public class LineViewModelCollectionProvider private readonly IReadOnlyList _headerCollection; private readonly Selection _markedLines; private readonly TransformationPerformer _transformationPerformer; + private readonly TimeIndex _timeIndex; public LineViewModelCollectionProvider( IItemProvider<(int index, string str)> lineProvider, ILineParser lineParser, LogHeaderCollection headerCollection, Selection markedLines, - TransformationPerformer transformationPerformer) + TransformationPerformer transformationPerformer, + TimeIndex timeIndex) { _lineProvider = lineProvider; _lineParser = lineParser; @@ -28,15 +30,18 @@ public LineViewModelCollectionProvider( _markedLines = markedLines; _transformationPerformer = transformationPerformer; + _timeIndex = timeIndex; } public (IReadOnlyList headerCollection, IReadOnlyList linesCollectoin, Func getIndexByValue) GetLogLinesCollection( Indexer indexer, // index: Components -> log line numbers - IReadOnlyDictionary> exclusions) + IReadOnlyDictionary> exclusions, + (long From, long To)? timeRange = null, + (int From, int To)? lineRange = null) { - var (itemProvider, getIndexByValue) = GetLineProvider(indexer,exclusions); + var (itemProvider, getIndexByValue) = GetLineProvider(indexer, exclusions, timeRange, lineRange); var lineCollection = CreateLinesCollection(itemProvider); return (_headerCollection, lineCollection, getIndexByValue); } @@ -59,10 +64,25 @@ public LineViewModelCollectionProvider( private (IItemProvider<(int index, string str)> itemProvider, Func GetIndexByValue) GetLineProvider( Indexer indexer, - IReadOnlyDictionary> exclusions) + IReadOnlyDictionary> exclusions, + (long From, long To)? timeRange, + (int From, int To)? lineRange) { //if (exclusions.Count == 0) return (_lineProvider, x=> x); - var lineNumbersProvider = indexer.GetIndexedLinesProvider(exclusions); + IIndexedLinesProvider lineNumbersProvider = indexer.GetIndexedLinesProvider(exclusions); + + if (timeRange is { } range && + _timeIndex.FindLineRange(range.From, range.To) is { } timeLineRange) + { + lineNumbersProvider = new LineRangeIndexedLinesProvider( + lineNumbersProvider, timeLineRange.StartLine, timeLineRange.EndLine); + } + else if (lineRange is { } fallbackLineRange) + { + lineNumbersProvider = new LineRangeIndexedLinesProvider( + lineNumbersProvider, fallbackLineRange.From, fallbackLineRange.To); + } + return (new ItemProviderMapper<(int index, string str)>(lineNumbersProvider, _lineProvider), value => lineNumbersProvider.GetIndexByValue(value)); } diff --git a/LogGrokCore/LogGrokCore.csproj b/LogGrokCore/LogGrokCore.csproj index fecf1a9..a1e65cb 100644 --- a/LogGrokCore/LogGrokCore.csproj +++ b/LogGrokCore/LogGrokCore.csproj @@ -2,11 +2,12 @@ WinExe - net7.0-windows + net10.0-windows true latest enable NU1605, CS8604, CS8625, CS8602, CS8618, CS8601, CS8603, CS8614, CS8600, CS8610 + $(NoWarn);NU1701 LogGrokCore.manifest false @@ -37,21 +38,25 @@ - - - - - - - - - - - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/LogGrokCore/LogHeaderCollection.cs b/LogGrokCore/LogHeaderCollection.cs index a45fd5a..979c6cb 100644 --- a/LogGrokCore/LogHeaderCollection.cs +++ b/LogGrokCore/LogHeaderCollection.cs @@ -58,7 +58,7 @@ private ItemViewModel[] Source var headerLength = (int)offset; using var stream = _logFile.Open(); var buffer = new byte[headerLength]; - stream.Read(buffer.AsSpan()); + stream.ReadExactly(buffer.AsSpan()); var headerString = _logFile.Encoding.GetString(buffer).TrimEnd(); return new ItemViewModel[] {new LogHeaderViewModel(headerString)}; } diff --git a/LogGrokCore/LogViewModel.cs b/LogGrokCore/LogViewModel.cs index e12c5a1..3e61401 100644 --- a/LogGrokCore/LogViewModel.cs +++ b/LogGrokCore/LogViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -10,12 +11,13 @@ using LogGrokCore.Controls; using LogGrokCore.Controls.GridView; using LogGrokCore.Controls.ListControls; +using LogGrokCore.Controls.TextRender; using LogGrokCore.Data; using LogGrokCore.Filter; namespace LogGrokCore { - public class LogViewModel : ViewModelBase + public class LogViewModel : ViewModelBase, IDisposable { private readonly GridViewFactory _viewFactory; private double _progress; @@ -26,6 +28,8 @@ public class LogViewModel : ViewModelBase private Regex? _highlightRegex; private Func _getIndexByValue; private readonly FilterSettings _filterSettings; + private readonly Selection _markedLines; + private readonly TimelinePlacementService _timelinePlacementService; private int _currentItemIndex; private readonly IReadOnlyList _headerCollection; @@ -34,16 +38,29 @@ public LogViewModel( LineViewModelCollectionProvider lineViewModelCollectionProvider, GridViewFactory viewFactory, FilterSettings filterSettings, - ColumnSettings columnSettings) + ColumnSettings columnSettings, + TimeRangeFilterViewModel timeRangeFilter, + Selection markedLines, + TimelinePlacementService timelinePlacementService, + TextViewSharedFoldingState foldingState) { _logModelFacade = logModelFacade; _filterSettings = filterSettings; + FoldingState = foldingState; + TimeRangeFilter = timeRangeFilter; + _markedLines = markedLines; + _timelinePlacementService = timelinePlacementService; + _timelinePlacementService.Changed += OnTimelinePlacementChanged; var lineProvider = _logModelFacade.LineProvider; var lineParser = _logModelFacade.LineParser; _lineViewModelCollectionProvider = lineViewModelCollectionProvider; filterSettings.ExclusionsChanged += UpdateFilteredCollection; + filterSettings.ExclusionsChanged += RefreshActiveFilters; + filterSettings.TimeRangeChanged += UpdateFilteredCollection; + filterSettings.LineRangeChanged += UpdateFilteredCollection; + markedLines.Changed += RefreshMarkers; IReadOnlyList lineCollection; (_headerCollection, lineCollection, _getIndexByValue) @@ -67,29 +84,176 @@ public LogViewModel( GetComponentsInSelectedLines(componentIndex)); }); - ClearExclusionsCommand = new DelegateCommand(() => _filterSettings.ClearAllExclusions(), - () => _filterSettings.HaveExclusions); + ClearExclusionsCommand = new DelegateCommand(() => _filterSettings.ClearAllExclusions()); + + ClearFiltersCommand = new DelegateCommand(() => + { + _filterSettings.ClearAllExclusions(); + TimeRangeFilter.Reset(); + }); + + NavigateToMarkerCommand = new DelegateCommand(index => + { + if (index is int logLineNumber) + NavigateTo(logLineNumber); + }); CopySelectedItemsToClipboardCommand = new DelegateCommand(CopySelectedItemsToClipboard, () => SelectedItems?.Cast().Any() ?? false); _viewFactory = viewFactory; ColumnSettings = columnSettings; + RefreshActiveFilters(); + TotalLineCount = _logModelFacade.LineCount; + RefreshMarkers(); UpdateDocumentWhileLoading(); UpdateProgress(); } + public ObservableCollection ActiveFilters { get; } = new(); + + public ObservableCollection Markers { get; } = new(); + + public ICommand NavigateToMarkerCommand { get; } + + private int _totalLineCount; + public int TotalLineCount + { + get => _totalLineCount; + private set => SetAndRaiseIfChanged(ref _totalLineCount, value); + } + + private bool[] _searchMatches = Array.Empty(); + public bool[] SearchMatches + { + get => _searchMatches; + private set => SetAndRaiseIfChanged(ref _searchMatches, value); + } + + public void SetSearchMatches(bool[] buckets) => SearchMatches = buckets; + + private int _searchMatchLine = -1; + public int SearchMatchLine + { + get => _searchMatchLine; + private set => SetAndRaiseIfChanged(ref _searchMatchLine, value); + } + + public void SetSearchMatchLine(int line) => SearchMatchLine = line; + + private int _firstVisibleIndex = -1; + public int FirstVisibleIndex + { + get => _firstVisibleIndex; + set + { + if (_firstVisibleIndex == value) + return; + + _firstVisibleIndex = value; + InvokePropertyChanged(); + UpdateScrollPosition(); + } + } + + private int _scrollPosition = -1; + public int ScrollPosition + { + get => _scrollPosition; + private set => SetAndRaiseIfChanged(ref _scrollPosition, value); + } + + private void UpdateScrollPosition() + { + var firstVisible = _firstVisibleIndex; + if (firstVisible < 0 || firstVisible >= Lines.Count) + { + ScrollPosition = -1; + return; + } + + for (var i = firstVisible; i < Lines.Count; i++) + { + if (Lines[i] is LineViewModel line) + { + ScrollPosition = line.Index; + return; + } + } + + ScrollPosition = -1; + } + + public TimeRangeFilterViewModel TimeRangeFilter { get; } + + public Dock TimelineDock => + _timelinePlacementService.IsAtTop ? Dock.Top : Dock.Bottom; + + private void OnTimelinePlacementChanged() => InvokePropertyChanged(nameof(TimelineDock)); + + public void Dispose() => _timelinePlacementService.Changed -= OnTimelinePlacementChanged; + + public bool HaveExclusions => _filterSettings.HaveExclusions; + + private void RefreshActiveFilters() + { + ActiveFilters.Clear(); + foreach (var (indexedComponentIndex, values) in _filterSettings.Exclusions) + { + var excludedValues = values.ToList(); + if (excludedValues.Count == 0) continue; + + var fieldName = MetaInformation.GetFieldNameByIndexedFieldIndex(indexedComponentIndex); + ActiveFilters.Add(new FilterChipViewModel(fieldName, indexedComponentIndex, + excludedValues, _filterSettings)); + } + + InvokePropertyChanged(nameof(HaveExclusions)); + } + + private void RefreshMarkers() + { + Markers.Clear(); + + var totalLineCount = TotalLineCount; + var exclusions = _filterSettings.Exclusions; + foreach (var logLineIndex in _markedLines) + { + if (logLineIndex < 0 || (totalLineCount > 0 && logLineIndex >= totalLineCount)) + continue; + + if (!_logModelFacade.Indexer.IsLineIncluded(logLineIndex, exclusions)) + continue; + + Markers.Add(logLineIndex); + } + } + public ColumnSettings ColumnSettings { get; } + public TextViewSharedFoldingState FoldingState { get; } + public int CurrentItemIndex { get => _currentItemIndex; set => SetAndRaiseIfChanged(ref _currentItemIndex, value); } + public int CurrentOriginalLine + { + get + { + var index = CurrentItemIndex; + if (index < 0 || index >= Lines.Count) + return -1; + + return Lines[index] is LineViewModel line ? line.Index : -1; + } + } + public bool CanFilter => true; public LogMetaInformation MetaInformation => _logModelFacade.MetaInformation; @@ -102,6 +266,8 @@ public int CurrentItemIndex public ICommand ClearExclusionsCommand { get; } + public ICommand ClearFiltersCommand { get; } + public IEnumerable? SelectedItems { get => _selectedItems; @@ -143,6 +309,11 @@ public void NavigateTo(in int logLineNumber) { NavigateToLineRequest.Raise(_getIndexByValue(logLineNumber) + _headerCollection.Count); } + + public void NavigateToScrollIndex(int scrollIndex) + { + NavigateToLineRequest.Raise(scrollIndex); + } private IEnumerable GetComponentsInSelectedLines(int componentIndex) { @@ -157,12 +328,17 @@ private async void UpdateDocumentWhileLoading() while (!_logModelFacade.IsLoaded) { Lines.UpdateCount(); + TotalLineCount = _logModelFacade.LineCount; + RefreshMarkers(); await Task.Delay(delay); if (delay < 500) delay *= 2; } Lines.UpdateCount(); + TotalLineCount = _logModelFacade.LineCount; + TimeRangeFilter.Refresh(); + RefreshMarkers(); IsLoading = false; } @@ -202,20 +378,29 @@ private async void UpdateFilteredCollection() var originalLineIndex = (item as LineViewModel)?.Index; var exclusionsCopy = _filterSettings.Exclusions.ToDictionary(kv - => kv.Key, kv => kv.Value); + => kv.Key, kv => kv.Value); + var timeRangeCopy = _filterSettings.TimeRange; + var lineRangeCopy = _filterSettings.LineRange; var (headerCollection, linesCollection, getIndexByValue) = await Task.Factory.StartNew(() => _lineViewModelCollectionProvider.GetLogLinesCollection( _logModelFacade.Indexer, - _filterSettings.Exclusions)); + _filterSettings.Exclusions, + timeRangeCopy, + lineRangeCopy)); var newExclusionsCopy = _filterSettings.Exclusions.ToList(); - if (!exclusionsCopy.SequenceEqual(newExclusionsCopy)) + if (!exclusionsCopy.SequenceEqual(newExclusionsCopy) || + _filterSettings.TimeRange != timeRangeCopy || + _filterSettings.LineRange != lineRangeCopy) { return; } _getIndexByValue = getIndexByValue; Lines.Reset(headerCollection, linesCollection); + TotalLineCount = _logModelFacade.LineCount; + RefreshMarkers(); + UpdateScrollPosition(); if (originalLineIndex is { } index) { diff --git a/LogGrokCore/LoggerRegistrationHelper.cs b/LogGrokCore/LoggerRegistrationHelper.cs index b807be8..dfa5f8e 100644 --- a/LogGrokCore/LoggerRegistrationHelper.cs +++ b/LogGrokCore/LoggerRegistrationHelper.cs @@ -11,7 +11,7 @@ public static class LoggerRegistrationHelper static LoggerRegistrationHelper() { var logConfigPath = PathHelpers.GetLocalFilePath("nlog.config"); - var logFactory = LogManager.LoadConfiguration(logConfigPath); + var logFactory = LogManager.Setup().LoadConfigurationFromFile(logConfigPath).LogFactory; LoggerProvider = new NLogLoggerProvider(new NLogProviderOptions(), logFactory); } diff --git a/LogGrokCore/MainWindow.xaml b/LogGrokCore/MainWindow.xaml index de45499..b4b205f 100644 --- a/LogGrokCore/MainWindow.xaml +++ b/LogGrokCore/MainWindow.xaml @@ -1,31 +1,32 @@ - - + + - - @@ -33,57 +34,75 @@ - - - - - - - - - - - - - - - - - - - - - - - + - + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IsTabStop="False"> + + + + - \ No newline at end of file + diff --git a/LogGrokCore/Styles/DocumentTemplate.xaml b/LogGrokCore/Styles/DocumentTemplate.xaml index 6c0d215..89443d0 100644 --- a/LogGrokCore/Styles/DocumentTemplate.xaml +++ b/LogGrokCore/Styles/DocumentTemplate.xaml @@ -7,8 +7,6 @@ xmlns:colors="clr-namespace:LogGrokCore.Colors" mc:Ignorable="d"> - - @@ -18,6 +16,8 @@ SearchTextCommand="{Binding SearchViewModel.SearchTextCommand}"> + + @@ -34,6 +34,7 @@ VerticalAlignment="Top" HorizontalAlignment="Stretch" Height="10" + Background="{DynamicResource ControlStrokeColorDefaultBrush}" Visibility="{Binding SearchViewModel.IsFilterEnabled, Converter={StaticResource BooleanToVisibilityConverter}}" /> diff --git a/LogGrokCore/Styles/FilterPopup.xaml b/LogGrokCore/Styles/FilterPopup.xaml index 7bfe5ce..611023e 100644 --- a/LogGrokCore/Styles/FilterPopup.xaml +++ b/LogGrokCore/Styles/FilterPopup.xaml @@ -1,19 +1,17 @@  - - - - - + + @@ -43,85 +41,73 @@ - - - - - - - - + RoutedCommand={x:Static logGrokCore:RoutedCommands.Cancel}}"> + - - - - - -