fix(editor): bound highlighting without interrupting program updates

This commit is contained in:
aj
2026-09-30 00:14:43 -04:00
parent fc53c7732e
commit 50884daca1
4 changed files with 542 additions and 31 deletions
+39
View File
@@ -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);
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
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)),
};
/// <summary>
/// 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.
/// </summary>
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
{
ArgumentNullException.ThrowIfNull(text);
var spans = new List<HighlightSpan>();
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();
}
}
@@ -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<int>();
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<OracleSpan> 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<OracleSpan>();
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<Regex[]>(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<NotSupportedException>(() => ((IList<HighlightSpan>)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<OracleSpan> 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);
}
@@ -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<Label>(control, "lblGcode");
Assert.False(control.IsHandleCreated);
Assert.False(status.IsHandleCreated);
control.LoadEntities(CreateEntities());
Assert.True(control.IsLoaded);
Assert.False(control.IsDirty);
Assert.NotNull(control.Program);
Assert.True(control.SpansCollectedBeforeTimeout > 0);
AssertCompleteText(control);
AssertUniformBaseColor(GetControl<RichTextBox>(control, "gcodeEditor"));
Assert.Equal("G-Code (highlighting timed out)", status.Text);
Assert.Equal(3, GetControl<EntityView>(control, "preview").Entities.Count);
});
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReverseAndReorderFinishModelPreviewAndEventsWithOrWithoutTimeout(bool timeout) => RunSta(() =>
{
using var control = new TestEditor { TimeoutFromCall = timeout ? 2 : int.MaxValue };
using var form = new Form { Width = 900, Height = 600 };
control.Dock = DockStyle.Fill;
form.Controls.Add(control);
form.Show();
var changes = 0;
control.ProgramChanged += (_, _) => changes++;
control.LoadEntities(CreateEntities());
Assert.Equal(0, changes); // Loading did not raise ProgramChanged before this repair either.
Assert.False(control.IsDirty);
AssertCompleteText(control);
var list = GetControl<ListBox>(control, "contourList");
list.SelectedIndex = 0;
var firstContour = Assert.IsType<ContourInfo>(list.Items[0]);
var oldDirection = firstContour.DirectionLabel;
var oldProgram = control.Program;
var preview = GetControl<EntityView>(control, "preview");
var oldPreview = preview.Entities.ToArray();
GetControl<Button>(control, "reverseButton").PerformClick();
Assert.NotSame(oldProgram, control.Program);
Assert.NotEqual(oldDirection, firstContour.DirectionLabel);
Assert.True(control.IsDirty);
Assert.Equal(1, changes);
AssertPreviewRebuilt(preview, oldPreview);
Assert.Equal(Assert.IsType<Circle>(firstContour.Shape.Entities[0]).Rotation,
Assert.IsType<Circle>(preview.Entities[0]).Rotation);
AssertCompleteText(control);
AssertHighlightingOutcome(control, timeout);
oldProgram = control.Program;
oldPreview = preview.Entities.ToArray();
GetField<ToolStripMenuItem>(control, "menuMoveDown").PerformClick();
Assert.NotSame(oldProgram, control.Program);
Assert.Same(firstContour, list.Items[1]);
Assert.Equal(1, list.SelectedIndex);
Assert.True(control.IsDirty);
Assert.Equal(2, changes);
AssertPreviewRebuilt(preview, oldPreview);
AssertCompleteText(control);
AssertHighlightingOutcome(control, timeout);
Assert.Equal(3, control.MatchCalls);
if (timeout)
Assert.True(control.SpansCollectedBeforeTimeout > 0);
});
[Fact]
public void SuccessfulRetryAndClearRemoveTheNonmodalTimeoutIndication() => RunSta(() =>
{
using var control = new TestEditor { TimeoutFromCall = 1 };
control.LoadEntities(CreateEntities());
var status = GetControl<Label>(control, "lblGcode");
Assert.Contains("timed out", status.Text);
control.TimeoutFromCall = int.MaxValue;
control.LoadEntities(CreateEntities());
Assert.Equal("G-Code", status.Text);
AssertCompleteText(control);
var editor = GetControl<RichTextBox>(control, "gcodeEditor");
editor.Select(0, 3);
Assert.Equal(Color.FromArgb(200, 140, 220), editor.SelectionColor);
control.TimeoutFromCall = 1;
control.LoadEntities(CreateEntities());
Assert.Contains("timed out", status.Text);
control.Clear();
Assert.Equal("G-Code", status.Text);
Assert.Empty(editor.Text);
Assert.False(control.IsLoaded);
Assert.False(control.IsDirty);
Assert.Null(control.Program);
});
[Fact]
public void OrdinaryHighlightingPreservesAllColorsAndUnmatchedCoordinateText() => RunSta(() =>
{
using var control = new TestEditor();
var editor = GetControl<RichTextBox>(control, "gcodeEditor");
editor.Text = ";)\nG91\nG00 X1\nG01 X2\nG02 X3\nG03 X4\nG000";
var original = editor.Text;
ApplyHighlighting(control);
Assert.Equal(original, editor.Text);
Assert.Equal(0, editor.SelectionStart);
Assert.Equal(0, editor.SelectionLength);
var colors = new[]
{
Color.FromArgb(120, 120, 140), Color.FromArgb(200, 140, 220),
Color.FromArgb(230, 180, 80), Color.FromArgb(130, 200, 140),
Color.FromArgb(120, 160, 255),
};
var spans = ProgramHighlighting.ComputeSpans(original);
for (var index = 0; index < editor.TextLength; index++)
{
if (original[index] is '\r' or '\n')
continue;
var expected = BaseColor;
foreach (var span in spans)
if (index >= span.Index && index < span.Index + span.Length)
expected = colors[span.RuleIndex];
editor.Select(index, 1);
Assert.Equal(expected, editor.SelectionColor);
}
});
[Fact]
public void UnexpectedApplicationFailureStillRestoresSelectionAndResumesLayout() => RunSta(() =>
{
using var control = new TestEditor { ReturnInvalidRule = true };
var editor = GetControl<RichTextBox>(control, "gcodeEditor");
Assert.Throws<IndexOutOfRangeException>(() => control.LoadEntities(CreateEntities()));
Assert.Equal(0, editor.SelectionStart);
Assert.Equal(0, editor.SelectionLength);
var layouts = 0;
editor.Layout += (_, _) => layouts++;
editor.PerformLayout();
Assert.True(layouts > 0); // PerformLayout would be deferred if SuspendLayout were unbalanced.
});
[Fact]
public void UnexpectedMatchingFailureIsNotSwallowed() => RunSta(() =>
{
using var control = new TestEditor { FailMatching = true };
var exception = Assert.Throws<InvalidOperationException>(() => control.LoadEntities(CreateEntities()));
Assert.Equal("Unexpected matching failure", exception.Message);
});
private sealed class TestEditor : ProgramEditorControl
{
public int TimeoutFromCall { get; set; } = int.MaxValue;
public int MatchCalls { get; private set; }
public int SpansCollectedBeforeTimeout { get; private set; }
public bool ReturnInvalidRule { get; set; }
public bool FailMatching { get; set; }
internal override IReadOnlyList<HighlightSpan> ComputeHighlightSpans(string text)
{
MatchCalls++;
if (FailMatching)
throw new InvalidOperationException("Unexpected matching failure");
if (MatchCalls >= TimeoutFromCall)
{
// Simulate lazy enumeration succeeding for the comment rule, then timing out
// in a later rule. The local prefix must never reach the color renderer.
var partial = new List<HighlightSpan>();
var comments = new Regex(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100));
foreach (Match match in comments.Matches(text))
partial.Add(new HighlightSpan(match.Index, match.Length, 0));
SpansCollectedBeforeTimeout = partial.Count;
throw new RegexMatchTimeoutException();
}
if (ReturnInvalidRule)
return new[] { new HighlightSpan(0, 3, 99) };
return base.ComputeHighlightSpans(text);
}
}
private static List<Entity> CreateEntities() => new()
{
new Circle(3, 3, 1), new Circle(7, 7, 1), new Circle(10, 10, 10),
};
private static void AssertCompleteText(ProgramEditorControl control)
{
var contours = GetField<List<ContourInfo>>(control, "contours");
var raw = Assert.IsType<string>(typeof(ProgramEditorControl)
.GetMethod("FormatProgram", BindingFlags.Static | BindingFlags.NonPublic)!
.Invoke(null, new object[] { control.Program, contours }));
// RichTextBox performs its existing newline normalization; compare with the same
// generated text assigned to an unhighlighted editor, not with another highlight pass.
using var unhighlighted = new RichTextBox { Text = raw };
var editor = GetControl<RichTextBox>(control, "gcodeEditor");
Assert.NotEmpty(raw);
Assert.Equal(unhighlighted.Text, editor.Text);
Assert.True(editor.ReadOnly);
}
private static void AssertHighlightingOutcome(ProgramEditorControl control, bool timeout)
{
var editor = GetControl<RichTextBox>(control, "gcodeEditor");
Assert.Equal(0, editor.SelectionStart);
Assert.Equal(0, editor.SelectionLength);
Assert.Equal(timeout ? "G-Code (highlighting timed out)" : "G-Code",
GetControl<Label>(control, "lblGcode").Text);
if (timeout)
AssertUniformBaseColor(editor);
}
private static void AssertUniformBaseColor(RichTextBox editor)
{
Assert.Equal(0, editor.SelectionStart);
Assert.Equal(0, editor.SelectionLength);
editor.SelectAll();
Assert.Equal(BaseColor, editor.SelectionColor);
for (var index = 0; index < editor.TextLength; index++)
{
if (editor.Text[index] is '\r' or '\n')
continue;
editor.Select(index, 1);
Assert.Equal(BaseColor, editor.SelectionColor);
}
editor.Select(0, 0);
}
private static void AssertPreviewRebuilt(EntityView preview, Entity[] before)
{
Assert.Equal(before.Length, preview.Entities.Count);
Assert.All(preview.Entities, entity => Assert.DoesNotContain(entity, before));
}
private static T GetControl<T>(Control control, string name) where T : Control =>
Assert.IsType<T>(control.Controls.Find(name, true).Single());
private static T GetField<T>(ProgramEditorControl control, string name) =>
Assert.IsType<T>(typeof(ProgramEditorControl)
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(control));
private static void ApplyHighlighting(ProgramEditorControl control) => typeof(ProgramEditorControl)
.GetMethod("ApplyHighlighting", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(control, null);
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
}
+55 -31
View File
@@ -65,6 +65,7 @@ namespace OpenNest.Controls
preview.Entities.Clear(); preview.Entities.Clear();
preview.Invalidate(); preview.Invalidate();
gcodeEditor.Clear(); gcodeEditor.Clear();
lblGcode.Text = "G-Code";
Program = null; Program = null;
isDirty = false; isDirty = false;
isLoaded = false; isLoaded = false;
@@ -154,44 +155,67 @@ namespace OpenNest.Controls
.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); .ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
} }
internal virtual IReadOnlyList<HighlightSpan> ComputeHighlightSpans(string text) =>
ProgramHighlighting.ComputeSpans(text);
private void ApplyHighlighting() private void ApplyHighlighting()
{ {
var text = gcodeEditor.Text; var text = gcodeEditor.Text;
if (string.IsNullOrEmpty(text)) if (string.IsNullOrEmpty(text))
{
lblGcode.Text = "G-Code";
return; return;
gcodeEditor.SuspendLayout();
var rapidColor = Color.FromArgb(230, 180, 80);
var linearColor = Color.FromArgb(130, 200, 140);
var arcColor = Color.FromArgb(120, 160, 255);
var commentColor = Color.FromArgb(120, 120, 140);
var modeColor = Color.FromArgb(200, 140, 220);
var coordColor = Color.FromArgb(180, 200, 180);
gcodeEditor.SelectAll();
gcodeEditor.SelectionColor = coordColor;
var rules = new (Regex pattern, Color color)[]
{
(new Regex(@"^;.*$", RegexOptions.Multiline), commentColor),
(new Regex(@"^G9[01]\b", RegexOptions.Multiline), modeColor),
(new Regex(@"^G00\b", RegexOptions.Multiline), rapidColor),
(new Regex(@"^G01\b", RegexOptions.Multiline), linearColor),
(new Regex(@"^G0[23]\b", RegexOptions.Multiline), arcColor),
};
foreach (var (pattern, color) in rules)
{
foreach (Match match in pattern.Matches(text))
{
gcodeEditor.Select(match.Index, match.Length);
gcodeEditor.SelectionColor = color;
}
} }
gcodeEditor.Select(0, 0); IReadOnlyList<HighlightSpan> spans;
gcodeEditor.ResumeLayout(); var timedOut = false;
try
{
// Complete lazy regex enumeration before applying any rule colors.
spans = ComputeHighlightSpans(text);
}
catch (RegexMatchTimeoutException)
{
spans = Array.Empty<HighlightSpan>();
timedOut = true;
}
var colors = new[]
{
Color.FromArgb(120, 120, 140), // Comment
Color.FromArgb(200, 140, 220), // Mode
Color.FromArgb(230, 180, 80), // Rapid
Color.FromArgb(130, 200, 140), // Linear
Color.FromArgb(120, 160, 255), // Arc
};
var coordColor = Color.FromArgb(180, 200, 180);
gcodeEditor.SuspendLayout();
try
{
gcodeEditor.SelectAll();
gcodeEditor.SelectionColor = coordColor;
foreach (var span in spans)
{
gcodeEditor.Select(span.Index, span.Length);
gcodeEditor.SelectionColor = colors[span.RuleIndex];
}
// Reuse the existing toolbar label; no modal UI or handle-dependent invoke.
lblGcode.Text = timedOut ? "G-Code (highlighting timed out)" : "G-Code";
}
finally
{
try
{
gcodeEditor.Select(0, 0);
}
finally
{
gcodeEditor.ResumeLayout();
}
}
} }
private void RefreshPreview() private void RefreshPreview()