fix(editor): bound highlighting without interrupting program updates
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user