From 50884daca17506bdb6e1238b32968e8ab81ff1ce Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 30 Sep 2026 00:14:43 -0400 Subject: [PATCH] fix(editor): bound highlighting without interrupting program updates --- OpenNest.Core/CNC/ProgramHighlighting.cs | 39 +++ .../CNC/ProgramHighlightingTests.cs | 167 +++++++++++ .../Controls/ProgramEditorControlTests.cs | 281 ++++++++++++++++++ OpenNest/Controls/ProgramEditorControl.cs | 86 ++++-- 4 files changed, 542 insertions(+), 31 deletions(-) create mode 100644 OpenNest.Core/CNC/ProgramHighlighting.cs create mode 100644 OpenNest.Tests/CNC/ProgramHighlightingTests.cs create mode 100644 OpenNest.WinForms.Tests/Controls/ProgramEditorControlTests.cs diff --git a/OpenNest.Core/CNC/ProgramHighlighting.cs b/OpenNest.Core/CNC/ProgramHighlighting.cs new file mode 100644 index 0000000..1aeffe6 --- /dev/null +++ b/OpenNest.Core/CNC/ProgramHighlighting.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace OpenNest.CNC; + +public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex); + +/// Computes cosmetic G-code highlight spans without changing the generated text. +public static class ProgramHighlighting +{ + private static readonly Regex[] Rules = + { + new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)), + new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)), + new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)), + new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)), + new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)), + }; + + /// + /// Materializes all matches in rule/application order (later rules overwrite earlier ones). + /// A timeout propagates before any result is published; no partial span list escapes. + /// The timeout is per regex match, not a deadline for the complete operation. + /// + /// A rule exceeded its match budget. + public static IReadOnlyList ComputeSpans(string text) + { + ArgumentNullException.ThrowIfNull(text); + var spans = new List(); + for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++) + { + // MatchCollection is lazy: enumerate every rule before returning any spans. + foreach (Match match in Rules[ruleIndex].Matches(text)) + spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex)); + } + return spans.AsReadOnly(); + } +} diff --git a/OpenNest.Tests/CNC/ProgramHighlightingTests.cs b/OpenNest.Tests/CNC/ProgramHighlightingTests.cs new file mode 100644 index 0000000..c142b55 --- /dev/null +++ b/OpenNest.Tests/CNC/ProgramHighlightingTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using OpenNest.CNC; + +namespace OpenNest.Tests.CNC; + +public class ProgramHighlightingTests +{ + [Theory] + [InlineData("G00", 2, 3)] + [InlineData("G000", -1, 0)] + [InlineData("G00.5", 2, 3)] + [InlineData("G00 X1", 2, 3)] + [InlineData("G00X1", -1, 0)] + [InlineData("G00_", -1, 0)] + [InlineData("G00é", -1, 0)] + [InlineData("G01", 3, 3)] + [InlineData("G010", -1, 0)] + [InlineData("G01.5", 3, 3)] + [InlineData("G02 X1", 4, 3)] + [InlineData("G03 X1", 4, 3)] + [InlineData("G020", -1, 0)] + [InlineData("G90", 1, 3)] + [InlineData("G91", 1, 3)] + [InlineData("G901", -1, 0)] + [InlineData("G910", -1, 0)] + [InlineData("G90.5", 1, 3)] + [InlineData("g00 X1", -1, 0)] + [InlineData(" G00 X1", -1, 0)] + [InlineData(";)", 0, 2)] + [InlineData("; G00 X1", 0, 8)] + [InlineData("X1 ; comment", -1, 0)] + [InlineData("G00 X1 ; comment", 2, 3)] + public void SingleLineRetainsExistingAnchorsAndWordBoundaries( + string text, int ruleIndex, int length) + { + var spans = ComputeSpans(text); + if (ruleIndex < 0) + Assert.Empty(spans); + else + Assert.Equal(new[] { new OracleSpan(0, length, ruleIndex) }, spans); + } + + [Theory] + [InlineData("\n", 0)] + [InlineData("\r\n", 1)] + public void MultilineCommentsRetainCarriageReturnAndCommandsOnlyColorTheirPrefix( + string newline, int carriageReturnLength) + { + var lines = new[] { ";)", "G91", "G00 X1", "G01 Y2", "G02 X3", "G03 X4", "X1 ; mid-line" }; + var text = string.Join(newline, lines) + newline; + var starts = new List(); + var index = 0; + foreach (var line in lines) + { + starts.Add(index); + index += line.Length + newline.Length; + } + + Assert.Equal(new[] + { + new OracleSpan(starts[0], 2 + carriageReturnLength, 0), + new OracleSpan(starts[1], 3, 1), + new OracleSpan(starts[2], 3, 2), + new OracleSpan(starts[3], 3, 3), + new OracleSpan(starts[4], 3, 4), + new OracleSpan(starts[5], 3, 4), + }, ComputeSpans(text)); + } + + [Theory] + [InlineData("")] + [InlineData("\n\n")] + [InlineData("X1 Y2")] + [InlineData("x\rG00")] + public void EmptyOrUnmatchedTextHasNoSpans(string text) => Assert.Empty(ComputeSpans(text)); + + [Fact] + public void RulesStayInApplicationOrderRatherThanTextOrderAndNeverOverlap() + { + const string text = "G03\nG01\nG00\nG91\n;)\nG02\nG90\nG00"; + var spans = ComputeSpans(text); + Assert.Equal(new[] + { + new OracleSpan(16, 2, 0), + new OracleSpan(12, 3, 1), + new OracleSpan(23, 3, 1), + new OracleSpan(8, 3, 2), + new OracleSpan(27, 3, 2), + new OracleSpan(4, 3, 3), + new OracleSpan(0, 3, 4), + new OracleSpan(19, 3, 4), + }, spans); + Assert.Equal(spans.Select(s => s.RuleIndex).OrderBy(i => i), spans.Select(s => s.RuleIndex)); + var coloredIndices = spans.SelectMany(s => Enumerable.Range(s.Index, s.Length)).ToArray(); + Assert.Equal(coloredIndices.Length, coloredIndices.Distinct().Count()); + } + + // Frozen oracle: the original five inline rules from ProgramEditorControl. + // Keep this independent of production patterns and timeout configuration. + private static IReadOnlyList ComputeLegacySpans(string text) + { + var rules = new[] + { + new Regex(@"^;.*$", RegexOptions.Multiline), + new Regex(@"^G9[01]\b", RegexOptions.Multiline), + new Regex(@"^G00\b", RegexOptions.Multiline), + new Regex(@"^G01\b", RegexOptions.Multiline), + new Regex(@"^G0[23]\b", RegexOptions.Multiline), + }; + var spans = new List(); + for (var ruleIndex = 0; ruleIndex < rules.Length; ruleIndex++) + foreach (Match match in rules[ruleIndex].Matches(text)) + spans.Add(new OracleSpan(match.Index, match.Length, ruleIndex)); + return spans; + } + + [Fact] + public void AllFiveCachedRulesHaveUnchangedPatternsAndFinite100MillisecondBudgets() + { + var rules = Assert.IsType(typeof(ProgramHighlighting) + .GetField("Rules", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)); + Assert.Equal(new[] { @"^;.*$", @"^G9[01]\b", @"^G00\b", @"^G01\b", @"^G0[23]\b" }, + rules.Select(r => r.ToString())); + Assert.All(rules, rule => + { + Assert.Equal(RegexOptions.Multiline, rule.Options); + Assert.NotEqual(Regex.InfiniteMatchTimeout, rule.MatchTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(100), rule.MatchTimeout); + }); + } + + [Fact] + public void GeneratedSizedFixtureRetainsEverySpanAndTheOriginalText() + { + var text = string.Concat(Enumerable.Repeat( + "G91\r\n; Perimeter (CCW)\r\nG00 X1 Y2\r\nG01 X2 Y3\r\nG02 X1 Y0 I1 J2\r\nG03 X0 Y1 I2 J3\r\n", 5000)); + var original = text; + var spans = ComputeSpans(text); + Assert.Equal(30000, spans.Count); + Assert.Equal(original, text); + } + + [Fact] + public void PublishedSpansAreReadOnlyAndIndependentOfSubsequentCalls() + { + var spans = ProgramHighlighting.ComputeSpans("G00"); + Assert.Throws(() => ((IList)spans) + .Add(new HighlightSpan(0, 3, 4))); + Assert.Empty(ProgramHighlighting.ComputeSpans(string.Empty)); + Assert.Equal(new[] { new HighlightSpan(0, 3, 2) }, spans); + } + + private static IReadOnlyList ComputeSpans(string text) + { + var expected = ComputeLegacySpans(text); + var actual = ProgramHighlighting.ComputeSpans(text) + .Select(s => new OracleSpan(s.Index, s.Length, s.RuleIndex)).ToArray(); + Assert.Equal(expected, actual); + return actual; + } + + private readonly record struct OracleSpan(int Index, int Length, int RuleIndex); +} diff --git a/OpenNest.WinForms.Tests/Controls/ProgramEditorControlTests.cs b/OpenNest.WinForms.Tests/Controls/ProgramEditorControlTests.cs new file mode 100644 index 0000000..903a510 --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/ProgramEditorControlTests.cs @@ -0,0 +1,281 @@ +using System.Drawing; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Controls; + +public class ProgramEditorControlTests +{ + private static readonly Color BaseColor = Color.FromArgb(180, 200, 180); + + [Fact] + public void TimeoutDuringInitialLoadKeepsCompleteTextAndBaseColorWithoutAParentHandle() => RunSta(() => + { + using var control = new TestEditor { TimeoutFromCall = 1 }; + var status = GetControl