diff --git a/.codetesting/AnalysisReport_20260125_220624_678.md b/.codetesting/AnalysisReport_20260125_220624_678.md
deleted file mode 100644
index bef7b7d5..00000000
--- a/.codetesting/AnalysisReport_20260125_220624_678.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Test Failures due possible code bugs
-
-## Tests.csproj - Text_Grab.Utilities.UnitTests.WindowsAiUtilitiesTests.CleanRegexResult_OnlyOpeningFence_ReturnsPattern
-- **Confidence**: High
-- **Test File**: Tests\Utilities\WindowsAiUtilitiesTests.cs
-- **Bug Location**: Text-Grab\Utilities\WindowsAiUtilities.cs@588-592
-
-### Analysis
-The production code has a logical error in the CleanRegexResult method. The Where clause at lines 588-592 filters out lines starting with 'Pattern:' (case-insensitive) BEFORE the Select clause at lines 593-601 can remove the 'pattern:' prefix. When the input is '```\npattern: [a-z]+', after removing the opening fence, we have 'pattern: [a-z]+'. This line gets filtered out by the Where clause because it starts with 'Pattern:' (case-insensitive), so the Select clause never gets a chance to remove the prefix. The method then returns the cleaned text as-is ('pattern: [a-z]+') instead of the extracted pattern ('[a-z]+'). The fix is to remove the filtering of 'Pattern:', 'Regex:', and 'Expression:' from the Where clause (lines 590-592), allowing the Select clause to handle prefix removal.
-
-### Suggested Fix
-In the CleanRegexResult method at D:\source\TheJoeFin\Text-Grab\Text-Grab\Utilities\WindowsAiUtilities.cs, remove lines 590-592 from the Where clause. The Where clause should only filter out comment lines (lines starting with '//' or '#'), not descriptor lines like 'Regex:', 'Pattern:', or 'Expression:', since the subsequent Select clause is designed to handle removing these prefixes. The corrected Where clause should be:
-
-```csharp
-.Where(line => !line.StartsWith("//", StringComparison.Ordinal) &&
- !line.StartsWith('#'))
-```
-
-This allows lines with 'pattern:', 'regex:', or 'expression:' prefixes to reach the Select clause where the prefixes are properly removed.
-
diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml
index db3fba66..79f9c39c 100644
--- a/.github/workflows/Release.yml
+++ b/.github/workflows/Release.yml
@@ -36,7 +36,7 @@ jobs:
build:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -231,25 +231,25 @@ jobs:
}
- name: Upload build artifact (x64 framework-dependent)
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: Text-Grab-win-x64-framework-dependent
path: ${{ env.BUILD_X64 }}
- name: Upload build artifact (x64 self-contained)
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: Text-Grab-win-x64-self-contained
path: ${{ steps.compute.outputs.archive_x64_sc }}
- name: Upload build artifact (ARM64 framework-dependent)
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: Text-Grab-win-arm64-framework-dependent
path: ${{ env.BUILD_ARM64 }}
- name: Upload build artifact (ARM64 self-contained)
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: Text-Grab-win-arm64-self-contained
path: ${{ steps.compute.outputs.archive_arm64_sc }}
diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml
index 71e04cf9..ae14fbb8 100644
--- a/.github/workflows/buildDev.yml
+++ b/.github/workflows/buildDev.yml
@@ -17,7 +17,7 @@ jobs:
build:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
@@ -33,7 +33,7 @@ jobs:
run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -o publish
- name: Upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: Text-Grab
path: .\publish
diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs
new file mode 100644
index 00000000..f88de72c
--- /dev/null
+++ b/Tests/ClipboardUtilitiesTests.cs
@@ -0,0 +1,137 @@
+using Text_Grab.Utilities;
+
+namespace Tests;
+
+public class ClipboardUtilitiesTests
+{
+ private const string SampleCfHtml = """
+ Version:1.0
+ StartHTML:00000097
+ EndHTML:00002353
+ StartFragment:00000153
+ EndFragment:00002320
+
+
+
+
+ | Month |
+ Int |
+ Season |
+
+
+ | January |
+ 1 |
+ Winter |
+
+
+ | February |
+ 2 |
+ Winter |
+
+
+
+
+ """;
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_ParsesBasicTable()
+ {
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(SampleCfHtml);
+
+ string[] lines = result.Split('\n');
+ Assert.Equal(3, lines.Length);
+ Assert.Equal("Month\tInt\tSeason", lines[0]);
+ Assert.Equal("January\t1\tWinter", lines[1]);
+ Assert.Equal("February\t2\tWinter", lines[2]);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_HandlesBrTag()
+ {
+ string html = """
+
+ """;
+
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+
+ Assert.Equal("4 A\tSpring", result);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_ReturnsEmptyWhenNoTable()
+ {
+ string html = "No table here
";
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_DecodesHtmlEntities()
+ {
+ string html = """
+
+ """;
+
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+
+ Assert.Equal("A & B\t", result);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_HandlesThElements()
+ {
+ string html = """
+
+ """;
+
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+
+ string[] lines = result.Split('\n');
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("Name\tValue", lines[0]);
+ Assert.Equal("Foo\t42", lines[1]);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_HandlesColspan()
+ {
+ string html = """
+
+ """;
+
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+
+ string[] lines = result.Split('\n');
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("Merged\tMerged\tRight", lines[0]);
+ Assert.Equal("A\tB\tC", lines[1]);
+ }
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_HandlesRowspan()
+ {
+ string html = """
+
+ """;
+
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html);
+
+ string[] lines = result.Split('\n');
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("Tall\tTop", lines[0]);
+ Assert.Equal("Tall\tBottom", lines[1]);
+ }
+}
diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs
index 89eadb6b..c8f96203 100644
--- a/Tests/EditTextWindowSpreadsheetTests.cs
+++ b/Tests/EditTextWindowSpreadsheetTests.cs
@@ -35,6 +35,65 @@ public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells()
Assert.Equal(string.Empty, dataTable.Rows[1][2]);
}
+ [Fact]
+ public void TryCutSpreadsheetCellValues_CopiesThenClearsRequestedCells()
+ {
+ 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 clipboardText = string.Empty;
+
+ bool didCut = EditTextWindow.TryCutSpreadsheetCellValues(
+ dataTable,
+ [
+ (1, 2),
+ (0, 1),
+ (1, 0),
+ (0, 1),
+ (-1, 0),
+ (5, 5)
+ ],
+ text =>
+ {
+ clipboardText = text;
+ return true;
+ });
+
+ Assert.True(didCut);
+ Assert.Equal("b1" + Environment.NewLine + "a2\tc2", clipboardText);
+ Assert.Equal("a1", dataTable.Rows[0][0]);
+ Assert.Equal(string.Empty, dataTable.Rows[0][1]);
+ Assert.Equal("c1", dataTable.Rows[0][2]);
+ Assert.Equal(string.Empty, dataTable.Rows[1][0]);
+ Assert.Equal("b2", dataTable.Rows[1][1]);
+ Assert.Equal(string.Empty, dataTable.Rows[1][2]);
+ }
+
+ [Fact]
+ public void TryCutSpreadsheetCellValues_DoesNotClearWhenClipboardCopyFails()
+ {
+ DataTable dataTable = new();
+ dataTable.Columns.Add("A", typeof(string));
+ dataTable.Columns.Add("B", typeof(string));
+ dataTable.Rows.Add("a1", "b1");
+
+ bool didCut = EditTextWindow.TryCutSpreadsheetCellValues(
+ dataTable,
+ [
+ (0, 0),
+ (0, 1)
+ ],
+ _ => false);
+
+ Assert.False(didCut);
+ Assert.Equal("a1", dataTable.Rows[0][0]);
+ Assert.Equal("b1", dataTable.Rows[0][1]);
+ }
+
[Fact]
public void BuildSpreadsheetSelectionText_IncludesOnlySelectedCells()
{
diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs
index 6fbb5403..6560b7ca 100644
--- a/Tests/FilesIoTests.cs
+++ b/Tests/FilesIoTests.cs
@@ -1,4 +1,6 @@
-using System.Drawing;
+using System.Drawing;
+using System.IO;
+using System.Windows;
using Text_Grab;
using Text_Grab.Models;
using Text_Grab.Utilities;
@@ -107,4 +109,91 @@ public void GetEditorModeForPath_UsesFileExtension(string path, EtwEditorMode ex
{
Assert.Equal(expectedMode, IoUtilities.GetEditorModeForPath(path));
}
+
+ [Theory]
+ [InlineData(@"C:\Temp\scan.png", OpenContentKind.Image)]
+ [InlineData(@"C:\Temp\scan.PDF", OpenContentKind.PdfDocument)]
+ [InlineData(@"C:\Temp\notes.txt", OpenContentKind.TextFile)]
+ public void GetOpenContentKindForPath_ClassifiesVisualDocumentsAndText(string path, OpenContentKind expectedKind)
+ {
+ Assert.Equal(expectedKind, IoUtilities.GetOpenContentKindForPath(path));
+ }
+
+ [Theory]
+ [InlineData(".png", true)]
+ [InlineData(".PDF", true)]
+ [InlineData(".txt", false)]
+ [InlineData("", false)]
+ public void IsVisualDocumentFileExtension_RecognizesImagesAndPdf(string extension, bool expected)
+ {
+ Assert.Equal(expected, IoUtilities.IsVisualDocumentFileExtension(extension));
+ }
+
+ [Fact]
+ public void GetVisualDocumentFilter_IncludesPdfSupport()
+ {
+ string filter = FileUtilities.GetVisualDocumentFilter();
+
+ Assert.Contains("Image and PDF files|", filter);
+ Assert.Contains("PDF files|*.pdf", filter);
+ Assert.Contains("Image files|", filter);
+ }
+
+ [Fact]
+ public void GetOpenDocumentFilter_IncludesVisualAndTextOptions()
+ {
+ string filter = FileUtilities.GetOpenDocumentFilter();
+
+ Assert.Contains("Supported documents|", filter);
+ Assert.Contains("Image and PDF files|", filter);
+ Assert.Contains("Spreadsheet documents|*.csv;*.tsv;*.tab", filter);
+ Assert.Contains("Markdown documents|*.md;*.markdown", filter);
+ Assert.Contains("Text documents (*.txt)|*.txt", filter);
+ Assert.Contains("All files (*.*)|*.*", filter);
+ }
+
+ [WpfFact]
+ public void GetDroppedFilePaths_ReturnsExistingFilesOnly()
+ {
+ string firstPath = Path.GetTempFileName();
+ string secondPath = Path.GetTempFileName();
+ string missingPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt");
+ DataObject dataObject = new(DataFormats.FileDrop, new[] { firstPath, missingPath, secondPath });
+
+ try
+ {
+ IReadOnlyList paths = App.GetDroppedFilePaths(dataObject);
+
+ Assert.Equal([firstPath, secondPath], paths);
+ }
+ finally
+ {
+ File.Delete(firstPath);
+ File.Delete(secondPath);
+ }
+ }
+
+ [WpfFact]
+ public void GetDroppedFileEffect_ReturnsCopyWhenExistingFilesAreDropped()
+ {
+ string path = Path.GetTempFileName();
+ DataObject dataObject = new(DataFormats.FileDrop, new[] { path });
+
+ try
+ {
+ Assert.Equal(DragDropEffects.Copy, App.GetDroppedFileEffect(dataObject));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [WpfFact]
+ public void GetDroppedFileEffect_ReturnsNoneWhenNoFilesCanBeOpened()
+ {
+ DataObject dataObject = new(DataFormats.Text, "hello");
+
+ Assert.Equal(DragDropEffects.None, App.GetDroppedFileEffect(dataObject));
+ }
}
diff --git a/Tests/PdfDocumentRendererTests.cs b/Tests/PdfDocumentRendererTests.cs
new file mode 100644
index 00000000..8d00801c
--- /dev/null
+++ b/Tests/PdfDocumentRendererTests.cs
@@ -0,0 +1,89 @@
+using Text_Grab.Utilities;
+using UglyToad.PdfPig.Core;
+using Windows.Media.Ocr;
+
+namespace Tests;
+
+public class PdfDocumentRendererTests
+{
+ [Fact]
+ public void GetRenderDimensions_DoublesTypicalPdfPageSize()
+ {
+ (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(612, 792);
+
+ Assert.Equal(1224u, width);
+ Assert.Equal(1584u, height);
+ }
+
+ [Fact]
+ public void GetRenderDimensions_ClampsToOcrEngineLimit()
+ {
+ (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(5000, 2500);
+
+ Assert.True(Math.Max(width, height) <= OcrEngine.MaxImageDimension);
+ Assert.True(width > height);
+ }
+
+ [Fact]
+ public void GetRenderDimensions_InvalidSize_ReturnsSinglePixel()
+ {
+ (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(0, -1);
+
+ Assert.Equal(1u, width);
+ Assert.Equal(1u, height);
+ }
+
+ [Fact]
+ public void ConvertPdfRectToImageRect_MapsPdfCoordinatesToRenderedBitmapSpace()
+ {
+ PdfRectangle pdfRect = new(10, 20, 60, 80);
+
+ Windows.Foundation.Rect imageRect = PdfDocumentRenderer.ConvertPdfRectToImageRect(pdfRect, 100, 100, 200, 200);
+
+ Assert.Equal(20, imageRect.X);
+ Assert.Equal(40, imageRect.Y);
+ Assert.Equal(100, imageRect.Width);
+ Assert.Equal(120, imageRect.Height);
+ }
+
+ [Fact]
+ public void GroupWordsIntoLines_GroupsNearbyWordsIntoSingleLine()
+ {
+ IReadOnlyList lines = PdfDocumentRenderer.GroupWordsIntoLines(
+ [
+ (new Windows.Foundation.Rect(10, 10, 20, 12), "Hello"),
+ (new Windows.Foundation.Rect(35, 11, 25, 12), "world"),
+ (new Windows.Foundation.Rect(12, 40, 30, 12), "Again")
+ ]);
+
+ Assert.Collection(
+ lines,
+ firstLine =>
+ {
+ Assert.Equal("Hello world", firstLine.Text);
+ Assert.True(firstLine.IsNativeText);
+ Assert.Equal(10, firstLine.SourceRect.X);
+ Assert.Equal(10, firstLine.SourceRect.Y);
+ Assert.Equal(50, firstLine.SourceRect.Width);
+ Assert.Equal(13, firstLine.SourceRect.Height);
+ },
+ secondLine => Assert.Equal("Again", secondLine.Text));
+ }
+
+ [Fact]
+ public void ShouldIncludeOcrLine_OnlyReturnsTrueWhenImageOverlapIsMeaningful()
+ {
+ Windows.Foundation.Rect sourceRect = new(0, 0, 10, 10);
+
+ bool shouldIncludeFromLargeOverlap = PdfDocumentRenderer.ShouldIncludeOcrLine(
+ sourceRect,
+ [new Windows.Foundation.Rect(5, 5, 10, 10)]);
+
+ bool shouldIgnoreFromSmallOverlap = PdfDocumentRenderer.ShouldIncludeOcrLine(
+ sourceRect,
+ [new Windows.Foundation.Rect(8, 8, 10, 10)]);
+
+ Assert.True(shouldIncludeFromLargeOverlap);
+ Assert.False(shouldIgnoreFromSmallOverlap);
+ }
+}
diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs
index 85738b95..415ec3fa 100644
--- a/Text-Grab/App.xaml.cs
+++ b/Text-Grab/App.xaml.cs
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
+using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Markup;
@@ -74,6 +75,49 @@ public static void DefaultLaunch()
SetTheme();
}
+ public static async Task OpenFileWithPickerAsync(bool isQuiet = false)
+ {
+ OpenFileDialog openFileDialog = new()
+ {
+ Filter = FileUtilities.GetOpenDocumentFilter(),
+ Title = "Open File",
+ CheckFileExists = true,
+ InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
+ };
+
+ if (openFileDialog.ShowDialog() == true)
+ await TryToOpenFilePathAsync(openFileDialog.FileName, isQuiet);
+ }
+
+ public static DragDropEffects GetDroppedFileEffect(IDataObject? dataObject)
+ {
+ return GetDroppedFilePaths(dataObject).Any()
+ ? DragDropEffects.Copy
+ : DragDropEffects.None;
+ }
+
+ public static IReadOnlyList GetDroppedFilePaths(IDataObject? dataObject)
+ {
+ if (dataObject is null || !dataObject.GetDataPresent(DataFormats.FileDrop, true))
+ return [];
+
+
+ if (dataObject.GetData(DataFormats.FileDrop, true) is not string[] paths || paths.Length == 0)
+ return [];
+
+ return [.. paths.Where(File.Exists)];
+ }
+
+ public static async Task TryToOpenDroppedFilesAsync(IDataObject? dataObject, bool isQuiet = false)
+ {
+ bool openedAny = false;
+
+ foreach (string path in GetDroppedFilePaths(dataObject))
+ openedAny = await TryToOpenFilePathAsync(path, isQuiet) || openedAny;
+
+ return openedAny;
+ }
+
public static void SetTheme(object? sender = null, EventArgs? e = null)
{
bool gotTheme = Enum.TryParse(_defaultSettings.AppTheme.ToString(), true, out AppTheme currentAppTheme);
@@ -240,7 +284,7 @@ private static async Task HandleStartupArgs(string[] args)
}
else
{
- Debug.WriteLine("--grabframe flag specified but no valid image file path provided");
+ Debug.WriteLine("--grabframe flag specified but no valid image or PDF file path provided");
// Fall through to default launch behavior
}
}
@@ -265,7 +309,7 @@ private static async Task HandleStartupArgs(string[] args)
return true;
}
- bool openedFile = await TryToOpenFile(currentArgument, isQuiet);
+ bool openedFile = await TryToOpenFilePathAsync(currentArgument, isQuiet);
if (openedFile)
return true;
@@ -305,7 +349,7 @@ private static void ShowAndSetFirstRun()
_defaultSettings.Save();
}
- private static async Task TryToOpenFile(string possiblePath, bool isQuiet)
+ public static async Task TryToOpenFilePathAsync(string possiblePath, bool isQuiet = false)
{
if (!File.Exists(possiblePath))
return false;
@@ -318,7 +362,7 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet)
false,
false);
}
- else if (IoUtilities.IsImageFile(possiblePath))
+ else if (IoUtilities.IsVisualDocumentFile(possiblePath))
{
GrabFrame gf = new(possiblePath);
gf.Show();
@@ -329,6 +373,7 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet)
EditTextWindow manipulateTextWindow = new();
manipulateTextWindow.OpenPath(possiblePath);
manipulateTextWindow.Show();
+ manipulateTextWindow.Activate();
}
return true;
}
diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml b/Text-Grab/Controls/FindAndReplaceWindow.xaml
index a6677039..8935d419 100644
--- a/Text-Grab/Controls/FindAndReplaceWindow.xaml
+++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml
@@ -289,8 +289,7 @@
-
-
+
@@ -302,23 +301,18 @@
+ Text="{Binding LocationDisplay}" />
-
diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs
index 851735d3..f04bd253 100644
--- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs
+++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs
@@ -59,6 +59,8 @@ public FindAndReplaceWindow()
#region Properties
+ private bool IsSpreadsheetSearch => textEditWindow?.IsSpreadsheetMode is true;
+
public List FindResults { get; set; } = [];
public string StringFromWindow
@@ -85,6 +87,8 @@ public EditTextWindow? TextEditWindow
public void SearchForText()
{
+ if (IsSpreadsheetSearch) { SearchSpreadsheetCells(); return; }
+
FindResults.Clear();
ResultsListView.ItemsSource = null;
@@ -180,6 +184,61 @@ public void SearchForText()
}
}
+ private Regex? BuildCurrentRegex()
+ {
+ string rawPattern = FindTextBox.Text;
+ if (string.IsNullOrEmpty(rawPattern)) return null;
+
+ if (rawPattern.StartsWith('^') && rawPattern.EndsWith('$') && rawPattern.Length > 2)
+ rawPattern = rawPattern[1..^1];
+
+ if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly)
+ rawPattern = rawPattern.EscapeSpecialRegexChars(matchExactly);
+
+ RegexOptions options = RegexOptions.None;
+ if (ExactMatchCheckBox.IsChecked is not true) options |= RegexOptions.IgnoreCase;
+ if (UsePatternCheckBox.IsChecked is true) options |= RegexOptions.IgnorePatternWhitespace;
+
+ try { return new Regex(rawPattern, options, TimeSpan.FromSeconds(5)); }
+ catch { return null; }
+ }
+
+ private void SearchSpreadsheetCells()
+ {
+ FindResults.Clear();
+ ResultsListView.ItemsSource = null;
+ Matches = null;
+
+ if (textEditWindow is null || string.IsNullOrWhiteSpace(FindTextBox.Text))
+ {
+ MatchesText.Text = "0 Matches";
+ return;
+ }
+
+ Regex? regex = BuildCurrentRegex();
+ if (regex is null) { MatchesText.Text = "0 Matches"; return; }
+
+ textEditWindow.CommitSpreadsheetAndSync();
+
+ List results;
+ try { results = textEditWindow.SearchSpreadsheetCells(regex); }
+ catch (RegexMatchTimeoutException) { MatchesText.Text = "Regex timeout"; return; }
+
+ FindResults.AddRange(results);
+ if (FindResults.Count == 0) { MatchesText.Text = "0 Matches"; return; }
+
+ MatchesText.Text = FindResults.Count == 1 ? "1 Match" : $"{FindResults.Count} Matches";
+ ResultsListView.IsEnabled = true;
+ ResultsListView.ItemsSource = FindResults;
+
+ FindResult first = FindResults[0];
+ if (this.IsFocused && first.RowIndex.HasValue && first.ColumnIndex.HasValue)
+ {
+ textEditWindow.NavigateToSpreadsheetCell(first.RowIndex.Value, first.ColumnIndex.Value);
+ this.Focus();
+ }
+ }
+
public void ShouldCloseWithThisETW(EditTextWindow etw)
{
if (textEditWindow is not null && etw == textEditWindow)
@@ -200,6 +259,12 @@ private void PrecisionSlider_Tick(object? sender, EventArgs? e)
private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
+ if (IsSpreadsheetSearch)
+ {
+ e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text);
+ return;
+ }
+
if (Matches is null || Matches.Count < 1 || string.IsNullOrEmpty(FindTextBox.Text))
e.CanExecute = false;
else
@@ -208,9 +273,9 @@ private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs
private void CopyMatchesCmd_Executed(object sender, ExecutedRoutedEventArgs e)
{
- if (Matches is null
- || textEditWindow is null
- || Matches.Count < 1)
+ if (textEditWindow is null) return;
+
+ if (!IsSpreadsheetSearch && (Matches is null || Matches.Count < 1))
return;
StringBuilder stringBuilder = new();
@@ -230,6 +295,12 @@ private void CopyMatchesCmd_Executed(object sender, ExecutedRoutedEventArgs e)
private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
+ if (IsSpreadsheetSearch)
+ {
+ e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text);
+ return;
+ }
+
if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(FindTextBox.Text))
e.CanExecute = true;
else
@@ -238,24 +309,41 @@ private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e)
private async void DeleteAll_Executed(object sender, ExecutedRoutedEventArgs e)
{
- if (Matches is null
- || Matches.Count < 1
- || textEditWindow is null)
+ if (textEditWindow is null) return;
+
+ if (IsSpreadsheetSearch)
+ {
+ if (FindResults.Count == 0) return;
+ SetWindowToLoading();
+ Regex? regex = BuildCurrentRegex();
+ if (regex is null) { ResetWindowLoading(); return; }
+ IList selection = ResultsListView.SelectedItems;
+ List targets = selection.Count >= 2
+ ? [.. selection.Cast()]
+ : [.. ResultsListView.Items.Cast()];
+ await Task.Run(() => Dispatcher.Invoke(() =>
+ textEditWindow.ReplaceInSpreadsheetCells(targets, string.Empty, regex)));
+ SearchForText();
+ ResetWindowLoading();
+ return;
+ }
+
+ if (Matches is null || Matches.Count < 1)
return;
SetWindowToLoading();
- IList selection = ResultsListView.SelectedItems;
+ IList selection2 = ResultsListView.SelectedItems;
StringBuilder stringBuilderOfText = new(textEditWindow.PassedTextControl.Text);
await Task.Run(() =>
{
- if (selection.Count < 2)
- selection = ResultsListView.Items;
+ if (selection2.Count < 2)
+ selection2 = ResultsListView.Items;
- for (int j = selection.Count - 1; j >= 0; j--)
+ for (int j = selection2.Count - 1; j >= 0; j--)
{
- if (selection[j] is not FindResult selectedResult)
+ if (selection2[j] is not FindResult selectedResult)
continue;
stringBuilderOfText.Remove(selectedResult.Index, selectedResult.Length);
@@ -270,6 +358,8 @@ await Task.Run(() =>
private void EditTextBoxChanged(object sender, TextChangedEventArgs e)
{
+ if (IsSpreadsheetSearch) return;
+
ChangeFindTextTimer.Stop();
if (textEditWindow is not null)
StringFromWindow = textEditWindow.PassedTextControl.Text;
@@ -279,6 +369,8 @@ private void EditTextBoxChanged(object sender, TextChangedEventArgs e)
private void ExtractPattern_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
+ if (IsSpreadsheetSearch) { e.CanExecute = false; return; }
+
if (textEditWindow is not null
&& textEditWindow.PassedTextControl.SelectedText.Length > 0)
e.CanExecute = true;
@@ -410,6 +502,12 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e)
private void Replace_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
+ if (IsSpreadsheetSearch)
+ {
+ e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(ReplaceTextBox.Text);
+ return;
+ }
+
if (string.IsNullOrEmpty(ReplaceTextBox.Text)
|| Matches is null
|| Matches.Count < 1)
@@ -420,10 +518,21 @@ private void Replace_CanExecute(object sender, CanExecuteRoutedEventArgs e)
private void Replace_Executed(object sender, ExecutedRoutedEventArgs e)
{
- if (Matches is null
- || textEditWindow is null
- || ResultsListView.Items.Count is 0)
+ if (textEditWindow is null || ResultsListView.Items.Count is 0)
+ return;
+
+ if (IsSpreadsheetSearch)
+ {
+ if (ResultsListView.SelectedIndex == -1) ResultsListView.SelectedIndex = 0;
+ if (ResultsListView.SelectedItem is not FindResult fr) return;
+ Regex? regex = BuildCurrentRegex();
+ if (regex is null) return;
+ textEditWindow.ReplaceInSpreadsheetCells([fr], ReplaceTextBox.Text, regex);
+ SearchForText();
return;
+ }
+
+ if (Matches is null) return;
if (ResultsListView.SelectedIndex == -1)
ResultsListView.SelectedIndex = 0;
@@ -439,26 +548,44 @@ private void Replace_Executed(object sender, ExecutedRoutedEventArgs e)
private async void ReplaceAll_Executed(object sender, ExecutedRoutedEventArgs e)
{
- if (Matches is null
- || Matches.Count < 1
- || textEditWindow is null)
+ if (textEditWindow is null) return;
+
+ if (IsSpreadsheetSearch)
+ {
+ if (FindResults.Count == 0) return;
+ SetWindowToLoading();
+ Regex? regex = BuildCurrentRegex();
+ if (regex is null) { ResetWindowLoading(); return; }
+ IList selection = ResultsListView.SelectedItems;
+ List targets = selection.Count >= 2
+ ? [.. selection.Cast()]
+ : [.. ResultsListView.Items.Cast()];
+ string replaceWith = ReplaceTextBox.Text;
+ await Task.Run(() => Dispatcher.Invoke(() =>
+ textEditWindow.ReplaceInSpreadsheetCells(targets, replaceWith, regex)));
+ SearchForText();
+ ResetWindowLoading();
+ return;
+ }
+
+ if (Matches is null || Matches.Count < 1)
return;
SetWindowToLoading();
StringBuilder stringBuilder = new(textEditWindow.PassedTextControl.Text);
- IList selection = ResultsListView.SelectedItems;
+ IList selection2 = ResultsListView.SelectedItems;
string newText = ReplaceTextBox.Text;
await Task.Run(() =>
{
- if (selection.Count < 2)
- selection = ResultsListView.Items;
+ if (selection2.Count < 2)
+ selection2 = ResultsListView.Items;
- for (int j = selection.Count - 1; j >= 0; j--)
+ for (int j = selection2.Count - 1; j >= 0; j--)
{
- if (selection[j] is not FindResult selectedResult)
+ if (selection2[j] is not FindResult selectedResult)
continue;
stringBuilder.Remove(selectedResult.Index, selectedResult.Length);
@@ -486,15 +613,21 @@ private void SetWindowToLoading()
private void ResultsListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (ResultsListView.SelectedItem is not FindResult selectedResult)
+ if (ResultsListView.SelectedItem is not FindResult selectedResult || textEditWindow is null)
return;
- if (textEditWindow is not null)
+ if (IsSpreadsheetSearch)
{
- textEditWindow.PassedTextControl.Focus();
- textEditWindow.PassedTextControl.Select(selectedResult.Index, selectedResult.Length);
+ if (selectedResult.RowIndex.HasValue && selectedResult.ColumnIndex.HasValue)
+ textEditWindow.NavigateToSpreadsheetCell(
+ selectedResult.RowIndex.Value, selectedResult.ColumnIndex.Value);
this.Focus();
+ return;
}
+
+ textEditWindow.PassedTextControl.Focus();
+ textEditWindow.PassedTextControl.Select(selectedResult.Index, selectedResult.Length);
+ this.Focus();
}
private void SetExtraOptionsVisibility(Visibility optionsVisibility)
diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml
index bd6e13f7..946db309 100644
--- a/Text-Grab/Controls/NotifyIconWindow.xaml
+++ b/Text-Grab/Controls/NotifyIconWindow.xaml
@@ -37,6 +37,14 @@
+