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.Invalidate();
|
||||
gcodeEditor.Clear();
|
||||
lblGcode.Text = "G-Code";
|
||||
Program = null;
|
||||
isDirty = false;
|
||||
isLoaded = false;
|
||||
@@ -154,45 +155,68 @@ namespace OpenNest.Controls
|
||||
.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
internal virtual IReadOnlyList<HighlightSpan> ComputeHighlightSpans(string text) =>
|
||||
ProgramHighlighting.ComputeSpans(text);
|
||||
|
||||
private void ApplyHighlighting()
|
||||
{
|
||||
var text = gcodeEditor.Text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
lblGcode.Text = "G-Code";
|
||||
return;
|
||||
}
|
||||
|
||||
gcodeEditor.SuspendLayout();
|
||||
IReadOnlyList<HighlightSpan> spans;
|
||||
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 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 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;
|
||||
|
||||
var rules = new (Regex pattern, Color color)[]
|
||||
foreach (var span in spans)
|
||||
{
|
||||
(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(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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user