From 3d1d64c898ea3f93583ab7d897b359fa2bac9712 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 20 May 2026 17:10:29 +0200 Subject: [PATCH 1/7] controlchar settings --- .../Config/ControlCharSettings.cs | 39 ++++ src/LogExpert.Core/Config/ControlCharStyle.cs | 10 + src/LogExpert.Core/Config/Preferences.cs | 5 + .../ControlCharSettingsTests.cs | 173 ++++++++++++++++++ .../PreferencesControlCharTests.cs | 29 +++ 5 files changed, 256 insertions(+) create mode 100644 src/LogExpert.Core/Config/ControlCharSettings.cs create mode 100644 src/LogExpert.Core/Config/ControlCharStyle.cs create mode 100644 src/LogExpert.Tests/ConfigManagerTests/ControlCharSettingsTests.cs create mode 100644 src/LogExpert.Tests/ConfigManagerTests/PreferencesControlCharTests.cs diff --git a/src/LogExpert.Core/Config/ControlCharSettings.cs b/src/LogExpert.Core/Config/ControlCharSettings.cs new file mode 100644 index 00000000..8a066476 --- /dev/null +++ b/src/LogExpert.Core/Config/ControlCharSettings.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; + +using Newtonsoft.Json; + +namespace LogExpert.Core.Config; + +public sealed class ControlCharSettings +{ + public bool Substitute { get; set; } + + public ControlCharStyle Style + { + get; + set => field = Enum.IsDefined(value) ? value : ControlCharStyle.ControlPictures; + } = ControlCharStyle.ControlPictures; + + public Color ForeColor { get; set; } = Color.Gray; + + public Color BackColor { get; set; } = Color.Empty; + + public bool Bold { get; set; } + + public bool Italic { get; set; } + + public bool CopyDisplayedForm { get; set; } + + [JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public HashSet EnabledCodepoints { get; set; } = BuildNonWhitespacePreset(); + + internal static HashSet BuildNonWhitespacePreset () + { + return Enumerable.Range(0x00, 0x20) + .Where(c => c is not 0x09 and not 0x0A and not 0x0D) + .Append(0x7F) + .ToHashSet(); + } +} diff --git a/src/LogExpert.Core/Config/ControlCharStyle.cs b/src/LogExpert.Core/Config/ControlCharStyle.cs new file mode 100644 index 00000000..5395b219 --- /dev/null +++ b/src/LogExpert.Core/Config/ControlCharStyle.cs @@ -0,0 +1,10 @@ +namespace LogExpert.Core.Config; + +public enum ControlCharStyle +{ + ControlPictures = 0, + Caret = 1, + CEscape = 2, + Abbreviation = 3, + Iso2047 = 4, +} diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index be0c50fe..d4b20fdd 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -35,6 +35,11 @@ public List HilightGroupList public bool PortableMode { get; set; } + /// + /// Settings controlling display substitution of ASCII control characters (C0 + DEL) in the log grid. + /// + public ControlCharSettings ControlCharSettings { get; set; } = new(); + /// /// OBSOLETE: This setting is no longer used. It was originally intended to show an error dialog when "Allow Only /// One Instance" was enabled, but this behavior was incorrect (showed dialog on success instead of failure). The diff --git a/src/LogExpert.Tests/ConfigManagerTests/ControlCharSettingsTests.cs b/src/LogExpert.Tests/ConfigManagerTests/ControlCharSettingsTests.cs new file mode 100644 index 00000000..fbba9e92 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/ControlCharSettingsTests.cs @@ -0,0 +1,173 @@ +using System.Drawing; +using System.Linq; + +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class ControlCharSettingsTests +{ + [Test] + public void Default_Substitute_IsFalse () + { + var settings = new ControlCharSettings(); + Assert.That(settings.Substitute, Is.False); + } + + [Test] + public void Default_Style_IsControlPictures () + { + var settings = new ControlCharSettings(); + Assert.That(settings.Style, Is.EqualTo(ControlCharStyle.ControlPictures)); + } + + [Test] + public void Default_ForeColor_IsGray () + { + var settings = new ControlCharSettings(); + Assert.That(settings.ForeColor, Is.EqualTo(Color.Gray)); + } + + [Test] + public void Default_BackColor_IsEmpty () + { + var settings = new ControlCharSettings(); + Assert.That(settings.BackColor, Is.EqualTo(Color.Empty)); + } + + [Test] + public void Default_BoldAndItalic_AreFalse () + { + var settings = new ControlCharSettings(); + Assert.That(settings.Bold, Is.False); + Assert.That(settings.Italic, Is.False); + } + + [Test] + public void Default_CopyDisplayedForm_IsFalse () + { + var settings = new ControlCharSettings(); + Assert.That(settings.CopyDisplayedForm, Is.False); + } + + [Test] + public void Default_EnabledCodepoints_IsNonWhitespacePreset () + { + var settings = new ControlCharSettings(); + + var expected = Enumerable.Range(0x00, 0x20) + .Where(c => c != 0x09 && c != 0x0A && c != 0x0D) + .Append(0x7F) + .ToArray(); + + Assert.That(settings.EnabledCodepoints, Is.EquivalentTo(expected)); + Assert.That(settings.EnabledCodepoints.Count, Is.EqualTo(30)); + } + + [Test] + public void RoundTrip_DefaultInstance_PreservesAllProperties () + { + var original = new ControlCharSettings(); + + string json = JsonConvert.SerializeObject(original); + var roundTripped = JsonConvert.DeserializeObject(json); + + Assert.That(roundTripped, Is.Not.Null); + Assert.That(roundTripped!.Substitute, Is.EqualTo(original.Substitute)); + Assert.That(roundTripped.Style, Is.EqualTo(original.Style)); + Assert.That(roundTripped.ForeColor.ToArgb(), Is.EqualTo(original.ForeColor.ToArgb())); + Assert.That(roundTripped.BackColor.ToArgb(), Is.EqualTo(original.BackColor.ToArgb())); + Assert.That(roundTripped.Bold, Is.EqualTo(original.Bold)); + Assert.That(roundTripped.Italic, Is.EqualTo(original.Italic)); + Assert.That(roundTripped.CopyDisplayedForm, Is.EqualTo(original.CopyDisplayedForm)); + Assert.That(roundTripped.EnabledCodepoints, Is.EquivalentTo(original.EnabledCodepoints)); + } + + [Test] + public void RoundTrip_CustomisedInstance_PreservesAllProperties () + { + var original = new ControlCharSettings + { + Substitute = true, + Style = ControlCharStyle.Caret, + ForeColor = Color.FromArgb(255, 200, 100, 50), + BackColor = Color.FromArgb(255, 10, 20, 30), + Bold = true, + Italic = true, + CopyDisplayedForm = true, + EnabledCodepoints = [0x01, 0x02, 0x7F], + }; + + string json = JsonConvert.SerializeObject(original); + var roundTripped = JsonConvert.DeserializeObject(json); + + Assert.That(roundTripped, Is.Not.Null); + Assert.That(roundTripped!.Substitute, Is.True); + Assert.That(roundTripped.Style, Is.EqualTo(ControlCharStyle.Caret)); + Assert.That(roundTripped.ForeColor.ToArgb(), Is.EqualTo(original.ForeColor.ToArgb())); + Assert.That(roundTripped.BackColor.ToArgb(), Is.EqualTo(original.BackColor.ToArgb())); + Assert.That(roundTripped.Bold, Is.True); + Assert.That(roundTripped.Italic, Is.True); + Assert.That(roundTripped.CopyDisplayedForm, Is.True); + Assert.That(roundTripped.EnabledCodepoints, Is.EquivalentTo(new[] { 0x01, 0x02, 0x7F })); + } + + [Test] + public void Deserialize_EnabledCodepointsNull_FallsBackToPreset () + { + const string json = "{ \"EnabledCodepoints\": null }"; + + var settings = JsonConvert.DeserializeObject(json); + + Assert.That(settings, Is.Not.Null); + Assert.That(settings!.EnabledCodepoints, Is.Not.Null); + Assert.That(settings.EnabledCodepoints.Count, Is.EqualTo(30)); + } + + [Test] + public void Deserialize_EnabledCodepointsEmptyArray_StaysEmpty () + { + const string json = "{ \"EnabledCodepoints\": [] }"; + + var settings = JsonConvert.DeserializeObject(json); + + Assert.That(settings, Is.Not.Null); + Assert.That(settings!.EnabledCodepoints, Is.Empty); + } + + [Test] + public void Deserialize_StyleOutOfRange_FallsBackToControlPictures () + { + const string json = "{ \"Style\": 99 }"; + + var settings = JsonConvert.DeserializeObject(json); + + Assert.That(settings, Is.Not.Null); + Assert.That(settings!.Style, Is.EqualTo(ControlCharStyle.ControlPictures)); + } + + [TestCase(0, ControlCharStyle.ControlPictures)] + [TestCase(1, ControlCharStyle.Caret)] + [TestCase(2, ControlCharStyle.CEscape)] + [TestCase(3, ControlCharStyle.Abbreviation)] + [TestCase(4, ControlCharStyle.Iso2047)] + public void RoundTrip_StyleNumericValue_MapsToNamedStyle (int numeric, ControlCharStyle expected) + { + string json = $"{{ \"Style\": {numeric} }}"; + + var settings = JsonConvert.DeserializeObject(json); + + Assert.That(settings, Is.Not.Null); + Assert.That(settings!.Style, Is.EqualTo(expected)); + + string reserialised = JsonConvert.SerializeObject(settings); + var reloaded = JsonConvert.DeserializeObject(reserialised); + + Assert.That(reloaded!.Style, Is.EqualTo(expected)); + } +} diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesControlCharTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesControlCharTests.cs new file mode 100644 index 00000000..12e1b2d7 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesControlCharTests.cs @@ -0,0 +1,29 @@ +using System.Drawing; +using System.Linq; + +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class PreferencesControlCharTests +{ + [Test] + public void Deserialize_LegacyJsonMissingControlCharSettings_GetsDefaultInstance () + { + const string legacyJson = "{}"; + + var prefs = JsonConvert.DeserializeObject(legacyJson); + + Assert.That(prefs, Is.Not.Null); + Assert.That(prefs!.ControlCharSettings, Is.Not.Null); + Assert.That(prefs.ControlCharSettings.Substitute, Is.False); + Assert.That(prefs.ControlCharSettings.Style, Is.EqualTo(ControlCharStyle.ControlPictures)); + Assert.That(prefs.ControlCharSettings.ForeColor.ToArgb(), Is.EqualTo(Color.Gray.ToArgb())); + Assert.That(prefs.ControlCharSettings.EnabledCodepoints.Count, Is.EqualTo(30)); + } +} From 88fcdd25e7f42e5e6941c983e86eaee95e42a763 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 20 May 2026 17:11:32 +0200 Subject: [PATCH 2/7] paint --- .../ControlCharRendererTests.cs | 236 +++++++++++++++ .../ControlCharStyleFormatterTests.cs | 121 ++++++++ .../SubstitutedHighlightSegmenterTests.cs | 282 ++++++++++++++++++ .../ControlCharDisplay/ControlCharRenderer.cs | 57 ++++ .../ControlCharStyleFormatter.cs | 115 +++++++ .../ControlCharDisplay/PaintSegment.cs | 12 + .../ControlCharDisplay/RenderSegment.cs | 7 + .../SubstitutedHighlightSegmenter.cs | 138 +++++++++ .../Controls/LogWindow/LogWindow.cs | 50 ++-- 9 files changed, 999 insertions(+), 19 deletions(-) create mode 100644 src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs create mode 100644 src/LogExpert.Tests/ControlCharDisplay/ControlCharStyleFormatterTests.cs create mode 100644 src/LogExpert.Tests/ControlCharDisplay/SubstitutedHighlightSegmenterTests.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/ControlCharStyleFormatter.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/PaintSegment.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/RenderSegment.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/SubstitutedHighlightSegmenter.cs diff --git a/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs b/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs new file mode 100644 index 00000000..6f027d64 --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs @@ -0,0 +1,236 @@ +using System.Collections.Generic; + +using LogExpert.Core.Config; +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class ControlCharRendererTests +{ + [TestCase(true)] + [TestCase(false)] + public void Render_EmptyInput_ReturnsEmptySegmentList (bool substitute) + { + var settings = new ControlCharSettings { Substitute = substitute }; + + IReadOnlyList segments = ControlCharRenderer.Render(string.Empty, settings); + + Assert.That(segments, Is.Empty); + } + + [Test] + public void Render_SubstituteOff_ReturnsSingleRawSegmentForWholeInput () + { + var settings = new ControlCharSettings { Substitute = false }; + const string raw = "hello\u0001world"; + + IReadOnlyList segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[0].SourceLength, Is.EqualTo(raw.Length)); + Assert.That(segments[0].RenderedText, Is.EqualTo(raw)); + Assert.That(segments[0].IsSubstituted, Is.False); + } + + [Test] + public void Render_SubstituteOn_NoControlChars_ReturnsSingleRawSegment () + { + var settings = new ControlCharSettings { Substitute = true }; + const string raw = "plain text only"; + + IReadOnlyList segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[0].SourceLength, Is.EqualTo(raw.Length)); + Assert.That(segments[0].RenderedText, Is.EqualTo(raw)); + Assert.That(segments[0].IsSubstituted, Is.False); + } + + [Test] + public void Render_SubstituteOn_SingleEnabledControlChar_ReturnsSingleSubstitutedSegment () + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + const string raw = "\u0001"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[0].SourceLength, Is.EqualTo(1)); + Assert.That(segments[0].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[0].IsSubstituted, Is.True); + } + + [Test] + public void Render_SubstituteOn_SingleDisabledControlChar_ReturnsRawSegment () + { + // 0x01 is in the default preset; explicitly remove it. + var settings = new ControlCharSettings + { + Substitute = true, + EnabledCodepoints = new HashSet(), + }; + const string raw = "\u0001"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].IsSubstituted, Is.False); + Assert.That(segments[0].RenderedText, Is.EqualTo("\u0001")); + Assert.That(segments[0].SourceLength, Is.EqualTo(1)); + } + + [Test] + public void Render_SubstituteOn_MixedInput_SplitsIntoThreeSegments () + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + const string raw = "a\u0001b"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(3)); + + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[0].SourceLength, Is.EqualTo(1)); + Assert.That(segments[0].RenderedText, Is.EqualTo("a")); + Assert.That(segments[0].IsSubstituted, Is.False); + + Assert.That(segments[1].SourceStart, Is.EqualTo(1)); + Assert.That(segments[1].SourceLength, Is.EqualTo(1)); + Assert.That(segments[1].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[1].IsSubstituted, Is.True); + + Assert.That(segments[2].SourceStart, Is.EqualTo(2)); + Assert.That(segments[2].SourceLength, Is.EqualTo(1)); + Assert.That(segments[2].RenderedText, Is.EqualTo("b")); + Assert.That(segments[2].IsSubstituted, Is.False); + } + + [Test] + public void Render_AdjacentControlChars_ProducesTwoSubstitutedSegments () + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + const string raw = "\u0001\u0002"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(2)); + Assert.That(segments[0].IsSubstituted, Is.True); + Assert.That(segments[0].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[1].IsSubstituted, Is.True); + Assert.That(segments[1].RenderedText, Is.EqualTo("^B")); + Assert.That(segments[1].SourceStart, Is.EqualTo(1)); + } + + [Test] + public void Render_LeadingControlChar_NoEmptyRawSegmentBefore () + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + const string raw = "\u0001a"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(2)); + Assert.That(segments[0].IsSubstituted, Is.True); + Assert.That(segments[1].IsSubstituted, Is.False); + Assert.That(segments[1].RenderedText, Is.EqualTo("a")); + } + + [Test] + public void Render_TrailingControlChar_NoEmptyRawSegmentAfter () + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + const string raw = "a\u0001"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(2)); + Assert.That(segments[0].IsSubstituted, Is.False); + Assert.That(segments[0].RenderedText, Is.EqualTo("a")); + Assert.That(segments[1].IsSubstituted, Is.True); + } + + private static readonly object[] s_sourceIndexCases = + [ + "", + "plain text", + "\u0001", + "a\u0001b", + "\u0001\u0002", + "\u0001a", + "a\u0001", + "abc\u0001\u0002def\u0007ghi", + ]; + + [TestCaseSource(nameof(s_sourceIndexCases))] + public void Render_SourceIndexIntegrity_SegmentsCoverInputExactly (string raw) + { + var settings = new ControlCharSettings { Substitute = true, Style = ControlCharStyle.Caret }; + + var segments = ControlCharRenderer.Render(raw, settings); + + var reconstructed = new System.Text.StringBuilder(); + int expectedStart = 0; + foreach (var seg in segments) + { + Assert.That(seg.SourceStart, Is.EqualTo(expectedStart), + $"Segment starts at {seg.SourceStart}, expected {expectedStart}."); + reconstructed.Append(raw.AsSpan(seg.SourceStart, seg.SourceLength)); + expectedStart = seg.SourceStart + seg.SourceLength; + } + + Assert.That(reconstructed.ToString(), Is.EqualTo(raw)); + Assert.That(expectedStart, Is.EqualTo(raw.Length)); + } + + [TestCase(ControlCharStyle.Caret, "^G")] + [TestCase(ControlCharStyle.Abbreviation, "BEL")] + [TestCase(ControlCharStyle.ControlPictures, "\u2407")] + public void Render_StylePropagation_DelegatesToFormatter (ControlCharStyle style, string expected) + { + var settings = new ControlCharSettings { Substitute = true, Style = style }; + const string raw = "\u0007"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].RenderedText, Is.EqualTo(expected)); + Assert.That(segments[0].IsSubstituted, Is.True); + } + + [Test] + public void Render_SelectiveOptIn_LeavesDisabledCodepointAsRaw () + { + // Only \x02 is enabled; \x01 must remain in a raw segment. + var settings = new ControlCharSettings + { + Substitute = true, + Style = ControlCharStyle.ControlPictures, + EnabledCodepoints = [0x02], + }; + const string raw = "a\u0001b\u0002c"; + + var segments = ControlCharRenderer.Render(raw, settings); + + Assert.That(segments, Has.Count.EqualTo(3)); + Assert.That(segments[0].IsSubstituted, Is.False); + Assert.That(segments[0].RenderedText, Is.EqualTo("a\u0001b")); + Assert.That(segments[0].SourceStart, Is.EqualTo(0)); + Assert.That(segments[0].SourceLength, Is.EqualTo(3)); + + Assert.That(segments[1].IsSubstituted, Is.True); + Assert.That(segments[1].RenderedText, Is.EqualTo("\u2402")); + Assert.That(segments[1].SourceStart, Is.EqualTo(3)); + Assert.That(segments[1].SourceLength, Is.EqualTo(1)); + + Assert.That(segments[2].IsSubstituted, Is.False); + Assert.That(segments[2].RenderedText, Is.EqualTo("c")); + Assert.That(segments[2].SourceStart, Is.EqualTo(4)); + } +} diff --git a/src/LogExpert.Tests/ControlCharDisplay/ControlCharStyleFormatterTests.cs b/src/LogExpert.Tests/ControlCharDisplay/ControlCharStyleFormatterTests.cs new file mode 100644 index 00000000..0c47ff78 --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/ControlCharStyleFormatterTests.cs @@ -0,0 +1,121 @@ +using System; + +using LogExpert.Core.Config; +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class ControlCharStyleFormatterTests +{ + [TestCase(0x00, "^@")] + [TestCase(0x01, "^A")] + [TestCase(0x07, "^G")] + [TestCase(0x1F, "^_")] + [TestCase(0x7F, "^?")] + public void Format_CaretStyle_ReturnsCaretNotation (int codepoint, string expected) + { + string actual = ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.Caret); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(0x00, "\\0")] + [TestCase(0x07, "\\a")] + [TestCase(0x08, "\\b")] + [TestCase(0x09, "\\t")] + [TestCase(0x0A, "\\n")] + [TestCase(0x0B, "\\v")] + [TestCase(0x0C, "\\f")] + [TestCase(0x0D, "\\r")] + public void Format_CEscape_ExplicitTable_ReturnsBackslashLetter (int codepoint, string expected) + { + string actual = ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.CEscape); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(0x01, "\\x01")] + [TestCase(0x1F, "\\x1F")] + [TestCase(0x7F, "\\x7F")] + public void Format_CEscape_Fallback_ReturnsHexEscape (int codepoint, string expected) + { + string actual = ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.CEscape); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(0x00, "NUL")] + [TestCase(0x01, "SOH")] + [TestCase(0x02, "STX")] + [TestCase(0x03, "ETX")] + [TestCase(0x04, "EOT")] + [TestCase(0x05, "ENQ")] + [TestCase(0x06, "ACK")] + [TestCase(0x07, "BEL")] + [TestCase(0x08, "BS")] + [TestCase(0x09, "HT")] + [TestCase(0x0A, "LF")] + [TestCase(0x0B, "VT")] + [TestCase(0x0C, "FF")] + [TestCase(0x0D, "CR")] + [TestCase(0x0E, "SO")] + [TestCase(0x0F, "SI")] + [TestCase(0x10, "DLE")] + [TestCase(0x11, "DC1")] + [TestCase(0x12, "DC2")] + [TestCase(0x13, "DC3")] + [TestCase(0x14, "DC4")] + [TestCase(0x15, "NAK")] + [TestCase(0x16, "SYN")] + [TestCase(0x17, "ETB")] + [TestCase(0x18, "CAN")] + [TestCase(0x19, "EM")] + [TestCase(0x1A, "SUB")] + [TestCase(0x1B, "ESC")] + [TestCase(0x1C, "FS")] + [TestCase(0x1D, "GS")] + [TestCase(0x1E, "RS")] + [TestCase(0x1F, "US")] + [TestCase(0x7F, "DEL")] + public void Format_Abbreviation_ReturnsMnemonic (int codepoint, string expected) + { + string actual = ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.Abbreviation); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(0x00, "\u2400")] + [TestCase(0x07, "\u2407")] + [TestCase(0x1F, "\u241F")] + [TestCase(0x7F, "\u2421")] + public void Format_ControlPictures_ReturnsU24xxGlyph (int codepoint, string expected) + { + string actual = ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.ControlPictures); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Format_Iso2047_CoveredCharacter_ReturnsIso2047Glyph () + { + // DEL has a distinct ISO 2047 representation (U+2425, "SYMBOL FOR DELETE FORM TWO"), + // separate from the Control Pictures glyph for DEL (U+2421). + string actual = ControlCharStyleFormatter.Format(0x7F, ControlCharStyle.Iso2047); + Assert.That(actual, Is.EqualTo("\u2425")); + } + + [Test] + public void Format_Iso2047_UncoveredCharacter_FallsBackToControlPictures () + { + string actual = ControlCharStyleFormatter.Format(0x07, ControlCharStyle.Iso2047); + Assert.That(actual, Is.EqualTo("\u2407")); + } + + [TestCase(0x20)] + [TestCase(0x41)] + [TestCase(0x80)] + [TestCase(-1)] + public void Format_OutOfScopeCodepoint_Throws (int codepoint) + { + Assert.Throws( + () => ControlCharStyleFormatter.Format(codepoint, ControlCharStyle.ControlPictures)); + } +} diff --git a/src/LogExpert.Tests/ControlCharDisplay/SubstitutedHighlightSegmenterTests.cs b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedHighlightSegmenterTests.cs new file mode 100644 index 00000000..a24e2e94 --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedHighlightSegmenterTests.cs @@ -0,0 +1,282 @@ +using System.Collections.Generic; +using System.Drawing; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Config; +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class SubstitutedHighlightSegmenterTests +{ + private static HighlightEntry Ground (Color fore, Color back) => new() + { + ForegroundColor = fore, + BackgroundColor = back, + }; + + [Test] + public void Combine_NoHighlights_AllRaw_ReturnsSingleGroundStyleSegment () + { + const string raw = "hello"; + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings(); + var renderSegments = new List + { + new(0, raw.Length, raw, false), + }; + var highlightMatches = new List(); + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, highlightMatches, ground, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].RenderedText, Is.EqualTo("hello")); + Assert.That(segments[0].ForeColor, Is.EqualTo(Color.Black)); + Assert.That(segments[0].BackColor, Is.EqualTo(Color.White)); + Assert.That(segments[0].IsSubstituted, Is.False); + } + + [Test] + public void Combine_OneSubstitutedSegment_UsesSubstitutionStyleAndAtomic () + { + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings + { + ForeColor = Color.Gray, + }; + // Source: "\u0001" — one char, substituted to "^A" (Caret style) + var renderSegments = new List + { + new(0, 1, "^A", true), + }; + settings.Style = ControlCharStyle.Caret; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, [], ground, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[0].ForeColor, Is.EqualTo(Color.Gray)); + Assert.That(segments[0].IsSubstituted, Is.True); + } + + [Test] + public void Combine_WordHighlightInsideRaw_SplitsRawIntoMultiplePaintSegments () + { + // Raw: "hello world", word match "world" at 6..10 (length 5, IsWordMatch=true) + const string raw = "hello world"; + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings(); + var renderSegments = new List + { + new(0, raw.Length, raw, false), + }; + var wordHighlight = new HighlightEntry + { + ForegroundColor = Color.Yellow, + BackgroundColor = Color.Red, + IsWordMatch = true, + }; + var matches = new List + { + new() { StartPos = 6, Length = 5, HighlightEntry = wordHighlight }, + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, matches, ground, settings); + + Assert.That(segments, Has.Count.EqualTo(2)); + Assert.That(segments[0].RenderedText, Is.EqualTo("hello ")); + Assert.That(segments[0].ForeColor, Is.EqualTo(Color.Black)); + Assert.That(segments[1].RenderedText, Is.EqualTo("world")); + Assert.That(segments[1].ForeColor, Is.EqualTo(Color.Yellow)); + Assert.That(segments[1].BackColor, Is.EqualTo(Color.Red)); + } + + [Test] + public void Combine_WordHighlightCoversSubstitutedCharacter_BackgroundFromHighlight_ForegroundFromSettings () + { + // Raw: "\u0001" — single substituted char, fully covered by a word highlight. + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings { ForeColor = Color.Gray }; + var renderSegments = new List + { + new(0, 1, "\u2401", true), + }; + var wordHighlight = new HighlightEntry + { + ForegroundColor = Color.Yellow, + BackgroundColor = Color.Red, + IsWordMatch = true, + }; + var matches = new List + { + new() { StartPos = 0, Length = 1, HighlightEntry = wordHighlight }, + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, matches, ground, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].IsSubstituted, Is.True); + Assert.That(segments[0].ForeColor, Is.EqualTo(Color.Gray)); + Assert.That(segments[0].BackColor, Is.EqualTo(Color.Red)); + } + + [Test] + public void Combine_WordHighlightStraddlesSubstitutionBoundary_SplitsCorrectly () + { + // Raw: "a\u0001b" → render segments: raw "a"(0), sub "^A"(1), raw "b"(2) + // Word highlight covers positions 1..2 (substituted + trailing raw). + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings { ForeColor = Color.Gray }; + var renderSegments = new List + { + new(0, 1, "a", false), + new(1, 1, "^A", true), + new(2, 1, "b", false), + }; + var wordHighlight = new HighlightEntry + { + ForegroundColor = Color.Yellow, + BackgroundColor = Color.Red, + IsWordMatch = true, + }; + var matches = new List + { + new() { StartPos = 1, Length = 2, HighlightEntry = wordHighlight }, + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, matches, ground, settings); + + Assert.That(segments, Has.Count.EqualTo(3)); + // Raw "a" — ground style + Assert.That(segments[0].RenderedText, Is.EqualTo("a")); + Assert.That(segments[0].ForeColor, Is.EqualTo(Color.Black)); + // Substituted — sub fore, highlight back + Assert.That(segments[1].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[1].IsSubstituted, Is.True); + Assert.That(segments[1].ForeColor, Is.EqualTo(Color.Gray)); + Assert.That(segments[1].BackColor, Is.EqualTo(Color.Red)); + // Raw "b" — highlight style + Assert.That(segments[2].RenderedText, Is.EqualTo("b")); + Assert.That(segments[2].ForeColor, Is.EqualTo(Color.Yellow)); + Assert.That(segments[2].BackColor, Is.EqualTo(Color.Red)); + } + + [Test] + public void Combine_AdjacentSubstitutedSegments_StayAtomicEvenWithIdenticalStyle () + { + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings { ForeColor = Color.Gray }; + var renderSegments = new List + { + new(0, 1, "^A", true), + new(1, 1, "^B", true), + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, [], ground, settings); + + Assert.That(segments, Has.Count.EqualTo(2)); + Assert.That(segments[0].RenderedText, Is.EqualTo("^A")); + Assert.That(segments[1].RenderedText, Is.EqualTo("^B")); + Assert.That(segments[0].IsSubstituted, Is.True); + Assert.That(segments[1].IsSubstituted, Is.True); + } + + [Test] + public void Combine_BoldItalicFromSettings_PropagateToSubstitutedSegment () + { + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings + { + ForeColor = Color.Gray, + Bold = true, + Italic = true, + }; + var renderSegments = new List + { + new(0, 1, "^A", true), + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, [], ground, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].IsBold, Is.True); + Assert.That(segments[0].IsItalic, Is.True); + } + + [Test] + public void Combine_HighlightWithNoBackgroundCoversSubstituted_FallsBackToSettingsBackColor () + { + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings + { + ForeColor = Color.Gray, + BackColor = Color.LightBlue, + }; + var renderSegments = new List + { + new(0, 1, "^A", true), + }; + var noBgHighlight = new HighlightEntry + { + ForegroundColor = Color.Yellow, + BackgroundColor = Color.Red, + NoBackground = true, + IsWordMatch = true, + }; + var matches = new List + { + new() { StartPos = 0, Length = 1, HighlightEntry = noBgHighlight }, + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, matches, ground, settings); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0].IsSubstituted, Is.True); + Assert.That(segments[0].BackColor, Is.EqualTo(Color.LightBlue)); + Assert.That(segments[0].NoBackground, Is.False); + } + + [Test] + public void Combine_SourceIndexIntegrity_ConcatenatedRenderedTextEqualsRendererConcatenation () + { + // Mixed input with raw + substituted + raw, plus a straddling highlight. + var ground = Ground(Color.Black, Color.White); + var settings = new ControlCharSettings { ForeColor = Color.Gray }; + var renderSegments = new List + { + new(0, 3, "abc", false), + new(3, 1, "^A", true), + new(4, 2, "de", false), + new(6, 1, "^B", true), + new(7, 1, "f", false), + }; + var wordHighlight = new HighlightEntry + { + ForegroundColor = Color.Yellow, + BackgroundColor = Color.Red, + IsWordMatch = true, + }; + var matches = new List + { + new() { StartPos = 2, Length = 4, HighlightEntry = wordHighlight }, + }; + + IReadOnlyList segments = + SubstitutedHighlightSegmenter.Combine(renderSegments, matches, ground, settings); + + var rendererConcat = string.Concat(System.Linq.Enumerable.Select(renderSegments, s => s.RenderedText)); + var segmenterConcat = string.Concat(System.Linq.Enumerable.Select(segments, s => s.RenderedText)); + Assert.That(segmenterConcat, Is.EqualTo(rendererConcat)); + } +} diff --git a/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs b/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs new file mode 100644 index 00000000..7c43d45a --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +using LogExpert.Core.Config; + +namespace LogExpert.UI.ControlCharDisplay; + +internal static class ControlCharRenderer +{ + public static IReadOnlyList Render (string raw, ControlCharSettings settings) + { + if (raw.Length == 0) + { + return []; + } + + if (!settings.Substitute) + { + return [new RenderSegment(0, raw.Length, raw, false)]; + } + + var segments = new List(); + int rawRunStart = 0; + + for (int i = 0; i < raw.Length; i++) + { + int codepoint = raw[i]; + if (!settings.EnabledCodepoints.Contains(codepoint)) + { + continue; + } + + if (i > rawRunStart) + { + segments.Add(new RenderSegment( + rawRunStart, + i - rawRunStart, + raw.Substring(rawRunStart, i - rawRunStart), + false)); + } + + string rendered = ControlCharStyleFormatter.Format(codepoint, settings.Style); + segments.Add(new RenderSegment(i, 1, rendered, true)); + rawRunStart = i + 1; + } + + if (rawRunStart < raw.Length) + { + segments.Add(new RenderSegment( + rawRunStart, + raw.Length - rawRunStart, + raw[rawRunStart..], + false)); + } + + return segments; + } +} diff --git a/src/LogExpert.UI/ControlCharDisplay/ControlCharStyleFormatter.cs b/src/LogExpert.UI/ControlCharDisplay/ControlCharStyleFormatter.cs new file mode 100644 index 00000000..1c544ba1 --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/ControlCharStyleFormatter.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using LogExpert.Core.Config; + +namespace LogExpert.UI.ControlCharDisplay; + +internal static class ControlCharStyleFormatter +{ + private static readonly IReadOnlyDictionary CEscapeExplicit = new Dictionary + { + [0x00] = "\\0", + [0x07] = "\\a", + [0x08] = "\\b", + [0x09] = "\\t", + [0x0A] = "\\n", + [0x0B] = "\\v", + [0x0C] = "\\f", + [0x0D] = "\\r", + }; + + private static readonly string[] AbbreviationsC0 = + [ + "NUL", "SOH", "STX", "ETX", "EOT", "ENQ", "ACK", "BEL", + "BS", "HT", "LF", "VT", "FF", "CR", "SO", "SI", + "DLE", "DC1", "DC2", "DC3", "DC4", "NAK", "SYN", "ETB", + "CAN", "EM", "SUB", "ESC", "FS", "GS", "RS", "US", + ]; + + // ISO 2047 defines pictographic glyphs for C0 control codes. Most are not present in + // standard Unicode as distinct codepoints, so this table is sparse: only entries with a + // distinct Unicode representation that differs from the U+2400 Control Pictures block + // are listed. Everything not in this table falls back to the Control Pictures glyph. + private static readonly IReadOnlyDictionary Iso2047Glyphs = new Dictionary + { + [0x7F] = "\u2425", // SYMBOL FOR DELETE FORM TWO + }; + + public static string Format (int codepoint, ControlCharStyle style) + { + if (!IsInScope(codepoint)) + { + throw new ArgumentOutOfRangeException( + nameof(codepoint), + codepoint, + "Codepoint must be in C0 range (0x00..0x1F) or DEL (0x7F)."); + } + + return style switch + { + ControlCharStyle.Caret => FormatCaret(codepoint), + ControlCharStyle.CEscape => FormatCEscape(codepoint), + ControlCharStyle.Abbreviation => FormatAbbreviation(codepoint), + ControlCharStyle.ControlPictures => FormatControlPictures(codepoint), + ControlCharStyle.Iso2047 => FormatIso2047(codepoint), + _ => throw new ArgumentOutOfRangeException(nameof(style), style, "Style not implemented."), + }; + } + + private static bool IsInScope (int codepoint) + { + return (codepoint >= 0x00 && codepoint <= 0x1F) || codepoint == 0x7F; + } + + private static string FormatCaret (int codepoint) + { + if (codepoint == 0x7F) + { + return "^?"; + } + + return "^" + (char)(codepoint + 0x40); + } + + private static string FormatCEscape (int codepoint) + { + if (CEscapeExplicit.TryGetValue(codepoint, out string? explicitEscape)) + { + return explicitEscape; + } + + return "\\x" + codepoint.ToString("X2", CultureInfo.InvariantCulture); + } + + private static string FormatAbbreviation (int codepoint) + { + if (codepoint == 0x7F) + { + return "DEL"; + } + + return AbbreviationsC0[codepoint]; + } + + private static string FormatControlPictures (int codepoint) + { + if (codepoint == 0x7F) + { + return "\u2421"; + } + + return ((char)(0x2400 + codepoint)).ToString(); + } + + private static string FormatIso2047 (int codepoint) + { + if (Iso2047Glyphs.TryGetValue(codepoint, out string? glyph)) + { + return glyph; + } + + return FormatControlPictures(codepoint); + } +} diff --git a/src/LogExpert.UI/ControlCharDisplay/PaintSegment.cs b/src/LogExpert.UI/ControlCharDisplay/PaintSegment.cs new file mode 100644 index 00000000..388eb25d --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/PaintSegment.cs @@ -0,0 +1,12 @@ +using System.Drawing; + +namespace LogExpert.UI.ControlCharDisplay; + +internal readonly record struct PaintSegment( + string RenderedText, + Color ForeColor, + Color BackColor, + bool IsBold, + bool IsItalic, + bool NoBackground, + bool IsSubstituted); diff --git a/src/LogExpert.UI/ControlCharDisplay/RenderSegment.cs b/src/LogExpert.UI/ControlCharDisplay/RenderSegment.cs new file mode 100644 index 00000000..0ed237f3 --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/RenderSegment.cs @@ -0,0 +1,7 @@ +namespace LogExpert.UI.ControlCharDisplay; + +internal readonly record struct RenderSegment( + int SourceStart, + int SourceLength, + string RenderedText, + bool IsSubstituted); diff --git a/src/LogExpert.UI/ControlCharDisplay/SubstitutedHighlightSegmenter.cs b/src/LogExpert.UI/ControlCharDisplay/SubstitutedHighlightSegmenter.cs new file mode 100644 index 00000000..b149a881 --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/SubstitutedHighlightSegmenter.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Drawing; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Config; + +namespace LogExpert.UI.ControlCharDisplay; + +internal static class SubstitutedHighlightSegmenter +{ + /// + /// Combines the render-segment list from with the + /// highlight match list to produce a paint-ready sequence of s, + /// where each substituted render segment becomes an atomic paint segment whose + /// foreground/font derive from and whose background is + /// inherited from the controlling highlight entry. + /// + public static IReadOnlyList Combine ( + IReadOnlyList renderSegments, + IEnumerable highlightMatches, + HighlightEntry groundEntry, + ControlCharSettings settings) + { + // Total source length is the sum of source lengths in the render segments. + int totalSourceLength = 0; + for (int i = 0; i < renderSegments.Count; i++) + { + totalSourceLength += renderSegments[i].SourceLength; + } + + // Build the per-source-char controlling-highlight array (mirrors the existing + // MergeHighlightMatchEntries algorithm — ground entry by default, overwritten by + // word-mode matches). + var perChar = new HighlightEntry[totalSourceLength]; + for (int i = 0; i < perChar.Length; i++) + { + perChar[i] = groundEntry; + } + + foreach (var match in highlightMatches) + { + if (!match.HighlightEntry.IsWordMatch) + { + continue; + } + + int end = match.StartPos + match.Length; + for (int i = match.StartPos; i < end && i < perChar.Length; i++) + { + perChar[i] = match.HighlightEntry; + } + } + + var result = new List(); + foreach (var seg in renderSegments) + { + if (seg.IsSubstituted) + { + result.Add(BuildSubstitutedSegment(seg, perChar, settings)); + } + else + { + EmitRawSegments(seg, perChar, result); + } + } + + return result; + } + + private static PaintSegment BuildSubstitutedSegment ( + RenderSegment seg, + HighlightEntry[] perChar, + ControlCharSettings settings) + { + var controlling = perChar[seg.SourceStart]; + // When the controlling highlight suppresses background painting, fall back to the + // substitution settings background. Otherwise inherit the highlight background so + // a substituted glyph sits inside a coherent highlight band. + bool useHighlightBack = !controlling.NoBackground && controlling.BackgroundColor != Color.Empty; + var backColor = useHighlightBack ? controlling.BackgroundColor : settings.BackColor; + bool noBackground = !useHighlightBack && settings.BackColor == Color.Empty; + return new PaintSegment( + RenderedText: seg.RenderedText, + ForeColor: settings.ForeColor, + BackColor: backColor, + IsBold: settings.Bold, + IsItalic: settings.Italic, + NoBackground: noBackground, + IsSubstituted: true); + } + + private static void EmitRawSegments ( + RenderSegment seg, + HighlightEntry[] perChar, + List result) + { + int start = seg.SourceStart; + int end = seg.SourceStart + seg.SourceLength; + int runStart = start; + var runEntry = perChar[start]; + + for (int pos = start + 1; pos < end; pos++) + { + if (!ReferenceEquals(perChar[pos], runEntry)) + { + result.Add(BuildRawSegment(seg, runStart, pos, runEntry)); + runStart = pos; + runEntry = perChar[pos]; + } + } + + result.Add(BuildRawSegment(seg, runStart, end, runEntry)); + } + + private static PaintSegment BuildRawSegment ( + RenderSegment seg, + int absoluteStart, + int absoluteEnd, + HighlightEntry controlling) + { + // RenderedText for raw segments is the slice of the original raw input that the + // RenderSegment captured. Slice by the relative offset. + int relStart = absoluteStart - seg.SourceStart; + int length = absoluteEnd - absoluteStart; + string text = length == seg.SourceLength + ? seg.RenderedText + : seg.RenderedText.Substring(relStart, length); + + return new PaintSegment( + RenderedText: text, + ForeColor: controlling.ForegroundColor, + BackColor: controlling.BackgroundColor, + IsBold: controlling.IsBold, + IsItalic: false, + NoBackground: controlling.NoBackground, + IsSubstituted: false); + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index eb838d32..879665c9 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -22,6 +22,7 @@ using LogExpert.Core.EventArguments; using LogExpert.Core.Interfaces; using LogExpert.Dialogs; +using LogExpert.UI.ControlCharDisplay; using LogExpert.UI.Dialogs; using LogExpert.UI.Entities; using LogExpert.UI.Extensions; @@ -3499,10 +3500,18 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh hme.HighlightEntry.IsBold = groundEntry.IsBold; } - matchList = MergeHighlightMatchEntries(matchList, hme); - - //var leftPad = e.CellStyle.Padding.Left; - //RectangleF rect = new(e.CellBounds.Left + leftPad, e.CellBounds.Top, e.CellBounds.Width, e.CellBounds.Height); + // Build render segments (substitution-aware) and combine with the highlight match + // list to produce paint-ready segments. When ControlCharSettings.Substitute is + // false the renderer returns a single raw segment, so this path produces the same + // visual output as the legacy MergeHighlightMatchEntries-based loop. + var controlCharSettings = Preferences.ControlCharSettings ?? new Core.Config.ControlCharSettings(); + var rawText = column.DisplayValue.ToString(); + var renderSegments = ControlCharRenderer.Render(rawText, controlCharSettings); + var paintSegments = SubstitutedHighlightSegmenter.Combine( + renderSegments, + matchList, + hme.HighlightEntry, + controlCharSettings); var borderWidths = PaintHelper.BorderWidths(e.AdvancedBorderStyle); var valBounds = e.CellBounds; @@ -3525,31 +3534,29 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh | TextFormatFlags.VerticalCenter | TextFormatFlags.TextBoxControl; - // | TextFormatFlags.VerticalCenter - // | TextFormatFlags.TextBoxControl - // TextFormatFlags.SingleLine - - //TextRenderer.DrawText(e.Graphics, e.Value as String, e.CellStyle.Font, valBounds, Color.FromKnownColor(KnownColor.Black), flags); - var wordPos = valBounds.Location; Size proposedSize = new(valBounds.Width, valBounds.Height); e.Graphics.SetClip(e.CellBounds); - foreach (var matchEntry in matchList) + foreach (var segment in paintSegments) { - var font = matchEntry != null && matchEntry.HighlightEntry.IsBold ? BoldFont : NormalFont; + var font = segment.IsBold ? BoldFont : NormalFont; + // Italic only applies to substituted glyphs; raw runs never set it. + if (segment.IsItalic) + { + font = new Font(font, font.Style | FontStyle.Italic); + } - using var bgBrush = matchEntry.HighlightEntry.BackgroundColor != Color.Empty - ? new SolidBrush(matchEntry.HighlightEntry.BackgroundColor) + using var bgBrush = segment.BackColor != Color.Empty + ? new SolidBrush(segment.BackColor) : null; - var matchWord = column.DisplayValue.Slice(matchEntry.StartPos, matchEntry.Length); - var wordSize = TextRenderer.MeasureText(e.Graphics, matchWord.ToString(), font, proposedSize, flags); + var wordSize = TextRenderer.MeasureText(e.Graphics, segment.RenderedText, font, proposedSize, flags); wordSize.Height = e.CellBounds.Height; Rectangle wordRect = new(wordPos, wordSize); - var foreColor = matchEntry.HighlightEntry.ForegroundColor; + var foreColor = segment.ForeColor; if (e.State.HasFlag(DataGridViewElementStates.Selected)) { if (foreColor.Equals(Color.Black)) @@ -3559,14 +3566,19 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh } else { - if (bgBrush != null && !matchEntry.HighlightEntry.NoBackground) + if (bgBrush != null && !segment.NoBackground) { e.Graphics.FillRectangle(bgBrush, wordRect); } } - TextRenderer.DrawText(e.Graphics, matchWord.ToString(), font, wordRect, foreColor, flags); + TextRenderer.DrawText(e.Graphics, segment.RenderedText, font, wordRect, foreColor, flags); wordPos.Offset(wordSize.Width, 0); + + if (segment.IsItalic) + { + font.Dispose(); + } } } From 13e981b5be3c0e73a1dc41947c1b57a4d4d44e94 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 20 May 2026 17:25:20 +0200 Subject: [PATCH 3/7] update tests after font change --- src/LogExpert.Tests/Services/ToolWindowCoordinatorTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/LogExpert.Tests/Services/ToolWindowCoordinatorTests.cs b/src/LogExpert.Tests/Services/ToolWindowCoordinatorTests.cs index 92edd3df..b1ba8cb7 100644 --- a/src/LogExpert.Tests/Services/ToolWindowCoordinatorTests.cs +++ b/src/LogExpert.Tests/Services/ToolWindowCoordinatorTests.cs @@ -34,6 +34,11 @@ public void Setup () _configManagerMock = new Mock(); _settings = new Settings(); + + // Materialize Font from FontString (mirrors ConfigManager.InitializeFont) + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(Font)); + _settings.Preferences.Font = (Font)converter.ConvertFromInvariantString(_settings.Preferences.FontString)!; + _ = _configManagerMock.Setup(cm => cm.Settings).Returns(_settings); _coordinator = new ToolWindowCoordinator(_configManagerMock.Object); From b5d2f2b9db0fee15a5dcdcd657e9a7214127c0a3 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 20 May 2026 17:30:50 +0200 Subject: [PATCH 4/7] updates --- .../ControlCharRendererTests.cs | 21 +++ .../ControlCharDisplay/ControlCharRenderer.cs | 24 ++++ .../Controls/LogWindow/LogWindow.cs | 126 ++++++++++++++++-- 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs b/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs index 6f027d64..bd8157ed 100644 --- a/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs +++ b/src/LogExpert.Tests/ControlCharDisplay/ControlCharRendererTests.cs @@ -233,4 +233,25 @@ public void Render_SelectiveOptIn_LeavesDisabledCodepointAsRaw () Assert.That(segments[2].RenderedText, Is.EqualTo("c")); Assert.That(segments[2].SourceStart, Is.EqualTo(4)); } + + [Test] + public void HasAnyEnabledCodepoint_NoMatch_ReturnsFalse () + { + var enabled = new HashSet { 0x01, 0x07 }; + Assert.That(ControlCharRenderer.HasAnyEnabledCodepoint("plain text", enabled), Is.False); + } + + [Test] + public void HasAnyEnabledCodepoint_OneMatch_ReturnsTrue () + { + var enabled = new HashSet { 0x01 }; + Assert.That(ControlCharRenderer.HasAnyEnabledCodepoint("a\u0001b", enabled), Is.True); + } + + [Test] + public void HasAnyEnabledCodepoint_EmptyOrNullSet_ReturnsFalse () + { + Assert.That(ControlCharRenderer.HasAnyEnabledCodepoint("a\u0001b", []), Is.False); + Assert.That(ControlCharRenderer.HasAnyEnabledCodepoint("a\u0001b", null!), Is.False); + } } diff --git a/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs b/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs index 7c43d45a..a051839c 100644 --- a/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs +++ b/src/LogExpert.UI/ControlCharDisplay/ControlCharRenderer.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using LogExpert.Core.Config; @@ -54,4 +55,27 @@ public static IReadOnlyList Render (string raw, ControlCharSettin return segments; } + + /// + /// Cheap pre-scan used by paint code to decide whether to take the substitution path. + /// Returns true when at least one character in is contained in + /// . + /// + public static bool HasAnyEnabledCodepoint (ReadOnlySpan text, HashSet enabledCodepoints) + { + if (enabledCodepoints is null || enabledCodepoints.Count == 0) + { + return false; + } + + foreach (char c in text) + { + if (enabledCodepoints.Contains(c)) + { + return true; + } + } + + return false; + } } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 879665c9..83ccb908 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -3443,6 +3443,7 @@ private void AutoResizeColumns (BufferedDataGridView gridView) try { gridView.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.DisplayedCells); + AdjustColumnWidthsForControlCharSubstitution(gridView); if (gridView.Columns.Count > 1 && Preferences.SetLastColumnWidth && gridView.Columns[gridView.Columns.Count - 1].Width < Preferences.LastColumnWidth ) @@ -3462,6 +3463,76 @@ private void AutoResizeColumns (BufferedDataGridView gridView) } } + /// + /// DataGridView's built-in auto-resize measures the cell Value (raw text). When + /// control-character substitution is enabled, substituted glyphs render wider than the + /// original 1-character control bytes, so columns under-measure and clip. This routine + /// walks the displayed rows, measures the rendered (post-substitution) string for each + /// cell, and grows the column width when needed. + /// + private void AdjustColumnWidthsForControlCharSubstitution (BufferedDataGridView gridView) + { + var settings = Preferences.ControlCharSettings; + if (settings is null || !settings.Substitute || settings.EnabledCodepoints is null || settings.EnabledCodepoints.Count == 0) + { + return; + } + + int firstRow = gridView.FirstDisplayedScrollingRowIndex; + if (firstRow < 0) + { + return; + } + + int displayed = gridView.DisplayedRowCount(true); + if (displayed <= 0) + { + return; + } + + int lastRow = Math.Min(firstRow + displayed, gridView.RowCount) - 1; + + for (int colIndex = 0; colIndex < gridView.ColumnCount; colIndex++) + { + var gridColumn = gridView.Columns[colIndex]; + int requiredWidth = gridColumn.Width; + + for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) + { + var cellValue = gridView.Rows[rowIndex].Cells[colIndex].Value; + if (cellValue is not IColumnMemory mem || mem.DisplayValue.IsEmpty) + { + continue; + } + + if (!ControlCharRenderer.HasAnyEnabledCodepoint(mem.DisplayValue.Span, settings.EnabledCodepoints)) + { + continue; + } + + var rendered = ControlCharRenderer.Render(mem.DisplayValue.ToString(), settings); + var sb = new System.Text.StringBuilder(mem.DisplayValue.Length + 8); + foreach (var seg in rendered) + { + _ = sb.Append(seg.RenderedText); + } + + var size = TextRenderer.MeasureText(sb.ToString(), NormalFont); + // Match the padding DataGridView uses for DisplayedCells auto-size. + int candidate = size.Width + 9; + if (candidate > requiredWidth) + { + requiredWidth = candidate; + } + } + + if (requiredWidth > gridColumn.Width) + { + gridColumn.Width = requiredWidth; + } + } + } + private void PaintCell (DataGridViewCellPaintingEventArgs e, HighlightEntry groundEntry) { PaintHighlightedCell(e, groundEntry); @@ -3500,18 +3571,28 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh hme.HighlightEntry.IsBold = groundEntry.IsBold; } - // Build render segments (substitution-aware) and combine with the highlight match - // list to produce paint-ready segments. When ControlCharSettings.Substitute is - // false the renderer returns a single raw segment, so this path produces the same - // visual output as the legacy MergeHighlightMatchEntries-based loop. + // Decide path: take the cheap legacy merge when substitution is disabled or the cell + // contains no enabled control character (the dominant case). Otherwise build + // render segments and combine them with the highlight match list. var controlCharSettings = Preferences.ControlCharSettings ?? new Core.Config.ControlCharSettings(); - var rawText = column.DisplayValue.ToString(); - var renderSegments = ControlCharRenderer.Render(rawText, controlCharSettings); - var paintSegments = SubstitutedHighlightSegmenter.Combine( - renderSegments, - matchList, - hme.HighlightEntry, - controlCharSettings); + bool useSubstitutionPath = controlCharSettings.Substitute + && ControlCharRenderer.HasAnyEnabledCodepoint(column.DisplayValue.Span, controlCharSettings.EnabledCodepoints); + + IReadOnlyList paintSegments; + if (useSubstitutionPath) + { + var rawText = column.DisplayValue.ToString(); + var renderSegments = ControlCharRenderer.Render(rawText, controlCharSettings); + paintSegments = SubstitutedHighlightSegmenter.Combine( + renderSegments, + matchList, + hme.HighlightEntry, + controlCharSettings); + } + else + { + paintSegments = ToPaintSegments(MergeHighlightMatchEntries(matchList, hme), column.DisplayValue); + } var borderWidths = PaintHelper.BorderWidths(e.AdvancedBorderStyle); var valBounds = e.CellBounds; @@ -3582,6 +3663,29 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh } } + /// + /// Adapter that converts the legacy merged highlight-match list (produced by + /// ) into a list so + /// the unified paint loop can render it without a separate code path. + /// + private static IReadOnlyList ToPaintSegments (IList mergedMatches, ReadOnlyMemory raw) + { + var result = new List(mergedMatches.Count); + foreach (var me in mergedMatches) + { + result.Add(new PaintSegment( + RenderedText: raw.Slice(me.StartPos, me.Length).ToString(), + ForeColor: me.HighlightEntry.ForegroundColor, + BackColor: me.HighlightEntry.BackgroundColor, + IsBold: me.HighlightEntry.IsBold, + IsItalic: false, + NoBackground: me.HighlightEntry.NoBackground, + IsSubstituted: false)); + } + + return result; + } + /// /// Builds a list of HighlightMatchEntry objects. A HighlightMatchEntry spans over a region that is painted with the /// same foreground and background colors. All regions which don't match a word-mode entry will be painted with the From 46629ff4546bfdbd0793c8def208417177beb37f Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 20 May 2026 17:39:12 +0200 Subject: [PATCH 5/7] helper class for font --- .../UI/LogTabWindowResourceTests.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs b/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs index f4f4e39f..e1c755ac 100644 --- a/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs +++ b/src/LogExpert.Tests/UI/LogTabWindowResourceTests.cs @@ -14,6 +14,19 @@ namespace LogExpert.Tests.UI; [Apartment(ApartmentState.STA)] // Required for WinForms components public class LogTabWindowResourceTests { + /// + /// Creates a instance with materialized + /// from , mirroring what ConfigManager.InitializeFont + /// does at runtime. + /// + private static Settings CreateSettings () + { + var settings = new Settings(); + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(Font)); + settings.Preferences.Font = (Font)converter.ConvertFromInvariantString(settings.Preferences.FontString)!; + return settings; + } + [Test] [Category("Resource")] [SupportedOSPlatform("windows")] @@ -22,7 +35,7 @@ public void Dispose_DisposesAllGdiResources () { // Arrange var mockConfigManager = new Mock(); - _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + _ = mockConfigManager.Setup(m => m.Settings).Returns(CreateSettings()); // Create the window using the factory method ILogTabWindow? window = null; @@ -84,7 +97,7 @@ public void Constructor_InitializesSuccessfully () { // Arrange var mockConfigManager = new Mock(); - _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + _ = mockConfigManager.Setup(m => m.Settings).Returns(CreateSettings()); ILogTabWindow? window = null; @@ -132,7 +145,7 @@ public void MultipleCreateDispose_DoesNotLeakResources () { // Arrange var mockConfigManager = new Mock(); - _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + _ = mockConfigManager.Setup(m => m.Settings).Returns(CreateSettings()); var exceptions = new List(); @@ -182,7 +195,7 @@ public void Dispose_CanBeCalledMultipleTimes () { // Arrange var mockConfigManager = new Mock(); - _ = mockConfigManager.Setup(m => m.Settings).Returns(new Settings()); + _ = mockConfigManager.Setup(m => m.Settings).Returns(CreateSettings()); var window = AbstractLogTabWindow.Create( [], From b3e41a230b95fe6b7e0e88d19301689ee26cb8a9 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Thu, 21 May 2026 13:48:00 +0200 Subject: [PATCH 6/7] fixes ui --- src/LogExpert.Resources/Resources.Designer.cs | 207 +++++++++ src/LogExpert.Resources/Resources.de.resx | 69 +++ src/LogExpert.Resources/Resources.resx | 69 +++ src/LogExpert.Resources/Resources.zh-CN.resx | 63 +++ .../ControlCharPresetProviderTests.cs | 57 +++ .../SubstitutedClipboardBuilderTests.cs | 173 ++++++++ .../SubstitutedPixelPositionMapperTests.cs | 199 +++++++++ .../ControlCharPresetProvider.cs | 20 + .../SubstitutedClipboardBuilder.cs | 46 ++ .../SubstitutedPixelPositionMapper.cs | 143 ++++++ .../Controls/LogWindow/LogWindow.cs | 31 +- .../Dialogs/SettingsDialog.Designer.cs | 418 +++++++++++++++++- src/LogExpert.UI/Dialogs/SettingsDialog.cs | 245 +++++++++- src/LogExpert.UI/Dialogs/SettingsDialog.resx | 64 +-- 14 files changed, 1759 insertions(+), 45 deletions(-) create mode 100644 src/LogExpert.Tests/ControlCharDisplay/ControlCharPresetProviderTests.cs create mode 100644 src/LogExpert.Tests/ControlCharDisplay/SubstitutedClipboardBuilderTests.cs create mode 100644 src/LogExpert.Tests/ControlCharDisplay/SubstitutedPixelPositionMapperTests.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/ControlCharPresetProvider.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/SubstitutedClipboardBuilder.cs create mode 100644 src/LogExpert.UI/ControlCharDisplay/SubstitutedPixelPositionMapper.cs diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index be43c9d7..88763f50 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -5095,6 +5095,60 @@ public static string SettingsDialog_UI_Button_buttonConfigPlugin { } } + /// + /// Looks up a localized string similar to . + /// + public static string SettingsDialog_UI_Button_buttonControlCharsBackColor { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsBackColor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (no color). + /// + public static string SettingsDialog_UI_Button_buttonControlCharsBackColorClear { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsBackColorClear", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to . + /// + public static string SettingsDialog_UI_Button_buttonControlCharsForeColor { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsForeColor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable all. + /// + public static string SettingsDialog_UI_Button_buttonControlCharsPresetAll { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsPresetAll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disable all. + /// + public static string SettingsDialog_UI_Button_buttonControlCharsPresetNone { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsPresetNone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Non-whitespace defaults. + /// + public static string SettingsDialog_UI_Button_buttonControlCharsPresetNonWhitespace { + get { + return ResourceManager.GetString("SettingsDialog_UI_Button_buttonControlCharsPresetNonWhitespace", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -5230,6 +5284,42 @@ public static string SettingsDialog_UI_CheckBox_checkBoxColumnSize { } } + /// + /// Looks up a localized string similar to Bold. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxControlCharsBold { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxControlCharsBold", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy displayed form instead of raw characters. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxControlCharsCopyDisplayedForm { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxControlCharsCopyDisplayedForm", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Substitute control characters in display. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxControlCharsEnable { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxControlCharsEnable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Italic. + /// + public static string SettingsDialog_UI_CheckBox_checkBoxControlCharsItalic { + get { + return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxControlCharsItalic", resourceCulture); + } + } + /// /// Looks up a localized string similar to Dark Mode (restart required). /// @@ -5527,6 +5617,33 @@ public static string SettingsDialog_UI_FolderBrowser_folderBrowserWorkingDir { } } + /// + /// Looks up a localized string similar to Appearance. + /// + public static string SettingsDialog_UI_GroupBox_groupBoxControlCharsAppearance { + get { + return ResourceManager.GetString("SettingsDialog_UI_GroupBox_groupBoxControlCharsAppearance", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clipboard. + /// + public static string SettingsDialog_UI_GroupBox_groupBoxControlCharsClipboard { + get { + return ResourceManager.GetString("SettingsDialog_UI_GroupBox_groupBoxControlCharsClipboard", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Display style. + /// + public static string SettingsDialog_UI_GroupBox_groupBoxControlCharsStyle { + get { + return ResourceManager.GetString("SettingsDialog_UI_GroupBox_groupBoxControlCharsStyle", resourceCulture); + } + } + /// /// Looks up a localized string similar to CPU and stuff. /// @@ -5671,6 +5788,42 @@ public static string SettingsDialog_UI_Label_labelArguments { } } + /// + /// Looks up a localized string similar to Background colour:. + /// + public static string SettingsDialog_UI_Label_labelControlCharsBackColor { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelControlCharsBackColor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Foreground colour:. + /// + public static string SettingsDialog_UI_Label_labelControlCharsForeColor { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelControlCharsForeColor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable substitution above to apply.. + /// + public static string SettingsDialog_UI_Label_labelControlCharsHint { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelControlCharsHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Display only — does not modify file contents or affect search, filter, or copy-to-clipboard (unless the option below is enabled). Enabling this can make some log files look unusual.. + /// + public static string SettingsDialog_UI_Label_labelControlCharsWarning { + get { + return ResourceManager.GetString("SettingsDialog_UI_Label_labelControlCharsWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Default encoding. /// @@ -5915,6 +6068,51 @@ public static string SettingsDialog_UI_RadioButton_radioButtonAskWhatToDo { } } + /// + /// Looks up a localized string similar to Abbreviation (<SOH>). + /// + public static string SettingsDialog_UI_RadioButton_radioButtonControlCharStyleAbbreviation { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioButtonControlCharStyleAbbreviation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Caret notation (^X). + /// + public static string SettingsDialog_UI_RadioButton_radioButtonControlCharStyleCaret { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioButtonControlCharStyleCaret", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to C escape sequence (\x01). + /// + public static string SettingsDialog_UI_RadioButton_radioButtonControlCharStyleCEscape { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioButtonControlCharStyleCEscape", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unicode Control Pictures (default). + /// + public static string SettingsDialog_UI_RadioButton_radioButtonControlCharStyleControlPictures { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioButtonControlCharStyleControlPictures", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ISO 2047 graphics. + /// + public static string SettingsDialog_UI_RadioButton_radioButtonControlCharStyleIso2047 { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioButtonControlCharStyleIso2047", resourceCulture); + } + } + /// /// Looks up a localized string similar to Horizontal. /// @@ -6041,6 +6239,15 @@ public static string SettingsDialog_UI_TabPage_tabPageColumnizers { } } + /// + /// Looks up a localized string similar to Control characters. + /// + public static string SettingsDialog_UI_TabPage_tabPageControlCharacters { + get { + return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageControlCharacters", resourceCulture); + } + } + /// /// Looks up a localized string similar to External Tools. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 71acdd17..64fd831d 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -2205,4 +2205,73 @@ LogExpert neu starten, um die Änderungen zu übernehmen? (Vorschau) + + Steuerzeichen + + + Steuerzeichen in der Anzeige ersetzen + + + Nur Anzeige — der Dateiinhalt wird nicht verändert und Suche, Filter sowie Zwischenablage bleiben unberührt (sofern die untenstehende Option nicht aktiviert ist). Manche Logdateien können dadurch ungewohnt aussehen. + + + Aktiviere oben die Ersetzung, damit diese Einstellungen wirken. + + + Anzeigestil + + + Unicode-Steuerzeichen-Bilder (Standard) + + + Caret-Notation (^X) + + + C-Escape-Sequenz (\x01) + + + Abkürzung (<SOH>) + + + ISO-2047-Grafiken + + + Alle aktivieren + + + Alle deaktivieren + + + Nur Nicht-Whitespace + + + Darstellung + + + Vordergrundfarbe: + + + Hintergrundfarbe: + + + + + + + + + (keine Farbe) + + + Fett + + + Kursiv + + + Zwischenablage + + + Angezeigte Form statt der Rohzeichen kopieren + \ No newline at end of file diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 156adced..294f6792 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2219,4 +2219,73 @@ Restart LogExpert to apply changes? Scanning bookmarks finished / canceled! + + Control characters + + + Substitute control characters in display + + + Display only — does not modify file contents or affect search, filter, or copy-to-clipboard (unless the option below is enabled). Enabling this can make some log files look unusual. + + + Enable substitution above to apply. + + + Display style + + + Unicode Control Pictures (default) + + + Caret notation (^X) + + + C escape sequence (\x01) + + + Abbreviation (<SOH>) + + + ISO 2047 graphics + + + Enable all + + + Disable all + + + Non-whitespace defaults + + + Appearance + + + Foreground colour: + + + Background colour: + + + + + + + + + (no color) + + + Bold + + + Italic + + + Clipboard + + + Copy displayed form instead of raw characters + \ No newline at end of file diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 56fc04a6..88b1be25 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -2122,4 +2122,67 @@ YY[YY] = 年 (预览) + + 控制字符 + + + 替换显示中的控制字符 + + + 仅显示 — 不修改文件内容或影响搜索、过滤或复制到剪贴板(除非启用以下选项)。启用此功能可能会使某些日志文件看起来不寻常。 + + + 启用上面的替换以应用。 + + + 展示风格 + + + Unicode 控制图片(默认) + + + 插入符号 (^X) + + + C 转义序列 (\x01) + + + 缩写 (<SOH>) + + + ISO 2047 图形 + + + 全部启用 + + + 全部禁用 + + + 非空白默认值 + + + 外貌 + + + 前景色: + + + 背景颜色: + + + (无颜色) + + + 粗体 + + + 斜体 + + + 剪贴板 + + + 复制显示的形式而不是原始字符 + \ No newline at end of file diff --git a/src/LogExpert.Tests/ControlCharDisplay/ControlCharPresetProviderTests.cs b/src/LogExpert.Tests/ControlCharDisplay/ControlCharPresetProviderTests.cs new file mode 100644 index 00000000..d1786033 --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/ControlCharPresetProviderTests.cs @@ -0,0 +1,57 @@ +using LogExpert.Core.Config; +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class ControlCharPresetProviderTests +{ + [Test] + public void All_ContainsAllC0AndDel_33Entries () + { + var expected = Enumerable.Range(0, 32).Append(0x7F).ToHashSet(); + + Assert.That(ControlCharPresetProvider.All, Is.EquivalentTo(expected)); + Assert.That(ControlCharPresetProvider.All.Count, Is.EqualTo(33)); + } + + [Test] + public void None_IsEmpty () + { + Assert.That(ControlCharPresetProvider.None, Is.Empty); + } + + [Test] + public void NonWhitespaceDefaults_IsAllMinusTabLfCr_30Entries () + { + var expected = Enumerable.Range(0, 32) + .Append(0x7F) + .Where(cp => cp is not 0x09 and not 0x0A and not 0x0D) + .ToHashSet(); + + Assert.That(ControlCharPresetProvider.NonWhitespaceDefaults, Is.EquivalentTo(expected)); + Assert.That(ControlCharPresetProvider.NonWhitespaceDefaults.Count, Is.EqualTo(30)); + } + + [Test] + public void ReturnedSets_AreImmutable () + { + // FrozenSet implements ISet but throws on mutation; verify that contract. + Assert.Multiple(() => + { + _ = Assert.Throws(() => ((ISet)ControlCharPresetProvider.All).Add(99)); + _ = Assert.Throws(() => ((ISet)ControlCharPresetProvider.None).Add(99)); + _ = Assert.Throws(() => ((ISet)ControlCharPresetProvider.NonWhitespaceDefaults).Add(99)); + }); + } + + [Test] + public void NonWhitespaceDefaults_EqualsControlCharSettingsDefault () + { + // Sanity check that the preset matches what a fresh ControlCharSettings yields. + var freshSettings = new ControlCharSettings(); + Assert.That(freshSettings.EnabledCodepoints, Is.EquivalentTo(ControlCharPresetProvider.NonWhitespaceDefaults)); + } +} diff --git a/src/LogExpert.Tests/ControlCharDisplay/SubstitutedClipboardBuilderTests.cs b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedClipboardBuilderTests.cs new file mode 100644 index 00000000..5e81f98c --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedClipboardBuilderTests.cs @@ -0,0 +1,173 @@ +using LogExpert.Core.Config; +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class SubstitutedClipboardBuilderTests +{ + [Test] + public void Build_SubstituteFalse_ReturnsRawSubstring () + { + var settings = new ControlCharSettings + { + Substitute = false, + CopyDisplayedForm = true, // Even with this true, Substitute=false short-circuits. + }; + + var result = SubstitutedClipboardBuilder.Build("hello\u0001world", 0, 11, settings); + + Assert.That(result, Is.EqualTo("hello\u0001world")); + } + + [Test] + public void Build_SubstituteTrue_CopyDisplayedFormFalse_ReturnsRawSubstring () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = false, + Style = ControlCharStyle.Caret, + }; + + var result = SubstitutedClipboardBuilder.Build("a\u0001b", 0, 3, settings); + + Assert.That(result, Is.EqualTo("a\u0001b")); + } + + [Test] + public void Build_CopyDisplayedFormTrue_SelectionWithNoEnabledCodepoints_ReturnsRawSubstring () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + // EnabledCodepoints default preset (non-whitespace C0+DEL). "plain text" has none. + var result = SubstitutedClipboardBuilder.Build("plain text", 0, 10, settings); + + Assert.That(result, Is.EqualTo("plain text")); + } + + [Test] + public void Build_CaretStyle_SelectionContainingOneSubstitutedChar_Interleaves () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + var result = SubstitutedClipboardBuilder.Build("a\u0001b", 0, 3, settings); + + Assert.That(result, Is.EqualTo("a^Ab")); + } + + [TestCase(ControlCharStyle.Caret, "a^Ab")] + [TestCase(ControlCharStyle.CEscape, "a\\x01b")] + [TestCase(ControlCharStyle.Abbreviation, "aSOHb")] + [TestCase(ControlCharStyle.ControlPictures, "a\u2401b")] + [TestCase(ControlCharStyle.Iso2047, "a\u2401b")] + public void Build_AllStyles_ProduceExpectedRenderedText (ControlCharStyle style, string expected) + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = style, + }; + + var result = SubstitutedClipboardBuilder.Build("a\u0001b", 0, 3, settings); + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void Build_SelectionStartsMidRaw_EndsMidRaw_SpansOneSubstituted () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + // Raw "abc\u0001def" — select "c\u0001d" (start=2, len=3). + var result = SubstitutedClipboardBuilder.Build("abc\u0001def", 2, 3, settings); + + Assert.That(result, Is.EqualTo("c^Ad")); + } + + [Test] + public void Build_SelectionStartsOnSubstitutedChar_OutputBeginsWithGlyph () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + // Raw "a\u0001b" — select starting at the SOH (start=1, len=2). + var result = SubstitutedClipboardBuilder.Build("a\u0001b", 1, 2, settings); + + Assert.That(result, Is.EqualTo("^Ab")); + } + + [Test] + public void Build_SelectionEndsOnSubstitutedChar_OutputEndsWithGlyph () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + // Raw "a\u0001b" — select "a\u0001" (start=0, len=2). + var result = SubstitutedClipboardBuilder.Build("a\u0001b", 0, 2, settings); + + Assert.That(result, Is.EqualTo("a^A")); + } + + [Test] + public void Build_ZeroLengthSelection_ReturnsEmptyString () + { + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + Assert.That(SubstitutedClipboardBuilder.Build("anything\u0001", 3, 0, settings), Is.Empty); + } + + [Test] + public void Build_SelectionEqualsFullInput_MatchesRendererConcatenation () + { + const string raw = "abc\u0001def\u0007ghi"; + var settings = new ControlCharSettings + { + Substitute = true, + CopyDisplayedForm = true, + Style = ControlCharStyle.Caret, + }; + + var rendered = ControlCharRenderer.Render(raw, settings); + var expected = new System.Text.StringBuilder(); + foreach (var seg in rendered) + { + _ = expected.Append(seg.RenderedText); + } + + var result = SubstitutedClipboardBuilder.Build(raw, 0, raw.Length, settings); + + Assert.That(result, Is.EqualTo(expected.ToString())); + } +} diff --git a/src/LogExpert.Tests/ControlCharDisplay/SubstitutedPixelPositionMapperTests.cs b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedPixelPositionMapperTests.cs new file mode 100644 index 00000000..a7fac8cf --- /dev/null +++ b/src/LogExpert.Tests/ControlCharDisplay/SubstitutedPixelPositionMapperTests.cs @@ -0,0 +1,199 @@ +using LogExpert.UI.ControlCharDisplay; + +using NUnit.Framework; + +namespace LogExpert.Tests.ControlCharDisplay; + +[TestFixture] +public class SubstitutedPixelPositionMapperTests +{ + private static PaintSegment Raw (string text) => + new(text, Color.Black, Color.White, IsBold: false, IsItalic: false, NoBackground: false, IsSubstituted: false); + + private static PaintSegment Sub (string text) => + new(text, Color.Gray, Color.Empty, IsBold: false, IsItalic: false, NoBackground: true, IsSubstituted: true); + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_AllRawSingleSegment_ProportionalMappingMatchesPosition () + { + // Single raw segment "abcde" — 5 source chars, 50 px wide (10 px/char). + var segments = new List { Raw("abcde") }; + var widths = new[] { 50 }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 5 }; + + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 0), + Is.EqualTo(0)); + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 25), + Is.EqualTo(3)); + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 50), + Is.EqualTo(5)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_ClickAtPixelZero_ReturnsZero () + { + var segments = new List { Raw("abc"), Sub("^A") }; + var widths = new[] { 30, 20 }; + var sourceStarts = new[] { 0, 3 }; + var sourceLengths = new[] { 3, 1 }; + + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 0), + Is.EqualTo(0)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_ClickBeyondLastSegment_ReturnsTotalSourceLength () + { + var segments = new List { Raw("ab"), Sub("^A") }; + var widths = new[] { 20, 20 }; + var sourceStarts = new[] { 0, 2 }; + var sourceLengths = new[] { 2, 1 }; + + // Pixel well past right edge of all segments. + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 999), + Is.EqualTo(3)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_ClickLeftHalfOfSubstitutedSegment_ReturnsSourceStart () + { + // Single substituted segment at source index 0, 1 source char, 20 px wide. + var segments = new List { Sub("^A") }; + var widths = new[] { 20 }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 1 }; + + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 5), + Is.EqualTo(0)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_ClickRightHalfOfSubstitutedSegment_ReturnsSourceEnd () + { + var segments = new List { Sub("^A") }; + var widths = new[] { 20 }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 1 }; + + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 15), + Is.EqualTo(1)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PixelToSourceIndex_ClickAtBoundaryBetweenAdjacentSubstituted_ReturnsStartOfSecond () + { + // "\u0001\u0002" → two adjacent substituted segments, each source length 1, 20 px each. + var segments = new List { Sub("^A"), Sub("^B") }; + var widths = new[] { 20, 20 }; + var sourceStarts = new[] { 0, 1 }; + var sourceLengths = new[] { 1, 1 }; + + // Pixel 20 is the boundary; click here should resolve to source index 1 (the + // start of the second substituted segment, equivalent to "between them"). + Assert.That( + SubstitutedPixelPositionMapper.PixelToSourceIndex(segments, widths, sourceStarts, sourceLengths, 20), + Is.EqualTo(1)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void StepSourceIndex_InsideRawSegment_MovesByOne () + { + // Single raw "abcde" — step right from 2 → 3, step left from 2 → 1. + var segments = new List { Raw("abcde") }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 5 }; + + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 2, +1), + Is.EqualTo(3)); + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 2, -1), + Is.EqualTo(1)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void StepSourceIndex_AtLeftEdgeOfSubstituted_StepRight_JumpsToRightEdge () + { + // "a\u0001b" → raw a, sub ^A, raw b. From source index 1 (left edge of ^A), + // stepping +1 lands at 2 (right edge of ^A). + var segments = new List { Raw("a"), Sub("^A"), Raw("b") }; + var sourceStarts = new[] { 0, 1, 2 }; + var sourceLengths = new[] { 1, 1, 1 }; + + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 1, +1), + Is.EqualTo(2)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void StepSourceIndex_AtRightEdgeOfSubstituted_StepLeft_JumpsToLeftEdge () + { + var segments = new List { Raw("a"), Sub("^A"), Raw("b") }; + var sourceStarts = new[] { 0, 1, 2 }; + var sourceLengths = new[] { 1, 1, 1 }; + + // From source index 2 (right edge of ^A = left edge of "b"), stepping -1 should + // land at 1 (left edge of ^A) — the substituted glyph is a single navigation unit. + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 2, -1), + Is.EqualTo(1)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void StepSourceIndex_ClampsAtZeroAndTotalLength () + { + var segments = new List { Raw("abc") }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 3 }; + + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 0, -1), + Is.EqualTo(0)); + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, 3, +1), + Is.EqualTo(3)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void StepSourceIndex_SubstitutionDisabledSingleRawSegment_AlwaysMovesByOne () + { + var segments = new List { Raw("hello world") }; + var sourceStarts = new[] { 0 }; + var sourceLengths = new[] { 11 }; + + for (int idx = 0; idx < 11; idx++) + { + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, idx, +1), + Is.EqualTo(idx + 1), + $"+1 step from {idx}"); + } + + for (int idx = 11; idx > 0; idx--) + { + Assert.That( + SubstitutedPixelPositionMapper.StepSourceIndex(segments, sourceStarts, sourceLengths, idx, -1), + Is.EqualTo(idx - 1), + $"-1 step from {idx}"); + } + } +} diff --git a/src/LogExpert.UI/ControlCharDisplay/ControlCharPresetProvider.cs b/src/LogExpert.UI/ControlCharDisplay/ControlCharPresetProvider.cs new file mode 100644 index 00000000..2b8337ea --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/ControlCharPresetProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Frozen; + +namespace LogExpert.UI.ControlCharDisplay; + +/// +/// Immutable preset sets of control-character code points used by the settings dialog's +/// quick-preset buttons. Sets are returned as so callers +/// cannot mutate them; the dialog copies into a new HashSet when assigning. +/// +public static class ControlCharPresetProvider +{ + /// C0 control characters (0x00..0x1F) plus DEL (0x7F) — 33 entries. + public static IReadOnlySet All { get; } = Enumerable.Range(0, 32).Append(0x7F).ToFrozenSet(); + + /// Empty set. + public static IReadOnlySet None { get; } = FrozenSet.Empty; + + /// minus TAB (0x09), LF (0x0A), CR (0x0D) — 30 entries. + public static IReadOnlySet NonWhitespaceDefaults { get; } = All.Where(cp => cp is not 0x09 and not 0x0A and not 0x0D).ToFrozenSet(); +} diff --git a/src/LogExpert.UI/ControlCharDisplay/SubstitutedClipboardBuilder.cs b/src/LogExpert.UI/ControlCharDisplay/SubstitutedClipboardBuilder.cs new file mode 100644 index 00000000..3c1df37e --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/SubstitutedClipboardBuilder.cs @@ -0,0 +1,46 @@ +using LogExpert.Core.Config; + +namespace LogExpert.UI.ControlCharDisplay; + +/// +/// Pure builder for the clipboard string when copying a selection from a log cell. +/// Returns the raw substring by default; when both +/// and are true, returns the +/// substituted (displayed) form. +/// +internal static class SubstitutedClipboardBuilder +{ + public static string Build ( + ReadOnlySpan rawText, + int selectionStart, + int selectionLength, + ControlCharSettings settings) + { + if (selectionLength <= 0) + { + return string.Empty; + } + + var slice = rawText.Slice(selectionStart, selectionLength); + + if (settings is null || !settings.Substitute || !settings.CopyDisplayedForm) + { + return slice.ToString(); + } + + // Substitution path: render the selected slice and concatenate. + var rendered = ControlCharRenderer.Render(slice.ToString(), settings); + if (rendered.Count == 1 && !rendered[0].IsSubstituted) + { + return rendered[0].RenderedText; + } + + var sb = new System.Text.StringBuilder(slice.Length); + foreach (var seg in rendered) + { + _ = sb.Append(seg.RenderedText); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/LogExpert.UI/ControlCharDisplay/SubstitutedPixelPositionMapper.cs b/src/LogExpert.UI/ControlCharDisplay/SubstitutedPixelPositionMapper.cs new file mode 100644 index 00000000..74f5c6d3 --- /dev/null +++ b/src/LogExpert.UI/ControlCharDisplay/SubstitutedPixelPositionMapper.cs @@ -0,0 +1,143 @@ +namespace LogExpert.UI.ControlCharDisplay; + +/// +/// Pure pixel-to-source-index mapping for cell text containing substituted glyphs. +/// Substituted segments snap to one of their two source edges; raw segments use +/// proportional mapping within the segment width. Callers are responsible for +/// pre-measuring segment pixel widths. +/// +internal static class SubstitutedPixelPositionMapper +{ + public static int PixelToSourceIndex ( + IReadOnlyList paintSegments, + IReadOnlyList segmentPixelWidths, + IReadOnlyList segmentSourceStarts, + IReadOnlyList segmentSourceLengths, + int pixelX) + { + if (paintSegments.Count == 0) + { + return 0; + } + + if (pixelX <= 0) + { + return segmentSourceStarts[0]; + } + + int totalSourceEnd = segmentSourceStarts[^1] + segmentSourceLengths[^1]; + + int cursor = 0; + for (int i = 0; i < paintSegments.Count; i++) + { + int width = segmentPixelWidths[i]; + int segEnd = cursor + width; + if (pixelX >= segEnd && i < paintSegments.Count - 1) + { + cursor = segEnd; + continue; + } + + int relX = pixelX - cursor; + int srcStart = segmentSourceStarts[i]; + int srcLen = segmentSourceLengths[i]; + + if (paintSegments[i].IsSubstituted) + { + // Snap to left edge when click is on left half, right edge otherwise. + return relX * 2 < width ? srcStart : srcStart + srcLen; + } + + // Raw segment: proportional, round to nearest character edge. + if (width <= 0) + { + return srcStart; + } + + int offset = (relX * srcLen + width / 2) / width; + if (offset < 0) + { + offset = 0; + } + else if (offset > srcLen) + { + offset = srcLen; + } + + return srcStart + offset; + } + + return totalSourceEnd; + } + + /// + /// Steps the cursor by one source position in the given direction (+1 = right, + /// -1 = left), treating substituted segments as atomic units. Raw segments behave + /// like normal text. Result is clamped to [0, totalSourceLength]. + /// + public static int StepSourceIndex ( + IReadOnlyList paintSegments, + IReadOnlyList segmentSourceStarts, + IReadOnlyList segmentSourceLengths, + int currentSourceIndex, + int direction) + { + if (paintSegments.Count == 0) + { + return 0; + } + + int totalSourceEnd = segmentSourceStarts[^1] + segmentSourceLengths[^1]; + + if (direction > 0) + { + if (currentSourceIndex >= totalSourceEnd) + { + return totalSourceEnd; + } + + // Find the segment that begins exactly at or contains currentSourceIndex + // such that stepping forward exits it. + for (int i = 0; i < paintSegments.Count; i++) + { + int segStart = segmentSourceStarts[i]; + int segEnd = segStart + segmentSourceLengths[i]; + + if (currentSourceIndex < segEnd) + { + return paintSegments[i].IsSubstituted + ? segEnd + : currentSourceIndex + 1; + } + } + + return totalSourceEnd; + } + + if (direction < 0) + { + if (currentSourceIndex <= 0) + { + return 0; + } + + // Stepping left: find the segment ending at or containing + // (currentSourceIndex - 1) and jump to its start when substituted. + for (int i = paintSegments.Count - 1; i >= 0; i--) + { + int segStart = segmentSourceStarts[i]; + int segEnd = segStart + segmentSourceLengths[i]; + if (currentSourceIndex > segStart && currentSourceIndex <= segEnd) + { + return paintSegments[i].IsSubstituted + ? segStart + : currentSourceIndex - 1; + } + } + + return 0; + } + + return currentSourceIndex; + } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 83ccb908..31a43f3b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -5357,9 +5357,25 @@ private void ProcessFilterPipes (int lineNum) [SupportedOSPlatform("windows")] private void CopyMarkedLinesToClipboard () { + var clipboardSettings = Preferences.ControlCharSettings ?? new ControlCharSettings(); + bool transformDisplayedForm = clipboardSettings.Substitute && clipboardSettings.CopyDisplayedForm; + if (_guiStateArgs.CellSelectMode) { var data = dataGridView.GetClipboardContent(); + if (transformDisplayedForm && data is not null) + { + // Replace the UnicodeText payload with the substituted form. Default + // EnabledCodepoints exclude TAB/LF/CR so the grid's cell/line separators + // are preserved; users who opt in to those codepoints will see those + // separators substituted too. + if (data.TryGetData(DataFormats.UnicodeText, out var unicodeText)) + { + var transformed = SubstitutedClipboardBuilder.Build(unicodeText.AsSpan(), 0, unicodeText.Length, clipboardSettings); + data = new DataObject(DataFormats.UnicodeText, transformed); + } + } + Clipboard.SetDataObject(data); } else @@ -5386,7 +5402,20 @@ private void CopyMarkedLinesToClipboard () line = xmlColumnizer.GetLineTextForClipboard(line, callback); } - _ = clipText.AppendLine(line.ToClipBoardText()); + if (transformDisplayedForm) + { + var rawLine = line.FullLine; + var substituted = SubstitutedClipboardBuilder.Build( + rawLine.Span, 0, rawLine.Length, clipboardSettings); + _ = clipText.Append('\t') + .Append(line.LineNumber + 1) + .Append('\t') + .AppendLine(substituted); + } + else + { + _ = clipText.AppendLine(line.ToClipBoardText()); + } } Clipboard.SetDataObject(clipText.ToString()); diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs index 724b3ca5..e1027136 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs @@ -111,6 +111,36 @@ private void InitializeComponent () dataGridViewHighlightMask = new DataGridView(); dataGridViewTextBoxColumnFileName = new DataGridViewTextBoxColumn(); dataGridViewComboBoxColumnHighlightGroup = new DataGridViewComboBoxColumn(); + tabPageControlCharacters = new TabPage(); + checkBoxControlCharsEnable = new CheckBox(); + labelControlCharsWarning = new Label(); + labelControlCharsHint = new Label(); + groupBoxControlCharsStyle = new GroupBox(); + radioButtonControlCharStyleControlPictures = new RadioButton(); + radioButtonControlCharStyleCaret = new RadioButton(); + radioButtonControlCharStyleCEscape = new RadioButton(); + radioButtonControlCharStyleAbbreviation = new RadioButton(); + radioButtonControlCharStyleIso2047 = new RadioButton(); + buttonControlCharsPresetAll = new Button(); + buttonControlCharsPresetNone = new Button(); + buttonControlCharsPresetNonWhitespace = new Button(); + dataGridViewControlChars = new DataGridView(); + columnControlCharEnabled = new DataGridViewCheckBoxColumn(); + columnControlCharHex = new DataGridViewTextBoxColumn(); + columnControlCharAbbr = new DataGridViewTextBoxColumn(); + columnControlCharCaret = new DataGridViewTextBoxColumn(); + columnControlCharPreview = new DataGridViewTextBoxColumn(); + groupBoxControlCharsAppearance = new GroupBox(); + labelControlCharsForeColor = new Label(); + buttonControlCharsForeColor = new Button(); + labelControlCharsBackColor = new Label(); + buttonControlCharsBackColor = new Button(); + buttonControlCharsBackColorClear = new Button(); + checkBoxControlCharsBold = new CheckBox(); + checkBoxControlCharsItalic = new CheckBox(); + labelControlCharsSample = new Label(); + groupBoxControlCharsClipboard = new GroupBox(); + checkBoxControlCharsCopyDisplayedForm = new CheckBox(); tabPageMultiFile = new TabPage(); groupBoxDefaultFileNamePattern = new GroupBox(); labelMaxDays = new Label(); @@ -179,6 +209,11 @@ private void InitializeComponent () ((System.ComponentModel.ISupportInitialize)dataGridViewColumnizer).BeginInit(); tabPageHighlightMask.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)dataGridViewHighlightMask).BeginInit(); + tabPageControlCharacters.SuspendLayout(); + groupBoxControlCharsStyle.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)dataGridViewControlChars).BeginInit(); + groupBoxControlCharsAppearance.SuspendLayout(); + groupBoxControlCharsClipboard.SuspendLayout(); tabPageMultiFile.SuspendLayout(); groupBoxDefaultFileNamePattern.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)upDownMultifileDays).BeginInit(); @@ -204,6 +239,7 @@ private void InitializeComponent () tabControlSettings.Controls.Add(tabPageExternalTools); tabControlSettings.Controls.Add(tabPageColumnizers); tabControlSettings.Controls.Add(tabPageHighlightMask); + tabControlSettings.Controls.Add(tabPageControlCharacters); tabControlSettings.Controls.Add(tabPageMultiFile); tabControlSettings.Controls.Add(tabPagePlugins); tabControlSettings.Controls.Add(tabPageSessions); @@ -245,7 +281,7 @@ private void InitializeComponent () labelWarningMaximumLineLength.AutoSize = true; labelWarningMaximumLineLength.Location = new Point(446, 101); labelWarningMaximumLineLength.Name = "labelWarningMaximumLineLength"; - labelWarningMaximumLineLength.Size = new Size(483, 15); + labelWarningMaximumLineLength.Size = new Size(482, 15); labelWarningMaximumLineLength.TabIndex = 16; labelWarningMaximumLineLength.Text = "! Changing the Maximum Line Length can impact performance and is not recommended !"; // @@ -267,7 +303,7 @@ private void InitializeComponent () labelMaximumLineLength.Location = new Point(467, 123); labelMaximumLineLength.Margin = new Padding(4, 0, 4, 0); labelMaximumLineLength.Name = "labelMaximumLineLength"; - labelMaximumLineLength.Size = new Size(218, 15); + labelMaximumLineLength.Size = new Size(217, 15); labelMaximumLineLength.TabIndex = 14; labelMaximumLineLength.Text = "Maximum Line Length (restart required)"; // @@ -289,7 +325,7 @@ private void InitializeComponent () labelMaxDisplayLength.Location = new Point(467, 151); labelMaxDisplayLength.Margin = new Padding(4, 0, 4, 0); labelMaxDisplayLength.Name = "labelMaxDisplayLength"; - labelMaxDisplayLength.Size = new Size(234, 15); + labelMaxDisplayLength.Size = new Size(233, 15); labelMaxDisplayLength.TabIndex = 17; labelMaxDisplayLength.Text = "Maximum Display Length (restart required)"; // @@ -310,7 +346,7 @@ private void InitializeComponent () labelMaximumFilterEntriesDisplayed.Location = new Point(467, 71); labelMaximumFilterEntriesDisplayed.Margin = new Padding(4, 0, 4, 0); labelMaximumFilterEntriesDisplayed.Name = "labelMaximumFilterEntriesDisplayed"; - labelMaximumFilterEntriesDisplayed.Size = new Size(180, 15); + labelMaximumFilterEntriesDisplayed.Size = new Size(179, 15); labelMaximumFilterEntriesDisplayed.TabIndex = 12; labelMaximumFilterEntriesDisplayed.Text = "Maximum filter entries displayed"; // @@ -330,7 +366,7 @@ private void InitializeComponent () labelMaximumFilterEntries.Location = new Point(467, 44); labelMaximumFilterEntries.Margin = new Padding(4, 0, 4, 0); labelMaximumFilterEntries.Name = "labelMaximumFilterEntries"; - labelMaximumFilterEntries.Size = new Size(127, 15); + labelMaximumFilterEntries.Size = new Size(126, 15); labelMaximumFilterEntries.TabIndex = 10; labelMaximumFilterEntries.Text = "Maximum filter entries"; // @@ -650,7 +686,7 @@ private void InitializeComponent () radioButtonTimeView.Location = new Point(9, 29); radioButtonTimeView.Margin = new Padding(4, 5, 4, 5); radioButtonTimeView.Name = "radioButtonTimeView"; - radioButtonTimeView.Size = new Size(78, 19); + radioButtonTimeView.Size = new Size(79, 19); radioButtonTimeView.TabIndex = 10; radioButtonTimeView.TabStop = true; radioButtonTimeView.Text = "Time view"; @@ -1136,6 +1172,325 @@ private void InitializeComponent () dataGridViewComboBoxColumnHighlightGroup.MinimumWidth = 50; dataGridViewComboBoxColumnHighlightGroup.Name = "dataGridViewComboBoxColumnHighlightGroup"; // + // tabPageControlCharacters + // + tabPageControlCharacters.Controls.Add(checkBoxControlCharsEnable); + tabPageControlCharacters.Controls.Add(labelControlCharsWarning); + tabPageControlCharacters.Controls.Add(labelControlCharsHint); + tabPageControlCharacters.Controls.Add(groupBoxControlCharsStyle); + tabPageControlCharacters.Controls.Add(buttonControlCharsPresetAll); + tabPageControlCharacters.Controls.Add(buttonControlCharsPresetNone); + tabPageControlCharacters.Controls.Add(buttonControlCharsPresetNonWhitespace); + tabPageControlCharacters.Controls.Add(dataGridViewControlChars); + tabPageControlCharacters.Controls.Add(groupBoxControlCharsAppearance); + tabPageControlCharacters.Controls.Add(groupBoxControlCharsClipboard); + tabPageControlCharacters.Location = new Point(4, 24); + tabPageControlCharacters.Margin = new Padding(4, 5, 4, 5); + tabPageControlCharacters.Name = "tabPageControlCharacters"; + tabPageControlCharacters.Padding = new Padding(8); + tabPageControlCharacters.Size = new Size(942, 440); + tabPageControlCharacters.TabIndex = 9; + tabPageControlCharacters.Text = "Control characters"; + tabPageControlCharacters.UseVisualStyleBackColor = true; + // + // checkBoxControlCharsEnable + // + checkBoxControlCharsEnable.AutoSize = true; + checkBoxControlCharsEnable.Location = new Point(12, 12); + checkBoxControlCharsEnable.Name = "checkBoxControlCharsEnable"; + checkBoxControlCharsEnable.Size = new Size(230, 19); + checkBoxControlCharsEnable.TabIndex = 0; + checkBoxControlCharsEnable.Tag = ""; + checkBoxControlCharsEnable.Text = "Substitute control characters in display"; + checkBoxControlCharsEnable.UseVisualStyleBackColor = true; + checkBoxControlCharsEnable.CheckedChanged += OnControlCharsEnableChanged; + // + // labelControlCharsWarning + // + labelControlCharsWarning.Location = new Point(12, 38); + labelControlCharsWarning.Name = "labelControlCharsWarning"; + labelControlCharsWarning.Size = new Size(900, 36); + labelControlCharsWarning.TabIndex = 1; + labelControlCharsWarning.Text = "Display only — does not modify file contents or affect search, filter, or copy-to-clipboard (unless the option below is enabled). Enabling this can make some log files look unusual."; + // + // labelControlCharsHint + // + labelControlCharsHint.AutoSize = true; + labelControlCharsHint.ForeColor = SystemColors.GrayText; + labelControlCharsHint.Location = new Point(12, 78); + labelControlCharsHint.Name = "labelControlCharsHint"; + labelControlCharsHint.Size = new Size(192, 15); + labelControlCharsHint.TabIndex = 2; + labelControlCharsHint.Text = "Enable substitution above to apply."; + // + // groupBoxControlCharsStyle + // + groupBoxControlCharsStyle.Controls.Add(radioButtonControlCharStyleControlPictures); + groupBoxControlCharsStyle.Controls.Add(radioButtonControlCharStyleCaret); + groupBoxControlCharsStyle.Controls.Add(radioButtonControlCharStyleCEscape); + groupBoxControlCharsStyle.Controls.Add(radioButtonControlCharStyleAbbreviation); + groupBoxControlCharsStyle.Controls.Add(radioButtonControlCharStyleIso2047); + groupBoxControlCharsStyle.Location = new Point(12, 102); + groupBoxControlCharsStyle.Name = "groupBoxControlCharsStyle"; + groupBoxControlCharsStyle.Size = new Size(360, 160); + groupBoxControlCharsStyle.TabIndex = 3; + groupBoxControlCharsStyle.TabStop = false; + groupBoxControlCharsStyle.Text = "Display style"; + // + // radioButtonControlCharStyleControlPictures + // + radioButtonControlCharStyleControlPictures.AutoSize = true; + radioButtonControlCharStyleControlPictures.Location = new Point(12, 22); + radioButtonControlCharStyleControlPictures.Name = "radioButtonControlCharStyleControlPictures"; + radioButtonControlCharStyleControlPictures.Size = new Size(205, 19); + radioButtonControlCharStyleControlPictures.TabIndex = 0; + radioButtonControlCharStyleControlPictures.Text = "Unicode Control Pictures (default)"; + radioButtonControlCharStyleControlPictures.UseVisualStyleBackColor = true; + radioButtonControlCharStyleControlPictures.CheckedChanged += OnControlCharStyleChanged; + // + // radioButtonControlCharStyleCaret + // + radioButtonControlCharStyleCaret.AutoSize = true; + radioButtonControlCharStyleCaret.Location = new Point(12, 48); + radioButtonControlCharStyleCaret.Name = "radioButtonControlCharStyleCaret"; + radioButtonControlCharStyleCaret.Size = new Size(127, 19); + radioButtonControlCharStyleCaret.TabIndex = 1; + radioButtonControlCharStyleCaret.Text = "Caret notation (^X)"; + radioButtonControlCharStyleCaret.UseVisualStyleBackColor = true; + radioButtonControlCharStyleCaret.CheckedChanged += OnControlCharStyleChanged; + // + // radioButtonControlCharStyleCEscape + // + radioButtonControlCharStyleCEscape.AutoSize = true; + radioButtonControlCharStyleCEscape.Location = new Point(12, 74); + radioButtonControlCharStyleCEscape.Name = "radioButtonControlCharStyleCEscape"; + radioButtonControlCharStyleCEscape.Size = new Size(158, 19); + radioButtonControlCharStyleCEscape.TabIndex = 2; + radioButtonControlCharStyleCEscape.Text = "C escape sequence (\\x01)"; + radioButtonControlCharStyleCEscape.UseVisualStyleBackColor = true; + radioButtonControlCharStyleCEscape.CheckedChanged += OnControlCharStyleChanged; + // + // radioButtonControlCharStyleAbbreviation + // + radioButtonControlCharStyleAbbreviation.AutoSize = true; + radioButtonControlCharStyleAbbreviation.Location = new Point(12, 100); + radioButtonControlCharStyleAbbreviation.Name = "radioButtonControlCharStyleAbbreviation"; + radioButtonControlCharStyleAbbreviation.Size = new Size(144, 19); + radioButtonControlCharStyleAbbreviation.TabIndex = 3; + radioButtonControlCharStyleAbbreviation.Text = "Abbreviation ()"; + radioButtonControlCharStyleAbbreviation.UseVisualStyleBackColor = true; + radioButtonControlCharStyleAbbreviation.CheckedChanged += OnControlCharStyleChanged; + // + // radioButtonControlCharStyleIso2047 + // + radioButtonControlCharStyleIso2047.AutoSize = true; + radioButtonControlCharStyleIso2047.Location = new Point(12, 126); + radioButtonControlCharStyleIso2047.Name = "radioButtonControlCharStyleIso2047"; + radioButtonControlCharStyleIso2047.Size = new Size(118, 19); + radioButtonControlCharStyleIso2047.TabIndex = 4; + radioButtonControlCharStyleIso2047.Text = "ISO 2047 graphics"; + radioButtonControlCharStyleIso2047.UseVisualStyleBackColor = true; + radioButtonControlCharStyleIso2047.CheckedChanged += OnControlCharStyleChanged; + // + // buttonControlCharsPresetAll + // + buttonControlCharsPresetAll.Location = new Point(384, 109); + buttonControlCharsPresetAll.Name = "buttonControlCharsPresetAll"; + buttonControlCharsPresetAll.Size = new Size(188, 28); + buttonControlCharsPresetAll.TabIndex = 4; + buttonControlCharsPresetAll.Text = "Enable all"; + buttonControlCharsPresetAll.UseVisualStyleBackColor = true; + buttonControlCharsPresetAll.Click += OnControlCharsPresetAllClick; + // + // buttonControlCharsPresetNone + // + buttonControlCharsPresetNone.Location = new Point(384, 143); + buttonControlCharsPresetNone.Name = "buttonControlCharsPresetNone"; + buttonControlCharsPresetNone.Size = new Size(188, 28); + buttonControlCharsPresetNone.TabIndex = 5; + buttonControlCharsPresetNone.Text = "Disable all"; + buttonControlCharsPresetNone.UseVisualStyleBackColor = true; + buttonControlCharsPresetNone.Click += OnControlCharsPresetNoneClick; + // + // buttonControlCharsPresetNonWhitespace + // + buttonControlCharsPresetNonWhitespace.Location = new Point(384, 177); + buttonControlCharsPresetNonWhitespace.Name = "buttonControlCharsPresetNonWhitespace"; + buttonControlCharsPresetNonWhitespace.Size = new Size(188, 28); + buttonControlCharsPresetNonWhitespace.TabIndex = 6; + buttonControlCharsPresetNonWhitespace.Text = "Non-whitespace defaults"; + buttonControlCharsPresetNonWhitespace.UseVisualStyleBackColor = true; + buttonControlCharsPresetNonWhitespace.Click += OnControlCharsPresetNonWhitespaceClick; + // + // dataGridViewControlChars + // + dataGridViewControlChars.AllowUserToAddRows = false; + dataGridViewControlChars.AllowUserToDeleteRows = false; + dataGridViewControlChars.AllowUserToResizeRows = false; + dataGridViewControlChars.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dataGridViewControlChars.Columns.AddRange(new DataGridViewColumn[] { columnControlCharEnabled, columnControlCharHex, columnControlCharAbbr, columnControlCharCaret, columnControlCharPreview }); + dataGridViewControlChars.EditMode = DataGridViewEditMode.EditOnEnter; + dataGridViewControlChars.Location = new Point(12, 268); + dataGridViewControlChars.MultiSelect = false; + dataGridViewControlChars.Name = "dataGridViewControlChars"; + dataGridViewControlChars.RowHeadersVisible = false; + dataGridViewControlChars.RowHeadersWidthSizeMode = DataGridViewRowHeadersWidthSizeMode.AutoSizeToDisplayedHeaders; + dataGridViewControlChars.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + dataGridViewControlChars.Size = new Size(560, 161); + dataGridViewControlChars.TabIndex = 7; + dataGridViewControlChars.CellValueChanged += OnControlCharsGridCellValueChanged; + dataGridViewControlChars.CurrentCellDirtyStateChanged += OnControlCharsGridCurrentCellDirtyStateChanged; + // + // columnControlCharEnabled + // + columnControlCharEnabled.AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader; + columnControlCharEnabled.HeaderText = "On"; + columnControlCharEnabled.MinimumWidth = 30; + columnControlCharEnabled.Name = "columnControlCharEnabled"; + columnControlCharEnabled.Resizable = DataGridViewTriState.False; + // + // columnControlCharHex + // + columnControlCharHex.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + columnControlCharHex.HeaderText = "Hex"; + columnControlCharHex.Name = "columnControlCharHex"; + columnControlCharHex.ReadOnly = true; + // + // columnControlCharAbbr + // + columnControlCharAbbr.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + columnControlCharAbbr.HeaderText = "Abbr"; + columnControlCharAbbr.Name = "columnControlCharAbbr"; + columnControlCharAbbr.ReadOnly = true; + // + // columnControlCharCaret + // + columnControlCharCaret.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + columnControlCharCaret.HeaderText = "Caret"; + columnControlCharCaret.Name = "columnControlCharCaret"; + columnControlCharCaret.ReadOnly = true; + // + // columnControlCharPreview + // + columnControlCharPreview.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + columnControlCharPreview.HeaderText = "Preview"; + columnControlCharPreview.Name = "columnControlCharPreview"; + columnControlCharPreview.ReadOnly = true; + // + // groupBoxControlCharsAppearance + // + groupBoxControlCharsAppearance.Controls.Add(labelControlCharsForeColor); + groupBoxControlCharsAppearance.Controls.Add(buttonControlCharsForeColor); + groupBoxControlCharsAppearance.Controls.Add(labelControlCharsBackColor); + groupBoxControlCharsAppearance.Controls.Add(buttonControlCharsBackColor); + groupBoxControlCharsAppearance.Controls.Add(buttonControlCharsBackColorClear); + groupBoxControlCharsAppearance.Controls.Add(checkBoxControlCharsBold); + groupBoxControlCharsAppearance.Controls.Add(checkBoxControlCharsItalic); + groupBoxControlCharsAppearance.Controls.Add(labelControlCharsSample); + groupBoxControlCharsAppearance.Location = new Point(588, 102); + groupBoxControlCharsAppearance.Name = "groupBoxControlCharsAppearance"; + groupBoxControlCharsAppearance.Size = new Size(340, 220); + groupBoxControlCharsAppearance.TabIndex = 8; + groupBoxControlCharsAppearance.TabStop = false; + groupBoxControlCharsAppearance.Text = "Appearance"; + // + // labelControlCharsForeColor + // + labelControlCharsForeColor.AutoSize = true; + labelControlCharsForeColor.Location = new Point(12, 28); + labelControlCharsForeColor.Name = "labelControlCharsForeColor"; + labelControlCharsForeColor.Size = new Size(109, 15); + labelControlCharsForeColor.TabIndex = 0; + labelControlCharsForeColor.Text = "Foreground colour:"; + // + // buttonControlCharsForeColor + // + buttonControlCharsForeColor.Location = new Point(150, 24); + buttonControlCharsForeColor.Name = "buttonControlCharsForeColor"; + buttonControlCharsForeColor.Size = new Size(40, 24); + buttonControlCharsForeColor.TabIndex = 1; + buttonControlCharsForeColor.UseVisualStyleBackColor = false; + buttonControlCharsForeColor.Click += OnControlCharsForeColorClick; + // + // labelControlCharsBackColor + // + labelControlCharsBackColor.AutoSize = true; + labelControlCharsBackColor.Location = new Point(12, 60); + labelControlCharsBackColor.Name = "labelControlCharsBackColor"; + labelControlCharsBackColor.Size = new Size(111, 15); + labelControlCharsBackColor.TabIndex = 2; + labelControlCharsBackColor.Text = "Background colour:"; + // + // buttonControlCharsBackColor + // + buttonControlCharsBackColor.Location = new Point(150, 56); + buttonControlCharsBackColor.Name = "buttonControlCharsBackColor"; + buttonControlCharsBackColor.Size = new Size(40, 24); + buttonControlCharsBackColor.TabIndex = 3; + buttonControlCharsBackColor.UseVisualStyleBackColor = false; + buttonControlCharsBackColor.Click += OnControlCharsBackColorClick; + // + // buttonControlCharsBackColorClear + // + buttonControlCharsBackColorClear.Location = new Point(196, 56); + buttonControlCharsBackColorClear.Name = "buttonControlCharsBackColorClear"; + buttonControlCharsBackColorClear.Size = new Size(90, 24); + buttonControlCharsBackColorClear.TabIndex = 4; + buttonControlCharsBackColorClear.Text = "(no color)"; + buttonControlCharsBackColorClear.UseVisualStyleBackColor = true; + buttonControlCharsBackColorClear.Click += OnControlCharsBackColorClearClick; + // + // checkBoxControlCharsBold + // + checkBoxControlCharsBold.AutoSize = true; + checkBoxControlCharsBold.Location = new Point(12, 96); + checkBoxControlCharsBold.Name = "checkBoxControlCharsBold"; + checkBoxControlCharsBold.Size = new Size(50, 19); + checkBoxControlCharsBold.TabIndex = 5; + checkBoxControlCharsBold.Text = "Bold"; + checkBoxControlCharsBold.UseVisualStyleBackColor = true; + checkBoxControlCharsBold.CheckedChanged += OnControlCharsBoldChanged; + // + // checkBoxControlCharsItalic + // + checkBoxControlCharsItalic.AutoSize = true; + checkBoxControlCharsItalic.Location = new Point(100, 96); + checkBoxControlCharsItalic.Name = "checkBoxControlCharsItalic"; + checkBoxControlCharsItalic.Size = new Size(51, 19); + checkBoxControlCharsItalic.TabIndex = 6; + checkBoxControlCharsItalic.Text = "Italic"; + checkBoxControlCharsItalic.UseVisualStyleBackColor = true; + checkBoxControlCharsItalic.CheckedChanged += OnControlCharsItalicChanged; + // + // labelControlCharsSample + // + labelControlCharsSample.BorderStyle = BorderStyle.FixedSingle; + labelControlCharsSample.Font = new Font("Courier New", 12F); + labelControlCharsSample.Location = new Point(12, 132); + labelControlCharsSample.Name = "labelControlCharsSample"; + labelControlCharsSample.Size = new Size(316, 64); + labelControlCharsSample.TabIndex = 7; + labelControlCharsSample.TextAlign = ContentAlignment.MiddleCenter; + // + // groupBoxControlCharsClipboard + // + groupBoxControlCharsClipboard.Controls.Add(checkBoxControlCharsCopyDisplayedForm); + groupBoxControlCharsClipboard.Location = new Point(588, 332); + groupBoxControlCharsClipboard.Name = "groupBoxControlCharsClipboard"; + groupBoxControlCharsClipboard.Size = new Size(340, 60); + groupBoxControlCharsClipboard.TabIndex = 9; + groupBoxControlCharsClipboard.TabStop = false; + groupBoxControlCharsClipboard.Text = "Clipboard"; + // + // checkBoxControlCharsCopyDisplayedForm + // + checkBoxControlCharsCopyDisplayedForm.AutoSize = true; + checkBoxControlCharsCopyDisplayedForm.Location = new Point(12, 24); + checkBoxControlCharsCopyDisplayedForm.Name = "checkBoxControlCharsCopyDisplayedForm"; + checkBoxControlCharsCopyDisplayedForm.Size = new Size(15, 14); + checkBoxControlCharsCopyDisplayedForm.TabIndex = 0; + checkBoxControlCharsCopyDisplayedForm.UseVisualStyleBackColor = true; + // // tabPageMultiFile // tabPageMultiFile.Controls.Add(groupBoxDefaultFileNamePattern); @@ -1172,7 +1527,7 @@ private void InitializeComponent () labelMaxDays.Location = new Point(10, 75); labelMaxDays.Margin = new Padding(4, 0, 4, 0); labelMaxDays.Name = "labelMaxDays"; - labelMaxDays.Size = new Size(60, 15); + labelMaxDays.Size = new Size(59, 15); labelMaxDays.TabIndex = 3; labelMaxDays.Text = "Max days:"; // @@ -1222,7 +1577,7 @@ private void InitializeComponent () labelNoteMultiFile.Name = "labelNoteMultiFile"; labelNoteMultiFile.Size = new Size(705, 82); labelNoteMultiFile.TabIndex = 1; - labelNoteMultiFile.Text = "Note: You can always load your logfiles as MultiFile automatically if the files names follow the MultiFile naming rule (, .1, .2, ...). Simply choose 'MultiFile' from the File menu after loading the first file."; + labelNoteMultiFile.Text = resources.GetString("labelNoteMultiFile.Text"); // // groupBoxWhenOpeningMultiFile // @@ -1256,7 +1611,7 @@ private void InitializeComponent () radioButtonTreatAllFilesAsOneMultifile.Location = new Point(10, 68); radioButtonTreatAllFilesAsOneMultifile.Margin = new Padding(4, 5, 4, 5); radioButtonTreatAllFilesAsOneMultifile.Name = "radioButtonTreatAllFilesAsOneMultifile"; - radioButtonTreatAllFilesAsOneMultifile.Size = new Size(181, 19); + radioButtonTreatAllFilesAsOneMultifile.Size = new Size(182, 19); radioButtonTreatAllFilesAsOneMultifile.TabIndex = 1; radioButtonTreatAllFilesAsOneMultifile.TabStop = true; radioButtonTreatAllFilesAsOneMultifile.Text = "Treat all files as one 'MultiFile'"; @@ -1442,7 +1797,7 @@ private void InitializeComponent () radioButtonsessionSaveDocuments.Location = new Point(10, 65); radioButtonsessionSaveDocuments.Margin = new Padding(4, 5, 4, 5); radioButtonsessionSaveDocuments.Name = "radioButtonsessionSaveDocuments"; - radioButtonsessionSaveDocuments.Size = new Size(161, 19); + radioButtonsessionSaveDocuments.Size = new Size(160, 19); radioButtonsessionSaveDocuments.TabIndex = 1; radioButtonsessionSaveDocuments.TabStop = true; radioButtonsessionSaveDocuments.Text = "MyDocuments/LogExpert"; @@ -1479,7 +1834,7 @@ private void InitializeComponent () checkBoxSaveSessions.Location = new Point(35, 40); checkBoxSaveSessions.Margin = new Padding(4, 5, 4, 5); checkBoxSaveSessions.Name = "checkBoxSaveSessions"; - checkBoxSaveSessions.Size = new Size(242, 19); + checkBoxSaveSessions.Size = new Size(241, 19); checkBoxSaveSessions.TabIndex = 0; checkBoxSaveSessions.Text = "Automatically save persistence files (.lxp)"; checkBoxSaveSessions.UseVisualStyleBackColor = true; @@ -1574,7 +1929,7 @@ private void InitializeComponent () labelInfo.Location = new Point(9, 145); labelInfo.Margin = new Padding(4, 0, 4, 0); labelInfo.Name = "labelInfo"; - labelInfo.Size = new Size(220, 15); + labelInfo.Size = new Size(219, 15); labelInfo.TabIndex = 4; labelInfo.Text = "Changes will take effect on next file load"; // @@ -1722,6 +2077,15 @@ private void InitializeComponent () ((System.ComponentModel.ISupportInitialize)dataGridViewColumnizer).EndInit(); tabPageHighlightMask.ResumeLayout(false); ((System.ComponentModel.ISupportInitialize)dataGridViewHighlightMask).EndInit(); + tabPageControlCharacters.ResumeLayout(false); + tabPageControlCharacters.PerformLayout(); + groupBoxControlCharsStyle.ResumeLayout(false); + groupBoxControlCharsStyle.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)dataGridViewControlChars).EndInit(); + groupBoxControlCharsAppearance.ResumeLayout(false); + groupBoxControlCharsAppearance.PerformLayout(); + groupBoxControlCharsClipboard.ResumeLayout(false); + groupBoxControlCharsClipboard.PerformLayout(); tabPageMultiFile.ResumeLayout(false); groupBoxDefaultFileNamePattern.ResumeLayout(false); groupBoxDefaultFileNamePattern.PerformLayout(); @@ -1872,9 +2236,39 @@ private void InitializeComponent () private System.Windows.Forms.NumericUpDown upDownMaximumLineLength; private System.Windows.Forms.Label labelMaximumLineLength; private System.Windows.Forms.Label labelWarningMaximumLineLength; + private System.Windows.Forms.TabPage tabPageControlCharacters; + private System.Windows.Forms.CheckBox checkBoxControlCharsEnable; + private System.Windows.Forms.Label labelControlCharsWarning; + private System.Windows.Forms.Label labelControlCharsHint; + private System.Windows.Forms.GroupBox groupBoxControlCharsStyle; + private System.Windows.Forms.RadioButton radioButtonControlCharStyleControlPictures; + private System.Windows.Forms.RadioButton radioButtonControlCharStyleCaret; + private System.Windows.Forms.RadioButton radioButtonControlCharStyleCEscape; + private System.Windows.Forms.RadioButton radioButtonControlCharStyleAbbreviation; + private System.Windows.Forms.RadioButton radioButtonControlCharStyleIso2047; + private System.Windows.Forms.Button buttonControlCharsPresetAll; + private System.Windows.Forms.Button buttonControlCharsPresetNone; + private System.Windows.Forms.Button buttonControlCharsPresetNonWhitespace; + private System.Windows.Forms.DataGridView dataGridViewControlChars; + private System.Windows.Forms.GroupBox groupBoxControlCharsAppearance; + private System.Windows.Forms.Label labelControlCharsForeColor; + private System.Windows.Forms.Button buttonControlCharsForeColor; + private System.Windows.Forms.Label labelControlCharsBackColor; + private System.Windows.Forms.Button buttonControlCharsBackColor; + private System.Windows.Forms.Button buttonControlCharsBackColorClear; + private System.Windows.Forms.CheckBox checkBoxControlCharsBold; + private System.Windows.Forms.CheckBox checkBoxControlCharsItalic; + private System.Windows.Forms.Label labelControlCharsSample; + private System.Windows.Forms.GroupBox groupBoxControlCharsClipboard; + private System.Windows.Forms.CheckBox checkBoxControlCharsCopyDisplayedForm; private System.Windows.Forms.NumericUpDown upDownMaxDisplayLength; private System.Windows.Forms.Label labelMaxDisplayLength; private Label labelLanguage; private ComboBox comboBoxLanguage; private ComboBox comboBoxReaderType; + private DataGridViewCheckBoxColumn columnControlCharEnabled; + private DataGridViewTextBoxColumn columnControlCharHex; + private DataGridViewTextBoxColumn columnControlCharAbbr; + private DataGridViewTextBoxColumn columnControlCharCaret; + private DataGridViewTextBoxColumn columnControlCharPreview; } diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index a4de497a..e03018b3 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -11,6 +11,7 @@ using LogExpert.Core.Entities; using LogExpert.Core.Enums; using LogExpert.Core.Interfaces; +using LogExpert.UI.ControlCharDisplay; using LogExpert.UI.Controls.LogTabWindow; using LogExpert.UI.Dialogs; using LogExpert.UI.Extensions; @@ -31,6 +32,32 @@ internal partial class SettingsDialog : Form private ILogExpertPluginConfigurator _selectedPlugin; private ToolEntry _selectedTool; + private Color _controlCharsForeColor; + private Color _controlCharsBackColor; + private readonly Dictionary _controlCharsEnabledByCp = new(33); + + // Codepoint set displayed in the grid (C0 + DEL, 33 rows). + private static readonly int[] _allDisplayableControlCps = [.. Enumerable.Range(0, 32), 0x7F]; + + // Friendly metadata for the grid; tooltip uses the formal Unicode name. + private static readonly (string Abbr, string Name)[] _controlCharMeta = + [ + ("NUL", "NULL"), ("SOH", "START OF HEADING"), ("STX", "START OF TEXT"), + ("ETX", "END OF TEXT"), ("EOT", "END OF TRANSMISSION"), ("ENQ", "ENQUIRY"), + ("ACK", "ACKNOWLEDGE"), ("BEL", "BELL"), ("BS", "BACKSPACE"), + ("HT", "HORIZONTAL TABULATION"), ("LF", "LINE FEED"), ("VT", "VERTICAL TABULATION"), + ("FF", "FORM FEED"), ("CR", "CARRIAGE RETURN"), ("SO", "SHIFT OUT"), + ("SI", "SHIFT IN"), ("DLE", "DATA LINK ESCAPE"), ("DC1", "DEVICE CONTROL ONE"), + ("DC2", "DEVICE CONTROL TWO"), ("DC3", "DEVICE CONTROL THREE"), + ("DC4", "DEVICE CONTROL FOUR"), ("NAK", "NEGATIVE ACKNOWLEDGE"), + ("SYN", "SYNCHRONOUS IDLE"), ("ETB", "END OF TRANSMISSION BLOCK"), + ("CAN", "CANCEL"), ("EM", "END OF MEDIUM"), ("SUB", "SUBSTITUTE"), + ("ESC", "ESCAPE"), ("FS", "FILE SEPARATOR"), ("GS", "GROUP SEPARATOR"), + ("RS", "RECORD SEPARATOR"), ("US", "UNIT SEPARATOR"), + // index 32 maps to 0x7F + ("DEL", "DELETE"), + ]; + #endregion #region cTor @@ -235,6 +262,8 @@ private void FillDialog () checkBoxColumnFinder.Checked = Preferences.ShowColumnFinder; checkBoxShowErrorMessageOnlyOneInstance.Checked = Preferences.ShowErrorMessageAllowOnlyOneInstances; + + FillControlCharsTab(); } private void FillReaderTypeList () @@ -596,7 +625,7 @@ private void FillMultifileSettings () break; } - textBoxMultifilePattern.Text = Preferences.MultiFileOptions.FormatPattern; //TODO: Impport settings file throws an exception. Fix or I caused it? + textBoxMultifilePattern.Text = Preferences.MultiFileOptions.FormatPattern; upDownMultifileDays.Value = Preferences.MultiFileOptions.MaxDayTry; } @@ -789,6 +818,7 @@ private void OnBtnOkClick (object sender, EventArgs e) SaveHighlightMaskList(); GetToolListBoxData(); SaveMultifileData(); + SaveControlCharsTab(); } private void OnBtnToolClick (object sender, EventArgs e) @@ -1293,5 +1323,218 @@ private Dictionary GetToolTipMap () }; } + private void FillControlCharsTab () + { + var s = Preferences.ControlCharSettings ??= new ControlCharSettings(); + + checkBoxControlCharsEnable.Checked = s.Substitute; + checkBoxControlCharsCopyDisplayedForm.Checked = s.CopyDisplayedForm; + checkBoxControlCharsBold.Checked = s.Bold; + checkBoxControlCharsItalic.Checked = s.Italic; + _controlCharsForeColor = s.ForeColor == Color.Empty ? Color.Gray : s.ForeColor; + _controlCharsBackColor = s.BackColor; + + switch (s.Style) + { + case ControlCharStyle.Caret: radioButtonControlCharStyleCaret.Checked = true; break; + case ControlCharStyle.CEscape: radioButtonControlCharStyleCEscape.Checked = true; break; + case ControlCharStyle.Abbreviation: radioButtonControlCharStyleAbbreviation.Checked = true; break; + case ControlCharStyle.Iso2047: radioButtonControlCharStyleIso2047.Checked = true; break; + default: radioButtonControlCharStyleControlPictures.Checked = true; break; + } + + _controlCharsEnabledByCp.Clear(); + var enabled = s.EnabledCodepoints ?? []; + foreach (var cp in _allDisplayableControlCps) + { + _controlCharsEnabledByCp[cp] = enabled.Contains(cp); + } + + PopulateControlCharsGrid(); + UpdateColorButtons(); + UpdateSampleAndPreview(); + UpdateHintVisibility(); + } + + private void SaveControlCharsTab () + { + var s = Preferences.ControlCharSettings ??= new ControlCharSettings(); + + s.Substitute = checkBoxControlCharsEnable.Checked; + s.CopyDisplayedForm = checkBoxControlCharsCopyDisplayedForm.Checked; + s.Bold = checkBoxControlCharsBold.Checked; + s.Italic = checkBoxControlCharsItalic.Checked; + s.ForeColor = _controlCharsForeColor; + s.BackColor = _controlCharsBackColor; + s.Style = GetSelectedStyle(); + + var newSet = new HashSet(); + foreach (var kvp in _controlCharsEnabledByCp) + { + if (kvp.Value) + { + _ = newSet.Add(kvp.Key); + } + } + + s.EnabledCodepoints = newSet; + } + + private void PopulateControlCharsGrid () + { + var style = GetSelectedStyle(); + dataGridViewControlChars.SuspendLayout(); + dataGridViewControlChars.Rows.Clear(); + + for (var i = 0; i < _allDisplayableControlCps.Length; i++) + { + var cp = _allDisplayableControlCps[i]; + var meta = _controlCharMeta[i]; + var preview = ControlCharStyleFormatter.Format(cp, style); + var caret = cp == 0x7F ? "^?" : "^" + (char)(cp + 0x40); + var rowIndex = dataGridViewControlChars.Rows.Add( + _controlCharsEnabledByCp.TryGetValue(cp, out var on) && on, + "0x" + cp.ToString("X2", CultureInfo.InvariantCulture), + meta.Abbr, + caret, + preview); + + dataGridViewControlChars.Rows[rowIndex].Tag = cp; + dataGridViewControlChars.Rows[rowIndex].Cells[columnControlCharAbbr.Index].ToolTipText = meta.Name; + } + + dataGridViewControlChars.ResumeLayout(); + } + + private void OnControlCharsGridCellValueChanged (object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 || e.ColumnIndex != columnControlCharEnabled.Index) + { + return; + } + + var row = dataGridViewControlChars.Rows[e.RowIndex]; + if (row.Tag is int cp && row.Cells[columnControlCharEnabled.Index].Value is bool checkedValue) + { + _controlCharsEnabledByCp[cp] = checkedValue; + } + } + + private void OnControlCharStyleChanged (object sender, EventArgs e) + { + if (sender is RadioButton rb && rb.Checked) + { + PopulateControlCharsGrid(); + UpdateSampleAndPreview(); + } + } + + private void OnControlCharsEnableChanged (object sender, EventArgs e) => UpdateHintVisibility(); + + private void UpdateHintVisibility () + { + labelControlCharsHint.Visible = !checkBoxControlCharsEnable.Checked; + } + + private void ApplyPreset (IReadOnlySet preset) + { + foreach (var cp in _allDisplayableControlCps) + { + _controlCharsEnabledByCp[cp] = preset.Contains(cp); + } + + PopulateControlCharsGrid(); + } + + private ControlCharStyle GetSelectedStyle () + { + return radioButtonControlCharStyleCaret.Checked + ? ControlCharStyle.Caret + : radioButtonControlCharStyleCEscape.Checked + ? ControlCharStyle.CEscape + : radioButtonControlCharStyleAbbreviation.Checked + ? ControlCharStyle.Abbreviation + : radioButtonControlCharStyleIso2047.Checked + ? ControlCharStyle.Iso2047 + : ControlCharStyle.ControlPictures; + } + + private void OnControlCharsPresetAllClick (object sender, EventArgs e) => ApplyPreset(ControlCharPresetProvider.All); + + private void OnControlCharsPresetNoneClick (object sender, EventArgs e) => ApplyPreset(ControlCharPresetProvider.None); + + private void OnControlCharsPresetNonWhitespaceClick (object sender, EventArgs e) => ApplyPreset(ControlCharPresetProvider.NonWhitespaceDefaults); + + private void OnControlCharsGridCurrentCellDirtyStateChanged (object sender, EventArgs e) + { + if (dataGridViewControlChars.IsCurrentCellDirty) + { + _ = dataGridViewControlChars.CommitEdit(DataGridViewDataErrorContexts.Commit); + } + } + + private void OnControlCharsBackColorClearClick (object sender, EventArgs e) + { + _controlCharsBackColor = Color.Empty; + UpdateColorButtons(); + UpdateSampleAndPreview(); + } + + private void OnControlCharsBoldChanged (object sender, EventArgs e) => UpdateSampleAndPreview(); + + private void OnControlCharsItalicChanged (object sender, EventArgs e) => UpdateSampleAndPreview(); + + private void OnControlCharsForeColorClick (object sender, EventArgs e) + { + using var dlg = new ColorDialog { Color = _controlCharsForeColor == Color.Empty ? Color.Gray : _controlCharsForeColor }; + + if (dlg.ShowDialog(this) == DialogResult.OK) + { + _controlCharsForeColor = dlg.Color; + UpdateColorButtons(); + UpdateSampleAndPreview(); + } + } + + private void OnControlCharsBackColorClick (object sender, EventArgs e) + { + using var dlg = new ColorDialog { Color = _controlCharsBackColor == Color.Empty ? Color.White : _controlCharsBackColor }; + if (dlg.ShowDialog(this) == DialogResult.OK) + { + _controlCharsBackColor = dlg.Color; + UpdateColorButtons(); + UpdateSampleAndPreview(); + } + } + + private void UpdateColorButtons () + { + buttonControlCharsForeColor.BackColor = _controlCharsForeColor == Color.Empty ? Color.Gray : _controlCharsForeColor; + buttonControlCharsBackColor.BackColor = _controlCharsBackColor == Color.Empty ? SystemColors.Control : _controlCharsBackColor; + } + + private void UpdateSampleAndPreview () + { + var style = GetSelectedStyle(); + // Sample renders 0x01 SOH so all styles look distinct. + var sample = ControlCharStyleFormatter.Format(0x01, style); + labelControlCharsSample.Text = "abc" + sample + "def"; + labelControlCharsSample.ForeColor = _controlCharsForeColor == Color.Empty ? Color.Gray : _controlCharsForeColor; + labelControlCharsSample.BackColor = _controlCharsBackColor == Color.Empty ? SystemColors.Control : _controlCharsBackColor; + + var fontStyle = FontStyle.Regular; + if (checkBoxControlCharsBold.Checked) + { + fontStyle |= FontStyle.Bold; + } + + if (checkBoxControlCharsItalic.Checked) + { + fontStyle |= FontStyle.Italic; + } + + labelControlCharsSample.Font = new Font(FontFamily.GenericMonospace, 12f, fontStyle); + } + #endregion } \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.resx b/src/LogExpert.UI/Dialogs/SettingsDialog.resx index b5f5e2bd..239344f9 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.resx +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.resx @@ -1,17 +1,17 @@ - @@ -117,11 +117,13 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - + 144, 17 - - + + 17, 17 + + + Note: You can always load your logfiles as MultiFile automatically if the files names follow the MultiFile naming rule (<filename>, <filename>.1, <filename>.2, ...). Simply choose 'MultiFile' from the File menu after loading the first file. \ No newline at end of file From d86ed3453e186b240348341de4465acbfff18c9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 18:55:08 +0000 Subject: [PATCH 7/7] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index cf08c2af..e6886706 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-05-19 20:13:54 UTC + /// Generated: 2026-05-21 18:55:06 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "87E50EA915140C9F594471EF06C862AB668EB93619E32E43656A16C18E2FC0C6", + ["AutoColumnizer.dll"] = "17544DB1CA2EADEB7465991A85F641AAED1BDB534E08D7FD87684A52A82A033B", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "8FF0321593BB8A3A2FB3E32CF8586547204AA27809FDCF43748740AF26A556C7", - ["CsvColumnizer.dll (x86)"] = "8FF0321593BB8A3A2FB3E32CF8586547204AA27809FDCF43748740AF26A556C7", - ["DefaultPlugins.dll"] = "B88C87586A6C12B877CF798A88F8B47DE7C032B6D0CEF9F7BD53EC9FC24E9A47", - ["FlashIconHighlighter.dll"] = "78F12169D88F4E3CACA57CD0398FA05DC468035066C3FA258D33B6CCF98B6EB5", - ["GlassfishColumnizer.dll"] = "3D1EBB4E913F4053FBF3DA33DD9238A5EBCA3336FDD8A3AC9F6111FA78745B56", - ["JsonColumnizer.dll"] = "4EEE0FA7995C6B5F269582BC8A7860707E7F1901972236D1807CF20814033583", - ["JsonCompactColumnizer.dll"] = "1A54DA4D05BCDF46353E42E5730EACFAD733790A65BAF85EB0A821406354D70D", - ["Log4jXmlColumnizer.dll"] = "A226AAD5959EEB8F93DFFAF1D2E09355363C02AA97BACF7F3F2107E3BB2BB1ED", - ["LogExpert.Core.dll"] = "F7B7126F9E1D6B1A39FD05E32E8750C72517F87F25A4867D756C72FFFC7F11DA", - ["LogExpert.Resources.dll"] = "841E7FA856C59364F8CD03C6E1DF51CE960AF38733E1AB133A9C166EB9031BC4", + ["CsvColumnizer.dll"] = "0C385855089302C6918F2752BE7CF4C2EED609944B08E502751E1F322F96D7F2", + ["CsvColumnizer.dll (x86)"] = "0C385855089302C6918F2752BE7CF4C2EED609944B08E502751E1F322F96D7F2", + ["DefaultPlugins.dll"] = "AAEC21CA713A6E9E94DEBA4C97BFCEE032C88623C7CAEAD87076695458E53803", + ["FlashIconHighlighter.dll"] = "CCDFB1867796634C73E79827BB3A3429F7EDF6F1C88139315764FC9587A3CC21", + ["GlassfishColumnizer.dll"] = "72FFCFDC5EB3D751E0B0015C1651647114170FE3AEBCF912A9B7EA420686F172", + ["JsonColumnizer.dll"] = "2BCA1492B339D4F10ADC1C03AA377F99F706CC7BCA79E6A7E70E27C6F9F71AA2", + ["JsonCompactColumnizer.dll"] = "07BD344BA2DAA0894E6AD02A369BB18E7FBCDEDFB0BACA627BCB9CB9424219D5", + ["Log4jXmlColumnizer.dll"] = "1DFD43D1B9CA1875510D5A5D98FB561187D8C46945D5968F0F92599FD8D47EBD", + ["LogExpert.Core.dll"] = "32BC381FEB3DC6759D183E70896BB76AF277316F56AB907E9290F9EB3EFC7C57", + ["LogExpert.Resources.dll"] = "D5AF8275B7F4005E8C7294DCC08CC2C9442F7AA577D5FFF1640C7022A1DA294A", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "0406128BB1F418CBEA376270987399B8E75FC2F2399932728A8B12B5B128D697", - ["SftpFileSystem.dll"] = "49A6F6EDE8A88DA908DF19571E0DD92928C7404A833B6B24A7F8045B026E93AB", - ["SftpFileSystem.dll (x86)"] = "99E68F1896239F9CCF8BCD2037F44FBAA33DB5CC92DD6913C6651EA5A54015EB", - ["SftpFileSystem.Resources.dll"] = "E7B3910AB0D312027174DC071114995B6694A2980582DCF90F71949B27B47177", - ["SftpFileSystem.Resources.dll (x86)"] = "E7B3910AB0D312027174DC071114995B6694A2980582DCF90F71949B27B47177", + ["RegexColumnizer.dll"] = "054279EEE6FC2404307BBC1BBC7FC3CF7C8D4217CC3A68418755FDE92A8DAB16", + ["SftpFileSystem.dll"] = "193A0934D73B935365F8D1663EAF77E1D3B39D02BAE6A13FED8134D00C13F789", + ["SftpFileSystem.dll (x86)"] = "3F480E77E4C49DAF4C3987FD2A30EEC0D0C92F9F6B18751AA886B2F93229426B", + ["SftpFileSystem.Resources.dll"] = "6056C7248B7A68DBF005A2484C943D4D8972159039C1DC4EC9AA117A1A969F9D", + ["SftpFileSystem.Resources.dll (x86)"] = "6056C7248B7A68DBF005A2484C943D4D8972159039C1DC4EC9AA117A1A969F9D", }; }