diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 210267d5..6aff92af 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +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 api *)" ], "deny": [] } 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/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs new file mode 100644 index 00000000..b94fb194 --- /dev/null +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -0,0 +1,28 @@ +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); + } + } +} 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..89eadb6b --- /dev/null +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -0,0 +1,140 @@ +using System.Data; +using Text_Grab; +using Text_Grab.Models; + +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]); + } + + [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/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); + } +} 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/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/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/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..e70120fb 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -48,6 +48,11 @@ public HistoryInfo() 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; + } +} 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) { 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/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 + 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..b1d9405a --- /dev/null +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -0,0 +1,837 @@ +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(); + 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) + 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("\n", "
", 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.CodeSpan => $"`{NormalizeDocumentText(run.Text)}`", + 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); +} 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/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 b/Text-Grab/Views/EditTextWindow.xaml index 335f9357..73352783 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..f078286d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,23 +1,29 @@ -using Humanizer; +using Humanizer; using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Data; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; 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; @@ -27,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; @@ -42,6 +46,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 +66,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 +95,34 @@ 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 List<(int RowIndex, int ColumnIndex)> selectedSpreadsheetCellCoordinates = []; + 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 +153,8 @@ public EditTextWindow(HistoryInfo historyInfo) App.SetTheme(); PassedTextControl.Text = historyInfo.TextContent; + editorMode = historyInfo.EditorMode; + tableDocument = EditTextTableDocument.TryDeserialize(historyInfo.EditTextTableDocumentJson); historyId = historyInfo.ID; @@ -172,6 +213,7 @@ public static Dictionary GetRoutedCommands() {nameof(SplitAfterSelectionCmd), SplitAfterSelectionCmd}, {nameof(OcrPasteCommand), OcrPasteCommand}, {nameof(MakeQrCodeCmd), MakeQrCodeCmd}, + {nameof(TransposeTableCmd), TransposeTableCmd}, {nameof(WebSearchCmd), WebSearchCmd}, {nameof(DefaultWebSearchCmd), DefaultWebSearchCmd}, }; @@ -300,109 +342,1413 @@ public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions op if (options.OutputFooter) { - PassedTextControl.AppendText(Environment.NewLine); - PassedTextControl.AppendText($"----- COMPLETED OCR OF {imageFiles.Count} images"); + PassedTextControl.AppendText(Environment.NewLine); + PassedTextControl.AppendText($"----- COMPLETED OCR OF {imageFiles.Count} images"); + } + } + catch (OperationCanceledException) + { + PassedTextControl.AppendText(Environment.NewLine); + int countCompleted = ocrFileResults.Where(r => r.OcrResult is not null).Count(); + PassedTextControl.AppendText($"----- CANCELLED OCR OF {ocrFileResults.Count - countCompleted}, Completed {countCompleted} images"); + } + finally + { + cancellationTokenForDirOCR.Dispose(); + } + + Mouse.OverrideCursor = null; + stopwatch.Stop(); + + if (options.OutputFooter) + { + PassedTextControl.AppendText(Environment.NewLine); + PassedTextControl.AppendText($"----- from {folderPath}"); + PassedTextControl.AppendText(Environment.NewLine); + PassedTextControl.AppendText($"----- and took {stopwatch.Elapsed:c}"); + } + PassedTextControl.ScrollToEnd(); + + GC.Collect(); + cancellationTokenForDirOCR = null; + } + + public void RemoveCharsFromEditTextWindow(int numberOfChars, SpotInLine spotInLine) + { + PassedTextControl.Text = PassedTextControl.Text.RemoveFromEachLine(numberOfChars, spotInLine); + } + + public void SetBottomBarButtons() + { + BottomBarButtons.Children.Clear(); + + List buttons = CustomBottomBarUtilities.GetBottomBarButtons(this); + + if (DefaultSettings.ScrollBottomBar) + BottomBarScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + else + BottomBarScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + + if (DefaultSettings.ShowCursorText) + BottomBarText.Visibility = Visibility.Visible; + else + BottomBarText.Visibility = Visibility.Collapsed; + + foreach (CollapsibleButton collapsibleButton in buttons) + BottomBarButtons.Children.Add(collapsibleButton); + + if (DefaultSettings.EtwShowLangPicker) + { + LanguagePicker languagePicker = new(); + languagePicker.LanguageChanged -= LanguagePicker_LanguageChanged; + languagePicker.LanguageChanged += LanguagePicker_LanguageChanged; + BottomBarButtons.Children.Add(languagePicker); + } + } + + private void LanguagePicker_LanguageChanged(object sender, RoutedEventArgs e) + { + if (sender is not LanguagePicker languagePicker) + return; + + selectedILanguage = languagePicker.SelectedLanguage; + + string tag = selectedILanguage.LanguageTag; + + foreach (MenuItem item in LanguageMenuItem.Items) + { + if (item.Tag is ILanguage iLanguageFromTag && iLanguageFromTag.LanguageTag == tag) + item.IsChecked = true; + else + item.IsChecked = false; + } + + if (selectedILanguage is not GlobalLang) + { + SetCultureAndLanguageToDefault(); + return; + } + + CultureInfo cultureInfo = new(selectedILanguage.LanguageTag); + selectedCultureInfo = cultureInfo; + XmlLanguage xmlLang = XmlLanguage.GetLanguage(selectedILanguage.LanguageTag); + Language = xmlLang; + } + + private void SetCultureAndLanguageToDefault() + { + selectedCultureInfo = CultureInfo.CurrentCulture; + string currentInputTag = Windows.Globalization.Language.CurrentInputMethodLanguageTag; + XmlLanguage xmlDefaultLang = XmlLanguage.GetLanguage(currentInputTag); + Language = xmlDefaultLang; + } + + 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()]; + + 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) + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + if (selectedCellCoordinates.Count == 0) + return; + + string selectionText = BuildSpreadsheetSelectionText(spreadsheetTable, selectedCellCoordinates); + if (string.IsNullOrEmpty(selectionText)) + return; + + 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)); + UpdateSelectedSpreadsheetCellCoordinates(); + 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; + selectedSpreadsheetCellCoordinates = []; + 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) + { + UpdateSelectedSpreadsheetCellCoordinates(); + + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void ClearSelectedSpreadsheetCellValues() + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + + 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; + } + } + + 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()) + 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 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 + .Cast() + .Select(column => column.ColumnName)]; + + tableDocument.Rows = [.. spreadsheetTable.Rows + .Cast() + .Select(row => spreadsheetTable.Columns + .Cast() + .Select(column => row[column]?.ToString() ?? string.Empty) + .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); } } - catch (OperationCanceledException) + + 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++) { - PassedTextControl.AppendText(Environment.NewLine); - int countCompleted = ocrFileResults.Where(r => r.OcrResult is not null).Count(); - PassedTextControl.AppendText($"----- CANCELLED OCR OF {ocrFileResults.Count - countCompleted}, Completed {countCompleted} images"); + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + double width = column.ActualWidth > 0 ? column.ActualWidth : column.Width.DisplayValue; + tableDocument.SetColumnWidth(columnIndex, width); } - finally + + foreach (object item in SpreadsheetDataGrid.Items) { - cancellationTokenForDirOCR.Dispose(); - } + if (item == CollectionView.NewItemPlaceholder) + continue; - Mouse.OverrideCursor = null; - stopwatch.Stop(); + if (SpreadsheetDataGrid.ItemContainerGenerator.ContainerFromItem(item) is not DataGridRow row) + continue; - if (options.OutputFooter) - { - PassedTextControl.AppendText(Environment.NewLine); - PassedTextControl.AppendText($"----- from {folderPath}"); - PassedTextControl.AppendText(Environment.NewLine); - PassedTextControl.AppendText($"----- and took {stopwatch.Elapsed:c}"); - } - PassedTextControl.ScrollToEnd(); + int rowIndex = row.GetIndex(); + if (rowIndex < 0) + continue; - GC.Collect(); - cancellationTokenForDirOCR = null; + double height = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + tableDocument.SetRowHeight(rowIndex, height); + } } - public void RemoveCharsFromEditTextWindow(int numberOfChars, SpotInLine spotInLine) + private void DetachSpreadsheetColumnWidthTracking() { - PassedTextControl.Text = PassedTextControl.Text.RemoveFromEachLine(numberOfChars, spotInLine); + foreach (DataGridColumn column in trackedSpreadsheetColumns) + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.RemoveValueChanged(column, SpreadsheetColumnWidthChanged); + + trackedSpreadsheetColumns.Clear(); } - public void SetBottomBarButtons() + private void TrackSpreadsheetColumnWidth(DataGridColumn column) { - BottomBarButtons.Children.Clear(); - - List buttons = CustomBottomBarUtilities.GetBottomBarButtons(this); + trackedSpreadsheetColumns.Add(column); + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.AddValueChanged(column, SpreadsheetColumnWidthChanged); + } - if (DefaultSettings.ScrollBottomBar) - BottomBarScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; - else - BottomBarScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + private void TrySetClipboardText(string text) + { + try + { + System.Windows.Clipboard.SetDataObject(text, true); + } + catch + { + } + } - if (DefaultSettings.ShowCursorText) - BottomBarText.Visibility = Visibility.Visible; - else - BottomBarText.Visibility = Visibility.Collapsed; + private void UpdateSpreadsheetModeUi() + { + bool isSpreadsheetMode = editorMode == EtwEditorMode.Spreadsheet; + bool isMarkdownMode = editorMode == EtwEditorMode.Markdown; - foreach (CollapsibleButton collapsibleButton in buttons) - BottomBarButtons.Children.Add(collapsibleButton); + 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(); + } - if (DefaultSettings.EtwShowLangPicker) + private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject + { + while (child is not null) { - LanguagePicker languagePicker = new(); - languagePicker.LanguageChanged -= LanguagePicker_LanguageChanged; - languagePicker.LanguageChanged += LanguagePicker_LanguageChanged; - BottomBarButtons.Children.Add(languagePicker); + if (child is T matchingParent) + return matchingParent; + + child = VisualTreeHelper.GetParent(child); } + + return null; } - private void LanguagePicker_LanguageChanged(object sender, RoutedEventArgs e) + private void SelectSpreadsheetColumn(int columnIndex) { - if (sender is not LanguagePicker languagePicker) + if (columnIndex < 0 || columnIndex >= SpreadsheetDataGrid.Columns.Count) return; - selectedILanguage = languagePicker.SelectedLanguage; + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); - string tag = selectedILanguage.LanguageTag; + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + object? firstRowItem = null; - foreach (MenuItem item in LanguageMenuItem.Items) + foreach (object item in SpreadsheetDataGrid.Items) { - if (item.Tag is ILanguage iLanguageFromTag && iLanguageFromTag.LanguageTag == tag) - item.IsChecked = true; - else - item.IsChecked = false; + if (ReferenceEquals(item, CollectionView.NewItemPlaceholder)) + continue; + + firstRowItem ??= item; + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(item, column)); } - if (selectedILanguage is not GlobalLang) + if (firstRowItem is not null) { - SetCultureAndLanguageToDefault(); - return; + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(firstRowItem, column); + SpreadsheetDataGrid.ScrollIntoView(firstRowItem, column); } - CultureInfo cultureInfo = new(selectedILanguage.LanguageTag); - selectedCultureInfo = cultureInfo; - XmlLanguage xmlLang = XmlLanguage.GetLanguage(selectedILanguage.LanguageTag); - Language = xmlLang; + UpdateSelectedSpreadsheetCellCoordinates(); + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); } - private void SetCultureAndLanguageToDefault() + private void SelectSpreadsheetRow(object rowItem) { - selectedCultureInfo = CultureInfo.CurrentCulture; - string currentInputTag = Windows.Globalization.Language.CurrentInputMethodLanguageTag; - XmlLanguage xmlDefaultLang = XmlLanguage.GetLanguage(currentInputTag); - Language = xmlDefaultLang; + 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); + UpdateSelectedSpreadsheetCellCoordinates(); + 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) { @@ -421,7 +1767,9 @@ internal HistoryInfo AsHistoryItem() TextContent = PassedTextControl.Text, SourceMode = TextGrabMode.EditText, CalcPaneWidth = calcPaneWidth, - HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true + HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true, + EditorMode = editorMode, + EditTextTableDocumentJson = tableDocument?.SerializeToJson() }; if (string.IsNullOrWhiteSpace(historyInfo.ID)) @@ -430,6 +1778,75 @@ internal HistoryInfo AsHistoryItem() 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) + { + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (IoUtilities.IsSpreadsheetFileExtension(existingExtension)) + return 1; + + if (IoUtilities.IsMarkdownFileExtension(existingExtension)) + return 2; + + if (string.Equals(existingExtension, ".txt", StringComparison.OrdinalIgnoreCase)) + return 3; + + if (!string.IsNullOrWhiteSpace(existingExtension)) + return 4; + + return editorMode switch + { + EtwEditorMode.Spreadsheet => 1, + EtwEditorMode.Markdown => 2, + _ => 3 + }; + } + + private static string GetSpreadsheetSaveExtension(EditTextTableDocument? tableDocument) + { + if (tableDocument is null) + return ".tsv"; + + 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) { PassedTextControl.Text = PassedTextControl.Text.LimitCharactersPerLine(numberOfChars, spotInLine); @@ -437,16 +1854,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.Text = 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) @@ -919,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(); @@ -949,6 +2430,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) { @@ -1314,7 +2829,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, @@ -1364,7 +2879,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 +3084,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,31 +3128,179 @@ 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(); + if (editorMode != EtwEditorMode.Markdown) + SetMargins(MarginsMenuItem.IsChecked is true); + } + + private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) + { + if (DefaultSettings.EditWindowStartFullscreen && prevWindowState is not null) + { + this.WindowState = prevWindowState.Value; + prevWindowState = null; + } + + UpdateLineAndColumnText(); + + // Reset the debounce timer + _debounceTimer?.Stop(); + _debounceTimer?.Start(); + // 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 PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) - { - if (DefaultSettings.EditWindowStartFullscreen && prevWindowState is not null) + 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) { - this.WindowState = prevWindowState.Value; - prevWindowState = null; - } + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; - UpdateLineAndColumnText(); + ReloadMarkdownDocumentAndRestoreCaret(selectionStartOffset + renderedPasteLength); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + } - // Reset the debounce timer - _debounceTimer?.Stop(); - _debounceTimer?.Start(); - // 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); + private void MarkdownEditorControl_RequestNavigate(object sender, RequestNavigateEventArgs e) + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + e.Handled = true; } private DispatcherTimer? _debounceTimer = null; @@ -1869,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) @@ -1961,46 +3604,161 @@ 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) + { + _ = 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 +3772,13 @@ private void SelectAllMenuItem_Click(Object? sender = null, RoutedEventArgs? e = if (!IsLoaded) return; + if (editorMode == EtwEditorMode.Spreadsheet) + { + SpreadsheetDataGrid.SelectAllCells(); + SpreadsheetDataGrid.Focus(); + return; + } + PassedTextControl.SelectAll(); } @@ -2049,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(); @@ -2063,6 +3886,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 +3899,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 +3938,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)); @@ -2200,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) @@ -2275,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) @@ -2308,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) @@ -2378,6 +4216,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 +4324,12 @@ private void UpdateLineAndColumnText() private void UpdateSelectionSpecificUI() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + return; + } + string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) @@ -2733,11 +4633,24 @@ 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); + + windowSource?.RemoveHook(EditTextWindowMessageHook); + string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -2774,23 +4687,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 +4790,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,35 +5256,23 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) else PassedTextControl.TextWrapping = TextWrapping.NoWrap; + ApplyMarkdownWrapSetting(); + DefaultSettings.EditWindowIsWordWrapOn = WrapTextMenuItem.IsChecked; } 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 { @@ -3348,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 { @@ -3368,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 { @@ -3402,24 +5331,37 @@ 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(); - 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) {