From e3fccf8145d9f757a34b0ef636b55e31cc958351 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:14 -0500 Subject: [PATCH 01/11] Add table document model and spreadsheet undo/redo support Introduce EditTextTableDocument for structured text editing, with parsing, serialization, and manipulation of tabular data (plain, CSV, TSV, XML). Add EtwEditorMode and EditTextTableDocumentJson to HistoryInfo for editor state tracking. Implement SpreadsheetUndoHistory for undo/redo of spreadsheet/table edits. --- Text-Grab/Models/EditTextTableDocument.cs | 827 +++++++++++++++++++++ Text-Grab/Models/HistoryInfo.cs | 9 +- Text-Grab/Models/SpreadsheetUndoHistory.cs | 69 ++ 3 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 Text-Grab/Models/EditTextTableDocument.cs create mode 100644 Text-Grab/Models/SpreadsheetUndoHistory.cs diff --git a/Text-Grab/Models/EditTextTableDocument.cs b/Text-Grab/Models/EditTextTableDocument.cs new file mode 100644 index 00000000..696dd599 --- /dev/null +++ b/Text-Grab/Models/EditTextTableDocument.cs @@ -0,0 +1,827 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace Text_Grab.Models; + +public enum EtwEditorMode +{ + Text, + Markdown, + Spreadsheet +} + +public enum EtwStructuredTextFormat +{ + PlainText, + DelimitedText, + Csv, + Tsv, + Xml +} + +public sealed class EditTextTableDocument +{ + public const int DefaultMinimumRowCount = 25; + public const int DefaultMinimumColumnCount = 8; + + public EtwStructuredTextFormat Format { get; set; } = EtwStructuredTextFormat.PlainText; + + public string NewLineSequence { get; set; } = Environment.NewLine; + + public string Delimiter { get; set; } = "\t"; + + public string XmlRootElementName { get; set; } = "rows"; + + public string? XmlContainerElementName { get; set; } + + public string XmlRowElementName { get; set; } = "row"; + + public List ColumnNames { get; set; } = []; + + public List> Rows { get; set; } = []; + + public int RowCount { get; set; } + + public int ColumnCount { get; set; } + + public int MinimumRowCount { get; set; } = DefaultMinimumRowCount; + + public int MinimumColumnCount { get; set; } = DefaultMinimumColumnCount; + + public List ColumnWidths { get; set; } = []; + + public List RowHeights { get; set; } = []; + + public static EditTextTableDocument CreateFromText( + string? text, + int minimumRowCount = DefaultMinimumRowCount, + int minimumColumnCount = DefaultMinimumColumnCount) + { + string safeText = text ?? string.Empty; + string newlineSequence = DetectNewLineSequence(safeText); + + EditTextTableDocument document = + TryCreateDelimitedDocument(safeText, '\t', EtwStructuredTextFormat.Tsv, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateDelimitedDocument(safeText, ',', EtwStructuredTextFormat.Csv, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateXmlDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateHeuristicDelimitedDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount) + ?? CreatePlainTextDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount); + + document.EnsureMinimumSize(); + return document; + } + + public static EditTextTableDocument? TryDeserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + EditTextTableDocument? document = JsonSerializer.Deserialize(json); + if (document is null) + return null; + + document.EnsureMinimumSize(); + return document; + } + catch (JsonException) + { + return null; + } + } + + public string SerializeToJson() + { + return JsonSerializer.Serialize(this); + } + + public string SerializeToText() + { + EnsureMinimumSize(); + + return Format switch + { + EtwStructuredTextFormat.Xml => SerializeToXml(), + EtwStructuredTextFormat.Csv => SerializeDelimitedText(','), + EtwStructuredTextFormat.Tsv => SerializeDelimitedText('\t'), + EtwStructuredTextFormat.DelimitedText => SerializeDelimitedText(GetDelimiterCharacter()), + _ => SerializePlainText(), + }; + } + + public void InsertRow(int rowIndex) + { + EnsureMinimumSize(); + + int insertIndex = Math.Clamp(rowIndex, 0, RowCount); + Rows.Insert(insertIndex, Enumerable.Repeat(string.Empty, ColumnNames.Count).ToList()); + RowHeights.Insert(insertIndex, null); + RowCount++; + MinimumRowCount = Math.Max(MinimumRowCount, RowCount); + } + + public void InsertColumn(int columnIndex, string? columnName = null) + { + EnsureMinimumSize(); + + int insertIndex = Math.Clamp(columnIndex, 0, ColumnCount); + string nameToInsert = EnsureUniqueColumnName(columnName ?? GetDefaultColumnName(insertIndex), ColumnNames); + + ColumnNames.Insert(insertIndex, nameToInsert); + ColumnWidths.Insert(insertIndex, null); + foreach (List row in Rows) + row.Insert(insertIndex, string.Empty); + + ColumnCount++; + MinimumColumnCount = Math.Max(MinimumColumnCount, ColumnCount); + } + + public void DeleteRow(int rowIndex) + { + EnsureMinimumSize(); + + if (rowIndex < 0 || rowIndex >= RowCount) + return; + + Rows.RemoveAt(rowIndex); + if (rowIndex < RowHeights.Count) + RowHeights.RemoveAt(rowIndex); + RowCount = Math.Max(1, RowCount - 1); + } + + public void DeleteColumn(int columnIndex) + { + EnsureMinimumSize(); + + if (columnIndex < 0 || columnIndex >= ColumnCount) + return; + + ColumnNames.RemoveAt(columnIndex); + if (columnIndex < ColumnWidths.Count) + ColumnWidths.RemoveAt(columnIndex); + foreach (List row in Rows) + { + if (columnIndex < row.Count) + row.RemoveAt(columnIndex); + } + + ColumnCount = Math.Max(1, ColumnCount - 1); + } + + public void MoveRow(int fromIndex, int toIndex) + { + EnsureMinimumSize(); + + if (fromIndex < 0 || fromIndex >= RowCount || toIndex < 0 || toIndex >= RowCount || fromIndex == toIndex) + return; + + List row = Rows[fromIndex]; + Rows.RemoveAt(fromIndex); + Rows.Insert(toIndex, row); + MoveListItem(RowHeights, fromIndex, toIndex); + } + + public void MoveColumn(int fromIndex, int toIndex) + { + EnsureMinimumSize(); + + if (fromIndex < 0 || fromIndex >= ColumnCount || toIndex < 0 || toIndex >= ColumnCount || fromIndex == toIndex) + return; + + string columnName = ColumnNames[fromIndex]; + ColumnNames.RemoveAt(fromIndex); + ColumnNames.Insert(toIndex, columnName); + MoveListItem(ColumnWidths, fromIndex, toIndex); + + foreach (List row in Rows) + { + string value = row[fromIndex]; + row.RemoveAt(fromIndex); + row.Insert(toIndex, value); + } + } + + public void Transpose() + { + EnsureMinimumSize(); + + int sourceRowCount = Math.Max(1, RowCount); + int sourceColumnCount = Math.Max(1, ColumnCount); + int originalMinimumRowCount = MinimumRowCount; + int originalMinimumColumnCount = MinimumColumnCount; + + List> transposedRows = []; + for (int columnIndex = 0; columnIndex < sourceColumnCount; columnIndex++) + { + List transposedRow = []; + for (int rowIndex = 0; rowIndex < sourceRowCount; rowIndex++) + { + string value = rowIndex < Rows.Count && columnIndex < Rows[rowIndex].Count + ? Rows[rowIndex][columnIndex] ?? string.Empty + : string.Empty; + transposedRow.Add(value); + } + + transposedRows.Add(transposedRow); + } + + Rows = transposedRows; + RowCount = sourceColumnCount; + ColumnCount = sourceRowCount; + MinimumRowCount = Math.Max(1, originalMinimumColumnCount); + MinimumColumnCount = Math.Max(1, originalMinimumRowCount); + ColumnNames = BuildGenericColumnNames(Math.Max(1, ColumnCount)); + ColumnWidths = []; + RowHeights = []; + EnsureMinimumSize(); + } + + public void EnsureMinimumSize() + { + if (MinimumRowCount < 1) + MinimumRowCount = DefaultMinimumRowCount; + + if (MinimumColumnCount < 1) + MinimumColumnCount = DefaultMinimumColumnCount; + + int maxRowWidth = Rows.Count == 0 ? 0 : Rows.Max(row => row.Count); + + if (ColumnCount < 0) + ColumnCount = 0; + + if (RowCount < 0) + RowCount = 0; + + if (ColumnCount == 0) + ColumnCount = InferLogicalColumnCount(); + + if (RowCount == 0 && Rows.Any(row => row.Any(value => !string.IsNullOrEmpty(value)))) + RowCount = Rows.Count; + + int requiredColumns = Math.Max(Math.Max(ColumnCount, maxRowWidth), MinimumColumnCount); + + while (ColumnNames.Count < requiredColumns) + ColumnNames.Add(EnsureUniqueColumnName(GetDefaultColumnName(ColumnNames.Count), ColumnNames)); + + while (ColumnWidths.Count < requiredColumns) + ColumnWidths.Add(null); + + while (ColumnWidths.Count > requiredColumns) + ColumnWidths.RemoveAt(ColumnWidths.Count - 1); + + foreach (List row in Rows) + while (row.Count < requiredColumns) + row.Add(string.Empty); + + int requiredRows = Math.Max(RowCount, MinimumRowCount); + while (Rows.Count < requiredRows) + Rows.Add(Enumerable.Repeat(string.Empty, requiredColumns).ToList()); + + while (RowHeights.Count < requiredRows) + RowHeights.Add(null); + + while (RowHeights.Count > requiredRows) + RowHeights.RemoveAt(RowHeights.Count - 1); + } + + public void ApplyViewMetricsFrom(EditTextTableDocument source) + { + ArgumentNullException.ThrowIfNull(source); + + EnsureMinimumSize(); + source.EnsureMinimumSize(); + + for (int columnIndex = 0; columnIndex < Math.Min(ColumnWidths.Count, source.ColumnWidths.Count); columnIndex++) + ColumnWidths[columnIndex] = source.ColumnWidths[columnIndex]; + + for (int rowIndex = 0; rowIndex < Math.Min(RowHeights.Count, source.RowHeights.Count); rowIndex++) + RowHeights[rowIndex] = source.RowHeights[rowIndex]; + } + + public void SetColumnWidth(int columnIndex, double? width) + { + EnsureMinimumSize(); + if (columnIndex < 0 || columnIndex >= ColumnWidths.Count) + return; + + ColumnWidths[columnIndex] = NormalizeViewMetric(width); + } + + public void SetRowHeight(int rowIndex, double? height) + { + EnsureMinimumSize(); + if (rowIndex < 0 || rowIndex >= RowHeights.Count) + return; + + RowHeights[rowIndex] = NormalizeViewMetric(height); + } + + private string SerializePlainText() + { + if (ColumnCount <= 1) + return string.Join(NewLineSequence, Rows.Take(RowCount).Select(row => row.FirstOrDefault() ?? string.Empty)); + + return SerializeDelimitedText(GetDelimiterCharacter()); + } + + private static void MoveListItem(List items, int fromIndex, int toIndex) + { + if (fromIndex < 0 || fromIndex >= items.Count || toIndex < 0 || toIndex >= items.Count || fromIndex == toIndex) + return; + + T item = items[fromIndex]; + items.RemoveAt(fromIndex); + items.Insert(toIndex, item); + } + + private static double? NormalizeViewMetric(double? value) + { + if (!value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value) || value.Value <= 0) + return null; + + return value.Value; + } + + private string SerializeDelimitedText(char delimiter) + { + StringBuilder builder = new(); + + for (int rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + if (rowIndex > 0) + builder.Append(NewLineSequence); + + List row = Rows[rowIndex]; + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + if (columnIndex > 0) + builder.Append(delimiter); + + string cellValue = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + builder.Append(EscapeDelimitedValue(cellValue, delimiter)); + } + } + + return builder.ToString(); + } + + private string SerializeToXml() + { + XElement root = new(CreateXmlName(XmlRootElementName, "rows", 0)); + XContainer rowContainer = root; + + if (!string.IsNullOrWhiteSpace(XmlContainerElementName)) + { + XElement container = new(CreateXmlName(XmlContainerElementName, "items", 0)); + root.Add(container); + rowContainer = container; + } + + for (int rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + XElement rowElement = new(CreateXmlName(XmlRowElementName, "row", rowIndex)); + List row = Rows[rowIndex]; + + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + string columnName = ColumnNames[columnIndex]; + string value = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + + if (columnName.StartsWith('@')) + { + rowElement.SetAttributeValue(CreateXmlName(columnName[1..], "attribute", columnIndex), value); + continue; + } + + rowElement.Add(new XElement(CreateXmlName(columnName, "column", columnIndex), value)); + } + + rowContainer.Add(rowElement); + } + + XDocument document = new(root); + return NormalizeLineEndings(document.ToString(), NewLineSequence); + } + + private char GetDelimiterCharacter() + { + return string.IsNullOrEmpty(Delimiter) ? '\t' : Delimiter[0]; + } + + private static EditTextTableDocument? TryCreateDelimitedDocument( + string text, + char delimiter, + EtwStructuredTextFormat format, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + List> rows = ParseDelimitedText(text, delimiter); + if (!LooksStructured(rows)) + return null; + + TrimParserAddedTerminalRow(text, rows); + + return new EditTextTableDocument + { + Format = format, + Delimiter = delimiter.ToString(), + NewLineSequence = newlineSequence, + ColumnNames = BuildGenericColumnNames(rows.Max(row => row.Count)), + Rows = rows, + RowCount = rows.Count, + ColumnCount = rows.Max(row => row.Count), + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + private static EditTextTableDocument? TryCreateHeuristicDelimitedDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + char[] heuristicDelimiters = ['|', ';', ':']; + + foreach (char delimiter in heuristicDelimiters) + { + List> rows = ParseDelimitedText(text, delimiter); + if (!LooksStructured(rows)) + continue; + + TrimParserAddedTerminalRow(text, rows); + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.DelimitedText, + Delimiter = delimiter.ToString(), + NewLineSequence = newlineSequence, + ColumnNames = BuildGenericColumnNames(rows.Max(row => row.Count)), + Rows = rows, + RowCount = rows.Count, + ColumnCount = rows.Max(row => row.Count), + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + return null; + } + + private static EditTextTableDocument? TryCreateXmlDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + if (string.IsNullOrWhiteSpace(text) || !text.TrimStart().StartsWith('<')) + return null; + + try + { + XDocument xDocument = XDocument.Parse(text, LoadOptions.None); + XElement? root = xDocument.Root; + if (root is null) + return null; + + XElement? rowParent = null; + List? rowElements = null; + + foreach (XElement candidateParent in root.DescendantsAndSelf()) + { + IGrouping? repeatedGroup = candidateParent.Elements() + .GroupBy(element => element.Name.LocalName) + .OrderByDescending(group => group.Count()) + .FirstOrDefault(group => group.Count() > 1); + + if (repeatedGroup is null) + continue; + + if (rowElements is null || repeatedGroup.Count() > rowElements.Count) + { + rowParent = candidateParent; + rowElements = repeatedGroup.ToList(); + } + } + + if (rowParent is null || rowElements is null || rowElements.Count == 0) + return null; + + List columnNames = []; + foreach (XElement rowElement in rowElements) + { + foreach (XAttribute attribute in rowElement.Attributes()) + AddUnique(columnNames, $"@{attribute.Name.LocalName}"); + + foreach (XElement child in rowElement.Elements()) + AddUnique(columnNames, child.Name.LocalName); + } + + if (columnNames.Count == 0) + columnNames.Add("Value"); + + List> rows = []; + foreach (XElement rowElement in rowElements) + { + List row = []; + foreach (string columnName in columnNames) + { + if (columnName.StartsWith('@')) + { + row.Add(rowElement.Attribute(columnName[1..])?.Value ?? string.Empty); + continue; + } + + row.Add(rowElement.Element(columnName)?.Value ?? string.Empty); + } + + rows.Add(row); + } + + string? containerElementName = rowParent == root ? null : rowParent.Name.LocalName; + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.Xml, + NewLineSequence = newlineSequence, + XmlRootElementName = root.Name.LocalName, + XmlContainerElementName = containerElementName, + XmlRowElementName = rowElements[0].Name.LocalName, + ColumnNames = columnNames, + Rows = rows, + RowCount = rows.Count, + ColumnCount = columnNames.Count, + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + catch (XmlException) + { + return null; + } + } + + private static EditTextTableDocument CreatePlainTextDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + List> rows = SplitPlainTextRows(text); + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.PlainText, + NewLineSequence = newlineSequence, + Delimiter = "\t", + ColumnNames = ["Column A"], + Rows = rows, + RowCount = rows.Count, + ColumnCount = 1, + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + private static List> SplitPlainTextRows(string text) + { + if (string.IsNullOrEmpty(text)) + return []; + + string normalized = NormalizeLineEndings(text, "\n"); + string[] lines = normalized.Split('\n', StringSplitOptions.None); + return lines.Select(line => new List { line }).ToList(); + } + + private static List> ParseDelimitedText(string text, char delimiter) + { + List> rows = []; + List currentRow = []; + StringBuilder currentField = new(); + bool insideQuotes = false; + + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + + if (insideQuotes) + { + if (character == '"') + { + if (index + 1 < text.Length && text[index + 1] == '"') + { + currentField.Append('"'); + index++; + } + else + { + insideQuotes = false; + } + } + else + { + currentField.Append(character); + } + + continue; + } + + if (character == '"') + { + insideQuotes = true; + continue; + } + + if (character == delimiter) + { + currentRow.Add(currentField.ToString()); + currentField.Clear(); + continue; + } + + if (character is '\r' or '\n') + { + currentRow.Add(currentField.ToString()); + currentField.Clear(); + rows.Add(currentRow); + currentRow = []; + + if (character == '\r' && index + 1 < text.Length && text[index + 1] == '\n') + index++; + + continue; + } + + currentField.Append(character); + } + + currentRow.Add(currentField.ToString()); + rows.Add(currentRow); + + return rows; + } + + private static bool LooksStructured(List> rows) + { + if (rows.Count == 0) + return false; + + List> nonEmptyRows = rows.Where(row => row.Any(value => !string.IsNullOrWhiteSpace(value))).ToList(); + if (nonEmptyRows.Count == 0) + return false; + + int maxColumns = nonEmptyRows.Max(row => row.Count); + if (maxColumns < 2) + return false; + + int matchingStructuredRows = nonEmptyRows.Count(row => row.Count == maxColumns && maxColumns > 1); + if (matchingStructuredRows >= 2) + return true; + + return nonEmptyRows.Count == 1 && maxColumns >= 2; + } + + private int InferLogicalColumnCount() + { + for (int columnIndex = ColumnNames.Count - 1; columnIndex >= 0; columnIndex--) + { + foreach (List row in Rows) + { + if (columnIndex < row.Count && !string.IsNullOrEmpty(row[columnIndex])) + return columnIndex + 1; + } + } + + return ColumnNames.Count > 0 ? 1 : 0; + } + + private static void TrimParserAddedTerminalRow(string originalText, List> rows) + { + if (rows.Count < 2 || string.IsNullOrEmpty(originalText)) + return; + + bool endsWithNewLine = originalText.EndsWith("\r", StringComparison.Ordinal) + || originalText.EndsWith("\n", StringComparison.Ordinal); + + if (!endsWithNewLine) + return; + + List lastRow = rows[^1]; + if (lastRow.All(string.IsNullOrEmpty)) + rows.RemoveAt(rows.Count - 1); + } + + private static string EscapeDelimitedValue(string value, char delimiter) + { + bool needsQuotes = + value.Contains(delimiter) + || value.Contains('"') + || value.Contains('\r') + || value.Contains('\n'); + + if (!needsQuotes) + return value; + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + private static string DetectNewLineSequence(string text) + { + int carriageReturnLineFeedIndex = text.IndexOf("\r\n", StringComparison.Ordinal); + if (carriageReturnLineFeedIndex >= 0) + return "\r\n"; + + if (text.Contains('\n')) + return "\n"; + + if (text.Contains('\r')) + return "\r"; + + return Environment.NewLine; + } + + private static string NormalizeLineEndings(string text, string newLineSequence) + { + return text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .Replace("\n", newLineSequence, StringComparison.Ordinal); + } + + private static List BuildGenericColumnNames(int count) + { + List columnNames = []; + for (int index = 0; index < count; index++) + columnNames.Add(GetDefaultColumnName(index)); + + return columnNames; + } + + public static string GetSpreadsheetColumnLabel(int index) + { + return ToSpreadsheetColumnName(index); + } + + private static string GetDefaultColumnName(int index) + { + return $"Column {GetSpreadsheetColumnLabel(index)}"; + } + + private static string ToSpreadsheetColumnName(int index) + { + int workingIndex = index + 1; + StringBuilder builder = new(); + + while (workingIndex > 0) + { + workingIndex--; + builder.Insert(0, (char)('A' + (workingIndex % 26))); + workingIndex /= 26; + } + + return builder.ToString(); + } + + private static string EnsureUniqueColumnName(string desiredName, IEnumerable existingNames) + { + string baseName = string.IsNullOrWhiteSpace(desiredName) ? "Column" : desiredName.Trim(); + HashSet existingNameSet = new(existingNames, StringComparer.OrdinalIgnoreCase); + + if (!existingNameSet.Contains(baseName)) + return baseName; + + int suffix = 2; + while (existingNameSet.Contains($"{baseName} {suffix}")) + suffix++; + + return $"{baseName} {suffix}"; + } + + private static void AddUnique(ICollection names, string name) + { + if (!names.Contains(name, StringComparer.OrdinalIgnoreCase)) + names.Add(name); + } + + private static XName CreateXmlName(string? rawName, string fallbackPrefix, int index) + { + string safeName = string.IsNullOrWhiteSpace(rawName) + ? $"{fallbackPrefix}{index + 1}" + : rawName.Trim(); + + safeName = safeName.Replace(' ', '_'); + safeName = XmlConvert.EncodeLocalName(safeName); + + if (char.IsDigit(safeName[0])) + safeName = $"_{safeName}"; + + return XName.Get(safeName); + } +} diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 69f3e52c..4f6acd26 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -44,9 +44,14 @@ public HistoryInfo() [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool UsedUiAutomation { get; set; } - public bool HasCalcPaneOpen { get; set; } = false; + public bool HasCalcPaneOpen { get; set; } = false; - public int CalcPaneWidth { get; set; } = 0; + public int CalcPaneWidth { get; set; } = 0; + + public EtwEditorMode EditorMode { get; set; } = EtwEditorMode.Text; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditTextTableDocumentJson { get; set; } [JsonIgnore] public ILanguage OcrLanguage diff --git a/Text-Grab/Models/SpreadsheetUndoHistory.cs b/Text-Grab/Models/SpreadsheetUndoHistory.cs new file mode 100644 index 00000000..7dc51512 --- /dev/null +++ b/Text-Grab/Models/SpreadsheetUndoHistory.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Models; + +internal sealed class SpreadsheetUndoState +{ + public SpreadsheetUndoState(string documentJson, int? focusRow, int? focusColumn) + { + DocumentJson = documentJson ?? string.Empty; + FocusRow = focusRow; + FocusColumn = focusColumn; + } + + public string DocumentJson { get; } + + public int? FocusRow { get; } + + public int? FocusColumn { get; } +} + +internal sealed class SpreadsheetUndoHistory +{ + private readonly Stack undoStack = []; + private readonly Stack redoStack = []; + + public bool CanUndo => undoStack.Count > 0; + + public bool CanRedo => redoStack.Count > 0; + + public void Clear() + { + undoStack.Clear(); + redoStack.Clear(); + } + + public void RecordChange(SpreadsheetUndoState? beforeChange, SpreadsheetUndoState? afterChange) + { + if (beforeChange is null + || afterChange is null + || string.Equals(beforeChange.DocumentJson, afterChange.DocumentJson, StringComparison.Ordinal)) + { + return; + } + + undoStack.Push(beforeChange); + redoStack.Clear(); + } + + public SpreadsheetUndoState? Undo(SpreadsheetUndoState? currentState) + { + if (currentState is null || undoStack.Count == 0) + return null; + + SpreadsheetUndoState previousState = undoStack.Pop(); + redoStack.Push(currentState); + return previousState; + } + + public SpreadsheetUndoState? Redo(SpreadsheetUndoState? currentState) + { + if (currentState is null || redoStack.Count == 0) + return null; + + SpreadsheetUndoState nextState = redoStack.Pop(); + undoStack.Push(currentState); + return nextState; + } +} From 535ec663a6907128fd03a0bbe5aa7970411f5e3d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:37 -0500 Subject: [PATCH 02/11] Add markdown utilities and async file read support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IoUtilities methods for markdown/spreadsheet detection and editor mode selection - Use ReadToEndAsync in FileUtilities for async file reading - Add MarkdownDocumentUtilities for Markdown↔FlowDocument conversion, theming, and detection using Markdig --- Text-Grab/Utilities/FileUtilities.cs | 2 +- Text-Grab/Utilities/IoUtilities.cs | 32 + .../Utilities/MarkdownDocumentUtilities.cs | 832 ++++++++++++++++++ 3 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 Text-Grab/Utilities/MarkdownDocumentUtilities.cs diff --git a/Text-Grab/Utilities/FileUtilities.cs b/Text-Grab/Utilities/FileUtilities.cs index 97b84f15..73bae77d 100644 --- a/Text-Grab/Utilities/FileUtilities.cs +++ b/Text-Grab/Utilities/FileUtilities.cs @@ -137,7 +137,7 @@ private static async Task GetTextFilePackaged(string fileName, FileStora StorageFile file = await folder.GetFileAsync(fileName); using Stream stream = await file.OpenStreamForReadAsync(); StreamReader streamReader = new(stream); - return streamReader.ReadToEnd(); + return await streamReader.ReadToEndAsync(); } catch { diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index 748eb472..b05e90cc 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -4,12 +4,15 @@ using System.Text; using System.Threading.Tasks; using Text_Grab.Interfaces; +using Text_Grab.Models; namespace Text_Grab.Utilities; public class IoUtilities { public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif", ".tif", ".webp", ".ico"]; + public static readonly List MarkdownExtensions = [".md", ".markdown"]; + public static readonly List SpreadsheetExtensions = [".csv", ".tsv", ".tab"]; public static bool IsImageFile(string path) { @@ -27,6 +30,35 @@ public static bool IsImageFileExtension(string extension) return ImageExtensions.Contains(extension.ToLowerInvariant()); } + public static bool IsMarkdownFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return MarkdownExtensions.Contains(extension.ToLowerInvariant()); + } + + public static bool IsSpreadsheetFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return SpreadsheetExtensions.Contains(extension.ToLowerInvariant()); + } + + public static EtwEditorMode GetEditorModeForPath(string? path) + { + string extension = Path.GetExtension(path ?? string.Empty); + + if (IsSpreadsheetFileExtension(extension)) + return EtwEditorMode.Spreadsheet; + + if (IsMarkdownFileExtension(extension)) + return EtwEditorMode.Markdown; + + return EtwEditorMode.Text; + } + public static async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) { StringBuilder stringBuilder = new(); diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs new file mode 100644 index 00000000..d04152ed --- /dev/null +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -0,0 +1,832 @@ +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Extensions.TaskLists; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Documents; +using System.Windows.Media; +using MarkdigBlock = Markdig.Syntax.Block; +using MarkdigInline = Markdig.Syntax.Inlines.Inline; +using MarkdigTable = Markdig.Extensions.Tables.Table; +using MarkdigTableCell = Markdig.Extensions.Tables.TableCell; +using MarkdigTableRow = Markdig.Extensions.Tables.TableRow; +using WpfBlock = System.Windows.Documents.Block; +using WpfInline = System.Windows.Documents.Inline; +using WpfList = System.Windows.Documents.List; +using WpfTable = System.Windows.Documents.Table; +using WpfTableCell = System.Windows.Documents.TableCell; +using WpfTableRow = System.Windows.Documents.TableRow; + +namespace Text_Grab.Utilities; + +public static class MarkdownDocumentUtilities +{ + private static readonly Regex LiveBlockTriggerRegex = new( + @"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", + RegexOptions.Compiled); + private static readonly Regex LiveInlinePromotionRegex = new( + @"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", + RegexOptions.Compiled | RegexOptions.Multiline); + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAutoLinks() + .UsePipeTables() + .UseTaskLists() + .Build(); + + private enum MarkdownBlockRole + { + None, + CodeBlock, + ThematicBreak + } + + private enum MarkdownInlineRole + { + None, + CodeSpan, + LiteralMarkdown, + TaskListMarker + } + + private sealed record MarkdownTheme( + Brush ForegroundBrush, + Brush BorderBrush, + Brush AccentBrush, + Brush QuoteBrush, + Brush TableHeaderBrush, + Brush CodeBackgroundBrush, + FontFamily BaseFontFamily, + FontFamily CodeFontFamily, + double BaseFontSize); + + public static FlowDocument CreateFlowDocument(string? markdownText, FontFamily fontFamily, double fontSize) + { + string safeMarkdown = markdownText ?? string.Empty; + FlowDocument document = new() + { + FontFamily = fontFamily, + FontSize = fontSize, + PagePadding = new Thickness(0) + }; + + MarkdownDocument markdownDocument = Markdown.Parse(safeMarkdown, MarkdownPipeline); + foreach (MarkdigBlock block in markdownDocument) + AppendBlock(document.Blocks, block, safeMarkdown, quoteDepth: 0); + + if (document.Blocks.Count == 0) + document.Blocks.Add(new Paragraph()); + + return document; + } + + public static string SerializeToMarkdown(FlowDocument document, bool preserveLiteralMarkdown = false) + { + ArgumentNullException.ThrowIfNull(document); + + StringBuilder builder = new(); + bool wroteBlock = false; + foreach (WpfBlock block in document.Blocks) + { + if (wroteBlock) + builder.Append($"{Environment.NewLine}{Environment.NewLine}"); + + WriteBlock(builder, block, listDepth: 0, preserveLiteralMarkdown); + wroteBlock = true; + } + + return builder.ToString().TrimEnd('\r', '\n'); + } + + public static string GetDocumentPlainText(FlowDocument document) + { + ArgumentNullException.ThrowIfNull(document); + return NormalizeDocumentText(new TextRange(document.ContentStart, document.ContentEnd).Text); + } + + public static bool ShouldPromoteLiveBlock(string? lineTextBeforeSpace) + { + if (string.IsNullOrWhiteSpace(lineTextBeforeSpace)) + return false; + + return LiveBlockTriggerRegex.IsMatch(lineTextBeforeSpace); + } + + public static bool LooksLikeMarkdown(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return MarkdownPatternRegex.IsMatch(text); + } + + public static bool ShouldPromoteLiveMarkdown(string? paragraphText) + { + if (string.IsNullOrWhiteSpace(paragraphText)) + return false; + + return LiveInlinePromotionRegex.IsMatch(NormalizeDocumentText(paragraphText)); + } + + public static void ApplyTheme(FlowDocument document, FrameworkElement resourceHost, bool isLightTheme) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(resourceHost); + + MarkdownTheme theme = CreateTheme(resourceHost, isLightTheme, document.FontFamily, document.FontSize); + document.Foreground = theme.ForegroundBrush; + document.FontFamily = theme.BaseFontFamily; + document.FontSize = theme.BaseFontSize; + document.PagePadding = new Thickness(0); + + foreach (WpfBlock block in document.Blocks) + ApplyBlockTheme(block, theme); + } + + private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, string source, int quoteDepth) + { + switch (block) + { + case HeadingBlock headingBlock: + Paragraph headingParagraph = new() + { + Margin = new Thickness(0, 10, 0, 4), + FontWeight = FontWeights.Bold + }; + SetHeadingLevel(headingParagraph, Math.Clamp(headingBlock.Level, 1, 6)); + SetQuoteDepth(headingParagraph, quoteDepth); + AppendInlineContainer(headingParagraph.Inlines, headingBlock.Inline, source); + blocks.Add(headingParagraph); + break; + + case ParagraphBlock paragraphBlock: + Paragraph paragraph = new() + { + Margin = new Thickness(0, 4, 0, 4) + }; + SetQuoteDepth(paragraph, quoteDepth); + AppendInlineContainer(paragraph.Inlines, paragraphBlock.Inline, source); + blocks.Add(paragraph); + break; + + case QuoteBlock quoteBlock: + foreach (MarkdigBlock child in quoteBlock) + AppendBlock(blocks, child, source, quoteDepth + 1); + break; + + case ListBlock listBlock: + WpfList list = new() + { + MarkerStyle = listBlock.IsOrdered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, + Margin = new Thickness(0, 4, 0, 4) + }; + SetQuoteDepth(list, quoteDepth); + + foreach (ListItemBlock itemBlock in listBlock.OfType()) + { + ListItem listItem = new(); + foreach (MarkdigBlock child in itemBlock) + AppendBlock(listItem.Blocks, child, source, quoteDepth: 0); + + if (listItem.Blocks.Count == 0) + listItem.Blocks.Add(new Paragraph()); + + list.ListItems.Add(listItem); + } + + blocks.Add(list); + break; + + case FencedCodeBlock fencedCodeBlock: + blocks.Add(CreateCodeParagraph(GetCodeBlockText(fencedCodeBlock), fencedCodeBlock.Info, quoteDepth)); + break; + + case CodeBlock codeBlock: + blocks.Add(CreateCodeParagraph(GetCodeBlockText(codeBlock), info: null, quoteDepth)); + break; + + case ThematicBreakBlock: + Paragraph breakParagraph = new() + { + Margin = new Thickness(0, 8, 0, 8) + }; + SetBlockRole(breakParagraph, MarkdownBlockRole.ThematicBreak); + SetQuoteDepth(breakParagraph, quoteDepth); + breakParagraph.Inlines.Add(new Run("----------")); + blocks.Add(breakParagraph); + break; + + case MarkdigTable table: + blocks.Add(CreateTable(table, source, quoteDepth)); + break; + + default: + blocks.Add(CreateLiteralParagraph(GetSourceSlice(source, block), quoteDepth)); + break; + } + } + + private static Paragraph CreateCodeParagraph(string codeText, string? info, int quoteDepth) + { + Paragraph paragraph = new() + { + Margin = new Thickness(0, 6, 0, 6) + }; + + SetBlockRole(paragraph, MarkdownBlockRole.CodeBlock); + SetQuoteDepth(paragraph, quoteDepth); + SetCodeFenceInfo(paragraph, info?.ToString() ?? string.Empty); + paragraph.Inlines.Add(new Run(codeText)); + return paragraph; + } + + private static Paragraph CreateLiteralParagraph(string literalMarkdown, int quoteDepth) + { + Paragraph paragraph = new() + { + Margin = new Thickness(0, 4, 0, 4) + }; + + SetQuoteDepth(paragraph, quoteDepth); + Run literalRun = new(literalMarkdown); + SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); + paragraph.Inlines.Add(literalRun); + return paragraph; + } + + private static WpfTable CreateTable(MarkdigTable table, string source, int quoteDepth) + { + WpfTable flowTable = new() + { + CellSpacing = 0, + Margin = new Thickness(0, 6, 0, 6) + }; + + SetQuoteDepth(flowTable, quoteDepth); + + int maxColumnCount = table.OfType().Select(row => row.Count).DefaultIfEmpty(0).Max(); + for (int columnIndex = 0; columnIndex < maxColumnCount; columnIndex++) + flowTable.Columns.Add(new TableColumn()); + + TableRowGroup rowGroup = new(); + flowTable.RowGroups.Add(rowGroup); + + foreach (MarkdigTableRow row in table.OfType()) + { + WpfTableRow flowRow = new(); + rowGroup.Rows.Add(flowRow); + + foreach (MarkdigTableCell cell in row.OfType()) + { + WpfTableCell flowCell = new() + { + Padding = new Thickness(6, 4, 6, 4) + }; + SetIsTableHeader(flowCell, row.IsHeader); + + foreach (MarkdigBlock child in cell) + AppendBlock(flowCell.Blocks, child, source, quoteDepth: 0); + + if (flowCell.Blocks.Count == 0) + flowCell.Blocks.Add(new Paragraph()); + + flowRow.Cells.Add(flowCell); + } + } + + return flowTable; + } + + private static void AppendInlineContainer(InlineCollection inlines, ContainerInline? container, string source) + { + if (container is null) + return; + + for (MarkdigInline? inline = container.FirstChild; inline is not null; inline = inline.NextSibling) + AppendInline(inlines, inline, source); + } + + private static void AppendInline(InlineCollection inlines, MarkdigInline inline, string source) + { + switch (inline) + { + case LiteralInline literalInline: + inlines.Add(new Run(literalInline.Content.ToString())); + break; + + case LineBreakInline: + inlines.Add(new LineBreak()); + break; + + case CodeInline codeInline: + Run codeRun = new(codeInline.Content) + { + FontFamily = new FontFamily("Consolas") + }; + SetInlineRole(codeRun, MarkdownInlineRole.CodeSpan); + inlines.Add(codeRun); + break; + + case TaskList taskList: + Run taskListRun = new(taskList.Checked ? "\u2611" : "\u2610"); + SetInlineRole(taskListRun, MarkdownInlineRole.TaskListMarker); + SetTaskListMarkerChecked(taskListRun, taskList.Checked); + inlines.Add(taskListRun); + break; + + case EmphasisInline emphasisInline: + Span emphasisSpan = emphasisInline.DelimiterCount >= 2 + ? new Bold() + : new Italic(); + + AppendInlineContainer(emphasisSpan.Inlines, emphasisInline, source); + if (emphasisInline.DelimiterCount >= 3) + inlines.Add(new Italic(emphasisSpan)); + else + inlines.Add(emphasisSpan); + break; + + case LinkInline linkInline when !linkInline.IsImage: + Hyperlink hyperlink = new(); + if (!string.IsNullOrWhiteSpace(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url)) + hyperlink.NavigateUri = new Uri(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl()! : linkInline.Url!, UriKind.RelativeOrAbsolute); + + AppendInlineContainer(hyperlink.Inlines, linkInline, source); + if (hyperlink.Inlines.FirstInline is null) + hyperlink.Inlines.Add(new Run(linkInline.Url ?? string.Empty)); + + inlines.Add(hyperlink); + break; + + case LinkInline linkInline: + Run literalImageRun = new(GetSourceSlice(source, linkInline)); + SetInlineRole(literalImageRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(literalImageRun); + break; + + case HtmlInline htmlInline: + Run htmlRun = new(htmlInline.Tag); + SetInlineRole(htmlRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(htmlRun); + break; + + case ContainerInline containerInline: + Span containerSpan = new(); + AppendInlineContainer(containerSpan.Inlines, containerInline, source); + inlines.Add(containerSpan); + break; + + default: + Run literalRun = new(GetSourceSlice(source, inline)); + SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(literalRun); + break; + } + } + + private static void WriteBlock(StringBuilder builder, WpfBlock block, int listDepth, bool preserveLiteralMarkdown) + { + switch (block) + { + case Paragraph paragraph: + WriteParagraph(builder, paragraph, preserveLiteralMarkdown); + break; + + case WpfList list: + WriteList(builder, list, listDepth, preserveLiteralMarkdown); + break; + + case WpfTable table: + WriteTable(builder, table); + break; + + default: + builder.Append(SerializeLiteralText(block, preserveLiteralMarkdown)); + break; + } + } + + private static void WriteParagraph(StringBuilder builder, Paragraph paragraph, bool preserveLiteralMarkdown) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(paragraph)); + + if (GetBlockRole(paragraph) == MarkdownBlockRole.ThematicBreak) + { + builder.Append(ApplyQuotePrefix("---", quotePrefix)); + return; + } + + if (GetBlockRole(paragraph) == MarkdownBlockRole.CodeBlock) + { + string codeInfo = GetCodeFenceInfo(paragraph); + string codeText = NormalizeDocumentText(new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + string fencedBlock = string.IsNullOrWhiteSpace(codeInfo) + ? $"```{Environment.NewLine}{codeText}{Environment.NewLine}```" + : $"```{codeInfo}{Environment.NewLine}{codeText}{Environment.NewLine}```"; + builder.Append(ApplyQuotePrefix(fencedBlock, quotePrefix)); + return; + } + + string content = SerializeInlines(paragraph.Inlines, preserveLiteralMarkdown); + int headingLevel = GetHeadingLevel(paragraph); + if (headingLevel > 0) + content = $"{new string('#', headingLevel)} {content}"; + + builder.Append(ApplyQuotePrefix(content, quotePrefix)); + } + + private static void WriteList(StringBuilder builder, WpfList list, int listDepth, bool preserveLiteralMarkdown) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(list)); + bool isOrdered = list.MarkerStyle == TextMarkerStyle.Decimal; + int itemIndex = 1; + + foreach (ListItem item in list.ListItems) + { + if (itemIndex > 1) + builder.AppendLine(); + + StringBuilder itemBuilder = new(); + bool wroteItemBlock = false; + foreach (WpfBlock block in item.Blocks) + { + if (wroteItemBlock) + itemBuilder.Append($"{Environment.NewLine}{Environment.NewLine}"); + + WriteBlock(itemBuilder, block, listDepth + 1, preserveLiteralMarkdown); + wroteItemBlock = true; + } + + string[] itemLines = NormalizeNewlines(itemBuilder.ToString()).Split('\n'); + string indent = new(' ', listDepth * 2); + string marker = isOrdered ? $"{itemIndex}. " : "- "; + + builder.Append(ApplyQuotePrefix($"{indent}{marker}{itemLines[0]}", quotePrefix)); + string continuationIndent = $"{indent}{new string(' ', marker.Length)}"; + for (int lineIndex = 1; lineIndex < itemLines.Length; lineIndex++) + { + builder.AppendLine(); + builder.Append(ApplyQuotePrefix($"{continuationIndent}{itemLines[lineIndex]}", quotePrefix)); + } + + itemIndex++; + } + } + + private static void WriteTable(StringBuilder builder, WpfTable table) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(table)); + TableRowGroup? firstGroup = table.RowGroups.FirstOrDefault(); + if (firstGroup is null || firstGroup.Rows.Count == 0) + return; + + List rows = firstGroup.Rows.Cast().ToList(); + List headerCells = rows[0].Cells.Cast().Select(SerializeTableCell).ToList(); + + builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", headerCells)} |", quotePrefix)); + builder.AppendLine(); + builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", Enumerable.Repeat("---", Math.Max(1, headerCells.Count)))} |", quotePrefix)); + + IEnumerable dataRows = rows.Count > 1 && rows[0].Cells.Cast().Any(GetIsTableHeader) + ? rows.Skip(1) + : rows; + + foreach (WpfTableRow row in dataRows) + { + builder.AppendLine(); + List rowCells = row.Cells.Cast().Select(SerializeTableCell).ToList(); + builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", rowCells)} |", quotePrefix)); + } + } + + private static string SerializeTableCell(WpfTableCell cell) + { + string rawText = NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); + return rawText + .Replace("|", "\\|", StringComparison.Ordinal) + .Replace(Environment.NewLine, "
", StringComparison.Ordinal); + } + + private static string SerializeInlines(InlineCollection inlines, bool preserveLiteralMarkdown) + { + StringBuilder builder = new(); + foreach (WpfInline inline in inlines) + WriteInline(builder, inline, preserveLiteralMarkdown); + + return builder.ToString(); + } + + private static void WriteInline(StringBuilder builder, WpfInline inline, bool preserveLiteralMarkdown) + { + switch (inline) + { + case LineBreak: + builder.Append($" {Environment.NewLine}"); + break; + + case Run run: + builder.Append(GetInlineRole(run) switch + { + MarkdownInlineRole.TaskListMarker => GetTaskListMarkerChecked(run) ? "[x]" : "[ ]", + MarkdownInlineRole.LiteralMarkdown => run.Text, + _ when preserveLiteralMarkdown => run.Text, + _ => EscapeMarkdownText(run.Text) + }); + break; + + case Hyperlink hyperlink: + string linkText = SerializeInlines(hyperlink.Inlines, preserveLiteralMarkdown); + string linkTarget = hyperlink.NavigateUri?.OriginalString ?? linkText; + builder.Append($"[{linkText}]({EscapeLinkDestination(linkTarget)})"); + break; + + case Bold bold: + builder.Append("**"); + builder.Append(SerializeInlines(bold.Inlines, preserveLiteralMarkdown)); + builder.Append("**"); + break; + + case Italic italic: + builder.Append('*'); + builder.Append(SerializeInlines(italic.Inlines, preserveLiteralMarkdown)); + builder.Append('*'); + break; + + case Span span when GetInlineRole(span) == MarkdownInlineRole.CodeSpan: + builder.Append('`'); + builder.Append(NormalizeDocumentText(new TextRange(span.ContentStart, span.ContentEnd).Text)); + builder.Append('`'); + break; + + case Span span: + builder.Append(SerializeInlines(span.Inlines, preserveLiteralMarkdown)); + break; + + default: + builder.Append(SerializeLiteralText(inline, preserveLiteralMarkdown)); + break; + } + } + + private static void ApplyBlockTheme(WpfBlock block, MarkdownTheme theme) + { + switch (block) + { + case Paragraph paragraph: + paragraph.Foreground = theme.ForegroundBrush; + paragraph.BorderThickness = new Thickness(0); + paragraph.Padding = new Thickness(0); + + int headingLevel = GetHeadingLevel(paragraph); + if (headingLevel > 0) + { + paragraph.FontWeight = FontWeights.SemiBold; + paragraph.FontSize = theme.BaseFontSize + Math.Max(2, 14 - (headingLevel * 2)); + } + else if (GetBlockRole(paragraph) == MarkdownBlockRole.CodeBlock) + { + paragraph.FontFamily = theme.CodeFontFamily; + paragraph.Background = theme.CodeBackgroundBrush; + paragraph.Padding = new Thickness(8, 6, 8, 6); + paragraph.BorderBrush = theme.BorderBrush; + paragraph.BorderThickness = new Thickness(1); + } + else + { + paragraph.FontFamily = theme.BaseFontFamily; + paragraph.FontSize = theme.BaseFontSize; + paragraph.Background = Brushes.Transparent; + } + + int quoteDepth = GetQuoteDepth(paragraph); + paragraph.Margin = quoteDepth > 0 + ? new Thickness(18 * quoteDepth, 4, 0, 4) + : paragraph.Margin; + + if (quoteDepth > 0 && GetBlockRole(paragraph) != MarkdownBlockRole.CodeBlock) + paragraph.Foreground = theme.QuoteBrush; + + foreach (WpfInline inline in paragraph.Inlines) + ApplyInlineTheme(inline, theme); + + break; + + case WpfList list: + list.Foreground = theme.ForegroundBrush; + list.Margin = GetQuoteDepth(list) > 0 + ? new Thickness(18 * GetQuoteDepth(list), 4, 0, 4) + : list.Margin; + + foreach (ListItem item in list.ListItems) + { + foreach (WpfBlock child in item.Blocks) + ApplyBlockTheme(child, theme); + } + + break; + + case WpfTable table: + table.Foreground = theme.ForegroundBrush; + table.Margin = GetQuoteDepth(table) > 0 + ? new Thickness(18 * GetQuoteDepth(table), 6, 0, 6) + : table.Margin; + + foreach (TableRowGroup rowGroup in table.RowGroups) + { + foreach (WpfTableRow row in rowGroup.Rows.Cast()) + { + foreach (WpfTableCell cell in row.Cells.Cast()) + { + cell.BorderBrush = theme.BorderBrush; + cell.BorderThickness = new Thickness(0.5); + cell.Background = GetIsTableHeader(cell) ? theme.TableHeaderBrush : Brushes.Transparent; + + foreach (WpfBlock child in cell.Blocks) + ApplyBlockTheme(child, theme); + } + } + } + + break; + } + } + + private static void ApplyInlineTheme(WpfInline inline, MarkdownTheme theme) + { + switch (inline) + { + case Hyperlink hyperlink: + hyperlink.Foreground = theme.AccentBrush; + hyperlink.TextDecorations = TextDecorations.Underline; + foreach (WpfInline child in hyperlink.Inlines) + ApplyInlineTheme(child, theme); + break; + + case Run run when GetInlineRole(run) == MarkdownInlineRole.CodeSpan: + run.FontFamily = theme.CodeFontFamily; + run.Background = theme.CodeBackgroundBrush; + break; + + case Span span: + foreach (WpfInline child in span.Inlines) + ApplyInlineTheme(child, theme); + break; + } + } + + private static MarkdownTheme CreateTheme(FrameworkElement resourceHost, bool isLightTheme, FontFamily baseFontFamily, double baseFontSize) + { + Brush foreground = FindBrush(resourceHost, "TextFillColorPrimaryBrush", Colors.Black); + Brush border = FindBrush(resourceHost, "ControlStrokeColorDefaultBrush", Color.FromRgb(120, 120, 120)); + Brush accent = FindBrush(resourceHost, "Teal", Color.FromRgb(48, 142, 152)); + Brush quote = FindBrush(resourceHost, "TextFillColorSecondaryBrush", isLightTheme ? Color.FromRgb(70, 70, 70) : Color.FromRgb(190, 190, 190)); + Brush tableHeader = new SolidColorBrush(isLightTheme ? Color.FromRgb(244, 246, 248) : Color.FromRgb(43, 43, 46)); + Brush codeBackground = new SolidColorBrush(isLightTheme ? Color.FromRgb(245, 245, 245) : Color.FromRgb(32, 32, 36)); + + return new MarkdownTheme( + foreground, + border, + accent, + quote, + tableHeader, + codeBackground, + baseFontFamily, + new FontFamily("Consolas"), + baseFontSize); + } + + private static Brush FindBrush(FrameworkElement resourceHost, string resourceKey, Color fallback) + { + return resourceHost.TryFindResource(resourceKey) switch + { + Brush brush => brush, + Color color => new SolidColorBrush(color), + _ => new SolidColorBrush(fallback) + }; + } + + private static string GetCodeBlockText(LeafBlock block) + { + return NormalizeDocumentText(block.Lines.ToString()); + } + + private static string SerializeLiteralText(TextElement element, bool preserveLiteralMarkdown) + { + string text = NormalizeDocumentText(new TextRange(element.ContentStart, element.ContentEnd).Text); + return preserveLiteralMarkdown ? text : EscapeMarkdownText(text); + } + + private static string EscapeMarkdownText(string? text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + string escapedText = text + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("`", "\\`", StringComparison.Ordinal) + .Replace("*", "\\*", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal) + .Replace("[", "\\[", StringComparison.Ordinal) + .Replace("]", "\\]", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); + + escapedText = Regex.Replace(escapedText, @"^(#{1,6}\s)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*>+)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*[-+]\s)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*\d+\.\s)", @"\$1", RegexOptions.Multiline); + return escapedText; + } + + private static string EscapeLinkDestination(string destination) + { + return destination.Replace(")", "\\)", StringComparison.Ordinal); + } + + private static string ApplyQuotePrefix(string text, string quotePrefix) + { + if (string.IsNullOrEmpty(quotePrefix)) + return text; + + return string.Join( + Environment.NewLine, + NormalizeNewlines(text).Split('\n').Select(line => string.IsNullOrEmpty(line) + ? quotePrefix.TrimEnd() + : $"{quotePrefix}{line}")); + } + + private static string GetQuotePrefix(int quoteDepth) + { + if (quoteDepth <= 0) + return string.Empty; + + StringBuilder builder = new(); + for (int i = 0; i < quoteDepth; i++) + builder.Append("> "); + + return builder.ToString(); + } + + private static string NormalizeDocumentText(string? text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + return NormalizeNewlines(text).TrimEnd('\n'); + } + + private static string NormalizeNewlines(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + + private static string GetSourceSlice(string source, MarkdownObject markdownObject) + { + if (markdownObject.Span.Start < 0 + || markdownObject.Span.End < markdownObject.Span.Start + || markdownObject.Span.End >= source.Length) + return string.Empty; + + return source.Substring(markdownObject.Span.Start, markdownObject.Span.End - markdownObject.Span.Start + 1); + } + + private static readonly DependencyProperty QuoteDepthProperty = + DependencyProperty.RegisterAttached("QuoteDepth", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + + private static readonly DependencyProperty HeadingLevelProperty = + DependencyProperty.RegisterAttached("HeadingLevel", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + + private static readonly DependencyProperty BlockRoleProperty = + DependencyProperty.RegisterAttached("BlockRole", typeof(MarkdownBlockRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownBlockRole.None)); + + private static readonly DependencyProperty InlineRoleProperty = + DependencyProperty.RegisterAttached("InlineRole", typeof(MarkdownInlineRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownInlineRole.None)); + + private static readonly DependencyProperty TaskListMarkerCheckedProperty = + DependencyProperty.RegisterAttached("TaskListMarkerChecked", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + + private static readonly DependencyProperty CodeFenceInfoProperty = + DependencyProperty.RegisterAttached("CodeFenceInfo", typeof(string), typeof(MarkdownDocumentUtilities), new PropertyMetadata(string.Empty)); + + private static readonly DependencyProperty IsTableHeaderProperty = + DependencyProperty.RegisterAttached("IsTableHeader", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + + private static void SetQuoteDepth(DependencyObject element, int value) => element.SetValue(QuoteDepthProperty, value); + private static int GetQuoteDepth(DependencyObject element) => (int)element.GetValue(QuoteDepthProperty); + private static void SetHeadingLevel(DependencyObject element, int value) => element.SetValue(HeadingLevelProperty, value); + private static int GetHeadingLevel(DependencyObject element) => (int)element.GetValue(HeadingLevelProperty); + private static void SetBlockRole(DependencyObject element, MarkdownBlockRole value) => element.SetValue(BlockRoleProperty, value); + private static MarkdownBlockRole GetBlockRole(DependencyObject element) => (MarkdownBlockRole)element.GetValue(BlockRoleProperty); + private static void SetInlineRole(DependencyObject element, MarkdownInlineRole value) => element.SetValue(InlineRoleProperty, value); + private static MarkdownInlineRole GetInlineRole(DependencyObject element) => (MarkdownInlineRole)element.GetValue(InlineRoleProperty); + private static void SetTaskListMarkerChecked(DependencyObject element, bool value) => element.SetValue(TaskListMarkerCheckedProperty, value); + private static bool GetTaskListMarkerChecked(DependencyObject element) => (bool)element.GetValue(TaskListMarkerCheckedProperty); + private static void SetCodeFenceInfo(DependencyObject element, string value) => element.SetValue(CodeFenceInfoProperty, value); + private static string GetCodeFenceInfo(DependencyObject element) => (string)element.GetValue(CodeFenceInfoProperty); + private static void SetIsTableHeader(DependencyObject element, bool value) => element.SetValue(IsTableHeaderProperty, value); + private static bool GetIsTableHeader(DependencyObject element) => (bool)element.GetValue(IsTableHeaderProperty); +} From 89827bacb01cf4f5bbf778be1e4dacd2351c964a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:48 -0500 Subject: [PATCH 03/11] Replace SaveTextFile with SaveHistoryTextFileBlocking Switched from FileUtilities.SaveTextFile to SaveHistoryTextFileBlocking for saving history JSON files, delegating file writing to the new method. --- Text-Grab/Services/HistoryService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 20e81efb..30cde165 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -470,7 +470,7 @@ private static void WriteHistoryFiles(List history, string fileName try { - FileUtilities.SaveTextFile(historyAsJson, $"{fileName}.json", FileStorageKind.WithHistory); + SaveHistoryTextFileBlocking(historyAsJson, $"{fileName}.json"); } catch (Exception ex) { From 34b80c9cc85b5d291d0d1ec70c6213cde4f43d80 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:23:25 -0500 Subject: [PATCH 04/11] Add comprehensive unit tests for editor and markdown logic Introduce new test classes for EditTextTableDocument, EditTextWindow, MarkdownDocumentUtilities, SpreadsheetUndoHistory, and file I/O/history services. Tests cover table parsing/serialization, row/column/view metric operations, markdown round-tripping, undo/redo logic, file extension mode detection, and edit window state handling. Includes WPF and theory tests for markdown and history features. Minor import changes to support new tests. --- Tests/EditTextTableDocumentTests.cs | 173 ++++++++++++++++++++++++ Tests/EditTextWindowFileStateTests.cs | 68 ++++++++++ Tests/EditTextWindowSpreadsheetTests.cs | 36 +++++ Tests/FilesIoTests.cs | 13 ++ Tests/HistoryServiceTests.cs | 67 +++++++++ Tests/MarkdownDocumentUtilitiesTests.cs | 159 ++++++++++++++++++++++ Tests/SpreadsheetUndoHistoryTests.cs | 69 ++++++++++ 7 files changed, 585 insertions(+) create mode 100644 Tests/EditTextTableDocumentTests.cs create mode 100644 Tests/EditTextWindowFileStateTests.cs create mode 100644 Tests/EditTextWindowSpreadsheetTests.cs create mode 100644 Tests/MarkdownDocumentUtilitiesTests.cs create mode 100644 Tests/SpreadsheetUndoHistoryTests.cs diff --git a/Tests/EditTextTableDocumentTests.cs b/Tests/EditTextTableDocumentTests.cs new file mode 100644 index 00000000..3af23bef --- /dev/null +++ b/Tests/EditTextTableDocumentTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextTableDocumentTests +{ + [Fact] + public void Tsv_RoundTrips_WithoutMinimumGridPadding() + { + const string input = "Name\tValue\r\nAlpha\t42"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Tsv, document.Format); + Assert.Equal("\r\n", document.NewLineSequence); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void Csv_QuotedFields_RoundTrip() + { + const string input = "Name,Notes\r\nJoe,\"Hello, \"\"world\"\"\""; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Csv, document.Format); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void Xml_FlattensRows_AndSerializesAttributesAndChildren() + { + const string input = "Alpha42Beta99"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Xml, document.Format); + Assert.Equal(["@id", "name", "value"], document.ColumnNames.Take(3).ToList()); + Assert.Equal("1", document.Rows[0][0]); + Assert.Equal("Alpha", document.Rows[0][1]); + Assert.Contains("id=\"1\"", document.SerializeToText()); + Assert.Contains("Alpha", document.SerializeToText()); + } + + [Fact] + public void PlainText_PreservesNewLineStyle() + { + const string input = "first\nsecond\nthird"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.PlainText, document.Format); + Assert.Equal("\n", document.NewLineSequence); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void AddedRowsAndColumns_ExpandSerializedDocument_NotMinimumCapacity() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB"); + + document.InsertColumn(2); + document.InsertRow(1); + document.Rows[0][2] = "C"; + document.Rows[1][0] = "D"; + document.Rows[1][1] = "E"; + document.Rows[1][2] = "F"; + + Assert.Equal("A\tB\tC\r\nD\tE\tF", document.SerializeToText()); + } + + [Fact] + public void SerializedJson_RestoresLogicalDimensions() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("left\tright"); + document.InsertColumn(2); + document.Rows[0][2] = "extra"; + document.SetColumnWidth(0, 180); + document.SetRowHeight(0, 36); + + string json = document.SerializeToJson(); + EditTextTableDocument? restored = EditTextTableDocument.TryDeserialize(json); + + Assert.NotNull(restored); + Assert.Equal(document.RowCount, restored!.RowCount); + Assert.Equal(document.ColumnCount, restored.ColumnCount); + Assert.Equal(document.SerializeToText(), restored.SerializeToText()); + Assert.Equal(180, restored.ColumnWidths[0]); + Assert.Equal(36, restored.RowHeights[0]); + Assert.True(JsonDocument.Parse(json).RootElement.TryGetProperty("ColumnCount", out _)); + } + + [Fact] + public void MoveAndDeleteRow_UpdateLogicalOrdering() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\t1\r\nB\t2\r\nC\t3"); + + document.MoveRow(2, 0); + document.DeleteRow(1); + + Assert.Equal("C\t3\r\nB\t2", document.SerializeToText()); + } + + [Fact] + public void MoveAndDeleteColumn_UpdateLogicalOrdering() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\tC"); + + document.MoveColumn(2, 0); + document.DeleteColumn(1); + + Assert.Equal("C\tB", document.SerializeToText()); + } + + [Fact] + public void ViewMetrics_MoveWithRowsAndColumns() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\r\nC\tD"); + document.SetColumnWidth(0, 140); + document.SetColumnWidth(1, 220); + document.SetRowHeight(0, 30); + document.SetRowHeight(1, 44); + + document.MoveColumn(1, 0); + document.MoveRow(1, 0); + + Assert.Equal(220, document.ColumnWidths[0]); + Assert.Equal(140, document.ColumnWidths[1]); + Assert.Equal(44, document.RowHeights[0]); + Assert.Equal(30, document.RowHeights[1]); + } + + [Fact] + public void ApplyViewMetricsFrom_PreservesExistingSizing() + { + EditTextTableDocument source = EditTextTableDocument.CreateFromText("A\tB\r\nC\tD"); + source.SetColumnWidth(0, 160); + source.SetColumnWidth(1, 240); + source.SetRowHeight(0, 28); + source.SetRowHeight(1, 40); + + EditTextTableDocument target = EditTextTableDocument.CreateFromText("1\t2\r\n3\t4\r\n5\t6"); + target.ApplyViewMetricsFrom(source); + + Assert.Equal(160, target.ColumnWidths[0]); + Assert.Equal(240, target.ColumnWidths[1]); + Assert.Equal(28, target.RowHeights[0]); + Assert.Equal(40, target.RowHeights[1]); + Assert.Null(target.RowHeights[2]); + } + + [Fact] + public void Transpose_SwapsRowsAndColumns_AndResetsViewMetrics() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText( + "A\tB\tC\r\n1\t2\t3", + minimumRowCount: 2, + minimumColumnCount: 3); + document.SetColumnWidth(0, 180); + document.SetRowHeight(0, 36); + + document.Transpose(); + + Assert.Equal("A\t1\r\nB\t2\r\nC\t3", document.SerializeToText()); + Assert.Equal(3, document.RowCount); + Assert.Equal(2, document.ColumnCount); + Assert.Equal(3, document.MinimumRowCount); + Assert.Equal(2, document.MinimumColumnCount); + Assert.All(document.ColumnWidths.Take(document.ColumnCount), width => Assert.Null(width)); + Assert.All(document.RowHeights.Take(document.RowCount), height => Assert.Null(height)); + } +} diff --git a/Tests/EditTextWindowFileStateTests.cs b/Tests/EditTextWindowFileStateTests.cs new file mode 100644 index 00000000..8a3ace11 --- /dev/null +++ b/Tests/EditTextWindowFileStateTests.cs @@ -0,0 +1,68 @@ +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowFileStateTests +{ + [Theory] + [InlineData(null, false, "Edit Text")] + [InlineData("", true, "Edit Text")] + [InlineData(@"C:\Temp\notes.md", false, "Edit Text | notes.md")] + [InlineData(@"C:\Temp\notes.md", true, "Edit Text | *notes.md")] + public void GetWindowTitle_ReflectsTrackedFileAndPendingEdits(string? path, bool hasPendingEdits, string expectedTitle) + { + Assert.Equal(expectedTitle, EditTextWindow.GetWindowTitle(path, hasPendingEdits)); + } + + [Theory] + [InlineData(null, "saved", "changed", false)] + [InlineData("", "saved", "changed", false)] + [InlineData(@"C:\Temp\notes.md", "same", "same", false)] + [InlineData(@"C:\Temp\notes.md", "same", "changed", true)] + public void ShouldShowPendingFileEdits_RequiresTrackedFileAndChangedText(string? path, string savedText, string currentText, bool expected) + { + Assert.Equal(expected, EditTextWindow.ShouldShowPendingFileEdits(path, savedText, currentText)); + } + + [Theory] + [InlineData(null, EtwEditorMode.Text, null, null, ".txt")] + [InlineData(null, EtwEditorMode.Markdown, null, null, ".md")] + [InlineData(null, EtwEditorMode.Spreadsheet, null, null, ".tsv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.Csv, ",", ".csv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.Tsv, "\t", ".tsv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.DelimitedText, ",", ".csv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.DelimitedText, "|", ".tsv")] + [InlineData(@"C:\Temp\notes.markdown", EtwEditorMode.Text, null, null, ".markdown")] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Markdown, null, null, ".json")] + public void GetDefaultSaveExtension_MatchesEditorMode( + string? openedFilePath, + EtwEditorMode editorMode, + EtwStructuredTextFormat? format, + string? delimiter, + string expectedExtension) + { + EditTextTableDocument? tableDocument = format.HasValue + ? new EditTextTableDocument + { + Format = format.Value, + Delimiter = delimiter ?? "\t" + } + : null; + + Assert.Equal(expectedExtension, EditTextWindow.GetDefaultSaveExtension(openedFilePath, editorMode, tableDocument)); + } + + [Theory] + [InlineData(null, EtwEditorMode.Spreadsheet, 1)] + [InlineData(null, EtwEditorMode.Markdown, 2)] + [InlineData(null, EtwEditorMode.Text, 3)] + [InlineData(@"C:\Temp\sheet.csv", EtwEditorMode.Markdown, 1)] + [InlineData(@"C:\Temp\notes.md", EtwEditorMode.Text, 2)] + [InlineData(@"C:\Temp\notes.txt", EtwEditorMode.Markdown, 3)] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Text, 4)] + public void GetSaveDocumentFilterIndex_MatchesEditorMode(string? openedFilePath, EtwEditorMode editorMode, int expectedFilterIndex) + { + Assert.Equal(expectedFilterIndex, EditTextWindow.GetSaveDocumentFilterIndex(openedFilePath, editorMode)); + } +} diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs new file mode 100644 index 00000000..ce9d1971 --- /dev/null +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -0,0 +1,36 @@ +using System.Data; +using Text_Grab; + +namespace Tests; + +public class EditTextWindowSpreadsheetTests +{ + [Fact] + public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", "b1", "c1"); + dataTable.Rows.Add("a2", "b2", "c2"); + + EditTextWindow.ClearSpreadsheetCellValues( + dataTable, + [ + (0, 0), + (1, 2), + (1, 2), + (-1, 1), + (5, 0), + (0, 5) + ]); + + Assert.Equal(string.Empty, dataTable.Rows[0][0]); + Assert.Equal("b1", dataTable.Rows[0][1]); + Assert.Equal("c1", dataTable.Rows[0][2]); + Assert.Equal("a2", dataTable.Rows[1][0]); + Assert.Equal("b2", dataTable.Rows[1][1]); + Assert.Equal(string.Empty, dataTable.Rows[1][2]); + } +} diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 18808438..6fbb5403 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -94,4 +94,17 @@ public async Task ReadNotExistingImageFileEmpty(FileStorageKind storageKind) Bitmap? emptyReturn = await FileUtilities.GetImageFileAsync(fileName, storageKind); Assert.Null(emptyReturn); } + + [Theory] + [InlineData(@"C:\Temp\sheet.csv", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\sheet.TSV", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\sheet.tab", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\notes.md", EtwEditorMode.Markdown)] + [InlineData(@"C:\Temp\notes.markdown", EtwEditorMode.Markdown)] + [InlineData(@"C:\Temp\notes.txt", EtwEditorMode.Text)] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Text)] + public void GetEditorModeForPath_UsesFileExtension(string path, EtwEditorMode expectedMode) + { + Assert.Equal(expectedMode, IoUtilities.GetEditorModeForPath(path)); + } } diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs index c2965c80..28bf8b63 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Windows; +using System.Reflection; using Text_Grab; using Text_Grab.Models; using Text_Grab.Services; @@ -181,6 +182,72 @@ await SaveHistoryFileAsync( Assert.Contains("\"UsedUiAutomation\": true", savedHistoryJson); } + [WpfFact] + public async Task TextHistory_PreservesMarkdownEditorModeAndSource() + { + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "markdown-history", + CaptureDateTime = new DateTimeOffset(2024, 1, 5, 12, 0, 0, TimeSpan.Zero), + TextContent = "# Heading\r\n\r\n**bold**", + SourceMode = TextGrabMode.EditText, + EditorMode = EtwEditorMode.Markdown + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetEditWindows()); + + Assert.Equal(EtwEditorMode.Markdown, historyItem.EditorMode); + Assert.Equal("# Heading\r\n\r\n**bold**", historyItem.TextContent); + } + + [WpfFact] + public void TextHistory_WriteHistory_PersistsSavedEditWindowText() + { + bool originalUseHistory = AppUtilities.TextGrabSettings.UseHistory; + AppUtilities.TextGrabSettings.UseHistory = true; + + try + { + HistoryService historyService = new(); + historyService.DeleteHistory(); + SetPrivateField(historyService, "HistoryTextOnly", new List + { + new() + { + ID = "saved-edit-window", + CaptureDateTime = new DateTimeOffset(2024, 1, 6, 12, 0, 0, TimeSpan.Zero), + TextContent = "history text from close action", + SourceMode = TextGrabMode.EditText + } + }); + SetPrivateField(historyService, "_textHistoryLoaded", true); + SetPrivateField(historyService, "_hasPendingWrite", true); + + historyService.WriteHistory(); + historyService.ReleaseLoadedHistories(); + + HistoryInfo historyItem = Assert.Single(historyService.GetEditWindows()); + Assert.Equal("history text from close action", historyItem.TextContent); + } + finally + { + AppUtilities.TextGrabSettings.UseHistory = originalUseHistory; + } + } + + private static void SetPrivateField(object target, string fieldName, T value) + { + FieldInfo fieldInfo = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Field '{fieldName}' was not found."); + + fieldInfo.SetValue(target, value); + } + private static Task SaveHistoryFileAsync(string fileName, List historyItems) { string historyJson = JsonSerializer.Serialize(historyItems, HistoryJsonOptions); diff --git a/Tests/MarkdownDocumentUtilitiesTests.cs b/Tests/MarkdownDocumentUtilitiesTests.cs new file mode 100644 index 00000000..d403729b --- /dev/null +++ b/Tests/MarkdownDocumentUtilitiesTests.cs @@ -0,0 +1,159 @@ +using System.Windows.Documents; +using System.Windows.Media; +using Text_Grab.Utilities; + +namespace Tests; + +public class MarkdownDocumentUtilitiesTests +{ + [WpfFact] + public void Markdown_RoundTrips_CommonFormatting() + { + const string markdown = """ +# Heading + +Plain **bold** text with a [link](https://example.com). + +- one +- two + +> quoted + +```csharp +Console.WriteLine("hi"); +``` +"""; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("# Heading", serialized); + Assert.Contains("**bold**", serialized); + Assert.Contains("[link](https://example.com)", serialized); + Assert.Contains("- one", serialized); + Assert.Contains("> quoted", serialized); + Assert.Contains("```csharp", serialized); + Assert.Contains("Console.WriteLine(\"hi\");", serialized); + } + + [WpfFact] + public void Markdown_Tables_RoundTrip_ToPipeTable() + { + const string markdown = """ +| Name | Value | +| --- | --- | +| Alpha | 42 | +| Beta | 99 | +"""; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("| Name | Value |", serialized); + Assert.Contains("| Alpha | 42 |", serialized); + Assert.Contains("| Beta | 99 |", serialized); + } + + [WpfFact] + public void Markdown_TaskLists_RoundTrip_ToCheckboxMarkers() + { + const string markdown = """ + - [ ] open item + - [x] done item + """; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("- [ ] open item", serialized); + Assert.Contains("- [x] done item", serialized); + } + + [WpfFact] + public void PlainText_WithMarkdownCharacters_IsEscapedDuringSerialization() + { + FlowDocument document = new(); + document.Blocks.Add(new Paragraph(new Run("*literal* [value]"))); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Equal(@"\*literal\* \[value\]", serialized); + } + + [WpfFact] + public void PreserveLiteralMarkdown_KeepsTypedMarkdownSyntax() + { + FlowDocument document = new(); + document.Blocks.Add(new Paragraph(new Run("**bold** [link](https://example.com)"))); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document, preserveLiteralMarkdown: true); + + Assert.Equal("**bold** [link](https://example.com)", serialized); + } + + [Theory] + [InlineData("#")] + [InlineData("##")] + [InlineData(">")] + [InlineData(" >")] + [InlineData("-")] + [InlineData("1.")] + public void LiveBlockTriggerMarkers_AreRecognized(string marker) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(marker)); + } + + [Theory] + [InlineData("text")] + [InlineData("hello # world")] + [InlineData("1.2")] + public void NonTriggerText_DoesNotPromoteLiveBlock(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(text)); + } + + [Theory] + [InlineData("**bold**")] + [InlineData("`code`")] + [InlineData("[link](https://example.com)")] + [InlineData("[ ] task")] + [InlineData("[x] done")] + public void CompletedMarkdownSyntax_PromotesLiveParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("*")] + [InlineData("[link]")] + [InlineData("plain text")] + [InlineData("2026.04 release notes")] + public void IncompleteMarkdownSyntax_DoesNotPromoteLiveParsing(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("# Heading")] + [InlineData("> quote")] + [InlineData("- item")] + [InlineData("1. item")] + [InlineData("[link](https://example.com)")] + [InlineData("```csharp\nConsole.WriteLine(\"hi\");\n```")] + public void MarkdownLikeText_IsDetectedForPasteParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } + + [Theory] + [InlineData("Just a normal sentence.")] + [InlineData("2026.04 release notes")] + [InlineData("email me at joe@example.com")] + public void PlainText_IsNotDetectedAsMarkdown(string text) + { + Assert.False(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } +} diff --git a/Tests/SpreadsheetUndoHistoryTests.cs b/Tests/SpreadsheetUndoHistoryTests.cs new file mode 100644 index 00000000..4dd9b6a9 --- /dev/null +++ b/Tests/SpreadsheetUndoHistoryTests.cs @@ -0,0 +1,69 @@ +using Text_Grab.Models; + +namespace Tests; + +public class SpreadsheetUndoHistoryTests +{ + [Fact] + public void RecordChange_UndoAndRedo_RestoreExpectedStates() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState originalState = new("{\"Rows\":[[\"one\"]]}", 1, 2); + SpreadsheetUndoState editedState = new("{\"Rows\":[[\"two\"]]}", 3, 4); + + history.RecordChange(originalState, editedState); + + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + + SpreadsheetUndoState? undoneState = history.Undo(editedState); + + Assert.NotNull(undoneState); + Assert.Equal(originalState.DocumentJson, undoneState.DocumentJson); + Assert.Equal(originalState.FocusRow, undoneState.FocusRow); + Assert.Equal(originalState.FocusColumn, undoneState.FocusColumn); + Assert.False(history.CanUndo); + Assert.True(history.CanRedo); + + SpreadsheetUndoState? redoneState = history.Redo(undoneState); + + Assert.NotNull(redoneState); + Assert.Equal(editedState.DocumentJson, redoneState.DocumentJson); + Assert.Equal(editedState.FocusRow, redoneState.FocusRow); + Assert.Equal(editedState.FocusColumn, redoneState.FocusColumn); + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + } + + [Fact] + public void RecordChange_NoOpChange_DoesNotCreateUndoEntry() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState state = new("{\"Rows\":[[\"same\"]]}", 0, 0); + + history.RecordChange(state, new SpreadsheetUndoState(state.DocumentJson, 5, 6)); + + Assert.False(history.CanUndo); + Assert.False(history.CanRedo); + } + + [Fact] + public void RecordChange_NewEditClearsRedoHistory() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState stateA = new("{\"Rows\":[[\"A\"]]}", 0, 0); + SpreadsheetUndoState stateB = new("{\"Rows\":[[\"B\"]]}", 0, 1); + SpreadsheetUndoState stateC = new("{\"Rows\":[[\"C\"]]}", 1, 0); + + history.RecordChange(stateA, stateB); + SpreadsheetUndoState? undoneState = history.Undo(stateB); + + Assert.NotNull(undoneState); + Assert.True(history.CanRedo); + + history.RecordChange(undoneState, stateC); + + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + } +} From 8bc93d36f4759e8ca1aa19287da5f1c7ee78ba26 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:23:48 -0500 Subject: [PATCH 05/11] Expand allowed Bash patterns for pdm CLI commands Added "Bash(pdm *)" and "Bash(pdm api *)" to the allow list in settings.local.json, broadening permitted Bash command patterns for the pdm CLI alongside the existing "Bash(bin/pdm *)". No permissions were removed. --- .claude/settings.local.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 210267d5..80c27172 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,10 @@ "Bash(curl -o bin/pdm https://app.produckmap.com/cli/pdm)", "Bash(chmod +x bin/pdm)", "Bash(bin/pdm ui-element:*)", - "Bash(bin/pdm *)" + "Bash(bin/pdm *)", + "Bash(pdm *)", + "Bash(pdm api *)", + "Read(//c/Users/josep/.claude/skills/pdm/**)" ], "deny": [] } From d06540f90f6afb2ecf9b1035a27efa3c2b7f2c8d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:24:13 -0500 Subject: [PATCH 06/11] Add spreadsheet and markdown editing modes to EditTextWindow Major refactor to support Raw Text, Spreadsheet, and Markdown modes in EditTextWindow. Added DataGrid-based spreadsheet editor with context menus, undo/redo, and row/column operations. Introduced Markdown editor with live preview and theming. Updated UI for mode switching, file open/save for new formats, and improved styles. Enhanced bottom bar, selection UI, and window lifecycle handling for all modes. --- Text-Grab/Styles/DataGridStyles.xaml | 24 +- Text-Grab/Styles/TextBoxStyles.xaml | 8 +- Text-Grab/Views/EditTextWindow.xaml | 218 ++- Text-Grab/Views/EditTextWindow.xaml.cs | 1789 +++++++++++++++++++++++- 4 files changed, 1934 insertions(+), 105 deletions(-) diff --git a/Text-Grab/Styles/DataGridStyles.xaml b/Text-Grab/Styles/DataGridStyles.xaml index 872e2b2d..264fc636 100644 --- a/Text-Grab/Styles/DataGridStyles.xaml +++ b/Text-Grab/Styles/DataGridStyles.xaml @@ -75,7 +75,7 @@ + + diff --git a/Text-Grab/Styles/TextBoxStyles.xaml b/Text-Grab/Styles/TextBoxStyles.xaml index 393d0e24..6b3fbd87 100644 --- a/Text-Grab/Styles/TextBoxStyles.xaml +++ b/Text-Grab/Styles/TextBoxStyles.xaml @@ -72,8 +72,8 @@ + HorizontalScrollBarVisibility="{TemplateBinding HorizontalScrollBarVisibility}" + VerticalScrollBarVisibility="{TemplateBinding VerticalScrollBarVisibility}" /> @@ -126,8 +126,8 @@ x:Name="PART_ContentHost" Padding="0" Focusable="false" - HorizontalScrollBarVisibility="Hidden" - VerticalScrollBarVisibility="Hidden" /> + HorizontalScrollBarVisibility="{TemplateBinding HorizontalScrollBarVisibility}" + VerticalScrollBarVisibility="{TemplateBinding VerticalScrollBarVisibility}" /> diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 335f9357..4027e9f4 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -69,6 +69,36 @@ Header="Move Selection Down" InputGestureText="Alt + Down" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + Orientation="Horizontal"> + + + + - + + + + + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 55afd1a0..4a439cd1 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,6 +1,8 @@ -using Humanizer; +using Humanizer; using System; +using System.ComponentModel; using System.Collections.Generic; +using System.Data; using System.Diagnostics; using System.Globalization; using System.IO; @@ -13,11 +15,16 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; using System.Windows.Forms; using System.Windows.Input; +using System.Windows.Interop; using System.Windows.Markup; using System.Windows.Media; using System.Windows.Media.Animation; +using System.Windows.Navigation; using System.Windows.Threading; using Text_Grab.Controls; using Text_Grab.Interfaces; @@ -42,6 +49,12 @@ namespace Text_Grab; public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow { + private const string EditTextWindowTitle = "Edit Text"; + private const double SpreadsheetDefaultColumnWidth = 120; + private const double HorizontalWheelScrollStep = 48; + private const int WmMouseHWheel = 0x020E; + private const string OpenDocumentFilter = "Supported text documents (*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt)|*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt|Spreadsheet documents (*.csv;*.tsv;*.tab)|*.csv;*.tsv;*.tab|Markdown documents (*.md;*.markdown)|*.md;*.markdown|Text documents (*.txt)|*.txt|All files (*.*)|*.*"; + private const string SaveDocumentFilter = "Spreadsheet documents (*.csv;*.tsv;*.tab)|*.csv;*.tsv;*.tab|Markdown documents (*.md;*.markdown)|*.md;*.markdown|Text documents (*.txt)|*.txt|All files (*.*)|*.*"; #region Fields public static RoutedCommand DeleteAllSelectionCmd = new(); @@ -56,6 +69,7 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow public static RoutedCommand SplitOnSelectionCmd = new(); public static RoutedCommand SplitAfterSelectionCmd = new(); public static RoutedCommand ToggleCaseCmd = new(); + public static RoutedCommand TransposeTableCmd = new(); public static RoutedCommand UnstackCmd = new(); public static RoutedCommand UnstackGroupCmd = new(); public static RoutedCommand WebSearchCmd = new(); @@ -84,6 +98,33 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private ExtractedPattern? currentExtractedPattern = null; private int currentPrecisionLevel = ExtractedPattern.DefaultPrecisionLevel; private CalculationResult? calculationResult; + private EditTextTableDocument? tableDocument; + private readonly SpreadsheetUndoHistory spreadsheetUndoHistory = new(); + private readonly DataTable spreadsheetTable = new(); + private readonly List trackedSpreadsheetColumns = []; + private EtwEditorMode editorMode = EtwEditorMode.Text; + private bool isSyncingTextFromSpreadsheet = false; + private bool isSyncingTextFromMarkdown = false; + private bool isApplyingSpreadsheetLayout = false; + private bool isApplyingMarkdownDocument = false; + private bool isLoadingOpenedFile = false; + private bool hasPendingFileEdits = false; + private bool isShowingPendingFileClosePrompt = false; + private bool allowCloseAfterPendingFilePrompt = false; + private bool isRestoringSpreadsheetUndoState = false; + private int? spreadsheetContextRowIndex; + private int? spreadsheetContextColumnIndex; + private SpreadsheetUndoState? pendingSpreadsheetUndoState; + private string savedFileText = string.Empty; + private HwndSource? windowSource; + + private enum PendingFileCloseAction + { + Cancel, + Save, + DontSave, + SaveToHistory, + } #endregion Fields @@ -114,6 +155,8 @@ public EditTextWindow(HistoryInfo historyInfo) App.SetTheme(); PassedTextControl.Text = historyInfo.TextContent; + editorMode = historyInfo.EditorMode; + tableDocument = EditTextTableDocument.TryDeserialize(historyInfo.EditTextTableDocumentJson); historyId = historyInfo.ID; @@ -172,6 +215,7 @@ public static Dictionary GetRoutedCommands() {nameof(SplitAfterSelectionCmd), SplitAfterSelectionCmd}, {nameof(OcrPasteCommand), OcrPasteCommand}, {nameof(MakeQrCodeCmd), MakeQrCodeCmd}, + {nameof(TransposeTableCmd), TransposeTableCmd}, {nameof(WebSearchCmd), WebSearchCmd}, {nameof(DefaultWebSearchCmd), DefaultWebSearchCmd}, }; @@ -401,33 +445,1174 @@ private void SetCultureAndLanguageToDefault() Language = xmlDefaultLang; } - internal HistoryInfo AsHistoryItem() + private void ApplySpreadsheetDocumentChange(Action changeAction, int? focusRow = null, int? focusColumn = null) + { + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + + if (tableDocument is null) + return; + + changeAction(tableDocument); + tableDocument.EnsureMinimumSize(); + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + + if (focusRow.HasValue && focusColumn.HasValue) + { + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow.Value, focusColumn.Value), + DispatcherPriority.Background); + } + } + + private SpreadsheetUndoState? CreateCurrentSpreadsheetUndoState(bool syncFromTable = false) + { + if (syncFromTable && editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(writeText: false); + + if (tableDocument is null) + return null; + + tableDocument.EnsureMinimumSize(); + return new SpreadsheetUndoState( + tableDocument.SerializeToJson(), + GetSpreadsheetCurrentRowIndex(), + GetSpreadsheetCurrentColumnIndex()); + } + + private int? GetSpreadsheetCurrentRowIndex() + { + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + return rowIndex >= 0 ? rowIndex : null; + } + + private int? GetSpreadsheetCurrentColumnIndex() + { + return SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex; + } + + private void CommitSpreadsheetEditsAndCapturePendingHistory() + { + if (editorMode != EtwEditorMode.Spreadsheet) + return; + + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Row, true); + CaptureCommittedSpreadsheetEditIfPending(); + } + + private void CaptureCommittedSpreadsheetEditIfPending() + { + if (pendingSpreadsheetUndoState is null || isRestoringSpreadsheetUndoState) + return; + + SpreadsheetUndoState beforeChange = pendingSpreadsheetUndoState; + pendingSpreadsheetUndoState = null; + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + } + + private void RecordSpreadsheetUndoChange(SpreadsheetUndoState? beforeChange, SpreadsheetUndoState? afterChange) + { + spreadsheetUndoHistory.RecordChange(beforeChange, afterChange); + CommandManager.InvalidateRequerySuggested(); + } + + private void ResetSpreadsheetUndoHistory() + { + spreadsheetUndoHistory.Clear(); + pendingSpreadsheetUndoState = null; + CommandManager.InvalidateRequerySuggested(); + } + + private void RestoreSpreadsheetUndoState(SpreadsheetUndoState stateToRestore) + { + EditTextTableDocument? restoredDocument = EditTextTableDocument.TryDeserialize(stateToRestore.DocumentJson); + if (restoredDocument is null) + return; + + isRestoringSpreadsheetUndoState = true; + try + { + pendingSpreadsheetUndoState = null; + tableDocument = restoredDocument; + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + } + finally + { + isRestoringSpreadsheetUndoState = false; + } + + if (SpreadsheetDataGrid.Items.Count == 0 || SpreadsheetDataGrid.Columns.Count == 0) + { + UpdateLineAndColumnText(); + return; + } + + int focusRow = Math.Clamp(stateToRestore.FocusRow ?? 0, 0, SpreadsheetDataGrid.Items.Count - 1); + int focusColumn = Math.Clamp(stateToRestore.FocusColumn ?? 0, 0, SpreadsheetDataGrid.Columns.Count - 1); + + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow, focusColumn, beginEdit: false), + DispatcherPriority.Background); + UpdateLineAndColumnText(); + } + + private void CopySpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int columnIndex = + spreadsheetContextColumnIndex + ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex + ?? -1; + + if (columnIndex < 0) + return; + List values = []; + + foreach (DataRow row in spreadsheetTable.Rows) + { + if (columnIndex >= spreadsheetTable.Columns.Count) + break; + + values.Add(row[columnIndex]?.ToString() ?? string.Empty); + } + + TrySetClipboardText(string.Join(Environment.NewLine, values)); + } + + private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) + { + List selectedRows = SpreadsheetDataGrid.SelectedItems.OfType().ToList(); + + if (selectedRows.Count == 0 && SpreadsheetDataGrid.CurrentItem is DataRowView currentRow) + selectedRows.Add(currentRow); + + if (selectedRows.Count == 0) + return; + + string rowText = string.Join( + Environment.NewLine, + selectedRows.Select(row => string.Join("\t", row.Row.ItemArray.Select(value => value?.ToString() ?? string.Empty)))); + + TrySetClipboardText(rowText); + } + + private void CopySpreadsheetSelectionMenuItem_Click(object sender, RoutedEventArgs e) + { + if (SpreadsheetDataGrid.SelectedCells.Count == 0) + return; + + string selectionText = string.Join( + Environment.NewLine, + SpreadsheetDataGrid.SelectedCells + .GroupBy(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) + .OrderBy(group => group.Key) + .Select(group => string.Join( + "\t", + group.OrderBy(cell => cell.Column.DisplayIndex) + .Select(cell => ((cell.Item as DataRowView)?.Row[cell.Column.DisplayIndex] ?? string.Empty).ToString())))); + + TrySetClipboardText(selectionText); + } + + private void AddSpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int currentColumnIndex = + spreadsheetContextColumnIndex + ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex + ?? ((tableDocument?.ColumnCount ?? 1) - 1); + int insertIndex = Math.Clamp(currentColumnIndex + 1, 0, Math.Max(tableDocument?.ColumnCount ?? 0, 0)); + + ApplySpreadsheetDocumentChange( + document => document.InsertColumn(insertIndex), + SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem), + insertIndex); + } + + private void AddSpreadsheetRowMenuItem_Click(object sender, RoutedEventArgs e) + { + int currentRowIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (currentRowIndex < 0) + currentRowIndex = (tableDocument?.RowCount ?? 1) - 1; + + int insertIndex = Math.Clamp(currentRowIndex + 1, 0, Math.Max(tableDocument?.RowCount ?? 0, 0)); + int focusColumn = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange( + document => document.InsertRow(insertIndex), + insertIndex, + focusColumn); + } + + private void TransposeTableCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + e.CanExecute = editorMode == EtwEditorMode.Spreadsheet; + } + + private void TransposeTableExecuted(object sender, ExecutedRoutedEventArgs e) + { + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange(document => document.Transpose()); + + if (SpreadsheetDataGrid.Items.Count == 0 || SpreadsheetDataGrid.Columns.Count == 0) + return; + + int focusRow = Math.Clamp(currentColumnIndex, 0, SpreadsheetDataGrid.Items.Count - 1); + int focusColumn = Math.Clamp(Math.Max(0, currentRowIndex), 0, SpreadsheetDataGrid.Columns.Count - 1); + + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow, focusColumn), + DispatcherPriority.Background); + } + + private void DeleteSpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int columnIndex = spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? -1; + if (columnIndex < 0) + return; + + int nextColumnIndex = Math.Max(0, Math.Min(columnIndex, (tableDocument?.ColumnCount ?? 1) - 2)); + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + + ApplySpreadsheetDocumentChange( + document => document.DeleteColumn(columnIndex), + Math.Max(0, rowIndex), + nextColumnIndex); + } + + private void DeleteSpreadsheetRowMenuItem_Click(object sender, RoutedEventArgs e) + { + int rowIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (rowIndex < 0) + return; + + int nextRowIndex = Math.Max(0, Math.Min(rowIndex, (tableDocument?.RowCount ?? 1) - 2)); + int columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange( + document => document.DeleteRow(rowIndex), + nextRowIndex, + columnIndex); + } + + private void EnsureSpreadsheetDocumentFromText() + { + if (tableDocument is not null) + { + tableDocument.EnsureMinimumSize(); + return; + } + + tableDocument = EditTextTableDocument.CreateFromText(PassedTextControl.Text); + } + + private void FocusSpreadsheetCell(int rowIndex, int columnIndex, bool beginEdit = true) + { + if (rowIndex < 0 + || columnIndex < 0 + || rowIndex >= SpreadsheetDataGrid.Items.Count + || columnIndex >= SpreadsheetDataGrid.Columns.Count) + { + return; + } + + object rowItem = SpreadsheetDataGrid.Items[rowIndex]; + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + + SpreadsheetDataGrid.ScrollIntoView(rowItem, column); + SpreadsheetDataGrid.SelectedCells.Clear(); + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + SpreadsheetDataGrid.Focus(); + + if (beginEdit) + SpreadsheetDataGrid.BeginEdit(); + } + + private void MoveSpreadsheetColumnLeftMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetColumn(-1); + } + + private void MoveSpreadsheetColumnRightMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetColumn(1); + } + + private void MoveSpreadsheetColumn(int direction) + { + int fromIndex = spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? -1; + if (fromIndex < 0) + return; + + int toIndex = fromIndex + direction; + if (toIndex < 0 || toIndex >= (tableDocument?.ColumnCount ?? 0)) + return; + + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + ApplySpreadsheetDocumentChange( + document => document.MoveColumn(fromIndex, toIndex), + Math.Max(0, rowIndex), + toIndex); + } + + private void MoveSpreadsheetRowDownMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetRow(1); + } + + private void MoveSpreadsheetRowUpMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetRow(-1); + } + + private void MoveSpreadsheetRow(int direction) + { + int fromIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (fromIndex < 0) + return; + + int toIndex = fromIndex + direction; + if (toIndex < 0 || toIndex >= (tableDocument?.RowCount ?? 0)) + return; + + int columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + ApplySpreadsheetDocumentChange( + document => document.MoveRow(fromIndex, toIndex), + toIndex, + columnIndex); + } + + private void HideSelectionSpecificUi() + { + MatchCountButton.Visibility = Visibility.Collapsed; + RegexPatternButton.Visibility = Visibility.Collapsed; + SimilarMatchesButton.Visibility = Visibility.Collapsed; + CharDetailsButton.Visibility = Visibility.Collapsed; + } + + private void RebuildSpreadsheetTable() + { + if (tableDocument is null) + return; + + DetachSpreadsheetColumnWidthTracking(); + isApplyingSpreadsheetLayout = true; + spreadsheetTable.BeginInit(); + spreadsheetTable.Clear(); + spreadsheetTable.Columns.Clear(); + + foreach (string columnName in tableDocument.ColumnNames) + spreadsheetTable.Columns.Add(columnName, typeof(string)); + + foreach (List row in tableDocument.Rows) + { + DataRow dataRow = spreadsheetTable.NewRow(); + for (int columnIndex = 0; columnIndex < tableDocument.ColumnNames.Count; columnIndex++) + dataRow[columnIndex] = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + + spreadsheetTable.Rows.Add(dataRow); + } + + spreadsheetTable.EndInit(); + + SpreadsheetDataGrid.ItemsSource = spreadsheetTable.DefaultView; + SpreadsheetDataGrid.Columns.Clear(); + + for (int columnIndex = 0; columnIndex < spreadsheetTable.Columns.Count; columnIndex++) + { + DataColumn column = spreadsheetTable.Columns[columnIndex]; + double width = tableDocument.ColumnWidths.ElementAtOrDefault(columnIndex) ?? SpreadsheetDefaultColumnWidth; + DataGridTextColumn gridColumn = new() + { + Header = EditTextTableDocument.GetSpreadsheetColumnLabel(columnIndex), + Binding = new System.Windows.Data.Binding($"[{column.ColumnName}]"), + MinWidth = SpreadsheetDefaultColumnWidth, + Width = new DataGridLength(Math.Max(SpreadsheetDefaultColumnWidth, width)), + }; + + SpreadsheetDataGrid.Columns.Add(gridColumn); + TrackSpreadsheetColumnWidth(gridColumn); + } + + SpreadsheetDataGrid.Items.Refresh(); + isApplyingSpreadsheetLayout = false; + } + + private void RefreshSpreadsheetFromText(bool rebuildTable = true) + { + if (isSyncingTextFromSpreadsheet) + return; + + EditTextTableDocument? existingDocument = tableDocument; + tableDocument = EditTextTableDocument.CreateFromText( + PassedTextControl.Text, + existingDocument?.MinimumRowCount ?? EditTextTableDocument.DefaultMinimumRowCount, + existingDocument?.MinimumColumnCount ?? EditTextTableDocument.DefaultMinimumColumnCount); + + if (existingDocument is not null) + tableDocument.ApplyViewMetricsFrom(existingDocument); + + if (rebuildTable) + RebuildSpreadsheetTable(); + UpdateLineAndColumnText(); + } + + private void RefreshMarkdownFromText() + { + if (isSyncingTextFromMarkdown) + return; + + LoadMarkdownDocumentFromText(PassedTextControl.Text); + UpdateLineAndColumnText(); + } + + private void LoadMarkdownDocumentFromText(string? markdownText) + { + isApplyingMarkdownDocument = true; + MarkdownEditorControl.Document = MarkdownDocumentUtilities.CreateFlowDocument( + markdownText, + MarkdownEditorControl.FontFamily, + MarkdownEditorControl.FontSize); + ApplyMarkdownTheme(); + ApplyMarkdownWrapSetting(); + SetMargins(MarginsMenuItem.IsChecked is true); + isApplyingMarkdownDocument = false; + } + + private void SyncMarkdownTextFromDocument() + { + if (MarkdownEditorControl.Document is null) + return; + + isSyncingTextFromMarkdown = true; + PassedTextControl.Text = MarkdownDocumentUtilities.SerializeToMarkdown( + MarkdownEditorControl.Document, + preserveLiteralMarkdown: true); + isSyncingTextFromMarkdown = false; + } + + private void ApplyMarkdownTheme() + { + if (MarkdownEditorControl.Document is null) + return; + + MarkdownDocumentUtilities.ApplyTheme( + MarkdownEditorControl.Document, + this, + SystemThemeUtility.IsLightTheme()); + } + + private void ApplyMarkdownWrapSetting() + { + if (MarkdownEditorControl.Document is null) + return; + + if (WrapTextMenuItem.IsChecked) + { + MarkdownEditorControl.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + MarkdownEditorControl.Document.PageWidth = double.NaN; + } + else + { + MarkdownEditorControl.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + MarkdownEditorControl.Document.PageWidth = 4000; + } + } + + private void ReloadMarkdownDocumentAndRestoreCaret(int targetPlainTextOffset) + { + SyncMarkdownTextFromDocument(); + LoadMarkdownDocumentFromText(PassedTextControl.Text); + + if (MarkdownEditorControl.Document is null) + return; + + TextPointer caretPosition = GetMarkdownTextPointerAtPlainTextOffset(targetPlainTextOffset); + MarkdownEditorControl.Selection.Select(caretPosition, caretPosition); + } + + private int GetMarkdownPlainTextOffset(TextPointer position) + { + if (MarkdownEditorControl.Document is null) + return 0; + + return new TextRange(MarkdownEditorControl.Document.ContentStart, position).Text.Length; + } + + private TextPointer GetMarkdownTextPointerAtPlainTextOffset(int targetPlainTextOffset) + { + if (MarkdownEditorControl.Document is null) + return MarkdownEditorControl.CaretPosition; + + TextPointer navigator = MarkdownEditorControl.Document.ContentStart; + TextPointer lastInsertionPosition = navigator; + + while (navigator is not null) + { + int currentOffset = new TextRange(MarkdownEditorControl.Document.ContentStart, navigator).Text.Length; + if (currentOffset >= targetPlainTextOffset) + return navigator; + + lastInsertionPosition = navigator; + TextPointer? next = navigator.GetNextInsertionPosition(LogicalDirection.Forward); + if (next is null) + break; + + navigator = next; + } + + return lastInsertionPosition; + } + + private static T? FindParent(DependencyObject? current) where T : DependencyObject + { + while (current is not null) + { + if (current is T typedParent) + return typedParent; + + current = current switch + { + TextElement textElement => textElement.Parent, + _ => VisualTreeHelper.GetParent(current) + }; + } + + return null; + } + + private void SetEditorMode(EtwEditorMode mode) + { + bool isModeAlreadyApplied = mode switch + { + EtwEditorMode.Spreadsheet => SpreadsheetDataGrid.Visibility == Visibility.Visible + && PassedTextControl.Visibility != Visibility.Visible + && MarkdownEditorControl.Visibility != Visibility.Visible, + EtwEditorMode.Markdown => MarkdownEditorControl.Visibility == Visibility.Visible + && PassedTextControl.Visibility != Visibility.Visible + && SpreadsheetDataGrid.Visibility != Visibility.Visible, + _ => PassedTextControl.Visibility == Visibility.Visible + && SpreadsheetDataGrid.Visibility != Visibility.Visible + && MarkdownEditorControl.Visibility != Visibility.Visible + }; + + if (editorMode == mode && isModeAlreadyApplied) + { + if (mode == EtwEditorMode.Markdown) + ApplyMarkdownTheme(); + + UpdateSpreadsheetModeUi(); + UpdateLineAndColumnText(); + return; + } + + if (mode == EtwEditorMode.Spreadsheet) + { + if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + SyncMarkdownTextFromDocument(); + + EnsureSpreadsheetDocumentFromText(); + RebuildSpreadsheetTable(); + PassedTextControl.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Collapsed; + SpreadsheetDataGrid.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Spreadsheet; + SpreadsheetDataGrid.Focus(); + } + else if (mode == EtwEditorMode.Markdown) + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + SyncSpreadsheetDocumentFromTable(); + + LoadMarkdownDocumentFromText(PassedTextControl.Text); + SpreadsheetDataGrid.Visibility = Visibility.Collapsed; + PassedTextControl.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Markdown; + MarkdownEditorControl.Focus(); + } + else + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + SyncMarkdownTextFromDocument(); + + SpreadsheetDataGrid.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Collapsed; + PassedTextControl.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Text; + PassedTextControl.Focus(); + } + + UpdateSpreadsheetModeUi(); + UpdateLineAndColumnText(); + } + + private void SpreadsheetDataGrid_BeginningEdit(object sender, DataGridBeginningEditEventArgs e) + { + if (isRestoringSpreadsheetUndoState) + return; + + pendingSpreadsheetUndoState = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + } + + private void SpreadsheetDataGrid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction == DataGridEditAction.Cancel) + { + pendingSpreadsheetUndoState = null; + return; + } + + Dispatcher.BeginInvoke( + () => + { + CaptureCommittedSpreadsheetEditIfPending(); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void SpreadsheetDataGrid_CurrentCellChanged(object sender, EventArgs e) + { + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void SpreadsheetDataGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + int rowIndex = e.Row.GetIndex(); + e.Row.Header = (rowIndex + 1).ToString(CultureInfo.InvariantCulture); + e.Row.SizeChanged -= SpreadsheetRow_SizeChanged; + e.Row.SizeChanged += SpreadsheetRow_SizeChanged; + + double? rowHeight = tableDocument?.RowHeights.ElementAtOrDefault(rowIndex); + if (rowHeight.HasValue) + e.Row.Height = rowHeight.Value; + else + e.Row.ClearValue(HeightProperty); + } + + private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == Key.Delete) + { + bool hasMultipleSelectedCells = SpreadsheetDataGrid.SelectedCells.Count > 1; + if (!hasMultipleSelectedCells) + return; + + e.Handled = true; + ClearSelectedSpreadsheetCellValues(); + return; + } + + if (e.Key != Key.Enter || SpreadsheetDataGrid.CurrentCell.Column is null) + return; + + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column.DisplayIndex; + int lastLogicalRowIndex = (tableDocument?.RowCount ?? spreadsheetTable.Rows.Count) - 1; + + if (currentRowIndex != lastLogicalRowIndex) + return; + + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Row, true); + SyncSpreadsheetDocumentFromTable(); + + e.Handled = true; + int insertRowIndex = lastLogicalRowIndex + 1; + ApplySpreadsheetDocumentChange( + document => document.InsertRow(insertRowIndex), + insertRowIndex, + currentColumnIndex); + } + + private void SpreadsheetDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + spreadsheetContextRowIndex = null; + spreadsheetContextColumnIndex = null; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is not null) + return; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridColumnHeader columnHeader + && columnHeader.Column is DataGridColumn dataGridColumn) + { + SelectSpreadsheetColumn(dataGridColumn.DisplayIndex); + e.Handled = true; + return; + } + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridRowHeader rowHeader + && rowHeader.DataContext is not null) + { + SelectSpreadsheetRow(rowHeader.DataContext); + e.Handled = true; + } + } + + private void SpreadsheetDataGrid_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + spreadsheetContextRowIndex = null; + spreadsheetContextColumnIndex = null; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is not null) + return; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridColumnHeader columnHeader + && columnHeader.Column is DataGridColumn dataGridColumn) + { + spreadsheetContextColumnIndex = dataGridColumn.DisplayIndex; + SelectSpreadsheetColumn(dataGridColumn.DisplayIndex); + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetColumnHeaderContextMenu") as ContextMenu; + return; + } + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridRowHeader rowHeader + && rowHeader.DataContext is not null) + { + spreadsheetContextRowIndex = SpreadsheetDataGrid.Items.IndexOf(rowHeader.DataContext); + SelectSpreadsheetRow(rowHeader.DataContext); + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetRowHeaderContextMenu") as ContextMenu; + return; + } + + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetContextMenu") as ContextMenu; + } + + private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) + { + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void ClearSelectedSpreadsheetCellValues() + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = SpreadsheetDataGrid.SelectedCells + .Select(cell => ( + RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), + ColumnIndex: cell.Column?.DisplayIndex ?? -1)) + .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) + .Distinct() + .ToList(); + + if (selectedCellCoordinates.Count == 0) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + + ClearSpreadsheetCellValues(spreadsheetTable, selectedCellCoordinates); + SyncSpreadsheetDocumentFromTable(); + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + UpdateLineAndColumnText(); + } + + internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + foreach ((int rowIndex, int columnIndex) in cellCoordinates.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= dataTable.Rows.Count + || columnIndex < 0 + || columnIndex >= dataTable.Columns.Count) + { + continue; + } + + dataTable.Rows[rowIndex][columnIndex] = string.Empty; + } + } + + private void SpreadsheetUndoCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = spreadsheetUndoHistory.CanUndo; + e.Handled = true; + } + + private void SpreadsheetRedoCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = spreadsheetUndoHistory.CanRedo; + e.Handled = true; + } + + private void SpreadsheetUndoExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? previousState = spreadsheetUndoHistory.Undo(CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + if (previousState is null) + return; + + RestoreSpreadsheetUndoState(previousState); + CommandManager.InvalidateRequerySuggested(); + e.Handled = true; + } + + private void SpreadsheetRedoExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? nextState = spreadsheetUndoHistory.Redo(CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + if (nextState is null) + return; + + RestoreSpreadsheetUndoState(nextState); + CommandManager.InvalidateRequerySuggested(); + e.Handled = true; + } + + private bool IsSpreadsheetCellEditorFocused() + { + if (Keyboard.FocusedElement is not DependencyObject focusedElement) + return false; + + return FindVisualParent(focusedElement) is not null + && FindVisualParent(focusedElement) is not null; + } + + private void SpreadsheetColumnWidthChanged(object? sender, EventArgs e) + { + if (isApplyingSpreadsheetLayout || tableDocument is null || sender is not DataGridColumn column) + return; + + int columnIndex = SpreadsheetDataGrid.Columns.IndexOf(column); + if (columnIndex < 0) + return; + + double width = column.ActualWidth > 0 ? column.ActualWidth : column.Width.DisplayValue; + tableDocument.SetColumnWidth(columnIndex, width); + } + + private void SpreadsheetRow_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (isApplyingSpreadsheetLayout || tableDocument is null || sender is not DataGridRow row) + return; + + int rowIndex = row.GetIndex(); + if (rowIndex < 0) + return; + + double height = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + tableDocument.SetRowHeight(rowIndex, height); + } + + private void EditorModeMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender == RawTextModeMenuItem) + SetEditorMode(EtwEditorMode.Text); + else if (sender == SpreadsheetModeMenuItem) + SetEditorMode(EtwEditorMode.Spreadsheet); + else if (sender == MarkdownModeMenuItem) + SetEditorMode(EtwEditorMode.Markdown); + } + + private void SyncSpreadsheetDocumentFromTable(bool writeText = true) + { + tableDocument ??= EditTextTableDocument.CreateFromText(PassedTextControl.Text); + + tableDocument.ColumnNames = spreadsheetTable.Columns + .Cast() + .Select(column => column.ColumnName) + .ToList(); + + tableDocument.Rows = spreadsheetTable.Rows + .Cast() + .Select(row => spreadsheetTable.Columns + .Cast() + .Select(column => row[column]?.ToString() ?? string.Empty) + .ToList()) + .ToList(); + + int furthestNonEmptyRowIndex = -1; + int furthestNonEmptyColumnIndex = -1; + + for (int rowIndex = 0; rowIndex < tableDocument.Rows.Count; rowIndex++) + { + for (int columnIndex = 0; columnIndex < tableDocument.Rows[rowIndex].Count; columnIndex++) + { + if (string.IsNullOrWhiteSpace(tableDocument.Rows[rowIndex][columnIndex])) + continue; + + furthestNonEmptyRowIndex = Math.Max(furthestNonEmptyRowIndex, rowIndex); + furthestNonEmptyColumnIndex = Math.Max(furthestNonEmptyColumnIndex, columnIndex); + } + } + + tableDocument.RowCount = Math.Max(tableDocument.RowCount, furthestNonEmptyRowIndex + 1); + tableDocument.ColumnCount = Math.Max(tableDocument.ColumnCount, furthestNonEmptyColumnIndex + 1); + tableDocument.MinimumColumnCount = Math.Max(tableDocument.MinimumColumnCount, spreadsheetTable.Columns.Count); + tableDocument.MinimumRowCount = Math.Max(tableDocument.MinimumRowCount, spreadsheetTable.Rows.Count); + CaptureSpreadsheetLayoutMetrics(); + tableDocument.EnsureMinimumSize(); + + if (writeText) + UpdateTextFromSpreadsheetDocument(); + } + + private void CaptureSpreadsheetLayoutMetrics() + { + if (tableDocument is null) + return; + + for (int columnIndex = 0; columnIndex < SpreadsheetDataGrid.Columns.Count; columnIndex++) + { + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + double width = column.ActualWidth > 0 ? column.ActualWidth : column.Width.DisplayValue; + tableDocument.SetColumnWidth(columnIndex, width); + } + + foreach (object item in SpreadsheetDataGrid.Items) + { + if (item == CollectionView.NewItemPlaceholder) + continue; + + if (SpreadsheetDataGrid.ItemContainerGenerator.ContainerFromItem(item) is not DataGridRow row) + continue; + + int rowIndex = row.GetIndex(); + if (rowIndex < 0) + continue; + + double height = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + tableDocument.SetRowHeight(rowIndex, height); + } + } + + private void DetachSpreadsheetColumnWidthTracking() + { + foreach (DataGridColumn column in trackedSpreadsheetColumns) + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.RemoveValueChanged(column, SpreadsheetColumnWidthChanged); + + trackedSpreadsheetColumns.Clear(); + } + + private void TrackSpreadsheetColumnWidth(DataGridColumn column) + { + trackedSpreadsheetColumns.Add(column); + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.AddValueChanged(column, SpreadsheetColumnWidthChanged); + } + + private void TrySetClipboardText(string text) + { + try + { + System.Windows.Clipboard.SetDataObject(text, true); + } + catch + { + } + } + + private void UpdateSpreadsheetModeUi() + { + bool isSpreadsheetMode = editorMode == EtwEditorMode.Spreadsheet; + bool isMarkdownMode = editorMode == EtwEditorMode.Markdown; + + AddSpreadsheetRowButton.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetColumnButton.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetRowMenuItem.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetColumnMenuItem.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + RawTextModeMenuItem.IsChecked = editorMode == EtwEditorMode.Text; + SpreadsheetModeMenuItem.IsChecked = isSpreadsheetMode; + MarkdownModeMenuItem.IsChecked = isMarkdownMode; + CommandManager.InvalidateRequerySuggested(); + } + + private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject + { + while (child is not null) + { + if (child is T matchingParent) + return matchingParent; + + child = VisualTreeHelper.GetParent(child); + } + + return null; + } + + private void SelectSpreadsheetColumn(int columnIndex) + { + if (columnIndex < 0 || columnIndex >= SpreadsheetDataGrid.Columns.Count) + return; + + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); + + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + object? firstRowItem = null; + + foreach (object item in SpreadsheetDataGrid.Items) + { + if (ReferenceEquals(item, CollectionView.NewItemPlaceholder)) + continue; + + firstRowItem ??= item; + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(item, column)); + } + + if (firstRowItem is not null) + { + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(firstRowItem, column); + SpreadsheetDataGrid.ScrollIntoView(firstRowItem, column); + } + + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); + } + + private void SelectSpreadsheetRow(object rowItem) + { + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); + + if (SpreadsheetDataGrid.Columns.Count == 0) + return; + + DataGridColumn firstColumn = SpreadsheetDataGrid.Columns[0]; + foreach (DataGridColumn column in SpreadsheetDataGrid.Columns) + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, firstColumn); + SpreadsheetDataGrid.ScrollIntoView(rowItem, firstColumn); + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); + } + + private void UpdateTextFromSpreadsheetDocument() + { + if (tableDocument is null) + return; + + isSyncingTextFromSpreadsheet = true; + PassedTextControl.Text = tableDocument.SerializeToText(); + isSyncingTextFromSpreadsheet = false; + } + + internal HistoryInfo AsHistoryItem() + { + if (editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown) + SyncMarkdownTextFromDocument(); + + int calcPaneWidth = 0; + if (ShowCalcPaneMenuItem.IsChecked is true && CalcColumn.Width.Value > 0) + { + if (CalcColumn.Width.IsStar) + calcPaneWidth = (int)CalcColumn.ActualWidth; + else + calcPaneWidth = (int)CalcColumn.Width.Value; + } + + HistoryInfo historyInfo = new() + { + ID = historyId, + LanguageTag = LanguageUtilities.GetCurrentInputLanguage().LanguageTag, + LanguageKind = LanguageKind.Global, + CaptureDateTime = DateTimeOffset.Now, + TextContent = PassedTextControl.Text, + SourceMode = TextGrabMode.EditText, + CalcPaneWidth = calcPaneWidth, + HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true, + EditorMode = editorMode, + EditTextTableDocumentJson = tableDocument?.SerializeToJson() + }; + + if (string.IsNullOrWhiteSpace(historyInfo.ID)) + historyInfo.ID = Guid.NewGuid().ToString(); + + return historyInfo; + } + + internal static string GetWindowTitle(string? openedFilePath, bool hasPendingEdits) + { + if (string.IsNullOrWhiteSpace(openedFilePath)) + return EditTextWindowTitle; + + string fileName = Path.GetFileName(openedFilePath); + if (hasPendingEdits) + fileName = $"*{fileName}"; + + return $"{EditTextWindowTitle} | {fileName}"; + } + + internal static bool ShouldShowPendingFileEdits(string? openedFilePath, string savedText, string currentText) + { + return !string.IsNullOrWhiteSpace(openedFilePath) + && !string.Equals(savedText, currentText, StringComparison.Ordinal); + } + + internal static string GetDefaultSaveExtension(string? openedFilePath, EtwEditorMode editorMode, EditTextTableDocument? tableDocument) + { + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (!string.IsNullOrWhiteSpace(existingExtension)) + return existingExtension; + + return editorMode switch + { + EtwEditorMode.Spreadsheet => GetSpreadsheetSaveExtension(tableDocument), + EtwEditorMode.Markdown => ".md", + _ => ".txt" + }; + } + + internal static int GetSaveDocumentFilterIndex(string? openedFilePath, EtwEditorMode editorMode) { - int calcPaneWidth = 0; - if (ShowCalcPaneMenuItem.IsChecked is true && CalcColumn.Width.Value > 0) - { - if (CalcColumn.Width.IsStar) - calcPaneWidth = (int)CalcColumn.ActualWidth; - else - calcPaneWidth = (int)CalcColumn.Width.Value; - } + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (IoUtilities.IsSpreadsheetFileExtension(existingExtension)) + return 1; - HistoryInfo historyInfo = new() + if (IoUtilities.IsMarkdownFileExtension(existingExtension)) + return 2; + + if (string.Equals(existingExtension, ".txt", StringComparison.OrdinalIgnoreCase)) + return 3; + + if (!string.IsNullOrWhiteSpace(existingExtension)) + return 4; + + return editorMode switch { - ID = historyId, - LanguageTag = LanguageUtilities.GetCurrentInputLanguage().LanguageTag, - LanguageKind = LanguageKind.Global, - CaptureDateTime = DateTimeOffset.Now, - TextContent = PassedTextControl.Text, - SourceMode = TextGrabMode.EditText, - CalcPaneWidth = calcPaneWidth, - HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true + EtwEditorMode.Spreadsheet => 1, + EtwEditorMode.Markdown => 2, + _ => 3 }; + } - if (string.IsNullOrWhiteSpace(historyInfo.ID)) - historyInfo.ID = Guid.NewGuid().ToString(); + private static string GetSpreadsheetSaveExtension(EditTextTableDocument? tableDocument) + { + if (tableDocument is null) + return ".tsv"; - return historyInfo; + return tableDocument.Format switch + { + EtwStructuredTextFormat.Csv => ".csv", + EtwStructuredTextFormat.Tsv => ".tsv", + EtwStructuredTextFormat.DelimitedText when string.Equals(tableDocument.Delimiter, ",", StringComparison.Ordinal) => ".csv", + _ => ".tsv" + }; } internal void LimitNumberOfCharsPerLine(int numberOfChars, SpotInLine spotInLine) @@ -437,16 +1622,40 @@ internal void LimitNumberOfCharsPerLine(int numberOfChars, SpotInLine spotInLine internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = false) { - OpenedFilePath = pathOfFileToOpen; - + ResetSpreadsheetUndoHistory(); (string TextContent, OpenContentKind KindOpened) = await IoUtilities.GetContentFromPath(pathOfFileToOpen, isMultipleFiles, selectedILanguage); + bool shouldTrackOpenedFile = KindOpened == OpenContentKind.TextFile && !isMultipleFiles; + + if (KindOpened == OpenContentKind.TextFile) + { + EtwEditorMode targetMode = isMultipleFiles + ? EtwEditorMode.Text + : IoUtilities.GetEditorModeForPath(pathOfFileToOpen); + + if (IsLoaded) + SetEditorMode(targetMode); + else + editorMode = targetMode; + } + + isLoadingOpenedFile = true; + try + { + PassedTextControl.AppendText(TextContent); - if (KindOpened == OpenContentKind.TextFile - && !isMultipleFiles - && !string.IsNullOrWhiteSpace(TextContent)) - UiTitleBar.Title = $"Edit Text | {Path.GetFileName(OpenedFilePath)}"; + if (!IsLoaded) + return; - PassedTextControl.AppendText(TextContent); + if (editorMode == EtwEditorMode.Spreadsheet) + RefreshSpreadsheetFromText(); + else if (editorMode == EtwEditorMode.Markdown) + RefreshMarkdownFromText(); + } + finally + { + isLoadingOpenedFile = false; + SetOpenedFileState(shouldTrackOpenedFile ? pathOfFileToOpen : null); + } } private void AboutMenuItem_Click(object sender, RoutedEventArgs e) @@ -949,6 +2158,40 @@ private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e) } } + private IntPtr EditTextWindowMessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg != WmMouseHWheel || Keyboard.Modifiers == ModifierKeys.Control) + return IntPtr.Zero; + + ScrollViewer? scrollViewer = GetHorizontalPanTargetScrollViewer(); + if (scrollViewer is null || scrollViewer.ScrollableWidth <= 0) + return IntPtr.Zero; + + short delta = unchecked((short)((wParam.ToInt64() >> 16) & 0xFFFF)); + double deltaSteps = delta / 120.0; + if (NumericUtilities.AreClose(deltaSteps, 0)) + return IntPtr.Zero; + + double targetOffset = scrollViewer.HorizontalOffset + (deltaSteps * HorizontalWheelScrollStep); + scrollViewer.ScrollToHorizontalOffset(Math.Clamp(targetOffset, 0, scrollViewer.ScrollableWidth)); + handled = true; + return IntPtr.Zero; + } + + private ScrollViewer? GetHorizontalPanTargetScrollViewer() + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + return WindowUtilities.GetScrollViewer(SpreadsheetDataGrid); + + if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + return WindowUtilities.GetScrollViewer(MarkdownEditorControl); + + if (CalcResultsTextControl.Visibility == Visibility.Visible && CalcResultsTextControl.IsMouseOver) + return WindowUtilities.GetScrollViewer(CalcResultsTextControl); + + return WindowUtilities.GetScrollViewer(PassedTextControl); + } + // Keep calc pane scroll in sync with main text box private void PassedTextControl_ScrollChanged(object sender, ScrollChangedEventArgs e) { @@ -1364,7 +2607,11 @@ private void LoadRecentTextHistory() if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) { + ResetSpreadsheetUndoHistory(); PassedTextControl.Text = selectedHistory.TextContent; + tableDocument = EditTextTableDocument.TryDeserialize(selectedHistory.EditTextTableDocumentJson); + editorMode = selectedHistory.EditorMode; + SetEditorMode(editorMode); return; } @@ -1565,7 +2812,7 @@ private void OpenFileMenuItem_Click(object sender, RoutedEventArgs e) { // Set filter for file extension and default file extension DefaultExt = ".txt", - Filter = "Text documents (.txt)|*.txt|All files (*.*)|*.*", + Filter = OpenDocumentFilter, DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; @@ -1609,13 +2856,17 @@ private void PassedTextControl_ContextMenuOpening(object sender, ContextMenuEven private void PassedTextControl_SelectionChanged(object sender, RoutedEventArgs e) { + if (editorMode is EtwEditorMode.Spreadsheet or EtwEditorMode.Markdown) + return; + UpdateLineAndColumnText(); } private void PassedTextControl_SizeChanged(object sender, SizeChangedEventArgs e) { UpdateLineAndColumnText(); - SetMargins(MarginsMenuItem.IsChecked is true); + if (editorMode != EtwEditorMode.Markdown) + SetMargins(MarginsMenuItem.IsChecked is true); } private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) @@ -1634,6 +2885,150 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e // If a newline append auto-scrolls the main box, ensure calc scroll follows too // Schedule after layout so offsets are accurate Dispatcher.BeginInvoke(SyncCalcScrollToMain, DispatcherPriority.Background); + + if (isSyncingTextFromSpreadsheet || isSyncingTextFromMarkdown) + { + if (isSyncingTextFromMarkdown) + ResetSpreadsheetUndoHistory(); + + UpdatePendingFileEditState(); + return; + } + + if (editorMode == EtwEditorMode.Spreadsheet) + { + RefreshSpreadsheetFromText(); + UpdatePendingFileEditState(); + return; + } + + if (editorMode == EtwEditorMode.Markdown) + { + RefreshMarkdownFromText(); + UpdatePendingFileEditState(); + return; + } + + ResetSpreadsheetUndoHistory(); + RefreshSpreadsheetFromText(rebuildTable: false); + UpdatePendingFileEditState(); + } + + private void MarkdownEditorControl_SelectionChanged(object sender, RoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + UpdateLineAndColumnText(); + } + + private void MarkdownEditorControl_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + UpdateLineAndColumnText(); + SetMargins(MarginsMenuItem.IsChecked is true); + } + + private void MarkdownEditorControl_TextChanged(object sender, TextChangedEventArgs e) + { + if (isApplyingMarkdownDocument) + return; + + int caretOffset = GetMarkdownPlainTextOffset(MarkdownEditorControl.CaretPosition); + string currentParagraphText = FindParent(MarkdownEditorControl.CaretPosition.Parent) is Paragraph currentParagraph + ? new TextRange(currentParagraph.ContentStart, currentParagraph.ContentEnd).Text + : string.Empty; + bool shouldPromoteMarkdown = MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(currentParagraphText); + + SyncMarkdownTextFromDocument(); + UpdateLineAndColumnText(); + + if (!shouldPromoteMarkdown) + return; + + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(caretOffset); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void MarkdownEditorControl_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown || e.Text != " ") + return; + + Paragraph? paragraph = FindParent(MarkdownEditorControl.CaretPosition.Parent); + if (paragraph is null) + return; + + string lineTextBeforeSpace = new TextRange(paragraph.ContentStart, MarkdownEditorControl.CaretPosition).Text; + if (!MarkdownDocumentUtilities.ShouldPromoteLiveBlock(lineTextBeforeSpace)) + return; + + int paragraphStartOffset = GetMarkdownPlainTextOffset(paragraph.ContentStart); + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(paragraphStartOffset); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void MarkdownEditorControl_Pasting(object sender, DataObjectPastingEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + string? pastedText = e.DataObject.GetData(System.Windows.DataFormats.UnicodeText) as string + ?? e.DataObject.GetData(System.Windows.DataFormats.Text) as string; + if (string.IsNullOrEmpty(pastedText)) + return; + + e.CancelCommand(); + + bool shouldParseAsMarkdown = MarkdownDocumentUtilities.LooksLikeMarkdown(pastedText); + int selectionStartOffset = GetMarkdownPlainTextOffset(MarkdownEditorControl.Selection.Start); + int renderedPasteLength = shouldParseAsMarkdown + ? MarkdownDocumentUtilities.GetDocumentPlainText( + MarkdownDocumentUtilities.CreateFlowDocument( + pastedText, + MarkdownEditorControl.FontFamily, + MarkdownEditorControl.FontSize)).Length + : pastedText.Length; + + MarkdownEditorControl.Selection.Text = pastedText; + + if (shouldParseAsMarkdown) + { + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(selectionStartOffset + renderedPasteLength); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + } + + private void MarkdownEditorControl_RequestNavigate(object sender, RequestNavigateEventArgs e) + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + e.Handled = true; } private DispatcherTimer? _debounceTimer = null; @@ -1961,46 +3356,162 @@ private void RestoreWindowSettings() private void SaveAsBTN_Click(object sender, RoutedEventArgs e) { - string fileText = PassedTextControl.Text; + _ = SaveCurrentDocument(saveAs: true); + } - Microsoft.Win32.SaveFileDialog dialog = new() - { - Filter = "Text Files(*.txt)|*.txt|All(*.*)|*", - InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - RestoreDirectory = true, - }; + private void SaveBTN_Click(object sender, RoutedEventArgs e) + { + SyncTextFromActiveEditor(); + _ = SaveCurrentDocument(); + } - if (dialog.ShowDialog() is true) - { - File.WriteAllText(dialog.FileName, fileText); - OpenedFilePath = dialog.FileName; - UiTitleBar.Title = $"Edit Text | {OpenedFilePath.Split('\\').LastOrDefault()}"; - } + private string GetDefaultSaveExtension() + { + return GetDefaultSaveExtension(OpenedFilePath, editorMode, tableDocument); } - private void SaveBTN_Click(object sender, RoutedEventArgs e) + private int GetSaveDocumentFilterIndex() + { + return GetSaveDocumentFilterIndex(OpenedFilePath, editorMode); + } + + private void SyncTextFromActiveEditor() + { + if (editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown) + SyncMarkdownTextFromDocument(); + } + + private bool SaveCurrentDocument(bool saveAs = false) { + SyncTextFromActiveEditor(); + string fileText = PassedTextControl.Text; + string? targetFilePath = saveAs ? null : OpenedFilePath; - if (string.IsNullOrEmpty(OpenedFilePath)) + if (string.IsNullOrEmpty(targetFilePath)) { Microsoft.Win32.SaveFileDialog dialog = new() { - Filter = "Text Files(*.txt)|*.txt|All(*.*)|*", + DefaultExt = GetDefaultSaveExtension(), + Filter = SaveDocumentFilter, + FilterIndex = GetSaveDocumentFilterIndex(), InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), RestoreDirectory = true, }; - if (dialog.ShowDialog() is true) + if (dialog.ShowDialog() is not true) + return false; + + targetFilePath = dialog.FileName; + } + + File.WriteAllText(targetFilePath, fileText); + SetOpenedFileState(targetFilePath); + return true; + } + + private void SetOpenedFileState(string? openedFilePath) + { + OpenedFilePath = openedFilePath; + savedFileText = string.IsNullOrWhiteSpace(openedFilePath) ? string.Empty : PassedTextControl.Text; + hasPendingFileEdits = false; + UpdateWindowTitle(); + } + + private void UpdateWindowTitle() + { + string windowTitle = GetWindowTitle(OpenedFilePath, hasPendingFileEdits); + Title = windowTitle; + UiTitleBar.Title = windowTitle; + } + + private void UpdatePendingFileEditState() + { + if (isLoadingOpenedFile) + return; + + hasPendingFileEdits = ShouldShowPendingFileEdits(OpenedFilePath, savedFileText, PassedTextControl.Text); + UpdateWindowTitle(); + } + + private async Task PromptForPendingFileEditsAsync() + { + if (string.IsNullOrWhiteSpace(OpenedFilePath)) + return PendingFileCloseAction.Cancel; + + string fileName = Path.GetFileName(OpenedFilePath); + PendingFileCloseAction closeButtonAction = PendingFileCloseAction.Cancel; + Wpf.Ui.Controls.ContentDialog promptDialog = new(PendingFileCloseDialogHost) + { + Title = $"Save changes to {fileName}?", + Content = "You have pending edits. Save the file, discard the changes, or keep the current version in Text Grab history.", + PrimaryButtonText = "Save", + SecondaryButtonText = "Don't Save", + CloseButtonText = "Save to History", + DefaultButton = Wpf.Ui.Controls.ContentDialogButton.Primary, + }; + + promptDialog.ButtonClicked += (_, e) => + { + if (e.Button == Wpf.Ui.Controls.ContentDialogButton.Close) + closeButtonAction = PendingFileCloseAction.SaveToHistory; + }; + + Wpf.Ui.Controls.ContentDialogResult result = await promptDialog.ShowAsync(); + + if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) + return PendingFileCloseAction.Save; + + if (result == Wpf.Ui.Controls.ContentDialogResult.Secondary) + return PendingFileCloseAction.DontSave; + + if (closeButtonAction == PendingFileCloseAction.SaveToHistory) + return closeButtonAction; + + return PendingFileCloseAction.Cancel; + } + + private void SaveWindowTextToHistoryIfNeeded() + { + if (string.IsNullOrEmpty(OpenedFilePath) + && !string.IsNullOrWhiteSpace(PassedTextControl.Text)) + Singleton.Instance.SaveToHistory(this); + } + + private void SaveWindowTextToHistoryNow() + { + Singleton.Instance.SaveToHistory(this); + Singleton.Instance.WriteHistory(); + } + + private async Task HandlePendingFileClosePromptAsync() + { + try + { + switch (await PromptForPendingFileEditsAsync()) { - File.WriteAllText(dialog.FileName, fileText); - OpenedFilePath = dialog.FileName; - UiTitleBar.Title = $"Edit Text | {OpenedFilePath.Split('\\').LastOrDefault()}"; + case PendingFileCloseAction.Save: + if (!SaveCurrentDocument()) + return; + break; + case PendingFileCloseAction.DontSave: + break; + case PendingFileCloseAction.SaveToHistory: + SaveWindowTextToHistoryNow(); + break; + case PendingFileCloseAction.Cancel: + default: + return; } + + allowCloseAfterPendingFilePrompt = true; + Close(); } - else + finally { - File.WriteAllText(OpenedFilePath, fileText); + isShowingPendingFileClosePrompt = false; } } @@ -2014,6 +3525,13 @@ private void SelectAllMenuItem_Click(Object? sender = null, RoutedEventArgs? e = if (!IsLoaded) return; + if (editorMode == EtwEditorMode.Spreadsheet) + { + SpreadsheetDataGrid.SelectAllCells(); + SpreadsheetDataGrid.Focus(); + return; + } + PassedTextControl.SelectAll(); } @@ -2063,6 +3581,10 @@ private void SetFontFromSettings() { PassedTextControl.FontFamily = new FontFamily(DefaultSettings.FontFamilySetting); PassedTextControl.FontSize = DefaultSettings.FontSizeSetting; + MarkdownEditorControl.FontFamily = PassedTextControl.FontFamily; + MarkdownEditorControl.FontSize = PassedTextControl.FontSize; + SpreadsheetDataGrid.FontFamily = PassedTextControl.FontFamily; + SpreadsheetDataGrid.FontSize = PassedTextControl.FontSize; if (DefaultSettings.IsFontBold) PassedTextControl.FontWeight = FontWeights.Bold; if (DefaultSettings.IsFontItalic) @@ -2072,24 +3594,36 @@ private void SetFontFromSettings() if (DefaultSettings.IsFontUnderline) tdc.Add(TextDecorations.Underline); if (DefaultSettings.IsFontStrikeout) tdc.Add(TextDecorations.Strikethrough); PassedTextControl.TextDecorations = tdc; + + if (MarkdownEditorControl.Document is not null) + { + MarkdownEditorControl.Document.FontFamily = MarkdownEditorControl.FontFamily; + MarkdownEditorControl.Document.FontSize = MarkdownEditorControl.FontSize; + ApplyMarkdownTheme(); + } } private void SetMargins(bool AreThereMargins) { + Thickness padding = new(0); + double editorWidth = editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.ActualWidth > 0 + ? MarkdownEditorControl.ActualWidth + : PassedTextControl.ActualWidth; if (AreThereMargins) { - if (PassedTextControl.ActualWidth < 400) - PassedTextControl.Padding = new Thickness(10, 0, 10, 0); - else if (PassedTextControl.ActualWidth < 1000) - PassedTextControl.Padding = new Thickness(50, 0, 50, 0); - else if (PassedTextControl.ActualWidth < 1400) - PassedTextControl.Padding = new Thickness(100, 0, 100, 0); + if (editorWidth < 400) + padding = new Thickness(10, 0, 10, 0); + else if (editorWidth < 1000) + padding = new Thickness(50, 0, 50, 0); + else if (editorWidth < 1400) + padding = new Thickness(100, 0, 100, 0); else - PassedTextControl.Padding = new Thickness(160, 0, 160, 0); + padding = new Thickness(160, 0, 160, 0); } - else - PassedTextControl.Padding = new Thickness(0); + + PassedTextControl.Padding = padding; + MarkdownEditorControl.Padding = padding; } private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) @@ -2099,6 +3633,9 @@ private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) private void SetupRoutedCommands() { + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Undo, SpreadsheetUndoExecuted, SpreadsheetUndoCanExecute)); + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Redo, SpreadsheetRedoExecuted, SpreadsheetRedoCanExecute)); + RoutedCommand newFullscreenGrab = new(); _ = newFullscreenGrab.InputGestures.Add(new KeyGesture(Key.F, ModifierKeys.Control)); _ = CommandBindings.Add(new CommandBinding(newFullscreenGrab, KeyedCtrlF)); @@ -2378,6 +3915,62 @@ private void UnstackGroupExecuted(object? sender = null, ExecutedRoutedEventArgs private void UpdateLineAndColumnText() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + + int rowCount = spreadsheetTable.Rows.Count; + int columnCount = spreadsheetTable.Columns.Count; + + if (SpreadsheetDataGrid.SelectedCells.Count == 0) + { + if (SpreadsheetDataGrid.CurrentCell.Column is not null) + { + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column.DisplayIndex; + + BottomBarText.Text = currentRowIndex >= 0 + ? $"Rows {rowCount}, Cols {columnCount}, Row {currentRowIndex + 1}, Col {currentColumnIndex + 1}" + : $"Rows {rowCount}, Cols {columnCount}"; + } + else + { + BottomBarText.Text = $"Rows {rowCount}, Cols {columnCount}"; + } + + return; + } + + int selectedRowCount = SpreadsheetDataGrid.SelectedCells + .Select(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) + .Where(index => index >= 0) + .Distinct() + .Count(); + int selectedColumnCount = SpreadsheetDataGrid.SelectedCells + .Select(cell => cell.Column.DisplayIndex) + .Distinct() + .Count(); + + BottomBarText.Text = + $"Rows {rowCount}, Cols {columnCount}, Selected {SpreadsheetDataGrid.SelectedCells.Count} cells ({selectedRowCount} rows x {selectedColumnCount} cols)"; + return; + } + + if (editorMode == EtwEditorMode.Markdown) + { + HideSelectionSpecificUi(); + + string plainText = MarkdownEditorControl.Document is null + ? string.Empty + : MarkdownDocumentUtilities.GetDocumentPlainText(MarkdownEditorControl.Document); + string selectedText = MarkdownEditorControl.Selection.Text.TrimEnd('\r', '\n'); + + BottomBarText.Text = string.IsNullOrEmpty(selectedText) + ? $"Markdown, Chars {plainText.Length}" + : $"Markdown, Selected {selectedText.Length} chars"; + return; + } + char[] delimiters = [' ', '\r', '\n']; if (PassedTextControl.SelectionLength < 1) @@ -2430,6 +4023,12 @@ private void UpdateLineAndColumnText() private void UpdateSelectionSpecificUI() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + return; + } + string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) @@ -2733,11 +4332,25 @@ private void CharDetailsButton_Click(object sender, RoutedEventArgs e) private void Window_Activated(object sender, EventArgs e) { - PassedTextControl.Focus(); + if (editorMode == EtwEditorMode.Spreadsheet) + SpreadsheetDataGrid.Focus(); + else if (editorMode == EtwEditorMode.Markdown) + { + ApplyMarkdownTheme(); + MarkdownEditorControl.Focus(); + } + else + PassedTextControl.Focus(); } private void Window_Closed(object sender, EventArgs e) { + DetachSpreadsheetColumnWidthTracking(); + System.Windows.DataObject.RemovePastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); + + if (windowSource is not null) + windowSource.RemoveHook(EditTextWindowMessageHook); + string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -2774,23 +4387,58 @@ private void Window_Closed(object sender, EventArgs e) private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) { - if (string.IsNullOrEmpty(OpenedFilePath) - && !string.IsNullOrWhiteSpace(PassedTextControl.Text)) - Singleton.Instance.SaveToHistory(this); + SyncTextFromActiveEditor(); + UpdatePendingFileEditState(); + + if (allowCloseAfterPendingFilePrompt) + { + allowCloseAfterPendingFilePrompt = false; + SaveWindowTextToHistoryIfNeeded(); + return; + } + + if (isShowingPendingFileClosePrompt) + { + e.Cancel = true; + return; + } + + if (!hasPendingFileEdits) + { + SaveWindowTextToHistoryIfNeeded(); + return; + } + + e.Cancel = true; + isShowingPendingFileClosePrompt = true; + _ = HandlePendingFileClosePromptAsync(); } private void Window_Initialized(object sender, EventArgs e) { PassedTextControl.PreviewMouseWheel += HandlePreviewMouseWheel; + MarkdownEditorControl.PreviewMouseWheel += HandlePreviewMouseWheel; + MarkdownEditorControl.PreviewTextInput += MarkdownEditorControl_PreviewTextInput; + System.Windows.DataObject.AddPastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); SetFontFromSettings(); + UpdateSpreadsheetModeUi(); + UpdateWindowTitle(); } private void Window_Loaded(object sender, RoutedEventArgs e) { SetupRoutedCommands(); + if (windowSource is null) + { + nint windowHandle = new WindowInteropHelper(this).Handle; + windowSource = HwndSource.FromHwnd(windowHandle); + windowSource?.AddHook(EditTextWindowMessageHook); + } + PassedTextControl.ContextMenu = this.FindResource("ContextMenuResource") as ContextMenu; if (PassedTextControl.ContextMenu != null) numberOfContextMenuItems = PassedTextControl.ContextMenu.Items.Count; + MarkdownEditorControl.AddHandler(Hyperlink.RequestNavigateEvent, new RequestNavigateEventHandler(MarkdownEditorControl_RequestNavigate)); CheckRightToLeftLanguage(); @@ -2842,6 +4490,13 @@ private void Window_Loaded(object sender, RoutedEventArgs e) // Initialize selectedILanguage with the last used OCR language from settings // This ensures that when images are dropped or pasted, the correct language is used selectedILanguage = LanguageUtilities.GetOCRLanguage(); + + if (editorMode == EtwEditorMode.Spreadsheet) + SetEditorMode(EtwEditorMode.Spreadsheet); + else if (editorMode == EtwEditorMode.Markdown) + SetEditorMode(EtwEditorMode.Markdown); + else + UpdateSpreadsheetModeUi(); } private void HideCalcPaneContextItem_Click(object sender, RoutedEventArgs e) @@ -3301,6 +4956,8 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) else PassedTextControl.TextWrapping = TextWrapping.NoWrap; + ApplyMarkdownWrapSetting(); + DefaultSettings.EditWindowIsWordWrapOn = WrapTextMenuItem.IsChecked; } From 77ba7f2b1e2c336dd5e23a44a52993faf96842c2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:24:31 -0500 Subject: [PATCH 07/11] Update dependencies and add document file associations - Upgrade NuGet packages in Tests.csproj and Text-Grab.csproj, including Magick.NET, WindowsAppSDK, WPF-UI, and coverlet.collector - Add Markdig for Markdown support - Enhance app manifest to associate .csv, .tsv, .tab, .md, .markdown, and .txt files with "Open with Text Grab" verb - No functional code changes; updates focus on dependencies and file type integration --- Tests/Tests.csproj | 4 ++-- Text-Grab-Package/Package.appxmanifest | 16 ++++++++++++++++ Text-Grab/Text-Grab.csproj | 21 +++++++++++---------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 97ada3c4..a6f350f3 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index bccb2398..b3fe9a6f 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -80,6 +80,22 @@ + + + + .csv + .tsv + .tab + .md + .markdown + .txt + + + Open with Text Grab + + + + diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 8ebd0208..16f1fad4 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -54,18 +54,19 @@ - - - + + + + - - - - + + + + - - + + @@ -120,4 +121,4 @@ Settings.Designer.cs - \ No newline at end of file + From 46ab38fa4e3590dfec58c3c02317cc3d6cefbd3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:18:16 +0000 Subject: [PATCH 08/11] fix: apply PR review feedback - CodeSpan, table cell newlines, URI safety, indentation, file open, save sync Agent-Logs-Url: https://github.com/TheJoeFin/Text-Grab/sessions/63f3f784-4485-4547-a38c-4d37de922366 Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- .claude/settings.local.json | 5 +---- Text-Grab/Models/HistoryInfo.cs | 4 ++-- Text-Grab/Utilities/MarkdownDocumentUtilities.cs | 11 ++++++++--- Text-Grab/Views/EditTextWindow.xaml.cs | 3 +-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 80c27172..6aff92af 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,10 +18,7 @@ "Bash(curl -o bin/pdm https://app.produckmap.com/cli/pdm)", "Bash(chmod +x bin/pdm)", "Bash(bin/pdm ui-element:*)", - "Bash(bin/pdm *)", - "Bash(pdm *)", - "Bash(pdm api *)", - "Read(//c/Users/josep/.claude/skills/pdm/**)" + "Bash(pdm api *)" ], "deny": [] } diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 4f6acd26..e70120fb 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -44,9 +44,9 @@ public HistoryInfo() [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool UsedUiAutomation { get; set; } - public bool HasCalcPaneOpen { get; set; } = false; + public bool HasCalcPaneOpen { get; set; } = false; - public int CalcPaneWidth { get; set; } = 0; + public int CalcPaneWidth { get; set; } = 0; public EtwEditorMode EditorMode { get; set; } = EtwEditorMode.Text; diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs index d04152ed..b1d9405a 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -356,8 +356,12 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, case LinkInline linkInline when !linkInline.IsImage: Hyperlink hyperlink = new(); - if (!string.IsNullOrWhiteSpace(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url)) - hyperlink.NavigateUri = new Uri(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl()! : linkInline.Url!, UriKind.RelativeOrAbsolute); + string? linkUrl = linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url; + if (!string.IsNullOrWhiteSpace(linkUrl) && + Uri.TryCreate(linkUrl, UriKind.RelativeOrAbsolute, out Uri? navigateUri)) + { + hyperlink.NavigateUri = navigateUri; + } AppendInlineContainer(hyperlink.Inlines, linkInline, source); if (hyperlink.Inlines.FirstInline is null) @@ -512,7 +516,7 @@ private static string SerializeTableCell(WpfTableCell cell) string rawText = NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); return rawText .Replace("|", "\\|", StringComparison.Ordinal) - .Replace(Environment.NewLine, "
", StringComparison.Ordinal); + .Replace("\n", "
", StringComparison.Ordinal); } private static string SerializeInlines(InlineCollection inlines, bool preserveLiteralMarkdown) @@ -536,6 +540,7 @@ private static void WriteInline(StringBuilder builder, WpfInline inline, bool pr builder.Append(GetInlineRole(run) switch { MarkdownInlineRole.TaskListMarker => GetTaskListMarkerChecked(run) ? "[x]" : "[ ]", + MarkdownInlineRole.CodeSpan => $"`{NormalizeDocumentText(run.Text)}`", MarkdownInlineRole.LiteralMarkdown => run.Text, _ when preserveLiteralMarkdown => run.Text, _ => EscapeMarkdownText(run.Text) diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 4a439cd1..fae39f00 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1641,7 +1641,7 @@ internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = fal isLoadingOpenedFile = true; try { - PassedTextControl.AppendText(TextContent); + PassedTextControl.Text = TextContent; if (!IsLoaded) return; @@ -3361,7 +3361,6 @@ private void SaveAsBTN_Click(object sender, RoutedEventArgs e) private void SaveBTN_Click(object sender, RoutedEventArgs e) { - SyncTextFromActiveEditor(); _ = SaveCurrentDocument(); } From 216b1045ec0f52b31197334a10baac26c2b11f41 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 09:38:45 -0500 Subject: [PATCH 09/11] Expand EditTextWindow actions and add action catalog tests - Add new spreadsheet, translation, AI, and utility buttons to ButtonInfo.cs with appropriate handlers and icons - Update translation prompt to use local alphabet/characters - Add event handlers for new actions and editor mode toggles in EditTextWindow.xaml.cs - Refactor list initializations with C# collection expressions; add ToggleMenuItem helper - Add EditTextWindowActionCatalogTests to verify all ButtonInfo actions are valid and expected - Minor code cleanups and naming consistency improvements --- Tests/EditTextWindowActionCatalogTests.cs | 110 +++++++ Text-Grab/Models/ButtonInfo.cs | 382 +++++++++++++++++++++- Text-Grab/Utilities/WindowsAiUtilities.cs | 2 +- Text-Grab/Views/EditTextWindow.xaml.cs | 82 ++++- 4 files changed, 549 insertions(+), 27 deletions(-) create mode 100644 Tests/EditTextWindowActionCatalogTests.cs diff --git a/Tests/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs new file mode 100644 index 00000000..fface42f --- /dev/null +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowActionCatalogTests +{ + private readonly record struct ExpectedButtonAction(string ButtonText, string? Command = null, string? ClickEvent = null); + + [Fact] + public void AllButtons_UsesResolvableEditTextCommandsAndClickEvents() + { + HashSet commandNames = [.. EditTextWindow.GetRoutedCommands().Keys]; + HashSet methodNames = [.. typeof(EditTextWindow) + .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(method => method.Name)]; + + foreach (ButtonInfo button in ButtonInfo.AllButtons) + { + if (!string.IsNullOrWhiteSpace(button.Command)) + Assert.Contains(button.Command, commandNames); + + if (!string.IsNullOrWhiteSpace(button.ClickEvent)) + Assert.Contains(button.ClickEvent, methodNames); + } + } + + [Fact] + public void AllButtons_ContainsExpectedEditTextActions() + { + ExpectedButtonAction[] expectedButtons = + [ + new("OCR Paste", Command: "OcrPasteCommand"), + new("Write .txt File For Each Image", ClickEvent: "ToggleWriteTxtFileForEachImage_Click"), + new("Close", ClickEvent: "CloseMenuItem_Click"), + new("Correct Common GUID/UUID Errors", ClickEvent: "CorrectGuid_Click"), + new("Transpose Table", Command: "TransposeTableCmd"), + new("Add Spreadsheet Row", ClickEvent: "AddSpreadsheetRowMenuItem_Click"), + new("Add Spreadsheet Column", ClickEvent: "AddSpreadsheetColumnMenuItem_Click"), + new("Copy Selected Spreadsheet Cells", ClickEvent: "CopySpreadsheetSelectionMenuItem_Click"), + new("Copy Selected Spreadsheet Rows", ClickEvent: "CopySpreadsheetRowsMenuItem_Click"), + new("Copy Current Spreadsheet Column", ClickEvent: "CopySpreadsheetColumnMenuItem_Click"), + new("Move Spreadsheet Row Up", ClickEvent: "MoveSpreadsheetRowUpMenuItem_Click"), + new("Move Spreadsheet Row Down", ClickEvent: "MoveSpreadsheetRowDownMenuItem_Click"), + new("Delete Spreadsheet Row", ClickEvent: "DeleteSpreadsheetRowMenuItem_Click"), + new("Move Spreadsheet Column Left", ClickEvent: "MoveSpreadsheetColumnLeftMenuItem_Click"), + new("Move Spreadsheet Column Right", ClickEvent: "MoveSpreadsheetColumnRightMenuItem_Click"), + new("Delete Spreadsheet Column", ClickEvent: "DeleteSpreadsheetColumnMenuItem_Click"), + new("Enter Raw Text Mode", ClickEvent: "EnterRawTextMode_Click"), + new("Enter Spreadsheet Mode", ClickEvent: "EnterSpreadsheetMode_Click"), + new("Enter Markdown Mode", ClickEvent: "EnterMarkdownMode_Click"), + new("Toggle Show Math Errors", ClickEvent: "ToggleShowMathErrors_Click"), + new("Toggle Calculation Pane", ClickEvent: "CalcToggleButton_Click"), + new("Copy All Calculation Results", ClickEvent: "CalcCopyAllButton_Click"), + new("Calculation Pane Help", ClickEvent: "CalcInfoButton_Click"), + new("Toggle Always On Top", ClickEvent: "ToggleAlwaysOnTop_Click"), + new("Toggle Hide Bottom Bar", ClickEvent: "ToggleHideBottomBar_Click"), + new("Toggle Fullscreen on Launch", ClickEvent: "ToggleLaunchFullscreenOnLoad_Click"), + new("Toggle Restore Window Position", ClickEvent: "ToggleRestorePosition_Click"), + new("Restore This Window Position", ClickEvent: "RestoreThisPosition_Click"), + new("Toggle Margins", ClickEvent: "ToggleMargins_Click"), + new("Toggle Wrap Text", ClickEvent: "ToggleWrapText_Click"), + new("Font...", ClickEvent: "FontMenuItem_Click"), + new("Grab Previous Region", ClickEvent: "PreviousRegion_Click"), + new("Edit Last Grab", ClickEvent: "OpenLastAsGrabFrameMenuItem_Click"), + new("Contact Developer", ClickEvent: "ContactMenuItem_Click"), + new("Rate and Review", ClickEvent: "RateAndReview_Click"), + new("Feedback...", ClickEvent: "FeedbackMenuItem_Click"), + new("About", ClickEvent: "AboutMenuItem_Click"), + new("Select All", ClickEvent: "SelectAllMenuItem_Click"), + new("Select None", ClickEvent: "SelectNoneMenuItem_Click"), + new("Delete Selected Text", ClickEvent: "DeleteSelectedTextMenuItem_Click"), + new("Show Character Details", ClickEvent: "CharDetailsButton_Click"), + new("Find Similar Matches", ClickEvent: "SimilarMatchesButton_Click"), + new("Open Regex Pattern Search", ClickEvent: "RegexPatternButton_Click"), + new("Save Regex Pattern", ClickEvent: "SavePatternMenuItem_Click"), + new("Explain Regex Pattern", ClickEvent: "ExplainPatternMenuItem_Click"), + new("Summarize Paragraph", ClickEvent: "SummarizeMenuItem_Click"), + new("Rewrite with Local AI", ClickEvent: "RewriteMenuItem_Click"), + new("Convert to Table", ClickEvent: "ConvertTableMenuItem_Click"), + new("Translate to System Language", ClickEvent: "TranslateToSystemLanguageMenuItem_Click"), + new("Translate to English", ClickEvent: "TranslateToEnglish_Click"), + new("Translate to Spanish", ClickEvent: "TranslateToSpanish_Click"), + new("Translate to French", ClickEvent: "TranslateToFrench_Click"), + new("Translate to German", ClickEvent: "TranslateToGerman_Click"), + new("Translate to Italian", ClickEvent: "TranslateToItalian_Click"), + new("Translate to Portuguese", ClickEvent: "TranslateToPortuguese_Click"), + new("Translate to Russian", ClickEvent: "TranslateToRussian_Click"), + new("Translate to Japanese", ClickEvent: "TranslateToJapanese_Click"), + new("Translate to Chinese (Simplified)", ClickEvent: "TranslateToChineseSimplified_Click"), + new("Translate to Korean", ClickEvent: "TranslateToKorean_Click"), + new("Translate to Arabic", ClickEvent: "TranslateToArabic_Click"), + new("Translate to Hindi", ClickEvent: "TranslateToHindi_Click"), + new("Extract RegEx", ClickEvent: "ExtractRegexMenuItem_Click"), + new("Learn About Local AI Features...", ClickEvent: "LearnAiMenuItem_Click"), + ]; + + foreach (ExpectedButtonAction expected in expectedButtons) + { + ButtonInfo button = Assert.Single(ButtonInfo.AllButtons, button => button.ButtonText == expected.ButtonText); + + if (expected.Command is not null) + Assert.Equal(expected.Command, button.Command); + + if (expected.ClickEvent is not null) + Assert.Equal(expected.ClickEvent, button.ClickEvent); + } + } +} diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 442af591..dd32483b 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -300,7 +300,7 @@ public static List AllButtons OrderNumber = 2.3, ButtonText = "OCR Paste", SymbolText = "", - Command = "PasteCommand", + Command = "OcrPasteCommand", SymbolIcon = SymbolRegular.ClipboardImage24 }, new() @@ -490,9 +490,9 @@ public static List AllButtons new() { OrderNumber = 5.4, - ButtonText = "Extract Text from Images to txt Files...", + ButtonText = "Write .txt File For Each Image", SymbolText = "", - ClickEvent = "ReadFolderOfImagesWriteTxtFiles_Click", + ClickEvent = "ToggleWriteTxtFileForEachImage_Click", SymbolIcon = SymbolRegular.TabDesktopImage24 }, new() @@ -520,18 +520,382 @@ public static List AllButtons SymbolIcon = SymbolRegular.QrCode24 }, new() + { + OrderNumber = 6.1, + ButtonText = "Close", + ClickEvent = "CloseMenuItem_Click", + SymbolIcon = SymbolRegular.WindowAdOff20 + }, + new() + { + OrderNumber = 6.2, + ButtonText = "Correct Common GUID/UUID Errors", + ClickEvent = "CorrectGuid_Click", + SymbolIcon = SymbolRegular.TextWholeWord20 + }, + new() + { + OrderNumber = 6.3, + ButtonText = "Transpose Table", + Command = "TransposeTableCmd", + SymbolIcon = SymbolRegular.TableSwitch24 + }, + new() + { + OrderNumber = 6.4, + ButtonText = "Add Spreadsheet Row", + ClickEvent = "AddSpreadsheetRowMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.5, + ButtonText = "Add Spreadsheet Column", + ClickEvent = "AddSpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertColumn24 + }, + new() + { + OrderNumber = 6.6, + ButtonText = "Copy Selected Spreadsheet Cells", + ClickEvent = "CopySpreadsheetSelectionMenuItem_Click", + SymbolIcon = SymbolRegular.CopySelect20 + }, + new() + { + OrderNumber = 6.7, + ButtonText = "Copy Selected Spreadsheet Rows", + ClickEvent = "CopySpreadsheetRowsMenuItem_Click", + SymbolIcon = SymbolRegular.TableCopy20 + }, + new() + { + OrderNumber = 6.8, + ButtonText = "Copy Current Spreadsheet Column", + ClickEvent = "CopySpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.Column20 + }, + new() + { + OrderNumber = 6.9, + ButtonText = "Move Spreadsheet Row Up", + ClickEvent = "MoveSpreadsheetRowUpMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.91, + ButtonText = "Move Spreadsheet Row Down", + ClickEvent = "MoveSpreadsheetRowDownMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.92, + ButtonText = "Delete Spreadsheet Row", + ClickEvent = "DeleteSpreadsheetRowMenuItem_Click", + SymbolIcon = SymbolRegular.TableDeleteRow24 + }, + new() + { + OrderNumber = 6.93, + ButtonText = "Move Spreadsheet Column Left", + ClickEvent = "MoveSpreadsheetColumnLeftMenuItem_Click", + SymbolIcon = SymbolRegular.TableMoveLeft24 + }, + new() + { + OrderNumber = 6.94, + ButtonText = "Move Spreadsheet Column Right", + ClickEvent = "MoveSpreadsheetColumnRightMenuItem_Click", + SymbolIcon = SymbolRegular.TableMoveRight24 + }, + new() + { + OrderNumber = 6.95, + ButtonText = "Delete Spreadsheet Column", + ClickEvent = "DeleteSpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.TableDeleteColumn24 + }, + new() + { + OrderNumber = 6.96, + ButtonText = "Enter Raw Text Mode", + ClickEvent = "EnterRawTextMode_Click", + SymbolIcon = SymbolRegular.TextT24 + }, + new() + { + OrderNumber = 6.97, + ButtonText = "Enter Spreadsheet Mode", + ClickEvent = "EnterSpreadsheetMode_Click", + SymbolIcon = SymbolRegular.Table24 + }, + new() + { + OrderNumber = 6.98, + ButtonText = "Enter Markdown Mode", + ClickEvent = "EnterMarkdownMode_Click", + SymbolIcon = SymbolRegular.DocumentTextToolbox24 + }, + new() + { + OrderNumber = 7.1, + ButtonText = "Toggle Show Math Errors", + ClickEvent = "ToggleShowMathErrors_Click", + SymbolIcon = SymbolRegular.MathSymbols24 + }, + new() + { + OrderNumber = 7.11, + ButtonText = "Toggle Calculation Pane", + ClickEvent = "CalcToggleButton_Click", + SymbolIcon = SymbolRegular.Calculator24 + }, + new() + { + OrderNumber = 7.12, + ButtonText = "Copy All Calculation Results", + ClickEvent = "CalcCopyAllButton_Click", + SymbolIcon = SymbolRegular.CopyAdd24 + }, + new() + { + OrderNumber = 7.2, + ButtonText = "Toggle Always On Top", + ClickEvent = "ToggleAlwaysOnTop_Click", + SymbolIcon = SymbolRegular.WindowLocationTarget20 + }, + new() + { + OrderNumber = 7.21, + ButtonText = "Toggle Hide Bottom Bar", + ClickEvent = "ToggleHideBottomBar_Click", + SymbolIcon = SymbolRegular.PanelBottomContract20 + }, + new() + { + OrderNumber = 7.24, + ButtonText = "Restore This Window Position", + ClickEvent = "RestoreThisPosition_Click", + SymbolIcon = SymbolRegular.WindowWrench24 + }, + new() + { + OrderNumber = 7.25, + ButtonText = "Toggle Margins", + ClickEvent = "ToggleMargins_Click", + SymbolIcon = SymbolRegular.DocumentMargins24 + }, + new() + { + OrderNumber = 7.26, + ButtonText = "Toggle Wrap Text", + ClickEvent = "ToggleWrapText_Click", + SymbolIcon = SymbolRegular.TextWrap24 + }, + new() + { + OrderNumber = 7.27, + ButtonText = "Font...", + ClickEvent = "FontMenuItem_Click", + SymbolIcon = SymbolRegular.TextFont24 + }, + new() + { + OrderNumber = 7.3, + ButtonText = "Grab Previous Region", + ClickEvent = "PreviousRegion_Click", + SymbolIcon = SymbolRegular.WindowArrowUp24 + }, + new() + { + OrderNumber = 7.31, + ButtonText = "Edit Last Grab", + ClickEvent = "OpenLastAsGrabFrameMenuItem_Click", + SymbolIcon = SymbolRegular.ImageEdit24 + }, + new() + { + OrderNumber = 7.4, + ButtonText = "Select All", + ClickEvent = "SelectAllMenuItem_Click", + SymbolIcon = SymbolRegular.SelectAllOn24 + }, + new() + { + OrderNumber = 7.41, + ButtonText = "Select None", + ClickEvent = "SelectNoneMenuItem_Click", + SymbolIcon = SymbolRegular.TextClearFormatting24 + }, + new() + { + OrderNumber = 7.42, + ButtonText = "Delete Selected Text", + ClickEvent = "DeleteSelectedTextMenuItem_Click", + SymbolIcon = SymbolRegular.Delete24 + }, + new() + { + OrderNumber = 7.43, + ButtonText = "Show Character Details", + ClickEvent = "CharDetailsButton_Click", + SymbolIcon = SymbolRegular.TextFontInfo24 + }, + new() + { + OrderNumber = 7.44, + ButtonText = "Find Similar Matches", + ClickEvent = "SimilarMatchesButton_Click", + SymbolIcon = SymbolRegular.DocumentSearch24 + }, + new() + { + OrderNumber = 7.45, + ButtonText = "Open Regex Pattern Search", + ClickEvent = "RegexPatternButton_Click", + SymbolIcon = SymbolRegular.TextEffects24 + }, + new() + { + OrderNumber = 7.46, + ButtonText = "Save Regex Pattern", + ClickEvent = "SavePatternMenuItem_Click", + SymbolIcon = SymbolRegular.SaveCopy24 + }, + new() + { + OrderNumber = 8.1, + ButtonText = "Summarize Paragraph", + ClickEvent = "SummarizeMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24 + }, + new() + { + OrderNumber = 8.2, + ButtonText = "Rewrite with Local AI", + ClickEvent = "RewriteMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24 + }, + new() + { + OrderNumber = 8.3, + ButtonText = "Convert to Table", + ClickEvent = "ConvertTableMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24 + }, + new() + { + OrderNumber = 8.4, + ButtonText = "Translate to System Language", + ClickEvent = "TranslateToSystemLanguageMenuItem_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.41, + ButtonText = "Translate to English", + ClickEvent = "TranslateToEnglish_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.42, + ButtonText = "Translate to Spanish", + ClickEvent = "TranslateToSpanish_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.43, + ButtonText = "Translate to French", + ClickEvent = "TranslateToFrench_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.44, + ButtonText = "Translate to German", + ClickEvent = "TranslateToGerman_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.45, + ButtonText = "Translate to Italian", + ClickEvent = "TranslateToItalian_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.46, + ButtonText = "Translate to Portuguese", + ClickEvent = "TranslateToPortuguese_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.47, + ButtonText = "Translate to Russian", + ClickEvent = "TranslateToRussian_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.48, + ButtonText = "Translate to Japanese", + ClickEvent = "TranslateToJapanese_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.49, + ButtonText = "Translate to Chinese (Simplified)", + ClickEvent = "TranslateToChineseSimplified_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.5, + ButtonText = "Translate to Korean", + ClickEvent = "TranslateToKorean_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.51, + ButtonText = "Translate to Arabic", + ClickEvent = "TranslateToArabic_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.52, + ButtonText = "Translate to Hindi", + ClickEvent = "TranslateToHindi_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.6, + ButtonText = "Extract RegEx", + ClickEvent = "ExtractRegexMenuItem_Click", + SymbolIcon = SymbolRegular.TextWholeWord20 + }, + new() { ButtonText = "Edit Bottom Bar", ClickEvent = "EditBottomBarMenuItem_Click", - SymbolIcon = SymbolRegular.CalendarEdit24 + SymbolIcon = SymbolRegular.PanelBottom20 }, new() { - ButtonText = "Settings", - ClickEvent = "SettingsMenuItem_Click", - SymbolIcon = SymbolRegular.Settings24 - }, - ]; + ButtonText = "Settings", + ClickEvent = "SettingsMenuItem_Click", + SymbolIcon = SymbolRegular.Settings24 + }, + ]; return _allButtons; } diff --git a/Text-Grab/Utilities/WindowsAiUtilities.cs b/Text-Grab/Utilities/WindowsAiUtilities.cs index 4f386490..c8b2d79f 100644 --- a/Text-Grab/Utilities/WindowsAiUtilities.cs +++ b/Text-Grab/Utilities/WindowsAiUtilities.cs @@ -20,7 +20,7 @@ namespace Text_Grab.Utilities; public static class WindowsAiUtilities { - private const string TranslationPromptTemplate = "Translate to {0}:\n\n{1}"; + private const string TranslationPromptTemplate = "Translate to {0} using local alphabet and characters of that langauage:\n\n{1}"; private static LanguageModel? _translationLanguageModel; private static readonly SemaphoreSlim _modelInitializationLock = new(1, 1); private static bool _disposed; diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index fae39f00..a0f6dc6d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,7 +1,7 @@ using Humanizer; using System; -using System.ComponentModel; using System.Collections.Generic; +using System.ComponentModel; using System.Data; using System.Diagnostics; using System.Globalization; @@ -9,7 +9,6 @@ using System.Linq; using System.Net; using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -34,8 +33,6 @@ using Text_Grab.Utilities; using Text_Grab.Views; using Windows.ApplicationModel.DataTransfer; -using Windows.Globalization; -using Windows.Media.Ocr; using Windows.Storage; using Windows.Storage.Streams; using ContextMenu = System.Windows.Controls.ContextMenu; @@ -584,7 +581,7 @@ private void CopySpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) { - List selectedRows = SpreadsheetDataGrid.SelectedItems.OfType().ToList(); + List selectedRows = [.. SpreadsheetDataGrid.SelectedItems.OfType()]; if (selectedRows.Count == 0 && SpreadsheetDataGrid.CurrentItem is DataRowView currentRow) selectedRows.Add(currentRow); @@ -1195,13 +1192,12 @@ private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCel private void ClearSelectedSpreadsheetCellValues() { - List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = SpreadsheetDataGrid.SelectedCells + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells .Select(cell => ( RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), ColumnIndex: cell.Column?.DisplayIndex ?? -1)) .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) - .Distinct() - .ToList(); + .Distinct()]; if (selectedCellCoordinates.Count == 0) return; @@ -1327,22 +1323,51 @@ private void EditorModeMenuItem_Click(object sender, RoutedEventArgs e) SetEditorMode(EtwEditorMode.Markdown); } + private void ToggleMenuItem(MenuItem menuItem, RoutedEventHandler handler) + { + menuItem.IsChecked = !menuItem.IsChecked; + handler(menuItem, new RoutedEventArgs()); + } + + private void EnterRawTextMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Text); + + private void EnterSpreadsheetMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Spreadsheet); + + private void EnterMarkdownMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Markdown); + + private void ToggleAlwaysOnTop_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(AlwaysOnTop, AlwaysOnTop_Checked); + + private void ToggleHideBottomBar_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(HideBottomBarMenuItem, HideBottomBarMenuItem_Click); + + private void ToggleLaunchFullscreenOnLoad_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(LaunchFullscreenOnLoad, LaunchFullscreenOnLoad_Click); + + private void ToggleRestorePosition_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(RestorePositionMenuItem, RestorePositionMenuItem_Checked); + + private void ToggleMargins_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(MarginsMenuItem, MarginsMenuItem_Checked); + + private void ToggleWrapText_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(WrapTextMenuItem, WrapTextCHBX_Checked); + + private void ToggleShowMathErrors_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(ShowErrorsMenuItem, ShowErrorsMenuItem_Click); + + private void ToggleWriteTxtFileForEachImage_Click(object sender, RoutedEventArgs e) + { + ReadFolderOfImagesWriteTxtFiles.IsChecked = !ReadFolderOfImagesWriteTxtFiles.IsChecked; + } + private void SyncSpreadsheetDocumentFromTable(bool writeText = true) { tableDocument ??= EditTextTableDocument.CreateFromText(PassedTextControl.Text); - tableDocument.ColumnNames = spreadsheetTable.Columns + tableDocument.ColumnNames = [.. spreadsheetTable.Columns .Cast() - .Select(column => column.ColumnName) - .ToList(); + .Select(column => column.ColumnName)]; - tableDocument.Rows = spreadsheetTable.Rows + tableDocument.Rows = [.. spreadsheetTable.Rows .Cast() .Select(row => spreadsheetTable.Columns .Cast() .Select(column => row[column]?.ToString() ?? string.Empty) - .ToList()) - .ToList(); + .ToList())]; int furthestNonEmptyRowIndex = -1; int furthestNonEmptyColumnIndex = -1; @@ -2557,7 +2582,7 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) bool usingTesseract = DefaultSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe(); List availableLanguages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(usingTesseract); - availableLanguages = availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible).ToList(); + availableLanguages = [.. availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible)]; int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( availableLanguages, DefaultSettings.LastUsedLang, @@ -4347,8 +4372,7 @@ private void Window_Closed(object sender, EventArgs e) DetachSpreadsheetColumnWidthTracking(); System.Windows.DataObject.RemovePastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); - if (windowSource is not null) - windowSource.RemoveHook(EditTextWindowMessageHook); + windowSource?.RemoveHook(EditTextWindowMessageHook); string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -5058,6 +5082,30 @@ private async void TranslateToSystemLanguageMenuItem_Click(object sender, Routed await PerformTranslationAsync(systemLanguage); } + private async void TranslateToEnglish_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("English"); + + private async void TranslateToSpanish_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Spanish"); + + private async void TranslateToFrench_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("French"); + + private async void TranslateToGerman_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("German"); + + private async void TranslateToItalian_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Italian"); + + private async void TranslateToPortuguese_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Portuguese"); + + private async void TranslateToRussian_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Russian"); + + private async void TranslateToJapanese_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Japanese"); + + private async void TranslateToChineseSimplified_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Chinese (Simplified)"); + + private async void TranslateToKorean_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Korean"); + + private async void TranslateToArabic_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Arabic"); + + private async void TranslateToHindi_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Hindi"); + private async Task PerformTranslationAsync(string targetLanguage) { string textToTranslate = GetSelectedTextOrAllText(); From d65e500b14e5bec8768d8a07fd3cf2acd4e3fa1b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 16:11:01 -0500 Subject: [PATCH 10/11] Enhance spreadsheet editing and text transform support Refactored EditTextWindow to unify text and spreadsheet cell transformation logic, enabling all text operations (replace, case toggle, trim, AI transforms, etc.) to work on selected spreadsheet cells. Added helper methods for cell selection and value updates. Improved word selection for spreadsheet cells. Updated command handlers and context menus for spreadsheet awareness. Enhanced CursorWordBoundaries for edge cases and added related unit tests. Minor UI tweaks to spreadsheet context menu. --- Tests/EditTextWindowSpreadsheetTests.cs | 104 +++++ Tests/StringMethodTests.cs | 22 + Text-Grab/Utilities/StringMethods.cs | 20 +- Text-Grab/Views/EditTextWindow.xaml | 2 +- Text-Grab/Views/EditTextWindow.xaml.cs | 510 +++++++++++++++++------- 5 files changed, 509 insertions(+), 149 deletions(-) diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index ce9d1971..89eadb6b 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -1,5 +1,6 @@ using System.Data; using Text_Grab; +using Text_Grab.Models; namespace Tests; @@ -33,4 +34,107 @@ public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells() Assert.Equal("b2", dataTable.Rows[1][1]); Assert.Equal(string.Empty, dataTable.Rows[1][2]); } + + [Fact] + public void BuildSpreadsheetSelectionText_IncludesOnlySelectedCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", "b1", "c1"); + dataTable.Rows.Add("a2", "b2", "c2"); + + string selectionText = EditTextWindow.BuildSpreadsheetSelectionText( + dataTable, + [ + (1, 2), + (0, 1), + (1, 0), + (0, 1), + (-1, 0), + (5, 5) + ]); + + Assert.Equal("b1" + Environment.NewLine + "a2\tc2", selectionText); + } + + [Fact] + public void GetSelectedOrPopulatedSpreadsheetCellCoordinates_PrefersValidSelection() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", string.Empty, "c1"); + dataTable.Rows.Add("a2", "b2", string.Empty); + + List<(int RowIndex, int ColumnIndex)> coordinates = EditTextWindow.GetSelectedOrPopulatedSpreadsheetCellCoordinates( + dataTable, + [ + (0, 1), + (1, 2), + (1, 2), + (-1, 0), + (5, 5) + ]); + + Assert.Equal([(0, 1), (1, 2)], coordinates); + } + + [Fact] + public void GetSelectedOrPopulatedSpreadsheetCellCoordinates_FallsBackToPopulatedCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", " ", string.Empty); + dataTable.Rows.Add(string.Empty, "b2", "c2"); + + List<(int RowIndex, int ColumnIndex)> coordinates = EditTextWindow.GetSelectedOrPopulatedSpreadsheetCellCoordinates( + dataTable, + [ + (-1, 0), + (10, 10) + ]); + + Assert.Equal([(0, 0), (1, 1), (1, 2)], coordinates); + } + + [Fact] + public void TransformSpreadsheetDocumentCellValues_TransformsOnlyRequestedCells() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + + EditTextWindow.TransformSpreadsheetDocumentCellValues( + document, + [ + (0, 0), + (1, 2), + (1, 2), + (-1, 0), + (5, 5) + ], + value => $"[{value}]"); + + Assert.Equal("[a1]\tb1\tc1\r\na2\tb2\t[c2]", document.SerializeToText()); + } + + [Fact] + public void SetSpreadsheetDocumentCellValues_SetsOnlyRequestedCells() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + + EditTextWindow.SetSpreadsheetDocumentCellValues( + document, + [ + (0, 1, "B!"), + (1, 0, "A!"), + (1, 0, "A!"), + (8, 1, "ignored") + ]); + + Assert.Equal("a1\tB!\tc1\r\nA!\tb2\tc2", document.SerializeToText()); + } } diff --git a/Tests/StringMethodTests.cs b/Tests/StringMethodTests.cs index a0412b95..6fb121d1 100644 --- a/Tests/StringMethodTests.cs +++ b/Tests/StringMethodTests.cs @@ -35,6 +35,28 @@ public void ReturnWordAtCursorPositionSix(string expectedWord, string fullLine) Assert.Equal(expectedWord, singleWordAtSix); } + [Theory] + [InlineData("there", "hello there", 11)] + [InlineData("world", "hello world", 10)] + [InlineData("Alpha", "Alpha", 5)] + [InlineData("hello", " hello", 0)] + public void CursorWordBoundaries_ClampsEndOfTextToNearestWord(string expectedWord, string input, int cursorPosition) + { + (int start, int length) = input.CursorWordBoundaries(cursorPosition); + + Assert.Equal(expectedWord, input.Substring(start, length)); + } + + [Fact] + public void CursorWordBoundaries_AllWhitespace_ReturnsEmptyRange() + { + const string input = " "; + + (int start, int length) = input.CursorWordBoundaries(1); + + Assert.Equal(string.Empty, input.Substring(start, length)); + } + private static string multiLineInput = @"Hello this is lots of text which has several lines and some spaces at the ends of line diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab/Utilities/StringMethods.cs index a026c8eb..f349808a 100644 --- a/Text-Grab/Utilities/StringMethods.cs +++ b/Text-Grab/Utilities/StringMethods.cs @@ -119,17 +119,7 @@ public static (int, int) CursorWordBoundaries(this string input, int cursorPosit if (string.IsNullOrEmpty(input)) return (0, 0); - if (cursorPosition < 0) - cursorPosition = 0; - - try - { - char check = input[cursorPosition]; - } - catch (IndexOutOfRangeException) - { - return (cursorPosition, 0); - } + cursorPosition = Math.Clamp(cursorPosition, 0, input.Length - 1); // Check if the cursor is at a space if (char.IsWhiteSpace(input[cursorPosition])) @@ -165,7 +155,7 @@ public static string GetWordAtCursorPosition(this string input, int cursorPositi private static int FindNearestLetterIndex(string input, int cursorPosition) { - Math.Clamp(cursorPosition, 0, input.Length - 1); + cursorPosition = Math.Clamp(cursorPosition, 0, input.Length - 1); int lastCharIndex = input.Length - 1; @@ -183,6 +173,12 @@ private static int FindNearestLetterIndex(string input, int cursorPosition) && nearestToTheRight > lastCharIndex) return cursorPosition; + if (nearestToTheLeft < 0) + return nearestToTheRight; + + if (nearestToTheRight > lastCharIndex) + return nearestToTheLeft; + int leftDistance = cursorPosition - nearestToTheLeft; int rightDistance = nearestToTheRight - cursorPosition; diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 4027e9f4..73352783 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -83,7 +83,7 @@ - + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index a0f6dc6d..f078286d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -99,6 +99,7 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private readonly SpreadsheetUndoHistory spreadsheetUndoHistory = new(); private readonly DataTable spreadsheetTable = new(); private readonly List trackedSpreadsheetColumns = []; + private List<(int RowIndex, int ColumnIndex)> selectedSpreadsheetCellCoordinates = []; private EtwEditorMode editorMode = EtwEditorMode.Text; private bool isSyncingTextFromSpreadsheet = false; private bool isSyncingTextFromMarkdown = false; @@ -598,18 +599,13 @@ private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) private void CopySpreadsheetSelectionMenuItem_Click(object sender, RoutedEventArgs e) { - if (SpreadsheetDataGrid.SelectedCells.Count == 0) + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + if (selectedCellCoordinates.Count == 0) return; - string selectionText = string.Join( - Environment.NewLine, - SpreadsheetDataGrid.SelectedCells - .GroupBy(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) - .OrderBy(group => group.Key) - .Select(group => string.Join( - "\t", - group.OrderBy(cell => cell.Column.DisplayIndex) - .Select(cell => ((cell.Item as DataRowView)?.Row[cell.Column.DisplayIndex] ?? string.Empty).ToString())))); + string selectionText = BuildSpreadsheetSelectionText(spreadsheetTable, selectedCellCoordinates); + if (string.IsNullOrEmpty(selectionText)) + return; TrySetClipboardText(selectionText); } @@ -724,6 +720,7 @@ private void FocusSpreadsheetCell(int rowIndex, int columnIndex, bool beginEdit SpreadsheetDataGrid.SelectedCells.Clear(); SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); if (beginEdit) @@ -818,6 +815,7 @@ private void RebuildSpreadsheetTable() spreadsheetTable.EndInit(); SpreadsheetDataGrid.ItemsSource = spreadsheetTable.DefaultView; + selectedSpreadsheetCellCoordinates = []; SpreadsheetDataGrid.Columns.Clear(); for (int columnIndex = 0; columnIndex < spreadsheetTable.Columns.Count; columnIndex++) @@ -1186,18 +1184,15 @@ private void SpreadsheetDataGrid_PreviewMouseRightButtonDown(object sender, Mous private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) { + UpdateSelectedSpreadsheetCellCoordinates(); + if (editorMode == EtwEditorMode.Spreadsheet) UpdateLineAndColumnText(); } private void ClearSelectedSpreadsheetCellValues() { - List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells - .Select(cell => ( - RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), - ColumnIndex: cell.Column?.DisplayIndex ?? -1)) - .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) - .Distinct()]; + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); if (selectedCellCoordinates.Count == 0) return; @@ -1230,6 +1225,216 @@ internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable } } + internal static string BuildSpreadsheetSelectionText( + DataTable dataTable, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + List<(int RowIndex, int ColumnIndex)> validCoordinates = [.. cellCoordinates + .Distinct() + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < dataTable.Rows.Count + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < dataTable.Columns.Count)]; + + if (validCoordinates.Count == 0) + return string.Empty; + + return string.Join( + Environment.NewLine, + validCoordinates + .GroupBy(cell => cell.RowIndex) + .OrderBy(group => group.Key) + .Select(group => string.Join( + "\t", + group.OrderBy(cell => cell.ColumnIndex) + .Select(cell => dataTable.Rows[cell.RowIndex][cell.ColumnIndex]?.ToString() ?? string.Empty)))); + } + + internal static List<(int RowIndex, int ColumnIndex)> GetSelectedOrPopulatedSpreadsheetCellCoordinates( + DataTable dataTable, + IEnumerable<(int RowIndex, int ColumnIndex)> selectedCellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(selectedCellCoordinates); + + List<(int RowIndex, int ColumnIndex)> validSelectedCoordinates = [.. selectedCellCoordinates + .Distinct() + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < dataTable.Rows.Count + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < dataTable.Columns.Count)]; + + if (validSelectedCoordinates.Count > 0) + return validSelectedCoordinates; + + List<(int RowIndex, int ColumnIndex)> populatedCoordinates = []; + + for (int rowIndex = 0; rowIndex < dataTable.Rows.Count; rowIndex++) + { + for (int columnIndex = 0; columnIndex < dataTable.Columns.Count; columnIndex++) + { + string cellValue = dataTable.Rows[rowIndex][columnIndex]?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(cellValue)) + populatedCoordinates.Add((rowIndex, columnIndex)); + } + } + + return populatedCoordinates; + } + + internal static void TransformSpreadsheetDocumentCellValues( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates, + Func transform) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellCoordinates); + ArgumentNullException.ThrowIfNull(transform); + + document.EnsureMinimumSize(); + + foreach ((int rowIndex, int columnIndex) in cellCoordinates.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= document.Rows.Count + || columnIndex < 0 + || document.Rows[rowIndex] is null + || columnIndex >= document.Rows[rowIndex].Count) + { + continue; + } + + string updatedValue = transform(document.Rows[rowIndex][columnIndex] ?? string.Empty); + ArgumentNullException.ThrowIfNull(updatedValue); + document.Rows[rowIndex][columnIndex] = updatedValue; + } + } + + internal static void SetSpreadsheetDocumentCellValues( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex, string Value)> cellValues) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellValues); + + document.EnsureMinimumSize(); + + foreach ((int rowIndex, int columnIndex, string value) in cellValues.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= document.Rows.Count + || columnIndex < 0 + || document.Rows[rowIndex] is null + || columnIndex >= document.Rows[rowIndex].Count) + { + continue; + } + + document.Rows[rowIndex][columnIndex] = value ?? string.Empty; + } + } + + private void UpdateSelectedSpreadsheetCellCoordinates() + { + selectedSpreadsheetCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells + .Select(cell => ( + RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), + ColumnIndex: cell.Column?.DisplayIndex ?? -1)) + .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) + .Distinct()]; + } + + private List<(int RowIndex, int ColumnIndex)> GetSelectedSpreadsheetCellCoordinates() + { + return [.. selectedSpreadsheetCellCoordinates]; + } + + private List<(int RowIndex, int ColumnIndex)> GetSelectedOrPopulatedSpreadsheetCellCoordinates() + { + return GetSelectedOrPopulatedSpreadsheetCellCoordinates(spreadsheetTable, GetSelectedSpreadsheetCellCoordinates()); + } + + private IEnumerable GetSelectedOrPopulatedSpreadsheetCellTexts() + { + foreach ((int rowIndex, int columnIndex) in GetSelectedOrPopulatedSpreadsheetCellCoordinates()) + yield return spreadsheetTable.Rows[rowIndex][columnIndex]?.ToString() ?? string.Empty; + } + + private bool TryApplySpreadsheetTextTransform(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + EnsureSpreadsheetDocumentFromText(); + + if (tableDocument is null) + return true; + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrPopulatedSpreadsheetCellCoordinates(); + if (targetCells.Count == 0) + return true; + + int focusRow = Math.Max(0, SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int focusColumn = Math.Max(0, SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + + ApplySpreadsheetDocumentChange( + document => TransformSpreadsheetDocumentCellValues(document, targetCells, transform), + focusRow, + focusColumn); + UpdateLineAndColumnText(); + return true; + } + + private async Task TryApplySpreadsheetTextTransformAsync(Func> transformAsync) + { + ArgumentNullException.ThrowIfNull(transformAsync); + + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + EnsureSpreadsheetDocumentFromText(); + + if (tableDocument is null) + return true; + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrPopulatedSpreadsheetCellCoordinates(); + if (targetCells.Count == 0) + return true; + + int focusRow = Math.Max(0, SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int focusColumn = Math.Max(0, SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + List<(int RowIndex, int ColumnIndex, string Value)> transformedCells = []; + + foreach ((int rowIndex, int columnIndex) in targetCells) + { + if (rowIndex < 0 + || rowIndex >= tableDocument.Rows.Count + || columnIndex < 0 + || columnIndex >= tableDocument.Rows[rowIndex].Count) + { + continue; + } + + string updatedValue = await transformAsync(tableDocument.Rows[rowIndex][columnIndex] ?? string.Empty); + ArgumentNullException.ThrowIfNull(updatedValue); + transformedCells.Add((rowIndex, columnIndex, updatedValue)); + } + + ApplySpreadsheetDocumentChange( + document => SetSpreadsheetDocumentCellValues(document, transformedCells), + focusRow, + focusColumn); + UpdateLineAndColumnText(); + return true; + } + private void SpreadsheetUndoCanExecute(object sender, CanExecuteRoutedEventArgs e) { if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) @@ -1503,6 +1708,7 @@ private void SelectSpreadsheetColumn(int columnIndex) SpreadsheetDataGrid.ScrollIntoView(firstRowItem, column); } + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); UpdateLineAndColumnText(); } @@ -1521,6 +1727,7 @@ private void SelectSpreadsheetRow(object rowItem) SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, firstColumn); SpreadsheetDataGrid.ScrollIntoView(rowItem, firstColumn); + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); UpdateLineAndColumnText(); } @@ -2153,6 +2360,46 @@ public string GetSelectedTextOrAllText() return textToModify; } + private IEnumerable GetSelectedOrAllTextSegmentsForEdit() + { + if (editorMode == EtwEditorMode.Spreadsheet) + return GetSelectedOrPopulatedSpreadsheetCellTexts(); + + return [GetSelectedTextOrAllText()]; + } + + private void ReplaceSelectedTextOrAllText(string updatedText) + { + ArgumentNullException.ThrowIfNull(updatedText); + + if (PassedTextControl.SelectionLength == 0) + PassedTextControl.Text = updatedText; + else + PassedTextControl.SelectedText = updatedText; + } + + private void ApplySelectedTextOrAllTextTransform(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + if (TryApplySpreadsheetTextTransform(transform)) + return; + + string updatedText = transform(GetSelectedTextOrAllText()); + ReplaceSelectedTextOrAllText(updatedText); + } + + private async Task ApplySelectedTextOrAllTextTransformAsync(Func> transformAsync) + { + ArgumentNullException.ThrowIfNull(transformAsync); + + if (await TryApplySpreadsheetTextTransformAsync(transformAsync)) + return; + + string updatedText = await transformAsync(GetSelectedTextOrAllText()); + ReplaceSelectedTextOrAllText(updatedText); + } + private void GrabFrameMenuItem_Click(object sender, RoutedEventArgs e) { CheckForGrabFrameOrLaunch(); @@ -3289,37 +3536,13 @@ private void RemoveDuplicateLines_Click(object sender, RoutedEventArgs e) private void ReplaceReservedCharsCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - bool containsAnyReservedChars = false; - - if (PassedTextControl.SelectionLength > 0) - { - foreach (char reservedChar in StringMethods.ReservedChars) - { - if (PassedTextControl.SelectedText.Contains(reservedChar)) - containsAnyReservedChars = true; - } - } - else - { - foreach (char reservedChar in StringMethods.ReservedChars) - { - if (PassedTextControl.Text.Contains(reservedChar)) - containsAnyReservedChars = true; - } - } - - if (containsAnyReservedChars) - e.CanExecute = true; - else - e.CanExecute = false; + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => StringMethods.ReservedChars.Any(text.Contains)); } private void ReplaceReservedCharsCmdExecuted(object sender, ExecutedRoutedEventArgs e) { - if (PassedTextControl.SelectionLength > 0) - PassedTextControl.SelectedText = PassedTextControl.SelectedText.ReplaceReservedCharacters(); - else - PassedTextControl.Text = PassedTextControl.Text.ReplaceReservedCharacters(); + ApplySelectedTextOrAllTextTransform(text => text.ReplaceReservedCharacters()); } private void RestorePositionMenuItem_Checked(object sender, RoutedEventArgs e) @@ -3591,11 +3814,69 @@ private void SelectNoneMenuItem_Click(Object? sender = null, RoutedEventArgs? e private void SelectWord(object? sender = null, ExecutedRoutedEventArgs? e = null) { + if (TrySelectSpreadsheetWord()) + return; + (int wordStart, int wordLength) = PassedTextControl.Text.CursorWordBoundaries(PassedTextControl.CaretIndex); PassedTextControl.Select(wordStart, wordLength); } + private bool TrySelectSpreadsheetWord() + { + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + if (TryGetFocusedSpreadsheetCellEditor(out System.Windows.Controls.TextBox? focusedEditor)) + { + (int editorWordStart, int editorWordLength) = focusedEditor.Text.CursorWordBoundaries(focusedEditor.CaretIndex); + focusedEditor.Select(editorWordStart, editorWordLength); + return true; + } + + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int? columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex; + if (rowIndex < 0 + || columnIndex is null + || rowIndex >= spreadsheetTable.Rows.Count + || columnIndex.Value < 0 + || columnIndex.Value >= spreadsheetTable.Columns.Count) + { + return true; + } + + string cellText = spreadsheetTable.Rows[rowIndex][columnIndex.Value]?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(cellText)) + return true; + + (int wordStart, int wordLength) = cellText.CursorWordBoundaries(0); + FocusSpreadsheetCell(rowIndex, columnIndex.Value); + + Dispatcher.BeginInvoke( + () => + { + if (TryGetFocusedSpreadsheetCellEditor(out System.Windows.Controls.TextBox? editor)) + editor.Select(wordStart, wordLength); + }, + DispatcherPriority.Background); + + return true; + } + + private bool TryGetFocusedSpreadsheetCellEditor([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out System.Windows.Controls.TextBox? editor) + { + editor = null; + + if (Keyboard.FocusedElement is not DependencyObject focusedElement) + return false; + + if (FindVisualParent(focusedElement) is null) + return false; + + editor = FindVisualParent(focusedElement); + return editor is not null; + } + private void SelectWordMenuItem_Click(object sender, RoutedEventArgs e) { SelectWord(); @@ -3761,25 +4042,15 @@ private void SetupRoutedCommands() private void SingleLineCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - string textToOperateOn = GetSelectedTextOrAllText(); - - if (textToOperateOn.Contains(Environment.NewLine) - || textToOperateOn.Contains('\r') - || textToOperateOn.Contains('\n')) - { - e.CanExecute = true; - return; - } - - e.CanExecute = false; + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => text.Contains(Environment.NewLine) + || text.Contains('\r') + || text.Contains('\n')); } private void SingleLineCmdExecuted(object sender, ExecutedRoutedEventArgs? e = null) { - if (PassedTextControl.SelectedText.Length > 0) - PassedTextControl.SelectedText = PassedTextControl.SelectedText.MakeStringSingleLine(); - else - PassedTextControl.Text = PassedTextControl.Text.MakeStringSingleLine(); + ApplySelectedTextOrAllTextTransform(text => text.MakeStringSingleLine()); } private void SplitOnSelectionCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) @@ -3836,6 +4107,23 @@ private async void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedE private void ToggleCase(object? sender = null, ExecutedRoutedEventArgs? e = null) { + if (editorMode == EtwEditorMode.Spreadsheet) + { + CaseStatusOfToggle = CurrentCase.Unknown; + ApplySelectedTextOrAllTextTransform(text => + { + CurrentCase caseStatus = StringMethods.DetermineToggleCase(text); + return caseStatus switch + { + CurrentCase.Lower => selectedCultureInfo.TextInfo.ToLower(text), + CurrentCase.Camel => selectedCultureInfo.TextInfo.ToTitleCase(text), + CurrentCase.Upper => selectedCultureInfo.TextInfo.ToUpper(text), + _ => text, + }; + }); + return; + } + string textToModify = GetSelectedTextOrAllText(); if (CaseStatusOfToggle == CurrentCase.Unknown) @@ -3869,54 +4157,43 @@ private void ToggleCase(object? sender = null, ExecutedRoutedEventArgs? e = null private void ToggleCaseCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - bool containsLetters = false; - string text = GetSelectedTextOrAllText(); - - foreach (char letter in text) - if (char.IsLetter(letter)) - containsLetters = true; - - if (containsLetters) - e.CanExecute = true; - else - e.CanExecute = false; + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => text.Any(char.IsLetter)); } private void TrimEachLineMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = PassedTextControl.Text; - string[] stringSplit = workingString.Split(Environment.NewLine); + static string TrimEachLine(string workingString) + { + string[] stringSplit = workingString.Split(Environment.NewLine); + string finalString = ""; + + foreach (string line in stringSplit) + { + if (!string.IsNullOrWhiteSpace(line)) + finalString += line.Trim() + Environment.NewLine; + } + + return finalString; + } - string finalString = ""; - foreach (string line in stringSplit) - if (!string.IsNullOrWhiteSpace(line)) - finalString += line.Trim() + Environment.NewLine; + if (editorMode == EtwEditorMode.Spreadsheet) + { + TryApplySpreadsheetTextTransform(TrimEachLine); + return; + } - PassedTextControl.Text = finalString; + PassedTextControl.Text = TrimEachLine(PassedTextControl.Text); } private void TryToAlphaMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.TryFixToLetters(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.TryFixToLetters()); } private void TryToNumberMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.TryFixToNumbers(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.TryFixToNumbers()); } private void UnstackExecuted(object? sender = null, ExecutedRoutedEventArgs? e = null) @@ -4986,30 +5263,16 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) private void CorrectGuid_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.CorrectCommonGuidErrors(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.CorrectCommonGuidErrors()); } private async void SummarizeMenuItem_Click(object sender, RoutedEventArgs e) { - string textToSummarize = GetSelectedTextOrAllText(); - SetToLoading("Summarizing..."); try { - string summarizedText = await WindowsAiUtilities.SummarizeParagraph(textToSummarize); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.SummarizeParagraph(text)); } finally { @@ -5028,17 +5291,10 @@ private void LearnAiMenuItem_Click(object sender, RoutedEventArgs e) private async void RewriteMenuItem_Click(object sender, RoutedEventArgs e) { - string textToRewrite = GetSelectedTextOrAllText(); - SetToLoading("Rewriting..."); try { - string summarizedText = await WindowsAiUtilities.Rewrite(textToRewrite); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.Rewrite(text)); } finally { @@ -5048,18 +5304,11 @@ private async void RewriteMenuItem_Click(object sender, RoutedEventArgs e) private async void ConvertTableMenuItem_Click(object sender, RoutedEventArgs e) { - string textToTable = GetSelectedTextOrAllText(); - SetToLoading("Converting..."); try { - string summarizedText = await WindowsAiUtilities.TextToTable(textToTable); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.TextToTable(text)); } finally { @@ -5108,22 +5357,11 @@ private async void TranslateToSystemLanguageMenuItem_Click(object sender, Routed private async Task PerformTranslationAsync(string targetLanguage) { - string textToTranslate = GetSelectedTextOrAllText(); - SetToLoading($"Translating to {targetLanguage}..."); try { - string translatedText = await WindowsAiUtilities.TranslateText(textToTranslate, targetLanguage); - - if (PassedTextControl.SelectionLength == 0) - { - PassedTextControl.Text = translatedText; - } - else - { - PassedTextControl.SelectedText = translatedText; - } + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.TranslateText(text, targetLanguage)); } catch (Exception ex) { From 42ecbd5cfb07314c767248e1c379c8365f810332 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 16:22:19 -0500 Subject: [PATCH 11/11] Remove pointless test checking values in AllButtons collection --- Tests/EditTextWindowActionCatalogTests.cs | 82 ----------------------- 1 file changed, 82 deletions(-) diff --git a/Tests/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs index fface42f..b94fb194 100644 --- a/Tests/EditTextWindowActionCatalogTests.cs +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -25,86 +25,4 @@ public void AllButtons_UsesResolvableEditTextCommandsAndClickEvents() Assert.Contains(button.ClickEvent, methodNames); } } - - [Fact] - public void AllButtons_ContainsExpectedEditTextActions() - { - ExpectedButtonAction[] expectedButtons = - [ - new("OCR Paste", Command: "OcrPasteCommand"), - new("Write .txt File For Each Image", ClickEvent: "ToggleWriteTxtFileForEachImage_Click"), - new("Close", ClickEvent: "CloseMenuItem_Click"), - new("Correct Common GUID/UUID Errors", ClickEvent: "CorrectGuid_Click"), - new("Transpose Table", Command: "TransposeTableCmd"), - new("Add Spreadsheet Row", ClickEvent: "AddSpreadsheetRowMenuItem_Click"), - new("Add Spreadsheet Column", ClickEvent: "AddSpreadsheetColumnMenuItem_Click"), - new("Copy Selected Spreadsheet Cells", ClickEvent: "CopySpreadsheetSelectionMenuItem_Click"), - new("Copy Selected Spreadsheet Rows", ClickEvent: "CopySpreadsheetRowsMenuItem_Click"), - new("Copy Current Spreadsheet Column", ClickEvent: "CopySpreadsheetColumnMenuItem_Click"), - new("Move Spreadsheet Row Up", ClickEvent: "MoveSpreadsheetRowUpMenuItem_Click"), - new("Move Spreadsheet Row Down", ClickEvent: "MoveSpreadsheetRowDownMenuItem_Click"), - new("Delete Spreadsheet Row", ClickEvent: "DeleteSpreadsheetRowMenuItem_Click"), - new("Move Spreadsheet Column Left", ClickEvent: "MoveSpreadsheetColumnLeftMenuItem_Click"), - new("Move Spreadsheet Column Right", ClickEvent: "MoveSpreadsheetColumnRightMenuItem_Click"), - new("Delete Spreadsheet Column", ClickEvent: "DeleteSpreadsheetColumnMenuItem_Click"), - new("Enter Raw Text Mode", ClickEvent: "EnterRawTextMode_Click"), - new("Enter Spreadsheet Mode", ClickEvent: "EnterSpreadsheetMode_Click"), - new("Enter Markdown Mode", ClickEvent: "EnterMarkdownMode_Click"), - new("Toggle Show Math Errors", ClickEvent: "ToggleShowMathErrors_Click"), - new("Toggle Calculation Pane", ClickEvent: "CalcToggleButton_Click"), - new("Copy All Calculation Results", ClickEvent: "CalcCopyAllButton_Click"), - new("Calculation Pane Help", ClickEvent: "CalcInfoButton_Click"), - new("Toggle Always On Top", ClickEvent: "ToggleAlwaysOnTop_Click"), - new("Toggle Hide Bottom Bar", ClickEvent: "ToggleHideBottomBar_Click"), - new("Toggle Fullscreen on Launch", ClickEvent: "ToggleLaunchFullscreenOnLoad_Click"), - new("Toggle Restore Window Position", ClickEvent: "ToggleRestorePosition_Click"), - new("Restore This Window Position", ClickEvent: "RestoreThisPosition_Click"), - new("Toggle Margins", ClickEvent: "ToggleMargins_Click"), - new("Toggle Wrap Text", ClickEvent: "ToggleWrapText_Click"), - new("Font...", ClickEvent: "FontMenuItem_Click"), - new("Grab Previous Region", ClickEvent: "PreviousRegion_Click"), - new("Edit Last Grab", ClickEvent: "OpenLastAsGrabFrameMenuItem_Click"), - new("Contact Developer", ClickEvent: "ContactMenuItem_Click"), - new("Rate and Review", ClickEvent: "RateAndReview_Click"), - new("Feedback...", ClickEvent: "FeedbackMenuItem_Click"), - new("About", ClickEvent: "AboutMenuItem_Click"), - new("Select All", ClickEvent: "SelectAllMenuItem_Click"), - new("Select None", ClickEvent: "SelectNoneMenuItem_Click"), - new("Delete Selected Text", ClickEvent: "DeleteSelectedTextMenuItem_Click"), - new("Show Character Details", ClickEvent: "CharDetailsButton_Click"), - new("Find Similar Matches", ClickEvent: "SimilarMatchesButton_Click"), - new("Open Regex Pattern Search", ClickEvent: "RegexPatternButton_Click"), - new("Save Regex Pattern", ClickEvent: "SavePatternMenuItem_Click"), - new("Explain Regex Pattern", ClickEvent: "ExplainPatternMenuItem_Click"), - new("Summarize Paragraph", ClickEvent: "SummarizeMenuItem_Click"), - new("Rewrite with Local AI", ClickEvent: "RewriteMenuItem_Click"), - new("Convert to Table", ClickEvent: "ConvertTableMenuItem_Click"), - new("Translate to System Language", ClickEvent: "TranslateToSystemLanguageMenuItem_Click"), - new("Translate to English", ClickEvent: "TranslateToEnglish_Click"), - new("Translate to Spanish", ClickEvent: "TranslateToSpanish_Click"), - new("Translate to French", ClickEvent: "TranslateToFrench_Click"), - new("Translate to German", ClickEvent: "TranslateToGerman_Click"), - new("Translate to Italian", ClickEvent: "TranslateToItalian_Click"), - new("Translate to Portuguese", ClickEvent: "TranslateToPortuguese_Click"), - new("Translate to Russian", ClickEvent: "TranslateToRussian_Click"), - new("Translate to Japanese", ClickEvent: "TranslateToJapanese_Click"), - new("Translate to Chinese (Simplified)", ClickEvent: "TranslateToChineseSimplified_Click"), - new("Translate to Korean", ClickEvent: "TranslateToKorean_Click"), - new("Translate to Arabic", ClickEvent: "TranslateToArabic_Click"), - new("Translate to Hindi", ClickEvent: "TranslateToHindi_Click"), - new("Extract RegEx", ClickEvent: "ExtractRegexMenuItem_Click"), - new("Learn About Local AI Features...", ClickEvent: "LearnAiMenuItem_Click"), - ]; - - foreach (ExpectedButtonAction expected in expectedButtons) - { - ButtonInfo button = Assert.Single(ButtonInfo.AllButtons, button => button.ButtonText == expected.ButtonText); - - if (expected.Command is not null) - Assert.Equal(expected.Command, button.Command); - - if (expected.ClickEvent is not null) - Assert.Equal(expected.ClickEvent, button.ClickEvent); - } - } }