diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e7e464a3..871c7699 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,10 @@ "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 *)", + "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/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/Tests/EditTextWindowSpellCheckTests.cs b/Tests/EditTextWindowSpellCheckTests.cs new file mode 100644 index 00000000..878f16d6 --- /dev/null +++ b/Tests/EditTextWindowSpellCheckTests.cs @@ -0,0 +1,108 @@ +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)); + } + + [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/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/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/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/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); + } } diff --git a/Tests/ProtocolUtilitiesTests.cs b/Tests/ProtocolUtilitiesTests.cs new file mode 100644 index 00000000..9bd0ce64 --- /dev/null +++ b/Tests/ProtocolUtilitiesTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +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"]); + } + + // ── 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/Tests/Tests.csproj b/Tests/Tests.csproj index 5d1fec52..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/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/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-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 22057898..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 @@ -96,6 +96,13 @@ + + + + Text Grab + + + 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/App.xaml.cs b/Text-Grab/App.xaml.cs index f84baa00..40b16da1 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,116 @@ 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": + { + // 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)) + { + 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(); + gf.Show(); + gf.Activate(); + return true; + } + case "grab-text": + { + // 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) + && ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) + { + _ = GrabTextFromFileAsync(safePath); + return true; + } + + Debug.WriteLine("grab-text protocol: missing or unsafe path; ignoring."); + 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 +547,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; 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/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..9181206a 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(300); + + public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousGrabIndicator.None) { InitializeComponent(); @@ -20,8 +37,38 @@ public PreviousGrabWindow(Rect rect) Left = rect.Left - borderThickness; Top = rect.Top - borderThickness; - DispatcherTimer timer = new(); - timer.Interval = TimeSpan.FromMilliseconds(500); + 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() + { + Interval = flashDuration + }; timer.Tick += (s, e) => { timer.Stop(); Close(); }; timer.Start(); } 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(); 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) { 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/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/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." /> + + + + + + + + + + + + + + + True + + Auto + diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index 30809fb6..2dddddca 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -308,7 +308,9 @@ 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; } @@ -450,7 +452,9 @@ 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; } @@ -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/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..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 @@ -68,16 +68,16 @@ - - - - + + + + - - - - - + + + + + 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() diff --git a/Text-Grab/Utilities/DiagnosticsUtilities.cs b/Text-Grab/Utilities/DiagnosticsUtilities.cs index ec443e64..3413a1c5 100644 --- a/Text-Grab/Utilities/DiagnosticsUtilities.cs +++ b/Text-Grab/Utilities/DiagnosticsUtilities.cs @@ -211,6 +211,7 @@ private static SettingsInfoModel GetSettingsInfo() EditWindowStartFullscreen = s.EditWindowStartFullscreen, RestoreEtwPositions = s.RestoreEtwPositions, EtwUseMargins = s.EtwUseMargins, + EtwSpellCheckMode = s.EtwSpellCheckMode ?? string.Empty, ShowCursorText = s.ShowCursorText, ScrollBottomBar = s.ScrollBottomBar, EtwShowLangPicker = s.EtwShowLangPicker, @@ -560,6 +561,7 @@ public class SettingsInfoModel public bool EditWindowStartFullscreen { get; set; } public bool RestoreEtwPositions { get; set; } public bool EtwUseMargins { get; set; } + public string EtwSpellCheckMode { get; set; } = string.Empty; public bool ShowCursorText { get; set; } public bool ScrollBottomBar { get; set; } public bool EtwShowLangPicker { get; set; } 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. 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; + } +} 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(); } 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/Utilities/ProtocolUtilities.cs b/Text-Grab/Utilities/ProtocolUtilities.cs new file mode 100644 index 00000000..cb5b250f --- /dev/null +++ b/Text-Grab/Utilities/ProtocolUtilities.cs @@ -0,0 +1,228 @@ +using Microsoft.Win32; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +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; + } + + /// + /// 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. + /// 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}"); + } + } +} diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 51341fe4..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..." /> + + + + + + + + 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; @@ -111,6 +112,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 +258,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 +554,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 +2447,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; - } - - if (dataPackageView is null) - { - e.CanExecute = false; - return; + Debug.WriteLine($"error updating clipboard OCR state: {ex.Message}"); + _cachedClipboardHasOcrContent = true; // optimistic fallback } - - 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 +2508,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; @@ -2882,6 +2890,76 @@ private async Task ApplySelectedTextOrAllTextTransformAsync(Func 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(); @@ -3726,6 +3804,102 @@ 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; + } + + /// + /// 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) @@ -3734,6 +3908,8 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e prevWindowState = null; } + ApplySpellCheckMode(); + UpdateLineAndColumnText(); // Reset the debounce timer @@ -3767,7 +3943,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 +4311,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) @@ -4196,6 +4375,10 @@ private void RestoreWindowSettings() SetMargins(true); } + if (!Enum.TryParse(DefaultSettings.EtwSpellCheckMode, out SpellCheckMode savedSpellCheckMode)) + savedSpellCheckMode = SpellCheckMode.Auto; + SetSpellCheckMode(savedSpellCheckMode); + SetBottomBarButtons(); } @@ -4757,8 +4940,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) @@ -5233,6 +5417,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) @@ -5404,6 +5593,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) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index ce283f92..0c091068 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,34 @@ 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. + // 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 + { + 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 +1142,7 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool else ResetSelectionVisualState(); - return; + return false; } if (NextStepDropDownButton.Flyout is ContextMenu contextMenu) @@ -1201,6 +1230,7 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool isTable, destinationTextBox); WindowUtilities.CloseAllFullscreenGrabs(); + return true; } private async void AcceptSelectionButton_Click(object sender, RoutedEventArgs e) 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" /> 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,13 +1442,24 @@ 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); } - public void MergeSelectedWordBorders() + private void DisposePreviousFrameContent() { - if (TableToggleButton.IsChecked is true) + if (GrabFrameImage.Source is null) return; + GrabFrameImage.Source = null; + GrabFrameImage.UpdateLayout(); + } + + public void MergeSelectedWordBorders() + { ShouldSaveOnClose = true; RectanglesCanvas.ContextMenu.IsOpen = false; if (!IsFreezeMode) @@ -1721,9 +1748,7 @@ private void CanChangeWordBorderExecute(object sender, CanExecuteRoutedEventArgs private void CanExecuteMergeWordBorders(object sender, CanExecuteRoutedEventArgs e) { - e.CanExecute = ShouldAllowWordBorderMerging( - TableToggleButton.IsChecked is true, - SelectedWordBorders().Count); + e.CanExecute = ShouldAllowWordBorderMerging(SelectedWordBorders().Count); } private void CanPasteExecute(object sender, CanExecuteRoutedEventArgs e) @@ -1933,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() @@ -2182,6 +2220,9 @@ private void AddRenderedWordBorder(WordBorder wordBorderBox) wordBorders.Add(wordBorderBox); _ = RectanglesCanvas.Children.Add(wordBorderBox); + if (isAutoOcrRedrawPass) + return; + UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, new GrabFrameOperationArgs() { @@ -2214,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 = "") @@ -2647,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; @@ -3605,7 +3653,94 @@ 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) + { + // 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") + { + return true; + } + } + + return false; } private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = null) @@ -3625,12 +3760,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) { @@ -3651,6 +3786,7 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = await Task.Delay(200); + DisposePreviousFrameContent(); frameContentImageSource = ImageMethods.GetWindowBoundsImage(this); GrabFrameImage.Source = frameContentImageSource; } @@ -4541,6 +4677,7 @@ private void UnfreezeGrabFrame() ResetGrabFrame(); Topmost = true; GrabFrameImage.Opacity = 0; + DisposePreviousFrameContent(); frameContentImageSource = null; historyItem = null; RectanglesBorder.Background.Opacity = overlayOpacity; @@ -4625,6 +4762,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 @@ -4733,9 +4876,9 @@ private bool IsParagraphDetectionActive() && OcrUtilities.ShouldUseParagraphDetection(isSpaceJoining, TableToggleButton.IsChecked is true); } - internal static bool ShouldAllowWordBorderMerging(bool isTableModeSelected, int selectedWordBorderCount) + internal static bool ShouldAllowWordBorderMerging(int selectedWordBorderCount) { - return !isTableModeSelected && selectedWordBorderCount > 1; + return selectedWordBorderCount > 1; } internal static bool ShouldRefreshOcrBordersForTableModeActivation( @@ -5105,7 +5248,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; @@ -5153,7 +5298,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; @@ -5201,7 +5348,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; @@ -5249,7 +5398,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; @@ -5297,7 +5448,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;