From 1bc13175ab2e5f4bbef1687328516941ee0dbd6b Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sun, 29 Mar 2026 23:15:19 -0500 Subject: [PATCH 01/29] feat: add plain-text FileMaker script editor with bidirectional XML conversion Add a Script tab to the clip editor that renders FileMaker clipboard XML as human-readable script syntax and converts edits back to valid XML. This enables authoring scripts without touching raw XML. Core converter (ported from agentic-fm, Apache 2.0): - Step catalog with all 206 FM script steps loaded from embedded JSON - Generic renderer handles any step via catalog metadata - Specialized renderers for Set Variable, Set Field, Perform Script, Go to Layout, Go to Record, Show Custom Dialog, and control flow - Multi-line calculation support with bracket-aware line merging - Pretty-printed XML output on HR-to-XML conversion Editor features: - Custom TextMate grammar for FM script syntax highlighting - Autocomplete for step names, param labels, and valid enum/boolean values - Validation with squiggly underlines (unknown steps, invalid values, unmatched block pairs) and hover tooltips - Bracket matching for [ ] pairs - Multi-line statement background highlighting - Tab-switch sync between XML and Script views Test suite: 86 xUnit tests covering catalog loading, line parsing, XML-to-HR, HR-to-XML, round-trips, validation, and completions. --- Core/ScriptConverter/BracketMatchRenderer.cs | 137 + Core/ScriptConverter/ErrorMarkerRenderer.cs | 112 + .../ScriptConverter/FmScriptCompletionData.cs | 29 + .../FmScriptCompletionProvider.cs | 178 + .../FmScriptRegistryOptions.cs | 48 + Core/ScriptConverter/GenericStepRenderer.cs | 320 + Core/ScriptConverter/HrToXmlConverter.cs | 135 + Core/ScriptConverter/IStepRenderer.cs | 9 + Core/ScriptConverter/ParsedLine.cs | 9 + .../Renderers/CommentStepRenderer.cs | 28 + .../Renderers/ControlFlowRenderer.cs | 78 + .../Renderers/GoToLayoutRenderer.cs | 75 + .../Renderers/GoToRecordRenderer.cs | 69 + .../Renderers/IMultiStepRenderer.cs | 6 + .../Renderers/PerformScriptRenderer.cs | 48 + .../Renderers/SetFieldRenderer.cs | 69 + .../Renderers/SetVariableRenderer.cs | 67 + .../Renderers/ShowCustomDialogRenderer.cs | 72 + Core/ScriptConverter/ScriptDiagnostic.cs | 16 + Core/ScriptConverter/ScriptLineParser.cs | 199 + Core/ScriptConverter/ScriptValidator.cs | 199 + .../StatementHighlightRenderer.cs | 140 + Core/ScriptConverter/StepCatalog.cs | 101 + Core/ScriptConverter/StepCatalogLoader.cs | 57 + Core/ScriptConverter/StepRendererRegistry.cs | 45 + Core/ScriptConverter/XmlToHrConverter.cs | 118 + Core/ScriptConverter/fmscript.tmLanguage.json | 107 + Core/ScriptConverter/step-catalog-en.json | 8306 +++++++++++++++++ MainWindow.axaml | 46 +- MainWindow.axaml.cs | 135 +- SharpFM.csproj | 14 + SharpFM.sln | 11 + THIRD_PARTY_NOTICES | 24 + ViewModels/ClipViewModel.cs | 70 +- tests/SharpFM.Tests/GlobalUsings.cs | 1 + .../CompletionProviderTests.cs | 87 + .../ScriptConverter/HrToXmlConverterTests.cs | 169 + .../ScriptConverter/RoundTripTests.cs | 282 + .../ScriptConverter/ScriptLineParserTests.cs | 165 + .../ScriptConverter/ScriptValidatorTests.cs | 124 + .../ScriptConverter/StepCatalogLoaderTests.cs | 86 + .../ScriptConverter/XmlToHrConverterTests.cs | 161 + tests/SharpFM.Tests/SharpFM.Tests.csproj | 29 + 43 files changed, 12147 insertions(+), 34 deletions(-) create mode 100644 Core/ScriptConverter/BracketMatchRenderer.cs create mode 100644 Core/ScriptConverter/ErrorMarkerRenderer.cs create mode 100644 Core/ScriptConverter/FmScriptCompletionData.cs create mode 100644 Core/ScriptConverter/FmScriptCompletionProvider.cs create mode 100644 Core/ScriptConverter/FmScriptRegistryOptions.cs create mode 100644 Core/ScriptConverter/GenericStepRenderer.cs create mode 100644 Core/ScriptConverter/HrToXmlConverter.cs create mode 100644 Core/ScriptConverter/IStepRenderer.cs create mode 100644 Core/ScriptConverter/ParsedLine.cs create mode 100644 Core/ScriptConverter/Renderers/CommentStepRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/ControlFlowRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/GoToLayoutRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/GoToRecordRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/IMultiStepRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/PerformScriptRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/SetFieldRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/SetVariableRenderer.cs create mode 100644 Core/ScriptConverter/Renderers/ShowCustomDialogRenderer.cs create mode 100644 Core/ScriptConverter/ScriptDiagnostic.cs create mode 100644 Core/ScriptConverter/ScriptLineParser.cs create mode 100644 Core/ScriptConverter/ScriptValidator.cs create mode 100644 Core/ScriptConverter/StatementHighlightRenderer.cs create mode 100644 Core/ScriptConverter/StepCatalog.cs create mode 100644 Core/ScriptConverter/StepCatalogLoader.cs create mode 100644 Core/ScriptConverter/StepRendererRegistry.cs create mode 100644 Core/ScriptConverter/XmlToHrConverter.cs create mode 100644 Core/ScriptConverter/fmscript.tmLanguage.json create mode 100644 Core/ScriptConverter/step-catalog-en.json create mode 100644 THIRD_PARTY_NOTICES create mode 100644 tests/SharpFM.Tests/GlobalUsings.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/CompletionProviderTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/HrToXmlConverterTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/RoundTripTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/ScriptLineParserTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/XmlToHrConverterTests.cs create mode 100644 tests/SharpFM.Tests/SharpFM.Tests.csproj diff --git a/Core/ScriptConverter/BracketMatchRenderer.cs b/Core/ScriptConverter/BracketMatchRenderer.cs new file mode 100644 index 0000000..42831ef --- /dev/null +++ b/Core/ScriptConverter/BracketMatchRenderer.cs @@ -0,0 +1,137 @@ +using System; +using Avalonia; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace SharpFM.Core.ScriptConverter; + +public class BracketMatchRenderer : IBackgroundRenderer +{ + private static readonly IBrush MatchBrush = new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)); + private static readonly IPen MatchPen = new Pen(new SolidColorBrush(Color.FromArgb(100, 255, 255, 255)), 1.0); + + private readonly TextArea _textArea; + private int _openOffset = -1; + private int _closeOffset = -1; + + public BracketMatchRenderer(TextArea textArea) + { + _textArea = textArea; + _textArea.Caret.PositionChanged += (_, _) => UpdateBracketMatch(); + } + + public KnownLayer Layer => KnownLayer.Selection; + + public void UpdateBracketMatch() + { + var oldOpen = _openOffset; + var oldClose = _closeOffset; + _openOffset = -1; + _closeOffset = -1; + + var doc = _textArea.Document; + if (doc == null) return; + + var offset = _textArea.Caret.Offset; + if (offset <= 0 || offset > doc.TextLength) return; + + // Check character before caret and at caret + var charBefore = offset > 0 ? doc.GetCharAt(offset - 1) : '\0'; + var charAt = offset < doc.TextLength ? doc.GetCharAt(offset) : '\0'; + + if (charBefore == '[') + { + var match = FindMatchingClose(doc.Text, offset - 1); + if (match >= 0) + { + _openOffset = offset - 1; + _closeOffset = match; + } + } + else if (charBefore == ']') + { + var match = FindMatchingOpen(doc.Text, offset - 2); + if (match >= 0) + { + _openOffset = match; + _closeOffset = offset - 1; + } + } + else if (charAt == '[') + { + var match = FindMatchingClose(doc.Text, offset); + if (match >= 0) + { + _openOffset = offset; + _closeOffset = match; + } + } + else if (charAt == ']') + { + var match = FindMatchingOpen(doc.Text, offset - 1); + if (match >= 0) + { + _openOffset = match; + _closeOffset = offset; + } + } + + if (_openOffset != oldOpen || _closeOffset != oldClose) + _textArea.TextView.InvalidateLayer(Layer); + } + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (_openOffset < 0 || _closeOffset < 0) return; + + DrawBracketHighlight(textView, drawingContext, _openOffset); + DrawBracketHighlight(textView, drawingContext, _closeOffset); + } + + private static void DrawBracketHighlight(TextView textView, DrawingContext context, int offset) + { + var segment = new TextSegment { StartOffset = offset, EndOffset = offset + 1 }; + foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) + { + context.DrawRectangle(MatchBrush, MatchPen, rect); + } + } + + private static int FindMatchingClose(string text, int openPos) + { + int depth = 1; + bool inQuote = false; + for (int i = openPos + 1; i < text.Length; i++) + { + var c = text[i]; + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == '[') depth++; + else if (!inQuote && c == ']') + { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + private static int FindMatchingOpen(string text, int closePos) + { + int depth = 1; + bool inQuote = false; + for (int i = closePos; i >= 0; i--) + { + var c = text[i]; + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == ']') depth++; + else if (!inQuote && c == '[') + { + depth--; + if (depth == 0) return i; + } + } + return -1; + } +} diff --git a/Core/ScriptConverter/ErrorMarkerRenderer.cs b/Core/ScriptConverter/ErrorMarkerRenderer.cs new file mode 100644 index 0000000..b47177c --- /dev/null +++ b/Core/ScriptConverter/ErrorMarkerRenderer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; + +namespace SharpFM.Core.ScriptConverter; + +public class ErrorMarkerRenderer : IBackgroundRenderer +{ + private readonly TextDocument _document; + private List _diagnostics = new(); + + private static readonly IPen ErrorPen = new Pen(Brushes.Red, 1.0); + private static readonly IPen WarningPen = new Pen(Brushes.Gold, 1.0); + + public ErrorMarkerRenderer(TextDocument document) + { + _document = document; + } + + public KnownLayer Layer => KnownLayer.Selection; + + public void UpdateDiagnostics(List diagnostics) + { + _diagnostics = diagnostics; + } + + public ScriptDiagnostic? GetDiagnosticAtOffset(int offset) + { + if (_diagnostics.Count == 0 || offset < 0 || offset >= _document.TextLength) + return null; + + var location = _document.GetLocation(offset); + var lineIndex = location.Line - 1; // 1-indexed to 0-indexed + + foreach (var diag in _diagnostics) + { + if (diag.Line != lineIndex) continue; + + var col = location.Column - 1; // 1-indexed to 0-indexed + var startCol = diag.StartCol; + var endCol = diag.EndCol; + + // If no specific span, the whole line is the target + if (startCol >= endCol) + { + return diag; + } + + if (col >= startCol && col <= endCol) + return diag; + } + + return null; + } + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (_diagnostics.Count == 0) return; + + foreach (var diag in _diagnostics) + { + if (diag.Line < 0 || diag.Line >= _document.LineCount) + continue; + + var docLine = _document.GetLineByNumber(diag.Line + 1); // 0-indexed to 1-indexed + var startOffset = docLine.Offset + Math.Min(diag.StartCol, docLine.Length); + var endOffset = docLine.Offset + Math.Min(diag.EndCol, docLine.Length); + + if (startOffset >= endOffset) + { + // If no span, underline the whole line content + startOffset = docLine.Offset; + endOffset = docLine.EndOffset; + } + + var segment = new TextSegment { StartOffset = startOffset, EndOffset = endOffset }; + var pen = diag.Severity == DiagnosticSeverity.Error ? ErrorPen : WarningPen; + + foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) + { + DrawZigzag(drawingContext, pen, rect); + } + } + } + + private static void DrawZigzag(DrawingContext context, IPen pen, Rect rect) + { + const double zigLength = 3; + const double zigHeight = 2; + + var y = rect.Bottom; + var startX = rect.Left; + var endX = rect.Right; + + var geometry = new StreamGeometry(); + using (var ctx = geometry.Open()) + { + ctx.BeginFigure(new Point(startX, y), false); + bool up = true; + for (double x = startX + zigLength; x <= endX; x += zigLength) + { + ctx.LineTo(new Point(x, up ? y - zigHeight : y)); + up = !up; + } + } + + context.DrawGeometry(null, pen, geometry); + } +} diff --git a/Core/ScriptConverter/FmScriptCompletionData.cs b/Core/ScriptConverter/FmScriptCompletionData.cs new file mode 100644 index 0000000..3ca8d39 --- /dev/null +++ b/Core/ScriptConverter/FmScriptCompletionData.cs @@ -0,0 +1,29 @@ +using System; +using Avalonia.Controls; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.CodeCompletion; + +namespace SharpFM.Core.ScriptConverter; + +public class FmScriptCompletionData : ICompletionData +{ + public FmScriptCompletionData(string text, string? description = null, double priority = 0) + { + Text = text; + Description = description ?? text; + Priority = priority; + } + + public IImage? Image => null; + public string Text { get; } + public object Content => new TextBlock { Text = Text }; + public object Description { get; } + public double Priority { get; } + + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + textArea.Document.Replace(completionSegment, Text); + } +} diff --git a/Core/ScriptConverter/FmScriptCompletionProvider.cs b/Core/ScriptConverter/FmScriptCompletionProvider.cs new file mode 100644 index 0000000..94108db --- /dev/null +++ b/Core/ScriptConverter/FmScriptCompletionProvider.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AvaloniaEdit.CodeCompletion; + +namespace SharpFM.Core.ScriptConverter; + +public enum CompletionContext +{ + StepName, + ParamLabel, + ParamValue, + None +} + +public static class FmScriptCompletionProvider +{ + public static (CompletionContext Context, IList Items) GetCompletions( + string lineText, int caretColumn) + { + var trimmed = lineText.TrimStart(); + + // Empty or start of line → suggest step names + if (string.IsNullOrWhiteSpace(trimmed) || !trimmed.Contains('[')) + { + var items = GetStepNameCompletions(trimmed); + return (CompletionContext.StepName, items); + } + + // Inside brackets → determine if we're after a label or need a label + var bracketPos = lineText.IndexOf('['); + if (bracketPos >= 0 && caretColumn > bracketPos) + { + var insideBrackets = lineText.Substring(bracketPos + 1); + var stepName = lineText.Substring(0, bracketPos).Trim(); + + // Check for disabled prefix + if (stepName.StartsWith("//")) + stepName = stepName.Substring(2).TrimStart(); + + if (!StepCatalogLoader.ByName.TryGetValue(stepName, out var definition)) + return (CompletionContext.None, Array.Empty()); + + // Find what the user is currently typing after the last semicolon + var lastSemicolon = insideBrackets.LastIndexOf(';'); + var currentSegment = lastSemicolon >= 0 + ? insideBrackets.Substring(lastSemicolon + 1).TrimStart() + : insideBrackets.TrimStart(); + + // Check if we're after a label (e.g., "With dialog: ") + var colonPos = currentSegment.IndexOf(':'); + if (colonPos >= 0) + { + var label = currentSegment.Substring(0, colonPos).Trim(); + var matchingParam = definition.Params + .FirstOrDefault(p => p.HrLabel != null && + p.HrLabel.Equals(label, StringComparison.OrdinalIgnoreCase)); + + if (matchingParam != null) + { + var items = GetParamValueCompletions(matchingParam); + return (CompletionContext.ParamValue, items); + } + } + + // Suggest param labels + valid values for unlabeled positional params + var labelItems = GetParamLabelCompletions(definition, insideBrackets); + var valueItems = GetPositionalValueCompletions(definition, insideBrackets); + + if (valueItems.Count > 0 && labelItems.Count > 0) + { + // Combine: values first (more immediately useful), then labels + foreach (var item in labelItems) + valueItems.Add(item); + return (CompletionContext.ParamValue, valueItems); + } + + if (valueItems.Count > 0) + return (CompletionContext.ParamValue, valueItems); + + return (CompletionContext.ParamLabel, labelItems); + } + + return (CompletionContext.None, Array.Empty()); + } + + private static IList GetStepNameCompletions(string prefix) + { + return StepCatalogLoader.All + .Where(s => s.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(prefix)) + .OrderBy(s => s.Name) + .Select(s => + { + var desc = s.Category; + if (s.BlockPair != null) desc += $" ({s.BlockPair.Role})"; + if (!string.IsNullOrEmpty(s.HrSignature)) desc += $" {s.HrSignature}"; + return (ICompletionData)new FmScriptCompletionData(s.Name, desc); + }) + .ToList(); + } + + private static IList GetParamLabelCompletions( + StepDefinition definition, string existingParams) + { + var items = new List(); + + foreach (var param in definition.Params) + { + // Use hrLabel, or wrapperElement as synthetic label for namedCalc params + var label = param.HrLabel + ?? (param.Type == "namedCalc" && param.WrapperElement != null + ? param.WrapperElement : null); + if (label == null) continue; + + // Skip labels already used + if (existingParams.Contains(label + ":", StringComparison.OrdinalIgnoreCase)) + continue; + + var desc = $"{param.Type}"; + var validValues = ScriptValidator.GetValidValues(param); + if (validValues.Count > 0) + desc += $" ({string.Join("|", validValues)})"; + + items.Add(new FmScriptCompletionData(label + ": ", desc)); + } + + return items; + } + + private static IList GetParamValueCompletions(StepParam param) + { + var validValues = ScriptValidator.GetValidValues(param); + return validValues + .Select(v => (ICompletionData)new FmScriptCompletionData(v)) + .ToList(); + } + + private static IList GetPositionalValueCompletions( + StepDefinition definition, string existingParams) + { + var items = new List(); + + // Count how many unlabeled positional params have been filled + var segments = existingParams.Split(';') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + + int positionalIndex = 0; + foreach (var seg in segments) + { + // If it has a label (contains ":"), it's not positional + bool hasLabel = definition.Params.Any(p => + p.HrLabel != null && seg.StartsWith(p.HrLabel + ":", StringComparison.OrdinalIgnoreCase)); + if (!hasLabel) + positionalIndex++; + } + + // Find the next unlabeled param with valid values + int unlabeledCount = 0; + foreach (var param in definition.Params) + { + if (param.HrLabel != null) continue; + + if (unlabeledCount == positionalIndex) + { + var values = ScriptValidator.GetValidValues(param); + foreach (var v in values) + items.Add(new FmScriptCompletionData(v, $"{param.XmlElement}")); + break; + } + unlabeledCount++; + } + + return items; + } +} diff --git a/Core/ScriptConverter/FmScriptRegistryOptions.cs b/Core/ScriptConverter/FmScriptRegistryOptions.cs new file mode 100644 index 0000000..57490a4 --- /dev/null +++ b/Core/ScriptConverter/FmScriptRegistryOptions.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using TextMateSharp.Grammars; +using TextMateSharp.Internal.Grammars.Reader; +using TextMateSharp.Internal.Types; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +namespace SharpFM.Core.ScriptConverter; + +public class FmScriptRegistryOptions : IRegistryOptions +{ + public const string ScopeName = "source.fmscript"; + + private readonly RegistryOptions _inner; + + public FmScriptRegistryOptions(RegistryOptions inner) + { + _inner = inner; + } + + public IRawTheme GetDefaultTheme() => _inner.GetDefaultTheme(); + + public IRawTheme GetTheme(string scopeName) => _inner.GetTheme(scopeName); + + public ICollection GetInjections(string scopeName) => _inner.GetInjections(scopeName); + + public IRawGrammar GetGrammar(string scopeName) + { + if (scopeName == ScopeName) + return LoadFmScriptGrammar(); + + return _inner.GetGrammar(scopeName); + } + + private static IRawGrammar LoadFmScriptGrammar() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly.GetManifestResourceNames() + [System.Array.FindIndex(assembly.GetManifestResourceNames(), + n => n.EndsWith("fmscript.tmLanguage.json"))]; + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + return GrammarReader.ReadGrammarSync(reader); + } +} diff --git a/Core/ScriptConverter/GenericStepRenderer.cs b/Core/ScriptConverter/GenericStepRenderer.cs new file mode 100644 index 0000000..e2dde99 --- /dev/null +++ b/Core/ScriptConverter/GenericStepRenderer.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter; + +public class GenericStepRenderer : IStepRenderer +{ + public string ToHr(XElement step, StepDefinition definition) + { + var parts = new List(); + + foreach (var param in definition.Params) + { + var value = ExtractParamValue(step, param); + if (value == null) + continue; + + var label = param.HrLabel + ?? (param.Type == "namedCalc" && param.WrapperElement != null ? param.WrapperElement : null); + + if (label != null) + parts.Add($"{label}: {value}"); + else + parts.Add(value); + } + + if (parts.Count == 0) + return definition.Name; + + return $"{definition.Name} [ {string.Join(" ; ", parts)} ]"; + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var sb = new StringBuilder(); + var enable = line.Disabled ? "False" : "True"; + sb.Append($""); + return sb.ToString(); + } + + sb.Append('>'); + + var matchedParams = MatchParams(line.Params, definition.Params); + for (int i = 0; i < definition.Params.Length; i++) + { + var param = definition.Params[i]; + var value = i < matchedParams.Length ? matchedParams[i] : null; + var xml = BuildParamXml(param, value); + if (xml != null) + sb.Append(xml); + } + + sb.Append(""); + return sb.ToString(); + } + + private static string? ExtractParamValue(XElement step, StepParam param) + { + var element = FindParamElement(step, param); + if (element == null) + return null; + + return param.Type switch + { + "calculation" or "calc" => ExtractCalculation(element), + "namedCalc" => ExtractCalculation(element), + "text" => element.Value, + "boolean" => ExtractBoolean(element, param), + "flagBoolean" or "flagElement" => ExtractBoolean(element, param), + "enum" => ExtractEnum(element, param), + "field" or "fieldOrVariable" => ExtractField(element), + "script" => ExtractNamedRef(element), + "layout" or "layoutRef" => ExtractNamedRef(element), + "tableOccurrence" or "tableRef" or "tableReference" => ExtractNamedRef(element), + _ => element.Value.Length > 0 ? element.Value : null + }; + } + + private static XElement? FindParamElement(XElement step, StepParam param) + { + if (param.ParentElement != null) + { + var parent = step.Element(param.ParentElement); + return parent?.Element(param.XmlElement); + } + // namedCalc params use wrapperElement as the container in XML + if (param.WrapperElement != null) + { + var wrapper = step.Element(param.WrapperElement); + return wrapper?.Element(param.XmlElement); + } + return step.Element(param.XmlElement); + } + + private static string? ExtractCalculation(XElement? element) + { + if (element == null) return null; + var value = element.Value; + return string.IsNullOrEmpty(value) ? null : value; + } + + private static string? ExtractBoolean(XElement element, StepParam param) + { + var attr = param.XmlAttr ?? "state"; + var raw = element.Attribute(attr)?.Value; + if (raw == null) return null; + + if (param.HrEnumValues != null && param.HrEnumValues.TryGetValue(raw, out var hrVal)) + return hrVal; + + if (param.InvertedHr) + raw = raw == "True" ? "Off" : "On"; + else if (param.HrValues is { Length: >= 2 }) + raw = raw == "True" ? param.HrValues[0] : param.HrValues[1]; + else + raw = raw == "True" ? "On" : "Off"; + + return raw; + } + + private static string? ExtractEnum(XElement element, StepParam param) + { + var attr = param.XmlAttr ?? "value"; + var raw = element.Attribute(attr)?.Value ?? element.Value; + if (string.IsNullOrEmpty(raw)) return null; + + if (param.HrEnumValues != null && param.HrEnumValues.TryGetValue(raw, out var hrVal)) + return hrVal; + + return raw; + } + + private static string? ExtractField(XElement element) + { + var table = element.Attribute("table")?.Value; + var name = element.Attribute("name")?.Value; + + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(name)) + return $"{table}::{name}"; + + // Variable reference stored as text content + var text = element.Value; + return string.IsNullOrEmpty(text) ? null : text; + } + + private static string? ExtractNamedRef(XElement element) + { + var name = element.Attribute("name")?.Value; + return string.IsNullOrEmpty(name) ? null : $"\"{name}\""; + } + + internal static string?[] MatchParams(string[] hrParams, StepParam[] catalogParams) + { + var result = new string?[catalogParams.Length]; + var used = new bool[hrParams.Length]; + + // First pass: match by label (including synthetic labels from wrapperElement) + for (int ci = 0; ci < catalogParams.Length; ci++) + { + var label = catalogParams[ci].HrLabel + ?? (catalogParams[ci].Type == "namedCalc" && catalogParams[ci].WrapperElement != null + ? catalogParams[ci].WrapperElement : null); + if (label == null) continue; + for (int hi = 0; hi < hrParams.Length; hi++) + { + if (used[hi]) continue; + var stripped = StripLabel(hrParams[hi], label); + if (stripped != null) + { + result[ci] = stripped; + used[hi] = true; + break; + } + } + } + + // Second pass: positional fill + int nextHr = 0; + for (int ci = 0; ci < catalogParams.Length; ci++) + { + if (result[ci] != null) continue; + while (nextHr < hrParams.Length && used[nextHr]) nextHr++; + if (nextHr < hrParams.Length) + { + result[ci] = hrParams[nextHr].Trim(); + used[nextHr] = true; + nextHr++; + } + } + + return result; + } + + private static string? StripLabel(string param, string label) + { + var trimmed = param.TrimStart(); + if (trimmed.StartsWith(label + ":", StringComparison.OrdinalIgnoreCase)) + return trimmed.Substring(label.Length + 1).TrimStart(); + return null; + } + + private static string? BuildParamXml(StepParam param, string? value) + { + return param.Type switch + { + "calculation" or "calc" => BuildCalculationXml(param, value), + "namedCalc" => BuildNamedCalcXml(param, value), + "text" => value != null ? $"<{param.XmlElement}>{XmlEscape(value)}" : null, + "boolean" or "flagBoolean" or "flagElement" => BuildBooleanXml(param, value), + "enum" => BuildEnumXml(param, value), + "field" or "fieldOrVariable" => BuildFieldXml(param, value), + "script" => BuildNamedRefXml(param, value), + "layout" or "layoutRef" => BuildNamedRefXml(param, value), + "tableOccurrence" or "tableRef" or "tableReference" => BuildNamedRefXml(param, value), + _ => null + }; + } + + private static string BuildCalculationXml(StepParam param, string? value) + { + var calc = value ?? ""; + return $"<{param.XmlElement}>"; + } + + private static string BuildNamedCalcXml(StepParam param, string? value) + { + var calc = value ?? ""; + var wrapper = param.WrapperElement ?? param.XmlElement; + return $"<{wrapper}>"; + } + + private static string? BuildBooleanXml(StepParam param, string? value) + { + var attr = param.XmlAttr ?? "state"; + string xmlValue; + + if (value == null) + { + xmlValue = param.DefaultValue ?? "False"; + } + else if (param.HrEnumValues != null) + { + // Reverse lookup: find xml value from HR value + xmlValue = param.HrEnumValues + .FirstOrDefault(kv => kv.Value != null && kv.Value.Equals(value, StringComparison.OrdinalIgnoreCase)).Key + ?? param.DefaultValue ?? "False"; + } + else if (param.InvertedHr) + { + xmlValue = value.Equals("On", StringComparison.OrdinalIgnoreCase) ? "False" : "True"; + } + else + { + xmlValue = value.Equals("On", StringComparison.OrdinalIgnoreCase) || + value.Equals("True", StringComparison.OrdinalIgnoreCase) ? "True" : "False"; + } + + return $"<{param.XmlElement} {attr}=\"{xmlValue}\"/>"; + } + + private static string? BuildEnumXml(StepParam param, string? value) + { + if (value == null && param.DefaultValue == null) return null; + var attr = param.XmlAttr ?? "value"; + var xmlValue = value ?? param.DefaultValue ?? ""; + + // Reverse lookup for hrEnumValues + if (param.HrEnumValues != null) + { + var reverse = param.HrEnumValues + .FirstOrDefault(kv => kv.Value != null && kv.Value.Equals(xmlValue, StringComparison.OrdinalIgnoreCase)).Key; + if (reverse != null) + xmlValue = reverse; + } + + return $"<{param.XmlElement} {attr}=\"{XmlEscape(xmlValue)}\"/>"; + } + + private static string? BuildFieldXml(StepParam param, string? value) + { + if (value == null) return $"<{param.XmlElement} table=\"\" id=\"0\" name=\"\"/>"; + + if (value.Contains("::")) + { + var parts = value.Split("::", 2); + return $"<{param.XmlElement} table=\"{XmlEscape(parts[0])}\" id=\"0\" name=\"{XmlEscape(parts[1])}\"/>"; + } + + // Variable reference + return $"<{param.XmlElement}>{XmlEscape(value)}"; + } + + private static string? BuildNamedRefXml(StepParam param, string? value) + { + var name = value != null ? Unquote(value) : ""; + return $"<{param.XmlElement} id=\"0\" name=\"{XmlEscape(name)}\"/>"; + } + + internal static string Unquote(string s) + { + if (s.Length >= 2 && s[0] == '"' && s[^1] == '"') + return s[1..^1]; + return s; + } + + internal static string XmlEscape(string s) + { + return s.Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + } +} diff --git a/Core/ScriptConverter/HrToXmlConverter.cs b/Core/ScriptConverter/HrToXmlConverter.cs new file mode 100644 index 0000000..a83514b --- /dev/null +++ b/Core/ScriptConverter/HrToXmlConverter.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter; + +public record ConversionResult(string Xml, List Errors); + +public static class HrToXmlConverter +{ + public static ConversionResult Convert(string hrText) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(hrText)) + return new ConversionResult(PrettyPrint(WrapSnippet("")), errors); + + var lines = ScriptLineParser.Parse(hrText); + var mergedLines = MergeCommentContinuations(lines); + var sb = new StringBuilder(); + + for (int i = 0; i < mergedLines.Count; i++) + { + var line = mergedLines[i]; + try + { + var stepXml = ConvertLine(line); + sb.Append(stepXml); + } + catch (Exception ex) + { + errors.Add($"Line {i + 1}: {ex.Message}"); + // Fallback: emit as comment preserving original text + var escaped = GenericStepRenderer.XmlEscape(line.RawLine.Trim()); + sb.Append($"{escaped}"); + } + } + + return new ConversionResult(PrettyPrint(WrapSnippet(sb.ToString())), errors); + } + + private static string ConvertLine(ParsedLine line) + { + if (line.IsComment) + { + var renderer = StepRendererRegistry.GetRenderer("# (comment)"); + var def = StepCatalogLoader.ByName["# (comment)"]; + return renderer.ToXml(line, def); + } + + if (StepCatalogLoader.ByName.TryGetValue(line.StepName, out var definition)) + { + var renderer = StepRendererRegistry.GetRenderer(line.StepName); + return renderer.ToXml(line, definition); + } + + // Unknown bare text → new comment step + var text = GenericStepRenderer.XmlEscape(line.RawLine.Trim()); + return $"{text}"; + } + + private static bool IsBareText(ParsedLine line) + { + return !line.IsComment + && line.Params.Length == 0 + && !StepCatalogLoader.ByName.ContainsKey(line.StepName); + } + + private static List MergeCommentContinuations(List lines) + { + var result = new List(); + + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + bool shouldMerge = result.Count > 0 && result[^1].IsComment + && (IsBareText(line) || line.IsComment); + + if (shouldMerge) + { + var prev = result[^1]; + var prevText = prev.Params.Length > 0 ? prev.Params[0] : ""; + var thisText = line.IsComment && line.Params.Length > 0 + ? line.Params[0] + : line.RawLine.Trim(); + var mergedText = string.IsNullOrEmpty(prevText) + ? thisText + : prevText + "\n" + thisText; + result[^1] = new ParsedLine( + prev.StepName, + new[] { mergedText }, + prev.Disabled, + true, + prev.RawLine + "\n" + line.RawLine); + } + else + { + result.Add(line); + } + } + + return result; + } + + private static string WrapSnippet(string stepsXml) + { + return $"{stepsXml}"; + } + + private static string PrettyPrint(string xml) + { + try + { + var element = XElement.Parse(xml); + var sb = new StringBuilder(); + var settings = new XmlWriterSettings + { + OmitXmlDeclaration = true, + Indent = true, + NewLineOnAttributes = false + }; + using (var writer = XmlWriter.Create(sb, settings)) + { + element.Save(writer); + } + return sb.ToString(); + } + catch + { + return xml; + } + } +} diff --git a/Core/ScriptConverter/IStepRenderer.cs b/Core/ScriptConverter/IStepRenderer.cs new file mode 100644 index 0000000..af35f3d --- /dev/null +++ b/Core/ScriptConverter/IStepRenderer.cs @@ -0,0 +1,9 @@ +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter; + +public interface IStepRenderer +{ + string ToHr(XElement step, StepDefinition definition); + string ToXml(ParsedLine line, StepDefinition definition); +} diff --git a/Core/ScriptConverter/ParsedLine.cs b/Core/ScriptConverter/ParsedLine.cs new file mode 100644 index 0000000..523222b --- /dev/null +++ b/Core/ScriptConverter/ParsedLine.cs @@ -0,0 +1,9 @@ +namespace SharpFM.Core.ScriptConverter; + +public record ParsedLine( + string StepName, + string[] Params, + bool Disabled, + bool IsComment, + string RawLine +); diff --git a/Core/ScriptConverter/Renderers/CommentStepRenderer.cs b/Core/ScriptConverter/Renderers/CommentStepRenderer.cs new file mode 100644 index 0000000..515c58c --- /dev/null +++ b/Core/ScriptConverter/Renderers/CommentStepRenderer.cs @@ -0,0 +1,28 @@ +using System.Linq; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.Renderers; + +public class CommentStepRenderer : IMultiStepRenderer +{ + public string[] StepNames => ["# (comment)"]; + + public string ToHr(XElement step, StepDefinition definition) + { + var text = step.Element("Text")?.Value ?? ""; + if (text.Contains('\n')) + { + // Prefix each line with # so the grammar highlights them all + var lines = text.Split('\n'); + return string.Join("\n", lines.Select(l => $"# {l.TrimEnd('\r')}")); + } + return $"# {text}"; + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var enable = line.Disabled ? "False" : "True"; + var text = line.Params.Length > 0 ? line.Params[0] : ""; + return $"{GenericStepRenderer.XmlEscape(text)}"; + } +} diff --git a/Core/ScriptConverter/Renderers/ControlFlowRenderer.cs b/Core/ScriptConverter/Renderers/ControlFlowRenderer.cs new file mode 100644 index 0000000..e1a1356 --- /dev/null +++ b/Core/ScriptConverter/Renderers/ControlFlowRenderer.cs @@ -0,0 +1,78 @@ +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.Renderers; + +public class ControlFlowRenderer : IMultiStepRenderer +{ + public string[] StepNames => + [ + "If", "Else If", "Else", "End If", + "Loop", "Exit Loop If", "End Loop" + ]; + + public string ToHr(XElement step, StepDefinition definition) + { + var name = definition.Name; + + switch (name) + { + case "If": + case "Else If": + case "Exit Loop If": + var calc = step.Element("Calculation")?.Value; + return string.IsNullOrEmpty(calc) + ? name + : $"{name} [ {calc} ]"; + + case "Else": + case "End If": + case "Loop": + case "End Loop": + return name; + + default: + return name; + } + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var enable = line.Disabled ? "False" : "True"; + var name = definition.Name; + + switch (name) + { + case "If": + { + var calc = line.Params.Length > 0 ? line.Params[0].Trim() : ""; + return $"" + + $"" + + ""; + } + case "Else If": + { + var calc = line.Params.Length > 0 ? line.Params[0].Trim() : ""; + return $"" + + $"" + + ""; + } + case "Exit Loop If": + { + var calc = line.Params.Length > 0 ? line.Params[0].Trim() : ""; + return $"" + + $"" + + ""; + } + case "Else": + return $""; + case "End If": + return $""; + case "Loop": + return $""; + case "End Loop": + return $""; + default: + return $""; + } + } +} diff --git a/Core/ScriptConverter/Renderers/GoToLayoutRenderer.cs b/Core/ScriptConverter/Renderers/GoToLayoutRenderer.cs new file mode 100644 index 0000000..2732619 --- /dev/null +++ b/Core/ScriptConverter/Renderers/GoToLayoutRenderer.cs @@ -0,0 +1,75 @@ +using System; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.Renderers; + +public class GoToLayoutRenderer : IMultiStepRenderer +{ + public string[] StepNames => ["Go to Layout"]; + + public string ToHr(XElement step, StepDefinition definition) + { + var dest = step.Element("LayoutDestination")?.Attribute("value")?.Value; + var layoutName = step.Element("Layout")?.Attribute("name")?.Value; + var animation = step.Element("Animation")?.Attribute("value")?.Value; + + string layoutRef = dest switch + { + "OriginalLayout" => "original layout", + "LayoutNameByCalculation" => + step.Element("Calculation")?.Value ?? "original layout", + "LayoutNumberByCalculation" => + $"Layout Number: {step.Element("Calculation")?.Value ?? ""}", + _ => !string.IsNullOrEmpty(layoutName) ? $"\"{layoutName}\"" : "original layout" + }; + + var parts = new System.Collections.Generic.List { layoutRef }; + + if (!string.IsNullOrEmpty(animation)) + parts.Add($"Animation: {animation}"); + + return $"Go to Layout [ {string.Join(" ; ", parts)} ]"; + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var enable = line.Disabled ? "False" : "True"; + string dest = "OriginalLayout"; + string layoutName = ""; + string animation = ""; + + foreach (var p in line.Params) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Animation:", StringComparison.OrdinalIgnoreCase)) + { + animation = trimmed.Substring(10).TrimStart(); + } + else if (trimmed.StartsWith("Layout Number:", StringComparison.OrdinalIgnoreCase)) + { + dest = "LayoutNumberByCalculation"; + } + else if (trimmed == "original layout") + { + dest = "OriginalLayout"; + } + else + { + dest = "SelectedLayout"; + layoutName = GenericStepRenderer.Unquote(trimmed); + } + } + + var xml = $"" + + $""; + + if (dest == "SelectedLayout") + xml += $""; + + if (!string.IsNullOrEmpty(animation)) + xml += $""; + + xml += ""; + return xml; + } +} diff --git a/Core/ScriptConverter/Renderers/GoToRecordRenderer.cs b/Core/ScriptConverter/Renderers/GoToRecordRenderer.cs new file mode 100644 index 0000000..d2679d4 --- /dev/null +++ b/Core/ScriptConverter/Renderers/GoToRecordRenderer.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.Renderers; + +public class GoToRecordRenderer : IMultiStepRenderer +{ + public string[] StepNames => ["Go to Record/Request/Page"]; + + public string ToHr(XElement step, StepDefinition definition) + { + var location = step.Element("RowPageLocation")?.Attribute("value")?.Value; + var exitAfterLast = step.Element("Exit")?.Attribute("state")?.Value; + var calc = step.Element("Calculation")?.Value; + + var parts = new List(); + + if (location == "By Calculation" && !string.IsNullOrEmpty(calc)) + parts.Add($"By Calculation: {calc}"); + else if (!string.IsNullOrEmpty(location)) + parts.Add(location); + + if (exitAfterLast == "True") + parts.Add("Exit after last: On"); + + if (parts.Count == 0) + return "Go to Record/Request/Page"; + + return $"Go to Record/Request/Page [ {string.Join(" ; ", parts)} ]"; + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var enable = line.Disabled ? "False" : "True"; + string location = "Next"; + string exitState = "False"; + string calc = ""; + + foreach (var p in line.Params) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Exit after last:", StringComparison.OrdinalIgnoreCase)) + { + var val = trimmed.Substring(16).TrimStart(); + exitState = val.Equals("On", StringComparison.OrdinalIgnoreCase) ? "True" : "False"; + } + else if (trimmed.StartsWith("By Calculation:", StringComparison.OrdinalIgnoreCase)) + { + location = "By Calculation"; + calc = trimmed.Substring(15).TrimStart(); + } + else if (trimmed is "First" or "Last" or "Previous" or "Next") + { + location = trimmed; + } + } + + var xml = $"" + + $"" + + $""; + + if (!string.IsNullOrEmpty(calc)) + xml += $""; + + xml += ""; + return xml; + } +} diff --git a/Core/ScriptConverter/Renderers/IMultiStepRenderer.cs b/Core/ScriptConverter/Renderers/IMultiStepRenderer.cs new file mode 100644 index 0000000..2d54fe9 --- /dev/null +++ b/Core/ScriptConverter/Renderers/IMultiStepRenderer.cs @@ -0,0 +1,6 @@ +namespace SharpFM.Core.ScriptConverter.Renderers; + +public interface IMultiStepRenderer : IStepRenderer +{ + string[] StepNames { get; } +} diff --git a/Core/ScriptConverter/Renderers/PerformScriptRenderer.cs b/Core/ScriptConverter/Renderers/PerformScriptRenderer.cs new file mode 100644 index 0000000..fb9d203 --- /dev/null +++ b/Core/ScriptConverter/Renderers/PerformScriptRenderer.cs @@ -0,0 +1,48 @@ +using System; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.Renderers; + +public class PerformScriptRenderer : IMultiStepRenderer +{ + public string[] StepNames => ["Perform Script"]; + + public string ToHr(XElement step, StepDefinition definition) + { + var scriptEl = step.Element("Script"); + var scriptName = scriptEl?.Attribute("name")?.Value; + var param = step.Element("Calculation")?.Value; + + var parts = new System.Collections.Generic.List(); + if (!string.IsNullOrEmpty(scriptName)) + parts.Add($"\"{scriptName}\""); + if (!string.IsNullOrEmpty(param)) + parts.Add($"Parameter: {param}"); + + if (parts.Count == 0) + return "Perform Script"; + + return $"Perform Script [ {string.Join(" ; ", parts)} ]"; + } + + public string ToXml(ParsedLine line, StepDefinition definition) + { + var enable = line.Disabled ? "False" : "True"; + string scriptName = ""; + string param = ""; + + foreach (var p in line.Params) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Parameter:", StringComparison.OrdinalIgnoreCase)) + param = trimmed.Substring(10).TrimStart(); + else + scriptName = GenericStepRenderer.Unquote(trimmed); + } + + return $"" + + $"" + + $""; + var hr = XmlToHrConverter.Convert(xml); + Assert.Equal("# inside script", hr); + } + + [Fact] + public void HandlesUnknownStep() + { + var xml = Wrap("bar"); + var hr = XmlToHrConverter.Convert(xml); + Assert.Contains("FutureStep", hr); + } + + [Fact] + public void HandlesNullInput() + { + var hr = XmlToHrConverter.Convert(""); + Assert.Equal("", hr); + } +} diff --git a/tests/SharpFM.Tests/SharpFM.Tests.csproj b/tests/SharpFM.Tests/SharpFM.Tests.csproj new file mode 100644 index 0000000..030de1e --- /dev/null +++ b/tests/SharpFM.Tests/SharpFM.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + From b4c242814a0dcec2601752d503cf14aa7712e7a2 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sun, 29 Mar 2026 23:46:18 -0500 Subject: [PATCH 02/29] feat: add typed FmScript object model for script editing --- Core/ScriptConverter/FmScript.cs | 224 +++++++ Core/ScriptConverter/ScriptLineParser.cs | 4 + .../ScriptConverter/ScriptStep.Specialized.cs | 617 ++++++++++++++++++ Core/ScriptConverter/ScriptStep.cs | 223 +++++++ Core/ScriptConverter/StepParamValue.cs | 241 +++++++ Core/ScriptConverter/XmlHelpers.cs | 47 ++ .../ScriptConverter/FmScriptModelTests.cs | 209 ++++++ .../ScriptConverter/ScriptStepTests.cs | 198 ++++++ 8 files changed, 1763 insertions(+) create mode 100644 Core/ScriptConverter/FmScript.cs create mode 100644 Core/ScriptConverter/ScriptStep.Specialized.cs create mode 100644 Core/ScriptConverter/ScriptStep.cs create mode 100644 Core/ScriptConverter/StepParamValue.cs create mode 100644 Core/ScriptConverter/XmlHelpers.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs create mode 100644 tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs diff --git a/Core/ScriptConverter/FmScript.cs b/Core/ScriptConverter/FmScript.cs new file mode 100644 index 0000000..9fe6be6 --- /dev/null +++ b/Core/ScriptConverter/FmScript.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter; + +public class FmScript +{ + public List Steps { get; } + + public FmScript(List steps) + { + Steps = steps; + } + + // --- Parse FM XML into model --- + + public static FmScript FromXml(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return new FmScript(new List()); + + XDocument doc; + try { doc = XDocument.Parse(xml); } + catch { return new FmScript(new List()); } + + var root = doc.Root; + if (root == null) return new FmScript(new List()); + + // Mac-XMSC: steps inside "; + var script = FmScript.FromXml(xml); + Assert.Single(script.Steps); + Assert.Equal("# inside", script.ToDisplayText()); + } + + [Fact] + public void FromDisplayText_ToXml_Comment() + { + var script = FmScript.FromDisplayText("# hello"); + var xml = script.ToXml(); + var doc = XDocument.Parse(xml); + var step = doc.Root!.Element("Step")!; + Assert.Equal("89", step.Attribute("id")?.Value); + Assert.Equal("hello", step.Element("Text")?.Value); + } + + [Fact] + public void FromDisplayText_ToXml_SetVariable() + { + var script = FmScript.FromDisplayText("Set Variable [ $count ; Value: $count + 1 ]"); + var xml = script.ToXml(); + var doc = XDocument.Parse(xml); + var step = doc.Root!.Element("Step")!; + Assert.Equal("141", step.Attribute("id")?.Value); + Assert.Equal("$count", step.Element("Name")?.Value); + } + + [Fact] + public void FromDisplayText_ToXml_OutputIsValid() + { + var scripts = new[] + { + "# comment", + "Set Variable [ $x ; Value: 1 ]", + "If [ $x > 0 ]\n Beep\nEnd If", + "Set Field [ T::F ; \"val\" ]", + "// Beep", + }; + + foreach (var text in scripts) + { + var script = FmScript.FromDisplayText(text); + var xml = script.ToXml(); + XDocument.Parse(xml); // should not throw + } + } + + [Fact] + public void Validate_ValidScript_NoDiagnostics() + { + var script = FmScript.FromDisplayText( + "# Comment\nSet Variable [ $x ; Value: 1 ]\nIf [ $x > 0 ]\n Beep\nEnd If"); + var diagnostics = script.Validate(); + Assert.Empty(diagnostics); + } + + [Fact] + public void Validate_UnmatchedIf() + { + var script = FmScript.FromDisplayText("If [ $x > 0 ]\n Beep"); + var diagnostics = script.Validate(); + Assert.Contains(diagnostics, d => d.Message.Contains("no matching closing step")); + } + + [Fact] + public void Validate_UnmatchedEndIf() + { + var script = FmScript.FromDisplayText("End If"); + var diagnostics = script.Validate(); + Assert.Contains(diagnostics, d => d.Message.Contains("without matching opening step")); + } + + [Fact] + public void RoundTrip_RealisticScript() + { + var original = "# Navigate and process records\n" + + "Go to Layout [ \"Invoices\" ]\n" + + "Perform Script [ \"Find Open Invoices\" ; Parameter: $status ]\n" + + "If [ Get ( FoundCount ) > 0 ]\n" + + " Go to Record/Request/Page [ First ]\n" + + " Loop\n" + + " Set Field [ Invoices::Status ; \"Processed\" ]\n" + + " Set Variable [ $count ; Value: $count + 1 ]\n" + + " Go to Record/Request/Page [ Next ; Exit after last: On ]\n" + + " End Loop\n" + + " Show Custom Dialog [ Title: \"Done\" ; Message: $count & \" records processed\" ]\n" + + "End If\n" + + "Go to Layout [ original layout ]"; + + // Display text → model → XML → model → display text + var script1 = FmScript.FromDisplayText(original); + var xml = script1.ToXml(); + XDocument.Parse(xml); // valid XML + + var script2 = FmScript.FromXml(xml); + var roundTripped = script2.ToDisplayText(); + Assert.Equal(original, roundTripped); + } + + [Fact] + public void RoundTrip_XmlToDisplayToXml_PreservesStructure() + { + var xml = Wrap( + "test" + + " 0]]>" + + "" + + "" + + "" + + ""); + + var script = FmScript.FromXml(xml); + var display = script.ToDisplayText(); + var script2 = FmScript.FromDisplayText(display); + + Assert.Equal(script.Steps.Count, script2.Steps.Count); + for (int i = 0; i < script.Steps.Count; i++) + { + Assert.Equal( + script.Steps[i].Definition?.Name, + script2.Steps[i].Definition?.Name); + Assert.Equal( + script.Steps[i].Enabled, + script2.Steps[i].Enabled); + } + } + + [Fact] + public void UpdateStep_ModifiesSingleStep() + { + var script = FmScript.FromDisplayText("# line one\nBeep\n# line three"); + Assert.Equal(3, script.Steps.Count); + + script.UpdateStep(1, "Set Variable [ $x ; Value: 1 ]"); + Assert.Equal("Set Variable", script.Steps[1].Definition?.Name); + Assert.Equal("# (comment)", script.Steps[0].Definition?.Name); + Assert.Equal("# (comment)", script.Steps[2].Definition?.Name); + } + + [Fact] + public void EmptyInput_NoDiagnostics() + { + var script = FmScript.FromDisplayText(""); + Assert.Empty(script.Steps); + Assert.Empty(script.Validate()); + } + + [Fact] + public void ToXml_EmptyScript() + { + var script = new FmScript(new System.Collections.Generic.List()); + var xml = script.ToXml(); + var doc = XDocument.Parse(xml); + Assert.Equal("fmxmlsnippet", doc.Root!.Name.LocalName); + } +} diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs new file mode 100644 index 0000000..b23cf1f --- /dev/null +++ b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs @@ -0,0 +1,198 @@ +using System.Linq; +using System.Xml.Linq; +using SharpFM.Core.ScriptConverter; +using Xunit; + +namespace SharpFM.Tests.ScriptConverter; + +public class ScriptStepTests +{ + private static XElement MakeStep(string xml) => + XElement.Parse(xml); + + [Fact] + public void Comment_FromXml_ToDisplayLine() + { + var el = MakeStep("hello world"); + var step = ScriptStep.FromXml(el); + Assert.Equal("# (comment)", step.Definition?.Name); + Assert.Equal("# hello world", step.ToDisplayLine()); + } + + [Fact] + public void Comment_FromDisplayLine_ToXml() + { + var step = ScriptStep.FromDisplayLine("# hello world"); + Assert.Equal("# (comment)", step.Definition?.Name); + var xml = step.ToXml(); + Assert.Equal("hello world", xml.Element("Text")?.Value); + } + + [Fact] + public void SetVariable_FromXml_ToDisplayLine() + { + var el = MakeStep( + "" + + "" + + "" + + "$count"); + var step = ScriptStep.FromXml(el); + Assert.Equal("Set Variable [ $count ; Value: $count + 1 ]", step.ToDisplayLine()); + } + + [Fact] + public void SetVariable_WithRepetition_ToDisplayLine() + { + var el = MakeStep( + "" + + "" + + "" + + "$arr"); + var step = ScriptStep.FromXml(el); + Assert.Equal("Set Variable [ $arr[2] ; Value: \"x\" ]", step.ToDisplayLine()); + } + + [Fact] + public void SetField_FromXml_ToDisplayLine() + { + var el = MakeStep( + "" + + "" + + ""); + var step = ScriptStep.FromXml(el); + Assert.Equal("Set Field [ Invoices::Status ; \"Done\" ]", step.ToDisplayLine()); + } + + [Fact] + public void PerformScript_FromXml_ToDisplayLine() + { + var el = MakeStep( + "" + + "" + + ""; - var hr = XmlToHrConverter.Convert(xml); - Assert.Equal("# inside script", hr); - } - - [Fact] - public void HandlesUnknownStep() - { - var xml = Wrap("bar"); - var hr = XmlToHrConverter.Convert(xml); - Assert.Contains("FutureStep", hr); - } - - [Fact] - public void HandlesNullInput() - { - var hr = XmlToHrConverter.Convert(""); - Assert.Equal("", hr); - } -} From 7c737b4d49af7a41d08645f7b7e1a5615c069ac4 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 00:09:08 -0500 Subject: [PATCH 06/29] fix: validation squiggles for unknown steps and invalid param values --- Core/ScriptConverter/ScriptStep.cs | 29 ++- Core/ScriptConverter/ScriptValidator.cs | 171 +++++++++++++++++- MainWindow.axaml.cs | 39 +++- nlog.config | 11 +- .../ScriptConverter/ScriptStepTests.cs | 15 +- .../ScriptConverter/ScriptValidatorTests.cs | 20 +- 6 files changed, 248 insertions(+), 37 deletions(-) diff --git a/Core/ScriptConverter/ScriptStep.cs b/Core/ScriptConverter/ScriptStep.cs index 3cfd92d..c11e67c 100644 --- a/Core/ScriptConverter/ScriptStep.cs +++ b/Core/ScriptConverter/ScriptStep.cs @@ -62,13 +62,13 @@ public static ScriptStep FromDisplayLine(string line) if (!StepCatalogLoader.ByName.TryGetValue(raw.StepName, out var definition)) { - // Unknown step — create as comment preserving original text - var commentDef = StepCatalogLoader.ByName["# (comment)"]; - var textParam = commentDef.Params.FirstOrDefault(p => p.XmlElement == "Text"); - var paramValues = textParam != null - ? new List { new(textParam, raw.RawLine.Trim()) } - : new List(); - return new ScriptStep(commentDef, true, paramValues); + // Unknown step — preserve as-is with Definition=null. + // Validate() will flag it. ToXml() will emit it as a comment for safety. + return new ScriptStep(null, !raw.Disabled, rawXml: + new XElement("Step", + new XAttribute("enable", raw.Disabled ? "False" : "True"), + new XAttribute("name", raw.StepName), + new XElement("RawText", raw.RawLine.Trim()))); } // Specialized steps build their own XML from display text, @@ -86,8 +86,19 @@ public static ScriptStep FromDisplayLine(string line) public XElement ToXml() { - if (Definition == null && RawXml != null) - return new XElement(RawXml); + if (Definition == null) + { + // Unknown step — emit as comment preserving original text + var text = RawXml?.Element("RawText")?.Value + ?? RawXml?.Attribute("name")?.Value + ?? "Unknown"; + var commentStep = new XElement("Step", + new XAttribute("enable", Enabled ? "True" : "False"), + new XAttribute("id", 89), + new XAttribute("name", "# (comment)")); + commentStep.Add(new XElement("Text", text)); + return commentStep; + } var name = Definition?.Name ?? "# (comment)"; var id = Definition?.Id ?? 89; diff --git a/Core/ScriptConverter/ScriptValidator.cs b/Core/ScriptConverter/ScriptValidator.cs index b6eed2c..8934897 100644 --- a/Core/ScriptConverter/ScriptValidator.cs +++ b/Core/ScriptConverter/ScriptValidator.cs @@ -12,8 +12,175 @@ public static List Validate(string displayText) if (string.IsNullOrWhiteSpace(displayText)) return new List(); - var script = FmScript.FromDisplayText(displayText); - return script.Validate(); + var diagnostics = new List(); + var textLines = displayText.Split('\n'); + var blockStack = new Stack<(string Name, int Line)>(); + + // Walk actual text lines for correct positions + int stepIndex = 0; + for (int lineNum = 0; lineNum < textLines.Length; lineNum++) + { + var rawLine = textLines[lineNum].TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(rawLine)) + continue; + + var trimmed = rawLine.TrimStart(); + var indent = rawLine.Length - trimmed.Length; + + // Strip disabled prefix for step name lookup + var forLookup = trimmed; + if (forLookup.StartsWith("//")) + forLookup = forLookup.Substring(2).TrimStart(); + + // Comments are always valid + if (forLookup.StartsWith("#")) + { + stepIndex++; + continue; + } + + // Extract step name (text before '[' or end of line) + var bracketPos = ScriptLineParser.FindTopLevelBracket(forLookup); + var stepName = bracketPos >= 0 + ? forLookup.Substring(0, bracketPos).Trim() + : forLookup.Trim(); + + // Check if step exists + if (!StepCatalogLoader.ByName.TryGetValue(stepName, out var definition)) + { + // Underline the step name portion of the line + var nameStart = rawLine.IndexOf(stepName, StringComparison.Ordinal); + if (nameStart < 0) nameStart = indent; + diagnostics.Add(new ScriptDiagnostic( + lineNum, nameStart, nameStart + stepName.Length, + $"Unknown script step: '{stepName}'", + DiagnosticSeverity.Error)); + stepIndex++; + continue; + } + + // Block pair validation + if (definition.BlockPair != null) + { + switch (definition.BlockPair.Role) + { + case "open": + blockStack.Push((definition.Name, lineNum)); + break; + case "middle": + if (blockStack.Count == 0) + diagnostics.Add(new ScriptDiagnostic( + lineNum, indent, indent + definition.Name.Length, + $"'{definition.Name}' without matching opening step", + DiagnosticSeverity.Error)); + break; + case "close": + if (blockStack.Count == 0) + diagnostics.Add(new ScriptDiagnostic( + lineNum, indent, indent + definition.Name.Length, + $"'{definition.Name}' without matching opening step", + DiagnosticSeverity.Error)); + else + blockStack.Pop(); + break; + } + } + + // Validate param values + if (bracketPos >= 0) + { + var parsed = ScriptLineParser.ParseRaw(rawLine); + var usedParams = new bool[definition.Params.Length]; + + foreach (var hrParam in parsed.Params) + { + var paramTrimmed = hrParam.Trim(); + bool matchedLabel = false; + + // First: try labeled match + for (int pi = 0; pi < definition.Params.Length; pi++) + { + var catalogParam = definition.Params[pi]; + var label = catalogParam.HrLabel + ?? (catalogParam.Type == "namedCalc" && catalogParam.WrapperElement != null + ? catalogParam.WrapperElement : null); + if (label == null) continue; + if (!paramTrimmed.StartsWith(label + ":", StringComparison.OrdinalIgnoreCase)) + continue; + + var value = paramTrimmed.Substring(label.Length + 1).TrimStart(); + ValidateParamValue(value, label, catalogParam, rawLine, lineNum, diagnostics); + usedParams[pi] = true; + matchedLabel = true; + break; + } + + // Second: positional match for unlabeled enum/boolean params + if (!matchedLabel && !LooksLikeCalculation(paramTrimmed)) + { + for (int pi = 0; pi < definition.Params.Length; pi++) + { + if (usedParams[pi]) continue; + var catalogParam = definition.Params[pi]; + var validValues = GetValidValues(catalogParam); + if (validValues.Count == 0) continue; + + // This param has restricted values — check + if (!validValues.Contains(paramTrimmed, StringComparer.OrdinalIgnoreCase)) + { + var paramLabel = catalogParam.HrLabel ?? catalogParam.XmlElement; + ValidateParamValue(paramTrimmed, paramLabel, catalogParam, rawLine, lineNum, diagnostics); + } + usedParams[pi] = true; + break; + } + } + } + } + + stepIndex++; + } + + // Report unclosed blocks + while (blockStack.Count > 0) + { + var unclosed = blockStack.Pop(); + diagnostics.Add(new ScriptDiagnostic( + unclosed.Line, 0, 0, + $"'{unclosed.Name}' has no matching closing step", + DiagnosticSeverity.Error)); + } + + return diagnostics; + } + + private static void ValidateParamValue( + string value, string label, StepParam catalogParam, + string rawLine, int lineNum, List diagnostics) + { + var validValues = GetValidValues(catalogParam); + if (validValues.Count > 0 && !validValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + var bracketStart = rawLine.IndexOf('['); + var valuePos = bracketStart >= 0 + ? rawLine.IndexOf(value, bracketStart, StringComparison.Ordinal) + : rawLine.IndexOf(value, StringComparison.Ordinal); + if (valuePos < 0) valuePos = 0; + diagnostics.Add(new ScriptDiagnostic( + lineNum, valuePos, valuePos + value.Length, + $"Invalid value '{value}' for {label}. Expected: {string.Join(", ", validValues)}", + DiagnosticSeverity.Warning)); + } + } + + private static bool LooksLikeCalculation(string value) + { + // Skip validation for values that appear to be calculations, expressions, or literals + if (value.Length > 0 && char.IsDigit(value[0])) return true; // numeric literal + return value.Contains('$') || value.Contains('(') || value.Contains('"') + || value.Contains('>') || value.Contains('<') || value.Contains('=') + || value.Contains('&') || value.Contains('+') || value.Contains('-') + || value.Contains('*') || value.Contains('/'); } internal static List GetValidValues(StepParam param) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 15f51d5..59e9b3a 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -43,21 +43,21 @@ public MainWindow() _scriptTextMateInstallation = _scriptEditor.InstallTextMate(fmScriptRegistry); _scriptTextMateInstallation.SetGrammar(FmScriptRegistryOptions.ScopeName); - // Error highlighting - _errorRenderer = new ErrorMarkerRenderer(_scriptEditor.Document); - _scriptEditor.TextArea.TextView.BackgroundRenderers.Add(_errorRenderer); - - // Debounced validation on text changes + // Debounced validation timer _validationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _validationTimer.Tick += (_, _) => { _validationTimer.Stop(); RunValidation(); }; - _scriptEditor.Document.TextChanged += (_, _) => + + // Attach error renderer and validation to current (and future) documents. + // The Document changes when the binding fires ScriptDocument for a new clip. + AttachToDocument(_scriptEditor.Document); + _scriptEditor.PropertyChanged += (_, args) => { - _validationTimer.Stop(); - _validationTimer.Start(); + if (args.Property.Name == "Document" && _scriptEditor.Document != null) + AttachToDocument(_scriptEditor.Document); }; // Bracket matching @@ -146,6 +146,29 @@ private void OnScriptTextEntered(object? sender, TextInputEventArgs e) _completionWindow.Closed += (_, _) => _completionWindow = null; } + private void AttachToDocument(AvaloniaEdit.Document.TextDocument document) + { + if (_scriptEditor == null) return; + + // Remove old renderer if present + if (_errorRenderer != null) + _scriptEditor.TextArea.TextView.BackgroundRenderers.Remove(_errorRenderer); + + // Create new renderer for this document + _errorRenderer = new ErrorMarkerRenderer(document); + _scriptEditor.TextArea.TextView.BackgroundRenderers.Add(_errorRenderer); + + // Subscribe to text changes for debounced validation + document.TextChanged += (_, _) => + { + _validationTimer?.Stop(); + _validationTimer?.Start(); + }; + + // Run initial validation + RunValidation(); + } + private void RunValidation() { if (_scriptEditor == null || _errorRenderer == null) return; diff --git a/nlog.config b/nlog.config index 26e731b..801c998 100644 --- a/nlog.config +++ b/nlog.config @@ -34,13 +34,16 @@ + + + + + + + - - - diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs index b23cf1f..c289c09 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs +++ b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs @@ -166,8 +166,12 @@ public void UnknownStep_PreservesRawXml() var step = ScriptStep.FromXml(el); Assert.Null(step.Definition); Assert.NotNull(step.RawXml); - var roundTrip = step.ToXml(); - Assert.Equal("FutureStep", roundTrip.Attribute("name")?.Value); + // Display shows original name + Assert.Contains("FutureStep", step.ToDisplayLine()); + // XML serializes as comment with original name preserved + var xml = step.ToXml(); + Assert.Equal("# (comment)", xml.Attribute("name")?.Value); + Assert.Contains("FutureStep", xml.Element("Text")?.Value ?? ""); } [Fact] @@ -180,10 +184,13 @@ public void FromDisplayLine_SetVariable() } [Fact] - public void FromDisplayLine_UnknownStep_BecomesComment() + public void FromDisplayLine_UnknownStep_HasNullDefinition() { var step = ScriptStep.FromDisplayLine("some random text"); - Assert.Equal("# (comment)", step.Definition?.Name); + Assert.Null(step.Definition); + // But ToXml emits it as a comment for safety + var xml = step.ToXml(); + Assert.Equal("89", xml.Attribute("id")?.Value); } [Fact] diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs b/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs index 89cb943..090e85b 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs +++ b/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs @@ -15,11 +15,12 @@ public void ValidScript_NoDiagnostics() } [Fact] - public void UnknownStep_BecomesComment_NoDiagnostics() + public void UnknownStep_ProducesError() { - // Unknown text is gracefully converted to a comment step var diagnostics = ScriptValidator.Validate("FakeStep [ param ]"); - Assert.Empty(diagnostics); + Assert.Single(diagnostics); + Assert.Equal(DiagnosticSeverity.Error, diagnostics[0].Severity); + Assert.Contains("Unknown script step", diagnostics[0].Message); } [Fact] @@ -105,20 +106,19 @@ public void ElseWithoutIf_ProducesError() } [Fact] - public void MultipleErrors_UnmatchedBlock() + public void MultipleErrors_AllReported() { - // Unknown steps become comments, only the unclosed If is an error var script = "FakeStep1\nFakeStep2\nIf [ 1 ]"; var diagnostics = ScriptValidator.Validate(script); - Assert.Single(diagnostics); - Assert.Contains("no matching closing step", diagnostics[0].Message); + // 2 unknown steps + 1 unclosed If + Assert.Equal(3, diagnostics.Count); } [Fact] - public void DisabledUnknownStep_BecomesComment() + public void DisabledUnknownStep_StillFlagged() { - // Disabled unknown text becomes a disabled comment — no error var diagnostics = ScriptValidator.Validate("// FakeStep [ param ]"); - Assert.Empty(diagnostics); + Assert.Single(diagnostics); + Assert.Contains("Unknown script step", diagnostics[0].Message); } } From 3e72a988dd6ee2789f4dd159e80a443051db6ffa Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 00:25:49 -0500 Subject: [PATCH 07/29] refactor: extract BracketMatcher utility, fix dead code, add error logging --- Core/ScriptConverter/BracketMatchRenderer.cs | 68 ++------ Core/ScriptConverter/BracketMatcher.cs | 153 ++++++++++++++++++ Core/ScriptConverter/FmScript.cs | 15 +- Core/ScriptConverter/ScriptLineParser.cs | 97 +---------- Core/ScriptConverter/ScriptValidator.cs | 2 +- .../StatementHighlightRenderer.cs | 18 +-- Core/ScriptConverter/XmlHelpers.cs | 6 +- 7 files changed, 185 insertions(+), 174 deletions(-) create mode 100644 Core/ScriptConverter/BracketMatcher.cs diff --git a/Core/ScriptConverter/BracketMatchRenderer.cs b/Core/ScriptConverter/BracketMatchRenderer.cs index 42831ef..05bd7d9 100644 --- a/Core/ScriptConverter/BracketMatchRenderer.cs +++ b/Core/ScriptConverter/BracketMatchRenderer.cs @@ -38,44 +38,29 @@ public void UpdateBracketMatch() if (offset <= 0 || offset > doc.TextLength) return; // Check character before caret and at caret + var text = doc.Text; var charBefore = offset > 0 ? doc.GetCharAt(offset - 1) : '\0'; var charAt = offset < doc.TextLength ? doc.GetCharAt(offset) : '\0'; if (charBefore == '[') { - var match = FindMatchingClose(doc.Text, offset - 1); - if (match >= 0) - { - _openOffset = offset - 1; - _closeOffset = match; - } + var match = BracketMatcher.FindMatchingClose(text, offset - 1); + if (match >= 0) { _openOffset = offset - 1; _closeOffset = match; } } else if (charBefore == ']') { - var match = FindMatchingOpen(doc.Text, offset - 2); - if (match >= 0) - { - _openOffset = match; - _closeOffset = offset - 1; - } + var match = BracketMatcher.FindMatchingOpen(text, offset - 2); + if (match >= 0) { _openOffset = match; _closeOffset = offset - 1; } } else if (charAt == '[') { - var match = FindMatchingClose(doc.Text, offset); - if (match >= 0) - { - _openOffset = offset; - _closeOffset = match; - } + var match = BracketMatcher.FindMatchingClose(text, offset); + if (match >= 0) { _openOffset = offset; _closeOffset = match; } } else if (charAt == ']') { - var match = FindMatchingOpen(doc.Text, offset - 1); - if (match >= 0) - { - _openOffset = match; - _closeOffset = offset; - } + var match = BracketMatcher.FindMatchingOpen(text, offset - 1); + if (match >= 0) { _openOffset = match; _closeOffset = offset; } } if (_openOffset != oldOpen || _closeOffset != oldClose) @@ -99,39 +84,4 @@ private static void DrawBracketHighlight(TextView textView, DrawingContext conte } } - private static int FindMatchingClose(string text, int openPos) - { - int depth = 1; - bool inQuote = false; - for (int i = openPos + 1; i < text.Length; i++) - { - var c = text[i]; - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == '[') depth++; - else if (!inQuote && c == ']') - { - depth--; - if (depth == 0) return i; - } - } - return -1; - } - - private static int FindMatchingOpen(string text, int closePos) - { - int depth = 1; - bool inQuote = false; - for (int i = closePos; i >= 0; i--) - { - var c = text[i]; - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == ']') depth++; - else if (!inQuote && c == '[') - { - depth--; - if (depth == 0) return i; - } - } - return -1; - } } diff --git a/Core/ScriptConverter/BracketMatcher.cs b/Core/ScriptConverter/BracketMatcher.cs new file mode 100644 index 0000000..b14d58b --- /dev/null +++ b/Core/ScriptConverter/BracketMatcher.cs @@ -0,0 +1,153 @@ +namespace SharpFM.Core.ScriptConverter; + +/// +/// Shared bracket matching utilities. All bracket/quote-aware logic +/// goes through here to avoid duplication across parser, renderers, and model. +/// +internal static class BracketMatcher +{ + /// + /// Find the first top-level '[' that isn't inside quotes or parentheses. + /// + internal static int FindTopLevelOpenBracket(string text) + { + bool inQuote = false; + int parenDepth = 0; + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\\' && inQuote && i + 1 < text.Length) { i++; continue; } + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == '(') parenDepth++; + else if (!inQuote && c == ')') parenDepth--; + else if (!inQuote && parenDepth == 0 && c == '[') + return i; + } + + return -1; + } + + /// + /// Find the matching ']' for the '[' at openPos. + /// + internal static int FindMatchingClose(string text, int openPos) + { + int depth = 1; + bool inQuote = false; + + for (int i = openPos + 1; i < text.Length; i++) + { + var c = text[i]; + if (c == '\\' && inQuote && i + 1 < text.Length) { i++; continue; } + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == '[') depth++; + else if (!inQuote && c == ']') + { + depth--; + if (depth == 0) return i; + } + } + + return -1; + } + + /// + /// Find the matching '[' for the ']' before closePos (scanning backwards). + /// + internal static int FindMatchingOpen(string text, int closePos) + { + int depth = 1; + bool inQuote = false; + + for (int i = closePos; i >= 0; i--) + { + var c = text[i]; + // Note: backward escape detection is imprecise, but sufficient for display + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == ']') depth++; + else if (!inQuote && c == '[') + { + depth--; + if (depth == 0) return i; + } + } + + return -1; + } + + /// + /// True if the text has more '[' than ']' (unbalanced, needs continuation lines). + /// + internal static bool HasUnbalancedBrackets(string text) + { + int depth = 0; + bool inQuote = false; + + foreach (var c in text) + { + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == '[') depth++; + else if (!inQuote && c == ']') depth--; + } + + return depth > 0; + } + + /// + /// Count the net bracket depth change of a line (positive = more opens than closes). + /// + internal static int CountBracketDepth(string line) + { + int depth = 0; + bool inQuote = false; + + foreach (var c in line) + { + if (c == '"') inQuote = !inQuote; + else if (!inQuote && c == '[') depth++; + else if (!inQuote && c == ']') depth--; + } + + return depth; + } + + /// + /// Split parameters by top-level semicolons (respecting quotes, parens, brackets). + /// + internal static string[] SplitParams(string paramText) + { + var results = new System.Collections.Generic.List(); + int start = 0; + int parenDepth = 0; + int bracketDepth = 0; + bool inQuote = false; + + for (int i = 0; i < paramText.Length; i++) + { + var c = paramText[i]; + if (c == '\\' && inQuote && i + 1 < paramText.Length) { i++; continue; } + if (c == '"') inQuote = !inQuote; + else if (!inQuote) + { + switch (c) + { + case '(': parenDepth++; break; + case ')': parenDepth--; break; + case '[': bracketDepth++; break; + case ']': bracketDepth--; break; + case ';' when parenDepth == 0 && bracketDepth == 0: + results.Add(paramText.Substring(start, i - start).Trim()); + start = i + 1; + break; + } + } + } + + var last = paramText.Substring(start).Trim(); + if (last.Length > 0) + results.Add(last); + + return results.ToArray(); + } +} diff --git a/Core/ScriptConverter/FmScript.cs b/Core/ScriptConverter/FmScript.cs index 9fe6be6..818af0f 100644 --- a/Core/ScriptConverter/FmScript.cs +++ b/Core/ScriptConverter/FmScript.cs @@ -2,12 +2,16 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Xml; using System.Xml.Linq; +using NLog; namespace SharpFM.Core.ScriptConverter; public class FmScript { + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + public List Steps { get; } public FmScript(List steps) @@ -24,7 +28,11 @@ public static FmScript FromXml(string xml) XDocument doc; try { doc = XDocument.Parse(xml); } - catch { return new FmScript(new List()); } + catch (XmlException ex) + { + Log.Error(ex, "Failed to parse script XML"); + return new FmScript(new List()); + } var root = doc.Root; if (root == null) return new FmScript(new List()); @@ -196,15 +204,10 @@ private static List MergeCommentContinuations(List steps foreach (var step in steps) { bool isComment = step.Definition?.Name == "# (comment)"; - bool isBareText = !isComment && step.Definition?.Name == "# (comment)" - && step.ParamValues.Any(p => p.Value?.StartsWith("[Unknown]") == false); - - // Bare unknown text following a comment → merge bool prevIsComment = result.Count > 0 && result[^1].Definition?.Name == "# (comment)"; if (prevIsComment && isComment) { - // Consecutive comments: merge text var prev = result[^1]; var prevText = prev.ParamValues.FirstOrDefault(p => p.Definition.XmlElement == "Text")?.Value ?? ""; var thisText = step.ParamValues.FirstOrDefault(p => p.Definition.XmlElement == "Text")?.Value ?? ""; diff --git a/Core/ScriptConverter/ScriptLineParser.cs b/Core/ScriptConverter/ScriptLineParser.cs index ed1841a..02e4e0f 100644 --- a/Core/ScriptConverter/ScriptLineParser.cs +++ b/Core/ScriptConverter/ScriptLineParser.cs @@ -66,20 +66,8 @@ internal static List MergeMultilineStatements(string[] lines) return result; } - internal static bool HasUnbalancedBrackets(string text) - { - int depth = 0; - bool inQuote = false; - - foreach (var c in text) - { - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == '[') depth++; - else if (!inQuote && c == ']') depth--; - } - - return depth > 0; - } + internal static bool HasUnbalancedBrackets(string text) => + BracketMatcher.HasUnbalancedBrackets(text); // Used by ScriptStep.FromDisplayLine — returns the same data as ParseLine // but avoids coupling ScriptStep to ParsedLine @@ -106,7 +94,7 @@ public static ParsedLine ParseLine(string line) } // Find the bracket-delimited parameters - var bracketStart = FindTopLevelBracket(trimmed); + var bracketStart = BracketMatcher.FindTopLevelOpenBracket(trimmed); if (bracketStart < 0) { // No parameters — just a step name @@ -114,90 +102,15 @@ public static ParsedLine ParseLine(string line) } var stepName = trimmed.Substring(0, bracketStart).Trim(); - var bracketEnd = FindMatchingBracket(trimmed, bracketStart); + var bracketEnd = BracketMatcher.FindMatchingClose(trimmed, bracketStart); if (bracketEnd < 0) bracketEnd = trimmed.Length - 1; var paramText = trimmed.Substring(bracketStart + 1, bracketEnd - bracketStart - 1).Trim(); var parameters = string.IsNullOrEmpty(paramText) ? Array.Empty() - : SplitParams(paramText); + : BracketMatcher.SplitParams(paramText); return new ParsedLine(stepName, parameters, disabled, false, raw); } - - internal static int FindTopLevelBracket(string text) - { - bool inQuote = false; - int parenDepth = 0; - - for (int i = 0; i < text.Length; i++) - { - var c = text[i]; - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == '(') parenDepth++; - else if (!inQuote && c == ')') parenDepth--; - else if (!inQuote && parenDepth == 0 && c == '[') - return i; - } - - return -1; - } - - internal static int FindMatchingBracket(string text, int openPos) - { - int depth = 1; - bool inQuote = false; - - for (int i = openPos + 1; i < text.Length; i++) - { - var c = text[i]; - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == '[') depth++; - else if (!inQuote && c == ']') - { - depth--; - if (depth == 0) return i; - } - } - - return -1; - } - - internal static string[] SplitParams(string paramText) - { - var results = new List(); - int start = 0; - int parenDepth = 0; - int bracketDepth = 0; - bool inQuote = false; - - for (int i = 0; i < paramText.Length; i++) - { - var c = paramText[i]; - - if (c == '"') inQuote = !inQuote; - else if (!inQuote) - { - switch (c) - { - case '(': parenDepth++; break; - case ')': parenDepth--; break; - case '[': bracketDepth++; break; - case ']': bracketDepth--; break; - case ';' when parenDepth == 0 && bracketDepth == 0: - results.Add(paramText.Substring(start, i - start).Trim()); - start = i + 1; - break; - } - } - } - - // Add final segment - var last = paramText.Substring(start).Trim(); - if (last.Length > 0) - results.Add(last); - - return results.ToArray(); - } } diff --git a/Core/ScriptConverter/ScriptValidator.cs b/Core/ScriptConverter/ScriptValidator.cs index 8934897..5bb7903 100644 --- a/Core/ScriptConverter/ScriptValidator.cs +++ b/Core/ScriptConverter/ScriptValidator.cs @@ -40,7 +40,7 @@ public static List Validate(string displayText) } // Extract step name (text before '[' or end of line) - var bracketPos = ScriptLineParser.FindTopLevelBracket(forLookup); + var bracketPos = BracketMatcher.FindTopLevelOpenBracket(forLookup); var stepName = bracketPos >= 0 ? forLookup.Substring(0, bracketPos).Trim() : forLookup.Trim(); diff --git a/Core/ScriptConverter/StatementHighlightRenderer.cs b/Core/ScriptConverter/StatementHighlightRenderer.cs index bdc0c16..a5afb2f 100644 --- a/Core/ScriptConverter/StatementHighlightRenderer.cs +++ b/Core/ScriptConverter/StatementHighlightRenderer.cs @@ -93,10 +93,10 @@ public void Draw(TextView textView, DrawingContext drawingContext) if (currentStart < 0) { // Not in a multi-line statement - if (ScriptLineParser.HasUnbalancedBrackets(line)) + if (BracketMatcher.HasUnbalancedBrackets(line)) { currentStart = lineNum; - depth = CountBracketDepth(line); + depth = BracketMatcher.CountBracketDepth(line); } else { @@ -106,7 +106,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) else { // Continuing a multi-line statement - depth += CountBracketDepth(line); + depth += BracketMatcher.CountBracketDepth(line); if (depth <= 0) { ranges.Add((currentStart, lineNum)); @@ -125,16 +125,4 @@ public void Draw(TextView textView, DrawingContext drawingContext) return ranges; } - private static int CountBracketDepth(string line) - { - int depth = 0; - bool inQuote = false; - foreach (var c in line) - { - if (c == '"') inQuote = !inQuote; - else if (!inQuote && c == '[') depth++; - else if (!inQuote && c == ']') depth--; - } - return depth; - } } diff --git a/Core/ScriptConverter/XmlHelpers.cs b/Core/ScriptConverter/XmlHelpers.cs index a1e8ca8..01f3162 100644 --- a/Core/ScriptConverter/XmlHelpers.cs +++ b/Core/ScriptConverter/XmlHelpers.cs @@ -1,11 +1,14 @@ +using System; using System.Text; using System.Xml; using System.Xml.Linq; +using NLog; namespace SharpFM.Core.ScriptConverter; internal static class XmlHelpers { + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); internal static string XmlEscape(string s) { return s.Replace("&", "&") @@ -39,8 +42,9 @@ internal static string PrettyPrint(string xml) } return sb.ToString(); } - catch + catch (Exception ex) { + Log.Warn(ex, "Failed to pretty-print XML, returning original"); return xml; } } From f9bba2e2a9b2eaf16e7387e10751d5d44d41387c Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 07:13:28 -0500 Subject: [PATCH 08/29] refactor: extract step handlers using Strategy pattern --- .../ScriptConverter/ScriptStep.Specialized.cs | 607 +----------------- .../StepHandlers/CommentHandler.cs | 33 + .../StepHandlers/ControlFlowHandler.cs | 75 +++ .../StepHandlers/GoToLayoutHandler.cs | 65 ++ .../StepHandlers/GoToRecordHandler.cs | 59 ++ .../StepHandlers/IStepHandler.cs | 22 + .../StepHandlers/PerformScriptHandler.cs | 46 ++ .../StepHandlers/SetFieldHandler.cs | 64 ++ .../StepHandlers/SetVariableHandler.cs | 77 +++ .../StepHandlers/ShowCustomDialogHandler.cs | 56 ++ .../StepHandlers/StepHandlerBase.cs | 49 ++ .../StepHandlers/StepHandlerRegistry.cs | 36 ++ 12 files changed, 592 insertions(+), 597 deletions(-) create mode 100644 Core/ScriptConverter/StepHandlers/CommentHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/IStepHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/SetFieldHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/SetVariableHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs create mode 100644 Core/ScriptConverter/StepHandlers/StepHandlerBase.cs create mode 100644 Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs diff --git a/Core/ScriptConverter/ScriptStep.Specialized.cs b/Core/ScriptConverter/ScriptStep.Specialized.cs index 33ede83..3917b7b 100644 --- a/Core/ScriptConverter/ScriptStep.Specialized.cs +++ b/Core/ScriptConverter/ScriptStep.Specialized.cs @@ -1,617 +1,30 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Xml.Linq; +using SharpFM.Core.ScriptConverter.StepHandlers; namespace SharpFM.Core.ScriptConverter; public partial class ScriptStep { - // --- Specialized display rendering --- - private string? ToDisplayLine_Specialized() { if (Definition == null) return null; - - return Definition.Name switch - { - "# (comment)" => ToDisplay_Comment(), - "Set Variable" => ToDisplay_SetVariable(), - "Set Field" => ToDisplay_SetField(), - "Perform Script" => ToDisplay_PerformScript(), - "Go to Layout" => ToDisplay_GoToLayout(), - "Go to Record/Request/Page" => ToDisplay_GoToRecord(), - "Show Custom Dialog" => ToDisplay_ShowCustomDialog(), - "If" or "Else If" or "Exit Loop If" => ToDisplay_ConditionStep(), - "Else" or "End If" or "Loop" or "End Loop" => Definition.Name, - _ => null // fall through to generic - }; - } - - // --- Specialized: build XML directly from display params (for FromDisplayLine) --- - - internal static XElement? BuildXmlFromDisplay_Specialized( - StepDefinition definition, bool enabled, string[] hrParams) - { - return definition.Name switch - { - "Set Variable" => BuildXml_SetVariable(enabled, hrParams), - "Set Field" => BuildXml_SetField(enabled, hrParams), - "Perform Script" => BuildXml_PerformScript(enabled, hrParams), - "Go to Layout" => BuildXml_GoToLayout(enabled, hrParams), - "Go to Record/Request/Page" => BuildXml_GoToRecord(enabled, hrParams), - "Show Custom Dialog" => BuildXml_ShowCustomDialog(enabled, hrParams), - "If" => BuildXml_Condition(68, "If", enabled, hrParams), - "Else If" => BuildXml_Condition(125, "Else If", enabled, hrParams), - "Exit Loop If" => BuildXml_Condition(72, "Exit Loop If", enabled, hrParams), - "Else" => MakeStepStatic(69, "Else", enabled), - "End If" => MakeStepStatic(70, "End If", enabled), - "Loop" => MakeStepStatic(71, "Loop", enabled), - "End Loop" => MakeStepStatic(73, "End Loop", enabled), - _ => null - }; - } - - private static XElement MakeStepStatic(int id, string name, bool enabled) - { - return new XElement("Step", - new XAttribute("enable", enabled ? "True" : "False"), - new XAttribute("id", id), - new XAttribute("name", name)); - } - - private static XElement BuildXml_SetVariable(bool enabled, string[] hrParams) - { - string varName = "", calcValue = "", repetition = "1"; - foreach (var p in hrParams) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Value:", StringComparison.OrdinalIgnoreCase)) - calcValue = trimmed.Substring(6).TrimStart(); - else if (trimmed.StartsWith("$")) - { - var parsed = ParseVarRepetition(trimmed); - varName = parsed.Name; - repetition = parsed.Repetition; - } - } - var step = MakeStepStatic(141, "Set Variable", enabled); - step.Add(XElement.Parse($"")); - step.Add(XElement.Parse($"")); - step.Add(new XElement("Name", varName)); - return step; - } - - private static XElement BuildXml_SetField(bool enabled, string[] hrParams) - { - string fieldTable = "", fieldName = "", calcValue = ""; - if (hrParams.Length >= 1) - { - var first = hrParams[0].Trim(); - if (first.Contains("::")) { var parts = first.Split("::", 2); fieldTable = parts[0]; fieldName = parts[1]; } - else fieldName = first; - } - if (hrParams.Length >= 2) calcValue = hrParams[1].Trim(); - var step = MakeStepStatic(76, "Set Field", enabled); - step.Add(XElement.Parse($"")); - step.Add(new XElement("Field", new XAttribute("table", fieldTable), new XAttribute("id", "0"), new XAttribute("name", fieldName))); - return step; - } - - private static XElement BuildXml_PerformScript(bool enabled, string[] hrParams) - { - string scriptName = "", param = ""; - foreach (var p in hrParams) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Parameter:", StringComparison.OrdinalIgnoreCase)) - param = trimmed.Substring(10).TrimStart(); - else scriptName = XmlHelpers.Unquote(trimmed); - } - var step = MakeStepStatic(1, "Perform Script", enabled); - step.Add(XElement.Parse($"")); - step.Add(new XElement("Script", new XAttribute("id", "0"), new XAttribute("name", scriptName))); - return step; - } - - private static XElement BuildXml_GoToLayout(bool enabled, string[] hrParams) - { - string dest = "OriginalLayout", layoutName = "", animation = ""; - foreach (var p in hrParams) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Animation:", StringComparison.OrdinalIgnoreCase)) - animation = trimmed.Substring(10).TrimStart(); - else if (trimmed.StartsWith("Layout Number:", StringComparison.OrdinalIgnoreCase)) - dest = "LayoutNumberByCalculation"; - else if (trimmed == "original layout") - dest = "OriginalLayout"; - else { dest = "SelectedLayout"; layoutName = XmlHelpers.Unquote(trimmed); } - } - var step = MakeStepStatic(6, "Go to Layout", enabled); - step.Add(new XElement("LayoutDestination", new XAttribute("value", dest))); - if (dest == "SelectedLayout") - step.Add(new XElement("Layout", new XAttribute("id", "0"), new XAttribute("name", layoutName))); - if (!string.IsNullOrEmpty(animation)) - step.Add(new XElement("Animation", new XAttribute("value", animation))); - return step; - } - - private static XElement BuildXml_GoToRecord(bool enabled, string[] hrParams) - { - string location = "Next", exitState = "False", calc = ""; - foreach (var p in hrParams) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Exit after last:", StringComparison.OrdinalIgnoreCase)) - exitState = trimmed.Substring(16).TrimStart().Equals("On", StringComparison.OrdinalIgnoreCase) ? "True" : "False"; - else if (trimmed.StartsWith("By Calculation:", StringComparison.OrdinalIgnoreCase)) - { location = "By Calculation"; calc = trimmed.Substring(15).TrimStart(); } - else if (trimmed is "First" or "Last" or "Previous" or "Next") - location = trimmed; - } - var step = MakeStepStatic(16, "Go to Record/Request/Page", enabled); - step.Add(new XElement("RowPageLocation", new XAttribute("value", location))); - step.Add(new XElement("Exit", new XAttribute("state", exitState))); - if (!string.IsNullOrEmpty(calc)) - step.Add(XElement.Parse($"")); - return step; - } - - private static XElement BuildXml_ShowCustomDialog(bool enabled, string[] hrParams) - { - string title = "", message = ""; - var buttons = new List(); - foreach (var p in hrParams) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Title:", StringComparison.OrdinalIgnoreCase)) - title = trimmed.Substring(6).TrimStart(); - else if (trimmed.StartsWith("Message:", StringComparison.OrdinalIgnoreCase)) - message = trimmed.Substring(8).TrimStart(); - else if (trimmed.StartsWith("Buttons:", StringComparison.OrdinalIgnoreCase)) - buttons.AddRange(trimmed.Substring(8).TrimStart().Split(',').Select(b => b.Trim())); - } - var step = MakeStepStatic(87, "Show Custom Dialog", enabled); - step.Add(XElement.Parse($"<Calculation><![CDATA[{title}]]></Calculation>")); - step.Add(XElement.Parse($"")); - if (buttons.Count > 0) - { - var buttonsEl = new XElement("Buttons"); - foreach (var btn in buttons) - buttonsEl.Add(XElement.Parse($"")); - step.Add(buttonsEl); - } - return step; - } - - private static XElement BuildXml_Condition(int id, string name, bool enabled, string[] hrParams) - { - var step = MakeStepStatic(id, name, enabled); - var calc = hrParams.Length > 0 ? hrParams[0].Trim() : ""; - step.Add(XElement.Parse($"")); - return step; + return StepHandlerRegistry.Get(Definition.Name)?.ToDisplayLine(this); } - // --- Specialized XML serialization (from model with RawXml) --- - internal XElement? ToXml_Specialized() { if (Definition == null) return null; - - return Definition.Name switch - { - "# (comment)" => ToXml_Comment(), - "Set Variable" => ToXml_SetVariable(), - "Set Field" => ToXml_SetField(), - "Perform Script" => ToXml_PerformScript(), - "Go to Layout" => ToXml_GoToLayout(), - "Go to Record/Request/Page" => ToXml_GoToRecord(), - "Show Custom Dialog" => ToXml_ShowCustomDialog(), - "If" => ToXml_Condition(68), - "Else If" => ToXml_Condition(125), - "Exit Loop If" => ToXml_Condition(72), - "Else" => ToXml_SelfClosing(69), - "End If" => ToXml_SelfClosing(70), - "Loop" => ToXml_SelfClosing(71), - "End Loop" => ToXml_SelfClosing(73), - _ => null // fall through to generic - }; + return StepHandlerRegistry.Get(Definition.Name)?.ToXml(this); } - // ========== Comment ========== - - private string ToDisplay_Comment() - { - var text = GetParamValue("Text") ?? ""; - if (text.Contains('\n')) - { - var lines = text.Split('\n'); - return string.Join("\n", lines.Select(l => $"# {l.TrimEnd('\r')}")); - } - return $"# {text}"; - } - - private XElement ToXml_Comment() - { - var text = GetParamValue("Text") ?? ""; - var step = MakeStep(89, "# (comment)"); - step.Add(new XElement("Text", text)); - return step; - } - - // ========== Set Variable ========== - - private string ToDisplay_SetVariable() - { - // Read from RawXml when available (loaded from FM), otherwise from ParamValues - string name, value, repetition; - if (RawXml != null) - { - name = RawXml.Element("Name")?.Value ?? ""; - value = RawXml.Element("Value")?.Element("Calculation")?.Value ?? ""; - repetition = RawXml.Element("Repetition")?.Element("Calculation")?.Value ?? ""; - } - else - { - name = GetParamValue("Name") ?? ""; - value = GetNamedCalcValue("Value") ?? ""; - repetition = GetNamedCalcValue("Repetition") ?? ""; - } - - var displayName = name; - if (!string.IsNullOrEmpty(repetition) && repetition != "1") - displayName = $"{name}[{repetition}]"; - - if (string.IsNullOrEmpty(value)) - return $"Set Variable [ {displayName} ]"; - - return $"Set Variable [ {displayName} ; Value: {value} ]"; - } - - private XElement ToXml_SetVariable() - { - var step = MakeStep(141, "Set Variable"); - - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplayLine()); - - string varName = ""; - string calcValue = ""; - string repetition = "1"; - - foreach (var p in raw.Params) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Value:", StringComparison.OrdinalIgnoreCase)) - calcValue = trimmed.Substring(6).TrimStart(); - else if (trimmed.StartsWith("$")) - { - var parsed = ParseVarRepetition(trimmed); - varName = parsed.Name; - repetition = parsed.Repetition; - } - } - - step.Add(XElement.Parse($"")); - step.Add(XElement.Parse($"")); - step.Add(new XElement("Name", varName)); - return step; - } - - internal static (string Name, string Repetition) ParseVarRepetition(string text) - { - var bracketStart = text.IndexOf('['); - if (bracketStart > 0 && text.EndsWith(']')) - { - var name = text.Substring(0, bracketStart); - var rep = text.Substring(bracketStart + 1, text.Length - bracketStart - 2); - return (name, rep); - } - return (text, "1"); - } - - // ========== Set Field ========== - - private string ToDisplay_SetField() - { - var field = RawXml?.Element("Field"); - string fieldRef = ""; - if (field != null) - { - var table = field.Attribute("table")?.Value; - var name = field.Attribute("name")?.Value; - if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(name)) - fieldRef = $"{table}::{name}"; - else if (!string.IsNullOrEmpty(name)) - fieldRef = name; - else if (!string.IsNullOrEmpty(field.Value)) - fieldRef = field.Value; - } - - var calc = RawXml?.Element("Calculation")?.Value; - var parts = new List(); - if (!string.IsNullOrEmpty(fieldRef)) parts.Add(fieldRef); - if (!string.IsNullOrEmpty(calc)) parts.Add(calc); - - return parts.Count == 0 ? "Set Field" : $"Set Field [ {string.Join(" ; ", parts)} ]"; - } - - private XElement ToXml_SetField() - { - var step = MakeStep(76, "Set Field"); - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplay_SetField()); - - string fieldTable = "", fieldName = "", calcValue = ""; - - if (raw.Params.Length >= 1) - { - var first = raw.Params[0].Trim(); - if (first.Contains("::")) - { - var parts = first.Split("::", 2); - fieldTable = parts[0]; - fieldName = parts[1]; - } - else - fieldName = first; - } - if (raw.Params.Length >= 2) - calcValue = raw.Params[1].Trim(); - - step.Add(XElement.Parse($"")); - step.Add(new XElement("Field", - new XAttribute("table", fieldTable), - new XAttribute("id", "0"), - new XAttribute("name", fieldName))); - return step; - } - - // ========== Perform Script ========== - - private string ToDisplay_PerformScript() - { - var scriptName = RawXml?.Element("Script")?.Attribute("name")?.Value; - var param = RawXml?.Element("Calculation")?.Value; - - var parts = new List(); - if (!string.IsNullOrEmpty(scriptName)) parts.Add($"\"{scriptName}\""); - if (!string.IsNullOrEmpty(param)) parts.Add($"Parameter: {param}"); - - return parts.Count == 0 ? "Perform Script" : $"Perform Script [ {string.Join(" ; ", parts)} ]"; - } - - private XElement ToXml_PerformScript() - { - var step = MakeStep(1, "Perform Script"); - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplay_PerformScript()); - - string scriptName = "", param = ""; - foreach (var p in raw.Params) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Parameter:", StringComparison.OrdinalIgnoreCase)) - param = trimmed.Substring(10).TrimStart(); - else - scriptName = XmlHelpers.Unquote(trimmed); - } - - step.Add(XElement.Parse($"")); - step.Add(new XElement("Script", new XAttribute("id", "0"), new XAttribute("name", scriptName))); - return step; - } - - // ========== Go to Layout ========== - - private string ToDisplay_GoToLayout() - { - var dest = RawXml?.Element("LayoutDestination")?.Attribute("value")?.Value; - var layoutName = RawXml?.Element("Layout")?.Attribute("name")?.Value; - var animation = RawXml?.Element("Animation")?.Attribute("value")?.Value; - - string layoutRef = dest switch - { - "OriginalLayout" => "original layout", - "LayoutNameByCalculation" => RawXml?.Element("Calculation")?.Value ?? "original layout", - "LayoutNumberByCalculation" => $"Layout Number: {RawXml?.Element("Calculation")?.Value ?? ""}", - _ => !string.IsNullOrEmpty(layoutName) ? $"\"{layoutName}\"" : "original layout" - }; - - var parts = new List { layoutRef }; - if (!string.IsNullOrEmpty(animation)) parts.Add($"Animation: {animation}"); - - return $"Go to Layout [ {string.Join(" ; ", parts)} ]"; - } - - private XElement ToXml_GoToLayout() - { - var step = MakeStep(6, "Go to Layout"); - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplay_GoToLayout()); - - string dest = "OriginalLayout", layoutName = "", animation = ""; - - foreach (var p in raw.Params) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Animation:", StringComparison.OrdinalIgnoreCase)) - animation = trimmed.Substring(10).TrimStart(); - else if (trimmed.StartsWith("Layout Number:", StringComparison.OrdinalIgnoreCase)) - dest = "LayoutNumberByCalculation"; - else if (trimmed == "original layout") - dest = "OriginalLayout"; - else - { - dest = "SelectedLayout"; - layoutName = XmlHelpers.Unquote(trimmed); - } - } - - step.Add(new XElement("LayoutDestination", new XAttribute("value", dest))); - if (dest == "SelectedLayout") - step.Add(new XElement("Layout", new XAttribute("id", "0"), new XAttribute("name", layoutName))); - if (!string.IsNullOrEmpty(animation)) - step.Add(new XElement("Animation", new XAttribute("value", animation))); - return step; - } - - // ========== Go to Record/Request/Page ========== - - private string ToDisplay_GoToRecord() - { - var location = RawXml?.Element("RowPageLocation")?.Attribute("value")?.Value; - var exitAfterLast = RawXml?.Element("Exit")?.Attribute("state")?.Value; - var calc = RawXml?.Element("Calculation")?.Value; - - var parts = new List(); - if (location == "By Calculation" && !string.IsNullOrEmpty(calc)) - parts.Add($"By Calculation: {calc}"); - else if (!string.IsNullOrEmpty(location)) - parts.Add(location); - if (exitAfterLast == "True") - parts.Add("Exit after last: On"); - - return parts.Count == 0 - ? "Go to Record/Request/Page" - : $"Go to Record/Request/Page [ {string.Join(" ; ", parts)} ]"; - } - - private XElement ToXml_GoToRecord() - { - var step = MakeStep(16, "Go to Record/Request/Page"); - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplay_GoToRecord()); - - string location = "Next", exitState = "False", calc = ""; - - foreach (var p in raw.Params) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Exit after last:", StringComparison.OrdinalIgnoreCase)) - { - var val = trimmed.Substring(16).TrimStart(); - exitState = val.Equals("On", StringComparison.OrdinalIgnoreCase) ? "True" : "False"; - } - else if (trimmed.StartsWith("By Calculation:", StringComparison.OrdinalIgnoreCase)) - { - location = "By Calculation"; - calc = trimmed.Substring(15).TrimStart(); - } - else if (trimmed is "First" or "Last" or "Previous" or "Next") - location = trimmed; - } - - step.Add(new XElement("RowPageLocation", new XAttribute("value", location))); - step.Add(new XElement("Exit", new XAttribute("state", exitState))); - if (!string.IsNullOrEmpty(calc)) - step.Add(XElement.Parse($"")); - return step; - } - - // ========== Show Custom Dialog ========== - - private string ToDisplay_ShowCustomDialog() - { - var title = RawXml?.Element("Title")?.Element("Calculation")?.Value; - var message = RawXml?.Element("Message")?.Element("Calculation")?.Value; - var buttons = RawXml?.Element("Buttons")?.Elements("Button") - .Select(b => b.Element("Calculation")?.Value) - .Where(b => !string.IsNullOrEmpty(b)) - .ToList() ?? new List(); - - var parts = new List(); - if (!string.IsNullOrEmpty(title)) parts.Add($"Title: {title}"); - if (!string.IsNullOrEmpty(message)) parts.Add($"Message: {message}"); - if (buttons.Count > 0) parts.Add($"Buttons: {string.Join(", ", buttons)}"); - - return parts.Count == 0 - ? "Show Custom Dialog" - : $"Show Custom Dialog [ {string.Join(" ; ", parts)} ]"; - } - - private XElement ToXml_ShowCustomDialog() - { - var step = MakeStep(87, "Show Custom Dialog"); - var raw = ScriptLineParser.ParseRaw( - (Enabled ? "" : "// ") + ToDisplay_ShowCustomDialog()); - - string title = "", message = ""; - var buttons = new List(); - - foreach (var p in raw.Params) - { - var trimmed = p.Trim(); - if (trimmed.StartsWith("Title:", StringComparison.OrdinalIgnoreCase)) - title = trimmed.Substring(6).TrimStart(); - else if (trimmed.StartsWith("Message:", StringComparison.OrdinalIgnoreCase)) - message = trimmed.Substring(8).TrimStart(); - else if (trimmed.StartsWith("Buttons:", StringComparison.OrdinalIgnoreCase)) - buttons.AddRange(trimmed.Substring(8).TrimStart().Split(',').Select(b => b.Trim())); - } - - step.Add(XElement.Parse($"<Calculation><![CDATA[{title}]]></Calculation>")); - step.Add(XElement.Parse($"")); - - if (buttons.Count > 0) - { - var buttonsEl = new XElement("Buttons"); - foreach (var btn in buttons) - buttonsEl.Add(XElement.Parse($"")); - step.Add(buttonsEl); - } - - return step; - } - - // ========== Control flow helpers ========== - - private string ToDisplay_ConditionStep() - { - var calc = RawXml?.Element("Calculation")?.Value; - return string.IsNullOrEmpty(calc) - ? Definition!.Name - : $"{Definition!.Name} [ {calc} ]"; - } - - private XElement ToXml_Condition(int id) - { - var step = MakeStep(id, Definition!.Name); - var calc = GetCalcFromParams(); - step.Add(XElement.Parse($"")); - return step; - } - - private XElement ToXml_SelfClosing(int id) - { - return MakeStep(id, Definition!.Name); - } - - // ========== Shared helpers ========== - - private XElement MakeStep(int id, string name) - { - return new XElement("Step", - new XAttribute("enable", Enabled ? "True" : "False"), - new XAttribute("id", id), - new XAttribute("name", name)); - } - - private string? GetParamValue(string xmlElement) - { - return ParamValues.FirstOrDefault(p => p.Definition.XmlElement == xmlElement)?.Value; - } - - private string? GetNamedCalcValue(string wrapperElement) + internal static XElement? BuildXmlFromDisplay_Specialized( + StepDefinition definition, bool enabled, string[] hrParams) { - return ParamValues.FirstOrDefault(p => p.Definition.WrapperElement == wrapperElement)?.Value; + return StepHandlerRegistry.Get(definition.Name) + ?.BuildXmlFromDisplay(definition, enabled, hrParams); } - private string GetCalcFromParams() - { - // For condition steps, the first param is usually the calculation - return ParamValues.FirstOrDefault(p => - p.Definition.Type is "calculation" or "calc")?.Value ?? ""; - } + // Shared helpers used by ScriptStep and handlers + internal static (string Name, string Repetition) ParseVarRepetition(string text) => + SetVariableHandler.ParseVarRepetition(text); } diff --git a/Core/ScriptConverter/StepHandlers/CommentHandler.cs b/Core/ScriptConverter/StepHandlers/CommentHandler.cs new file mode 100644 index 0000000..44d2cd2 --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/CommentHandler.cs @@ -0,0 +1,33 @@ +using System.Linq; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class CommentHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["# (comment)"]; + + public string? ToDisplayLine(ScriptStep step) + { + var text = step.ParamValues.FirstOrDefault(p => p.Definition.XmlElement == "Text")?.Value ?? ""; + if (text.Contains('\n')) + { + var lines = text.Split('\n'); + return string.Join("\n", lines.Select(l => $"# {l.TrimEnd('\r')}")); + } + return $"# {text}"; + } + + public XElement? ToXml(ScriptStep step) + { + var text = step.ParamValues.FirstOrDefault(p => p.Definition.XmlElement == "Text")?.Value ?? ""; + var el = MakeStep(89, "# (comment)", step.Enabled); + el.Add(new XElement("Text", text)); + return el; + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + return null; // Comments are handled by ScriptStep.FromDisplayLine directly + } +} diff --git a/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs b/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs new file mode 100644 index 0000000..cf84c4e --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs @@ -0,0 +1,75 @@ +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class ControlFlowHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => + [ + "If", "Else If", "Exit Loop If", + "Else", "End If", "Loop", "End Loop" + ]; + + public string? ToDisplayLine(ScriptStep step) + { + var name = step.Definition!.Name; + return name switch + { + "If" or "Else If" or "Exit Loop If" => + FormatCondition(name, step.RawXml?.Element("Calculation")?.Value), + _ => name + }; + } + + public XElement? ToXml(ScriptStep step) + { + var name = step.Definition!.Name; + return name switch + { + "If" => BuildCondition(68, name, step), + "Else If" => BuildCondition(125, name, step), + "Exit Loop If" => BuildCondition(72, name, step), + "Else" => MakeStep(69, name, step.Enabled), + "End If" => MakeStep(70, name, step.Enabled), + "Loop" => MakeStep(71, name, step.Enabled), + "End Loop" => MakeStep(73, name, step.Enabled), + _ => null + }; + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + return definition.Name switch + { + "If" => BuildConditionFromParams(68, "If", enabled, hrParams), + "Else If" => BuildConditionFromParams(125, "Else If", enabled, hrParams), + "Exit Loop If" => BuildConditionFromParams(72, "Exit Loop If", enabled, hrParams), + "Else" => MakeStep(69, "Else", enabled), + "End If" => MakeStep(70, "End If", enabled), + "Loop" => MakeStep(71, "Loop", enabled), + "End Loop" => MakeStep(73, "End Loop", enabled), + _ => null + }; + } + + private static string FormatCondition(string name, string? calc) + { + return string.IsNullOrEmpty(calc) ? name : $"{name} [ {calc} ]"; + } + + private static XElement BuildCondition(int id, string name, ScriptStep step) + { + var el = MakeStep(id, name, step.Enabled); + var calc = step.RawXml?.Element("Calculation")?.Value ?? ""; + el.Add(XElement.Parse($"")); + return el; + } + + private static XElement BuildConditionFromParams(int id, string name, bool enabled, string[] hrParams) + { + var el = MakeStep(id, name, enabled); + var calc = hrParams.Length > 0 ? hrParams[0].Trim() : ""; + el.Add(XElement.Parse($"")); + return el; + } +} diff --git a/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs b/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs new file mode 100644 index 0000000..c43f3f4 --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class GoToLayoutHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Go to Layout"]; + + public string? ToDisplayLine(ScriptStep step) + { + var dest = step.RawXml?.Element("LayoutDestination")?.Attribute("value")?.Value; + var layoutName = step.RawXml?.Element("Layout")?.Attribute("name")?.Value; + var animation = step.RawXml?.Element("Animation")?.Attribute("value")?.Value; + + string layoutRef = dest switch + { + "OriginalLayout" => "original layout", + "LayoutNameByCalculation" => step.RawXml?.Element("Calculation")?.Value ?? "original layout", + "LayoutNumberByCalculation" => $"Layout Number: {step.RawXml?.Element("Calculation")?.Value ?? ""}", + _ => !string.IsNullOrEmpty(layoutName) ? $"\"{layoutName}\"" : "original layout" + }; + + var parts = new List { layoutRef }; + if (!string.IsNullOrEmpty(animation)) parts.Add($"Animation: {animation}"); + + return $"Go to Layout [ {string.Join(" ; ", parts)} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + string dest = "OriginalLayout", layoutName = ""; + var animation = ExtractLabeled(hrParams, "Animation"); + + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Animation:", StringComparison.OrdinalIgnoreCase)) continue; + if (trimmed.StartsWith("Layout Number:", StringComparison.OrdinalIgnoreCase)) + dest = "LayoutNumberByCalculation"; + else if (trimmed == "original layout") + dest = "OriginalLayout"; + else + { + dest = "SelectedLayout"; + layoutName = XmlHelpers.Unquote(trimmed); + } + } + + var step = MakeStep(6, "Go to Layout", enabled); + step.Add(new XElement("LayoutDestination", new XAttribute("value", dest))); + if (dest == "SelectedLayout") + step.Add(new XElement("Layout", new XAttribute("id", "0"), new XAttribute("name", layoutName))); + if (!string.IsNullOrEmpty(animation)) + step.Add(new XElement("Animation", new XAttribute("value", animation))); + return step; + } +} diff --git a/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs b/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs new file mode 100644 index 0000000..07f7046 --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class GoToRecordHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Go to Record/Request/Page"]; + + public string? ToDisplayLine(ScriptStep step) + { + var location = step.RawXml?.Element("RowPageLocation")?.Attribute("value")?.Value; + var exitAfterLast = step.RawXml?.Element("Exit")?.Attribute("state")?.Value; + var calc = step.RawXml?.Element("Calculation")?.Value; + + var parts = new List(); + if (location == "By Calculation" && !string.IsNullOrEmpty(calc)) + parts.Add($"By Calculation: {calc}"); + else if (!string.IsNullOrEmpty(location)) + parts.Add(location); + if (exitAfterLast == "True") + parts.Add("Exit after last: On"); + + return parts.Count == 0 + ? "Go to Record/Request/Page" + : $"Go to Record/Request/Page [ {string.Join(" ; ", parts)} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + string location = "Next", exitState = "False", calc = ""; + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Exit after last:", StringComparison.OrdinalIgnoreCase)) + exitState = trimmed.Substring(16).TrimStart().Equals("On", StringComparison.OrdinalIgnoreCase) ? "True" : "False"; + else if (trimmed.StartsWith("By Calculation:", StringComparison.OrdinalIgnoreCase)) + { + location = "By Calculation"; + calc = trimmed.Substring(15).TrimStart(); + } + else if (trimmed is "First" or "Last" or "Previous" or "Next") + location = trimmed; + } + var step = MakeStep(16, "Go to Record/Request/Page", enabled); + step.Add(new XElement("RowPageLocation", new XAttribute("value", location))); + step.Add(new XElement("Exit", new XAttribute("state", exitState))); + if (!string.IsNullOrEmpty(calc)) + step.Add(XElement.Parse($"")); + return step; + } +} diff --git a/Core/ScriptConverter/StepHandlers/IStepHandler.cs b/Core/ScriptConverter/StepHandlers/IStepHandler.cs new file mode 100644 index 0000000..04ecedb --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/IStepHandler.cs @@ -0,0 +1,22 @@ +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +/// +/// Handles specialized display, serialization, and parsing for specific step types. +/// Implementations are registered by step name and dispatched by ScriptStep. +/// +public interface IStepHandler +{ + /// Step names this handler covers (e.g., ["Set Variable"] or ["If", "Else If"]). + string[] StepNames { get; } + + /// Render the step as a single display line. Return null to fall through to generic. + string? ToDisplayLine(ScriptStep step); + + /// Serialize the step model to XML. Return null to fall through to generic. + XElement? ToXml(ScriptStep step); + + /// Build XML directly from parsed display params. Return null to fall through to generic. + XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams); +} diff --git a/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs b/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs new file mode 100644 index 0000000..8b68acf --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class PerformScriptHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Perform Script"]; + + public string? ToDisplayLine(ScriptStep step) + { + var scriptName = step.RawXml?.Element("Script")?.Attribute("name")?.Value; + var param = step.RawXml?.Element("Calculation")?.Value; + + var parts = new List(); + if (!string.IsNullOrEmpty(scriptName)) parts.Add($"\"{scriptName}\""); + if (!string.IsNullOrEmpty(param)) parts.Add($"Parameter: {param}"); + + return parts.Count == 0 ? "Perform Script" : $"Perform Script [ {string.Join(" ; ", parts)} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + var scriptName = ""; + var param = ExtractLabeled(hrParams, "Parameter") ?? ""; + + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (!trimmed.StartsWith("Parameter:", StringComparison.OrdinalIgnoreCase)) + scriptName = XmlHelpers.Unquote(trimmed); + } + + var step = MakeStep(1, "Perform Script", enabled); + step.Add(XElement.Parse($"")); + step.Add(new XElement("Script", new XAttribute("id", "0"), new XAttribute("name", scriptName))); + return step; + } +} diff --git a/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs b/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs new file mode 100644 index 0000000..eca5b0e --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class SetFieldHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Set Field"]; + + public string? ToDisplayLine(ScriptStep step) + { + var field = step.RawXml?.Element("Field"); + string fieldRef = ""; + if (field != null) + { + var table = field.Attribute("table")?.Value; + var name = field.Attribute("name")?.Value; + if (!string.IsNullOrEmpty(table) && !string.IsNullOrEmpty(name)) + fieldRef = $"{table}::{name}"; + else if (!string.IsNullOrEmpty(name)) + fieldRef = name; + else if (!string.IsNullOrEmpty(field.Value)) + fieldRef = field.Value; + } + + var calc = step.RawXml?.Element("Calculation")?.Value; + var parts = new List(); + if (!string.IsNullOrEmpty(fieldRef)) parts.Add(fieldRef); + if (!string.IsNullOrEmpty(calc)) parts.Add(calc); + + return parts.Count == 0 ? "Set Field" : $"Set Field [ {string.Join(" ; ", parts)} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + string fieldTable = "", fieldName = "", calcValue = ""; + if (hrParams.Length >= 1) + { + var first = hrParams[0].Trim(); + if (first.Contains("::")) + { + var parts = first.Split("::", 2); + fieldTable = parts[0]; + fieldName = parts[1]; + } + else fieldName = first; + } + if (hrParams.Length >= 2) calcValue = hrParams[1].Trim(); + + var step = MakeStep(76, "Set Field", enabled); + step.Add(XElement.Parse($"")); + step.Add(new XElement("Field", + new XAttribute("table", fieldTable), + new XAttribute("id", "0"), + new XAttribute("name", fieldName))); + return step; + } +} diff --git a/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs b/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs new file mode 100644 index 0000000..5344f99 --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class SetVariableHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Set Variable"]; + + public string? ToDisplayLine(ScriptStep step) + { + string name, value, repetition; + if (step.RawXml != null) + { + name = step.RawXml.Element("Name")?.Value ?? ""; + value = step.RawXml.Element("Value")?.Element("Calculation")?.Value ?? ""; + repetition = step.RawXml.Element("Repetition")?.Element("Calculation")?.Value ?? ""; + } + else + { + name = step.ParamValues.FirstOrDefault(p => p.Definition.XmlElement == "Name")?.Value ?? ""; + value = step.ParamValues.FirstOrDefault(p => p.Definition.WrapperElement == "Value")?.Value ?? ""; + repetition = step.ParamValues.FirstOrDefault(p => p.Definition.WrapperElement == "Repetition")?.Value ?? ""; + } + + var displayName = name; + if (!string.IsNullOrEmpty(repetition) && repetition != "1") + displayName = $"{name}[{repetition}]"; + + return string.IsNullOrEmpty(value) + ? $"Set Variable [ {displayName} ]" + : $"Set Variable [ {displayName} ; Value: {value} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + // Re-parse from display for consistent output + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + string varName = "", calcValue = "", repetition = "1"; + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith("Value:", StringComparison.OrdinalIgnoreCase)) + calcValue = trimmed.Substring(6).TrimStart(); + else if (trimmed.StartsWith("$")) + { + var parsed = ParseVarRepetition(trimmed); + varName = parsed.Name; + repetition = parsed.Repetition; + } + } + var step = MakeStep(141, "Set Variable", enabled); + step.Add(XElement.Parse($"")); + step.Add(XElement.Parse($"")); + step.Add(new XElement("Name", varName)); + return step; + } + + internal static (string Name, string Repetition) ParseVarRepetition(string text) + { + var bracketStart = text.IndexOf('['); + if (bracketStart > 0 && text.EndsWith(']')) + { + var name = text.Substring(0, bracketStart); + var rep = text.Substring(bracketStart + 1, text.Length - bracketStart - 2); + return (name, rep); + } + return (text, "1"); + } +} diff --git a/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs b/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs new file mode 100644 index 0000000..149faae --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +internal class ShowCustomDialogHandler : StepHandlerBase, IStepHandler +{ + public string[] StepNames => ["Show Custom Dialog"]; + + public string? ToDisplayLine(ScriptStep step) + { + var title = step.RawXml?.Element("Title")?.Element("Calculation")?.Value; + var message = step.RawXml?.Element("Message")?.Element("Calculation")?.Value; + var buttons = step.RawXml?.Element("Buttons")?.Elements("Button") + .Select(b => b.Element("Calculation")?.Value) + .Where(b => !string.IsNullOrEmpty(b)) + .ToList() ?? new List(); + + var parts = new List(); + if (!string.IsNullOrEmpty(title)) parts.Add($"Title: {title}"); + if (!string.IsNullOrEmpty(message)) parts.Add($"Message: {message}"); + if (buttons.Count > 0) parts.Add($"Buttons: {string.Join(", ", buttons)}"); + + return parts.Count == 0 + ? "Show Custom Dialog" + : $"Show Custom Dialog [ {string.Join(" ; ", parts)} ]"; + } + + public XElement? ToXml(ScriptStep step) + { + return BuildXmlFromDisplay(step.Definition!, step.Enabled, + ScriptLineParser.ParseRaw(step.ToDisplayLine()).Params); + } + + public XElement? BuildXmlFromDisplay(StepDefinition definition, bool enabled, string[] hrParams) + { + var title = ExtractLabeled(hrParams, "Title") ?? ""; + var message = ExtractLabeled(hrParams, "Message") ?? ""; + var buttonsRaw = ExtractLabeled(hrParams, "Buttons"); + var buttons = buttonsRaw?.Split(',').Select(b => b.Trim()).ToList() ?? new List(); + + var step = MakeStep(87, "Show Custom Dialog", enabled); + step.Add(XElement.Parse($"<Calculation><![CDATA[{title}]]></Calculation>")); + step.Add(XElement.Parse($"")); + + if (buttons.Count > 0) + { + var buttonsEl = new XElement("Buttons"); + foreach (var btn in buttons) + buttonsEl.Add(XElement.Parse($"")); + step.Add(buttonsEl); + } + return step; + } +} diff --git a/Core/ScriptConverter/StepHandlers/StepHandlerBase.cs b/Core/ScriptConverter/StepHandlers/StepHandlerBase.cs new file mode 100644 index 0000000..d16348c --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/StepHandlerBase.cs @@ -0,0 +1,49 @@ +using System; +using System.Xml.Linq; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +/// +/// Shared helpers for step handler implementations. +/// +internal abstract class StepHandlerBase +{ + protected static XElement MakeStep(int id, string name, bool enabled) + { + return new XElement("Step", + new XAttribute("enable", enabled ? "True" : "False"), + new XAttribute("id", id), + new XAttribute("name", name)); + } + + protected static string? ExtractLabeled(string[] hrParams, string label) + { + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (trimmed.StartsWith(label + ":", StringComparison.OrdinalIgnoreCase)) + return trimmed.Substring(label.Length + 1).TrimStart(); + } + return null; + } + + protected static string? ExtractPositional(string[] hrParams, Func predicate) + { + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (predicate(trimmed)) return trimmed; + } + return null; + } + + protected static string? ExtractFirstUnlabeled(string[] hrParams) + { + foreach (var p in hrParams) + { + var trimmed = p.Trim(); + if (!trimmed.Contains(':')) return trimmed; + } + return null; + } +} diff --git a/Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs b/Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs new file mode 100644 index 0000000..e57d102 --- /dev/null +++ b/Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace SharpFM.Core.ScriptConverter.StepHandlers; + +/// +/// Central registry for step handlers. Lookup by step name, fallback to null (generic handling). +/// +internal static class StepHandlerRegistry +{ + private static readonly Dictionary _handlers = + new(StringComparer.OrdinalIgnoreCase); + + static StepHandlerRegistry() + { + Register(new CommentHandler()); + Register(new SetVariableHandler()); + Register(new SetFieldHandler()); + Register(new PerformScriptHandler()); + Register(new GoToLayoutHandler()); + Register(new GoToRecordHandler()); + Register(new ShowCustomDialogHandler()); + Register(new ControlFlowHandler()); + } + + private static void Register(IStepHandler handler) + { + foreach (var name in handler.StepNames) + _handlers[name] = handler; + } + + internal static IStepHandler? Get(string stepName) + { + return _handlers.TryGetValue(stepName, out var handler) ? handler : null; + } +} From afcea495281eb78b6a11be4c80f215e78437e4c0 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 08:30:17 -0500 Subject: [PATCH 09/29] refactor: consolidate block pair validation in ScriptValidator --- Core/ScriptConverter/FmScript.cs | 45 ++----------------- .../ScriptConverter/FmScriptModelTests.cs | 7 ++- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/Core/ScriptConverter/FmScript.cs b/Core/ScriptConverter/FmScript.cs index 818af0f..7e951cd 100644 --- a/Core/ScriptConverter/FmScript.cs +++ b/Core/ScriptConverter/FmScript.cs @@ -137,53 +137,14 @@ public string[] ToDisplayLines() } // --- Validate --- + // Block pair and positional validation lives in ScriptValidator (needs text line positions). + // This method validates the model itself (per-step param validation only). public List Validate() { var diagnostics = new List(); - var blockStack = new Stack<(string StepName, int Line)>(); - for (int i = 0; i < Steps.Count; i++) - { - var step = Steps[i]; - - // Per-step validation - diagnostics.AddRange(step.Validate(i)); - - // Block pair validation - if (step.Definition?.BlockPair != null) - { - switch (step.Definition.BlockPair.Role) - { - case "open": - blockStack.Push((step.Definition.Name, i)); - break; - case "middle": - if (blockStack.Count == 0) - diagnostics.Add(new ScriptDiagnostic(i, 0, step.Definition.Name.Length, - $"'{step.Definition.Name}' without matching opening step", - DiagnosticSeverity.Error)); - break; - case "close": - if (blockStack.Count == 0) - diagnostics.Add(new ScriptDiagnostic(i, 0, step.Definition.Name.Length, - $"'{step.Definition.Name}' without matching opening step", - DiagnosticSeverity.Error)); - else - blockStack.Pop(); - break; - } - } - } - - while (blockStack.Count > 0) - { - var unclosed = blockStack.Pop(); - diagnostics.Add(new ScriptDiagnostic(unclosed.Line, 0, 0, - $"'{unclosed.StepName}' has no matching closing step", - DiagnosticSeverity.Error)); - } - + diagnostics.AddRange(Steps[i].Validate(i)); return diagnostics; } diff --git a/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs b/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs index b7886e5..64bae8b 100644 --- a/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs +++ b/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs @@ -111,16 +111,15 @@ public void Validate_ValidScript_NoDiagnostics() [Fact] public void Validate_UnmatchedIf() { - var script = FmScript.FromDisplayText("If [ $x > 0 ]\n Beep"); - var diagnostics = script.Validate(); + // Block pair validation is in ScriptValidator (needs text line positions) + var diagnostics = ScriptValidator.Validate("If [ $x > 0 ]\n Beep"); Assert.Contains(diagnostics, d => d.Message.Contains("no matching closing step")); } [Fact] public void Validate_UnmatchedEndIf() { - var script = FmScript.FromDisplayText("End If"); - var diagnostics = script.Validate(); + var diagnostics = ScriptValidator.Validate("End If"); Assert.Contains(diagnostics, d => d.Message.Contains("without matching opening step")); } From 2035b695f8e82c7fca13ca1ab11c6559e12d580a Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 09:47:48 -0500 Subject: [PATCH 10/29] refactor: extract ScriptEditorController from MainWindow --- .../ScriptConverter/ScriptEditorController.cs | 140 +++++++++++++++ MainWindow.axaml.cs | 160 ++---------------- 2 files changed, 157 insertions(+), 143 deletions(-) create mode 100644 Core/ScriptConverter/ScriptEditorController.cs diff --git a/Core/ScriptConverter/ScriptEditorController.cs b/Core/ScriptConverter/ScriptEditorController.cs new file mode 100644 index 0000000..58cc17a --- /dev/null +++ b/Core/ScriptConverter/ScriptEditorController.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.Document; + +namespace SharpFM.Core.ScriptConverter; + +/// +/// Manages script editor behavior: validation, completion, tooltips, and document tracking. +/// Extracted from MainWindow to keep UI code thin and this logic independently testable. +/// +public class ScriptEditorController : IDisposable +{ + private readonly TextEditor _editor; + private readonly DispatcherTimer _validationTimer; + private ErrorMarkerRenderer? _errorRenderer; + private CompletionWindow? _completionWindow; + + public ScriptEditorController(TextEditor editor) + { + _editor = editor; + + _validationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _validationTimer.Tick += (_, _) => + { + _validationTimer.Stop(); + RunValidation(); + }; + + // Bracket matching + var bracketRenderer = new BracketMatchRenderer(_editor.TextArea); + _editor.TextArea.TextView.BackgroundRenderers.Add(bracketRenderer); + + // Multi-line statement highlighting + var statementRenderer = new StatementHighlightRenderer(_editor.TextArea); + _editor.TextArea.TextView.BackgroundRenderers.Add(statementRenderer); + + // Wire events + _editor.TextArea.TextEntered += OnTextEntered; + _editor.PointerMoved += OnPointerMoved; + + // Attach to initial document and track document changes + AttachToDocument(_editor.Document); + _editor.PropertyChanged += (_, args) => + { + if (args.Property.Name == "Document" && _editor.Document != null) + AttachToDocument(_editor.Document); + }; + } + + private void AttachToDocument(TextDocument document) + { + if (_errorRenderer != null) + _editor.TextArea.TextView.BackgroundRenderers.Remove(_errorRenderer); + + _errorRenderer = new ErrorMarkerRenderer(document); + _editor.TextArea.TextView.BackgroundRenderers.Add(_errorRenderer); + + document.TextChanged += (_, _) => + { + _validationTimer.Stop(); + _validationTimer.Start(); + }; + + RunValidation(); + } + + private void RunValidation() + { + if (_errorRenderer == null) return; + + var text = _editor.Document.Text; + var diagnostics = ScriptValidator.Validate(text); + _errorRenderer.UpdateDiagnostics(diagnostics); + _editor.TextArea.TextView.InvalidateLayer(_errorRenderer.Layer); + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (_errorRenderer == null) return; + + var pos = _editor.GetPositionFromPoint(e.GetPosition(_editor)); + if (pos == null) + { + ToolTip.SetIsOpen(_editor, false); + return; + } + + var offset = _editor.Document.GetOffset(pos.Value.Location); + var diag = _errorRenderer.GetDiagnosticAtOffset(offset); + + if (diag != null) + { + ToolTip.SetTip(_editor, diag.Message); + ToolTip.SetIsOpen(_editor, true); + } + else + { + ToolTip.SetIsOpen(_editor, false); + } + } + + private void OnTextEntered(object? sender, TextInputEventArgs e) + { + if (_completionWindow != null) return; + + var caret = _editor.TextArea.Caret; + var line = _editor.Document.GetLineByNumber(caret.Line); + var lineText = _editor.Document.GetText(line.Offset, line.Length); + var col = caret.Column - 1; + + var (context, items) = FmScriptCompletionProvider.GetCompletions(lineText, col); + if (context == CompletionContext.None || items.Count == 0) return; + + _completionWindow = new CompletionWindow(_editor.TextArea); + + if (context == CompletionContext.StepName) + { + var wordStart = lineText.Length - lineText.TrimStart().Length; + _completionWindow.StartOffset = line.Offset + wordStart; + } + + foreach (var item in items) + _completionWindow.CompletionList.CompletionData.Add(item); + + _completionWindow.Show(); + _completionWindow.Closed += (_, _) => _completionWindow = null; + } + + public void Dispose() + { + _validationTimer.Stop(); + _editor.TextArea.TextEntered -= OnTextEntered; + _editor.PointerMoved -= OnPointerMoved; + } +} diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 59e9b3a..ad78447 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -1,9 +1,6 @@ using System; using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Threading; using AvaloniaEdit; -using AvaloniaEdit.CodeCompletion; using AvaloniaEdit.TextMate; using SharpFM.Core.ScriptConverter; using TextMateSharp.Grammars; @@ -12,67 +9,31 @@ namespace SharpFM; public partial class MainWindow : Window { - private readonly RegistryOptions _registryOptions; - private readonly int _currentTheme = (int)ThemeName.DarkPlus; private readonly TextMate.Installation _xmlTextMateInstallation; private readonly TextMate.Installation? _scriptTextMateInstallation; - private readonly TextEditor _xmlEditor; - private readonly TextEditor? _scriptEditor; - private ErrorMarkerRenderer? _errorRenderer; - private DispatcherTimer? _validationTimer; - private CompletionWindow? _completionWindow; + private ScriptEditorController? _scriptController; public MainWindow() { InitializeComponent(); - _xmlEditor = this.FindControl("avaloniaEditor") ?? throw new Exception("no control"); - _scriptEditor = this.FindControl("scriptEditor"); + var registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); - _registryOptions = new RegistryOptions((ThemeName)_currentTheme); + // XML editor: syntax highlighting + var xmlEditor = this.FindControl("avaloniaEditor") ?? throw new Exception("no control"); + _xmlTextMateInstallation = xmlEditor.InstallTextMate(registryOptions); + var xmlLang = registryOptions.GetLanguageByExtension(".xml"); + _xmlTextMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId(xmlLang.Id)); - // XML editor: XML syntax highlighting - _xmlTextMateInstallation = _xmlEditor.InstallTextMate(_registryOptions); - Language xmlLang = _registryOptions.GetLanguageByExtension(".xml"); - _xmlTextMateInstallation.SetGrammar(_registryOptions.GetScopeByLanguageId(xmlLang.Id)); - - // Script editor: FM Script syntax highlighting + error markers - if (_scriptEditor != null) + // Script editor: syntax highlighting + controller for validation/completion/tooltips + var scriptEditor = this.FindControl("scriptEditor"); + if (scriptEditor != null) { - var fmScriptRegistry = new FmScriptRegistryOptions(_registryOptions); - _scriptTextMateInstallation = _scriptEditor.InstallTextMate(fmScriptRegistry); + var fmScriptRegistry = new FmScriptRegistryOptions(registryOptions); + _scriptTextMateInstallation = scriptEditor.InstallTextMate(fmScriptRegistry); _scriptTextMateInstallation.SetGrammar(FmScriptRegistryOptions.ScopeName); - // Debounced validation timer - _validationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; - _validationTimer.Tick += (_, _) => - { - _validationTimer.Stop(); - RunValidation(); - }; - - // Attach error renderer and validation to current (and future) documents. - // The Document changes when the binding fires ScriptDocument for a new clip. - AttachToDocument(_scriptEditor.Document); - _scriptEditor.PropertyChanged += (_, args) => - { - if (args.Property.Name == "Document" && _scriptEditor.Document != null) - AttachToDocument(_scriptEditor.Document); - }; - - // Bracket matching - var bracketRenderer = new BracketMatchRenderer(_scriptEditor.TextArea); - _scriptEditor.TextArea.TextView.BackgroundRenderers.Add(bracketRenderer); - - // Multi-line statement highlighting - var statementRenderer = new StatementHighlightRenderer(_scriptEditor.TextArea); - _scriptEditor.TextArea.TextView.BackgroundRenderers.Add(statementRenderer); - - // Intellisense - _scriptEditor.TextArea.TextEntered += OnScriptTextEntered; - - // Error tooltips on hover - _scriptEditor.PointerMoved += OnScriptPointerMoved; + _scriptController = new ScriptEditorController(scriptEditor); } // Tab switch: sync model between Script and XML views @@ -85,106 +46,19 @@ public MainWindow() if (vm == null || !vm.IsScriptClip) return; if (editorTabs.SelectedIndex == 0) - vm.SyncEditorFromXml(); // switching to Script tab + vm.SyncEditorFromXml(); else - vm.SyncModelFromEditor(); // switching to XML tab + vm.SyncModelFromEditor(); }; } } - private void OnScriptPointerMoved(object? sender, PointerEventArgs e) - { - if (_scriptEditor == null || _errorRenderer == null) return; - - var pos = _scriptEditor.GetPositionFromPoint(e.GetPosition(_scriptEditor)); - if (pos == null) - { - ToolTip.SetIsOpen(_scriptEditor, false); - return; - } - - var offset = _scriptEditor.Document.GetOffset(pos.Value.Location); - var diag = _errorRenderer.GetDiagnosticAtOffset(offset); - - if (diag != null) - { - ToolTip.SetTip(_scriptEditor, diag.Message); - ToolTip.SetIsOpen(_scriptEditor, true); - } - else - { - ToolTip.SetIsOpen(_scriptEditor, false); - } - } - - private void OnScriptTextEntered(object? sender, TextInputEventArgs e) - { - if (_scriptEditor == null || _completionWindow != null) return; - - var caret = _scriptEditor.TextArea.Caret; - var line = _scriptEditor.Document.GetLineByNumber(caret.Line); - var lineText = _scriptEditor.Document.GetText(line.Offset, line.Length); - var col = caret.Column - 1; - - var (context, items) = FmScriptCompletionProvider.GetCompletions(lineText, col); - if (context == CompletionContext.None || items.Count == 0) return; - - _completionWindow = new CompletionWindow(_scriptEditor.TextArea); - - // Set the start offset for filtering - if (context == CompletionContext.StepName) - { - // Filter from start of current word on the line - var wordStart = lineText.Length - lineText.TrimStart().Length; - _completionWindow.StartOffset = line.Offset + wordStart; - } - - foreach (var item in items) - _completionWindow.CompletionList.CompletionData.Add(item); - - _completionWindow.Show(); - _completionWindow.Closed += (_, _) => _completionWindow = null; - } - - private void AttachToDocument(AvaloniaEdit.Document.TextDocument document) - { - if (_scriptEditor == null) return; - - // Remove old renderer if present - if (_errorRenderer != null) - _scriptEditor.TextArea.TextView.BackgroundRenderers.Remove(_errorRenderer); - - // Create new renderer for this document - _errorRenderer = new ErrorMarkerRenderer(document); - _scriptEditor.TextArea.TextView.BackgroundRenderers.Add(_errorRenderer); - - // Subscribe to text changes for debounced validation - document.TextChanged += (_, _) => - { - _validationTimer?.Stop(); - _validationTimer?.Start(); - }; - - // Run initial validation - RunValidation(); - } - - private void RunValidation() - { - if (_scriptEditor == null || _errorRenderer == null) return; - - var text = _scriptEditor.Document.Text; - var diagnostics = ScriptValidator.Validate(text); - _errorRenderer.UpdateDiagnostics(diagnostics); - _scriptEditor.TextArea.TextView.InvalidateLayer(_errorRenderer.Layer); - } - protected override void OnClosed(EventArgs e) { base.OnClosed(e); - _validationTimer?.Stop(); + _scriptController?.Dispose(); _xmlTextMateInstallation.Dispose(); _scriptTextMateInstallation?.Dispose(); } -} \ No newline at end of file +} From d013a2094747fb6512a4c0a4611fa48925c0ba69 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 09:57:50 -0500 Subject: [PATCH 11/29] refactor: add BlockPairRole enum, IStepCatalog interface, rename RawXml/ParsedLine --- Core/ScriptConverter/FmScript.cs | 4 +- Core/ScriptConverter/IStepCatalog.cs | 13 ++++++ Core/ScriptConverter/ParsedLine.cs | 2 +- Core/ScriptConverter/ScriptLineParser.cs | 18 ++++---- Core/ScriptConverter/ScriptStep.cs | 14 +++--- Core/ScriptConverter/ScriptValidator.cs | 6 +-- Core/ScriptConverter/StepCatalog.cs | 37 ++++++++++++++- Core/ScriptConverter/StepCatalogLoader.cs | 46 ++++++++++++++----- .../StepHandlers/ControlFlowHandler.cs | 4 +- .../StepHandlers/GoToLayoutHandler.cs | 10 ++-- .../StepHandlers/GoToRecordHandler.cs | 6 +-- .../StepHandlers/PerformScriptHandler.cs | 4 +- .../StepHandlers/SetFieldHandler.cs | 4 +- .../StepHandlers/SetVariableHandler.cs | 8 ++-- .../StepHandlers/ShowCustomDialogHandler.cs | 6 +-- .../ScriptConverter/ScriptStepTests.cs | 4 +- .../ScriptConverter/StepCatalogLoaderTests.cs | 4 +- 17 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 Core/ScriptConverter/IStepCatalog.cs diff --git a/Core/ScriptConverter/FmScript.cs b/Core/ScriptConverter/FmScript.cs index 7e951cd..d34fab9 100644 --- a/Core/ScriptConverter/FmScript.cs +++ b/Core/ScriptConverter/FmScript.cs @@ -104,7 +104,7 @@ public string[] ToDisplayLines() var name = step.Definition?.Name ?? ""; // Decrease indent before close/middle blocks - if (step.Definition?.BlockPair?.Role is "close" or "middle" && indentLevel > 0) + if (step.Definition?.BlockPair?.Role is BlockPairRole.Close or BlockPairRole.Middle && indentLevel > 0) indentLevel--; var displayLine = step.ToDisplayLine(); @@ -129,7 +129,7 @@ public string[] ToDisplayLines() } // Increase indent after open/middle blocks - if (step.Definition?.BlockPair?.Role is "open" or "middle") + if (step.Definition?.BlockPair?.Role is BlockPairRole.Open or BlockPairRole.Middle) indentLevel++; } diff --git a/Core/ScriptConverter/IStepCatalog.cs b/Core/ScriptConverter/IStepCatalog.cs new file mode 100644 index 0000000..dcf463f --- /dev/null +++ b/Core/ScriptConverter/IStepCatalog.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace SharpFM.Core.ScriptConverter; + +/// +/// Abstracts step catalog access for testability and potential locale/version swapping. +/// +public interface IStepCatalog +{ + IReadOnlyList All { get; } + bool TryGetByName(string name, out StepDefinition definition); + bool TryGetById(int id, out StepDefinition definition); +} diff --git a/Core/ScriptConverter/ParsedLine.cs b/Core/ScriptConverter/ParsedLine.cs index 523222b..5e44e76 100644 --- a/Core/ScriptConverter/ParsedLine.cs +++ b/Core/ScriptConverter/ParsedLine.cs @@ -1,6 +1,6 @@ namespace SharpFM.Core.ScriptConverter; -public record ParsedLine( +public record ParsedStep( string StepName, string[] Params, bool Disabled, diff --git a/Core/ScriptConverter/ScriptLineParser.cs b/Core/ScriptConverter/ScriptLineParser.cs index 02e4e0f..87936a5 100644 --- a/Core/ScriptConverter/ScriptLineParser.cs +++ b/Core/ScriptConverter/ScriptLineParser.cs @@ -6,14 +6,14 @@ namespace SharpFM.Core.ScriptConverter; public static class ScriptLineParser { - public static List Parse(string hrText) + public static List Parse(string hrText) { if (string.IsNullOrWhiteSpace(hrText)) - return new List(); + return new List(); var rawLines = hrText.Split('\n'); var mergedLines = MergeMultilineStatements(rawLines); - var result = new List(); + var result = new List(); foreach (var raw in mergedLines) { @@ -70,10 +70,10 @@ internal static bool HasUnbalancedBrackets(string text) => BracketMatcher.HasUnbalancedBrackets(text); // Used by ScriptStep.FromDisplayLine — returns the same data as ParseLine - // but avoids coupling ScriptStep to ParsedLine - internal static ParsedLine ParseRaw(string line) => ParseLine(line); + // but avoids coupling ScriptStep to ParsedStep + internal static ParsedStep ParseRaw(string line) => ParseLine(line); - public static ParsedLine ParseLine(string line) + public static ParsedStep ParseLine(string line) { var raw = line; var trimmed = line.TrimStart(); @@ -90,7 +90,7 @@ public static ParsedLine ParseLine(string line) if (trimmed.StartsWith("#")) { var commentText = trimmed.Length > 1 ? trimmed.Substring(1).TrimStart() : ""; - return new ParsedLine("# (comment)", new[] { commentText }, disabled, true, raw); + return new ParsedStep("# (comment)", new[] { commentText }, disabled, true, raw); } // Find the bracket-delimited parameters @@ -98,7 +98,7 @@ public static ParsedLine ParseLine(string line) if (bracketStart < 0) { // No parameters — just a step name - return new ParsedLine(trimmed.Trim(), Array.Empty(), disabled, false, raw); + return new ParsedStep(trimmed.Trim(), Array.Empty(), disabled, false, raw); } var stepName = trimmed.Substring(0, bracketStart).Trim(); @@ -111,6 +111,6 @@ public static ParsedLine ParseLine(string line) ? Array.Empty() : BracketMatcher.SplitParams(paramText); - return new ParsedLine(stepName, parameters, disabled, false, raw); + return new ParsedStep(stepName, parameters, disabled, false, raw); } } diff --git a/Core/ScriptConverter/ScriptStep.cs b/Core/ScriptConverter/ScriptStep.cs index c11e67c..0befdb5 100644 --- a/Core/ScriptConverter/ScriptStep.cs +++ b/Core/ScriptConverter/ScriptStep.cs @@ -10,7 +10,7 @@ public partial class ScriptStep public StepDefinition? Definition { get; } public bool Enabled { get; set; } public List ParamValues { get; } - public XElement? RawXml { get; } + public XElement? SourceXml { get; } public ScriptStep(StepDefinition? definition, bool enabled, List? paramValues = null, XElement? rawXml = null) @@ -18,7 +18,7 @@ public ScriptStep(StepDefinition? definition, bool enabled, Definition = definition; Enabled = enabled; ParamValues = paramValues ?? new List(); - RawXml = rawXml; + SourceXml = rawXml; } // --- Factory: from XML element --- @@ -72,7 +72,7 @@ public static ScriptStep FromDisplayLine(string line) } // Specialized steps build their own XML from display text, - // then construct from that XML for consistent RawXml population. + // then construct from that XML for consistent SourceXml population. var specializedXml = BuildXmlFromDisplay_Specialized(definition, !raw.Disabled, raw.Params); if (specializedXml != null) return FromXml(specializedXml); @@ -89,8 +89,8 @@ public XElement ToXml() if (Definition == null) { // Unknown step — emit as comment preserving original text - var text = RawXml?.Element("RawText")?.Value - ?? RawXml?.Attribute("name")?.Value + var text = SourceXml?.Element("RawText")?.Value + ?? SourceXml?.Attribute("name")?.Value ?? "Unknown"; var commentStep = new XElement("Step", new XAttribute("enable", Enabled ? "True" : "False"), @@ -140,7 +140,7 @@ public string ToDisplayLine() .Where(s => s != null) .ToList(); - var name = Definition?.Name ?? RawXml?.Attribute("name")?.Value ?? "Unknown"; + var name = Definition?.Name ?? SourceXml?.Attribute("name")?.Value ?? "Unknown"; if (parts.Count == 0) return name; @@ -156,7 +156,7 @@ public List Validate(int lineIndex) if (Definition == null) { - var name = RawXml?.Attribute("name")?.Value ?? "Unknown"; + var name = SourceXml?.Attribute("name")?.Value ?? "Unknown"; diagnostics.Add(new ScriptDiagnostic( lineIndex, 0, name.Length, $"Unknown script step: '{name}'", diff --git a/Core/ScriptConverter/ScriptValidator.cs b/Core/ScriptConverter/ScriptValidator.cs index 5bb7903..d0a9c4e 100644 --- a/Core/ScriptConverter/ScriptValidator.cs +++ b/Core/ScriptConverter/ScriptValidator.cs @@ -64,17 +64,17 @@ public static List Validate(string displayText) { switch (definition.BlockPair.Role) { - case "open": + case BlockPairRole.Open: blockStack.Push((definition.Name, lineNum)); break; - case "middle": + case BlockPairRole.Middle: if (blockStack.Count == 0) diagnostics.Add(new ScriptDiagnostic( lineNum, indent, indent + definition.Name.Length, $"'{definition.Name}' without matching opening step", DiagnosticSeverity.Error)); break; - case "close": + case BlockPairRole.Close: if (blockStack.Count == 0) diagnostics.Add(new ScriptDiagnostic( lineNum, indent, indent + definition.Name.Length, diff --git a/Core/ScriptConverter/StepCatalog.cs b/Core/ScriptConverter/StepCatalog.cs index 4ae0906..8e95848 100644 --- a/Core/ScriptConverter/StepCatalog.cs +++ b/Core/ScriptConverter/StepCatalog.cs @@ -1,6 +1,7 @@ // Step catalog data model — ported from agentic-fm (https://github.com/petrowsky/agentic-fm) // Copyright 2026 Matt Petrowsky, Apache License 2.0 +using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -91,11 +92,45 @@ public record StepParam public Dictionary? ExtensionData { get; init; } } +public enum BlockPairRole +{ + Open, + Middle, + Close +} + public record StepBlockPair { [JsonPropertyName("role")] - public string Role { get; init; } = ""; + [JsonConverter(typeof(BlockPairRoleConverter))] + public BlockPairRole Role { get; init; } [JsonPropertyName("partners")] public string[] Partners { get; init; } = []; } + +internal class BlockPairRoleConverter : JsonConverter +{ + public override BlockPairRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value switch + { + "open" => BlockPairRole.Open, + "middle" => BlockPairRole.Middle, + "close" => BlockPairRole.Close, + _ => throw new JsonException($"Unknown BlockPairRole: '{value}'") + }; + } + + public override void Write(Utf8JsonWriter writer, BlockPairRole value, JsonSerializerOptions options) + { + writer.WriteStringValue(value switch + { + BlockPairRole.Open => "open", + BlockPairRole.Middle => "middle", + BlockPairRole.Close => "close", + _ => throw new JsonException($"Unknown BlockPairRole: '{value}'") + }); + } +} diff --git a/Core/ScriptConverter/StepCatalogLoader.cs b/Core/ScriptConverter/StepCatalogLoader.cs index d6fa999..985be08 100644 --- a/Core/ScriptConverter/StepCatalogLoader.cs +++ b/Core/ScriptConverter/StepCatalogLoader.cs @@ -9,15 +9,39 @@ namespace SharpFM.Core.ScriptConverter; -public static class StepCatalogLoader +public class StepCatalogLoader : IStepCatalog { - private static readonly Lazy> _all = new(LoadCatalog); - private static readonly Lazy> _byId = new(BuildByIdIndex); - private static readonly Lazy> _byName = new(BuildByNameIndex); + private static readonly Lazy _instance = new(() => new StepCatalogLoader()); + private readonly IReadOnlyList _all; + private readonly IReadOnlyDictionary _byId; + private readonly IReadOnlyDictionary _byName; - public static IReadOnlyList All => _all.Value; - public static IReadOnlyDictionary ById => _byId.Value; - public static IReadOnlyDictionary ByName => _byName.Value; + /// Default singleton instance. Use directly or inject IStepCatalog for testing. + public static StepCatalogLoader Default => _instance.Value; + + // Static accessors for backward compatibility + public static IReadOnlyList All => Default._all; + public static IReadOnlyDictionary ById => Default._byId; + public static IReadOnlyDictionary ByName => Default._byName; + + IReadOnlyList IStepCatalog.All => _all; + + public bool TryGetByName(string name, out StepDefinition definition) + { + return _byName.TryGetValue(name, out definition!); + } + + public bool TryGetById(int id, out StepDefinition definition) + { + return _byId.TryGetValue(id, out definition!); + } + + private StepCatalogLoader() + { + _all = LoadCatalog(); + _byId = BuildByIdIndex(); + _byName = BuildByNameIndex(); + } private static IReadOnlyList LoadCatalog() { @@ -34,10 +58,10 @@ private static IReadOnlyList LoadCatalog() return steps.AsReadOnly(); } - private static IReadOnlyDictionary BuildByIdIndex() + private IReadOnlyDictionary BuildByIdIndex() { var dict = new Dictionary(); - foreach (var step in All) + foreach (var step in _all) { if (step.Id.HasValue) dict.TryAdd(step.Id.Value, step); @@ -45,10 +69,10 @@ private static IReadOnlyDictionary BuildByIdIndex() return dict; } - private static IReadOnlyDictionary BuildByNameIndex() + private IReadOnlyDictionary BuildByNameIndex() { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var step in All) + foreach (var step in _all) { dict.TryAdd(step.Name, step); } diff --git a/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs b/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs index cf84c4e..123d54a 100644 --- a/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs +++ b/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs @@ -16,7 +16,7 @@ internal class ControlFlowHandler : StepHandlerBase, IStepHandler return name switch { "If" or "Else If" or "Exit Loop If" => - FormatCondition(name, step.RawXml?.Element("Calculation")?.Value), + FormatCondition(name, step.SourceXml?.Element("Calculation")?.Value), _ => name }; } @@ -60,7 +60,7 @@ private static string FormatCondition(string name, string? calc) private static XElement BuildCondition(int id, string name, ScriptStep step) { var el = MakeStep(id, name, step.Enabled); - var calc = step.RawXml?.Element("Calculation")?.Value ?? ""; + var calc = step.SourceXml?.Element("Calculation")?.Value ?? ""; el.Add(XElement.Parse($"")); return el; } diff --git a/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs b/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs index c43f3f4..ec2bb4f 100644 --- a/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs +++ b/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs @@ -10,15 +10,15 @@ internal class GoToLayoutHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { - var dest = step.RawXml?.Element("LayoutDestination")?.Attribute("value")?.Value; - var layoutName = step.RawXml?.Element("Layout")?.Attribute("name")?.Value; - var animation = step.RawXml?.Element("Animation")?.Attribute("value")?.Value; + var dest = step.SourceXml?.Element("LayoutDestination")?.Attribute("value")?.Value; + var layoutName = step.SourceXml?.Element("Layout")?.Attribute("name")?.Value; + var animation = step.SourceXml?.Element("Animation")?.Attribute("value")?.Value; string layoutRef = dest switch { "OriginalLayout" => "original layout", - "LayoutNameByCalculation" => step.RawXml?.Element("Calculation")?.Value ?? "original layout", - "LayoutNumberByCalculation" => $"Layout Number: {step.RawXml?.Element("Calculation")?.Value ?? ""}", + "LayoutNameByCalculation" => step.SourceXml?.Element("Calculation")?.Value ?? "original layout", + "LayoutNumberByCalculation" => $"Layout Number: {step.SourceXml?.Element("Calculation")?.Value ?? ""}", _ => !string.IsNullOrEmpty(layoutName) ? $"\"{layoutName}\"" : "original layout" }; diff --git a/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs b/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs index 07f7046..e30a37b 100644 --- a/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs +++ b/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs @@ -10,9 +10,9 @@ internal class GoToRecordHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { - var location = step.RawXml?.Element("RowPageLocation")?.Attribute("value")?.Value; - var exitAfterLast = step.RawXml?.Element("Exit")?.Attribute("state")?.Value; - var calc = step.RawXml?.Element("Calculation")?.Value; + var location = step.SourceXml?.Element("RowPageLocation")?.Attribute("value")?.Value; + var exitAfterLast = step.SourceXml?.Element("Exit")?.Attribute("state")?.Value; + var calc = step.SourceXml?.Element("Calculation")?.Value; var parts = new List(); if (location == "By Calculation" && !string.IsNullOrEmpty(calc)) diff --git a/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs b/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs index 8b68acf..426fc87 100644 --- a/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs +++ b/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs @@ -10,8 +10,8 @@ internal class PerformScriptHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { - var scriptName = step.RawXml?.Element("Script")?.Attribute("name")?.Value; - var param = step.RawXml?.Element("Calculation")?.Value; + var scriptName = step.SourceXml?.Element("Script")?.Attribute("name")?.Value; + var param = step.SourceXml?.Element("Calculation")?.Value; var parts = new List(); if (!string.IsNullOrEmpty(scriptName)) parts.Add($"\"{scriptName}\""); diff --git a/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs b/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs index eca5b0e..56fb0b3 100644 --- a/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs +++ b/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs @@ -9,7 +9,7 @@ internal class SetFieldHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { - var field = step.RawXml?.Element("Field"); + var field = step.SourceXml?.Element("Field"); string fieldRef = ""; if (field != null) { @@ -23,7 +23,7 @@ internal class SetFieldHandler : StepHandlerBase, IStepHandler fieldRef = field.Value; } - var calc = step.RawXml?.Element("Calculation")?.Value; + var calc = step.SourceXml?.Element("Calculation")?.Value; var parts = new List(); if (!string.IsNullOrEmpty(fieldRef)) parts.Add(fieldRef); if (!string.IsNullOrEmpty(calc)) parts.Add(calc); diff --git a/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs b/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs index 5344f99..d4ef2a6 100644 --- a/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs +++ b/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs @@ -12,11 +12,11 @@ internal class SetVariableHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { string name, value, repetition; - if (step.RawXml != null) + if (step.SourceXml != null) { - name = step.RawXml.Element("Name")?.Value ?? ""; - value = step.RawXml.Element("Value")?.Element("Calculation")?.Value ?? ""; - repetition = step.RawXml.Element("Repetition")?.Element("Calculation")?.Value ?? ""; + name = step.SourceXml.Element("Name")?.Value ?? ""; + value = step.SourceXml.Element("Value")?.Element("Calculation")?.Value ?? ""; + repetition = step.SourceXml.Element("Repetition")?.Element("Calculation")?.Value ?? ""; } else { diff --git a/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs b/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs index 149faae..d7cd1c5 100644 --- a/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs +++ b/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs @@ -10,9 +10,9 @@ internal class ShowCustomDialogHandler : StepHandlerBase, IStepHandler public string? ToDisplayLine(ScriptStep step) { - var title = step.RawXml?.Element("Title")?.Element("Calculation")?.Value; - var message = step.RawXml?.Element("Message")?.Element("Calculation")?.Value; - var buttons = step.RawXml?.Element("Buttons")?.Elements("Button") + var title = step.SourceXml?.Element("Title")?.Element("Calculation")?.Value; + var message = step.SourceXml?.Element("Message")?.Element("Calculation")?.Value; + var buttons = step.SourceXml?.Element("Buttons")?.Elements("Button") .Select(b => b.Element("Calculation")?.Value) .Where(b => !string.IsNullOrEmpty(b)) .ToList() ?? new List(); diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs index c289c09..db70363 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs +++ b/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs @@ -160,12 +160,12 @@ public void Disabled_Step_ToDisplayLine() } [Fact] - public void UnknownStep_PreservesRawXml() + public void UnknownStep_PreservesSourceXml() { var el = MakeStep("bar"); var step = ScriptStep.FromXml(el); Assert.Null(step.Definition); - Assert.NotNull(step.RawXml); + Assert.NotNull(step.SourceXml); // Display shows original name Assert.Contains("FutureStep", step.ToDisplayLine()); // XML serializes as comment with original name preserved diff --git a/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs b/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs index 5b253b5..86c5b27 100644 --- a/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs +++ b/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs @@ -25,7 +25,7 @@ public void LookupById_If_ReturnsCorrectDefinition() var step = StepCatalogLoader.ById[68]; Assert.Equal("If", step.Name); Assert.NotNull(step.BlockPair); - Assert.Equal("open", step.BlockPair!.Role); + Assert.Equal(BlockPairRole.Open, step.BlockPair!.Role); } [Fact] @@ -70,7 +70,7 @@ public void AllEntries_HaveNoDuplicateIds() public void BlockPairSteps_HaveMatchingPartners() { var openSteps = StepCatalogLoader.All - .Where(s => s.BlockPair?.Role == "open") + .Where(s => s.BlockPair?.Role == BlockPairRole.Open) .ToList(); foreach (var step in openSteps) From 658cc82309a6135cdff5110b820a6c6fac671428 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 13:15:29 -0500 Subject: [PATCH 12/29] refactor: move project to src/SharpFM, organize scripting into SharpFM.Scripting namespace Restructure repository to standard src/tests layout. Reorganize the scripting subsystem from Core/ScriptConverter into SharpFM.Scripting with Model, Catalog, Handlers, Parsing, Validation, and Editor sub-namespaces. Update CI workflows, VS Code configs, and project references for new paths. --- .github/workflows/ci.yml | 2 +- .github/workflows/release-artifacts.yml | 2 +- .vscode/launch.json | 6 +++--- SharpFM.sln | 2 +- App.axaml => src/SharpFM/App.axaml | 0 App.axaml.cs => src/SharpFM/App.axaml.cs | 0 .../Assets}/noun-sharp-teeth-monster-4226695.png | Bin .../noun-sharp-teeth-monster-4226695.small.png | Bin .../SharpFM/AvaloniaNLogSink.cs | 0 {Core => src/SharpFM/Core}/FileMakerClip.cs | 0 .../SharpFM/Core}/FileMakerClipExtensions.cs | 0 {Core => src/SharpFM/Core}/FileMakerField.cs | 0 MainWindow.axaml => src/SharpFM/MainWindow.axaml | 0 .../SharpFM/MainWindow.axaml.cs | 2 +- {Models => src/SharpFM/Models}/Clip.cs | 0 {Models => src/SharpFM/Models}/ClipRepository.cs | 0 Program.cs => src/SharpFM/Program.cs | 0 .../SharpFM/Scripting/Catalog}/IStepCatalog.cs | 2 +- .../SharpFM/Scripting/Catalog}/StepCatalog.cs | 2 +- .../SharpFM/Scripting/Catalog}/StepCatalogLoader.cs | 2 +- .../SharpFM/Scripting/Catalog}/step-catalog-en.json | 0 .../Scripting/Editor}/BracketMatchRenderer.cs | 2 +- .../Scripting/Editor}/ErrorMarkerRenderer.cs | 2 +- .../Scripting/Editor}/FmScriptCompletionData.cs | 2 +- .../Scripting/Editor}/FmScriptCompletionProvider.cs | 2 +- .../Scripting/Editor}/FmScriptRegistryOptions.cs | 2 +- .../Scripting/Editor}/ScriptEditorController.cs | 2 +- .../Scripting/Editor}/StatementHighlightRenderer.cs | 2 +- .../Scripting/Editor}/fmscript.tmLanguage.json | 0 src/SharpFM/Scripting/GlobalUsings.cs | 8 ++++++++ .../SharpFM/Scripting/Handlers}/CommentHandler.cs | 2 +- .../Scripting/Handlers}/ControlFlowHandler.cs | 2 +- .../Scripting/Handlers}/GoToLayoutHandler.cs | 2 +- .../Scripting/Handlers}/GoToRecordHandler.cs | 2 +- .../SharpFM/Scripting/Handlers}/IStepHandler.cs | 2 +- .../Scripting/Handlers}/PerformScriptHandler.cs | 2 +- .../SharpFM/Scripting/Handlers}/SetFieldHandler.cs | 2 +- .../Scripting/Handlers}/SetVariableHandler.cs | 2 +- .../Scripting/Handlers}/ShowCustomDialogHandler.cs | 2 +- .../SharpFM/Scripting/Handlers}/StepHandlerBase.cs | 2 +- .../Scripting/Handlers}/StepHandlerRegistry.cs | 2 +- .../SharpFM/Scripting/Model}/FmScript.cs | 2 +- .../SharpFM/Scripting/Model}/ParsedLine.cs | 2 +- .../Scripting/Model}/ScriptStep.Specialized.cs | 4 ++-- .../SharpFM/Scripting/Model}/ScriptStep.cs | 2 +- .../SharpFM/Scripting/Model}/StepParamValue.cs | 2 +- .../SharpFM/Scripting/Parsing}/BracketMatcher.cs | 2 +- .../SharpFM/Scripting/Parsing}/ScriptLineParser.cs | 2 +- .../Scripting/Validation}/ScriptDiagnostic.cs | 2 +- .../Scripting/Validation}/ScriptValidator.cs | 2 +- .../SharpFM/Scripting}/XmlHelpers.cs | 2 +- {Services => src/SharpFM/Services}/FolderService.cs | 0 SharpFM.csproj => src/SharpFM/SharpFM.csproj | 9 ++------- .../SharpFM/ViewModels}/ClipViewModel.cs | 2 +- .../SharpFM/ViewModels}/MainWindowViewModel.cs | 0 app.manifest => src/SharpFM/app.manifest | 0 nlog.config => src/SharpFM/nlog.config | 0 tests/SharpFM.Tests/GlobalUsings.cs | 7 ++++++- .../CompletionProviderTests.cs | 2 +- .../FmScriptModelTests.cs | 2 +- .../ScriptLineParserTests.cs | 2 +- .../ScriptStepTests.cs | 2 +- .../ScriptValidatorTests.cs | 2 +- .../StepCatalogLoaderTests.cs | 2 +- tests/SharpFM.Tests/SharpFM.Tests.csproj | 2 +- 65 files changed, 63 insertions(+), 55 deletions(-) rename App.axaml => src/SharpFM/App.axaml (100%) rename App.axaml.cs => src/SharpFM/App.axaml.cs (100%) rename {Assets => src/SharpFM/Assets}/noun-sharp-teeth-monster-4226695.png (100%) rename {Assets => src/SharpFM/Assets}/noun-sharp-teeth-monster-4226695.small.png (100%) rename AvaloniaNLogSink.cs => src/SharpFM/AvaloniaNLogSink.cs (100%) rename {Core => src/SharpFM/Core}/FileMakerClip.cs (100%) rename {Core => src/SharpFM/Core}/FileMakerClipExtensions.cs (100%) rename {Core => src/SharpFM/Core}/FileMakerField.cs (100%) rename MainWindow.axaml => src/SharpFM/MainWindow.axaml (100%) rename MainWindow.axaml.cs => src/SharpFM/MainWindow.axaml.cs (98%) rename {Models => src/SharpFM/Models}/Clip.cs (100%) rename {Models => src/SharpFM/Models}/ClipRepository.cs (100%) rename Program.cs => src/SharpFM/Program.cs (100%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Catalog}/IStepCatalog.cs (90%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Catalog}/StepCatalog.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Catalog}/StepCatalogLoader.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Catalog}/step-catalog-en.json (100%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/BracketMatchRenderer.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/ErrorMarkerRenderer.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/FmScriptCompletionData.cs (95%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/FmScriptCompletionProvider.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/FmScriptRegistryOptions.cs (97%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/ScriptEditorController.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/StatementHighlightRenderer.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Editor}/fmscript.tmLanguage.json (100%) create mode 100644 src/SharpFM/Scripting/GlobalUsings.cs rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/CommentHandler.cs (95%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/ControlFlowHandler.cs (97%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/GoToLayoutHandler.cs (97%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/GoToRecordHandler.cs (97%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/IStepHandler.cs (94%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/PerformScriptHandler.cs (96%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/SetFieldHandler.cs (97%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/SetVariableHandler.cs (98%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/ShowCustomDialogHandler.cs (97%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/StepHandlerBase.cs (96%) rename {Core/ScriptConverter/StepHandlers => src/SharpFM/Scripting/Handlers}/StepHandlerRegistry.cs (95%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Model}/FmScript.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Model}/ParsedLine.cs (76%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Model}/ScriptStep.Specialized.cs (90%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Model}/ScriptStep.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Model}/StepParamValue.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Parsing}/BracketMatcher.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Parsing}/ScriptLineParser.cs (98%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Validation}/ScriptDiagnostic.cs (83%) rename {Core/ScriptConverter => src/SharpFM/Scripting/Validation}/ScriptValidator.cs (99%) rename {Core/ScriptConverter => src/SharpFM/Scripting}/XmlHelpers.cs (96%) rename {Services => src/SharpFM/Services}/FolderService.cs (100%) rename SharpFM.csproj => src/SharpFM/SharpFM.csproj (90%) rename {ViewModels => src/SharpFM/ViewModels}/ClipViewModel.cs (98%) rename {ViewModels => src/SharpFM/ViewModels}/MainWindowViewModel.cs (100%) rename app.manifest => src/SharpFM/app.manifest (100%) rename nlog.config => src/SharpFM/nlog.config (100%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/CompletionProviderTests.cs (98%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/FmScriptModelTests.cs (99%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/ScriptLineParserTests.cs (99%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/ScriptStepTests.cs (99%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/ScriptValidatorTests.cs (99%) rename tests/SharpFM.Tests/{ScriptConverter => Scripting}/StepCatalogLoaderTests.cs (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c57fd8..29fca19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,4 @@ jobs: run: dotnet test --no-build - name: Publish - run: dotnet publish SharpFM.csproj --runtime "${{ matrix.target }}" -c Debug \ No newline at end of file + run: dotnet publish src/SharpFM/SharpFM.csproj --runtime "${{ matrix.target }}" -c Debug \ No newline at end of file diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 6cee861..2a39c2a 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -37,7 +37,7 @@ jobs: release_name="SharpFM-$tag-${{ matrix.target }}" # Build everything - dotnet publish SharpFM.csproj --runtime "${{ matrix.target }}" -c Release -o "$release_name" + dotnet publish src/SharpFM/SharpFM.csproj --runtime "${{ matrix.target }}" -c Release -o "$release_name" # Pack files if [ "${{ matrix.target }}" == "win-x64" ]; then diff --git a/.vscode/launch.json b/.vscode/launch.json index ad36dd2..438a263 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,11 +11,11 @@ "preLaunchTask": "build", "windows": { - "program": "${workspaceFolder}/bin/Debug/net8.0/win-x64/SharpFM.dll", + "program": "${workspaceFolder}/src/SharpFM/bin/Debug/net8.0/win-x64/SharpFM.dll", }, - "linux": + "linux": { - "program": "${workspaceFolder}/bin/Debug/net8.0/linux-x64/SharpFM.dll", + "program": "${workspaceFolder}/src/SharpFM/bin/Debug/net8.0/linux-x64/SharpFM.dll", }, "args": [], "cwd": "${workspaceFolder}", diff --git a/SharpFM.sln b/SharpFM.sln index 7a8ee3f..8c23fc6 100644 --- a/SharpFM.sln +++ b/SharpFM.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM", "SharpFM.csproj", "{5245F468-DAD7-478C-8E5F-518A03664F71}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpFM", "src\SharpFM\SharpFM.csproj", "{5245F468-DAD7-478C-8E5F-518A03664F71}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{E2FF2BB3-AF37-44BA-BD84-999B352D814E}" EndProject diff --git a/App.axaml b/src/SharpFM/App.axaml similarity index 100% rename from App.axaml rename to src/SharpFM/App.axaml diff --git a/App.axaml.cs b/src/SharpFM/App.axaml.cs similarity index 100% rename from App.axaml.cs rename to src/SharpFM/App.axaml.cs diff --git a/Assets/noun-sharp-teeth-monster-4226695.png b/src/SharpFM/Assets/noun-sharp-teeth-monster-4226695.png similarity index 100% rename from Assets/noun-sharp-teeth-monster-4226695.png rename to src/SharpFM/Assets/noun-sharp-teeth-monster-4226695.png diff --git a/Assets/noun-sharp-teeth-monster-4226695.small.png b/src/SharpFM/Assets/noun-sharp-teeth-monster-4226695.small.png similarity index 100% rename from Assets/noun-sharp-teeth-monster-4226695.small.png rename to src/SharpFM/Assets/noun-sharp-teeth-monster-4226695.small.png diff --git a/AvaloniaNLogSink.cs b/src/SharpFM/AvaloniaNLogSink.cs similarity index 100% rename from AvaloniaNLogSink.cs rename to src/SharpFM/AvaloniaNLogSink.cs diff --git a/Core/FileMakerClip.cs b/src/SharpFM/Core/FileMakerClip.cs similarity index 100% rename from Core/FileMakerClip.cs rename to src/SharpFM/Core/FileMakerClip.cs diff --git a/Core/FileMakerClipExtensions.cs b/src/SharpFM/Core/FileMakerClipExtensions.cs similarity index 100% rename from Core/FileMakerClipExtensions.cs rename to src/SharpFM/Core/FileMakerClipExtensions.cs diff --git a/Core/FileMakerField.cs b/src/SharpFM/Core/FileMakerField.cs similarity index 100% rename from Core/FileMakerField.cs rename to src/SharpFM/Core/FileMakerField.cs diff --git a/MainWindow.axaml b/src/SharpFM/MainWindow.axaml similarity index 100% rename from MainWindow.axaml rename to src/SharpFM/MainWindow.axaml diff --git a/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs similarity index 98% rename from MainWindow.axaml.cs rename to src/SharpFM/MainWindow.axaml.cs index ad78447..7405846 100644 --- a/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using AvaloniaEdit; using AvaloniaEdit.TextMate; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using TextMateSharp.Grammars; namespace SharpFM; diff --git a/Models/Clip.cs b/src/SharpFM/Models/Clip.cs similarity index 100% rename from Models/Clip.cs rename to src/SharpFM/Models/Clip.cs diff --git a/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs similarity index 100% rename from Models/ClipRepository.cs rename to src/SharpFM/Models/ClipRepository.cs diff --git a/Program.cs b/src/SharpFM/Program.cs similarity index 100% rename from Program.cs rename to src/SharpFM/Program.cs diff --git a/Core/ScriptConverter/IStepCatalog.cs b/src/SharpFM/Scripting/Catalog/IStepCatalog.cs similarity index 90% rename from Core/ScriptConverter/IStepCatalog.cs rename to src/SharpFM/Scripting/Catalog/IStepCatalog.cs index dcf463f..cb61dfc 100644 --- a/Core/ScriptConverter/IStepCatalog.cs +++ b/src/SharpFM/Scripting/Catalog/IStepCatalog.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Catalog; /// /// Abstracts step catalog access for testability and potential locale/version swapping. diff --git a/Core/ScriptConverter/StepCatalog.cs b/src/SharpFM/Scripting/Catalog/StepCatalog.cs similarity index 98% rename from Core/ScriptConverter/StepCatalog.cs rename to src/SharpFM/Scripting/Catalog/StepCatalog.cs index 8e95848..ca40127 100644 --- a/Core/ScriptConverter/StepCatalog.cs +++ b/src/SharpFM/Scripting/Catalog/StepCatalog.cs @@ -6,7 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Catalog; public record StepDefinition { diff --git a/Core/ScriptConverter/StepCatalogLoader.cs b/src/SharpFM/Scripting/Catalog/StepCatalogLoader.cs similarity index 98% rename from Core/ScriptConverter/StepCatalogLoader.cs rename to src/SharpFM/Scripting/Catalog/StepCatalogLoader.cs index 985be08..0f9153b 100644 --- a/Core/ScriptConverter/StepCatalogLoader.cs +++ b/src/SharpFM/Scripting/Catalog/StepCatalogLoader.cs @@ -7,7 +7,7 @@ using System.Reflection; using System.Text.Json; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Catalog; public class StepCatalogLoader : IStepCatalog { diff --git a/Core/ScriptConverter/step-catalog-en.json b/src/SharpFM/Scripting/Catalog/step-catalog-en.json similarity index 100% rename from Core/ScriptConverter/step-catalog-en.json rename to src/SharpFM/Scripting/Catalog/step-catalog-en.json diff --git a/Core/ScriptConverter/BracketMatchRenderer.cs b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs similarity index 98% rename from Core/ScriptConverter/BracketMatchRenderer.cs rename to src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs index 05bd7d9..8bacbfd 100644 --- a/Core/ScriptConverter/BracketMatchRenderer.cs +++ b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs @@ -5,7 +5,7 @@ using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public class BracketMatchRenderer : IBackgroundRenderer { diff --git a/Core/ScriptConverter/ErrorMarkerRenderer.cs b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs similarity index 98% rename from Core/ScriptConverter/ErrorMarkerRenderer.cs rename to src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs index b47177c..d70653f 100644 --- a/Core/ScriptConverter/ErrorMarkerRenderer.cs +++ b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs @@ -5,7 +5,7 @@ using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public class ErrorMarkerRenderer : IBackgroundRenderer { diff --git a/Core/ScriptConverter/FmScriptCompletionData.cs b/src/SharpFM/Scripting/Editor/FmScriptCompletionData.cs similarity index 95% rename from Core/ScriptConverter/FmScriptCompletionData.cs rename to src/SharpFM/Scripting/Editor/FmScriptCompletionData.cs index 3ca8d39..b03f33c 100644 --- a/Core/ScriptConverter/FmScriptCompletionData.cs +++ b/src/SharpFM/Scripting/Editor/FmScriptCompletionData.cs @@ -5,7 +5,7 @@ using AvaloniaEdit.Editing; using AvaloniaEdit.CodeCompletion; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public class FmScriptCompletionData : ICompletionData { diff --git a/Core/ScriptConverter/FmScriptCompletionProvider.cs b/src/SharpFM/Scripting/Editor/FmScriptCompletionProvider.cs similarity index 99% rename from Core/ScriptConverter/FmScriptCompletionProvider.cs rename to src/SharpFM/Scripting/Editor/FmScriptCompletionProvider.cs index 94108db..ee2c268 100644 --- a/Core/ScriptConverter/FmScriptCompletionProvider.cs +++ b/src/SharpFM/Scripting/Editor/FmScriptCompletionProvider.cs @@ -3,7 +3,7 @@ using System.Linq; using AvaloniaEdit.CodeCompletion; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public enum CompletionContext { diff --git a/Core/ScriptConverter/FmScriptRegistryOptions.cs b/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs similarity index 97% rename from Core/ScriptConverter/FmScriptRegistryOptions.cs rename to src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs index 57490a4..bbeeb83 100644 --- a/Core/ScriptConverter/FmScriptRegistryOptions.cs +++ b/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs @@ -7,7 +7,7 @@ using TextMateSharp.Registry; using TextMateSharp.Themes; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public class FmScriptRegistryOptions : IRegistryOptions { diff --git a/Core/ScriptConverter/ScriptEditorController.cs b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs similarity index 99% rename from Core/ScriptConverter/ScriptEditorController.cs rename to src/SharpFM/Scripting/Editor/ScriptEditorController.cs index 58cc17a..4fda456 100644 --- a/Core/ScriptConverter/ScriptEditorController.cs +++ b/src/SharpFM/Scripting/Editor/ScriptEditorController.cs @@ -7,7 +7,7 @@ using AvaloniaEdit.CodeCompletion; using AvaloniaEdit.Document; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; /// /// Manages script editor behavior: validation, completion, tooltips, and document tracking. diff --git a/Core/ScriptConverter/StatementHighlightRenderer.cs b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs similarity index 98% rename from Core/ScriptConverter/StatementHighlightRenderer.cs rename to src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs index a5afb2f..2740bb6 100644 --- a/Core/ScriptConverter/StatementHighlightRenderer.cs +++ b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs @@ -6,7 +6,7 @@ using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Editor; public class StatementHighlightRenderer : IBackgroundRenderer { diff --git a/Core/ScriptConverter/fmscript.tmLanguage.json b/src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json similarity index 100% rename from Core/ScriptConverter/fmscript.tmLanguage.json rename to src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json diff --git a/src/SharpFM/Scripting/GlobalUsings.cs b/src/SharpFM/Scripting/GlobalUsings.cs new file mode 100644 index 0000000..debaa7e --- /dev/null +++ b/src/SharpFM/Scripting/GlobalUsings.cs @@ -0,0 +1,8 @@ +// Shared using directives for the SharpFM.Scripting namespace tree. +// Allows types in sub-namespaces to reference each other without explicit usings. +global using SharpFM.Scripting.Catalog; +global using SharpFM.Scripting.Model; +global using SharpFM.Scripting.Parsing; +global using SharpFM.Scripting.Validation; +global using SharpFM.Scripting.Handlers; +global using SharpFM.Scripting.Editor; diff --git a/Core/ScriptConverter/StepHandlers/CommentHandler.cs b/src/SharpFM/Scripting/Handlers/CommentHandler.cs similarity index 95% rename from Core/ScriptConverter/StepHandlers/CommentHandler.cs rename to src/SharpFM/Scripting/Handlers/CommentHandler.cs index 44d2cd2..0da4e1b 100644 --- a/Core/ScriptConverter/StepHandlers/CommentHandler.cs +++ b/src/SharpFM/Scripting/Handlers/CommentHandler.cs @@ -1,7 +1,7 @@ using System.Linq; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class CommentHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs b/src/SharpFM/Scripting/Handlers/ControlFlowHandler.cs similarity index 97% rename from Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs rename to src/SharpFM/Scripting/Handlers/ControlFlowHandler.cs index 123d54a..76b490e 100644 --- a/Core/ScriptConverter/StepHandlers/ControlFlowHandler.cs +++ b/src/SharpFM/Scripting/Handlers/ControlFlowHandler.cs @@ -1,6 +1,6 @@ using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class ControlFlowHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs b/src/SharpFM/Scripting/Handlers/GoToLayoutHandler.cs similarity index 97% rename from Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs rename to src/SharpFM/Scripting/Handlers/GoToLayoutHandler.cs index ec2bb4f..5f64e09 100644 --- a/Core/ScriptConverter/StepHandlers/GoToLayoutHandler.cs +++ b/src/SharpFM/Scripting/Handlers/GoToLayoutHandler.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class GoToLayoutHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs b/src/SharpFM/Scripting/Handlers/GoToRecordHandler.cs similarity index 97% rename from Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs rename to src/SharpFM/Scripting/Handlers/GoToRecordHandler.cs index e30a37b..18410ea 100644 --- a/Core/ScriptConverter/StepHandlers/GoToRecordHandler.cs +++ b/src/SharpFM/Scripting/Handlers/GoToRecordHandler.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class GoToRecordHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/IStepHandler.cs b/src/SharpFM/Scripting/Handlers/IStepHandler.cs similarity index 94% rename from Core/ScriptConverter/StepHandlers/IStepHandler.cs rename to src/SharpFM/Scripting/Handlers/IStepHandler.cs index 04ecedb..2b8f162 100644 --- a/Core/ScriptConverter/StepHandlers/IStepHandler.cs +++ b/src/SharpFM/Scripting/Handlers/IStepHandler.cs @@ -1,6 +1,6 @@ using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; /// /// Handles specialized display, serialization, and parsing for specific step types. diff --git a/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs b/src/SharpFM/Scripting/Handlers/PerformScriptHandler.cs similarity index 96% rename from Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs rename to src/SharpFM/Scripting/Handlers/PerformScriptHandler.cs index 426fc87..f971239 100644 --- a/Core/ScriptConverter/StepHandlers/PerformScriptHandler.cs +++ b/src/SharpFM/Scripting/Handlers/PerformScriptHandler.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class PerformScriptHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs b/src/SharpFM/Scripting/Handlers/SetFieldHandler.cs similarity index 97% rename from Core/ScriptConverter/StepHandlers/SetFieldHandler.cs rename to src/SharpFM/Scripting/Handlers/SetFieldHandler.cs index 56fb0b3..e337ca3 100644 --- a/Core/ScriptConverter/StepHandlers/SetFieldHandler.cs +++ b/src/SharpFM/Scripting/Handlers/SetFieldHandler.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class SetFieldHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs b/src/SharpFM/Scripting/Handlers/SetVariableHandler.cs similarity index 98% rename from Core/ScriptConverter/StepHandlers/SetVariableHandler.cs rename to src/SharpFM/Scripting/Handlers/SetVariableHandler.cs index d4ef2a6..aeb4a71 100644 --- a/Core/ScriptConverter/StepHandlers/SetVariableHandler.cs +++ b/src/SharpFM/Scripting/Handlers/SetVariableHandler.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class SetVariableHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs b/src/SharpFM/Scripting/Handlers/ShowCustomDialogHandler.cs similarity index 97% rename from Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs rename to src/SharpFM/Scripting/Handlers/ShowCustomDialogHandler.cs index d7cd1c5..40a66f9 100644 --- a/Core/ScriptConverter/StepHandlers/ShowCustomDialogHandler.cs +++ b/src/SharpFM/Scripting/Handlers/ShowCustomDialogHandler.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; internal class ShowCustomDialogHandler : StepHandlerBase, IStepHandler { diff --git a/Core/ScriptConverter/StepHandlers/StepHandlerBase.cs b/src/SharpFM/Scripting/Handlers/StepHandlerBase.cs similarity index 96% rename from Core/ScriptConverter/StepHandlers/StepHandlerBase.cs rename to src/SharpFM/Scripting/Handlers/StepHandlerBase.cs index d16348c..20be980 100644 --- a/Core/ScriptConverter/StepHandlers/StepHandlerBase.cs +++ b/src/SharpFM/Scripting/Handlers/StepHandlerBase.cs @@ -1,7 +1,7 @@ using System; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; /// /// Shared helpers for step handler implementations. diff --git a/Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs b/src/SharpFM/Scripting/Handlers/StepHandlerRegistry.cs similarity index 95% rename from Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs rename to src/SharpFM/Scripting/Handlers/StepHandlerRegistry.cs index e57d102..002214a 100644 --- a/Core/ScriptConverter/StepHandlers/StepHandlerRegistry.cs +++ b/src/SharpFM/Scripting/Handlers/StepHandlerRegistry.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace SharpFM.Core.ScriptConverter.StepHandlers; +namespace SharpFM.Scripting.Handlers; /// /// Central registry for step handlers. Lookup by step name, fallback to null (generic handling). diff --git a/Core/ScriptConverter/FmScript.cs b/src/SharpFM/Scripting/Model/FmScript.cs similarity index 99% rename from Core/ScriptConverter/FmScript.cs rename to src/SharpFM/Scripting/Model/FmScript.cs index d34fab9..a4dfd9d 100644 --- a/Core/ScriptConverter/FmScript.cs +++ b/src/SharpFM/Scripting/Model/FmScript.cs @@ -6,7 +6,7 @@ using System.Xml.Linq; using NLog; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Model; public class FmScript { diff --git a/Core/ScriptConverter/ParsedLine.cs b/src/SharpFM/Scripting/Model/ParsedLine.cs similarity index 76% rename from Core/ScriptConverter/ParsedLine.cs rename to src/SharpFM/Scripting/Model/ParsedLine.cs index 5e44e76..eee0034 100644 --- a/Core/ScriptConverter/ParsedLine.cs +++ b/src/SharpFM/Scripting/Model/ParsedLine.cs @@ -1,4 +1,4 @@ -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Model; public record ParsedStep( string StepName, diff --git a/Core/ScriptConverter/ScriptStep.Specialized.cs b/src/SharpFM/Scripting/Model/ScriptStep.Specialized.cs similarity index 90% rename from Core/ScriptConverter/ScriptStep.Specialized.cs rename to src/SharpFM/Scripting/Model/ScriptStep.Specialized.cs index 3917b7b..eee7a0f 100644 --- a/Core/ScriptConverter/ScriptStep.Specialized.cs +++ b/src/SharpFM/Scripting/Model/ScriptStep.Specialized.cs @@ -1,7 +1,7 @@ using System.Xml.Linq; -using SharpFM.Core.ScriptConverter.StepHandlers; +using SharpFM.Scripting.Handlers; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Model; public partial class ScriptStep { diff --git a/Core/ScriptConverter/ScriptStep.cs b/src/SharpFM/Scripting/Model/ScriptStep.cs similarity index 99% rename from Core/ScriptConverter/ScriptStep.cs rename to src/SharpFM/Scripting/Model/ScriptStep.cs index 0befdb5..42860e7 100644 --- a/Core/ScriptConverter/ScriptStep.cs +++ b/src/SharpFM/Scripting/Model/ScriptStep.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Model; public partial class ScriptStep { diff --git a/Core/ScriptConverter/StepParamValue.cs b/src/SharpFM/Scripting/Model/StepParamValue.cs similarity index 99% rename from Core/ScriptConverter/StepParamValue.cs rename to src/SharpFM/Scripting/Model/StepParamValue.cs index 0485185..4cf9e7c 100644 --- a/Core/ScriptConverter/StepParamValue.cs +++ b/src/SharpFM/Scripting/Model/StepParamValue.cs @@ -4,7 +4,7 @@ using System.Text.Json; using System.Xml.Linq; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Model; public class StepParamValue { diff --git a/Core/ScriptConverter/BracketMatcher.cs b/src/SharpFM/Scripting/Parsing/BracketMatcher.cs similarity index 99% rename from Core/ScriptConverter/BracketMatcher.cs rename to src/SharpFM/Scripting/Parsing/BracketMatcher.cs index b14d58b..416407c 100644 --- a/Core/ScriptConverter/BracketMatcher.cs +++ b/src/SharpFM/Scripting/Parsing/BracketMatcher.cs @@ -1,4 +1,4 @@ -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Parsing; /// /// Shared bracket matching utilities. All bracket/quote-aware logic diff --git a/Core/ScriptConverter/ScriptLineParser.cs b/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs similarity index 98% rename from Core/ScriptConverter/ScriptLineParser.cs rename to src/SharpFM/Scripting/Parsing/ScriptLineParser.cs index 87936a5..39149fc 100644 --- a/Core/ScriptConverter/ScriptLineParser.cs +++ b/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Parsing; public static class ScriptLineParser { diff --git a/Core/ScriptConverter/ScriptDiagnostic.cs b/src/SharpFM/Scripting/Validation/ScriptDiagnostic.cs similarity index 83% rename from Core/ScriptConverter/ScriptDiagnostic.cs rename to src/SharpFM/Scripting/Validation/ScriptDiagnostic.cs index fa5d47c..bf7bfb0 100644 --- a/Core/ScriptConverter/ScriptDiagnostic.cs +++ b/src/SharpFM/Scripting/Validation/ScriptDiagnostic.cs @@ -1,4 +1,4 @@ -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Validation; public enum DiagnosticSeverity { diff --git a/Core/ScriptConverter/ScriptValidator.cs b/src/SharpFM/Scripting/Validation/ScriptValidator.cs similarity index 99% rename from Core/ScriptConverter/ScriptValidator.cs rename to src/SharpFM/Scripting/Validation/ScriptValidator.cs index d0a9c4e..e4dd13c 100644 --- a/Core/ScriptConverter/ScriptValidator.cs +++ b/src/SharpFM/Scripting/Validation/ScriptValidator.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Text.Json; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting.Validation; public static class ScriptValidator { diff --git a/Core/ScriptConverter/XmlHelpers.cs b/src/SharpFM/Scripting/XmlHelpers.cs similarity index 96% rename from Core/ScriptConverter/XmlHelpers.cs rename to src/SharpFM/Scripting/XmlHelpers.cs index 01f3162..2d7b7a2 100644 --- a/Core/ScriptConverter/XmlHelpers.cs +++ b/src/SharpFM/Scripting/XmlHelpers.cs @@ -4,7 +4,7 @@ using System.Xml.Linq; using NLog; -namespace SharpFM.Core.ScriptConverter; +namespace SharpFM.Scripting; internal static class XmlHelpers { diff --git a/Services/FolderService.cs b/src/SharpFM/Services/FolderService.cs similarity index 100% rename from Services/FolderService.cs rename to src/SharpFM/Services/FolderService.cs diff --git a/SharpFM.csproj b/src/SharpFM/SharpFM.csproj similarity index 90% rename from SharpFM.csproj rename to src/SharpFM/SharpFM.csproj index 89e4b73..9ee0218 100644 --- a/SharpFM.csproj +++ b/src/SharpFM/SharpFM.csproj @@ -29,13 +29,8 @@ - - - - - - - + + diff --git a/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs similarity index 98% rename from ViewModels/ClipViewModel.cs rename to src/SharpFM/ViewModels/ClipViewModel.cs index 7980134..709abeb 100644 --- a/ViewModels/ClipViewModel.cs +++ b/src/SharpFM/ViewModels/ClipViewModel.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using AvaloniaEdit.Document; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; namespace SharpFM.ViewModels; diff --git a/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs similarity index 100% rename from ViewModels/MainWindowViewModel.cs rename to src/SharpFM/ViewModels/MainWindowViewModel.cs diff --git a/app.manifest b/src/SharpFM/app.manifest similarity index 100% rename from app.manifest rename to src/SharpFM/app.manifest diff --git a/nlog.config b/src/SharpFM/nlog.config similarity index 100% rename from nlog.config rename to src/SharpFM/nlog.config diff --git a/tests/SharpFM.Tests/GlobalUsings.cs b/tests/SharpFM.Tests/GlobalUsings.cs index 8c927eb..d3ced34 100644 --- a/tests/SharpFM.Tests/GlobalUsings.cs +++ b/tests/SharpFM.Tests/GlobalUsings.cs @@ -1 +1,6 @@ -global using Xunit; \ No newline at end of file +global using Xunit; +global using SharpFM.Scripting.Catalog; +global using SharpFM.Scripting.Model; +global using SharpFM.Scripting.Parsing; +global using SharpFM.Scripting.Validation; +global using SharpFM.Scripting.Editor; \ No newline at end of file diff --git a/tests/SharpFM.Tests/ScriptConverter/CompletionProviderTests.cs b/tests/SharpFM.Tests/Scripting/CompletionProviderTests.cs similarity index 98% rename from tests/SharpFM.Tests/ScriptConverter/CompletionProviderTests.cs rename to tests/SharpFM.Tests/Scripting/CompletionProviderTests.cs index b462a08..7bc540a 100644 --- a/tests/SharpFM.Tests/ScriptConverter/CompletionProviderTests.cs +++ b/tests/SharpFM.Tests/Scripting/CompletionProviderTests.cs @@ -1,5 +1,5 @@ using System.Linq; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs b/tests/SharpFM.Tests/Scripting/FmScriptModelTests.cs similarity index 99% rename from tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs rename to tests/SharpFM.Tests/Scripting/FmScriptModelTests.cs index 64bae8b..628d995 100644 --- a/tests/SharpFM.Tests/ScriptConverter/FmScriptModelTests.cs +++ b/tests/SharpFM.Tests/Scripting/FmScriptModelTests.cs @@ -1,6 +1,6 @@ using System.Linq; using System.Xml.Linq; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptLineParserTests.cs b/tests/SharpFM.Tests/Scripting/ScriptLineParserTests.cs similarity index 99% rename from tests/SharpFM.Tests/ScriptConverter/ScriptLineParserTests.cs rename to tests/SharpFM.Tests/Scripting/ScriptLineParserTests.cs index f482481..35f0b25 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptLineParserTests.cs +++ b/tests/SharpFM.Tests/Scripting/ScriptLineParserTests.cs @@ -1,5 +1,5 @@ using System; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs b/tests/SharpFM.Tests/Scripting/ScriptStepTests.cs similarity index 99% rename from tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs rename to tests/SharpFM.Tests/Scripting/ScriptStepTests.cs index db70363..ed3905b 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptStepTests.cs +++ b/tests/SharpFM.Tests/Scripting/ScriptStepTests.cs @@ -1,6 +1,6 @@ using System.Linq; using System.Xml.Linq; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs b/tests/SharpFM.Tests/Scripting/ScriptValidatorTests.cs similarity index 99% rename from tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs rename to tests/SharpFM.Tests/Scripting/ScriptValidatorTests.cs index 090e85b..8ee470c 100644 --- a/tests/SharpFM.Tests/ScriptConverter/ScriptValidatorTests.cs +++ b/tests/SharpFM.Tests/Scripting/ScriptValidatorTests.cs @@ -1,5 +1,5 @@ using System.Linq; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs b/tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs similarity index 98% rename from tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs rename to tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs index 86c5b27..196459a 100644 --- a/tests/SharpFM.Tests/ScriptConverter/StepCatalogLoaderTests.cs +++ b/tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs @@ -1,5 +1,5 @@ using System.Linq; -using SharpFM.Core.ScriptConverter; +using SharpFM.Scripting; using Xunit; namespace SharpFM.Tests.ScriptConverter; diff --git a/tests/SharpFM.Tests/SharpFM.Tests.csproj b/tests/SharpFM.Tests/SharpFM.Tests.csproj index 030de1e..3a941ea 100644 --- a/tests/SharpFM.Tests/SharpFM.Tests.csproj +++ b/tests/SharpFM.Tests/SharpFM.Tests.csproj @@ -23,7 +23,7 @@ - + From a61d9e0dfaeb72160224e22f6b2e97d0018b681a Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 13:55:23 -0500 Subject: [PATCH 13/29] feat: add ClipboardService, status bar feedback, and model sync on save/copy --- src/SharpFM/App.axaml.cs | 11 +- src/SharpFM/MainWindow.axaml | 11 ++ src/SharpFM/Services/ClipboardService.cs | 47 +++++ src/SharpFM/Services/IClipboardService.cs | 11 ++ src/SharpFM/ViewModels/MainWindowViewModel.cs | 183 ++++++++++-------- .../ViewModels/MainWindowViewModelTests.cs | 91 +++++++++ 6 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 src/SharpFM/Services/ClipboardService.cs create mode 100644 src/SharpFM/Services/IClipboardService.cs create mode 100644 tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index 9ad5eda..f214047 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -23,16 +23,15 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow - { - DataContext = new MainWindowViewModel(logger) - }; + desktop.MainWindow = new MainWindow(); var services = new ServiceCollection(); - services.AddSingleton(x => new FolderService(desktop.MainWindow)); - + services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); Services = services.BuildServiceProvider(); + + var clipboard = Services.GetRequiredService(); + desktop.MainWindow.DataContext = new MainWindowViewModel(logger, clipboard); } base.OnFrameworkInitializationCompleted(); diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 8c82e6d..f8705da 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -184,6 +184,17 @@ + + + + + \ No newline at end of file diff --git a/src/SharpFM/Services/ClipboardService.cs b/src/SharpFM/Services/ClipboardService.cs new file mode 100644 index 0000000..7f0afe9 --- /dev/null +++ b/src/SharpFM/Services/ClipboardService.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using FluentAvalonia.UI.Data; + +namespace SharpFM.Services; + +public class ClipboardService : IClipboardService +{ + private readonly Window _window; + + public ClipboardService(Window window) + { + _window = window; + } + + public async Task SetTextAsync(string text) + { + var clipboard = _window.Clipboard + ?? throw new InvalidOperationException("Clipboard is not available."); + await clipboard.SetTextAsync(text); + } + + public async Task SetDataAsync(string format, byte[] data) + { + var clipboard = _window.Clipboard + ?? throw new InvalidOperationException("Clipboard is not available."); + var dp = new DataPackage(); + dp.SetData(format, data); + await clipboard.SetDataObjectAsync(dp); + } + + public async Task GetFormatsAsync() + { + var clipboard = _window.Clipboard + ?? throw new InvalidOperationException("Clipboard is not available."); + return await clipboard.GetFormatsAsync(); + } + + public async Task GetDataAsync(string format) + { + var clipboard = _window.Clipboard + ?? throw new InvalidOperationException("Clipboard is not available."); + return await clipboard.GetDataAsync(format); + } +} diff --git a/src/SharpFM/Services/IClipboardService.cs b/src/SharpFM/Services/IClipboardService.cs new file mode 100644 index 0000000..e14e033 --- /dev/null +++ b/src/SharpFM/Services/IClipboardService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace SharpFM.Services; + +public interface IClipboardService +{ + Task SetTextAsync(string text); + Task SetDataAsync(string format, byte[] data); + Task GetFormatsAsync(); + Task GetDataAsync(string format); +} diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index d4703b7..2337f15 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -7,8 +7,7 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using FluentAvalonia.UI.Data; -using Microsoft.Extensions.DependencyInjection; +using Avalonia.Threading; using Microsoft.Extensions.Logging; using SharpFM.Models; using SharpFM.Services; @@ -18,6 +17,8 @@ namespace SharpFM.ViewModels; public partial class MainWindowViewModel : INotifyPropertyChanged { private readonly ILogger _logger; + private readonly IClipboardService _clipboard; + private readonly DispatcherTimer _statusTimer; public event PropertyChangedEventHandler? PropertyChanged; @@ -26,9 +27,17 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public MainWindowViewModel(ILogger logger) + public MainWindowViewModel(ILogger logger, IClipboardService clipboard) { _logger = logger; + _clipboard = clipboard; + + _statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; + _statusTimer.Tick += (_, _) => + { + _statusTimer.Stop(); + StatusMessage = ""; + }; // default to the local app data folder + \SharpFM, otherwise use provided path _currentPath ??= Path.Join( @@ -71,41 +80,51 @@ private void LoadClips(string pathToLoad) public async Task OpenFolderPicker() { - if (App.Current?.Services?.GetService() is FolderService folderService) + if (App.Current?.Services?.GetService(typeof(FolderService)) is FolderService folderService) { CurrentPath = await folderService.GetFolderAsync(); - LoadClips(CurrentPath); } } public void SaveClipsStorage() { - var clipContext = new ClipRepository(CurrentPath); - - var fsClips = clipContext.Clips.ToList(); - - foreach (var clip in FileMakerClips) + try { - var dbClip = fsClips.FirstOrDefault(dbc => dbc.ClipName == clip.Name); + var clipContext = new ClipRepository(CurrentPath); + var fsClips = clipContext.Clips.ToList(); - if (dbClip is not null) + foreach (var clip in FileMakerClips) { - dbClip.ClipType = clip.ClipType; - dbClip.ClipXml = clip.ClipXml; - } - else - { - clipContext.Clips.Add(new Clip() + // Sync model to XML before saving + clip.SyncModelFromEditor(); + + var dbClip = fsClips.FirstOrDefault(dbc => dbc.ClipName == clip.Name); + + if (dbClip is not null) + { + dbClip.ClipType = clip.ClipType; + dbClip.ClipXml = clip.ClipXml; + } + else { - ClipName = clip.Name, - ClipType = clip.ClipType, - ClipXml = clip.ClipXml - }); + clipContext.Clips.Add(new Clip() + { + ClipName = clip.Name, + ClipType = clip.ClipType, + ClipXml = clip.ClipXml + }); + } } - } - clipContext.SaveChanges(); + clipContext.SaveChanges(); + ShowStatus($"Saved {FileMakerClips.Count} clip(s) to {CurrentPath}"); + } + catch (Exception e) + { + _logger.LogError(e, "Error saving clips."); + ShowStatus("Error saving clips"); + } } [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Bound to Xaml Button, throws when static.")] @@ -122,39 +141,34 @@ public void NewEmptyItem() try { var clip = new FileMakerClip("New", FileMakerClip.ClipTypes.First()?.KeyId ?? "", Array.Empty()); - var clipVm = new ClipViewModel(clip); - - FileMakerClips.Add(clipVm); + FileMakerClips.Add(new ClipViewModel(clip)); + ShowStatus("Created new clip"); } catch (Exception e) { - _logger.LogCritical(e, "Error creating new Clip."); + _logger.LogError(e, "Error creating new clip."); + ShowStatus("Error creating clip"); } } - public void CopyAsClass() + public async Task CopyAsClass() { - // TODO: improve the UX of this whole thing. This works as a hack - // for proving the concept, but it could be so much better. - try + if (SelectedClip == null) { - if (SelectedClip == null) - { - // no clip selected; - return; - } - - // See DepInject project for a sample of how to accomplish this. - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || - desktop.MainWindow?.Clipboard is not { } provider) - throw new NullReferenceException("Missing Clipboard instance."); + ShowStatus("No clip selected"); + return; + } + try + { var classString = SelectedClip.Clip.CreateClass(); - provider.SetTextAsync(classString); + await _clipboard.SetTextAsync(classString); + ShowStatus("Copied C# class to clipboard"); } catch (Exception e) { - _logger.LogCritical(e, "Error Copying as Class."); + _logger.LogError(e, "Error copying as class."); + ShowStatus("Error copying to clipboard"); } } @@ -162,71 +176,64 @@ public async Task PasteFileMakerClipData() { try { - // See DepInject project for a sample of how to accomplish this. - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || - desktop.MainWindow?.Clipboard is not { } provider) - throw new NullReferenceException("Missing Clipboard instance."); - - var formats = await provider.GetFormatsAsync(); + var formats = await _clipboard.GetFormatsAsync(); + int count = 0; foreach (var format in formats.Where(f => f.StartsWith("Mac-", StringComparison.CurrentCultureIgnoreCase)).Distinct()) { - if (string.IsNullOrEmpty(format)) { continue; } + if (string.IsNullOrEmpty(format)) continue; - object? clipData = await provider.GetDataAsync(format); + object? clipData = await _clipboard.GetDataAsync(format); - if (clipData is not byte[] dataObj) - { - // this is some type of clipboard data this program can't handle - continue; - } + if (clipData is not byte[] dataObj) continue; var clip = new FileMakerClip("new-clip", format, dataObj); - if (clip is null) { continue; } - - // don't bother adding a duplicate. For some reason entries were getting entered twice per clip - // this is not the most efficient method to detect it, but it works well enough for now - if (FileMakerClips.Any(k => k.ClipXml == clip.XmlData)) - { - continue; - } + // don't add duplicates + if (FileMakerClips.Any(k => k.ClipXml == clip.XmlData)) continue; FileMakerClips.Add(new ClipViewModel(clip)); + count++; } + + ShowStatus(count > 0 ? $"Pasted {count} clip(s) from FileMaker" : "No FileMaker clips found on clipboard"); } catch (Exception e) { - _logger.LogCritical(e, "Error translating FileMaker blob to Xml."); + _logger.LogError(e, "Error pasting from FileMaker clipboard."); + ShowStatus("Error pasting from clipboard"); } } public async Task CopySelectedToClip() { - try + if (SelectedClip is not ClipViewModel data) { - // See DepInject project for a sample of how to accomplish this. - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || - desktop.MainWindow?.Clipboard is not { } provider) - throw new NullReferenceException("Missing Clipboard instance."); - - var dp = new DataPackage(); - - if (SelectedClip is not ClipViewModel data) - { - return; // no data - } - - dp.SetData(data.ClipType, data.Clip.RawData); + ShowStatus("No clip selected"); + return; + } - await provider.SetDataObjectAsync(dp); + try + { + // Sync model to XML before copying to ensure current data + data.SyncModelFromEditor(); + await _clipboard.SetDataAsync(data.ClipType, data.Clip.RawData); + ShowStatus("Copied to FileMaker clipboard"); } catch (Exception e) { - _logger.LogCritical(e, "Error returning the selected Clip FileMaker blob format."); + _logger.LogError(e, "Error copying to FileMaker clipboard."); + ShowStatus("Error copying to clipboard"); } } + private void ShowStatus(string message) + { + StatusMessage = message; + _statusTimer.Stop(); + _statusTimer.Start(); + } + /// /// SharpFM Version. /// @@ -282,4 +289,14 @@ public string CurrentPath } } -} \ No newline at end of file + private string _statusMessage = ""; + public string StatusMessage + { + get => _statusMessage; + set + { + _statusMessage = value; + NotifyPropertyChanged(); + } + } +} diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs new file mode 100644 index 0000000..0d3a966 --- /dev/null +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SharpFM.Services; +using SharpFM.ViewModels; +using Xunit; + +namespace SharpFM.Tests.ViewModels; + +public class MockClipboardService : IClipboardService +{ + public string? LastText { get; private set; } + public string? LastFormat { get; private set; } + public byte[]? LastData { get; private set; } + public Dictionary ClipboardData { get; } = new(); + + public Task SetTextAsync(string text) { LastText = text; return Task.CompletedTask; } + public Task SetDataAsync(string format, byte[] data) { LastFormat = format; LastData = data; return Task.CompletedTask; } + public Task GetFormatsAsync() => Task.FromResult(Array.Empty()); + public Task GetDataAsync(string format) => + Task.FromResult(ClipboardData.TryGetValue(format, out var v) ? v : null); +} + +public class MainWindowViewModelTests +{ + private static MainWindowViewModel CreateVm(MockClipboardService? clipboard = null) + { + var logger = NullLoggerFactory.Instance.CreateLogger(); + return new MainWindowViewModel(logger, clipboard ?? new MockClipboardService()); + } + + [Fact] + public void NewEmptyItem_AddsClip() + { + var vm = CreateVm(); + var initialCount = vm.FileMakerClips.Count; + vm.NewEmptyItem(); + Assert.Equal(initialCount + 1, vm.FileMakerClips.Count); + Assert.Contains("Created new clip", vm.StatusMessage); + } + + [Fact] + public async Task CopyAsClass_NoSelection_ShowsStatus() + { + var vm = CreateVm(); + vm.SelectedClip = null; + await vm.CopyAsClass(); + Assert.Equal("No clip selected", vm.StatusMessage); + } + + [Fact] + public async Task CopySelectedToClip_NoSelection_ShowsStatus() + { + var vm = CreateVm(); + vm.SelectedClip = null; + await vm.CopySelectedToClip(); + Assert.Equal("No clip selected", vm.StatusMessage); + } + + [Fact] + public async Task PasteFileMakerClipData_NoFormats_ShowsStatus() + { + var clipboard = new MockClipboardService(); + var vm = CreateVm(clipboard); + await vm.PasteFileMakerClipData(); + Assert.Contains("No FileMaker clips found", vm.StatusMessage); + } + + [Fact] + public void StatusMessage_NotifiesPropertyChanged() + { + var vm = CreateVm(); + string? changedProperty = null; + vm.PropertyChanged += (_, args) => changedProperty = args.PropertyName; + vm.StatusMessage = "test"; + Assert.Equal("StatusMessage", changedProperty); + } + + [Fact] + public void SearchText_FiltersClips() + { + var vm = CreateVm(); + vm.NewEmptyItem(); // adds a clip named "New" + vm.SearchText = "zzz_nonexistent"; + Assert.Empty(vm.FilteredClips); + vm.SearchText = ""; + Assert.NotEmpty(vm.FilteredClips); + } +} From 02031fa711d4b2d6c66a98b3523eb76c3689e8a6 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 14:33:40 -0500 Subject: [PATCH 14/29] refactor: inject FolderService, extract shared theme constants, rename ParsedStep file --- src/SharpFM/App.axaml.cs | 8 +++++--- .../Scripting/Editor/BracketMatchRenderer.cs | 4 +--- .../Scripting/Editor/ErrorMarkerRenderer.cs | 6 +++--- .../Scripting/Editor/ScriptEditorTheme.cs | 16 ++++++++++++++++ .../Editor/StatementHighlightRenderer.cs | 3 +-- .../Model/{ParsedLine.cs => ParsedStep.cs} | 0 src/SharpFM/Services/FolderService.cs | 2 +- src/SharpFM/Services/IFolderService.cs | 8 ++++++++ src/SharpFM/ViewModels/MainWindowViewModel.cs | 14 +++++++++++--- .../ViewModels/MainWindowViewModelTests.cs | 15 +++++++++++++-- 10 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs rename src/SharpFM/Scripting/Model/{ParsedLine.cs => ParsedStep.cs} (100%) create mode 100644 src/SharpFM/Services/IFolderService.cs diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index f214047..d6250d5 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -26,12 +26,14 @@ public override void OnFrameworkInitializationCompleted() desktop.MainWindow = new MainWindow(); var services = new ServiceCollection(); - services.AddSingleton(x => new FolderService(desktop.MainWindow)); + services.AddSingleton(x => new FolderService(desktop.MainWindow)); services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); Services = services.BuildServiceProvider(); - var clipboard = Services.GetRequiredService(); - desktop.MainWindow.DataContext = new MainWindowViewModel(logger, clipboard); + desktop.MainWindow.DataContext = new MainWindowViewModel( + logger, + Services.GetRequiredService(), + Services.GetRequiredService()); } base.OnFrameworkInitializationCompleted(); diff --git a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs index 8bacbfd..6c466d9 100644 --- a/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs +++ b/src/SharpFM/Scripting/Editor/BracketMatchRenderer.cs @@ -9,8 +9,6 @@ namespace SharpFM.Scripting.Editor; public class BracketMatchRenderer : IBackgroundRenderer { - private static readonly IBrush MatchBrush = new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)); - private static readonly IPen MatchPen = new Pen(new SolidColorBrush(Color.FromArgb(100, 255, 255, 255)), 1.0); private readonly TextArea _textArea; private int _openOffset = -1; @@ -80,7 +78,7 @@ private static void DrawBracketHighlight(TextView textView, DrawingContext conte var segment = new TextSegment { StartOffset = offset, EndOffset = offset + 1 }; foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) { - context.DrawRectangle(MatchBrush, MatchPen, rect); + context.DrawRectangle(ScriptEditorTheme.BracketMatchBrush, ScriptEditorTheme.BracketMatchPen, rect); } } diff --git a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs index d70653f..8bd07c5 100644 --- a/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs +++ b/src/SharpFM/Scripting/Editor/ErrorMarkerRenderer.cs @@ -12,8 +12,6 @@ public class ErrorMarkerRenderer : IBackgroundRenderer private readonly TextDocument _document; private List _diagnostics = new(); - private static readonly IPen ErrorPen = new Pen(Brushes.Red, 1.0); - private static readonly IPen WarningPen = new Pen(Brushes.Gold, 1.0); public ErrorMarkerRenderer(TextDocument document) { @@ -77,7 +75,9 @@ public void Draw(TextView textView, DrawingContext drawingContext) } var segment = new TextSegment { StartOffset = startOffset, EndOffset = endOffset }; - var pen = diag.Severity == DiagnosticSeverity.Error ? ErrorPen : WarningPen; + var pen = diag.Severity == DiagnosticSeverity.Error + ? ScriptEditorTheme.ErrorPen + : ScriptEditorTheme.WarningPen; foreach (var rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) { diff --git a/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs b/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs new file mode 100644 index 0000000..63cd52f --- /dev/null +++ b/src/SharpFM/Scripting/Editor/ScriptEditorTheme.cs @@ -0,0 +1,16 @@ +using Avalonia; +using Avalonia.Media; + +namespace SharpFM.Scripting.Editor; + +/// +/// Shared color constants for script editor renderers. +/// +internal static class ScriptEditorTheme +{ + internal static readonly IPen ErrorPen = new Pen(Brushes.Red, 1.0); + internal static readonly IPen WarningPen = new Pen(Brushes.Gold, 1.0); + internal static readonly IBrush BracketMatchBrush = new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)); + internal static readonly IPen BracketMatchPen = new Pen(new SolidColorBrush(Color.FromArgb(100, 255, 255, 255)), 1.0); + internal static readonly IBrush StatementHighlightBrush = new SolidColorBrush(Color.FromArgb(20, 100, 180, 255)); +} diff --git a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs index 2740bb6..250dfa6 100644 --- a/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs +++ b/src/SharpFM/Scripting/Editor/StatementHighlightRenderer.cs @@ -10,7 +10,6 @@ namespace SharpFM.Scripting.Editor; public class StatementHighlightRenderer : IBackgroundRenderer { - private static readonly IBrush HighlightBrush = new SolidColorBrush(Color.FromArgb(20, 100, 180, 255)); private readonly TextArea _textArea; private int _highlightStartLine = -1; @@ -74,7 +73,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) var geometry = builder.CreateGeometry(); if (geometry != null) { - drawingContext.DrawGeometry(HighlightBrush, null, geometry); + drawingContext.DrawGeometry(ScriptEditorTheme.StatementHighlightBrush, null, geometry); } } diff --git a/src/SharpFM/Scripting/Model/ParsedLine.cs b/src/SharpFM/Scripting/Model/ParsedStep.cs similarity index 100% rename from src/SharpFM/Scripting/Model/ParsedLine.cs rename to src/SharpFM/Scripting/Model/ParsedStep.cs diff --git a/src/SharpFM/Services/FolderService.cs b/src/SharpFM/Services/FolderService.cs index 79a82f7..7c5cda5 100644 --- a/src/SharpFM/Services/FolderService.cs +++ b/src/SharpFM/Services/FolderService.cs @@ -6,7 +6,7 @@ namespace SharpFM.Services; -public class FolderService(Window target) +public class FolderService(Window target) : IFolderService { private readonly Window _target = target; diff --git a/src/SharpFM/Services/IFolderService.cs b/src/SharpFM/Services/IFolderService.cs new file mode 100644 index 0000000..71a915f --- /dev/null +++ b/src/SharpFM/Services/IFolderService.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace SharpFM.Services; + +public interface IFolderService +{ + Task GetFolderAsync(); +} diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 2337f15..4e757e6 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -18,6 +18,7 @@ public partial class MainWindowViewModel : INotifyPropertyChanged { private readonly ILogger _logger; private readonly IClipboardService _clipboard; + private readonly IFolderService _folderService; private readonly DispatcherTimer _statusTimer; public event PropertyChangedEventHandler? PropertyChanged; @@ -27,10 +28,11 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public MainWindowViewModel(ILogger logger, IClipboardService clipboard) + public MainWindowViewModel(ILogger logger, IClipboardService clipboard, IFolderService folderService) { _logger = logger; _clipboard = clipboard; + _folderService = folderService; _statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; _statusTimer.Tick += (_, _) => @@ -80,10 +82,16 @@ private void LoadClips(string pathToLoad) public async Task OpenFolderPicker() { - if (App.Current?.Services?.GetService(typeof(FolderService)) is FolderService folderService) + try { - CurrentPath = await folderService.GetFolderAsync(); + CurrentPath = await _folderService.GetFolderAsync(); LoadClips(CurrentPath); + ShowStatus($"Opened {CurrentPath}"); + } + catch (Exception e) + { + _logger.LogError(e, "Error opening folder."); + ShowStatus("Error opening folder"); } } diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs index 0d3a966..7a48ead 100644 --- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -23,12 +23,23 @@ public class MockClipboardService : IClipboardService Task.FromResult(ClipboardData.TryGetValue(format, out var v) ? v : null); } +public class MockFolderService : IFolderService +{ + public string FolderToReturn { get; set; } = "/tmp/test-clips"; + public Task GetFolderAsync() => Task.FromResult(FolderToReturn); +} + public class MainWindowViewModelTests { - private static MainWindowViewModel CreateVm(MockClipboardService? clipboard = null) + private static MainWindowViewModel CreateVm( + MockClipboardService? clipboard = null, + MockFolderService? folderService = null) { var logger = NullLoggerFactory.Instance.CreateLogger(); - return new MainWindowViewModel(logger, clipboard ?? new MockClipboardService()); + return new MainWindowViewModel( + logger, + clipboard ?? new MockClipboardService(), + folderService ?? new MockFolderService()); } [Fact] From b064e20aa407dc8c1cfa76f6ccced593c9ce00c5 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 14:43:21 -0500 Subject: [PATCH 15/29] perf: lazy-load XML editor, remove tab switching, open XML in separate window on demand --- src/SharpFM/MainWindow.axaml | 55 +++++++--------- src/SharpFM/MainWindow.axaml.cs | 86 ++++++++++++++++++------- src/SharpFM/ViewModels/ClipViewModel.cs | 3 +- 3 files changed, 88 insertions(+), 56 deletions(-) diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index f8705da..827d83a 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -34,6 +34,9 @@ + + + @@ -147,40 +150,28 @@ Width="16" /> - - - - - - - - - - - - - - - - + + + + + + diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index 7405846..c48bed3 100644 --- a/src/SharpFM/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -9,56 +9,96 @@ namespace SharpFM; public partial class MainWindow : Window { - private readonly TextMate.Installation _xmlTextMateInstallation; - private readonly TextMate.Installation? _scriptTextMateInstallation; + private readonly RegistryOptions _registryOptions; private ScriptEditorController? _scriptController; + private TextMate.Installation? _xmlTextMateInstallation; + private TextMate.Installation? _scriptTextMateInstallation; + private Window? _xmlWindow; public MainWindow() { InitializeComponent(); - var registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); + _registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); - // XML editor: syntax highlighting - var xmlEditor = this.FindControl("avaloniaEditor") ?? throw new Exception("no control"); - _xmlTextMateInstallation = xmlEditor.InstallTextMate(registryOptions); - var xmlLang = registryOptions.GetLanguageByExtension(".xml"); - _xmlTextMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId(xmlLang.Id)); - - // Script editor: syntax highlighting + controller for validation/completion/tooltips + // Script editor: setup on first load (deferred until control is available) var scriptEditor = this.FindControl("scriptEditor"); if (scriptEditor != null) { - var fmScriptRegistry = new FmScriptRegistryOptions(registryOptions); + var fmScriptRegistry = new FmScriptRegistryOptions(_registryOptions); _scriptTextMateInstallation = scriptEditor.InstallTextMate(fmScriptRegistry); _scriptTextMateInstallation.SetGrammar(FmScriptRegistryOptions.ScopeName); - _scriptController = new ScriptEditorController(scriptEditor); } - // Tab switch: sync model between Script and XML views - var editorTabs = this.FindControl("editorTabs"); - if (editorTabs != null) + // Fallback XML editor for non-script clips (lightweight — no TextMate needed, + // built-in SyntaxHighlighting="Xml" in the XAML handles it) + + // "View XML" menu item — opens XML in a separate window on demand + var viewXmlItem = this.FindControl("viewXmlMenuItem"); + if (viewXmlItem != null) + { + viewXmlItem.Click += (_, _) => ShowXmlWindow(); + } + } + + private void ShowXmlWindow() + { + var vm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; + if (vm == null) + return; + + // Sync model to XML before showing + vm.SyncModelFromEditor(); + + // Reuse or create the XML window + if (_xmlWindow == null || !_xmlWindow.IsVisible) { - editorTabs.SelectionChanged += (_, _) => + var xmlEditor = new TextEditor { - var vm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; - if (vm == null || !vm.IsScriptClip) return; + FontFamily = new Avalonia.Media.FontFamily("Cascadia Code,Consolas,Menlo,Monospace"), + ShowLineNumbers = true, + WordWrap = false, + IsReadOnly = true, + }; + + // Lazy-load XML TextMate only when first needed + if (_xmlTextMateInstallation == null) + { + _xmlTextMateInstallation = xmlEditor.InstallTextMate(_registryOptions); + var xmlLang = _registryOptions.GetLanguageByExtension(".xml"); + _xmlTextMateInstallation.SetGrammar(_registryOptions.GetScopeByLanguageId(xmlLang.Id)); + } + + xmlEditor.Document = new AvaloniaEdit.Document.TextDocument(vm.ClipXml ?? ""); - if (editorTabs.SelectedIndex == 0) - vm.SyncEditorFromXml(); - else - vm.SyncModelFromEditor(); + _xmlWindow = new Window + { + Title = $"XML — {vm.Name}", + Width = 600, + Height = 500, + Content = xmlEditor, }; } + else + { + // Update existing window content + if (_xmlWindow.Content is TextEditor existing) + existing.Document = new AvaloniaEdit.Document.TextDocument(vm.ClipXml ?? ""); + _xmlWindow.Title = $"XML — {vm.Name}"; + } + + _xmlWindow.Show(); + _xmlWindow.Activate(); } protected override void OnClosed(EventArgs e) { base.OnClosed(e); + _xmlWindow?.Close(); _scriptController?.Dispose(); - _xmlTextMateInstallation.Dispose(); + _xmlTextMateInstallation?.Dispose(); _scriptTextMateInstallation?.Dispose(); } } diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs index 709abeb..d060ec3 100644 --- a/src/SharpFM/ViewModels/ClipViewModel.cs +++ b/src/SharpFM/ViewModels/ClipViewModel.cs @@ -110,7 +110,8 @@ public void SyncModelFromEditor() } /// - /// Rebuild the script editor text from the XML (e.g., after XML tab edit). + /// Rebuild the script editor text from the XML. + /// Used when XML is edited externally (e.g., View XML window). /// public void SyncEditorFromXml() { From 562139bdb58f1f9b9e21bf3a1a01520ddecd53fc Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 15:36:21 -0500 Subject: [PATCH 16/29] fix: make clip search case-insensitive --- src/SharpFM/ViewModels/MainWindowViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 4e757e6..72bda35 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -278,7 +278,7 @@ public string SearchText { _searchText = value; FilteredClips.Clear(); - foreach (var c in FileMakerClips.Where(c => c.Name.Contains(_searchText))) + foreach (var c in FileMakerClips.Where(c => c.Name.Contains(_searchText, StringComparison.OrdinalIgnoreCase))) { FilteredClips.Add(c); } From 03fa68d33251a3bfb26602f91aff0df50ce4b7b0 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 15:48:36 -0500 Subject: [PATCH 17/29] fix: prevent crash on empty XML with FirstOrDefault --- src/SharpFM/Core/FileMakerClip.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpFM/Core/FileMakerClip.cs b/src/SharpFM/Core/FileMakerClip.cs index 9d44012..7de31db 100644 --- a/src/SharpFM/Core/FileMakerClip.cs +++ b/src/SharpFM/Core/FileMakerClip.cs @@ -64,7 +64,7 @@ public FileMakerClip(string name, string format, byte[] data) // try to show better "name" if possible var xdoc = XDocument.Load(new StringReader(XmlData)); - var containerName = xdoc.Element("fmxmlsnippet")?.Descendants().First()?.Attribute("name")?.Value ?? "new-clip"; + var containerName = xdoc.Element("fmxmlsnippet")?.Descendants().FirstOrDefault()?.Attribute("name")?.Value ?? "new-clip"; // set the name from the xml data if possible and fall back to constructor parameter Name = containerName ?? name; From 4b90cb64131d00262e200dd71dde8e992acc544c Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:06:41 -0500 Subject: [PATCH 18/29] fix: log and skip malformed clip files instead of crashing --- src/SharpFM/Models/ClipRepository.cs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/SharpFM/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs index 37cc577..266bd77 100644 --- a/src/SharpFM/Models/ClipRepository.cs +++ b/src/SharpFM/Models/ClipRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using NLog; namespace SharpFM.Models; @@ -9,6 +10,8 @@ namespace SharpFM.Models; /// public class ClipRepository { + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + /// /// Clips stored in the specified folder. /// @@ -43,16 +46,23 @@ public void LoadClips() { foreach (var clipFile in Directory.EnumerateFiles(ClipPath)) { - var fi = new FileInfo(clipFile); - - var clip = new Clip + try { - ClipName = fi.Name.Replace(fi.Extension, string.Empty), - ClipType = fi.Extension.Replace(".", string.Empty), - ClipXml = File.ReadAllText(clipFile) - }; + var fi = new FileInfo(clipFile); - Clips.Add(clip); + var clip = new Clip + { + ClipName = fi.Name.Replace(fi.Extension, string.Empty), + ClipType = fi.Extension.Replace(".", string.Empty), + ClipXml = File.ReadAllText(clipFile) + }; + + Clips.Add(clip); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load clip file: {File}", clipFile); + } } } From 91fdb8d42229f6b4c6f079931531488f414c6a04 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:07:33 -0500 Subject: [PATCH 19/29] perf: cache RawData, invalidate on XmlData change --- src/SharpFM/Core/FileMakerClip.cs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/SharpFM/Core/FileMakerClip.cs b/src/SharpFM/Core/FileMakerClip.cs index 7de31db..939096a 100644 --- a/src/SharpFM/Core/FileMakerClip.cs +++ b/src/SharpFM/Core/FileMakerClip.cs @@ -82,23 +82,37 @@ public FileMakerClip(string name, string format, byte[] data) /// /// Raw data that can be put back onto the Clipboard in FileMaker structure. + /// Cached — invalidated when XmlData changes. /// public byte[] RawData { get { - // recalculate the length of the original text and make sure that is the first four bytes in the stream - byte[] byteList = Encoding.UTF8.GetBytes(XmlData); - int bl = byteList.Length; - byte[] intBytes = BitConverter.GetBytes(bl); - return intBytes.Concat(byteList).ToArray(); + if (_cachedRawData == null) + { + byte[] byteList = Encoding.UTF8.GetBytes(XmlData); + byte[] intBytes = BitConverter.GetBytes(byteList.Length); + _cachedRawData = intBytes.Concat(byteList).ToArray(); + } + return _cachedRawData; } } + private string _xmlData = string.Empty; + private byte[]? _cachedRawData; + /// /// The actual clip. Users work with the Xml version here, and then pull the RawData property when ready to write back to FileMaker. /// - public string XmlData { get; set; } + public string XmlData + { + get => _xmlData; + set + { + _xmlData = value; + _cachedRawData = null; // invalidate cache + } + } /// /// The fields exposed through this FileMaker Clip (if its a table or a layout). From b46a3ea1998b3b7ce2901d2ed73fa7ecf8c739f5 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:08:40 -0500 Subject: [PATCH 20/29] chore: remove unused Script property from ClipViewModel --- src/SharpFM/ViewModels/ClipViewModel.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs index d060ec3..fcf7850 100644 --- a/src/SharpFM/ViewModels/ClipViewModel.cs +++ b/src/SharpFM/ViewModels/ClipViewModel.cs @@ -28,8 +28,6 @@ public ClipViewModel(FileMakerClip clip) public bool IsScriptClip => Clip.ClipboardFormat == "Mac-XMSS" || Clip.ClipboardFormat == "Mac-XMSC"; - public FmScript? Script => _script; - public string ClipType { get => Clip.ClipboardFormat; From 32bf28c856dd08f9eda38daaa284de0455bc96b9 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:10:21 -0500 Subject: [PATCH 21/29] ux: error status messages stay visible longer (8s vs 3s) --- src/SharpFM/ViewModels/MainWindowViewModel.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 72bda35..313ffe0 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -91,7 +91,7 @@ public async Task OpenFolderPicker() catch (Exception e) { _logger.LogError(e, "Error opening folder."); - ShowStatus("Error opening folder"); + ShowStatus("Error opening folder", isError: true); } } @@ -131,7 +131,7 @@ public void SaveClipsStorage() catch (Exception e) { _logger.LogError(e, "Error saving clips."); - ShowStatus("Error saving clips"); + ShowStatus("Error saving clips", isError: true); } } @@ -155,7 +155,7 @@ public void NewEmptyItem() catch (Exception e) { _logger.LogError(e, "Error creating new clip."); - ShowStatus("Error creating clip"); + ShowStatus("Error creating clip", isError: true); } } @@ -176,7 +176,7 @@ public async Task CopyAsClass() catch (Exception e) { _logger.LogError(e, "Error copying as class."); - ShowStatus("Error copying to clipboard"); + ShowStatus("Error copying to clipboard", isError: true); } } @@ -209,7 +209,7 @@ public async Task PasteFileMakerClipData() catch (Exception e) { _logger.LogError(e, "Error pasting from FileMaker clipboard."); - ShowStatus("Error pasting from clipboard"); + ShowStatus("Error pasting from clipboard", isError: true); } } @@ -231,13 +231,14 @@ public async Task CopySelectedToClip() catch (Exception e) { _logger.LogError(e, "Error copying to FileMaker clipboard."); - ShowStatus("Error copying to clipboard"); + ShowStatus("Error copying to clipboard", isError: true); } } - private void ShowStatus(string message) + private void ShowStatus(string message, bool isError = false) { StatusMessage = message; + _statusTimer.Interval = isError ? TimeSpan.FromSeconds(8) : TimeSpan.FromSeconds(3); _statusTimer.Stop(); _statusTimer.Start(); } From 6be1fbb671a0313d39325278a3d51cf8ae77e11c Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:11:34 -0500 Subject: [PATCH 22/29] ux: clear status message when selecting a different clip --- src/SharpFM/ViewModels/MainWindowViewModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 313ffe0..ae2fcc3 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -267,6 +267,7 @@ public ClipViewModel? SelectedClip set { _selectedClip = value; + StatusMessage = ""; NotifyPropertyChanged(); } } From 108968e7aee7f185a42691a94eb1aacda241bf9a Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:13:01 -0500 Subject: [PATCH 23/29] ux: add keyboard shortcuts (Ctrl+S save, Ctrl+N new, Ctrl+Shift+C copy, Ctrl+Shift+X xml) --- src/SharpFM/MainWindow.axaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 827d83a..0d755ba 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -18,15 +18,15 @@ - - + + - - + + @@ -35,7 +35,7 @@ - + From f0b2a65ab47c5246899ce38c6bf8f14d1156bf57 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:13:52 -0500 Subject: [PATCH 24/29] ux: make XML window editable, sync changes back to model on close --- src/SharpFM/MainWindow.axaml.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index c48bed3..e8d2b29 100644 --- a/src/SharpFM/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -59,7 +59,6 @@ private void ShowXmlWindow() FontFamily = new Avalonia.Media.FontFamily("Cascadia Code,Consolas,Menlo,Monospace"), ShowLineNumbers = true, WordWrap = false, - IsReadOnly = true, }; // Lazy-load XML TextMate only when first needed @@ -79,6 +78,20 @@ private void ShowXmlWindow() Height = 500, Content = xmlEditor, }; + + // Sync XML edits back to the model when the window closes + _xmlWindow.Closing += (_, _) => + { + if (_xmlWindow.Content is TextEditor editor) + { + var currentVm = (DataContext as SharpFM.ViewModels.MainWindowViewModel)?.SelectedClip; + if (currentVm != null) + { + currentVm.ClipXml = editor.Document.Text; + currentVm.SyncEditorFromXml(); + } + } + }; } else { From b87e8d985077ba9b42d2757d1d4b4c9f21d3d767 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:14:58 -0500 Subject: [PATCH 25/29] test: add ClipViewModel sync and binding tests --- .../ViewModels/ClipViewModelTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs diff --git a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs new file mode 100644 index 0000000..3ad404a --- /dev/null +++ b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs @@ -0,0 +1,116 @@ +using SharpFM.ViewModels; +using Xunit; + +namespace SharpFM.Tests.ViewModels; + +public class ClipViewModelTests +{ + private static string WrapXml(string steps) => + $"{steps}"; + + private static ClipViewModel CreateScriptClip(string xml) + { + var clip = new FileMakerClip("Test", "Mac-XMSS", xml); + return new ClipViewModel(clip); + } + + [Fact] + public void IsScriptClip_TrueForXMSS() + { + var vm = CreateScriptClip(WrapXml("")); + Assert.True(vm.IsScriptClip); + } + + [Fact] + public void IsScriptClip_FalseForTable() + { + var clip = new FileMakerClip("Test", "Mac-XMTB", ""); + var vm = new ClipViewModel(clip); + Assert.False(vm.IsScriptClip); + } + + [Fact] + public void ScriptDocument_LazyCreated() + { + var xml = WrapXml("hello"); + var vm = CreateScriptClip(xml); + + // Access ScriptDocument triggers lazy creation + var doc = vm.ScriptDocument; + Assert.NotNull(doc); + Assert.Contains("# hello", doc.Text); + } + + [Fact] + public void SyncModelFromEditor_UpdatesXml() + { + var xml = WrapXml("original"); + var vm = CreateScriptClip(xml); + + // Access the script document and modify it + var doc = vm.ScriptDocument; + doc.Text = "# modified"; + + vm.SyncModelFromEditor(); + + Assert.Contains("modified", vm.ClipXml); + Assert.Contains("modified", vm.Clip.XmlData); + } + + [Fact] + public void SyncEditorFromXml_UpdatesScriptDocument() + { + var xml = WrapXml("original"); + var vm = CreateScriptClip(xml); + + // Access script document first + _ = vm.ScriptDocument; + + // Change the XML directly + vm.ClipXml = WrapXml("changed via xml"); + + vm.SyncEditorFromXml(); + + Assert.Contains("changed via xml", vm.ScriptDocument.Text); + } + + [Fact] + public void SyncModelFromEditor_NoOpForNonScript() + { + var clip = new FileMakerClip("Test", "Mac-XMTB", ""); + var vm = new ClipViewModel(clip); + var originalXml = vm.Clip.XmlData; + + vm.SyncModelFromEditor(); // should not crash or modify anything + + Assert.Equal(originalXml, vm.Clip.XmlData); + } + + [Fact] + public void ClipXml_UpdatesBothClipAndDocument() + { + var xml = WrapXml(""); + var vm = CreateScriptClip(xml); + + // Access XML document to create it + _ = vm.XmlDocument; + + var newXml = WrapXml("new"); + vm.ClipXml = newXml; + + Assert.Equal(newXml, vm.Clip.XmlData); + Assert.Equal(newXml, vm.XmlDocument.Text); + } + + [Fact] + public void Name_TwoWayBinding() + { + var vm = CreateScriptClip(WrapXml("")); + string? changed = null; + vm.PropertyChanged += (_, args) => changed = args.PropertyName; + + vm.Name = "Renamed"; + Assert.Equal("Renamed", vm.Clip.Name); + Assert.Equal("Name", changed); + } +} From e35c423a2f3b7712651f672ef8e711064dbc54a5 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:15:54 -0500 Subject: [PATCH 26/29] perf: use StringBuilder for multi-line statement merging --- src/SharpFM/Scripting/Parsing/ScriptLineParser.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs b/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs index 39149fc..cd597ba 100644 --- a/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs +++ b/src/SharpFM/Scripting/Parsing/ScriptLineParser.cs @@ -29,7 +29,7 @@ public static List Parse(string hrText) internal static List MergeMultilineStatements(string[] lines) { var result = new List(); - string? accumulator = null; + System.Text.StringBuilder? accumulator = null; for (int i = 0; i < lines.Length; i++) { @@ -39,7 +39,7 @@ internal static List MergeMultilineStatements(string[] lines) { if (HasUnbalancedBrackets(line)) { - accumulator = line; + accumulator = new System.Text.StringBuilder(line); } else { @@ -48,20 +48,19 @@ internal static List MergeMultilineStatements(string[] lines) } else { - // Continue merging — preserve the newline for readability - accumulator += "\n" + line; + accumulator.Append('\n').Append(line); + var merged = accumulator.ToString(); - if (!HasUnbalancedBrackets(accumulator)) + if (!HasUnbalancedBrackets(merged)) { - result.Add(accumulator); + result.Add(merged); accumulator = null; } } } - // If still accumulating at end, emit what we have if (accumulator != null) - result.Add(accumulator); + result.Add(accumulator.ToString()); return result; } From 171c905e780c375d842a7315ee4bc4a6b0af2b97 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 16:16:49 -0500 Subject: [PATCH 27/29] ux: preserve clip selection when search filter narrows results --- src/SharpFM/ViewModels/MainWindowViewModel.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index ae2fcc3..4eb49d8 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -279,11 +279,15 @@ public string SearchText set { _searchText = value; + var previousSelection = _selectedClip; FilteredClips.Clear(); foreach (var c in FileMakerClips.Where(c => c.Name.Contains(_searchText, StringComparison.OrdinalIgnoreCase))) { FilteredClips.Add(c); } + // Preserve selection if still visible in filtered results + if (previousSelection != null && FilteredClips.Contains(previousSelection)) + SelectedClip = previousSelection; NotifyPropertyChanged(); } } From b016b59568da2be6a4afd5f234ef310ec616cc11 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 17:22:21 -0500 Subject: [PATCH 28/29] fix: text editor fills available width instead of sizing to content --- src/SharpFM/MainWindow.axaml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 0d755ba..29ce46c 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -39,6 +39,18 @@ + + + + + + @@ -153,10 +165,11 @@ - + - + - - - - \ No newline at end of file From 85511012d72f47369097932872027bdcfa23b601 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 18:23:58 -0500 Subject: [PATCH 29/29] fix: add window-level KeyBindings for Ctrl+S, Ctrl+N, Ctrl+Shift+C --- src/SharpFM/MainWindow.axaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 29ce46c..2d8045b 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -14,6 +14,13 @@ d:DesignWidth="700" x:DataType="vm:MainWindowViewModel" mc:Ignorable="d"> + + + + + + +