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 = "- Alpha42
- Beta99
";
+
+ 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)
{