From ee45a38759dd537eb6f3ed31ea89af26706a1ca4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 10 Jun 2026 20:21:21 -0500 Subject: [PATCH 01/23] Allow PowerShell dotnet test commands in settings Added support for running PowerShell commands starting with dotnet test * in settings.local.json, enabling .NET test execution via PowerShell alongside existing dotnet build support. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e7e464a3..5cb540c4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(bin/pdm *)", "Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)", "Bash(Select-Object Name)", - "PowerShell(dotnet build *)" + "PowerShell(dotnet build *)", + "PowerShell(dotnet test *)" ], "deny": [] } From 704a935130a8a5a98e55ebcc279c0218f5b190cb Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 10 Jun 2026 20:23:46 -0500 Subject: [PATCH 02/23] Enable word merging regardless of table mode Simplified the logic for the "Merge Words" command in GrabFrame so that merging is now allowed whenever more than one word border is selected, regardless of whether table mode is active. Removed the table mode check from both the XAML CanExecute handler and the ShouldAllowWordBorderMerging method. --- Text-Grab/Views/GrabFrame.xaml | 4 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 11 +++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 5f0fcb0b..8be8f3ec 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -54,11 +54,11 @@ Command="{x:Static local:GrabFrame.RedoCommand}" Executed="RedoExecuted" /> 1; + return selectedWordBorderCount > 1; } internal static bool ShouldRefreshOcrBordersForTableModeActivation( From 868380c905d1bef60b796609a479a6b7e2d64695 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 10 Jun 2026 20:23:56 -0500 Subject: [PATCH 03/23] Refactor word border merging test, add Magick image tests Renamed and simplified ShouldAllowWordBorderMerging test in GrabFrameTableModeTests to focus on selected word border count. Added ImageMagick-based bitmap comparison tests in ImageMethodsTests to verify image diff logic, including zero and non-zero difference scenarios. Updated using directives for required namespaces. --- Tests/GrabFrameTableModeTests.cs | 13 +++++-------- Tests/ImageMethodsTests.cs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Tests/GrabFrameTableModeTests.cs b/Tests/GrabFrameTableModeTests.cs index 92028f06..3eb4fd21 100644 --- a/Tests/GrabFrameTableModeTests.cs +++ b/Tests/GrabFrameTableModeTests.cs @@ -6,17 +6,14 @@ namespace Tests; public class GrabFrameTableModeTests { [Theory] - [InlineData(false, 2, true)] - [InlineData(false, 1, false)] - [InlineData(true, 2, false)] - public void ShouldAllowWordBorderMerging_DisablesMergingInTableMode( - bool isTableModeSelected, + [InlineData(2, true)] + [InlineData(1, false)] + [InlineData(0, false)] + public void ShouldAllowWordBorderMerging_RequiresMultipleSelectedWordBorders( int selectedWordBorderCount, bool expected) { - bool actual = GrabFrame.ShouldAllowWordBorderMerging( - isTableModeSelected, - selectedWordBorderCount); + bool actual = GrabFrame.ShouldAllowWordBorderMerging(selectedWordBorderCount); Assert.Equal(expected, actual); } diff --git a/Tests/ImageMethodsTests.cs b/Tests/ImageMethodsTests.cs index 4d166e39..f8737755 100644 --- a/Tests/ImageMethodsTests.cs +++ b/Tests/ImageMethodsTests.cs @@ -1,13 +1,18 @@ +using ImageMagick; using System.Drawing; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using Text_Grab; +using Text_Grab.Utilities; namespace Tests; public class ImageMethodsTests { + private const string fontTestPath = @".\Images\FontTest.png"; + private const string fontSamplePath = @".\Images\font_sample.png"; + [WpfFact] public void ImageSourceToBitmap_ConvertsBitmapSourceDerivedImages() { @@ -46,4 +51,30 @@ public void ImageSourceToBitmap_ReturnsNullForNonBitmapImageSources() Assert.Null(bitmap); } + + [WpfFact] + public void BitmapCompare_ReturnsZeroDiff() + { + string path1 = FileUtilities.GetPathToLocalFile(fontTestPath); + MagickImage img1 = new(path1); + + IMagickErrorInfo compare = img1.Compare(img1); + + Assert.NotNull(compare); + Assert.Equal(0, compare.NormalizedMeanError); + } + + [WpfFact] + public void BitmapCompare_ReturnsNonZeroDiff() + { + string path1 = FileUtilities.GetPathToLocalFile(fontTestPath); + string path2 = FileUtilities.GetPathToLocalFile(fontSamplePath); + MagickImage img1 = new(path1); + MagickImage img2 = new(path2); + + IMagickErrorInfo compare = img1.Compare(img2); + + Assert.NotNull(compare); + Assert.NotEqual(0, compare.NormalizedMeanError); + } } From bee0fe55acee30df91fcd2aa478de8236d48a4b7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 10 Jun 2026 22:11:33 -0500 Subject: [PATCH 04/23] update packages and add success and loading indicator --- .claude/settings.local.json | 4 +- Tests/CalculatorTests.cs | 50 ++++----- Tests/Tests.csproj | 2 +- Text-Grab/Controls/PreviousGrabWindow.xaml | 35 +++++- Text-Grab/Controls/PreviousGrabWindow.xaml.cs | 51 ++++++++- Text-Grab/Services/CalculationService.cs | 20 ++-- Text-Grab/Text-Grab.csproj | 16 +-- Text-Grab/Utilities/OcrUtilities.cs | 102 ++++++++++-------- .../Views/FullscreenGrab.SelectionStyles.cs | 25 ++++- 9 files changed, 206 insertions(+), 99 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5cb540c4..871c7699 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,9 @@ "Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)", "Bash(Select-Object Name)", "PowerShell(dotnet build *)", - "PowerShell(dotnet test *)" + "PowerShell(dotnet test *)", + "PowerShell(Get-ChildItem *)", + "WebFetch(domain:raw.githubusercontent.com)" ], "deny": [] } diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index 77357fec..5201a76d 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -11,7 +11,7 @@ public class CalculatorTests public async Task NCalc_HasBuiltInPi_ReturnsFalse() { // Test if NCalc has built-in Pi constant - AsyncExpression expression = new("Pi"); + Expression expression = new("Pi"); NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Contains("Pi", exception.Message); @@ -21,7 +21,7 @@ public async Task NCalc_HasBuiltInPi_ReturnsFalse() public async Task NCalc_HasBuiltInE_ReturnsFalse() { // Test if NCalc has built-in E constant - AsyncExpression expression = new("E"); + Expression expression = new("E"); NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Contains("E", exception.Message); @@ -44,16 +44,15 @@ public async Task NCalc_SupportsBasicMathFunctions() foreach ((string? expr, double expected) in tests) { - AsyncExpression expression = new(expr); + Expression expression = new(expr); // Add E parameter for the Log test if (expr.Contains('E')) { - expression.EvaluateParameterAsync += (name, args) => + expression.EvaluateParameter += (name, args) => { if (name == "E") args.Result = Math.E; - return ValueTask.CompletedTask; }; } @@ -67,12 +66,11 @@ public async Task NCalc_SupportsBasicMathFunctions() public async Task NCalc_WithCustomPiParameter_Works() { // Test that we can add Pi as a parameter - AsyncExpression expression = new("Sin(Pi/2)"); - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new("Sin(Pi/2)"); + expression.EvaluateParameter += (name, args) => { if (name == "Pi") args.Result = Math.PI; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -84,12 +82,11 @@ public async Task NCalc_WithCustomPiParameter_Works() public async Task NCalc_WithCustomEParameter_Works() { // Test that we can add E as a parameter - AsyncExpression expression = new("Log(E, E)"); // Log(value, base) format - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new("Log(E, E)"); // Log(value, base) format + expression.EvaluateParameter += (name, args) => { if (name == "E") args.Result = Math.E; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -101,8 +98,8 @@ public async Task NCalc_WithCustomEParameter_Works() public async Task NCalc_WithMultipleMathConstants_Works() { // Test multiple math constants together - AsyncExpression expression = new("Pi * E"); - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new("Pi * E"); + expression.EvaluateParameter += (name, args) => { args.Result = name switch { @@ -110,7 +107,6 @@ public async Task NCalc_WithMultipleMathConstants_Works() "E" => Math.E, _ => throw new ArgumentException($"Unknown parameter: {name}") }; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -141,12 +137,11 @@ public void MathConstants_HaveCorrectValues(string constantName, double expected public async Task NCalc_WithTauConstant_Works() { // Test that we can use Tau (2*Pi) - AsyncExpression expression = new("Tau/2"); - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new("Tau/2"); + expression.EvaluateParameter += (name, args) => { if (name == "Tau") args.Result = Math.Tau; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -172,8 +167,8 @@ public async Task NCalc_CaseInsensitive_MathConstants() foreach ((string? constantName, double expectedValue) in testCases) { - AsyncExpression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions); - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions); + expression.EvaluateParameter += (name, args) => { args.Result = name.ToLower() switch { @@ -182,7 +177,6 @@ public async Task NCalc_CaseInsensitive_MathConstants() "tau" => Math.Tau, _ => throw new ArgumentException($"Unknown parameter: {name}") }; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -195,8 +189,8 @@ public async Task NCalc_CaseInsensitive_MathConstants() public async Task NCalc_ComplexMathExpression_WithConstants() { // Test complex expression using multiple constants - AsyncExpression expression = new("Sin(Pi/6) + Cos(Pi/3) + Log(E, E)"); // Using Log(value, base) - expression.EvaluateParameterAsync += (name, args) => + Expression expression = new("Sin(Pi/6) + Cos(Pi/3) + Log(E, E)"); // Using Log(value, base) + expression.EvaluateParameter += (name, args) => { args.Result = name switch { @@ -204,7 +198,6 @@ public async Task NCalc_ComplexMathExpression_WithConstants() "E" => Math.E, _ => throw new ArgumentException($"Unknown parameter: {name}") }; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -217,9 +210,9 @@ public async Task NCalc_ComplexMathExpression_WithConstants() public async Task AsyncNCalc_WithMathConstants_Works() { // Test async version with constants - AsyncExpression expression = new("Sqrt(Pi * E)", ExpressionOptions.IgnoreCaseAtBuiltInFunctions); + Expression expression = new("Sqrt(Pi * E)", ExpressionOptions.IgnoreCaseAtBuiltInFunctions); - expression.EvaluateParameterAsync += (name, args) => + expression.EvaluateParameter += (name, args) => { args.Result = name.ToLower() switch { @@ -227,7 +220,6 @@ public async Task AsyncNCalc_WithMathConstants_Works() "e" => Math.E, _ => throw new ArgumentException($"Unknown parameter: {name}") }; - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); @@ -247,9 +239,9 @@ public async Task AsyncNCalc_WithMathConstants_Works() public async Task MathConstants_Integration_Test(string constantName, double expectedValue) { // Test the TryGetMathConstant method logic using realistic expressions - AsyncExpression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions); + Expression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions); - expression.EvaluateParameterAsync += (name, args) => + expression.EvaluateParameter += (name, args) => { // Simulate the TryGetMathConstant logic double value = name.ToLowerInvariant() switch @@ -270,8 +262,6 @@ public async Task MathConstants_Integration_Test(string constantName, double exp if (!double.IsNaN(value)) args.Result = value; - - return ValueTask.CompletedTask; }; double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 5d1fec52..664d059a 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Text-Grab/Controls/PreviousGrabWindow.xaml b/Text-Grab/Controls/PreviousGrabWindow.xaml index 743203c9..ffc35b7e 100644 --- a/Text-Grab/Controls/PreviousGrabWindow.xaml +++ b/Text-Grab/Controls/PreviousGrabWindow.xaml @@ -1,10 +1,11 @@ - - + + + + + + + + diff --git a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs index 063fb0f2..d27eb29b 100644 --- a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs +++ b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs @@ -1,15 +1,32 @@ -using System; +using System; using System.Windows; using System.Windows.Threading; namespace Text_Grab.Controls; +/// +/// The visual state shown inside the border overlay. +/// +public enum PreviousGrabIndicator +{ + /// Only the border flashes briefly. + None, + + /// A checkmark icon is shown briefly to indicate a successful grab. + Success, + + /// A spinner is shown until the caller invokes or closes the window. + Loading, +} + /// /// Interaction logic for PreviousGrabWindow.xaml /// public partial class PreviousGrabWindow : Window { - public PreviousGrabWindow(Rect rect) + private static readonly TimeSpan flashDuration = TimeSpan.FromMilliseconds(500); + + public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousGrabIndicator.None) { InitializeComponent(); @@ -20,8 +37,36 @@ public PreviousGrabWindow(Rect rect) Left = rect.Left - borderThickness; Top = rect.Top - borderThickness; + switch (indicator) + { + case PreviousGrabIndicator.Success: + SuccessViewbox.Visibility = Visibility.Visible; + CloseAfterDelay(); + break; + case PreviousGrabIndicator.Loading: + LoadingViewbox.Visibility = Visibility.Visible; + break; + case PreviousGrabIndicator.None: + default: + CloseAfterDelay(); + break; + } + } + + /// + /// Swaps the loading spinner for the success checkmark, then closes shortly after. + /// + public void ShowSuccess() + { + LoadingViewbox.Visibility = Visibility.Collapsed; + SuccessViewbox.Visibility = Visibility.Visible; + CloseAfterDelay(); + } + + private void CloseAfterDelay() + { DispatcherTimer timer = new(); - timer.Interval = TimeSpan.FromMilliseconds(500); + timer.Interval = flashDuration; timer.Tick += (s, e) => { timer.Stop(); Close(); }; timer.Start(); } diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 25ffa930..7502ce63 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -412,7 +412,7 @@ private async Task HandleParameterAssignmentAsync(string line) // Evaluate the expression to get the value expression = StandardizeDecimalAndGroupSeparators(expression); ExpressionOptions option = ExpressionOptions.IgnoreCaseAtBuiltInFunctions; - AsyncExpression expr = new(expression, option) + Expression expr = new(expression, option) { CultureInfo = CultureInfo ?? CultureInfo.CurrentCulture }; @@ -421,7 +421,7 @@ private async Task HandleParameterAssignmentAsync(string line) throw new ArgumentException($"Expression for '{variableName}' is empty."); // Set up parameter handler for existing parameters - expr.EvaluateParameterAsync += (name, args) => + expr.EvaluateParameter += (name, args) => { if (_parameters.ContainsKey(name)) { @@ -435,7 +435,6 @@ private async Task HandleParameterAssignmentAsync(string line) { args.Result = null; // Default to null if parameter not found } - return ValueTask.CompletedTask; }; // Register custom functions @@ -465,13 +464,13 @@ private async Task EvaluateStandardExpressionAsync(string line) ExpressionOptions option = ExpressionOptions.IgnoreCaseAtBuiltInFunctions; line = StandardizeDecimalAndGroupSeparators(line); - AsyncExpression expression = new(line, option) + Expression expression = new(line, option) { CultureInfo = CultureInfo ?? CultureInfo.CurrentCulture, }; // Set up parameter handler - expression.EvaluateParameterAsync += (name, args) => + expression.EvaluateParameter += (name, args) => { if (_parameters.ContainsKey(name)) { @@ -481,7 +480,6 @@ private async Task EvaluateStandardExpressionAsync(string line) { args.Result = constantValue; } - return ValueTask.CompletedTask; }; // Register custom functions @@ -590,23 +588,23 @@ public IReadOnlyDictionary GetParameters() /// /// Registers custom functions for the expression evaluator. /// - private static void RegisterCustomFunctions(AsyncExpression expression) + private static void RegisterCustomFunctions(Expression expression) { // Register Sum function - expression.EvaluateFunctionAsync += async (name, args) => + expression.EvaluateAsyncFunction += async (name, args) => { if (name.Equals("Sum", StringComparison.OrdinalIgnoreCase)) { - if (args.Parameters.Length == 0) + if (args.Parameters.Count == 0) { args.Result = 0; return; } decimal sum = 0m; - foreach (AsyncExpression parameter in args.Parameters) + for (int i = 0; i < args.Parameters.Count; i++) { - object? value = await parameter.EvaluateAsync(); + object? value = await args.Parameters.EvaluateAsync(i); // Handle different numeric types if (value is null) diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 2cbfa912..fabf80af 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -68,16 +68,16 @@ - - - + + + - - - - - + + + + + diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 6ca9befd..7dc1c366 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -301,31 +301,40 @@ public static async void GetCopyTextFromPreviousRegion() Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); - PreviousGrabWindow previousGrab = new(lastFsg.PositionRect); + PreviousGrabWindow previousGrab = new(lastFsg.PositionRect, PreviousGrabIndicator.Loading); previousGrab.Show(); - ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); - string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); - (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = - LanguageUtilities.GetPersistedLanguageIdentity(language); - - HistoryInfo newPrevRegionHistory = new() + try { - ID = Guid.NewGuid().ToString(), - CaptureDateTime = DateTimeOffset.Now, - ImageContent = Singleton.Instance.CachedBitmap, - TextContent = grabbedText, - PositionRect = lastFsg.PositionRect, - LanguageTag = languageTag, - LanguageKind = languageKind, - UsedUiAutomation = usedUiAutomation, - IsTable = lastFsg.IsTable, - SourceMode = TextGrabMode.Fullscreen, - DpiScaleFactor = lastFsg.DpiScaleFactor, - }; - Singleton.Instance.SaveToHistory(newPrevRegionHistory); + ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); + string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); - OutputUtilities.HandleTextFromOcr(grabbedText, false, lastFsg.IsTable, null); + HistoryInfo newPrevRegionHistory = new() + { + ID = Guid.NewGuid().ToString(), + CaptureDateTime = DateTimeOffset.Now, + ImageContent = Singleton.Instance.CachedBitmap, + TextContent = grabbedText, + PositionRect = lastFsg.PositionRect, + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, + IsTable = lastFsg.IsTable, + SourceMode = TextGrabMode.Fullscreen, + DpiScaleFactor = lastFsg.DpiScaleFactor, + }; + Singleton.Instance.SaveToHistory(newPrevRegionHistory); + + OutputUtilities.HandleTextFromOcr(grabbedText, false, lastFsg.IsTable, null); + previousGrab.ShowSuccess(); + } + catch + { + previousGrab.Close(); + throw; + } } public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinationTextBox = null) @@ -340,31 +349,40 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); - PreviousGrabWindow previousGrab = new(lastFsg.PositionRect); + PreviousGrabWindow previousGrab = new(lastFsg.PositionRect, PreviousGrabIndicator.Loading); previousGrab.Show(); - ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); - string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); - (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = - LanguageUtilities.GetPersistedLanguageIdentity(language); - - HistoryInfo newPrevRegionHistory = new() + try { - ID = Guid.NewGuid().ToString(), - CaptureDateTime = DateTimeOffset.Now, - ImageContent = Singleton.Instance.CachedBitmap, - TextContent = grabbedText, - PositionRect = lastFsg.PositionRect, - LanguageTag = languageTag, - LanguageKind = languageKind, - UsedUiAutomation = usedUiAutomation, - IsTable = lastFsg.IsTable, - SourceMode = TextGrabMode.Fullscreen, - DpiScaleFactor = lastFsg.DpiScaleFactor, - }; - Singleton.Instance.SaveToHistory(newPrevRegionHistory); + ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); + string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); - OutputUtilities.HandleTextFromOcr(grabbedText, false, lastFsg.IsTable, destinationTextBox); + HistoryInfo newPrevRegionHistory = new() + { + ID = Guid.NewGuid().ToString(), + CaptureDateTime = DateTimeOffset.Now, + ImageContent = Singleton.Instance.CachedBitmap, + TextContent = grabbedText, + PositionRect = lastFsg.PositionRect, + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, + IsTable = lastFsg.IsTable, + SourceMode = TextGrabMode.Fullscreen, + DpiScaleFactor = lastFsg.DpiScaleFactor, + }; + Singleton.Instance.SaveToHistory(newPrevRegionHistory); + + OutputUtilities.HandleTextFromOcr(grabbedText, false, lastFsg.IsTable, destinationTextBox); + previousGrab.ShowSuccess(); + } + catch + { + previousGrab.Close(); + throw; + } } public static async Task> GetTextFromRandomAccessStream(IRandomAccessStream randomAccessStream, ILanguage language) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index ce283f92..6a941721 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -11,6 +11,7 @@ using System.Windows.Media.Imaging; using System.Windows.Shapes; using System.Windows.Threading; +using Text_Grab.Controls; using Text_Grab.Extensions; using Text_Grab.Interfaces; using Text_Grab.Models; @@ -1029,6 +1030,27 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool return; } + // Show a loading indicator over the grabbed region while OCR and any + // post-grab actions run, then flash a success icon once text is handled. + PreviousGrabWindow grabIndicator = new(GetHistoryPositionRect(selection), PreviousGrabIndicator.Loading); + grabIndicator.Show(); + + bool grabbedText = false; + try + { + grabbedText = await CommitSelectionCoreAsync(selection, isSmallClick); + } + finally + { + if (grabbedText) + grabIndicator.ShowSuccess(); + else + grabIndicator.Close(); + } + } + + private async Task CommitSelectionCoreAsync(FullscreenCaptureResult selection, bool isSmallClick) + { if (LanguagesComboBox.SelectedItem is not ILanguage selectedOcrLang) selectedOcrLang = LanguageUtilities.GetOCRLanguage(); @@ -1113,7 +1135,7 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool else ResetSelectionVisualState(); - return; + return false; } if (NextStepDropDownButton.Flyout is ContextMenu contextMenu) @@ -1201,6 +1223,7 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool isTable, destinationTextBox); WindowUtilities.CloseAllFullscreenGrabs(); + return true; } private async void AcceptSelectionButton_Click(object sender, RoutedEventArgs e) From 5d888c24b5fa0e94d2f34752d978fa612f433c19 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 10 Jun 2026 22:23:07 -0500 Subject: [PATCH 05/23] Reduce flash duration to 300ms and refactor timer init Shortened the PreviousGrabWindow flash duration from 500ms to 300ms for a snappier UI experience. Refactored DispatcherTimer initialization to use an object initializer for improved code clarity. --- Text-Grab/Controls/PreviousGrabWindow.xaml.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs index d27eb29b..9181206a 100644 --- a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs +++ b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs @@ -24,7 +24,7 @@ public enum PreviousGrabIndicator /// public partial class PreviousGrabWindow : Window { - private static readonly TimeSpan flashDuration = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan flashDuration = TimeSpan.FromMilliseconds(300); public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousGrabIndicator.None) { @@ -65,8 +65,10 @@ public void ShowSuccess() private void CloseAfterDelay() { - DispatcherTimer timer = new(); - timer.Interval = flashDuration; + DispatcherTimer timer = new() + { + Interval = flashDuration + }; timer.Tick += (s, e) => { timer.Stop(); Close(); }; timer.Start(); } From acf9e030c9b6a346a7bf09e334887707d3e0b9b7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 14:39:23 -0500 Subject: [PATCH 06/23] Lazily build WordBorder context menu in code-behind Remove static ContextMenu from XAML and create it on demand in code-behind. This reduces memory usage by avoiding duplicate menu trees for each WordBorder and centralizes menu logic. ContextMenu items are now initialized only when first opened. --- Text-Grab/Controls/WordBorder.xaml | 73 +++++---------------------- Text-Grab/Controls/WordBorder.xaml.cs | 63 +++++++++++++++++++++-- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml index 9461a2d7..deb63d15 100644 --- a/Text-Grab/Controls/WordBorder.xaml +++ b/Text-Grab/Controls/WordBorder.xaml @@ -12,7 +12,9 @@ MouseEnter="WordBorder_MouseEnter" MouseLeave="WordBorder_MouseLeave" MouseMove="WordBorder_MouseEnter" - ToolTip="{Binding Path=Word, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" + ToolTip="{Binding Path=Word, + Mode=OneWay, + UpdateSourceTrigger=PropertyChanged}" Unloaded="WordBorderControl_Unloaded" mc:Ignorable="d"> @@ -67,58 +69,6 @@ - - - - - - - - - - - - - - @@ -133,7 +83,6 @@ Margin="0,0,0,0" BorderBrush="#308E98" BorderThickness="0,0,0,2" - ContextMenu="{StaticResource ContextOptions}" ContextMenuOpening="EditWordTextBox_ContextMenuOpening" CornerRadius="0"> @@ -145,7 +94,6 @@ Margin="-1,-3,-1,-1" d:Text="Test g" AcceptsReturn="True" - ContextMenu="{StaticResource ContextOptions}" ContextMenuOpening="EditWordTextBox_ContextMenuOpening" FontFamily="Segoe UI" FontSize="12" @@ -154,26 +102,31 @@ GotFocus="EditWordTextBox_GotFocus" MouseDown="EditWordTextBox_MouseDown" Style="{StaticResource TransparentTextBox}" - Text="{Binding ElementName=WordBorderControl, Path=DisplayText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + Text="{Binding ElementName=WordBorderControl, + Path=DisplayText, + Mode=TwoWay, + UpdateSourceTrigger=PropertyChanged}" TextChanged="EditWordTextBox_TextChanged" Visibility="Visible" /> + Visibility="{Binding Path=TemplateBadgeVisibility, + ElementName=WordBorderControl}"> + Text="{Binding Path=TemplateBadgeText, + ElementName=WordBorderControl}" /> 0) + return; + + contextMenu.Items.Add(NewContextMenuItem("Copy Text", CopyWordMenuItem_Click)); + contextMenu.Items.Add(NewContextMenuItem("Try To Make _Numbers", TryToNumberMenuItem_Click)); + contextMenu.Items.Add(NewContextMenuItem("Try To Make _Letters", TryToAlphaMenuItem_Click)); + contextMenu.Items.Add(NewContextMenuItem("Make Text _Single Line", MakeSingleLineMenuItem_Click)); + contextMenu.Items.Add(new Separator()); + + MenuItem translateMenuItem = NewContextMenuItem("Translate to System Language", TranslateWordMenuItem_Click); + translateMenuItem.Name = "TranslateWordMenuItem"; + translateMenuItem.Visibility = Visibility.Collapsed; + contextMenu.Items.Add(translateMenuItem); + contextMenu.Items.Add(new Separator() + { + Name = "TranslateSeparator", + Visibility = Visibility.Collapsed + }); + + contextMenu.Items.Add(new MenuItem() + { + Header = "_Merge Selected Word Borders", + HorizontalAlignment = HorizontalAlignment.Left, + Command = MergeWordsCommand, + InputGestureText = "Ctrl + M" + }); + contextMenu.Items.Add(NewContextMenuItem("_Break into words", BreakIntoWordsMenuItem_Click)); + contextMenu.Items.Add(NewContextMenuItem("_Search for similar text", SearchForSimilarMenuItem_Click)); + contextMenu.Items.Add(new Separator()); + contextMenu.Items.Add(NewContextMenuItem("_Delete", DeleteWordMenuItem_Click)); + + contextMenuBaseSize = contextMenu.Items.Count; + } + private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventArgs e) { - if (sender is not FrameworkElement senderElement) + if (sender is not FrameworkElement senderElement + || senderElement.ContextMenu is not ContextMenu textBoxContextMenu) + { return; + } - ContextMenu textBoxContextMenu = senderElement.ContextMenu; + EnsureContextMenuItems(textBoxContextMenu); while (textBoxContextMenu.Items.Count > contextMenuBaseSize) { From 3f9bc42de1c4ed8faee7c682b500c151bd5ae340 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 14:43:05 -0500 Subject: [PATCH 07/23] Format feet/inches output and fix decimal parsing bugs - Add tests for "X ft Y in" formatting and decimal edge cases - Format length conversions to feet as "X ft Y in" in CalculationService - Fix ParseNumericString to distinguish decimals from thousands separators - Use source-generated regex for numeric token extraction - Minor code style and clarity improvements --- Tests/UnitConversionTests.cs | 80 ++++++++++++++++++- .../Services/CalculationService.UnitMath.cs | 36 ++++++++- Text-Grab/Utilities/NumericUtilities.cs | 30 ++++--- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs index 950728a8..1170dd15 100644 --- a/Tests/UnitConversionTests.cs +++ b/Tests/UnitConversionTests.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Text_Grab.Services; namespace Tests; @@ -40,6 +39,7 @@ public async Task ExplicitConversion_ContainsTargetUnit(string input, string exp [InlineData("100 F to C", 37.778, 0.01)] [InlineData("0 C to F", 32, 0.01)] [InlineData("1 foot to inches", 12, 0.01)] + [InlineData("0.876 ft to in", 10.512, 0.1)] [InlineData("1 mile to feet", 5280, 1)] [InlineData("1 gallon to liters", 3.785, 0.01)] [InlineData("1 kg to grams", 1000, 0.01)] @@ -140,6 +140,82 @@ public async Task ExplicitConversion_IncompatibleTypes_FallsThrough() #endregion Explicit Conversion Tests + #region Feet and Inches Tests + + [Theory] + [InlineData("1.9 meters to feet", "6 ft 3 in")] + [InlineData("1 meter to feet", "3 ft 3 in")] + [InlineData("6 feet to feet", "6 ft")] + [InlineData("12 inches to feet", "1 ft")] + [InlineData("1 mile to feet", "5280 ft")] + public async Task ConversionToFeet_FormatsAsFeetAndInches(string input, string expectedOutput) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Equal(expectedOutput, result.Output); + } + + [Fact] + public async Task ConversionToFeet_StillTracksNumericValue() + { + // OutputNumbers should still contain the fractional feet value + CalculationResult result = await _service.EvaluateExpressionsAsync("1.9 meters to feet"); + + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], 6.23, 6.24); + } + + [Fact] + public async Task ContinuationConversionToFeet_FormatsAsFeetAndInches() + { + string input = "1.9 meters\nto feet"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("6 ft", lines[1]); + Assert.Contains("in", lines[1]); + } + + #endregion Feet and Inches Tests + + #region Decimal Parsing Tests + + /// + /// Regression: a 3-digit decimal like 0.345 was incorrectly stripped of its dot + /// (treated as a European thousands separator like "1.000") and parsed as 345. + /// The fix: only strip the dot when the integer part doesn't start with 0. + /// + [Theory] + [InlineData("0.345 meters to cm", 34.5, 0.01)] + [InlineData("0.100 km to meters", 100, 0.1)] + [InlineData("0.500 kg to grams", 500, 0.1)] + [InlineData("0.125 miles to km", 0.2012, 0.01)] + public async Task DecimalWithThreeDigits_ParsedCorrectly(string input, double expectedValue, double tolerance) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance); + } + + [Theory] + [InlineData("1.000 km to meters", 1000000, 1)] // "1.000" → thousands sep → 1000 km → 1,000,000 m + [InlineData("2.000 meters to cm", 200000, 1)] // "2.000" → thousands sep → 2000 m → 200,000 cm + public async Task DecimalVsThousandsSeparator_CorrectBehavior(string input, double expectedValue, double tolerance) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance); + } + + #endregion Decimal Parsing Tests + #region Continuation Conversion Tests [Fact] @@ -474,7 +550,7 @@ public void TryEvaluateUnitConversion_ContinuationWithoutPrevious_ReturnsFalse() [Fact] public void TryEvaluateUnitConversion_ContinuationWithPrevious_ReturnsTrue() { - var previous = new CalculationService.UnitResult + CalculationService.UnitResult previous = new() { Value = 5, Unit = UnitsNet.Units.LengthUnit.Mile, diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index 30809fb6..e065a5de 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -308,12 +308,14 @@ private bool TryContinuationConversion( QuantityName = target.QuantityName, Abbreviation = target.Abbreviation }; - result = FormatUnitValue(convertedValue, target.Abbreviation); + result = target.Unit is LengthUnit.Foot + ? FormatFeetAndInches(convertedValue) + : FormatUnitValue(convertedValue, target.Abbreviation); return true; } /// - /// Handles "+ 3 km" or "- 5 miles" operator continuation with units. + /// Handles "+ /// Converts the operand to the previous unit before adding/subtracting. /// private bool TryOperatorWithUnit( @@ -450,12 +452,14 @@ private bool TryExplicitConversion( QuantityName = targetUnit.QuantityName, Abbreviation = targetUnit.Abbreviation }; - result = FormatUnitValue(convertedValue, targetUnit.Abbreviation); + result = targetUnit.Unit is LengthUnit.Foot + ? FormatFeetAndInches(convertedValue) + : FormatUnitValue(convertedValue, targetUnit.Abbreviation); return true; } /// - /// Handles standalone unit expressions like "5 meters" — tracks the unit for future + /// Handles standalone unit expressions /// continuation but does not convert. Requires multi-character unit abbreviations /// to avoid conflicts with single-letter variable names and quantity words. /// @@ -699,6 +703,30 @@ private string FormatUnitValue(double value, string abbreviation) return $"{formatted} {abbreviation}"; } + /// + /// Formats a fractional feet value as a human-readable "X ft Y in" string. + /// For example, 6.2336 feet → "6 ft 3 in", 6.0 feet → "6 ft". + /// + private static string FormatFeetAndInches(double totalFeet) + { + bool negative = totalFeet < 0; + double absoluteFeet = Math.Abs(totalFeet); + int wholeFeet = (int)Math.Floor(absoluteFeet); + double remainingInches = (absoluteFeet - wholeFeet) * 12; + int wholeInches = (int)Math.Round(remainingInches); + + if (wholeInches == 12) + { + wholeFeet++; + wholeInches = 0; + } + + string sign = negative ? "-" : ""; + return wholeInches == 0 + ? $"{sign}{wholeFeet} ft" + : $"{sign}{wholeFeet} ft {wholeInches} in"; + } + #endregion Unit Conversion Helpers #region Unit Math Regex Patterns diff --git a/Text-Grab/Utilities/NumericUtilities.cs b/Text-Grab/Utilities/NumericUtilities.cs index 93943f97..51aea17a 100644 --- a/Text-Grab/Utilities/NumericUtilities.cs +++ b/Text-Grab/Utilities/NumericUtilities.cs @@ -7,11 +7,9 @@ namespace Text_Grab.Utilities; -public static class NumericUtilities +public static partial class NumericUtilities { - private static readonly Regex FirstNumericTokenRegex = new( - @"[-+]?(?:(?:\d[\d\s_,.]*)?\d)(?:[eE][-+]?\d+)?", - RegexOptions.Compiled); + private static readonly Regex FirstNumericTokenRegex = FirstNumericToken(); public static double CalculateMedian(List numbers) { @@ -38,26 +36,26 @@ public static string FormatNumber(double value) // Handle special floating-point values first if (double.IsNaN(value)) return "NaN"; - + if (double.IsPositiveInfinity(value)) return "∞"; - + if (double.IsNegativeInfinity(value)) return "-∞"; - + double absValue = Math.Abs(value); - + // Use scientific notation for very large or very small numbers - if (absValue >= 1e15 || (absValue < 1e-4 && absValue > 0)) + if (absValue is >= 1e15 or < 1e-4 and > 0) { return value.ToString("E6", CultureInfo.CurrentCulture); } - + // Check if value is "close enough" to an integer using epsilon comparison // Use a small tolerance to account for floating-point precision double fractionalPart = Math.Abs(value - Math.Round(value)); bool isEffectivelyInteger = fractionalPart < 1e-10 && absValue < 1e10; - + if (isEffectivelyInteger) { return Math.Round(value).ToString("N0", CultureInfo.CurrentCulture); @@ -114,7 +112,7 @@ private static string NormalizeNumberString(string input) StringBuilder sb = new(); foreach (char c in input.Trim()) { - if (c != ' ' && c != '_') + if (c is not ' ' and not '_') sb.Append(c); } @@ -150,11 +148,17 @@ private static string NormalizeNumberString(string input) int lastDotIndex = compact.LastIndexOf('.'); int digitsAfterDot = compact.Length - lastDotIndex - 1; bool hasMultipleDots = compact.Count(c => c == '.') > 1; + // A leading 0 before the dot (e.g. "0.345") means it can't be a thousands separator. + string beforeDot = compact[..lastDotIndex].TrimStart('-'); + bool couldBeThousandsSep = beforeDot.Length > 0 && !beforeDot.StartsWith('0'); - if (hasMultipleDots || digitsAfterDot == 3) + if (hasMultipleDots || (digitsAfterDot == 3 && couldBeThousandsSep)) compact = compact.Replace(".", string.Empty); } return compact; } + + [GeneratedRegex(@"[-+]?(?:(?:\d[\d\s_,.]*)?\d)(?:[eE][-+]?\d+)?", RegexOptions.Compiled)] + private static partial Regex FirstNumericToken(); } From 8080c4f019e1243acb0044b701373d59658a2437 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 14:44:38 -0500 Subject: [PATCH 08/23] Refactor UndoRedo transaction logic and add tests Added UndoRedoTests to verify stack capacity and transaction grouping. Made AddOperationToUndoStack and UndoOperationCount internal for testing. Refactored transaction counting and trimming logic to handle new transactions and stack limits more accurately. Improved comments and adjusted Undo/EndTransaction behavior for correctness. --- Tests/UndoRedoTests.cs | 81 ++++++++++++++++++++++++ Text-Grab/UndoRedoOperations/UndoRedo.cs | 35 ++++++---- 2 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 Tests/UndoRedoTests.cs diff --git a/Tests/UndoRedoTests.cs b/Tests/UndoRedoTests.cs new file mode 100644 index 00000000..4a37d95c --- /dev/null +++ b/Tests/UndoRedoTests.cs @@ -0,0 +1,81 @@ +using Text_Grab.UndoRedoOperations; + +namespace Tests; + +public class UndoRedoTests +{ + private sealed class FakeOperation(uint transactionId) : IUndoRedoOperation + { + public uint TransactionId { get; } = transactionId; + + public int UndoCount { get; private set; } + + public int RedoCount { get; private set; } + + public UndoRedoOperation GetUndoRedoOperation() => UndoRedoOperation.None; + + public void Undo() => UndoCount++; + + public void Redo() => RedoCount++; + } + + [Fact] + public void UndoStack_TrimsOldestTransactions_WhenOverCapacity() + { + UndoRedo undoRedo = new(); + int transactionCount = UndoRedo.UndoRedoTransactionCapacity + 50; + + for (uint transactionId = 0; transactionId < transactionCount; transactionId++) + { + undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId)); + undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId)); + } + + Assert.Equal(UndoRedo.UndoRedoTransactionCapacity * 2, undoRedo.UndoOperationCount); + } + + [Fact] + public void UndoStack_KeepsAllOperations_WhenUnderCapacity() + { + UndoRedo undoRedo = new(); + + for (uint transactionId = 0; transactionId < 10; transactionId++) + undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId)); + + Assert.Equal(10, undoRedo.UndoOperationCount); + } + + [Fact] + public void Undo_RunsAllOperationsOfNewestTransaction() + { + UndoRedo undoRedo = new(); + FakeOperation olderOperation = new(transactionId: 1); + FakeOperation newerOperation1 = new(transactionId: 2); + FakeOperation newerOperation2 = new(transactionId: 2); + undoRedo.AddOperationToUndoStack(olderOperation); + undoRedo.AddOperationToUndoStack(newerOperation1); + undoRedo.AddOperationToUndoStack(newerOperation2); + + undoRedo.Undo(); + + Assert.Equal(0, olderOperation.UndoCount); + Assert.Equal(1, newerOperation1.UndoCount); + Assert.Equal(1, newerOperation2.UndoCount); + Assert.Equal(1, undoRedo.UndoOperationCount); + Assert.True(undoRedo.HasRedoOperations()); + } + + [Fact] + public void Reset_ClearsAllOperations() + { + UndoRedo undoRedo = new(); + undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId: 1)); + undoRedo.Undo(); + undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId: 2)); + + undoRedo.Reset(); + + Assert.False(undoRedo.HasUndoOperations()); + Assert.False(undoRedo.HasRedoOperations()); + } +} diff --git a/Text-Grab/UndoRedoOperations/UndoRedo.cs b/Text-Grab/UndoRedoOperations/UndoRedo.cs index 0f4df573..97624186 100644 --- a/Text-Grab/UndoRedoOperations/UndoRedo.cs +++ b/Text-Grab/UndoRedoOperations/UndoRedo.cs @@ -16,6 +16,9 @@ internal class UndoRedo private LinkedList UndoStack { get; } = new(); + // Exposed for tests so capacity trimming can be verified. + internal int UndoOperationCount => UndoStack.Count; + // used for readability. public void StartTransaction() { @@ -24,10 +27,7 @@ public void StartTransaction() public void EndTransaction() { if (TransactionId <= HighestUsedTransactionId) - { TransactionId++; - ActiveTransactionIdCount++; - } } public void Reset() @@ -39,21 +39,27 @@ public void Reset() ActiveTransactionIdCount = 0; } - private void AddOperationToUndoStack(IUndoRedoOperation operation) + internal void AddOperationToUndoStack(IUndoRedoOperation operation) { - if (ActiveTransactionIdCount >= UndoRedoTransactionCapacity) + // A transaction is a run of operations sharing a TransactionId, so a + // new transaction starts whenever the incoming id differs from the + // last stacked operation. Counting here (instead of in EndTransaction) + // also covers operations inserted without transaction bracketing. + if (UndoStack.Last is null || UndoStack.Last.Value.TransactionId != operation.TransactionId) + ++ActiveTransactionIdCount; + + UndoStack.AddLast(operation); + + // Trim whole transactions from the oldest end so the stack cannot pin + // an unbounded number of WordBorder controls and their visual trees. + while (ActiveTransactionIdCount > UndoRedoTransactionCapacity && UndoStack.First is not null) { - uint? transactionIdToRemove = UndoStack.First?.Value.TransactionId; - while (UndoStack.First?.Value.TransactionId == transactionIdToRemove && transactionIdToRemove is not null) - { - if (UndoStack.Count != 0) - UndoStack.RemoveFirst(); - } + uint transactionIdToRemove = UndoStack.First.Value.TransactionId; + while (UndoStack.First is not null && UndoStack.First.Value.TransactionId == transactionIdToRemove) + UndoStack.RemoveFirst(); --ActiveTransactionIdCount; } - - UndoStack.AddLast(operation); } private void ClearRedoStack() @@ -134,7 +140,8 @@ public void Undo() operationNode = prev; } - --ActiveTransactionIdCount; + if (ActiveTransactionIdCount > 0) + --ActiveTransactionIdCount; } public void Redo() From fd7adf3a3f0fbd02fe4ed12c48bf70831c4b8950 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 14:50:10 -0500 Subject: [PATCH 09/23] Add ImageChangeDetector utility and unit tests Introduce ImageChangeDetector using Magick.NET to detect stable changes between screen captures by comparing downscaled thumbnails. Add comprehensive unit tests to verify baseline establishment, stable change detection, transient change filtering, and reset behavior. --- Tests/ImageChangeDetectorTests.cs | 73 ++++++++++++++++++ Text-Grab/Utilities/ImageChangeDetector.cs | 86 ++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 Tests/ImageChangeDetectorTests.cs create mode 100644 Text-Grab/Utilities/ImageChangeDetector.cs diff --git a/Tests/ImageChangeDetectorTests.cs b/Tests/ImageChangeDetectorTests.cs new file mode 100644 index 00000000..2b61a58d --- /dev/null +++ b/Tests/ImageChangeDetectorTests.cs @@ -0,0 +1,73 @@ +using System.Drawing; +using Text_Grab.Utilities; + +namespace Tests; + +public class ImageChangeDetectorTests +{ + private const string fontTestPath = @".\Images\FontTest.png"; + private const string fontSamplePath = @".\Images\font_sample.png"; + + [Fact] + public void FirstCapture_EstablishesBaseline_ReportsNoChange() + { + using ImageChangeDetector detector = new(); + using Bitmap image = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + + Assert.False(detector.CheckForChangeAndUpdate(image)); + } + + [Fact] + public void SameCapture_ReportsNoChange() + { + using ImageChangeDetector detector = new(); + using Bitmap image = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + + _ = detector.CheckForChangeAndUpdate(image); + + Assert.False(detector.CheckForChangeAndUpdate(image)); + } + + [Fact] + public void DifferentCapture_ReportsChange_OnceItHoldsForTwoChecks() + { + using ImageChangeDetector detector = new(); + using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath)); + + _ = detector.CheckForChangeAndUpdate(image1); + + // First differing capture is not yet stable, so no change is reported. + Assert.False(detector.CheckForChangeAndUpdate(image2)); + Assert.True(detector.CheckForChangeAndUpdate(image2)); + } + + [Fact] + public void TransientCapture_DoesNotReportChange() + { + using ImageChangeDetector detector = new(); + using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath)); + + _ = detector.CheckForChangeAndUpdate(image1); + + // A one-check blip (flash indicator, half-rendered frame) that + // reverts to the baseline never reports a change. + Assert.False(detector.CheckForChangeAndUpdate(image2)); + Assert.False(detector.CheckForChangeAndUpdate(image1)); + Assert.False(detector.CheckForChangeAndUpdate(image1)); + } + + [Fact] + public void Reset_NextCaptureBecomesBaseline_ReportsNoChange() + { + using ImageChangeDetector detector = new(); + using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath)); + + _ = detector.CheckForChangeAndUpdate(image1); + detector.Reset(); + + Assert.False(detector.CheckForChangeAndUpdate(image2)); + } +} diff --git a/Text-Grab/Utilities/ImageChangeDetector.cs b/Text-Grab/Utilities/ImageChangeDetector.cs new file mode 100644 index 00000000..cbdf34a7 --- /dev/null +++ b/Text-Grab/Utilities/ImageChangeDetector.cs @@ -0,0 +1,86 @@ +using ImageMagick; +using ImageMagick.Factories; +using System; +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace Text_Grab.Utilities; + +/// +/// Detects whether successive captures of the same screen region differ by +/// running a Magick.NET Compare on small downscaled copies, so each check is +/// fast and allocates very little. The first capture after construction or +/// Reset() becomes a fixed baseline; later captures are judged against it. +/// Holds two small images between checks; dispose to release them. +/// +public sealed partial class ImageChangeDetector : IDisposable +{ + // Comparing fixed-size thumbnails keeps Compare cheap regardless of how + // large the captured region is, while word-sized changes still register. + private const int ComparisonSize = 96; + + // NormalizedMeanError at or below this is treated as noise, such as a + // blinking caret or antialiasing differences between captures. + private const double ChangeThreshold = 0.001; + + private readonly MagickImageFactory imageFactory = new(); + private MagickImage? baselineImage; + private MagickImage? previousImage; + + /// + /// Compares the capture against the fixed baseline. Returns true only + /// when the capture differs from the baseline AND matches the previous + /// capture, so a transient state (an indicator flash, a half-rendered + /// frame) never triggers; the content must hold for two checks. The + /// first capture after construction or Reset() establishes the baseline + /// and returns false. + /// + public bool CheckForChangeAndUpdate(Bitmap capture) + { + using Bitmap thumbnail = CreateThumbnail(capture); + + if (imageFactory.Create(thumbnail) is not MagickImage currentImage) + return false; + + if (baselineImage is null) + { + baselineImage = currentImage; + previousImage?.Dispose(); + previousImage = null; + return false; + } + + bool differsFromBaseline = baselineImage.Compare(currentImage).NormalizedMeanError > ChangeThreshold; + bool isStable = previousImage is not null + && previousImage.Compare(currentImage).NormalizedMeanError <= ChangeThreshold; + + previousImage?.Dispose(); + previousImage = currentImage; + + return differsFromBaseline && isStable; + } + + /// + /// Drops the baseline so the next capture starts a fresh comparison. + /// + public void Reset() + { + baselineImage?.Dispose(); + baselineImage = null; + previousImage?.Dispose(); + previousImage = null; + } + + public void Dispose() => Reset(); + + private static Bitmap CreateThumbnail(Bitmap source) + { + Bitmap thumbnail = new(ComparisonSize, ComparisonSize, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using Graphics graphics = Graphics.FromImage(thumbnail); + // HighQualityBilinear prefilters when shrinking, so small on-screen + // changes still influence the thumbnail instead of being skipped over. + graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; + graphics.DrawImage(source, 0, 0, ComparisonSize, ComparisonSize); + return thumbnail; + } +} From 0f2b49a96fe71dda46078d1431702163918f8b75 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 14:57:42 -0500 Subject: [PATCH 10/23] Add ProtocolUtilities for text-grab:// URI handling Implements ProtocolUtilities to parse, recognize, register, and unregister text-grab:// protocol URIs for integration with companion apps and browser extensions. Adds tests for URI recognition, command/parameter parsing, normalization, and error handling to ensure robust protocol support. --- Tests/ProtocolUtilitiesTests.cs | 117 +++++++++++++++++++++ Text-Grab/Utilities/ProtocolUtilities.cs | 128 +++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 Tests/ProtocolUtilitiesTests.cs create mode 100644 Text-Grab/Utilities/ProtocolUtilities.cs diff --git a/Tests/ProtocolUtilitiesTests.cs b/Tests/ProtocolUtilitiesTests.cs new file mode 100644 index 00000000..53bbc58f --- /dev/null +++ b/Tests/ProtocolUtilitiesTests.cs @@ -0,0 +1,117 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class ProtocolUtilitiesTests +{ + [Theory] + [InlineData("text-grab://paste-spreadsheet")] + [InlineData("TEXT-GRAB://EDIT-TEXT")] + [InlineData("text-grab:grab-frame")] + public void IsProtocolUri_RecognizesProtocolArguments(string argument) + { + Assert.True(ProtocolUtilities.IsProtocolUri(argument)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Settings")] + [InlineData(@"C:\images\screenshot.png")] + [InlineData("https://example.com")] + [InlineData("--windowless")] + public void IsProtocolUri_RejectsOtherArguments(string? argument) + { + Assert.False(ProtocolUtilities.IsProtocolUri(argument)); + } + + [Theory] + [InlineData("text-grab://paste-spreadsheet", "paste-spreadsheet")] + [InlineData("text-grab://edit-text", "edit-text")] + [InlineData("text-grab://grab-frame", "grab-frame")] + [InlineData("text-grab://grab-text", "grab-text")] + [InlineData("text-grab://fullscreen", "fullscreen")] + [InlineData("text-grab://quick-lookup", "quick-lookup")] + [InlineData("text-grab://settings", "settings")] + public void TryParseProtocolUri_ParsesCommands(string uri, string expectedCommand) + { + bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out _); + + Assert.True(parsed); + Assert.Equal(expectedCommand, command); + } + + [Theory] + [InlineData("TEXT-GRAB://Paste-Spreadsheet", "paste-spreadsheet")] + [InlineData("text-grab://paste-spreadsheet/", "paste-spreadsheet")] + [InlineData("text-grab:paste-spreadsheet", "paste-spreadsheet")] + public void TryParseProtocolUri_NormalizesCommandForms(string uri, string expectedCommand) + { + bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out _); + + Assert.True(parsed); + Assert.Equal(expectedCommand, command); + } + + [Fact] + public void TryParseProtocolUri_ExtractsUrlEncodedPathParameter() + { + string localPath = @"C:\Users\joe\Downloads\TextGrab\capture 2026-06-12.png"; + string uri = $"text-grab://grab-frame?path={Uri.EscapeDataString(localPath)}"; + + bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out Dictionary parameters); + + Assert.True(parsed); + Assert.Equal("grab-frame", command); + Assert.Equal(localPath, parameters["path"]); + } + + [Fact] + public void TryParseProtocolUri_ParsesGrabTextWithPath() + { + string localPath = @"C:\Users\joe\Downloads\TextGrab\image-2026-06-12.png"; + string uri = $"text-grab://grab-text?path={Uri.EscapeDataString(localPath)}"; + + bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out Dictionary parameters); + + Assert.True(parsed); + Assert.Equal("grab-text", command); + Assert.Equal(localPath, parameters["path"]); + } + + [Fact] + public void TryParseProtocolUri_ParameterKeysAreCaseInsensitive() + { + bool parsed = ProtocolUtilities.TryParseProtocolUri( + "text-grab://grab-frame?PATH=C%3A%5Cimage.png", + out _, + out Dictionary parameters); + + Assert.True(parsed); + Assert.Equal(@"C:\image.png", parameters["path"]); + } + + [Theory] + [InlineData("https://example.com")] + [InlineData("not a uri")] + [InlineData("text-grab://")] + [InlineData("")] + public void TryParseProtocolUri_RejectsInvalidUris(string uri) + { + Assert.False(ProtocolUtilities.TryParseProtocolUri(uri, out _, out _)); + } + + [Fact] + public void TryParseProtocolUri_IgnoresMalformedQueryPairs() + { + bool parsed = ProtocolUtilities.TryParseProtocolUri( + "text-grab://grab-frame?=novalue&path=C%3A%5Ca.png&flag", + out string command, + out Dictionary parameters); + + Assert.True(parsed); + Assert.Equal("grab-frame", command); + Assert.Single(parameters); + Assert.Equal(@"C:\a.png", parameters["path"]); + } +} diff --git a/Text-Grab/Utilities/ProtocolUtilities.cs b/Text-Grab/Utilities/ProtocolUtilities.cs new file mode 100644 index 00000000..378507c5 --- /dev/null +++ b/Text-Grab/Utilities/ProtocolUtilities.cs @@ -0,0 +1,128 @@ +using Microsoft.Win32; +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Text_Grab.Utilities; + +/// +/// Utility class for the text-grab:// protocol used by companion apps such as +/// the Text Grab browser extension. The URI is only a command channel; any +/// data payload (like a copied table) travels via the clipboard. +/// Supported URIs: +/// text-grab://paste-spreadsheet Edit Text window in spreadsheet mode, paste clipboard +/// text-grab://edit-text Edit Text window with clipboard text +/// text-grab://grab-frame[?path=...] Grab Frame, optionally opening a local image/PDF +/// text-grab://grab-text?path=... OCR a local image/PDF straight to the clipboard (no window) +/// text-grab://fullscreen Fullscreen grab +/// text-grab://quick-lookup Quick Simple Lookup +/// text-grab://settings Settings window +/// +internal static class ProtocolUtilities +{ + internal const string Scheme = "text-grab"; + + private const string ProtocolKeyPath = @"Software\Classes\" + Scheme; + + /// + /// Returns true when a startup argument looks like a text-grab:// URI. + /// + internal static bool IsProtocolUri(string? argument) + { + return argument is not null + && argument.StartsWith($"{Scheme}:", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses a text-grab:// URI into a lowercase command and its query parameters. + /// Accepts both text-grab://command?key=value and text-grab:command forms. + /// + internal static bool TryParseProtocolUri( + string uriString, + out string command, + out Dictionary parameters) + { + command = string.Empty; + parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!Uri.TryCreate(uriString, UriKind.Absolute, out Uri? uri) + || !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) + return false; + + // text-grab://paste-spreadsheet puts the command in Host; + // text-grab:paste-spreadsheet puts it in AbsolutePath. + string rawCommand = !string.IsNullOrEmpty(uri.Host) ? uri.Host : uri.AbsolutePath; + command = rawCommand.Trim('/').ToLowerInvariant(); + if (string.IsNullOrEmpty(command)) + return false; + + string query = uri.Query.TrimStart('?'); + foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + int separatorIndex = pair.IndexOf('='); + if (separatorIndex <= 0) + continue; + string key = Uri.UnescapeDataString(pair[..separatorIndex]); + string value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]); + parameters[key] = value; + } + + return true; + } + + /// + /// Registers the text-grab:// protocol for the current user when running + /// unpackaged. Packaged installs register it through the MSIX manifest. + /// Safe to call on every startup; only writes when missing or stale. + /// + internal static void EnsureProtocolRegistration() + { + if (AppUtilities.IsPackaged()) + return; + + string executablePath = FileUtilities.GetExePath(); + if (string.IsNullOrEmpty(executablePath)) + return; + + string expectedCommand = $"\"{executablePath}\" \"%1\""; + + try + { + using (RegistryKey? existingCommandKey = + Registry.CurrentUser.OpenSubKey($@"{ProtocolKeyPath}\shell\open\command")) + { + if (existingCommandKey?.GetValue(string.Empty) as string == expectedCommand) + return; + } + + using RegistryKey protocolKey = Registry.CurrentUser.CreateSubKey(ProtocolKeyPath); + protocolKey.SetValue(string.Empty, "URL:Text Grab Protocol"); + protocolKey.SetValue("URL Protocol", string.Empty); + + using RegistryKey iconKey = protocolKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(string.Empty, $"\"{executablePath}\",0"); + + using RegistryKey commandKey = protocolKey.CreateSubKey(@"shell\open\command"); + commandKey.SetValue(string.Empty, expectedCommand); + } + catch (Exception ex) + { + Debug.WriteLine($"text-grab:// protocol registration failed: {ex.Message}"); + } + } + + /// + /// Removes the per-user text-grab:// protocol registration. + /// + internal static void RemoveProtocolRegistration() + { + try + { + Registry.CurrentUser.DeleteSubKeyTree(ProtocolKeyPath, throwOnMissingSubKey: false); + } + catch (Exception ex) + { + Debug.WriteLine($"text-grab:// protocol unregistration failed: {ex.Message}"); + } + } +} From 7c62d029388403611a4e0641b8fa4b42ab15c88a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 15:01:38 -0500 Subject: [PATCH 11/23] Enable dynamic spell check, optimize CanExecute, cache OCR Added EditTextWindow.ShouldEnableSpellCheck to control spell check based on text length and long token count, with tests for edge cases. Spell check now updates dynamically as text changes. Optimized ReplaceReservedChars and ToggleCase CanExecute handlers to avoid full text scans. Improved spreadsheet undo/redo state invalidation and table parsing performance. Introduced cached clipboard OCR state to speed up CanExecute checks, with cache updates on clipboard changes and window load. Added PasteClipboardIntoSpreadsheet for protocol-based pasting. --- Tests/EditTextWindowSpellCheckTests.cs | 80 +++++++++++++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 116 +++++++++++++++++++------ 2 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 Tests/EditTextWindowSpellCheckTests.cs diff --git a/Tests/EditTextWindowSpellCheckTests.cs b/Tests/EditTextWindowSpellCheckTests.cs new file mode 100644 index 00000000..7812ae38 --- /dev/null +++ b/Tests/EditTextWindowSpellCheckTests.cs @@ -0,0 +1,80 @@ +using Text_Grab; + +namespace Tests; + +public class EditTextWindowSpellCheckTests +{ + [Fact] + public void NormalSentence_SpellCheckEnabled() + { + string text = "The quick brown fox jumps over the lazy dog."; + Assert.True(EditTextWindow.ShouldEnableSpellCheck(text)); + } + + [Fact] + public void EmptyString_SpellCheckEnabled() + { + Assert.True(EditTextWindow.ShouldEnableSpellCheck(string.Empty)); + } + + [Fact] + public void TextExceedsLengthThreshold_SpellCheckDisabled() + { + string longText = new string('a', 10_001); + Assert.False(EditTextWindow.ShouldEnableSpellCheck(longText)); + } + + [Fact] + public void TwoLongWords_SpellCheckEnabled() + { + // Only 2 long words — below the threshold of 3 + string text = "normal words then SomeVeryLongManifestTokenThatIsOver25Chars and AnotherReallyLongTokenHere123 end"; + Assert.True(EditTextWindow.ShouldEnableSpellCheck(text)); + } + + [Fact] + public void ThreeLongWords_SpellCheckDisabled() + { + // 3 words each >= 25 chars → should disable spell check + string text = "Microsoft.Windows.AppManifest.Version1234 " + + "com.example.application.package.name.v2 " + + "SomeGuidLike_1234567890abcdef1234 " + + "normal short words"; + Assert.False(EditTextWindow.ShouldEnableSpellCheck(text)); + } + + [Fact] + public void AppManifestLikeContent_SpellCheckDisabled() + { + string manifest = """ + + + + + """; + Assert.False(EditTextWindow.ShouldEnableSpellCheck(manifest)); + } + + [Fact] + public void WordExactlyAtLongWordLength_NotCountedAsLong() + { + // Word of exactly 24 chars should NOT count as "very long" + string word24 = new string('x', 24); + string word25 = new string('y', 25); + // Two words of 24 + one of 25 = only one long word → still enabled + string text = $"{word24} {word24} {word25} normal text"; + Assert.True(EditTextWindow.ShouldEnableSpellCheck(text)); + } + + [Fact] + public void GuidTokens_SpellCheckDisabled() + { + // GUIDs are 32+ chars without hyphens when copy-pasted from some apps + string text = "id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 " + + "token=f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2 " + + "hash=1234567890abcdef1234567890abcdef12"; + Assert.False(EditTextWindow.ShouldEnableSpellCheck(text)); + } +} diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 0882b82b..354e5d29 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -111,6 +111,8 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private bool isShowingPendingFileClosePrompt = false; private bool allowCloseAfterPendingFilePrompt = false; private bool isRestoringSpreadsheetUndoState = false; + // Cached clipboard state to avoid slow COM calls on every CanExecute query + private bool _cachedClipboardHasOcrContent = true; private int? spreadsheetContextRowIndex; private int? spreadsheetContextColumnIndex; private SpreadsheetUndoState? pendingSpreadsheetUndoState; @@ -255,6 +257,13 @@ public System.Windows.Controls.TextBox GetMainTextBox() internal void EnterSpreadsheetMode() => SetEditorMode(EtwEditorMode.Spreadsheet); + /// + /// Pastes the clipboard into the spreadsheet editor, parsing HTML tables + /// (including colspan/rowspan) when present. Used by the text-grab:// + /// paste-spreadsheet protocol activation. + /// + internal void PasteClipboardIntoSpreadsheet() => PasteIntoSpreadsheet(); + public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions options) { IEnumerable? files = null; @@ -544,9 +553,13 @@ private void RecordSpreadsheetUndoChange(SpreadsheetUndoState? beforeChange, Spr private void ResetSpreadsheetUndoHistory() { + bool wasNonEmpty = spreadsheetUndoHistory.CanUndo || spreadsheetUndoHistory.CanRedo; spreadsheetUndoHistory.Clear(); pendingSpreadsheetUndoState = null; - CommandManager.InvalidateRequerySuggested(); + // Only invalidate when the undo/redo availability actually changed to avoid + // triggering all CanExecute handlers on every text change in text mode. + if (wasNonEmpty) + CommandManager.InvalidateRequerySuggested(); } private void RestoreSpreadsheetUndoState(SpreadsheetUndoState stateToRestore) @@ -2433,35 +2446,26 @@ private void CanLaunchUriExecute(object sender, CanExecuteRoutedEventArgs e) private void CanOcrPasteExecute(object sender, CanExecuteRoutedEventArgs e) { - IsAccessingClipboard = true; - DataPackageView? dataPackageView = null; + // Use cached value to avoid slow COM clipboard access on every CanExecute query. + // The cache is updated by Clipboard_ContentChanged and on window load. + e.CanExecute = _cachedClipboardHasOcrContent; + } + private void UpdateCachedClipboardOcrState() + { try { - dataPackageView = Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + DataPackageView view = Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + _cachedClipboardHasOcrContent = + view.Contains(StandardDataFormats.Text) + || view.Contains(StandardDataFormats.Bitmap) + || view.Contains(StandardDataFormats.StorageItems); } catch (Exception ex) { - Debug.WriteLine($"error with Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(). Exception Message: {ex.Message}"); - e.CanExecute = false; - } - finally - { - IsAccessingClipboard = false; + Debug.WriteLine($"error updating clipboard OCR state: {ex.Message}"); + _cachedClipboardHasOcrContent = true; // optimistic fallback } - - if (dataPackageView is null) - { - e.CanExecute = false; - return; - } - - if (dataPackageView.Contains(StandardDataFormats.Text) - || dataPackageView.Contains(StandardDataFormats.Bitmap) - || dataPackageView.Contains(StandardDataFormats.StorageItems)) - e.CanExecute = true; - else - e.CanExecute = false; } private void CaptureMenuItem_SubmenuOpened(object sender, RoutedEventArgs e) @@ -2503,6 +2507,9 @@ private void CheckRightToLeftLanguage() private async void Clipboard_ContentChanged(object? sender, object e) { + // Always keep OCR paste cache fresh regardless of clipboard watcher state. + UpdateCachedClipboardOcrState(); + if (ClipboardWatcherMenuItem.IsChecked is false || IsAccessingClipboard) return; @@ -3726,6 +3733,46 @@ private void PassedTextControl_SizeChanged(object sender, SizeChangedEventArgs e SetMargins(MarginsMenuItem.IsChecked is true); } + private const int SpellCheckDisableThreshold = 10_000; + // A "very long word" is one the spell checker will choke on (GUIDs, manifest tokens, etc.) + private const int SpellCheckLongWordLength = 25; + private const int SpellCheckLongWordCountThreshold = 3; + + /// + /// Returns true when spell checking should be active for the given text. + /// Disabled for large documents and for content that contains several very long + /// unspaced tokens (app manifests, GUIDs, base64 blobs, etc.). + /// + internal static bool ShouldEnableSpellCheck(string text) + { + if (text.Length > SpellCheckDisableThreshold) + return false; + + int longWordCount = 0; + int wordStart = -1; + for (int i = 0; i <= text.Length; i++) + { + bool isWordChar = i < text.Length && !char.IsWhiteSpace(text[i]); + if (isWordChar) + { + if (wordStart < 0) + wordStart = i; + } + else if (wordStart >= 0) + { + if (i - wordStart >= SpellCheckLongWordLength) + { + longWordCount++; + if (longWordCount >= SpellCheckLongWordCountThreshold) + return false; + } + wordStart = -1; + } + } + + return true; + } + private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) { if (DefaultSettings.EditWindowStartFullscreen && prevWindowState is not null) @@ -3734,6 +3781,12 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e prevWindowState = null; } + // Re-evaluate spell check eligibility on every change (the scan is O(n) but + // short-circuits early, so it stays fast even for large pastes). + bool shouldSpellCheck = ShouldEnableSpellCheck(PassedTextControl.Text); + if (SpellCheck.GetIsEnabled(PassedTextControl) != shouldSpellCheck) + SpellCheck.SetIsEnabled(PassedTextControl, shouldSpellCheck); + UpdateLineAndColumnText(); // Reset the debounce timer @@ -3767,7 +3820,9 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e } ResetSpreadsheetUndoHistory(); - RefreshSpreadsheetFromText(rebuildTable: false); + // Invalidate the cached document instead of eagerly re-parsing the entire text + // on every keystroke. It will be rebuilt lazily when switching to spreadsheet mode. + tableDocument = null; UpdatePendingFileEditState(); } @@ -4133,8 +4188,9 @@ private void ShuffleLinesMenuItem_Click(object sender, RoutedEventArgs e) private void ReplaceReservedCharsCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() - .Any(text => StringMethods.ReservedChars.Any(text.Contains)); + // Simplified: reserved chars (spaces, punctuation) are nearly always present in + // any non-empty text, so scanning the full content on every CanExecute is wasteful. + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit().Any(text => text.Length > 0); } private void ReplaceReservedCharsCmdExecuted(object sender, ExecutedRoutedEventArgs e) @@ -4757,8 +4813,9 @@ private void ToggleCase(object? sender = null, ExecutedRoutedEventArgs? e = null private void ToggleCaseCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() - .Any(text => text.Any(char.IsLetter)); + // Simplified: avoid scanning every character on each CanExecute query. + // Any non-empty text segment is likely to contain letters. + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit().Any(text => text.Length > 0); } private void TrimEachLineMenuItem_Click(object sender, RoutedEventArgs e) @@ -5404,6 +5461,9 @@ private void Window_Loaded(object sender, RoutedEventArgs e) // This ensures that when images are dropped or pasted, the correct language is used selectedILanguage = LanguageUtilities.GetOCRLanguage(); + // Warm up the cached clipboard OCR state so CanOcrPasteExecute is accurate immediately. + UpdateCachedClipboardOcrState(); + if (editorMode == EtwEditorMode.Spreadsheet) SetEditorMode(EtwEditorMode.Spreadsheet); else if (editorMode == EtwEditorMode.Markdown) From 27623128ce7443caf58e5e631b666b7d80bb4f15 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 15:02:02 -0500 Subject: [PATCH 12/23] Add live content change detection and memory cleanup to GrabFrame Introduce automatic screen content change detection in GrabFrame using a timer and image differencing to trigger OCR refreshes on live changes. Prevent unnecessary OCR by rebasing the change detector after overlay repaints. Improve undo/redo logic to avoid memory leaks from timer-driven redraws. Add explicit disposal of image sources and automation peers to ensure proper cleanup. Update image manipulation actions to release previous resources. Add helpers for automation peer cache resets and popup detection to enhance accessibility and stability. --- Text-Grab/Views/GrabFrame.xaml.cs | 190 +++++++++++++++++++++++++++--- 1 file changed, 172 insertions(+), 18 deletions(-) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 423886c8..06143fb3 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; +using System.Windows.Automation.Peers; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; @@ -65,6 +66,7 @@ public partial class GrabFrame : Window private bool hasLoadedImageSource = false; private bool IsDragOver = false; private bool isDrawing = false; + private bool isAutoOcrRedrawPass = false; private bool isLanguageBoxLoaded = false; private bool isMiddleDown = false; private bool IsOcrValid = false; @@ -81,6 +83,8 @@ public partial class GrabFrame : Window private readonly DispatcherTimer frameMessageTimer = new(); private readonly DispatcherTimer reDrawTimer = new(); private readonly DispatcherTimer reSearchTimer = new(); + private readonly DispatcherTimer contentChangeTimer = new(); + private readonly ImageChangeDetector contentChangeDetector = new(); private Side resizingSide = Side.None; private readonly Border selectBorder = new(); private Point startingMovingPoint; @@ -322,7 +326,6 @@ private async Task LoadContentFromHistory(HistoryInfo history) history.ImageContent = bgBitmap; frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap); hasLoadedImageSource = true; - GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); List wbInfoList = await Singleton.Instance.GetWordBorderInfosAsync(history); @@ -552,6 +555,10 @@ private void StandardInitialize() frameMessageTimer.Interval = TimeSpan.FromSeconds(4); frameMessageTimer.Tick += FrameMessageTimer_Tick; + contentChangeTimer.Interval = TimeSpan.FromSeconds(1); + contentChangeTimer.Tick += ContentChangeTimer_Tick; + contentChangeTimer.Start(); + _ = UndoRedo.HasUndoOperations(); _ = UndoRedo.HasRedoOperations(); @@ -1370,6 +1377,10 @@ private void CleanupGrabFrame() frameMessageTimer.Stop(); frameMessageTimer.Tick -= FrameMessageTimer_Tick; + contentChangeTimer.Stop(); + contentChangeTimer.Tick -= ContentChangeTimer_Tick; + contentChangeDetector.Dispose(); + translationTimer.Stop(); translationTimer.Tick -= TranslationTimer_Tick; translationSemaphore.Dispose(); @@ -1406,6 +1417,10 @@ private void CleanupGrabFrame() windowResizer?.Dispose(); windowResizer = null; + // Release the undo/redo history; its operations hold WordBorder + // controls which in turn reference this window via OwnerGrabFrame. + UndoRedo.Reset(); + foreach (WordBorder wb in wordBorders) wb.OwnerGrabFrame = null; wordBorders.Clear(); @@ -1414,8 +1429,9 @@ private void CleanupGrabFrame() _loadedPdfDocument = null; _currentPdfPageContent = null; - frameContentImageSource = null; GrabFrameImage.Source = null; + GrabFrameImage.UpdateLayout(); + frameContentImageSource = null; ocrResultOfWindow = null; frozenUiAutomationSnapshot = null; liveUiAutomationSnapshot = null; @@ -1426,6 +1442,20 @@ private void CleanupGrabFrame() originalTexts.Clear(); pdfTextLineOverlays.Clear(); RectanglesCanvas.Children.Clear(); + + // Drop any stale automation peers so a connected UIA client cannot + // keep this closed window's visual tree alive. + ResetAutomationPeerChildrenCache(RectanglesCanvas); + ResetAutomationPeerChildrenCache(this); + } + + private void DisposePreviousFrameContent() + { + if (GrabFrameImage.Source is null) + return; + + GrabFrameImage.Source = null; + GrabFrameImage.UpdateLayout(); } public void MergeSelectedWordBorders() @@ -1928,12 +1958,25 @@ private void ClearRenderedWordBorders() RectanglesCanvas.Children.Clear(); wordBorders.Clear(); ClearRenderedPdfTextLines(); + + // When a UIA client (Narrator, touch keyboard, etc.) is connected, + // WPF caches automation peers per element; without a reset the stale + // peers keep every discarded WordBorder — and through OwnerGrabFrame, + // this window — reachable until the client re-walks the tree. + ResetAutomationPeerChildrenCache(RectanglesCanvas); + } + + private static void ResetAutomationPeerChildrenCache(UIElement element) + { + if (UIElementAutomationPeer.FromElement(element) is AutomationPeer peer) + peer.ResetChildrenCache(); } private void ClearRenderedPdfTextLines() { PdfTextCanvas.Children.Clear(); pdfTextLineOverlays.Clear(); + ResetAutomationPeerChildrenCache(PdfTextCanvas); } private IReadOnlyCollection? GetUiAutomationExcludedHandles() @@ -2177,6 +2220,9 @@ private void AddRenderedWordBorder(WordBorder wordBorderBox) wordBorders.Add(wordBorderBox); _ = RectanglesCanvas.Children.Add(wordBorderBox); + if (isAutoOcrRedrawPass) + return; + UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, new GrabFrameOperationArgs() { @@ -2209,11 +2255,17 @@ private void AddRenderedPdfTextLine(PdfTextLineOverlay overlay) _ = PdfTextCanvas.Children.Add(overlay); } - private Task DrawRectanglesAroundWords(string searchWord = "") + private async Task DrawRectanglesAroundWords(string searchWord = "") { - return CurrentLanguage is UiAutomationLang - ? DrawUiAutomationRectanglesAsync(searchWord) - : DrawOcrRectanglesAsync(searchWord); + if (CurrentLanguage is UiAutomationLang) + await DrawUiAutomationRectanglesAsync(searchWord); + else + await DrawOcrRectanglesAsync(searchWord); + + // The overlay just changed; rebase the change detector so the newly + // drawn word borders become part of the baseline instead of being + // judged as screen-content changes that re-trigger a refresh. + contentChangeDetector.Reset(); } private async Task DrawOcrRectanglesAsync(string searchWord = "") @@ -2642,6 +2694,7 @@ private void FeedbackMenuItem_Click(object sender, RoutedEventArgs ev) private void FreezeGrabFrame() { + DisposePreviousFrameContent(); GrabFrameImage.Opacity = 1; if (frameContentImageSource is not null) GrabFrameImage.Source = frameContentImageSource; @@ -3600,7 +3653,90 @@ private async void ReDrawTimer_Tick(object? sender, EventArgs? e) return; if (SearchBox.Text is string searchText) - await DrawRectanglesAroundWords(searchText); + { + // Timer-driven redraws are not user actions, so the word borders + // they render must not be recorded in the undo stack; recording + // them pinned every rendered border for the life of the frame. + isAutoOcrRedrawPass = true; + try + { + await DrawRectanglesAroundWords(searchText); + } + finally + { + isAutoOcrRedrawPass = false; + } + } + } + + private void ContentChangeTimer_Tick(object? sender, EventArgs e) + { + // Only an unfrozen frame shows live screen content worth watching. + if (!IsLoaded + || IsFreezeMode + || hasLoadedImageSource + || isStaticImageSource + || IsPdfDocumentLoaded + || WindowState == WindowState.Minimized) + { + contentChangeDetector.Reset(); + return; + } + + // Skip while the user or the OCR pipeline is mid-operation; a capture + // taken now would make an unstable baseline. Open context menus, + // dropdowns, and tooltips render over the captured region, so they + // would register as a content change and wrongly trigger a refresh. + if (AutoOcrCheckBox.IsChecked is not true + || isDrawing + || reDrawTimer.IsEnabled + || isSelecting + || IsEditingAnyWordBorders + || movingWordBordersDictionary.Count > 0 + || Mouse.Captured is not null + || IsAnyPopupOpen() + || CheckKey(VirtualKeyCodes.LeftButton) + || CheckKey(VirtualKeyCodes.MiddleButton)) + { + return; + } + + System.Drawing.Rectangle contentRect = GetContentAreaScreenRect(); + if (contentRect.Width <= 1 || contentRect.Height <= 1) + return; + + using System.Drawing.Bitmap capture = ImageMethods.GetRegionOfScreenAsBitmap(contentRect, cacheResult: false); + + if (!contentChangeDetector.CheckForChangeAndUpdate(capture)) + return; + + // The screen behind the frame changed; clear stale results and let the + // redraw timer re-capture and re-OCR (same path as moving the window). + // Reset the detector so the freshly drawn word borders become the next + // baseline instead of immediately re-triggering another refresh. + contentChangeDetector.Reset(); + ResetGrabFrame(); + reDrawTimer.Stop(); + reDrawTimer.Start(); + } + + /// + /// Returns true when any WPF popup is showing on this thread — context + /// menus, combo box dropdowns, submenus, and tooltips are all hosted in + /// a visible PopupRoot presentation source. + /// + private static bool IsAnyPopupOpen() + { + foreach (PresentationSource source in PresentationSource.CurrentSources) + { + if (source.RootVisual is UIElement { IsVisible: true } rootElement + && rootElement.GetType().Name == "PopupRoot") + { + return true; + } + } + + return false; } private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = null) @@ -3620,12 +3756,12 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = UndoRedo.StartTransaction(); UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.RemoveWordBorder, -new GrabFrameOperationArgs() -{ - RemovingWordBorders = [.. wordBorders], - WordBorders = wordBorders, - GrabFrameCanvas = RectanglesCanvas -}); + new GrabFrameOperationArgs() + { + RemovingWordBorders = [.. wordBorders], + WordBorders = wordBorders, + GrabFrameCanvas = RectanglesCanvas + }); if (hasLoadedImageSource || IsFreezeMode) { @@ -3646,6 +3782,7 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = await Task.Delay(200); + DisposePreviousFrameContent(); frameContentImageSource = ImageMethods.GetWindowBoundsImage(this); GrabFrameImage.Source = frameContentImageSource; } @@ -4536,6 +4673,7 @@ private void UnfreezeGrabFrame() ResetGrabFrame(); Topmost = true; GrabFrameImage.Opacity = 0; + DisposePreviousFrameContent(); frameContentImageSource = null; historyItem = null; RectanglesBorder.Background.Opacity = overlayOpacity; @@ -4620,6 +4758,12 @@ private bool IsLinkedEditTextWindowInSpreadsheetMode() private void UpdateFrameText(bool preserveLinkedSpreadsheetSelection = false) { + // Nearly every overlay mutation (selection, edits, merges, moves, + // deletes, table changes) funnels through here, and each repaints the + // word borders; rebase the change detector so those repaints are not + // judged as screen-content changes. + contentChangeDetector.Reset(); + StringBuilder stringBuilder = new(); List<(double Top, double Left, double Height, string Text, bool AllowParagraphJoin)> selectedLines = [.. wordBorders @@ -5100,7 +5244,9 @@ private void InvertColorsMI_Click(object sender, RoutedEventArgs e) return; } - frameContentImageSource = MagickHelpers.Invert(frameContentImageSource); + ImageSource? invertedSource = MagickHelpers.Invert(frameContentImageSource); + DisposePreviousFrameContent(); + frameContentImageSource = invertedSource; GrabFrameImage.Source = frameContentImageSource; args.NewImage = frameContentImageSource; @@ -5148,7 +5294,9 @@ private void AutoContrastMI_Click(object sender, RoutedEventArgs e) return; } - frameContentImageSource = MagickHelpers.Contrast(frameContentImageSource); + ImageSource? contrastedSource = MagickHelpers.Contrast(frameContentImageSource); + DisposePreviousFrameContent(); + frameContentImageSource = contrastedSource; GrabFrameImage.Source = frameContentImageSource; args.NewImage = frameContentImageSource; @@ -5196,7 +5344,9 @@ private void BrightenMI_Click(object sender, RoutedEventArgs e) return; } - frameContentImageSource = MagickHelpers.Brighten(frameContentImageSource); + ImageSource? brightenedSource = MagickHelpers.Brighten(frameContentImageSource); + DisposePreviousFrameContent(); + frameContentImageSource = brightenedSource; GrabFrameImage.Source = frameContentImageSource; args.NewImage = frameContentImageSource; @@ -5244,7 +5394,9 @@ private void DarkenMI_Click(object sender, RoutedEventArgs e) return; } - frameContentImageSource = MagickHelpers.Darken(frameContentImageSource); + ImageSource? darkenedSource = MagickHelpers.Darken(frameContentImageSource); + DisposePreviousFrameContent(); + frameContentImageSource = darkenedSource; GrabFrameImage.Source = frameContentImageSource; args.NewImage = frameContentImageSource; @@ -5292,7 +5444,9 @@ private void GrayscaleMI_Click(object sender, RoutedEventArgs e) return; } - frameContentImageSource = MagickHelpers.Grayscale(frameContentImageSource as BitmapSource); + ImageSource? grayscaledSource = MagickHelpers.Grayscale(frameContentImageSource as BitmapSource); + DisposePreviousFrameContent(); + frameContentImageSource = grayscaledSource; GrabFrameImage.Source = frameContentImageSource; args.NewImage = frameContentImageSource; From 89a25d79036d86b91765a99e75f65010e4946587 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 13 Jun 2026 15:02:21 -0500 Subject: [PATCH 13/23] Add text-grab:// protocol support and browser ext test Enables text-grab:// protocol activation for direct feature launches (e.g., paste-spreadsheet, edit-text, grab-frame, grab-text, fullscreen, quick-lookup, settings) from browser extension or links. Registers protocol in manifest and at runtime. Adds test for browser extension table HTML parsing to ensure compatibility. Prioritizes protocol handling at startup and supports protocol-based OCR to clipboard. --- Tests/ClipboardUtilitiesTests.cs | 30 +++++++ Text-Grab-Package/Package.appxmanifest | 7 ++ Text-Grab/App.xaml.cs | 112 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs index f88de72c..9efef250 100644 --- a/Tests/ClipboardUtilitiesTests.cs +++ b/Tests/ClipboardUtilitiesTests.cs @@ -134,4 +134,34 @@ public void ConvertHtmlToTabSeparated_HandlesRowspan() Assert.Equal("Tall\tTop", lines[0]); Assert.Equal("Tall\tBottom", lines[1]); } + + // The Text Grab browser extension's Table mode (including its layout + // reconstruction fallback for non- grids) writes a clean + //
to the clipboard with
for + // newlines and &-style entity escaping, then hands off via + // text-grab://paste-spreadsheet. This pins compatibility with that exact + // output (see Text-Grab-Extension/lib/formats.js -> toCleanHtmlTable). + private const string ExtensionRegionTableCfHtml = """ + Version:0.9 + StartHTML:00000097 + EndHTML:00000260 + StartFragment:00000131 + EndFragment:00000224 + +
ProductQtyUnit price
USB-C hub12$24.50
Monitor
arm
5$130 & up
+ + """; + + [Fact] + public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable() + { + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(ExtensionRegionTableCfHtml); + + string[] lines = result.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("Product\tQty\tUnit price", lines[0]); + Assert.Equal("USB-C hub\t12\t$24.50", lines[1]); + //
collapses to a space; & decodes to &. + Assert.Equal("Monitor arm\t5\t$130 & up", lines[2]); + } } diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 22057898..6da9f0c4 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -96,6 +96,13 @@ + + + + Text Grab + + + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index f84baa00..4aee8365 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -294,6 +294,12 @@ internal static StartupArguments ParseStartupArguments(IEnumerable args) private static async Task HandleStartupArgs(string[] args) { + // text-grab:// protocol activation (e.g. from the Text Grab browser + // extension); short-circuit before any file-path probing of arguments. + foreach (string arg in args) + if (ProtocolUtilities.IsProtocolUri(arg)) + return HandleProtocolUri(arg); + StartupArguments startupArguments = ParseStartupArguments(args); if (startupArguments.IsQuiet) @@ -350,6 +356,108 @@ private static async Task HandleStartupArgs(string[] args) return await CheckForOcringFolder(currentArgument); } + internal static bool HandleProtocolUri(string uriString) + { + if (!ProtocolUtilities.TryParseProtocolUri(uriString, out string command, out Dictionary parameters)) + return false; + + switch (command) + { + case "paste-spreadsheet": + { + EditTextWindow etw = new(); + etw.Show(); + etw.EnterSpreadsheetMode(); + // Defer the paste until the window has loaded so the + // spreadsheet grid is ready to receive the clipboard table. + etw.Dispatcher.InvokeAsync( + etw.PasteClipboardIntoSpreadsheet, + DispatcherPriority.Loaded); + etw.Activate(); + return true; + } + case "edit-text": + { + EditTextWindow etw = new(); + try + { + string clipboardText = Clipboard.GetText(); + if (!string.IsNullOrEmpty(clipboardText)) + etw.AddThisText(clipboardText); + } + catch (Exception ex) + { + Debug.WriteLine($"edit-text protocol: clipboard read failed. {ex.Message}"); + } + etw.Show(); + etw.Activate(); + return true; + } + case "grab-frame": + { + if (parameters.TryGetValue("path", out string? path) + && File.Exists(path) + && IoUtilities.IsVisualDocumentFile(path)) + { + GrabFrame gfWithFile = new(path); + gfWithFile.Show(); + gfWithFile.Activate(); + return true; + } + + GrabFrame gf = new(); + gf.Show(); + gf.Activate(); + return true; + } + case "grab-text": + { + // OCR a local image/PDF straight to the clipboard, no window. + if (parameters.TryGetValue("path", out string? path) + && File.Exists(path) + && IoUtilities.IsVisualDocumentFile(path)) + { + _ = GrabTextFromFileAsync(path); + return true; + } + return false; + } + case "fullscreen": + LaunchStandardMode(TextGrabMode.Fullscreen); + return true; + case "quick-lookup": + LaunchStandardMode(TextGrabMode.QuickLookup); + return true; + case "settings": + { + SettingsWindow sw = new(); + sw.Show(); + return true; + } + default: + Debug.WriteLine($"Unknown text-grab:// command: {command}"); + return false; + } + } + + /// + /// OCRs a local image/PDF file and routes the result to the clipboard (and + /// a toast), the same handling as a normal grab. Used by text-grab://grab-text. + /// + private static async Task GrabTextFromFileAsync(string path) + { + try + { + string ocrText = await OcrUtilities.OcrAbsoluteFilePathAsync( + path, LanguageUtilities.GetOCRLanguage()); + OutputUtilities.HandleTextFromOcr(ocrText, isSingleLine: false, isTable: false); + } + catch (Exception ex) + { + Debug.WriteLine($"grab-text protocol: OCR failed. {ex.Message}"); + } + } + private static void LaunchStandardMode(TextGrabMode launchMode) { switch (launchMode) @@ -431,6 +539,10 @@ private async void appStartup(object sender, StartupEventArgs e) NumberOfRunningInstances = Process.GetProcessesByName("Text-Grab").Length; Current.DispatcherUnhandledException += CurrentDispatcherUnhandledException; + // Per-user text-grab:// registration for unpackaged installs + // (packaged installs register the protocol via the MSIX manifest). + ProtocolUtilities.EnsureProtocolRegistration(); + // Register COM server and activator type bool handledArgument = false; From 289a52fe482bcb9db03ea6ab49b816679bba77a2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 15 Jun 2026 21:09:01 -0500 Subject: [PATCH 14/23] Add EtwSpellCheckMode user setting with default "Auto" Introduces a new user-scoped setting, EtwSpellCheckMode, with a default value of "Auto" in App.config, Settings.settings, and Settings.Designer.cs. This enables configurable spell check mode for the application. --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++++++- Text-Grab/Properties/Settings.settings | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index f9b12a29..30822db8 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -196,6 +196,9 @@ True + + Auto + diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index bdc02f43..3e473f5b 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -778,7 +778,19 @@ public bool EtwNormalizeLineEndingsOnPaste { this["EtwNormalizeLineEndingsOnPaste"] = value; } } - + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("Auto")] + public string EtwSpellCheckMode { + get { + return ((string)(this["EtwSpellCheckMode"])); + } + set { + this["EtwSpellCheckMode"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index e66e26ae..a9d58acc 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -191,6 +191,9 @@ True + + Auto + From 4ee4809907261b0b4eaab98612e95de2e059cbe5 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 15 Jun 2026 21:09:47 -0500 Subject: [PATCH 15/23] Add configurable spell check modes to Edit Text Window Introduce SpellCheckMode (Auto, AlwaysOn, Off) for Edit Text Window, with new settings UI and menu options. Refactor spell check logic to respect selected mode and update dynamically. Sync mode changes across settings and open windows. Extend diagnostics and add unit tests for all spell check scenarios. --- Tests/EditTextWindowSpellCheckTests.cs | 28 ++++++++ Text-Grab/Enums.cs | 11 +++ Text-Grab/Pages/EditTextWindowSettings.xaml | 51 +++++++++++++ .../Pages/EditTextWindowSettings.xaml.cs | 19 +++++ Text-Grab/Utilities/DiagnosticsUtilities.cs | 2 + Text-Grab/Views/EditTextWindow.xaml | 21 ++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 72 +++++++++++++++++-- 7 files changed, 199 insertions(+), 5 deletions(-) diff --git a/Tests/EditTextWindowSpellCheckTests.cs b/Tests/EditTextWindowSpellCheckTests.cs index 7812ae38..878f16d6 100644 --- a/Tests/EditTextWindowSpellCheckTests.cs +++ b/Tests/EditTextWindowSpellCheckTests.cs @@ -77,4 +77,32 @@ public void GuidTokens_SpellCheckDisabled() "hash=1234567890abcdef1234567890abcdef12"; Assert.False(EditTextWindow.ShouldEnableSpellCheck(text)); } + + [Fact] + public void AlwaysOnMode_EnabledEvenForContentAutoWouldReject() + { + // Content Auto mode would disable (3+ long tokens), but Always On forces it on + string text = "Microsoft.Windows.AppManifest.Version1234 " + + "com.example.application.package.name.v2 " + + "SomeGuidLike_1234567890abcdef1234"; + Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text)); + Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.AlwaysOn, text)); + } + + [Fact] + public void OffMode_DisabledEvenForNormalText() + { + string text = "The quick brown fox jumps over the lazy dog."; + Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text)); + Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Off, text)); + } + + [Fact] + public void AutoMode_MatchesContentHeuristic() + { + string normal = "The quick brown fox."; + string longTokens = new string('a', 10_001); + Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, normal)); + Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, longTokens)); + } } diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index 4ed5a1f5..39399410 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -87,6 +87,17 @@ public enum ScrollBehavior ZoomWhenFrozen = 3, } +public enum SpellCheckMode +{ + // Enable spell check unless the text looks like it would choke the checker + // (very long documents or several long unspaced tokens). + Auto = 0, + // Always show spell check, regardless of content. + AlwaysOn = 1, + // Never show spell check. + Off = 2, +} + public enum LanguageKind { Global = 0, diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml b/Text-Grab/Pages/EditTextWindowSettings.xaml index 6b03ff73..844ea71b 100644 --- a/Text-Grab/Pages/EditTextWindowSettings.xaml +++ b/Text-Grab/Pages/EditTextWindowSettings.xaml @@ -75,6 +75,57 @@ Style="{StaticResource TextBodyNormal}" Text="Convert Unix (LF) and classic Mac (CR) line endings to Windows (CRLF) when pasting text." /> + + + + + + + + + + + + + + + + + + + + + trackedSpreadsheetColumns = []; private List<(int RowIndex, int ColumnIndex)> selectedSpreadsheetCellCoordinates = []; private EtwEditorMode editorMode = EtwEditorMode.Text; + private SpellCheckMode spellCheckMode = SpellCheckMode.Auto; private bool isSyncingTextFromSpreadsheet = false; private bool isSyncingTextFromMarkdown = false; private bool isApplyingSpreadsheetLayout = false; @@ -3773,6 +3774,62 @@ internal static bool ShouldEnableSpellCheck(string text) return true; } + /// + /// Resolves whether spell check should be active for the given mode and text. + /// Always On / Off force the state; Auto defers to . + /// + internal static bool ShouldEnableSpellCheck(SpellCheckMode mode, string text) => mode switch + { + SpellCheckMode.AlwaysOn => true, + SpellCheckMode.Off => false, + _ => ShouldEnableSpellCheck(text), + }; + + /// + /// Applies the current to the editors. In Auto mode the + /// content is re-evaluated (the scan is O(n) but short-circuits early, so it stays fast + /// even for large pastes); Always On / Off force the state regardless of content. + /// + private void ApplySpellCheckMode() + { + bool shouldSpellCheck = ShouldEnableSpellCheck(spellCheckMode, PassedTextControl.Text); + + if (SpellCheck.GetIsEnabled(PassedTextControl) != shouldSpellCheck) + SpellCheck.SetIsEnabled(PassedTextControl, shouldSpellCheck); + + if (SpellCheck.GetIsEnabled(MarkdownEditorControl) != shouldSpellCheck) + SpellCheck.SetIsEnabled(MarkdownEditorControl, shouldSpellCheck); + } + + private void SetSpellCheckMode(SpellCheckMode mode) + { + spellCheckMode = mode; + SetSpellCheckMenuItems(); + ApplySpellCheckMode(); + } + + private void SetSpellCheckMenuItems() + { + SpellCheckAutoMenuItem.IsChecked = spellCheckMode == SpellCheckMode.Auto; + SpellCheckAlwaysOnMenuItem.IsChecked = spellCheckMode == SpellCheckMode.AlwaysOn; + SpellCheckOffMenuItem.IsChecked = spellCheckMode == SpellCheckMode.Off; + } + + private void SpellCheckModeMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem + || !Enum.TryParse(menuItem.Tag?.ToString(), out SpellCheckMode mode)) + { + // Re-sync the check marks so a stray click can't leave the menu inconsistent. + SetSpellCheckMenuItems(); + return; + } + + SetSpellCheckMode(mode); + DefaultSettings.EtwSpellCheckMode = mode.ToString(); + DefaultSettings.Save(); + } + private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) { if (DefaultSettings.EditWindowStartFullscreen && prevWindowState is not null) @@ -3781,11 +3838,7 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e prevWindowState = null; } - // Re-evaluate spell check eligibility on every change (the scan is O(n) but - // short-circuits early, so it stays fast even for large pastes). - bool shouldSpellCheck = ShouldEnableSpellCheck(PassedTextControl.Text); - if (SpellCheck.GetIsEnabled(PassedTextControl) != shouldSpellCheck) - SpellCheck.SetIsEnabled(PassedTextControl, shouldSpellCheck); + ApplySpellCheckMode(); UpdateLineAndColumnText(); @@ -4252,6 +4305,10 @@ private void RestoreWindowSettings() SetMargins(true); } + if (!Enum.TryParse(DefaultSettings.EtwSpellCheckMode, out SpellCheckMode savedSpellCheckMode)) + savedSpellCheckMode = SpellCheckMode.Auto; + SetSpellCheckMode(savedSpellCheckMode); + SetBottomBarButtons(); } @@ -5290,6 +5347,11 @@ private void CharDetailsButton_Click(object sender, RoutedEventArgs e) private void Window_Activated(object sender, EventArgs e) { + // Pick up a spell-check mode changed from the settings page while this window was open. + if (Enum.TryParse(DefaultSettings.EtwSpellCheckMode, out SpellCheckMode settingsMode) + && settingsMode != spellCheckMode) + SetSpellCheckMode(settingsMode); + if (editorMode == EtwEditorMode.Spreadsheet) SpreadsheetDataGrid.Focus(); else if (editorMode == EtwEditorMode.Markdown) From fcbde3326b55ccb56f6e52ad3c449f9ec6d6e0a5 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 15 Jun 2026 21:53:13 -0500 Subject: [PATCH 16/23] Skip loading indicator for UI Automation language Do not show the loading indicator window when UI Automation is the selected language, as it would interfere with screen reading. Updated grabIndicator handling to be nullable and use null-conditional calls for ShowSuccess and Close. --- Text-Grab/Views/FullscreenGrab.SelectionStyles.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index 6a941721..0c091068 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -1032,8 +1032,15 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool // Show a loading indicator over the grabbed region while OCR and any // post-grab actions run, then flash a success icon once text is handled. - PreviousGrabWindow grabIndicator = new(GetHistoryPositionRect(selection), PreviousGrabIndicator.Loading); - grabIndicator.Show(); + // Direct Text (UI Automation) reads the live screen, so a Topmost window + // over the region would occlude the read and return no text — skip it. + ILanguage selectedLanguage = LanguagesComboBox.SelectedItem as ILanguage ?? LanguageUtilities.GetOCRLanguage(); + PreviousGrabWindow? grabIndicator = null; + if (selectedLanguage is not UiAutomationLang) + { + grabIndicator = new(GetHistoryPositionRect(selection), PreviousGrabIndicator.Loading); + grabIndicator.Show(); + } bool grabbedText = false; try @@ -1043,9 +1050,9 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool finally { if (grabbedText) - grabIndicator.ShowSuccess(); + grabIndicator?.ShowSuccess(); else - grabIndicator.Close(); + grabIndicator?.Close(); } } From 49f5be1124eb1c42e91d991e5eef58af1c088edc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 17 Jun 2026 17:48:52 -0500 Subject: [PATCH 17/23] Add IsTextOnly and ApplyTextOnlyTemplate support Introduce IsTextOnly property to GrabTemplate for detecting templates without capture regions. Add ApplyTextOnlyTemplate method to GrabTemplateExecutor for applying such templates to existing text without OCR. Include comprehensive unit tests for these behaviors. --- Tests/GrabTemplateExecutorTests.cs | 73 +++++++++++++++++++++ Text-Grab/Models/GrabTemplate.cs | 7 ++ Text-Grab/Utilities/GrabTemplateExecutor.cs | 31 +++++++++ 3 files changed, 111 insertions(+) diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs index 18f17917..0ee8f830 100644 --- a/Tests/GrabTemplateExecutorTests.cs +++ b/Tests/GrabTemplateExecutorTests.cs @@ -436,6 +436,79 @@ public void GrabTemplate_GetReferencedPatternNames_ParsesNames() Assert.Contains("Phone Number", names); } + [Fact] + public void GrabTemplate_IsTextOnly_TrueWhenNoRegions() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "{p:Email:first}" + }; + + Assert.True(template.IsTextOnly); + } + + [Fact] + public void GrabTemplate_IsTextOnly_FalseWhenRegionsPresent() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "{1}", + Regions = [new TemplateRegion { RegionNumber = 1 }] + }; + + Assert.False(template.IsTextOnly); + } + + // ── ApplyTextOnlyTemplate ───────────────────────────────────────────────── + + [Fact] + public void ApplyTextOnlyTemplate_LiteralOutput_IgnoresInputText() + { + GrabTemplate template = new("Header") + { + OutputTemplate = "Static header line" + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "some selected text"); + Assert.Equal("Static header line", result); + } + + [Fact] + public void ApplyTextOnlyTemplate_RegionPlaceholders_ResolveToEmpty() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "Region: {1}!" + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "input"); + Assert.Equal("Region: !", result); + } + + [Fact] + public void ApplyTextOnlyTemplate_EscapeSequences_AreProcessed() + { + GrabTemplate template = new("Test") + { + OutputTemplate = @"line1\nline2" + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "input"); + Assert.Equal("line1\nline2", result); + } + + [Fact] + public void ApplyTextOnlyTemplate_InvalidTemplate_ReturnsInputUnchanged() + { + GrabTemplate template = new("Test") + { + OutputTemplate = string.Empty + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "keep me"); + Assert.Equal("keep me", result); + } + // ── ValidateOutputTemplate with patterns ────────────────────────────────── [Fact] diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs index ea063af1..92fc6e34 100644 --- a/Text-Grab/Models/GrabTemplate.cs +++ b/Text-Grab/Models/GrabTemplate.cs @@ -93,6 +93,13 @@ public GrabTemplate(string name) !string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(OutputTemplate); + /// + /// True when this template has no capture regions and therefore operates purely + /// on text (its output template uses only literal text and {p:Name:mode} pattern + /// placeholders). These can be applied to existing text in the Edit Text Window. + /// + public bool IsTextOnly => Regions.Count == 0; + /// /// Returns all region numbers referenced in the output template. /// diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs index cea400d4..ead23425 100644 --- a/Text-Grab/Utilities/GrabTemplateExecutor.cs +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -203,6 +203,37 @@ public static async Task ExecuteTemplateOnBitmapAsync( return output; } + /// + /// Applies a text-only template (one with no capture regions) to existing + /// . The text itself is used as the source for any + /// {p:Name:mode} pattern placeholders; region placeholders resolve to empty. + /// No OCR is performed, so this runs synchronously. Used when applying a template + /// to text already present in the Edit Text Window. + /// + public static string ApplyTextOnlyTemplate(GrabTemplate template, string text) + { + if (!template.IsValid) + return text; + + // No regions to OCR — region placeholders simply resolve to empty. + string output = ApplyOutputTemplate(template.OutputTemplate, new Dictionary()); + + bool hasPatternRefs = template.PatternMatches.Count > 0 + || PatternPlaceholderRegex.IsMatch(template.OutputTemplate); + + if (hasPatternRefs) + { + List effectivePatternMatches = template.PatternMatches.Count > 0 + ? template.PatternMatches + : ParsePatternMatchesFromOutputTemplate(template.OutputTemplate); + + Dictionary patternRegexes = ResolvePatternRegexes(effectivePatternMatches); + output = ApplyPatternPlaceholders(output, text, effectivePatternMatches, patternRegexes); + } + + return output; + } + /// /// Applies the output template string with the provided region text values. /// Useful for unit testing the string processing independently of OCR. From 1d929d916fd7a1b2db41cb89a94c000bd890d3a2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 17 Jun 2026 17:49:13 -0500 Subject: [PATCH 18/23] Add dynamic "Apply Grab Template" options to Edit menu Edit menu now includes "Apply Grab Template" and "Apply Grab Template Per Line" submenus, which are populated at runtime with valid text-only templates. Selecting a template applies it to the selected or all text, or to each non-blank line, respectively. Template usage is tracked via GrabTemplateManager. --- Text-Grab/Views/EditTextWindow.xaml | 10 +++- Text-Grab/Views/EditTextWindow.xaml.cs | 70 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index ae679fed..07514bf7 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -240,7 +240,7 @@ Header="Close" InputGestureText="Alt + F4" /> - + @@ -312,6 +312,14 @@ Click="AddRemoveAtMenuItem_Click" Header="_Add, Remove, Limit..." /> + + textOnlyTemplates = [.. GrabTemplateManager.GetAllTemplates().Where(template => template.IsTextOnly && template.IsValid)]; + + PopulateTemplateMenu(ApplyGrabTemplateMenuItem, textOnlyTemplates, ApplyGrabTemplateItem_Click); + PopulateTemplateMenu(ApplyGrabTemplatePerLineMenuItem, textOnlyTemplates, ApplyGrabTemplatePerLineItem_Click); + } + + private static void PopulateTemplateMenu(MenuItem parent, List templates, RoutedEventHandler clickHandler) + { + parent.Items.Clear(); + + if (templates.Count == 0) + { + parent.Visibility = Visibility.Collapsed; + return; + } + + foreach (GrabTemplate template in templates) + { + MenuItem templateItem = new() + { + Header = template.Name, + ToolTip = string.IsNullOrWhiteSpace(template.Description) ? null : template.Description, + Tag = template, + }; + templateItem.Click += clickHandler; + parent.Items.Add(templateItem); + } + + parent.Visibility = Visibility.Visible; + } + + private void ApplyGrabTemplateItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem { Tag: GrabTemplate template }) + return; + + ApplySelectedTextOrAllTextTransform(text => GrabTemplateExecutor.ApplyTextOnlyTemplate(template, text)); + GrabTemplateManager.RecordUsage(template.Id); + } + + private void ApplyGrabTemplatePerLineItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem { Tag: GrabTemplate template }) + return; + + ApplySelectedTextOrAllTextTransform(text => ApplyTemplatePerLine(template, text)); + GrabTemplateManager.RecordUsage(template.Id); + } + + /// + /// Splits into lines and applies the text-only template to each + /// non-blank line independently, preserving blank lines and the overall line structure. + /// + private static string ApplyTemplatePerLine(GrabTemplate template, string text) + { + string[] lines = text.Split(Environment.NewLine); + + for (int i = 0; i < lines.Length; i++) + { + if (!string.IsNullOrWhiteSpace(lines[i])) + lines[i] = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, lines[i]); + } + + return string.Join(Environment.NewLine, lines); + } + private void GrabFrameMenuItem_Click(object sender, RoutedEventArgs e) { CheckForGrabFrameOrLaunch(); From bd2a7fb939b03a1792d9d43a9c6013b04a60fe09 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 17 Jun 2026 17:49:23 -0500 Subject: [PATCH 19/23] Add template apply & regex quick ref to Find/Replace dialogs Added "Apply Template" to Find and Replace for batch applying text-only templates to matches. Enhanced Regex Editor with a collapsible quick reference of regex tokens, allowing one-click insertion. Made Regex Editor scrollable for better usability. Added "New Regex..." button to TextOnlyTemplateDialog to launch Regex Manager, with auto-refresh of pattern picker on dialog activation. --- Text-Grab/Controls/FindAndReplaceWindow.xaml | 10 ++ .../Controls/FindAndReplaceWindow.xaml.cs | 103 +++++++++++++++++ Text-Grab/Controls/RegexEditorDialog.xaml | 107 +++++++++++++----- Text-Grab/Controls/RegexEditorDialog.xaml.cs | 88 ++++++++++++++ .../Controls/TextOnlyTemplateDialog.xaml | 20 +++- .../Controls/TextOnlyTemplateDialog.xaml.cs | 18 +++ 6 files changed, 311 insertions(+), 35 deletions(-) diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml b/Text-Grab/Controls/FindAndReplaceWindow.xaml index 8935d419..b0a6bcbb 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml @@ -212,6 +212,16 @@ ButtonSymbol="WindowEdit20" ButtonText="Edit Matches" Command="{x:Static local:FindAndReplaceWindow.CopyMatchesCmd}" /> + + ResetWindowLoading(); } + private void ApplyTemplateButton_Click(object sender, RoutedEventArgs e) + { + System.Windows.Controls.ContextMenu menu = new() + { + PlacementTarget = ApplyTemplateButton, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, + }; + + if (IsSpreadsheetSearch) + { + menu.Items.Add(DisabledMenuItem("Not available in spreadsheet mode")); + menu.IsOpen = true; + return; + } + + // Load text-only templates fresh each time, mirroring the Edit Text Window menu. + List textOnlyTemplates = GrabTemplateManager.GetAllTemplates() + .Where(template => template.IsTextOnly && template.IsValid) + .ToList(); + + if (textOnlyTemplates.Count == 0) + { + menu.Items.Add(DisabledMenuItem("No text-only templates found")); + menu.IsOpen = true; + return; + } + + bool hasMatches = Matches is not null && Matches.Count > 0; + + foreach (GrabTemplate template in textOnlyTemplates) + { + System.Windows.Controls.MenuItem item = new() + { + Header = template.Name, + ToolTip = string.IsNullOrWhiteSpace(template.Description) ? null : template.Description, + Tag = template, + IsEnabled = hasMatches, + }; + item.Click += TemplateMenuItem_Click; + menu.Items.Add(item); + } + + if (!hasMatches) + menu.Items.Add(DisabledMenuItem("Run a search to find matches first")); + + menu.IsOpen = true; + } + + private static System.Windows.Controls.MenuItem DisabledMenuItem(string text) => + new() { Header = text, IsEnabled = false }; + + private void TemplateMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is System.Windows.Controls.MenuItem { Tag: GrabTemplate template }) + _ = ApplyTemplateToMatchesAsync(template); + } + + /// + /// Applies a text-only Grab Template to every matched result, replacing each match + /// with the template output evaluated against that match's own text. When two or + /// more results are selected, only those are affected; otherwise all matches are. + /// + private async Task ApplyTemplateToMatchesAsync(GrabTemplate template) + { + if (textEditWindow is null || IsSpreadsheetSearch) + return; + + if (Matches is null || Matches.Count < 1) + return; + + SetWindowToLoading(); + + string originalText = textEditWindow.PassedTextControl.Text; + StringBuilder stringBuilder = new(originalText); + + IList selection = ResultsListView.SelectedItems; + List targets = selection.Count >= 2 + ? [.. selection.Cast()] + : [.. ResultsListView.Items.Cast()]; + + await Task.Run(() => + { + // Apply from the end backwards so earlier indices stay valid as we edit. + foreach (FindResult result in targets.OrderByDescending(r => r.Index)) + { + if (result.Index < 0 || result.Index + result.Length > originalText.Length) + continue; + + string matchText = originalText.Substring(result.Index, result.Length); + string replacement = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, matchText); + + stringBuilder.Remove(result.Index, result.Length); + stringBuilder.Insert(result.Index, replacement); + } + }); + + textEditWindow.PassedTextControl.Text = stringBuilder.ToString(); + GrabTemplateManager.RecordUsage(template.Id); + + SearchForText(); + ResetWindowLoading(); + } + private void ResetWindowLoading() { MainContentGrid.IsEnabled = true; diff --git a/Text-Grab/Controls/RegexEditorDialog.xaml b/Text-Grab/Controls/RegexEditorDialog.xaml index 06d76453..a9889dd8 100644 --- a/Text-Grab/Controls/RegexEditorDialog.xaml +++ b/Text-Grab/Controls/RegexEditorDialog.xaml @@ -30,39 +30,86 @@ Padding="8,2" Icon="{StaticResource TextGrabIcon}" /> - - - + + + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /// Inserts the clicked reference token into the pattern box at the current caret + /// position (replacing any selection), then returns focus to the pattern box. + /// + private void InsertToken_Click(object sender, RoutedEventArgs e) + { + if (sender is not Wpf.Ui.Controls.Button { Tag: string token } || string.IsNullOrEmpty(token)) + return; + + int caret = PatternTextBox.CaretIndex; + if (PatternTextBox.SelectionLength > 0) + { + caret = PatternTextBox.SelectionStart; + PatternTextBox.SelectedText = token; + } + else + { + PatternTextBox.Text = PatternTextBox.Text.Insert(caret, token); + } + + PatternTextBox.CaretIndex = caret + token.Length; + PatternTextBox.Focus(); + } + + private static List BuildRegexReference() => + [ + new("Character classes", + [ + new(@"\d", "Any digit (0–9)"), + new(@"\D", "Any non-digit"), + new(@"\w", "Word character: letter, digit, or underscore"), + new(@"\W", "Any non-word character"), + new(@"\s", "Any whitespace (space, tab, newline)"), + new(@"\S", "Any non-whitespace character"), + new(".", "Any single character (except newline)"), + new("[abc]", "Any one of the listed characters"), + new("[^abc]", "Any character NOT listed"), + new("[a-z]", "Any character in the range a to z"), + ]), + new("Anchors & boundaries", + [ + new("^", "Start of the line/string"), + new("$", "End of the line/string"), + new(@"\b", "Word boundary (edge of a word)"), + new(@"\B", "Not a word boundary"), + ]), + new("Quantifiers", + [ + new("*", "Zero or more of the preceding item"), + new("+", "One or more of the preceding item"), + new("?", "Zero or one (makes it optional)"), + new("{3}", "Exactly 3 of the preceding item"), + new("{2,}", "2 or more of the preceding item"), + new("{2,5}", "Between 2 and 5 of the preceding item"), + new("*?", "Lazy: as few as possible (also +? and ??)"), + ]), + new("Groups & alternation", + [ + new("(...)", "Capture group — remembers the match"), + new("(?:...)", "Group without capturing"), + new("(?...)", "Named capture group"), + new("a|b", "Match either a or b"), + ]), + new("Lookaround (match context without consuming it)", + [ + new("(?=...)", "Lookahead: followed by ... (text after a match)"), + new("(?!...)", "Negative lookahead: NOT followed by ..."), + new("(?<=...)", "Lookbehind: preceded by ... (text before a match)"), + new("(?A named group of regex reference rows shown in the quick-reference expander. + public sealed record RegexReferenceCategory(string CategoryName, IReadOnlyList Items); + + /// A single regex token and a plain-language description of what it does. + public sealed record RegexReferenceItem(string Token, string Description); } diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml index 7f633da0..1c71e64c 100644 --- a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml @@ -50,11 +50,21 @@ ToolTip="Type { to insert a pattern placeholder. Plain text is also supported." VerticalScrollBarVisibility="Auto" /> - + + + + (); + regexManager.Show(); + regexManager.Activate(); + } + private void ValidateInput(object sender, TextChangedEventArgs e) => UpdateSaveButton(); private void OutputTemplateBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSaveButton(); From 2e31f876e638998e5716b501642af8e6a9ff4802 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 19 Jun 2026 21:39:38 -0500 Subject: [PATCH 20/23] update comments --- Text-Grab/Services/CalculationService.UnitMath.cs | 4 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index e065a5de..2dddddca 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -315,7 +315,7 @@ private bool TryContinuationConversion( } /// - /// Handles "+ + /// Handles "+ 3 km" or "- 5 miles" operator continuation with units. /// Converts the operand to the previous unit before adding/subtracting. /// private bool TryOperatorWithUnit( @@ -459,7 +459,7 @@ private bool TryExplicitConversion( } /// - /// Handles standalone unit expressions + /// Handles standalone unit expressions like "5 meters" — tracks the unit for future /// continuation but does not convert. Requires multi-character unit abbreviations /// to avoid conflicts with single-letter variable names and quantity words. /// diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 06143fb3..57d5dcf8 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -3729,6 +3729,10 @@ private static bool IsAnyPopupOpen() { foreach (PresentationSource source in PresentationSource.CurrentSources) { + // PopupRoot is internal to WPF, so it can only be matched by type + // name; if a future framework version renames it this check silently + // stops detecting popups (the change detector would then run while a + // popup is open, at worst causing one spurious refresh). if (source.RootVisual is UIElement { IsVisible: true } rootElement && rootElement.GetType().Name == "PopupRoot") { From 27ff3c339e0f8a80dfb3a27e956962fe396c5736 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 19 Jun 2026 22:16:41 -0500 Subject: [PATCH 21/23] Secure protocol file path validation and tests Add ProtocolUtilities.TryGetSafeProtocolFilePath to validate protocol file paths, restricting access to local, non-network, allowed-root image/PDF files. Update protocol handlers to use this method, preventing unsafe file access. Add unit tests for path validation logic. Remove unused RemoveProtocolRegistration method. --- Tests/ProtocolUtilitiesTests.cs | 79 ++++++++++++++ Text-Grab/App.xaml.cs | 30 ++++-- Text-Grab/Utilities/ProtocolUtilities.cs | 130 ++++++++++++++++++++--- 3 files changed, 213 insertions(+), 26 deletions(-) diff --git a/Tests/ProtocolUtilitiesTests.cs b/Tests/ProtocolUtilitiesTests.cs index 53bbc58f..9bd0ce64 100644 --- a/Tests/ProtocolUtilitiesTests.cs +++ b/Tests/ProtocolUtilitiesTests.cs @@ -1,3 +1,5 @@ +using System; +using System.IO; using Text_Grab.Utilities; namespace Tests; @@ -114,4 +116,81 @@ public void TryParseProtocolUri_IgnoresMalformedQueryPairs() Assert.Single(parameters); Assert.Equal(@"C:\a.png", parameters["path"]); } + + // ── TryGetSafeProtocolFilePath ──────────────────────────────────────────── + + [Fact] + public void TryGetSafeProtocolFilePath_AcceptsImageInTempFolder() + { + string tempImage = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.png"); + File.WriteAllBytes(tempImage, [0]); + try + { + bool safe = ProtocolUtilities.TryGetSafeProtocolFilePath(tempImage, out string fullPath); + + Assert.True(safe); + Assert.Equal(Path.GetFullPath(tempImage), fullPath); + } + finally + { + File.Delete(tempImage); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(@"\\server\share\image.png")] // UNC: would trigger an SMB credential leak + [InlineData("//server/share/image.png")] // forward-slash UNC + [InlineData(@"\\?\C:\Windows\image.png")] // extended-length device path + [InlineData(@"\\.\PhysicalDrive0")] // device namespace + public void TryGetSafeProtocolFilePath_RejectsUncDeviceAndEmptyPaths(string? path) + { + Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(path, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsPathOutsideAllowedRoots() + { + // The Windows folder is never an allowed root; rejection happens before any + // existence check, so the file need not exist. + string outside = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + $"text-grab-{Guid.NewGuid():N}.png"); + + Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(outside, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsTraversalEscapingAllowedRoot() + { + // Starts inside Temp but climbs out to the Windows folder. + string traversal = Path.Combine(Path.GetTempPath(), "..", "..", "..", "Windows", "image.png"); + + Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(traversal, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsNonImageExtensionInAllowedRoot() + { + string tempText = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.txt"); + File.WriteAllText(tempText, "hello"); + try + { + Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(tempText, out _)); + } + finally + { + File.Delete(tempText); + } + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsNonexistentImageInAllowedRoot() + { + string missing = Path.Combine(Path.GetTempPath(), $"text-grab-missing-{Guid.NewGuid():N}.png"); + + Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(missing, out _)); + } } diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 4aee8365..40b16da1 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -395,14 +395,20 @@ internal static bool HandleProtocolUri(string uriString) } case "grab-frame": { - if (parameters.TryGetValue("path", out string? path) - && File.Exists(path) - && IoUtilities.IsVisualDocumentFile(path)) + // A path is optional. When present it is untrusted (the protocol can be + // launched by any web page), so it must pass the safe-path gate before we + // open the file; an unsafe path falls back to an empty Grab Frame. + if (parameters.TryGetValue("path", out string? path)) { - GrabFrame gfWithFile = new(path); - gfWithFile.Show(); - gfWithFile.Activate(); - return true; + if (ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) + { + GrabFrame gfWithFile = new(safePath); + gfWithFile.Show(); + gfWithFile.Activate(); + return true; + } + + Debug.WriteLine("grab-frame protocol: rejected unsafe path; opening empty frame."); } GrabFrame gf = new(); @@ -412,14 +418,16 @@ internal static bool HandleProtocolUri(string uriString) } case "grab-text": { - // OCR a local image/PDF straight to the clipboard, no window. + // OCR a local image/PDF straight to the clipboard, no window. The path is + // untrusted; only proceed for a validated, allowed local file. if (parameters.TryGetValue("path", out string? path) - && File.Exists(path) - && IoUtilities.IsVisualDocumentFile(path)) + && ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) { - _ = GrabTextFromFileAsync(path); + _ = GrabTextFromFileAsync(safePath); return true; } + + Debug.WriteLine("grab-text protocol: missing or unsafe path; ignoring."); return false; } case "fullscreen": diff --git a/Text-Grab/Utilities/ProtocolUtilities.cs b/Text-Grab/Utilities/ProtocolUtilities.cs index 378507c5..cb5b250f 100644 --- a/Text-Grab/Utilities/ProtocolUtilities.cs +++ b/Text-Grab/Utilities/ProtocolUtilities.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; namespace Text_Grab.Utilities; @@ -70,6 +71,120 @@ internal static bool TryParseProtocolUri( return true; } + /// + /// Validates a path= parameter supplied via the text-grab:// protocol and, + /// when safe, returns its canonical full path. Because any web page can launch the + /// protocol, the path is treated as untrusted and must clear several gates: + /// + /// No UNC or device paths (\\server\share, \\?\, \\.\) — probing + /// one would trigger an outbound SMB authentication and leak the user's NTLM credentials. + /// Canonicalized with so traversal + /// (..\..\) and relative paths cannot escape the allowed locations. + /// Rooted on a local, non-network drive. + /// Located under a folder the companion extension legitimately writes to + /// (Downloads, Temp, Pictures), so a page cannot point us at arbitrary files. + /// An existing image/PDF file. + /// + /// All checks that could touch the path (existence, drive type) run only after the + /// UNC/device gate, so the dangerous network probe is never performed. + /// + internal static bool TryGetSafeProtocolFilePath(string? rawPath, out string fullPath) + { + fullPath = string.Empty; + + if (string.IsNullOrWhiteSpace(rawPath)) + return false; + + // Reject UNC and device/extended-length paths before any filesystem call. + if (rawPath.StartsWith(@"\\", StringComparison.Ordinal) + || rawPath.StartsWith("//", StringComparison.Ordinal)) + return false; + + string candidate; + try + { + candidate = Path.GetFullPath(rawPath); + } + catch + { + return false; + } + + // GetFullPath can still surface a UNC root for some inputs; re-check. + if (candidate.StartsWith(@"\\", StringComparison.Ordinal)) + return false; + + // Require a drive-letter root (e.g. "C:\"). This also rejects rooted-but- + // drive-less paths like "\Windows\..." which resolve against the current drive. + string? root = Path.GetPathRoot(candidate); + if (root is null || root.Length < 2 || root[1] != ':') + return false; + + try + { + DriveInfo drive = new(root); + if (drive.DriveType is DriveType.Network or DriveType.NoRootDirectory or DriveType.Unknown) + return false; + } + catch + { + return false; + } + + if (!IsUnderAllowedRoot(candidate)) + return false; + + if (!IoUtilities.IsVisualDocumentFile(candidate)) + return false; + + fullPath = candidate; + return true; + } + + /// + /// Folders the text-grab:// protocol is allowed to open files from — the locations a + /// companion app such as the browser extension realistically deposits a captured image. + /// + private static IEnumerable AllowedFileRoots() + { + yield return Path.GetTempPath(); + + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(userProfile)) + yield return Path.Combine(userProfile, "Downloads"); + + yield return Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + } + + private static bool IsUnderAllowedRoot(string fullPath) + { + foreach (string root in AllowedFileRoots()) + { + if (string.IsNullOrEmpty(root)) + continue; + + string normalizedRoot; + try + { + normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + } + catch + { + continue; + } + + if (fullPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + return true; + + // Require a separator after the root so "C:\Downloads" does not also + // match a sibling like "C:\DownloadsEvil". + if (fullPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + /// /// Registers the text-grab:// protocol for the current user when running /// unpackaged. Packaged installs register it through the MSIX manifest. @@ -110,19 +225,4 @@ internal static void EnsureProtocolRegistration() Debug.WriteLine($"text-grab:// protocol registration failed: {ex.Message}"); } } - - /// - /// Removes the per-user text-grab:// protocol registration. - /// - internal static void RemoveProtocolRegistration() - { - try - { - Registry.CurrentUser.DeleteSubKeyTree(ProtocolKeyPath, throwOnMissingSubKey: false); - } - catch (Exception ex) - { - Debug.WriteLine($"text-grab:// protocol unregistration failed: {ex.Message}"); - } - } } From 808db70b30465294462da699bd230021a5d74743 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 19 Jun 2026 23:45:00 -0500 Subject: [PATCH 22/23] Update NCalcAsync and Markdig package versions Updated NCalcAsync to 6.2.0 in both main and test projects. Updated Markdig to 1.3.2 in Text-Grab.csproj for improved compatibility and features. --- Tests/Tests.csproj | 2 +- Text-Grab/Text-Grab.csproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 664d059a..19ae399f 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index fabf80af..325897c0 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -71,13 +71,13 @@ - + - + From 9bec14792a46395182e1e16c9c1da0b1f9b1de06 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 20 Jun 2026 12:44:43 -0500 Subject: [PATCH 23/23] bump version to 1.14.2 --- Text-Grab-Package/Package.appxmanifest | 2 +- Text-Grab/Text-Grab.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 6da9f0c4..97abd3f7 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -14,7 +14,7 @@ + Version="4.14.2.0" /> Text Grab diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 325897c0..9af1b174 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -23,7 +23,7 @@ true win-x86;win-x64;win-arm64 false - 4.14.1 + 4.14.2 $(NoWarn);WFO0003