diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 871c7699..c4fdeaa7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,21 @@ "PowerShell(dotnet build *)", "PowerShell(dotnet test *)", "PowerShell(Get-ChildItem *)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(gh issue view *)", + "Bash(git log *)", + "Bash(gh pr view *)", + "Bash(gh pr diff *)", + "Bash(git fetch *)", + "WebFetch(domain:app.produckmap.com)", + "Read(//c/Users/josep/.claude/skills/pdm/**)", + "Read(//c/Users/josep/.claude/skills/pdm/bin/**)", + "PowerShell(& \"C:\\\\Users\\\\josep\\\\.claude\\\\skills\\\\pdm\\\\bin\\\\pdm.ps1\" *)", + "PowerShell(& \"C:\\\\Users\\\\josep\\\\.claude\\\\skills\\\\pdm\\\\bin\\\\pdm.ps1\" auth)", + "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" story-map *)", + "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" type *)", + "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" ui-element *)", + "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" api *)" ], "deny": [] } diff --git a/BUILT-WITH.md b/BUILT-WITH.md index 9ef27731..06772325 100644 --- a/BUILT-WITH.md +++ b/BUILT-WITH.md @@ -2,6 +2,7 @@ Text Grab depends on the direct NuGet packages listed below. +- **Versions** are intentionally omitted here. The project files are the single source of truth — see [`Text-Grab/Text-Grab.csproj`](Text-Grab/Text-Grab.csproj) and [`Tests/Tests.csproj`](Tests/Tests.csproj) for the exact version of each package. - **Scope** identifies whether a package is used by the app, the tests, or both. - **Notice** links to a bundled local notice file when one ships with Text Grab, or to the upstream project license file otherwise. - **Project** links to the upstream project home or repository. @@ -9,31 +10,40 @@ Text Grab depends on the direct NuGet packages listed below. The same package inventory is also available from the app's **About → Licenses** flow. -| Package | Version | Scope | License | Notice | Project | Notes | -| --- | --- | --- | --- | --- | --- | --- | -| CliWrap | 3.10.1 | App | MIT | [Open](https://github.com/Tyrrrz/CliWrap/blob/master/License.txt) | [Project](https://github.com/Tyrrrz/CliWrap) | — | -| Dapplo.Windows.User32 | 2.0.89 | App | MIT | [Open](https://github.com/dapplo/Dapplo.Windows/blob/master/LICENSE) | [Project](https://github.com/dapplo/Dapplo.Windows) | — | -| Humanizer.Core | 3.0.10 | App | MIT | [Open](https://github.com/Humanizr/Humanizer/blob/main/license.txt) | [Project](https://github.com/Humanizr/Humanizer) | — | -| Magick.NET-Q16-AnyCPU | 14.12.0 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | -| Magick.NET.SystemDrawing | 8.0.20 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | -| Magick.NET.SystemWindowsMedia | 8.0.20 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | -| Markdig | 1.1.3 | App | BSD-2-Clause | [Open](ThirdPartyNotices/licenses/Markdig-license.txt) | [Project](https://github.com/xoofx/markdig) | Bundled to satisfy BSD-2-Clause binary redistribution notice requirements. | -| Microsoft.Toolkit.Uwp.Notifications | 7.1.3 | App | MIT | [Open](https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/License.md) | [Project](https://github.com/CommunityToolkit/WindowsCommunityToolkit) | — | -| Microsoft.WindowsAppSDK.AI | 1.8.70 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | -| Microsoft.WindowsAppSDK.Foundation | 1.8.260415000 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | -| Microsoft.WindowsAppSDK.Runtime | 1.8.260416003 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | -| Microsoft.WindowsAppSDK.WinUI | 1.8.260415005 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | -| NCalcAsync | 5.12.0 | App, Tests | MIT | [Open](https://github.com/ncalc/ncalc/blob/master/LICENSE) | [Project](https://github.com/ncalc/ncalc) | Shared by the application and the test project. | -| PdfPig | 0.1.14 | App | Apache-2.0 | [Open](https://github.com/UglyToad/PdfPig/blob/master/LICENSE) | [Project](https://github.com/UglyToad/PdfPig) | — | -| UnitsNet | 5.75.0 | App | MIT-0 | [Open](https://github.com/angularsen/UnitsNet/blob/master/LICENSE) | [Project](https://github.com/angularsen/UnitsNet) | — | -| WPF-UI | 4.2.1 | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | -| WPF-UI.Tray | 4.2.1 | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | -| ZXing.Net | 0.16.11 | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | -| ZXing.Net.Bindings.Windows.Compatibility | 0.16.14 | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | -| BenchmarkDotNet | 0.15.8 | Tests | MIT | [Open](https://github.com/dotnet/BenchmarkDotNet/blob/master/LICENSE.md) | [Project](https://github.com/dotnet/BenchmarkDotNet) | Test-only dependency. | -| coverlet.collector | 10.0.0 | Tests | MIT | [Open](https://github.com/coverlet-coverage/coverlet/blob/master/LICENSE) | [Project](https://github.com/coverlet-coverage/coverlet) | Test-only dependency. | -| Microsoft.NET.Test.Sdk | 18.4.0 | Tests | MIT | [Open](https://github.com/microsoft/vstest/blob/main/LICENSE) | [Project](https://github.com/microsoft/vstest) | Test-only dependency. | -| Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers | 18.7.37220.1 | Tests | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md) | [Project](https://learn.microsoft.com/visualstudio/profiling/) | Visual Studio benchmarking tooling; test-only dependency. | -| xunit.runner.visualstudio | 3.1.5 | Tests | Apache-2.0 | [Open](https://github.com/xunit/visualstudio.xunit/blob/main/License.txt) | [Project](https://github.com/xunit/visualstudio.xunit) | Test-only dependency. | -| Xunit.StaFact | 3.0.13 | Tests | MS-PL | [Open](https://github.com/AArnott/Xunit.StaFact/blob/main/LICENSE) | [Project](https://github.com/AArnott/Xunit.StaFact) | Test-only dependency. | -| xunit.v3 | 3.2.2 | Tests | Apache-2.0 | [Open](https://github.com/xunit/xunit/blob/main/LICENSE) | [Project](https://github.com/xunit/xunit) | Test-only dependency. | +| Package | Scope | License | Notice | Project | Notes | +| --- | --- | --- | --- | --- | --- | +| CliWrap | App | MIT | [Open](https://github.com/Tyrrrz/CliWrap/blob/master/License.txt) | [Project](https://github.com/Tyrrrz/CliWrap) | — | +| Dapplo.Windows.User32 | App | MIT | [Open](https://github.com/dapplo/Dapplo.Windows/blob/master/LICENSE) | [Project](https://github.com/dapplo/Dapplo.Windows) | — | +| Humanizer.Core | App | MIT | [Open](https://github.com/Humanizr/Humanizer/blob/main/license.txt) | [Project](https://github.com/Humanizr/Humanizer) | — | +| Magick.NET-Q16-AnyCPU | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Magick.NET.SystemDrawing | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Magick.NET.SystemWindowsMedia | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Markdig | App | BSD-2-Clause | [Open](ThirdPartyNotices/licenses/Markdig-license.txt) | [Project](https://github.com/xoofx/markdig) | Bundled to satisfy BSD-2-Clause binary redistribution notice requirements. | +| Microsoft.Recognizers.Text.Choice | App | MIT | [Open](https://github.com/microsoft/Recognizers-Text/blob/master/LICENSE.md) | [Project](https://github.com/microsoft/Recognizers-Text) | Culture-aware entity recognizers. | +| Microsoft.Recognizers.Text.DateTime | App | MIT | [Open](https://github.com/microsoft/Recognizers-Text/blob/master/LICENSE.md) | [Project](https://github.com/microsoft/Recognizers-Text) | Culture-aware entity recognizers. | +| Microsoft.Recognizers.Text.Number | App | MIT | [Open](https://github.com/microsoft/Recognizers-Text/blob/master/LICENSE.md) | [Project](https://github.com/microsoft/Recognizers-Text) | Culture-aware entity recognizers. | +| Microsoft.Recognizers.Text.NumberWithUnit | App | MIT | [Open](https://github.com/microsoft/Recognizers-Text/blob/master/LICENSE.md) | [Project](https://github.com/microsoft/Recognizers-Text) | Culture-aware entity recognizers. | +| Microsoft.Recognizers.Text.Sequence | App | MIT | [Open](https://github.com/microsoft/Recognizers-Text/blob/master/LICENSE.md) | [Project](https://github.com/microsoft/Recognizers-Text) | Culture-aware entity recognizers. | +| Microsoft.Toolkit.Uwp.Notifications | App | MIT | [Open](https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/License.md) | [Project](https://github.com/CommunityToolkit/WindowsCommunityToolkit) | — | +| Microsoft.WindowsAppSDK.AI | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.Foundation | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.Runtime | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.WinUI | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| NCalcAsync | App, Tests | MIT | [Open](https://github.com/ncalc/ncalc/blob/master/LICENSE) | [Project](https://github.com/ncalc/ncalc) | Shared by the application and the test project. | +| PdfPig | App | Apache-2.0 | [Open](https://github.com/UglyToad/PdfPig/blob/master/LICENSE) | [Project](https://github.com/UglyToad/PdfPig) | — | +| UnitsNet | App | MIT-0 | [Open](https://github.com/angularsen/UnitsNet/blob/master/LICENSE) | [Project](https://github.com/angularsen/UnitsNet) | — | +| Vortice.Direct3D11 | App | MIT | [Open](https://github.com/amerkoleci/Vortice.Windows/blob/main/LICENSE) | [Project](https://github.com/amerkoleci/Vortice.Windows) | Direct3D 11 interop for HDR-aware screen capture. | +| Vortice.DXGI | App | MIT | [Open](https://github.com/amerkoleci/Vortice.Windows/blob/main/LICENSE) | [Project](https://github.com/amerkoleci/Vortice.Windows) | DXGI interop for HDR-aware screen capture. | +| WPF-UI | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | +| WPF-UI.Tray | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | +| ZXing.Net | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | +| ZXing.Net.Bindings.Windows.Compatibility | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | +| BenchmarkDotNet | Tests | MIT | [Open](https://github.com/dotnet/BenchmarkDotNet/blob/master/LICENSE.md) | [Project](https://github.com/dotnet/BenchmarkDotNet) | Test-only dependency. | +| coverlet.collector | Tests | MIT | [Open](https://github.com/coverlet-coverage/coverlet/blob/master/LICENSE) | [Project](https://github.com/coverlet-coverage/coverlet) | Test-only dependency. | +| Microsoft.NET.Test.Sdk | Tests | MIT | [Open](https://github.com/microsoft/vstest/blob/main/LICENSE) | [Project](https://github.com/microsoft/vstest) | Test-only dependency. | +| Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers | Tests | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md) | [Project](https://learn.microsoft.com/visualstudio/profiling/) | Visual Studio benchmarking tooling; test-only dependency. | +| xunit.runner.visualstudio | Tests | Apache-2.0 | [Open](https://github.com/xunit/visualstudio.xunit/blob/main/License.txt) | [Project](https://github.com/xunit/visualstudio.xunit) | Test-only dependency. | +| Xunit.StaFact | Tests | MS-PL | [Open](https://github.com/AArnott/Xunit.StaFact/blob/main/LICENSE) | [Project](https://github.com/AArnott/Xunit.StaFact) | Test-only dependency. | +| xunit.v3 | Tests | Apache-2.0 | [Open](https://github.com/xunit/xunit/blob/main/LICENSE) | [Project](https://github.com/xunit/xunit) | Test-only dependency. | + + diff --git a/Tests/CaptureLanguageUtilitiesTests.cs b/Tests/CaptureLanguageUtilitiesTests.cs index b992513d..290b777b 100644 --- a/Tests/CaptureLanguageUtilitiesTests.cs +++ b/Tests/CaptureLanguageUtilitiesTests.cs @@ -9,15 +9,18 @@ namespace Tests; public class CaptureLanguageUtilitiesTests : IDisposable { private readonly bool _originalUiAutomationEnabled; + private readonly bool _originalWindowsAiDescriptionEnabled; public CaptureLanguageUtilitiesTests() { _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + _originalWindowsAiDescriptionEnabled = Settings.Default.WindowsAiDescriptionEnabled; } public void Dispose() { Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.WindowsAiDescriptionEnabled = _originalWindowsAiDescriptionEnabled; Settings.Default.Save(); LanguageUtilities.InvalidateAllCaches(); } @@ -42,6 +45,16 @@ public void MatchesPersistedLanguage_MatchesLegacyTesseractDisplayName() Assert.True(matches); } + [Fact] + public void MatchesPersistedLanguage_MatchesWindowsAiDescriptionTag() + { + WindowsAiDescriptionLang language = new(); + + bool matches = CaptureLanguageUtilities.MatchesPersistedLanguage(language, WindowsAiDescriptionLang.Tag); + + Assert.True(matches); + } + [Fact] public void FindPreferredLanguageIndex_PrefersPersistedMatchBeforeFallbackLanguage() { @@ -84,12 +97,45 @@ public async Task GetCaptureLanguagesAsync_IncludesUiAutomationWhenEnabled() Assert.Contains(languages, language => language is UiAutomationLang); } + [WpfFact] + public async Task GetCaptureLanguagesAsync_ExcludesWindowsAiDescriptionByDefault() + { + Settings.Default.WindowsAiDescriptionEnabled = false; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.DoesNotContain(languages, language => language is WindowsAiDescriptionLang); + } + + [WpfFact] + public async Task GetCaptureLanguagesAsync_IncludesWindowsAiDescriptionOnlyWhenSupported() + { + Settings.Default.WindowsAiDescriptionEnabled = true; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + if (WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + Assert.Contains(languages, language => language is WindowsAiDescriptionLang); + else + Assert.DoesNotContain(languages, language => language is WindowsAiDescriptionLang); + } + [Fact] public void SupportsTableOutput_ReturnsFalseForUiAutomation() { Assert.False(CaptureLanguageUtilities.SupportsTableOutput(new UiAutomationLang())); } + [Fact] + public void SupportsTableOutput_ReturnsFalseForWindowsAiDescription() + { + Assert.False(CaptureLanguageUtilities.SupportsTableOutput(new WindowsAiDescriptionLang())); + } + [Fact] public void RequiresLiveUiAutomationSource_ReturnsTrueForStaticUiAutomationWithoutSnapshot() { diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs index 9efef250..af36ff6f 100644 --- a/Tests/ClipboardUtilitiesTests.cs +++ b/Tests/ClipboardUtilitiesTests.cs @@ -135,6 +135,24 @@ public void ConvertHtmlToTabSeparated_HandlesRowspan() Assert.Equal("Tall\tBottom", lines[1]); } + [Fact] + public void ConvertHtmlToTabSeparated_DoesNotOverwriteRowspanWithColspan() + { + string html = """ + + + +
LeftTallRight
Merged
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + string[] lines = result.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Left\tTall\tRight", lines[0]); + Assert.Equal("\tTall\tMerged\tMerged", 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 diff --git a/Tests/ColumnSplitUtilitiesTests.cs b/Tests/ColumnSplitUtilitiesTests.cs new file mode 100644 index 00000000..c6d1071e --- /dev/null +++ b/Tests/ColumnSplitUtilitiesTests.cs @@ -0,0 +1,251 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class ColumnSplitUtilitiesTests +{ + [Fact] + public void SplitCell_Delimiter_SplitsOnLiteralString() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Delimiter, DelimiterText = " " }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("John Smith", options); + + // Then + Assert.Equal(["John", "Smith"], parts); + } + + [Fact] + public void SplitCell_Delimiter_MultiCharacterDelimiter() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Delimiter, DelimiterText = ", " }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("a, b, c", options); + + // Then + Assert.Equal(["a", "b", "c"], parts); + } + + [Fact] + public void SplitCell_Delimiter_EmptyDelimiterReturnsWholeValue() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Delimiter, DelimiterText = "" }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("John Smith", options); + + // Then + Assert.Equal(["John Smith"], parts); + } + + [Fact] + public void SplitCell_Regex_SplitsOnPattern() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Regex, Pattern = @"\s*-\s*" }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("ABC - 123 - XY", options); + + // Then + Assert.Equal(["ABC", "123", "XY"], parts); + } + + [Fact] + public void SplitCell_Regex_InvalidPatternReturnsWholeValue() + { + // Given - an unbalanced group is an invalid regex + SplitColumnOptions options = new() { Mode = SplitMode.Regex, Pattern = "(" }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("anything", options); + + // Then + Assert.Equal(["anything"], parts); + } + + [Fact] + public void SplitCell_Regex_IgnoreCaseSplitsOnLetterRegardlessOfCase() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Regex, Pattern = "x", IgnoreCase = true }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("aXbxc", options); + + // Then + Assert.Equal(["a", "b", "c"], parts); + } + + [Fact] + public void SplitCell_FixedLength_SplitsFromStart() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.FixedLength, Length = 3 }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("ABC12345", options); + + // Then + Assert.Equal(["ABC", "12345"], parts); + } + + [Fact] + public void SplitCell_FixedLength_SplitsFromEnd() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.FixedLength, Length = 3, SplitFromEnd = true }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("ABC12345", options); + + // Then + Assert.Equal(["ABC12", "345"], parts); + } + + [Fact] + public void SplitCell_FixedLength_LengthBeyondValueClampsToWhole() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.FixedLength, Length = 100 }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("short", options); + + // Then + Assert.Equal(["short", ""], parts); + } + + [Fact] + public void SplitCell_Delimiter_KeepLeft_AttachesSplitterToLeftPart() + { + // Given + SplitColumnOptions options = new() + { + Mode = SplitMode.Delimiter, + DelimiterText = ".", + SplitterHandling = SplitterHandling.KeepLeft, + }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("20.30", options); + + // Then + Assert.Equal(["20.", "30"], parts); + } + + [Fact] + public void SplitCell_Delimiter_KeepRight_AttachesSplitterToRightPart() + { + // Given + SplitColumnOptions options = new() + { + Mode = SplitMode.Delimiter, + DelimiterText = ".", + SplitterHandling = SplitterHandling.KeepRight, + }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("20.30", options); + + // Then + Assert.Equal(["20", ".30"], parts); + } + + [Fact] + public void SplitCell_Delimiter_KeepLeft_MultipleSplitters() + { + // Given + SplitColumnOptions options = new() + { + Mode = SplitMode.Delimiter, + DelimiterText = ".", + SplitterHandling = SplitterHandling.KeepLeft, + }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("a.b.c", options); + + // Then + Assert.Equal(["a.", "b.", "c"], parts); + } + + [Fact] + public void SplitCell_Regex_KeepRight_AttachesMatchToRightPart() + { + // Given + SplitColumnOptions options = new() + { + Mode = SplitMode.Regex, + Pattern = "-", + SplitterHandling = SplitterHandling.KeepRight, + }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("a-b-c", options); + + // Then + Assert.Equal(["a", "-b", "-c"], parts); + } + + [Fact] + public void SplitCell_PatternItem_SavedRegex_SplitsOnMatchedSpans() + { + // Given a saved regex used as the delimiter + PatternItem hexPattern = new(new StoredRegex("Hex", @"#[0-9a-fA-F]{6}")); + SplitColumnOptions options = new() { Mode = SplitMode.Regex, PatternItem = hexPattern }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("a #FFFFFF b #000000 c", options); + + // Then - matched spans are removed, leaving the gaps between them + Assert.Equal(["a ", " b ", " c"], parts); + } + + [Fact] + public void SplitCell_PatternItem_NoMatchReturnsWholeValue() + { + // Given + PatternItem hexPattern = new(new StoredRegex("Hex", @"#[0-9a-fA-F]{6}")); + SplitColumnOptions options = new() { Mode = SplitMode.Regex, PatternItem = hexPattern }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("no colors here", options); + + // Then + Assert.Equal(["no colors here"], parts); + } + + [Fact] + public void SplitCell_PatternItem_TakesPrecedenceOverRawPattern() + { + // Given a PatternItem plus a conflicting raw Pattern - the PatternItem should win + PatternItem hexPattern = new(new StoredRegex("Hex", @"#[0-9a-fA-F]{6}")); + SplitColumnOptions options = new() { Mode = SplitMode.Regex, PatternItem = hexPattern, Pattern = " " }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell("a #FFFFFF b", options); + + // Then - split on the hex color, not on spaces + Assert.Equal(["a ", " b"], parts); + } + + [Fact] + public void SplitCell_NullValueTreatedAsEmpty() + { + // Given + SplitColumnOptions options = new() { Mode = SplitMode.Delimiter, DelimiterText = "," }; + + // When + IReadOnlyList parts = ColumnSplitUtilities.SplitCell(null!, options); + + // Then + Assert.Equal([""], parts); + } +} diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index 3424c339..4529e926 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -118,6 +118,76 @@ public void BuildSpreadsheetSelectionText_IncludesOnlySelectedCells() Assert.Equal("b1" + Environment.NewLine + "a2\tc2", selectionText); } + [Fact] + public void BuildSpreadsheetSelectionMarkdown_BuildsTableFromSelectedCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", "b1", "c1"); + dataTable.Rows.Add("a2", "b2", "c2"); + + string markdown = EditTextWindow.BuildSpreadsheetSelectionMarkdown( + dataTable, + [ + (0, 0), + (0, 2), + (1, 0), + (1, 2), + (-1, 0), + (5, 5) + ]); + + string expected = string.Join( + Environment.NewLine, + "| a1 | c1 |", + "| --- | --- |", + "| a2 | c2 |"); + + Assert.Equal(expected, markdown); + } + + [Fact] + public void BuildSpreadsheetSelectionMarkdown_EscapesPipesAndNewlines() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Rows.Add("has | pipe", "line1\r\nline2"); + + string markdown = EditTextWindow.BuildSpreadsheetSelectionMarkdown( + dataTable, + [ + (0, 0), + (0, 1) + ]); + + string expected = string.Join( + Environment.NewLine, + "| has \\| pipe | line1
line2 |", + "| --- | --- |"); + + Assert.Equal(expected, markdown); + } + + [Fact] + public void BuildSpreadsheetSelectionMarkdown_ReturnsEmptyWhenNoValidCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Rows.Add("a1"); + + string markdown = EditTextWindow.BuildSpreadsheetSelectionMarkdown( + dataTable, + [ + (-1, 0), + (5, 5) + ]); + + Assert.Equal(string.Empty, markdown); + } + [Fact] public void ExtractSpreadsheetSelectionNumbers_PullsNumericValuesFromSelectedCells() { @@ -161,6 +231,42 @@ public void ExtractSpreadsheetSelectionNumbers_IgnoresNonNumericSelectedCells() Assert.Empty(numbers); } + [Fact] + public void SearchSpreadsheetDocumentCells_SmartPatternFindsAndNarrowsCellMatches() + { + PatternItem emailPattern = new( + BuiltInRecognizer.GetById("email") ?? throw new InvalidOperationException("missing email recognizer")); + EditTextTableDocument document = EditTextTableDocument.CreateFromText( + "Name\tEmail\r\nAlice\ta@b.com\r\nBob\tc@d.org", + minimumRowCount: 3, + minimumColumnCount: 2); + + List allMatches = EditTextWindow.SearchSpreadsheetDocumentCells(document, emailPattern); + List narrowedMatches = EditTextWindow.SearchSpreadsheetDocumentCells(document, emailPattern, "C@D"); + + Assert.Collection( + allMatches, + first => + { + Assert.Equal(1, first.RowIndex); + Assert.Equal(1, first.ColumnIndex); + Assert.Equal("a@b.com", first.RawText); + Assert.Equal(1, first.Count); + }, + second => + { + Assert.Equal(2, second.RowIndex); + Assert.Equal(1, second.ColumnIndex); + Assert.Equal("c@d.org", second.RawText); + Assert.Equal(2, second.Count); + }); + + FindResult narrowedMatch = Assert.Single(narrowedMatches); + Assert.Equal(2, narrowedMatch.RowIndex); + Assert.Equal(1, narrowedMatch.ColumnIndex); + Assert.Equal("c@d.org", narrowedMatch.RawText); + } + [Fact] public void BuildSpreadsheetSelectionNumbersPreviewText_FormatsExtractedNumbersForCalcPane() { diff --git a/Tests/FindAndReplaceWindowTests.cs b/Tests/FindAndReplaceWindowTests.cs new file mode 100644 index 00000000..1d5344d3 --- /dev/null +++ b/Tests/FindAndReplaceWindowTests.cs @@ -0,0 +1,39 @@ +using Text_Grab.Controls; +using Text_Grab.Models; + +namespace Tests; + +public class FindAndReplaceWindowTests +{ + [Fact] + public void GetMatchTextForEditing_PreservesRawWhitespace() + { + List results = + [ + new() { Text = "word·word", RawText = "word word" }, + new() { Text = "line⏎break", RawText = $"line{Environment.NewLine}break" }, + new() { Text = "tab⇥value", RawText = "tab\tvalue" }, + ]; + + string editText = FindAndReplaceWindow.GetMatchTextForEditing(results); + + Assert.Equal( + $"word word{Environment.NewLine}line{Environment.NewLine}break{Environment.NewLine}tab\tvalue", + editText); + } + + [Theory] + [InlineData("old value", "new value", false, "new value")] + [InlineData("old value", "new value", true, "old value")] + [InlineData("cached", null, false, "cached")] + public void ResolveSearchSourceText_UsesCurrentEditorTextOutsideSpreadsheetMode( + string cachedText, + string? editorText, + bool isSpreadsheetSearch, + string expected) + { + Assert.Equal( + expected, + FindAndReplaceWindow.ResolveSearchSourceText(cachedText, editorText, isSpreadsheetSearch)); + } +} diff --git a/Tests/FullscreenGrabWindowLayoutTests.cs b/Tests/FullscreenGrabWindowLayoutTests.cs new file mode 100644 index 00000000..ebde44fc --- /dev/null +++ b/Tests/FullscreenGrabWindowLayoutTests.cs @@ -0,0 +1,19 @@ +using System.Windows; +using Text_Grab.Views; + +namespace Tests; + +public class FullscreenGrabWindowLayoutTests +{ + [Theory] + [InlineData(40, 40)] + [InlineData(1920, 1080)] + public void GetFullscreenClipBounds_UsesRenderedWindowSize(double width, double height) + { + Rect expected = new(0, 0, width, height); + + Rect actual = FullscreenGrab.GetFullscreenClipBounds(new Size(width, height)); + + Assert.Equal(expected, actual); + } +} diff --git a/Tests/GrabFrameFileTests.cs b/Tests/GrabFrameFileTests.cs new file mode 100644 index 00000000..0ec2647b --- /dev/null +++ b/Tests/GrabFrameFileTests.cs @@ -0,0 +1,240 @@ +using System.Drawing; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Windows; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabFrameFileTests +{ + [Fact] + public async Task SaveAndLoad_RoundTripsMetadataWordBordersAndImage() + { + string tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + + List wordBorders = + [ + new() + { + Word = "Hello", + BorderRect = new Rect(1, 2, 30, 12), + LineNumber = 0, + }, + new() + { + Word = "World", + BorderRect = new Rect(35, 2, 32, 12), + LineNumber = 0, + }, + ]; + + HistoryInfo info = new() + { + ID = "round-trip-id", + TextContent = "Hello World", + SourceMode = TextGrabMode.GrabFrame, + IsTable = true, + LanguageTag = "en-US", + LanguageKind = LanguageKind.Global, + PositionRect = new Rect(100, 120, 400, 300), + WordBorderInfoJson = JsonSerializer.Serialize(wordBorders), + ImageContent = new Bitmap(64, 48), + }; + + try + { + bool saved = await GrabFrameFileUtilities.SaveGrabFrameFileAsync(info, tempPath); + Assert.True(saved); + Assert.True(File.Exists(tempPath)); + + HistoryInfo? loaded = await GrabFrameFileUtilities.LoadGrabFrameFileAsync(tempPath); + + Assert.NotNull(loaded); + Assert.Equal("round-trip-id", loaded!.ID); + Assert.Equal("Hello World", loaded.TextContent); + Assert.Equal(TextGrabMode.GrabFrame, loaded.SourceMode); + Assert.True(loaded.IsTable); + Assert.Equal("en-US", loaded.LanguageTag); + Assert.Equal(LanguageKind.Global, loaded.LanguageKind); + Assert.Equal(new Rect(100, 120, 400, 300), loaded.PositionRect); + + Assert.NotNull(loaded.ImageContent); + Assert.Equal(64, loaded.ImageContent!.Width); + Assert.Equal(48, loaded.ImageContent.Height); + + Assert.False(string.IsNullOrWhiteSpace(loaded.WordBorderInfoJson)); + List? loadedBorders = + JsonSerializer.Deserialize>(loaded.WordBorderInfoJson!); + Assert.NotNull(loadedBorders); + Assert.Equal(2, loadedBorders!.Count); + Assert.Equal("Hello", loadedBorders[0].Word); + Assert.Equal("World", loadedBorders[1].Word); + + loaded.ImageContent?.Dispose(); + } + finally + { + info.ImageContent?.Dispose(); + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Fact] + public async Task SaveGrabFrameFileAsync_DoesNotMutateSuppliedInfo() + { + string tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + + string originalWordBordersJson = JsonSerializer.Serialize(new List + { + new() { Word = "Hello", BorderRect = new Rect(1, 2, 30, 12), LineNumber = 0 }, + }); + Bitmap originalImage = new(64, 48); + + HistoryInfo info = new() + { + ID = "no-mutate-id", + TextContent = "Hello World", + SourceMode = TextGrabMode.GrabFrame, + ImagePath = "original-image-path.png", + WordBorderInfoJson = originalWordBordersJson, + WordBorderInfoFileName = "original-borders.json", + ImageContent = originalImage, + }; + + try + { + bool saved = await GrabFrameFileUtilities.SaveGrabFrameFileAsync(info, tempPath); + Assert.True(saved); + + // The save packs these fields into the archive from a copy; the caller's instance + // must be left exactly as it was passed in. + Assert.Equal(originalWordBordersJson, info.WordBorderInfoJson); + Assert.Equal("original-borders.json", info.WordBorderInfoFileName); + Assert.Equal("original-image-path.png", info.ImagePath); + Assert.Same(originalImage, info.ImageContent); + } + finally + { + info.ImageContent?.Dispose(); + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Fact] + public async Task SaveGrabFrameFileAsync_PreservesExistingFile_WhenAtomicReplaceFails() + { + string tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + byte[] originalContent = "existing grab frame content"u8.ToArray(); + await File.WriteAllBytesAsync(tempPath, originalContent, TestContext.Current.CancellationToken); + + HistoryInfo info = new() + { + ID = "replacement", + TextContent = "replacement content", + SourceMode = TextGrabMode.GrabFrame, + }; + + bool saved; + try + { + using (FileStream lockedFile = new(tempPath, FileMode.Open, FileAccess.Read, FileShare.None)) + saved = await GrabFrameFileUtilities.SaveGrabFrameFileAsync(info, tempPath); + + Assert.False(saved); + Assert.Equal( + originalContent, + await File.ReadAllBytesAsync(tempPath, TestContext.Current.CancellationToken)); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Fact] + public async Task SaveGrabFrameFileAsync_AtomicallyReplacesExistingFile() + { + string tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + await File.WriteAllTextAsync(tempPath, "old content", TestContext.Current.CancellationToken); + + HistoryInfo info = new() + { + ID = "replacement", + TextContent = "new content", + SourceMode = TextGrabMode.GrabFrame, + }; + + try + { + Assert.True(await GrabFrameFileUtilities.SaveGrabFrameFileAsync(info, tempPath)); + + HistoryInfo? loaded = await GrabFrameFileUtilities.LoadGrabFrameFileAsync(tempPath); + Assert.NotNull(loaded); + Assert.Equal("new content", loaded!.TextContent); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Fact] + public async Task LoadGrabFrameFileAsync_ReturnsNull_ForMissingFile() + { + string missingPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + + HistoryInfo? loaded = await GrabFrameFileUtilities.LoadGrabFrameFileAsync(missingPath); + + Assert.Null(loaded); + } + + [Fact] + public async Task LoadGrabFrameFileAsync_ReturnsNull_ForOversizedMetadata() + { + string tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tggf"); + + try + { + using (FileStream zipStream = new(tempPath, FileMode.Create, FileAccess.Write)) + using (ZipArchive archive = new(zipStream, ZipArchiveMode.Create)) + using (StreamWriter writer = new(archive.CreateEntry("metadata.json").Open())) + writer.Write(new string('a', checked((int)GrabFrameFileUtilities.MaxMetadataBytes + 1))); + + HistoryInfo? loaded = await GrabFrameFileUtilities.LoadGrabFrameFileAsync(tempPath); + + Assert.Null(loaded); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Theory] + [InlineData(8_000, 5_000, true)] + [InlineData(8_001, 5_000, false)] + [InlineData(16_385, 1, false)] + public void AreImageDimensionsAllowed_EnforcesDimensionAndPixelLimits(int width, int height, bool expected) + { + Assert.Equal(expected, GrabFrameFileUtilities.AreImageDimensionsAllowed(width, height)); + } + + [Theory] + [InlineData("frame.tggf", true)] + [InlineData("frame.TGGF", true)] + [InlineData("image.png", false)] + [InlineData("", false)] + public void IsGrabFrameFile_MatchesExtension(string path, bool expected) + { + Assert.Equal(expected, GrabFrameFileUtilities.IsGrabFrameFile(path)); + } +} diff --git a/Tests/GrabFrameSearchTests.cs b/Tests/GrabFrameSearchTests.cs new file mode 100644 index 00000000..9623634c --- /dev/null +++ b/Tests/GrabFrameSearchTests.cs @@ -0,0 +1,81 @@ +using Text_Grab.Views; + +namespace Tests; + +public class GrabFrameSearchTests +{ + [Fact] + public void BuildSearchText_MapsMultiWordTextBackToSourceItems() + { + (string text, IReadOnlyList<(int SourceIndex, int Start, int Length)> segments) = + GrabFrame.BuildSearchText( + [("555", 20), ("Call", 0), ("1234", 40)], + isSpaceJoining: true, + isRightToLeft: false); + + Assert.Equal("Call 555 1234", text); + Assert.Collection( + segments, + segment => Assert.Equal((1, 0, 4), segment), + segment => Assert.Equal((0, 5, 3), segment), + segment => Assert.Equal((2, 9, 4), segment)); + + List matchedSourceIndexes = + [ + .. segments + .Where(segment => GrabFrame.SpansOverlap(segment.Start, segment.Length, 5, 8)) + .Select(segment => segment.SourceIndex) + ]; + Assert.Equal([0, 2], matchedSourceIndexes); + } + + [Fact] + public void BuildSearchText_UsesRightToLeftVisualOrder() + { + (string text, _) = GrabFrame.BuildSearchText( + [("right", 100), ("left", 0)], + isSpaceJoining: true, + isRightToLeft: true); + + Assert.Equal("right left", text); + } + + [Theory] + [InlineData(0, 10, 10, 0, 12, 10, true)] + [InlineData(0, 10, 10, 0, 30, 10, false)] + [InlineData(0, 10, 10, 1, 10, 10, false)] + public void AreOnSameSearchLine_RequiresMatchingLineAndVerticalAlignment( + int firstLineNumber, + double firstTop, + double firstHeight, + int secondLineNumber, + double secondTop, + double secondHeight, + bool expected) + { + Assert.Equal( + expected, + GrabFrame.AreOnSameSearchLine( + firstLineNumber, + firstTop, + firstHeight, + secondLineNumber, + secondTop, + secondHeight)); + } + + [Theory] + [InlineData(0, 4, 2, 4, true)] + [InlineData(0, 4, 4, 2, false)] + [InlineData(5, 3, 0, 8, true)] + [InlineData(5, 0, 5, 1, false)] + public void SpansOverlap_DetectsOnlyNonEmptyIntersectingRanges( + int firstStart, + int firstLength, + int secondStart, + int secondLength, + bool expected) + { + Assert.Equal(expected, GrabFrame.SpansOverlap(firstStart, firstLength, secondStart, secondLength)); + } +} diff --git a/Tests/GrabFrameTtsTests.cs b/Tests/GrabFrameTtsTests.cs new file mode 100644 index 00000000..51f4a1f6 --- /dev/null +++ b/Tests/GrabFrameTtsTests.cs @@ -0,0 +1,26 @@ +using Text_Grab.Views; + +namespace Tests; + +public class GrabFrameTtsTests +{ + [Theory] + [InlineData(false, true, "Current frame text", true)] + [InlineData(true, true, "Current frame text", false)] + [InlineData(true, false, "Current frame text", false)] + [InlineData(false, true, "", false)] + [InlineData(false, true, " ", false)] + public void ShouldSpeakCurrentFrameWhenEnabled_RequiresUncheckedToCheckedTransition( + bool wasSpeakEnabled, + bool isSpeakEnabled, + string frameText, + bool expected) + { + Assert.Equal( + expected, + GrabFrame.ShouldSpeakCurrentFrameWhenEnabled( + wasSpeakEnabled, + isSpeakEnabled, + frameText)); + } +} diff --git a/Tests/GrabFrameUnfreezeTests.cs b/Tests/GrabFrameUnfreezeTests.cs new file mode 100644 index 00000000..f63496ab --- /dev/null +++ b/Tests/GrabFrameUnfreezeTests.cs @@ -0,0 +1,27 @@ +using Text_Grab.Views; + +namespace Tests; + +public class GrabFrameUnfreezeTests +{ + [Theory] + [InlineData(3, 3, false, false, true)] + [InlineData(3, 4, false, false, false)] + [InlineData(3, 3, true, false, false)] + [InlineData(3, 3, false, true, false)] + public void ShouldApplyUnfreezeResult_RequiresCurrentLiveTransition( + int transitionVersion, + int currentTransitionVersion, + bool isFreezeMode, + bool isCleanedUp, + bool expected) + { + Assert.Equal( + expected, + GrabFrame.ShouldApplyUnfreezeResult( + transitionVersion, + currentTransitionVersion, + isFreezeMode, + isCleanedUp)); + } +} diff --git a/Tests/HdrScreenCaptureTests.cs b/Tests/HdrScreenCaptureTests.cs new file mode 100644 index 00000000..6b8e2a7e --- /dev/null +++ b/Tests/HdrScreenCaptureTests.cs @@ -0,0 +1,47 @@ +using System.Drawing; +using Text_Grab.Utilities.Hdr; + +namespace Tests; + +public class HdrScreenCaptureTests +{ + [Fact] + public void BuildCaptureSegments_MapsCrossMonitorRegionsToCompositeCoordinates() + { + Rectangle region = new(-200, 100, 500, 300); + MonitorHdrInfo[] monitors = + [ + new((IntPtr)1, new Rectangle(-1920, 0, 1920, 1080), true, 200), + new((IntPtr)2, new Rectangle(0, 0, 2560, 1440), true, 160), + ]; + + HdrScreenCapture.HdrCaptureSegment[] segments = + HdrScreenCapture.BuildCaptureSegments(region, monitors); + + Assert.Collection( + segments, + left => + { + Assert.Equal(new Rectangle(-200, 100, 200, 300), left.CaptureRegion); + Assert.Equal(new Point(0, 0), left.Destination); + }, + right => + { + Assert.Equal(new Rectangle(0, 100, 300, 300), right.CaptureRegion); + Assert.Equal(new Point(200, 0), right.Destination); + }); + } + + [Fact] + public void BuildCaptureSegments_ExcludesSdrAndNonIntersectingMonitors() + { + Rectangle region = new(100, 100, 200, 200); + MonitorHdrInfo[] monitors = + [ + new((IntPtr)1, new Rectangle(0, 0, 500, 500), false, 0), + new((IntPtr)2, new Rectangle(500, 0, 500, 500), true, 200), + ]; + + Assert.Empty(HdrScreenCapture.BuildCaptureSegments(region, monitors)); + } +} diff --git a/Tests/HdrToneMapperTests.cs b/Tests/HdrToneMapperTests.cs new file mode 100644 index 00000000..dacacc3e --- /dev/null +++ b/Tests/HdrToneMapperTests.cs @@ -0,0 +1,91 @@ +using Text_Grab.Utilities.Hdr; + +namespace Tests; + +public class HdrToneMapperTests +{ + [Fact] + public void SdrWhiteScaleFromNits_ReferenceWhite_IsOne() + { + Assert.Equal(1.0, HdrToneMapper.SdrWhiteScaleFromNits(80.0), 5); + } + + [Theory] + [InlineData(200.0, 2.5)] + [InlineData(160.0, 2.0)] + [InlineData(480.0, 6.0)] + public void SdrWhiteScaleFromNits_ScalesRelativeTo80Nits(double nits, double expectedScale) + { + Assert.Equal(expectedScale, HdrToneMapper.SdrWhiteScaleFromNits(nits), 5); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-5.0)] + [InlineData(40.0)] + public void SdrWhiteScaleFromNits_NeverBrightens(double nits) + { + // Values at or below the 80-nit reference must not produce a scale below 1.0, + // which would brighten the image instead of correcting the HDR boost. + Assert.Equal(1.0, HdrToneMapper.SdrWhiteScaleFromNits(nits), 5); + } + + [Fact] + public void LinearToSrgb_Endpoints() + { + Assert.Equal(0.0, HdrToneMapper.LinearToSrgb(0.0), 5); + Assert.Equal(1.0, HdrToneMapper.LinearToSrgb(1.0), 5); + } + + [Fact] + public void ScRgbChannelToSrgbByte_SdrWhiteMapsToFullWhite() + { + // On a display with SDR white at 200 nits, SDR white sits at scRGB 2.5. + double scale = HdrToneMapper.SdrWhiteScaleFromNits(200.0); + + Assert.Equal(255, HdrToneMapper.ScRgbChannelToSrgbByte(2.5, scale)); + } + + [Fact] + public void ScRgbChannelToSrgbByte_UndoesHdrBrightnessBoost() + { + // The washout bug: SDR content lands above scRGB 1.0 on HDR displays. Normalizing by + // the SDR white level must pull mid-gray back to a mid sRGB value rather than near-white. + double scale = HdrToneMapper.SdrWhiteScaleFromNits(200.0); + + // Half of SDR white in linear light -> sRGB ~0.735 -> ~188. + byte midGray = HdrToneMapper.ScRgbChannelToSrgbByte(1.25, scale); + Assert.InRange(midGray, 186, 190); + } + + [Fact] + public void ScRgbChannelToSrgbByte_HighlightsAboveSdrWhiteClipToWhite() + { + double scale = HdrToneMapper.SdrWhiteScaleFromNits(200.0); + + // A specular highlight well above SDR white clamps to white rather than overflowing. + Assert.Equal(255, HdrToneMapper.ScRgbChannelToSrgbByte(10.0, scale)); + } + + [Fact] + public void ScRgbChannelToSrgbByte_NegativeWideGamutClampsToBlack() + { + double scale = HdrToneMapper.SdrWhiteScaleFromNits(200.0); + + Assert.Equal(0, HdrToneMapper.ScRgbChannelToSrgbByte(-0.5, scale)); + } + + [Fact] + public void ScRgbChannelToSrgbByte_IsMonotonic() + { + double scale = HdrToneMapper.SdrWhiteScaleFromNits(200.0); + int previous = -1; + + for (double channel = 0.0; channel <= 2.5; channel += 0.05) + { + int value = HdrToneMapper.ScRgbChannelToSrgbByte(channel, scale); + Assert.True(value >= previous, $"Value dropped at channel {channel}"); + previous = value; + } + } +} diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs index 505bad0e..a2153a53 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -94,6 +94,78 @@ await SaveHistoryFileAsync( Assert.Equal("image-2", historyService.GetLastFullScreenGrabInfo()?.ID); } + [Fact] + public void ImageHistory_SeparatesPdfDocumentsFromRecentGrabs() + { + HistoryInfo olderGrab = new() + { + ID = "grab", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), + ImagePath = "grab.bmp", + SourceContentKind = OpenContentKind.Image, + }; + HistoryInfo newerPdf = new() + { + ID = "pdf", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), + ImagePath = "pdf-page.bmp", + SourceContentKind = OpenContentKind.PdfDocument, + SourcePath = @"C:\documents\sample.pdf", + SourcePageIndex = 4, + }; + + HistoryService historyService = new(); + SetPrivateField(historyService, "HistoryWithImage", new List { olderGrab, newerPdf }); + SetPrivateField(historyService, "_imageHistoryLoaded", true); + + Assert.Same(olderGrab, Assert.Single(historyService.GetRecentGrabs())); + Assert.Same(newerPdf, Assert.Single(historyService.GetRecentPdfDocuments())); + Assert.Equal(4, newerPdf.SourcePageIndex); + Assert.True(historyService.HasAnyRecentGrabs()); + Assert.Same(olderGrab, HistoryService.GetMostRecentGrab([olderGrab, newerPdf])); + } + + [Fact] + public void GetMostRecentGrab_ReturnsNull_WhenHistoryOnlyContainsPdfs() + { + HistoryInfo pdf = new() + { + CaptureDateTime = DateTimeOffset.UtcNow, + SourceContentKind = OpenContentKind.PdfDocument, + }; + + Assert.Null(HistoryService.GetMostRecentGrab([pdf])); + } + + [Fact] + public void VisualHistoryRetention_LimitsGrabsAndPdfsIndependently() + { + List historyItems = []; + for (int index = 0; index < 12; index++) + { + historyItems.Add(new HistoryInfo + { + ID = $"grab-{index}", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 0, index, 0, TimeSpan.Zero), + SourceContentKind = OpenContentKind.Image, + }); + historyItems.Add(new HistoryInfo + { + ID = $"pdf-{index}", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 0, index, 0, TimeSpan.Zero), + SourceContentKind = OpenContentKind.PdfDocument, + }); + } + + List itemsToRemove = HistoryService.GetExcessVisualHistoryItems(historyItems); + + Assert.Equal(4, itemsToRemove.Count); + Assert.Contains(itemsToRemove, history => history.ID == "grab-0"); + Assert.Contains(itemsToRemove, history => history.ID == "grab-1"); + Assert.Contains(itemsToRemove, history => history.ID == "pdf-0"); + Assert.Contains(itemsToRemove, history => history.ID == "pdf-1"); + } + [WpfFact] public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarStorage() { diff --git a/Tests/ImageChangeDetectorTests.cs b/Tests/ImageChangeDetectorTests.cs index 2b61a58d..0750dac5 100644 --- a/Tests/ImageChangeDetectorTests.cs +++ b/Tests/ImageChangeDetectorTests.cs @@ -70,4 +70,22 @@ public void Reset_NextCaptureBecomesBaseline_ReportsNoChange() Assert.False(detector.CheckForChangeAndUpdate(image2)); } + + [Fact] + public void ImagesDifferBeyondThreshold_IdenticalImages_ReportsNoDifference() + { + using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + + Assert.False(ImageChangeDetector.ImagesDifferBeyondThreshold(image1, image2)); + } + + [Fact] + public void ImagesDifferBeyondThreshold_DifferentImages_ReportsDifference() + { + using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath)); + using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath)); + + Assert.True(ImageChangeDetector.ImagesDifferBeyondThreshold(image1, image2)); + } } diff --git a/Tests/Images/Ja-Lang-Image.png b/Tests/Images/Ja-Lang-Image.png new file mode 100644 index 00000000..3d1c6ae9 Binary files /dev/null and b/Tests/Images/Ja-Lang-Image.png differ diff --git a/Tests/LanguageServiceTests.cs b/Tests/LanguageServiceTests.cs index 17109145..cf8b015d 100644 --- a/Tests/LanguageServiceTests.cs +++ b/Tests/LanguageServiceTests.cs @@ -13,17 +13,20 @@ public class LanguageServiceTests : IDisposable { private readonly string _originalLastUsedLang; private readonly bool _originalUiAutomationEnabled; + private readonly bool _originalWindowsAiDescriptionEnabled; public LanguageServiceTests() { _originalLastUsedLang = Settings.Default.LastUsedLang; _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + _originalWindowsAiDescriptionEnabled = Settings.Default.WindowsAiDescriptionEnabled; } public void Dispose() { Settings.Default.LastUsedLang = _originalLastUsedLang; Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.WindowsAiDescriptionEnabled = _originalWindowsAiDescriptionEnabled; Settings.Default.Save(); LanguageUtilities.InvalidateAllCaches(); } @@ -48,6 +51,16 @@ public void GetLanguageTag_WithWindowsAiLang_ReturnsWinAI() Assert.Equal("WinAI", tag); } + [Fact] + public void GetLanguageTag_WithWindowsAiDescriptionLang_ReturnsDescriptionTag() + { + WindowsAiDescriptionLang windowsAiDescriptionLang = new(); + + string tag = LanguageService.GetLanguageTag(windowsAiDescriptionLang); + + Assert.Equal(WindowsAiDescriptionLang.Tag, tag); + } + [Fact] public void GetLanguageTag_WithUiAutomationLang_ReturnsUiAutomationTag() { @@ -98,6 +111,16 @@ public void GetLanguageKind_WithWindowsAiLang_ReturnsWindowsAi() Assert.Equal(LanguageKind.WindowsAi, kind); } + [Fact] + public void GetLanguageKind_WithWindowsAiDescriptionLang_ReturnsWindowsAiDescription() + { + WindowsAiDescriptionLang windowsAiDescriptionLang = new(); + + LanguageKind kind = LanguageService.GetLanguageKind(windowsAiDescriptionLang); + + Assert.Equal(LanguageKind.WindowsAiDescription, kind); + } + [Fact] public void GetLanguageKind_WithUiAutomationLang_ReturnsUiAutomation() { @@ -162,6 +185,19 @@ public void GetOCRLanguage_WhenUiAutomationWasLastUsedButFeatureIsDisabled_Falls Assert.IsNotType(language); } + [Fact] + public void GetOCRLanguage_WhenWindowsAiDescriptionWasLastUsedButFeatureIsDisabled_FallsBack() + { + Settings.Default.WindowsAiDescriptionEnabled = false; + Settings.Default.LastUsedLang = WindowsAiDescriptionLang.Tag; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + ILanguage language = Singleton.Instance.GetOCRLanguage(); + + Assert.IsNotType(language); + } + [Fact] public void LanguageService_IsSingleton() { @@ -193,4 +229,16 @@ public void HistoryInfo_OcrLanguage_FallsBackForUiAutomationPersistence() Assert.IsNotType(historyInfo.OcrLanguage); } + + [Fact] + public void HistoryInfo_OcrLanguage_ReturnsWindowsAiDescriptionLanguage() + { + HistoryInfo historyInfo = new() + { + LanguageTag = WindowsAiDescriptionLang.Tag, + LanguageKind = LanguageKind.WindowsAiDescription, + }; + + Assert.IsType(historyInfo.OcrLanguage); + } } diff --git a/Tests/MarkdownDocumentUtilitiesTests.cs b/Tests/MarkdownDocumentUtilitiesTests.cs index d403729b..e75717e9 100644 --- a/Tests/MarkdownDocumentUtilitiesTests.cs +++ b/Tests/MarkdownDocumentUtilitiesTests.cs @@ -72,6 +72,27 @@ public void Markdown_TaskLists_RoundTrip_ToCheckboxMarkers() Assert.Contains("- [x] done item", serialized); } + [WpfFact] + public void Markdown_OrderedList_RoundTripsStartNumber() + { + const string markdown = """ + 5. fifth + 6. sixth + """; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument( + markdown, + new FontFamily("Segoe UI"), + 16); + + System.Windows.Documents.List list = + Assert.IsType(Assert.Single(document.Blocks)); + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Equal(5, list.StartIndex); + Assert.Equal($"5. fifth{Environment.NewLine}6. sixth", serialized); + } + [WpfFact] public void PlainText_WithMarkdownCharacters_IsEscapedDuringSerialization() { diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs index 996488be..9b8386f4 100644 --- a/Tests/OcrTests.cs +++ b/Tests/OcrTests.cs @@ -7,6 +7,7 @@ using Text_Grab; using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Properties; using Text_Grab.Utilities; using Windows.Globalization; @@ -90,6 +91,18 @@ BANK CHARGES 344 670 326 49% TOTAL EXPENDITURES 1,016,684 2,065,620 1,048,936 51% REVENUES OVERY(UNDER) EXPENDITURES $9,749 $0 $9,749 N/A"; + private const string JaTestExpectedResult = @"""くろ からだ しつ +黒ごまは体にいいです。タンバク質やカルシウムが +かみ くろ こうか +たくさんあります。髪を黒くする効果もあります。 +くろ あぶら はだ かみ りようり +黒ごま油は肌や髪に使います。料理にも使います。 +かゆ た からだ +お粥やデサートに入れます。でも、食べすきると体 +た +によくないです。少しすつ食べましよう。 +"""; + [WpfFact] public async Task OcrFontSampleImage() { @@ -182,7 +195,9 @@ public async Task ParagraphWrapDetection() [InlineData(10, 10, 25, 10, true)] // bounding-box gap = 5 [InlineData(10, 10, 26, 10, false)] // threshold boundary: gap = 6 [InlineData(10, 10, 27, 10, false)] // bounding-box gap = 7 - [InlineData(10, 10, 10, 10, true)] // overlapping bounding boxes + [InlineData(10, 10, 10, 10, false)] // same visual row + [InlineData(10, 10, 14, 10, false)] // insufficient vertical advance + [InlineData(10, 10, 18, 10, true)] // distinct rows with slight overlap [InlineData(10, 10, 16, 30, false)] // height ratio = 3 [InlineData(10, 0, 13, 10, false)] // zero height public void IsWrappedParagraph_ReturnsExpected( @@ -268,6 +283,165 @@ public void GroupWrappedParagraphLines_CombinesWrappedLinesIntoParagraphBlocks() Assert.Equal("New paragraph.", groups[1].SingleLineText); } + [Fact] + public void GroupWrappedParagraphLines_DoesNotMergeEntriesOnTheSameVisualRow() + { + List lines = + [ + new(0, "Left entry", new Windows.Foundation.Rect(0, 10, 50, 10)), + new(1, "Right entry", new Windows.Foundation.Rect(60, 10, 50, 10)), + ]; + + List groups = OcrUtilities.GroupWrappedParagraphLines(lines); + + Assert.Equal(2, groups.Count); + Assert.All(groups, group => Assert.DoesNotContain(Environment.NewLine, group.DisplayText)); + Assert.All(groups, group => Assert.Equal(10, group.BoundingBox.Height)); + } + + [Fact] + public void GroupWrappedParagraphLines_RemovesEmbeddedLineBreaksFromIndividualOcrLines() + { + List lines = + [ + new(0, $"First{Environment.NewLine}line", new Windows.Foundation.Rect(0, 0, 100, 10)), + ]; + + OcrUtilities.GroupedOcrLines group = Assert.Single(OcrUtilities.GroupWrappedParagraphLines(lines)); + + Assert.Equal("First line", group.DisplayText); + Assert.Equal("First line", group.SingleLineText); + } + + private const string jaTestPath = @".\Images\Ja-Lang-Image.png"; + + // The reading-order-corrected OCR output for Ja-Lang-Image.png. Furigana ruby + // lines are still present inline (they are kept per the current line-ordering + // fix), but every line now appears in top-to-bottom / left-to-right reading + // order instead of the scrambled order the Windows OCR engine returns. + // + // JaTestExpectedResult (above) is the aspirational, fully-corrected target: + // furigana grouped per row with full-width spaces AND engine misreads fixed + // (からだ vs からた, こうか vs カ, ...). Reaching it needs more than ordering: + // furigana row grouping plus OCR error correction that recovers dakuten and + // small-kana the engine drops. This constant captures what is achievable today. + private const string JaReadingOrderResult = + "くろからたしつ黒ごまは体にいいです。タンバク質やカルシウムがかみ彡ろカたくさんあります。髪を黒くする効果もあります。くろあぶらはだかみりようり黒ごま油は肌や髪に使います。料理にも使います。かゆたからだお粥やデサ ー トに入れます。でも、食べすき、ると体たによくないです。少しすっ食べましよう。"; + + // With furigana removal enabled, the ruby-reading lines are dropped and only + // the main body text remains (still subject to the engine's own misreads and + // one stray mis-detected fragment "み彡" the geometry heuristic cannot catch). + private const string JaFuriganaRemovedResult = + "黒ごまは体にいいです。タンバク質やカルシウムがみ彡たくさんあります。髪を黒くする効果もあります。黒ごま油は肌や髪に使います。料理にも使います。お粥やデサ ー トに入れます。でも、食べすき、ると体によくないです。少しすっ食べましよう。"; + + [WpfFact] + public async Task OcrJapaneseImage_ReadingOrder_KeepsFuriganaWhenDisabled() + { + // Given + GlobalLang japanese = new("ja"); + + // Skip if the Japanese OCR language pack is not installed on this machine. + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Settings settings = AppUtilities.TextGrabSettings; + bool originalRemoveFurigana = settings.RemoveFurigana; + settings.RemoveFurigana = false; + + try + { + // When + string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync( + FileUtilities.GetPathToLocalFile(jaTestPath), japanese); + + // Then furigana are kept, but every line is in natural reading order + // (top-to-bottom, left-to-right). + Assert.Equal(JaReadingOrderResult, ocrTextResult); + } + finally + { + settings.RemoveFurigana = originalRemoveFurigana; + } + } + + [WpfFact] + public async Task OcrJapaneseImage_RemovesFuriganaWhenEnabled() + { + // Given + GlobalLang japanese = new("ja"); + + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Settings settings = AppUtilities.TextGrabSettings; + bool originalRemoveFurigana = settings.RemoveFurigana; + settings.RemoveFurigana = true; + + try + { + // When + string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync( + FileUtilities.GetPathToLocalFile(jaTestPath), japanese); + + // Then the furigana ruby lines are dropped, leaving the main text. + Assert.Equal(JaFuriganaRemovedResult, ocrTextResult); + } + finally + { + settings.RemoveFurigana = originalRemoveFurigana; + } + } + + [WpfFact] + public async Task InspectJapaneseOcrOutput() + { + // Exploration harness: dumps the raw OCR lines/words with their bounding + // boxes so we can see exactly what the Windows OCR engine returns for a + // furigana-heavy Japanese image, and how the current pipeline processes it. + GlobalLang japanese = new("ja"); + + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(jaTestPath)); + double scale = await OcrUtilities.GetIdealScaleFactorForOcrAsync(testBitmap, japanese); + Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(testBitmap, scale); + IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(scaledBitmap, japanese); + + StringBuilder report = new(); + report.AppendLine($"scale factor: {scale:0.###}"); + report.AppendLine($"line count: {ocrResult.Lines.Length}"); + report.AppendLine(); + + for (int i = 0; i < ocrResult.Lines.Length; i++) + { + IOcrLine line = ocrResult.Lines[i]; + Windows.Foundation.Rect lb = line.BoundingBox; + report.AppendLine( + $"LINE {i,2} Y={lb.Y,7:0.0} H={lb.Height,6:0.0} X={lb.X,7:0.0} W={lb.Width,7:0.0} \"{line.Text}\""); + foreach (IOcrWord w in line.Words) + { + Windows.Foundation.Rect wb = w.BoundingBox; + report.AppendLine( + $" word Y={wb.Y,7:0.0} H={wb.Height,6:0.0} X={wb.X,7:0.0} W={wb.Width,7:0.0} \"{w.Text}\""); + } + } + + report.AppendLine(); + report.AppendLine("=== reading-flow ordered lines ==="); + foreach (IOcrLine line in OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines)) + report.AppendLine($" Y={line.BoundingBox.Y,7:0.0} X={line.BoundingBox.X,7:0.0} \"{line.Text}\""); + + report.AppendLine(); + report.AppendLine("=== BuildTextFromOcrLines (current pipeline output) ==="); + report.AppendLine(OcrUtilities.BuildTextFromOcrLines(japanese, ocrResult)); + + string outPath = Path.Combine(Path.GetTempPath(), "ja-ocr-report.txt"); + await File.WriteAllTextAsync(outPath, report.ToString(), new UTF8Encoding(true)); + System.Diagnostics.Debug.WriteLine(report.ToString()); + System.Diagnostics.Debug.WriteLine($"Report written to {outPath}"); + } + [WpfFact] public async Task ReadQrCode() { @@ -476,6 +650,363 @@ public async Task GetTesseractGitHubLanguage() File.Delete(tempFilePath); } + [Fact] + public void BuildTextFromOcrLines_FiltersFuriganaForJapanese() + { + // Given a Japanese line where the kanji 黒 is annotated with the small + // furigana くろ rendered directly above it. + FakeOcrLine line = new("くろ黒ごま", new Windows.Foundation.Rect(0, 0, 60, 30)) + { + Words = + [ + // Furigana: short and sitting above the kanji it annotates. + new FakeOcrWord("くろ", new Windows.Foundation.Rect(0, 0, 16, 8)), + // Main text: full-height single characters. + new FakeOcrWord("黒", new Windows.Foundation.Rect(0, 10, 20, 20)), + new FakeOcrWord("ご", new Windows.Foundation.Rect(20, 10, 20, 20)), + new FakeOcrWord("ま", new Windows.Foundation.Rect(40, 10, 20, 20)), + ] + }; + + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + // When + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); + + // Then the furigana is dropped, leaving only the main text. + Assert.Equal("黒ごま", text); + } + + // ----- FilterFurigana unit tests (the geometry heuristic) ----- + + [Fact] + public void FilterFurigana_EmptyList_ReturnsEmpty() + { + List result = OcrUtilities.FilterFurigana([]); + + Assert.Empty(result); + } + + [Fact] + public void FilterFurigana_SingleWord_IsKept() + { + List words = [Word("黒", 0, 0, 20, 20)]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒"], result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_UniformHeights_KeepsAllInOrder() + { + // No word is small relative to the median, so nothing is furigana. + List words = + [ + Word("黒", 0, 0, 20, 20), + Word("ご", 20, 0, 20, 20), + Word("ま", 40, 0, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "ご", "ま"], result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_RemovesSmallWordAboveOverlappingKanji() + { + List words = + [ + Word("くろ", 0, 0, 16, 8), // furigana: short, sitting above + Word("黒", 0, 10, 20, 20), // kanji: taller, below, overlapping + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒"], result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_KeepsSmallWordWhenNotHorizontallyOverlapping() + { + // Small, but nowhere near a kanji horizontally, so it is real text. + List words = + [ + Word("くろ", 100, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["くろ", "黒"], result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_KeepsSmallWordBelowMainText() + { + // Furigana sits above its kanji; a small word BELOW a larger word is + // not furigana and must be kept. + List words = + [ + Word("黒", 0, 0, 20, 20), + Word("くろ", 0, 22, 16, 8), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "くろ"], result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_KeepsSmallWordWhenWordBelowIsNotLarger() + { + // A small word directly above another small word is not furigana: + // furigana requires a larger word (the kanji) beneath it. The two tall + // words only exist to raise the median height. + List words = + [ + Word("く", 0, 0, 8, 8), + Word("ろ", 0, 10, 8, 8), // below + overlapping, but also small + Word("本", 50, 0, 20, 20), + Word("語", 80, 0, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["く", "ろ", "本", "語"], result.Select(w => w.Text)); + } + + [Theory] + [InlineData("く", true)] // 1-char ruby is removed + [InlineData("くろ", true)] // 2-char ruby is removed + [InlineData("くろが", false)] // 3+ chars is treated as real text and kept + public void FilterFurigana_OnlyRemovesShortWords(string rubyText, bool removed) + { + List words = + [ + Word(rubyText, 0, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + string[] expected = removed ? ["黒"] : [rubyText, "黒"]; + Assert.Equal(expected, result.Select(w => w.Text)); + } + + [Fact] + public void FilterFurigana_RemovesMultipleFuriganaKeepingMainText() + { + List words = + [ + Word("くろ", 0, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + Word("ごま", 20, 0, 16, 8), + Word("米", 20, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "米"], result.Select(w => w.Text)); + } + + // ----- BuildTextFromOcrLines integration (language gating) ----- + + [Fact] + public void BuildTextFromOcrLines_JapaneseWithoutFurigana_IsUnchanged() + { + FakeOcrLine line = new("黒ごま", new Windows.Foundation.Rect(0, 0, 60, 20)) + { + Words = + [ + Word("黒", 0, 0, 20, 20), + Word("ご", 20, 0, 20, 20), + Word("ま", 40, 0, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); + + Assert.Equal("黒ごま", text); + } + + [Fact] + public void BuildTextFromOcrLines_ChineseText_JoinsWithoutSpaces() + { + FakeOcrLine line = new("中文", new Windows.Foundation.Rect(0, 0, 40, 20)) + { + Words = + [ + Word("中", 0, 0, 20, 20), + Word("文", 20, 0, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); + + Assert.Equal("中文", text); + } + + [Fact] + public void BuildTextFromOcrLines_FiltersRubyTextForChinese() + { + // The same small-ruby heuristic also runs for Chinese, another + // non-space-joining language (e.g. bopomofo above a character). + FakeOcrLine line = new("ㄓ中文", new Windows.Foundation.Rect(0, 0, 40, 30)) + { + Words = + [ + Word("ㄓ", 0, 0, 8, 8), + Word("中", 0, 10, 20, 20), + Word("文", 20, 10, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); + + Assert.Equal("中文", text); + } + + [Fact] + public void BuildTextFromOcrLines_SpaceJoiningLanguage_DoesNotFilterFurigana() + { + // For space-joining languages the whole line text is used verbatim, so + // the furigana heuristic never runs, even with a tiny word present. + Settings settings = AppUtilities.TextGrabSettings; + bool originalParagraphDetection = settings.ParagraphDetection; + bool originalCorrectErrors = settings.CorrectErrors; + settings.ParagraphDetection = false; + settings.CorrectErrors = false; + + try + { + FakeOcrLine line = new("Hello World", new Windows.Foundation.Rect(0, 0, 100, 30)) + { + Words = + [ + Word("x", 0, 0, 4, 4), // tiny word that would be furigana in CJK + Word("Hello", 0, 10, 50, 20), + Word("World", 55, 10, 50, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("en-US"), ocrResult); + + Assert.Equal("Hello World" + System.Environment.NewLine, text); + } + finally + { + settings.ParagraphDetection = originalParagraphDetection; + settings.CorrectErrors = originalCorrectErrors; + } + } + + [Fact] + public void OrderLinesForReadingFlow_SortsRowsTopToBottomAndLeftToRight() + { + // Mimics the Windows OCR engine returning furigana ruby lines and a + // trailing fragment out of reading order (as seen with Ja-Lang-Image.png). + // Row 1 (y~0): furigana くろ + main-line reading, emitted out of x-order. + // Row 2 (y~30): the main text line. + FakeOcrLine furiganaRight = new("しつ", new Windows.Foundation.Rect(200, 0, 20, 8)); + FakeOcrLine furiganaLeft = new("くろ", new Windows.Foundation.Rect(0, 0, 20, 8)); + FakeOcrLine mainLine = new("黒ごま質", new Windows.Foundation.Rect(0, 30, 240, 20)); + + // Engine order is scrambled: right furigana, main line, then left furigana. + FakeOcrLinesWords ocrResult = new() + { + Lines = [furiganaRight, mainLine, furiganaLeft] + }; + + IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); + + Assert.Equal(["くろ", "しつ", "黒ごま質"], ordered.Select(l => l.Text)); + } + + [Fact] + public void OrderLinesForReadingFlow_KeepsSeparateRowsInVerticalOrder() + { + // Two furigana rows and two main-text rows interleaved and shuffled must + // come back strictly top-to-bottom. + FakeOcrLine ruby2 = new("かみ", new Windows.Foundation.Rect(0, 100, 20, 8)); + FakeOcrLine main2 = new("髪", new Windows.Foundation.Rect(0, 130, 40, 20)); + FakeOcrLine ruby1 = new("くろ", new Windows.Foundation.Rect(0, 0, 20, 8)); + FakeOcrLine main1 = new("黒", new Windows.Foundation.Rect(0, 30, 40, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [main2, ruby1, main1, ruby2] }; + + IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); + + Assert.Equal(["くろ", "黒", "かみ", "髪"], ordered.Select(l => l.Text)); + } + + [Fact] + public void FilterFuriganaLines_RemovesShortLineAboveTallerOverlappingLine() + { + // A short furigana line sitting just above a taller kanji line that it + // overlaps horizontally is dropped. + FakeOcrLine furigana = new("くろ", new Windows.Foundation.Rect(0, 0, 40, 8)); + FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 10, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [furigana, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["黒ごま"], result.Select(l => l.Text)); + } + + [Fact] + public void FilterFuriganaLines_KeepsTwoBodyLinesOfSimilarHeight() + { + // Two normal body lines stacked vertically: neither is much shorter than + // the other, so nothing is treated as furigana. + FakeOcrLine top = new("黒ごまは体に", new Windows.Foundation.Rect(0, 0, 200, 20)); + FakeOcrLine bottom = new("たくさんあります", new Windows.Foundation.Rect(0, 26, 200, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [top, bottom] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["黒ごまは体に", "たくさんあります"], result.Select(l => l.Text)); + } + + [Fact] + public void FilterFuriganaLines_KeepsShortLineNotHorizontallyOverlappingAnyKanji() + { + // A short line off to the side (no taller line beneath it) is real text. + FakeOcrLine shortSide = new("注", new Windows.Foundation.Rect(300, 0, 20, 8)); + FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 10, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [shortSide, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["注", "黒ごま"], result.Select(l => l.Text)); + } + + [Fact] + public void FilterFuriganaLines_KeepsShortLineWhenGapIsTooLarge() + { + // Short line far above a taller line is a separate heading/body line, not + // a hugging ruby annotation, so it is kept. + FakeOcrLine shortHeading = new("メモ", new Windows.Foundation.Rect(0, 0, 40, 8)); + FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 60, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [shortHeading, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["メモ", "黒ごま"], result.Select(l => l.Text)); + } + + private static FakeOcrWord Word(string text, double x, double y, double width, double height) + => new(text, new Windows.Foundation.Rect(x, y, width, height)); + private sealed class FakeOcrLinesWords : IOcrLinesWords { public string Text { get; set; } = string.Empty; @@ -499,4 +1030,17 @@ public FakeOcrLine(string text, Windows.Foundation.Rect boundingBox) public Windows.Foundation.Rect BoundingBox { get; set; } } + + private sealed class FakeOcrWord : IOcrWord + { + public FakeOcrWord(string text, Windows.Foundation.Rect boundingBox) + { + Text = text; + BoundingBox = boundingBox; + } + + public string Text { get; set; } + + public Windows.Foundation.Rect BoundingBox { get; set; } + } } diff --git a/Tests/PatternExecutorTests.cs b/Tests/PatternExecutorTests.cs new file mode 100644 index 00000000..23152e0a --- /dev/null +++ b/Tests/PatternExecutorTests.cs @@ -0,0 +1,137 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class PatternExecutorTests +{ + // A deterministic saved-regex item that does not depend on the machine's saved patterns. + private static PatternItem SavedEmail() => + new(new StoredRegex("Email Address", @"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", true)); + + private static PatternItem RecognizerByName(string name) => + new(BuiltInRecognizer.GetByName(name) ?? throw new InvalidOperationException($"missing recognizer {name}")); + + // ── PatternItem catalog ─────────────────────────────────────────────────── + + [Fact] + public void GetAll_ListsSavedRegexesBeforeRecognizers() + { + IReadOnlyList all = PatternItem.GetAll(); + + int firstRecognizer = -1; + int lastSaved = -1; + for (int i = 0; i < all.Count; i++) + { + if (all[i].Kind == PatternKind.Recognizer && firstRecognizer < 0) + firstRecognizer = i; + if (all[i].Kind == PatternKind.SavedRegex) + lastSaved = i; + } + + Assert.True(firstRecognizer >= 0, "expected at least one recognizer item"); + Assert.True(lastSaved < firstRecognizer, "all saved regexes should precede recognizers"); + } + + [Fact] + public void GetAll_IncludesEveryRecognizerWithSmartGroup() + { + List recognizers = [.. PatternItem.GetAll().Where(p => p.Kind == PatternKind.Recognizer)]; + + Assert.Equal(BuiltInRecognizer.GetAll().Count, recognizers.Count); + Assert.All(recognizers, p => Assert.Equal(PatternItem.SmartGroup, p.GroupLabel)); + } + + [Fact] + public void GetByName_FindsRecognizer_CaseInsensitive() + { + PatternItem? email = PatternItem.GetByName("EMAIL"); + + Assert.NotNull(email); + Assert.Equal(PatternKind.Recognizer, email!.Kind); + } + + // ── PatternExecutor – recognizer-backed ─────────────────────────────────── + + [Fact] + public void HasMatch_Recognizer_DetectsEntity() + { + Assert.True(PatternExecutor.HasMatch(RecognizerByName("Email"), "reach me at a@b.com")); + Assert.False(PatternExecutor.HasMatch(RecognizerByName("Email"), "no address here")); + } + + [Fact] + public void Apply_Recognizer_NormalizesCurrencyResolvedValue() + { + string result = PatternExecutor.Apply( + RecognizerByName("Currency"), "it costs $5", "first", ", ", RecognizerOutputKind.ResolvedValue); + Assert.Equal("5 Dollar", result); + } + + [Fact] + public void Apply_Recognizer_MatchedText_KeepsOriginalSpan() + { + string result = PatternExecutor.Apply( + RecognizerByName("Currency"), "it costs $5", "first", ", ", RecognizerOutputKind.MatchedText); + Assert.Equal("$5", result); + } + + // ── PatternExecutor – saved-regex-backed ────────────────────────────────── + + [Fact] + public void GetMatches_SavedRegex_ReportsSpanAndMatchedText() + { + RecognizerMatch match = PatternExecutor.GetMatches(SavedEmail(), "write to a@b.com please")[0]; + + Assert.Equal("a@b.com", match.Text); + Assert.Equal("a@b.com", match.ResolvedValue); // regex has no resolution + Assert.Equal(9, match.Start); + Assert.Equal("a@b.com".Length, match.Length); + } + + [Fact] + public void HasMatch_SavedRegex_TrueWhenPresent() + { + Assert.True(PatternExecutor.HasMatch(SavedEmail(), "x a@b.com y")); + Assert.False(PatternExecutor.HasMatch(SavedEmail(), "nothing here")); + } + + [Fact] + public void Apply_SavedRegex_All_JoinsMatchedTextWithSeparator() + { + string result = PatternExecutor.Apply(SavedEmail(), "a@b.com and c@d.org", "all"); + Assert.Equal("a@b.com, c@d.org", result); + } + + [Fact] + public void Apply_SavedRegex_RespectsModeAndSeparator() + { + PatternItem email = SavedEmail(); + const string text = "a@b.com and c@d.org"; + + Assert.Equal("a@b.com", PatternExecutor.Apply(email, text, "first")); + Assert.Equal("c@d.org", PatternExecutor.Apply(email, text, "last")); + Assert.Equal("c@d.org", PatternExecutor.Apply(email, text, "2")); + Assert.Equal("a@b.com | c@d.org", PatternExecutor.Apply(email, text, "all", " | ")); + } + + [Fact] + public void Apply_SavedRegex_NoMatch_ReturnsEmpty() + { + Assert.Equal(string.Empty, PatternExecutor.Apply(SavedEmail(), "no emails here", "all")); + } + + [Fact] + public void GetMatches_InvalidRegex_ReturnsEmpty_DoesNotThrow() + { + PatternItem bad = new(new StoredRegex("Bad", "([unclosed", false)); + Assert.Empty(PatternExecutor.GetMatches(bad, "anything")); + } + + [Fact] + public void GetMatches_EmptyText_ReturnsEmpty() + { + Assert.Empty(PatternExecutor.GetMatches(SavedEmail(), string.Empty)); + Assert.Empty(PatternExecutor.GetMatches(RecognizerByName("Number"), string.Empty)); + } +} diff --git a/Tests/PdfDocumentRendererTests.cs b/Tests/PdfDocumentRendererTests.cs index 8d00801c..530bf7c8 100644 --- a/Tests/PdfDocumentRendererTests.cs +++ b/Tests/PdfDocumentRendererTests.cs @@ -21,9 +21,19 @@ public void GetRenderDimensions_ClampsToOcrEngineLimit() (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(5000, 2500); Assert.True(Math.Max(width, height) <= OcrEngine.MaxImageDimension); + Assert.True((ulong)width * height <= PdfDocumentRenderer.MaxRenderPixelCount); Assert.True(width > height); } + [Fact] + public void GetRenderDimensions_ClampsTotalPixelCount() + { + (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(10_000, 10_000); + + Assert.True((ulong)width * height <= PdfDocumentRenderer.MaxRenderPixelCount); + Assert.Equal(width, height); + } + [Fact] public void GetRenderDimensions_InvalidSize_ReturnsSinglePixel() { diff --git a/Tests/PostGrabActionManagerTests.cs b/Tests/PostGrabActionManagerTests.cs index 8e47ca19..7a0a3b92 100644 --- a/Tests/PostGrabActionManagerTests.cs +++ b/Tests/PostGrabActionManagerTests.cs @@ -13,7 +13,7 @@ public void GetDefaultPostGrabActions_ReturnsExpectedCount() // Assert Assert.NotNull(actions); - Assert.Equal(5, actions.Count); + Assert.Equal(6, actions.Count); } [Fact] @@ -28,6 +28,7 @@ public void GetDefaultPostGrabActions_ContainsExpectedActions() Assert.Contains(actions, a => a.ButtonText == "Remove duplicate lines"); Assert.Contains(actions, a => a.ButtonText == "Web Search"); Assert.Contains(actions, a => a.ButtonText == "Try to insert text"); + Assert.Contains(actions, a => a.ButtonText == "Speak text"); //Assert.Contains(actions, a => a.ButtonText == "Translate to system language"); } @@ -103,6 +104,21 @@ public async System.Threading.Tasks.Task ExecutePostGrabAction_RemoveDuplicateLi Assert.Single(lines, l => l == "Line 1"); } + [Fact] + public async Task ExecutePostGrabAction_SpeakText_ReturnsTextUnchanged() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "SpeakText_Click"); + string input = "Hello world"; + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert — TTS is fire-and-forget; text must pass through unchanged + Assert.Equal(input, result); + } + [Fact] public void GetCheckState_DefaultOff_ReturnsFalse() { diff --git a/Tests/RecognizerExecutorTests.cs b/Tests/RecognizerExecutorTests.cs new file mode 100644 index 00000000..3de3c575 --- /dev/null +++ b/Tests/RecognizerExecutorTests.cs @@ -0,0 +1,292 @@ +using Microsoft.Recognizers.Text; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class RecognizerExecutorTests +{ + private static BuiltInRecognizer Get(string id) => + BuiltInRecognizer.GetById(id) ?? throw new InvalidOperationException($"missing recognizer {id}"); + + private static ModelResult ResultWith(string text, params (string Key, object Value)[] resolution) + { + SortedDictionary map = new(); + foreach ((string key, object value) in resolution) + map[key] = value; + + return new ModelResult { Text = text, Start = 0, End = text.Length - 1, Resolution = map }; + } + + // ── BuiltInRecognizer catalog ───────────────────────────────────────────── + + [Fact] + public void GetAll_ReturnsFullCatalog() + { + Assert.Equal(14, BuiltInRecognizer.GetAll().Count); + } + + [Fact] + public void GetById_And_GetByName_AreCaseInsensitive() + { + Assert.NotNull(BuiltInRecognizer.GetById("NUMBER")); + Assert.NotNull(BuiltInRecognizer.GetByName("date / time")); + Assert.Null(BuiltInRecognizer.GetById("does-not-exist")); + Assert.Null(BuiltInRecognizer.GetByName("does-not-exist")); + } + + // ── GetMatches / HasMatch ───────────────────────────────────────────────── + + [Fact] + public void GetMatches_Number_FindsAllNumbersWithResolvedValues() + { + IReadOnlyList matches = + RecognizerExecutor.GetMatches(Get("number"), "I have 25 apples and 3.5 kg"); + + Assert.Equal(2, matches.Count); + Assert.Equal("25", matches[0].Text); + Assert.Equal("25", matches[0].ResolvedValue); + Assert.Equal("3.5", matches[1].ResolvedValue); + } + + [Fact] + public void GetMatches_ReportsCorrectSpan() + { + RecognizerMatch match = RecognizerExecutor.GetMatches(Get("email"), "write to a@b.com please")[0]; + + Assert.Equal("a@b.com", match.Text); + Assert.Equal(9, match.Start); + Assert.Equal("a@b.com".Length, match.Length); + } + + [Fact] + public void HasMatch_TrueWhenEntityPresent_FalseOtherwise() + { + Assert.True(RecognizerExecutor.HasMatch(Get("email"), "reach me at a@b.com")); + Assert.False(RecognizerExecutor.HasMatch(Get("email"), "no address here")); + } + + [Fact] + public void GetMatches_EmptyText_ReturnsEmpty() + { + Assert.Empty(RecognizerExecutor.GetMatches(Get("number"), string.Empty)); + } + + // ── ApplyRecognizer – modes ─────────────────────────────────────────────── + + [Fact] + public void ApplyRecognizer_All_JoinsWithSeparator() + { + string result = RecognizerExecutor.ApplyRecognizer(Get("number"), "25 and 3.5", "all"); + Assert.Equal("25, 3.5", result); + } + + [Fact] + public void ApplyRecognizer_First_ReturnsFirst() + { + Assert.Equal("25", RecognizerExecutor.ApplyRecognizer(Get("number"), "25 and 3.5", "first")); + } + + [Fact] + public void ApplyRecognizer_Last_ReturnsLast() + { + Assert.Equal("3.5", RecognizerExecutor.ApplyRecognizer(Get("number"), "25 and 3.5", "last")); + } + + [Fact] + public void ApplyRecognizer_NthIndex_ReturnsThatMatch() + { + Assert.Equal("3.5", RecognizerExecutor.ApplyRecognizer(Get("number"), "25 and 3.5", "2")); + } + + [Fact] + public void ApplyRecognizer_CustomSeparator_IsUsed() + { + string result = RecognizerExecutor.ApplyRecognizer(Get("number"), "1 and 2", "all", " | "); + Assert.Equal("1 | 2", result); + } + + [Fact] + public void ApplyRecognizer_NoMatch_ReturnsEmpty() + { + Assert.Equal(string.Empty, RecognizerExecutor.ApplyRecognizer(Get("number"), "no numbers here", "all")); + } + + // ── ApplyRecognizer – output kind ───────────────────────────────────────── + + [Fact] + public void ApplyRecognizer_ResolvedValue_NormalizesCurrency() + { + string result = RecognizerExecutor.ApplyRecognizer( + Get("currency"), "it costs $5", "first", ", ", RecognizerOutputKind.ResolvedValue); + Assert.Equal("5 Dollar", result); + } + + [Fact] + public void ApplyRecognizer_MatchedText_KeepsOriginalSpan() + { + string result = RecognizerExecutor.ApplyRecognizer( + Get("currency"), "it costs $5", "first", ", ", RecognizerOutputKind.MatchedText); + Assert.Equal("$5", result); + } + + // ── GrabTemplateExecutor – recognizer placeholders ──────────────────────── + + [Fact] + public void ApplyRecognizerPlaceholders_AllMatches_Substitutes() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("Found {r:Number:all}", "1 2 3"); + Assert.Equal("Found 1, 2, 3", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_TextOutput_UsesMatchedText() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Currency:first:text}", "it costs $5"); + Assert.Equal("$5", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_UnknownRecognizer_LeavesPlaceholder() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Nope:first}", "anything 5"); + Assert.Equal("{r:Nope:first}", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_LeavesPatternPlaceholdersUntouched() + { + // Recognizer pass must only resolve {r:...}, never {p:...} + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders( + "{p:Email:first} {r:Number:first}", "value 5"); + Assert.Equal("{p:Email:first} 5", result); + } + + // ── GrabTemplateExecutor – parsing ──────────────────────────────────────── + + [Fact] + public void ParseRecognizerMatches_ExtractsModeAndOutputKind() + { + List matches = + GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:text}"); + + TemplateRecognizerMatch match = Assert.Single(matches); + Assert.Equal("Number", match.RecognizerName); + Assert.Equal("all", match.MatchMode); + Assert.Equal(RecognizerOutputKind.MatchedText, match.OutputKind); + Assert.Equal(Get("number").Id, match.RecognizerId); + } + + [Fact] + public void ParseRecognizerMatches_WithSeparator_ParsesValueOutputAndSeparator() + { + List matches = + GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:value:; }"); + + TemplateRecognizerMatch match = Assert.Single(matches); + Assert.Equal("all", match.MatchMode); + Assert.Equal("; ", match.Separator); + Assert.Equal(RecognizerOutputKind.ResolvedValue, match.OutputKind); + } + + // ── FormatResolvedValue – resolution shapes (guards library coupling) ───── + + [Fact] + public void FormatResolvedValue_ValuesAsStringDictionaries_ReadsValue() + { + // The current Recognizers-Text shape: "values" is a list of string→string dictionaries. + ModelResult result = ResultWith("on 2026-01-15", + ("values", new List> { new() { ["value"] = "2026-01-15" } })); + + Assert.Equal("2026-01-15", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_ValuesAsObjectDictionaries_StillReadsValue() + { + // A hypothetical future shape: "values" holds string→object dictionaries. This must keep + // resolving instead of silently falling back to the matched text (issue: type coupling). + ModelResult result = ResultWith("next tuesday", + ("values", new List> { new() { ["value"] = "2026-01-20" } })); + + Assert.Equal("2026-01-20", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_ValuesWithStartAndEnd_FormatsRange() + { + ModelResult result = ResultWith("this week", + ("values", new List> + { + new() { ["start"] = "2026-01-01", ["end"] = "2026-01-05" } + })); + + Assert.Equal("2026-01-01 → 2026-01-05", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_ValuesWithOnlyTimex_FallsBackToTimex() + { + ModelResult result = ResultWith("every monday", + ("values", new List> { new() { ["timex"] = "XXXX-WXX-1" } })); + + Assert.Equal("XXXX-WXX-1", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_NotResolvedValue_FallsBackToText() + { + ModelResult result = ResultWith("someday", + ("values", new List> { new() { ["value"] = "not resolved" } })); + + Assert.Equal("someday", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_ValueAndUnit_JoinsWithSpace() + { + ModelResult result = ResultWith("5 dollars", ("value", "5"), ("unit", "Dollar")); + + Assert.Equal("5 Dollar", RecognizerExecutor.FormatResolvedValue(result)); + } + + [Fact] + public void FormatResolvedValue_EmptyResolution_ReturnsText() + { + Assert.Equal("plain text", RecognizerExecutor.FormatResolvedValue(ResultWith("plain text"))); + } + + // ── DateTime recognizer – real "values" path (pins the live library shape) ─ + + [Fact] + public void GetMatches_DateTime_ResolvesAbsoluteDate() + { + RecognizerMatch match = RecognizerExecutor.GetMatches(Get("datetime"), "meeting on 2026-01-15")[0]; + + // Resolution must produce the normalized date, distinct from the matched span. + Assert.Equal("2026-01-15", match.ResolvedValue); + } + + [Fact] + public void GetMatches_DateTime_ResolvesDateRange() + { + RecognizerMatch match = + RecognizerExecutor.GetMatches(Get("datetime"), "from 2026-01-01 to 2026-01-05")[0]; + + Assert.Equal("2026-01-01 → 2026-01-05", match.ResolvedValue); + } + + // ── ApplyTextOnlyTemplate – recognizer-only ─────────────────────────────── + + [Fact] + public void ApplyTextOnlyTemplate_RecognizerPlaceholder_Resolves() + { + GrabTemplate template = new("Numbers") + { + OutputTemplate = "Numbers: {r:Number:all}" + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "got 1 and 2"); + Assert.Equal("Numbers: 1, 2", result); + } +} diff --git a/Tests/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs index abf66e14..9c12cea5 100644 --- a/Tests/SettingsServiceTests.cs +++ b/Tests/SettingsServiceTests.cs @@ -308,6 +308,24 @@ public void LoadStoredRegexes_SidecarSurvivesSimulatedPackageUpgrade() Assert.Contains("survived-upgrade", settings.RegexList); } + [Fact] + public void SaveHiddenSmartPatternIds_FileBackedModeWritesBothStores() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = true + }; + SettingsService service = CreateService(settings); + + service.SaveHiddenSmartPatternIds(["email", "url"]); + + string filePath = Path.Combine(_tempFolder, "HiddenSmartPatternIds.json"); + Assert.Contains("email", settings.HiddenSmartPatternIds); + Assert.True(File.Exists(filePath)); + Assert.Contains("email", File.ReadAllText(filePath)); + Assert.Equal(["email", "url"], service.LoadHiddenSmartPatternIds()); + } + private SettingsService CreateService(Settings settings) => new( settings, diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 19ae399f..b59cb94e 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -17,8 +17,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -58,6 +58,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Tests/TtsServiceTests.cs b/Tests/TtsServiceTests.cs new file mode 100644 index 00000000..58b391f0 --- /dev/null +++ b/Tests/TtsServiceTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; +using Text_Grab.Interfaces; +using Text_Grab.Services; + +namespace Tests; + +public class TtsServiceTests +{ + [Fact] + public async Task DrainCallbackQueuingSpeech_DoesNotPublishIdleBetweenRequests() + { + ControlledTtsEngine engine = new(); + TtsService service = new() { Engine = engine }; + ConcurrentQueue busyEvents = new(); + TaskCompletionSource idle = new(TaskCreationOptions.RunContinuationsAsynchronously); + + service.BusyChanged += isBusy => + { + busyEvents.Enqueue(isBusy); + if (!isBusy) + idle.TrySetResult(); + }; + + service.Speak("first"); + await engine.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + service.RunWhenIdle(() => service.Speak("second")); + engine.ReleaseFirst.TrySetResult(); + await engine.SecondStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal([true], busyEvents); + + engine.ReleaseSecond.TrySetResult(); + await idle.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal([true, false], busyEvents); + } + + private sealed class ControlledTtsEngine : ITtsEngine + { + private int callCount; + + public TaskCompletionSource FirstStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource SecondStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReleaseFirst { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReleaseSecond { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task SpeakAsync(string text, CancellationToken ct) + { + int call = Interlocked.Increment(ref callCount); + TaskCompletionSource started = call == 1 ? FirstStarted : SecondStarted; + TaskCompletionSource release = call == 1 ? ReleaseFirst : ReleaseSecond; + + started.TrySetResult(); + await release.Task.WaitAsync(ct); + } + } +} diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 97abd3f7..c6e38862 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -14,7 +14,7 @@ + Version="4.15.0.0" /> Text Grab @@ -96,6 +96,19 @@ + + + + Text Grab Frame + + .tggf + + + Open in Text Grab + + + + diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 30822db8..9b3197a4 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -202,9 +202,15 @@ + + + False + + False + False @@ -226,6 +232,12 @@ False + + False + + + False + English @@ -247,12 +259,39 @@ False + + 100 + + + + + + 1 + True + + True + + + + + + Theme + + + #2A767E + + + False + + + False + diff --git a/Text-Grab/App.xaml b/Text-Grab/App.xaml index 2f2e0bd6..b2a116b3 100644 --- a/Text-Grab/App.xaml +++ b/Text-Grab/App.xaml @@ -43,6 +43,7 @@ + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 40b16da1..f3cbd7c0 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -504,6 +504,9 @@ public static async Task TryToOpenFilePathAsync(string possiblePath, bool if (!File.Exists(possiblePath)) return false; + if (GrabFrameFileUtilities.IsGrabFrameFile(possiblePath)) + return await TryOpenGrabFrameFileAsync(possiblePath, isQuiet); + if (isQuiet) { (string pathContent, _) = await IoUtilities.GetContentFromPath(possiblePath); @@ -528,6 +531,31 @@ public static async Task TryToOpenFilePathAsync(string possiblePath, bool return true; } + /// + /// Opens a Grab Frame file (.tggf). In quiet mode the saved OCR text is routed straight to + /// the clipboard; otherwise the frame is restored in a new Grab Frame window. + /// + private static async Task TryOpenGrabFrameFileAsync(string path, bool isQuiet) + { + HistoryInfo? historyInfo = await GrabFrameFileUtilities.LoadGrabFrameFileAsync(path); + + if (historyInfo is null) + return false; + + if (isQuiet) + { + historyInfo.ImageContent?.Dispose(); + historyInfo.ClearTransientImage(); + OutputUtilities.HandleTextFromOcr(historyInfo.TextContent, false, false); + return true; + } + + GrabFrame grabFrame = new(historyInfo); + grabFrame.Show(); + grabFrame.Activate(); + return true; + } + private void appExit(object sender, ExitEventArgs e) { TextGrabIcon?.Close(); @@ -547,9 +575,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). + // Per-user text-grab:// and .tggf registration for unpackaged installs + // (packaged installs register these via the MSIX manifest). ProtocolUtilities.EnsureProtocolRegistration(); + FileAssociationUtilities.EnsureGrabFrameFileAssociation(); // Register COM server and activator type bool handledArgument = false; @@ -559,7 +588,11 @@ private async void appStartup(object sender, StartupEventArgs e) LaunchFromToast(toastArgs); }; - handledArgument = HandleNotifyIcon(); + // Sets up the tray icon when configured to run in the background. This must not + // suppress handling of a file/protocol/share argument: opening a file should always + // launch UI, even when run-in-the-background + startup-on-login are enabled. The + // return value only decides whether to skip the DefaultLaunch window. + bool suppressDefaultLaunch = HandleNotifyIcon(); if (!handledArgument) handledArgument = await ShareTargetUtilities.HandleShareTargetActivationAsync(); @@ -569,9 +602,10 @@ private async void appStartup(object sender, StartupEventArgs e) WatchTheme(); - if (handledArgument) + if (handledArgument || suppressDefaultLaunch) { - // arguments were passed, so don't show firstRun dialog + // arguments were passed (or we launched only to sit in the background), + // so don't show firstRun dialog or the default launch window _defaultSettings.FirstRun = false; _defaultSettings.Save(); return; diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml b/Text-Grab/Controls/FindAndReplaceWindow.xaml index b0a6bcbb..a603791b 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml @@ -68,11 +68,11 @@ - + + - + RenderTransformOrigin="0.5,0.5" + Style="{DynamicResource ToggleSymbolButton}" + ToolTip="Show or hide more options"> + + + + + + + + + + + + + + + + + + + + - + ExactMatchChanged="OptionsChangedRefresh" + KeyUp="FindTextBox_KeyUp" + PlaceholderText="Find..." + SearchChanged="SearchBar_SearchChanged" + ShowExactMatchToggle="True" /> - - - - Match Exactly - - + + + + - - + - - - - Use Regex - - + + + + - - + - - - Precision - - - + + + + + + + + - + + + + + + + + + diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index 24215980..4e6a0eaa 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -59,6 +59,9 @@ public FindAndReplaceWindow() private bool IsSpreadsheetSearch => textEditWindow?.IsSpreadsheetMode is true; + private bool IsSmartPatternSearch => + SearchBar.SelectedPattern is { Kind: PatternKind.Recognizer, Recognizer: not null }; + public List FindResults { get; set; } = []; public string StringFromWindow @@ -72,11 +75,19 @@ public EditTextWindow? TextEditWindow get => textEditWindow; set { - textEditWindow?.PassedTextControl.TextChanged -= EditTextBoxChanged; + if (textEditWindow is not null) + { + textEditWindow.PassedTextControl.TextChanged -= EditTextBoxChanged; + textEditWindow.EditorModeChanged -= EditTextWindow_EditorModeChanged; + } textEditWindow = value; - textEditWindow?.PassedTextControl.TextChanged += EditTextBoxChanged; + if (textEditWindow is not null) + { + textEditWindow.PassedTextControl.TextChanged += EditTextBoxChanged; + textEditWindow.EditorModeChanged += EditTextWindow_EditorModeChanged; + } } } private string? Pattern { get; set; } @@ -85,41 +96,60 @@ public EditTextWindow? TextEditWindow #region Methods + /// + /// Loads text into the shared search bar (optionally enabling regex) and places the caret at + /// the end. Used by other windows that open Find & Replace pre-filled with a pattern. + /// + public void SetFindText(string text, bool useRegex = false) + { + SearchBar.SearchText = text; + if (useRegex) + SearchBar.UseRegex = true; + SearchBar.FocusInput(); + } + public void SearchForText() { if (IsSpreadsheetSearch) { SearchSpreadsheetCells(); return; } + RefreshSourceTextFromEditor(); FindResults.Clear(); ResultsListView.ItemsSource = null; - if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) + // Recognizers are find-only (no regex replace). A saved regex, by contrast, has already + // been loaded into the find box, so it flows through the normal regex search below. + // When a recognizer chip is active, any typed text narrows its matches. + if (SearchBar.SelectedPattern is { Kind: PatternKind.Recognizer, Recognizer: { } selectedRecognizer }) + { + SearchByRecognizer(selectedRecognizer, SearchBar.SearchText); + return; + } + + if (!TextSearchUtilities.HasSearchText(SearchBar.SearchText)) { Matches = null; MatchesText.Text = "0 Matches"; return; } - Pattern = FindTextBox.Text; + Pattern = SearchBar.SearchText; // Auto-detect regex pattern: if starts with ^ and ends with $, enable regex mode and strip anchors if (Pattern.StartsWith('^') && Pattern.EndsWith('$') && Pattern.Length > 2) { - UsePatternCheckBox.IsChecked = true; + SearchBar.UseRegex = true; Pattern = Pattern[1..^1]; // Strip ^ from start and $ from end } - if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) - Pattern = Pattern.EscapeSpecialRegexChars(matchExactly); - - if (string.IsNullOrEmpty(StringFromWindow) && TextEditWindow is not null) - StringFromWindow = TextEditWindow.GetSelectedTextOrAllText(); + if (!SearchBar.UseRegex) + Pattern = Pattern.EscapeSpecialRegexChars(SearchBar.ExactMatch); try { // When using pattern mode with inline flags, rely on the inline flags for case sensitivity // Otherwise, use RegexOptions for backward compatibility - bool usingPatternMode = UsePatternCheckBox.IsChecked is true; - bool exactMatch = ExactMatchCheckBox.IsChecked is true; + bool usingPatternMode = SearchBar.UseRegex; + bool exactMatch = SearchBar.ExactMatch; Regex regex = TextSearchUtilities.CreateFindAndReplaceSearchRegex(Pattern, usingPatternMode, exactMatch); Matches = regex.Matches(StringFromWindow); } @@ -160,6 +190,7 @@ public void SearchForText() { Index = m.Index, Text = TextSearchUtilities.FormatMatchTextForDisplay(m.Value), + RawText = m.Value, PreviewLeft = StringMethods.GetCharactersToLeftOfNewLine(ref stringFromWindow, m.Index, 12).MakeStringSingleLine(), PreviewRight = StringMethods.GetCharactersToRightOfNewLine(ref stringFromWindow, m.Index + m.Length, 12).MakeStringSingleLine(), Length = m.Length, @@ -184,18 +215,72 @@ public void SearchForText() } } + /// + /// Finds every entity the recognizer detects in the source text and lists them as + /// s. When is supplied, only matches + /// whose text contains it are kept (the chip + free-text case). Leaves + /// null (like spreadsheet search), so regex-based replace/navigation is disabled in recognizer mode. + /// + private void SearchByRecognizer(BuiltInRecognizer recognizer, string narrowText = "") + { + RefreshSourceTextFromEditor(); + + Matches = null; + + IReadOnlyList recognizerMatches = RecognizerExecutor.GetMatches(recognizer, StringFromWindow); + + if (!string.IsNullOrEmpty(narrowText)) + recognizerMatches = [.. recognizerMatches.Where(m => m.Text.Contains(narrowText, StringComparison.CurrentCultureIgnoreCase))]; + + if (recognizerMatches.Count == 0) + { + MatchesText.Text = "0 Matches"; + return; + } + + MatchesText.Text = recognizerMatches.Count == 1 ? "1 Match" : $"{recognizerMatches.Count} Matches"; + ResultsListView.IsEnabled = true; + + int count = 1; + foreach (RecognizerMatch m in recognizerMatches) + { + FindResult fr = new() + { + Index = m.Start, + Text = TextSearchUtilities.FormatMatchTextForDisplay(m.Text), + RawText = m.Text, + PreviewLeft = StringMethods.GetCharactersToLeftOfNewLine(ref stringFromWindow, m.Start, 12).MakeStringSingleLine(), + PreviewRight = StringMethods.GetCharactersToRightOfNewLine(ref stringFromWindow, m.Start + m.Length, 12).MakeStringSingleLine(), + Length = m.Length, + Count = count, + }; + FindResults.Add(fr); + count++; + } + + ResultsListView.ItemsSource = FindResults; + + if (textEditWindow is not null && this.IsFocused) + { + RecognizerMatch first = recognizerMatches[0]; + textEditWindow.PassedTextControl.Select(first.Start, first.Length); + textEditWindow.PassedTextControl.Focus(); + this.Focus(); + } + } + private Regex? BuildCurrentRegex() { - string rawPattern = FindTextBox.Text; + string rawPattern = SearchBar.SearchText; if (!TextSearchUtilities.HasSearchText(rawPattern)) return null; if (rawPattern.StartsWith('^') && rawPattern.EndsWith('$') && rawPattern.Length > 2) rawPattern = rawPattern[1..^1]; - if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) - rawPattern = rawPattern.EscapeSpecialRegexChars(matchExactly); + if (!SearchBar.UseRegex) + rawPattern = rawPattern.EscapeSpecialRegexChars(SearchBar.ExactMatch); - try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, ExactMatchCheckBox.IsChecked is true); } + try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, SearchBar.ExactMatch); } catch { return null; } } @@ -205,20 +290,33 @@ private void SearchSpreadsheetCells() ResultsListView.ItemsSource = null; Matches = null; - if (textEditWindow is null || !TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (textEditWindow is null) { MatchesText.Text = "0 Matches"; return; } - Regex? regex = BuildCurrentRegex(); - if (regex is null) { MatchesText.Text = "0 Matches"; return; } - textEditWindow.CommitSpreadsheetAndSync(); List results; - try { results = textEditWindow.SearchSpreadsheetCells(regex); } - catch (RegexMatchTimeoutException) { MatchesText.Text = "Regex timeout"; return; } + if (SearchBar.SelectedPattern is { Kind: PatternKind.Recognizer, Recognizer: not null } selectedPattern) + { + results = textEditWindow.SearchSpreadsheetCells(selectedPattern, SearchBar.SearchText); + } + else + { + if (!TextSearchUtilities.HasSearchText(SearchBar.SearchText)) + { + MatchesText.Text = "0 Matches"; + return; + } + + Regex? regex = BuildCurrentRegex(); + if (regex is null) { MatchesText.Text = "0 Matches"; return; } + + try { results = textEditWindow.SearchSpreadsheetCells(regex); } + catch (RegexMatchTimeoutException) { MatchesText.Text = "Regex timeout"; return; } + } FindResults.AddRange(results); if (FindResults.Count == 0) { MatchesText.Text = "0 Matches"; return; } @@ -255,49 +353,46 @@ private void PrecisionSlider_Tick(object? sender, EventArgs? e) private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs e) { - if (IsSpreadsheetSearch) - { - e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); - return; - } - - if (Matches is null || Matches.Count < 1 || string.IsNullOrEmpty(FindTextBox.Text)) - e.CanExecute = false; - else - e.CanExecute = true; + e.CanExecute = FindResults.Count > 0 + && (IsSmartPatternSearch || !string.IsNullOrEmpty(SearchBar.SearchText)); } private void CopyMatchesCmd_Executed(object sender, ExecutedRoutedEventArgs e) { if (textEditWindow is null) return; - if (!IsSpreadsheetSearch && (Matches is null || Matches.Count < 1)) + if (FindResults.Count == 0) return; - StringBuilder stringBuilder = new(); - IList selection = ResultsListView.SelectedItems; if (selection.Count < 2) selection = ResultsListView.Items; - foreach (object? item in selection) - if (item is FindResult findResult) - stringBuilder.AppendLine(findResult.Text); + string matchText = GetMatchTextForEditing(selection.OfType()); + if (string.IsNullOrEmpty(matchText)) + return; EditTextWindow etw = new(); - etw.AddThisText(stringBuilder.ToString()); + etw.AddThisText(matchText); etw.Show(); } + internal static string GetMatchTextForEditing(IEnumerable findResults) + { + return string.Join(Environment.NewLine, findResults.Select(findResult => findResult.RawText)); + } + private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e) { if (IsSpreadsheetSearch) { - e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); + e.CanExecute = !IsSmartPatternSearch + && FindResults.Count > 0 + && !string.IsNullOrEmpty(SearchBar.SearchText); return; } - if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(FindTextBox.Text)) + if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(SearchBar.SearchText)) e.CanExecute = true; else e.CanExecute = false; @@ -309,6 +404,7 @@ private async void DeleteAll_Executed(object sender, ExecutedRoutedEventArgs e) if (IsSpreadsheetSearch) { + if (IsSmartPatternSearch) return; if (FindResults.Count == 0) return; SetWindowToLoading(); Regex? regex = BuildCurrentRegex(); @@ -363,6 +459,23 @@ private void EditTextBoxChanged(object sender, TextChangedEventArgs e) ChangeFindTextTimer.Start(); } + private void EditTextWindow_EditorModeChanged(object? sender, EventArgs e) + { + ChangeFindTextTimer.Stop(); + SearchForText(); + } + + private void RefreshSourceTextFromEditor() + { + StringFromWindow = ResolveSearchSourceText( + StringFromWindow, + textEditWindow?.PassedTextControl.Text, + IsSpreadsheetSearch); + } + + internal static string ResolveSearchSourceText(string cachedText, string? editorText, bool isSpreadsheetSearch) + => !isSpreadsheetSearch && editorText is not null ? editorText : cachedText; + private void ExtractPattern_CanExecute(object sender, CanExecuteRoutedEventArgs e) { if (IsSpreadsheetSearch) { e.CanExecute = false; return; } @@ -382,15 +495,15 @@ private void ExtractPattern_Executed(object sender, ExecutedRoutedEventArgs e) string? selection = textEditWindow.PassedTextControl.SelectedText; // Generate all precision levels from the selected text - // Use inverse of ExactMatchCheckBox: when exact match is OFF, ignore case - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + // Use inverse of the exact-match toggle: when exact match is OFF, ignore case + bool ignoreCase = !SearchBar.ExactMatch; extractedPattern = new ExtractedPattern(selection, ignoreCase); int precisionLevel = (int)PrecisionSlider.Value; string simplePattern = extractedPattern.GetPattern(precisionLevel); - UsePatternCheckBox.IsChecked = true; - FindTextBox.Text = simplePattern; + SearchBar.UseRegex = true; + SearchBar.SearchText = simplePattern; // Show the slider now that we have an extracted pattern PrecisionSliderPanel.Visibility = Visibility.Visible; @@ -400,13 +513,13 @@ private void ExtractPattern_Executed(object sender, ExecutedRoutedEventArgs e) private void FindAndReplacedLoaded(object sender, RoutedEventArgs e) { - if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (IsSmartPatternSearch || TextSearchUtilities.HasSearchText(SearchBar.SearchText)) SearchForText(); // Update save button visibility on load UpdateSaveButtonVisibility(); - FindTextBox.Focus(); + SearchBar.FocusInput(); } private void FindTextBox_KeyUp(object sender, KeyEventArgs e) @@ -446,9 +559,9 @@ private void MoreOptionsToggleButton_Click(object sender, RoutedEventArgs e) SetExtraOptionsVisibility(optionsVisibility); } - private void OptionsChangedRefresh(object sender, RoutedEventArgs e) + private void OptionsChangedRefresh(object? sender, EventArgs e) { - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + bool ignoreCase = !SearchBar.ExactMatch; // If we have an extracted pattern and the case sensitivity changed, update it if (extractedPattern is not null) @@ -459,13 +572,13 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) // Update the FindTextBox with the regenerated pattern int precisionLevel = (int)PrecisionSlider.Value; - FindTextBox.Text = extractedPattern.GetPattern(precisionLevel); + SearchBar.SearchText = extractedPattern.GetPattern(precisionLevel); } } - else if (UsePatternCheckBox.IsChecked is true && TextSearchUtilities.HasSearchText(FindTextBox.Text)) + else if (SearchBar.UseRegex && TextSearchUtilities.HasSearchText(SearchBar.SearchText)) { // No extracted pattern, but we're in pattern mode - manually toggle (?i) flag - string currentPattern = FindTextBox.Text; + string currentPattern = SearchBar.SearchText; bool hasIgnoreCaseFlag = currentPattern.StartsWith("(?i)"); bool hasCaseSensitiveFlag = currentPattern.StartsWith("(?-i)"); @@ -475,18 +588,18 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) if (hasCaseSensitiveFlag) { // Replace (?-i) with (?i) - FindTextBox.Text = "(?i)" + currentPattern[5..]; + SearchBar.SearchText = "(?i)" + currentPattern[5..]; } else { // Add (?i) at the beginning - FindTextBox.Text = $"(?i){currentPattern}"; + SearchBar.SearchText = $"(?i){currentPattern}"; } } else if (!ignoreCase && hasIgnoreCaseFlag) { // Need case-sensitive: remove (?i) flag - FindTextBox.Text = currentPattern[4..]; + SearchBar.SearchText = currentPattern[4..]; } } @@ -500,7 +613,9 @@ private void Replace_CanExecute(object sender, CanExecuteRoutedEventArgs e) { if (IsSpreadsheetSearch) { - e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(ReplaceTextBox.Text); + e.CanExecute = !IsSmartPatternSearch + && FindResults.Count > 0 + && !string.IsNullOrEmpty(ReplaceTextBox.Text); return; } @@ -519,6 +634,7 @@ private void Replace_Executed(object sender, ExecutedRoutedEventArgs e) if (IsSpreadsheetSearch) { + if (IsSmartPatternSearch) return; if (ResultsListView.SelectedIndex == -1) ResultsListView.SelectedIndex = 0; if (ResultsListView.SelectedItem is not FindResult fr) return; Regex? regex = BuildCurrentRegex(); @@ -548,6 +664,7 @@ private async void ReplaceAll_Executed(object sender, ExecutedRoutedEventArgs e) if (IsSpreadsheetSearch) { + if (IsSmartPatternSearch) return; if (FindResults.Count == 0) return; SetWindowToLoading(); Regex? regex = BuildCurrentRegex(); @@ -734,17 +851,14 @@ private void SetExtraOptionsVisibility(Visibility optionsVisibility) ReplaceTextBox.Visibility = optionsVisibility; ReplaceButton.Visibility = optionsVisibility; ReplaceAllButton.Visibility = optionsVisibility; - MoreOptionsHozStack.Visibility = optionsVisibility; - EvenMoreOptionsHozStack.Visibility = optionsVisibility; - PatternButtonsStack.Visibility = optionsVisibility; + BulkActionsGrid.Visibility = optionsVisibility; + MatchActionsGrid.Visibility = optionsVisibility; + PatternActionsGrid.Visibility = optionsVisibility; } private void TextSearch_CanExecute(object sender, CanExecuteRoutedEventArgs e) { - if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) - e.CanExecute = false; - else - e.CanExecute = true; + e.CanExecute = IsSmartPatternSearch || TextSearchUtilities.HasSearchText(SearchBar.SearchText); } private void TextSearch_Executed(object sender, ExecutedRoutedEventArgs e) @@ -756,14 +870,18 @@ private void Window_Closed(object? sender, EventArgs e) { ChangeFindTextTimer.Tick -= ChangeFindText_Tick; PrecisionSliderTimer.Tick -= PrecisionSlider_Tick; - textEditWindow?.PassedTextControl.TextChanged -= EditTextBoxChanged; + if (textEditWindow is not null) + { + textEditWindow.PassedTextControl.TextChanged -= EditTextBoxChanged; + textEditWindow.EditorModeChanged -= EditTextWindow_EditorModeChanged; + } } private void Window_KeyUp(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { - if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) - FindTextBox.Clear(); + if (TextSearchUtilities.HasSearchText(SearchBar.SearchText)) + SearchBar.SearchText = string.Empty; else this.Close(); } @@ -780,7 +898,7 @@ private void PrecisionSlider_ValueChanged(object sender, RoutedPropertyChangedEv return; // Only update if regex mode is enabled - if (UsePatternCheckBox?.IsChecked is not true) + if (!SearchBar.UseRegex) return; int precisionLevel = (int)e.NewValue; @@ -788,7 +906,7 @@ private void PrecisionSlider_ValueChanged(object sender, RoutedPropertyChangedEv // Get the pre-generated pattern at this precision level (instant, no recalculation!) string pattern = extractedPattern.GetPattern(precisionLevel); - FindTextBox.Text = pattern; + SearchBar.SearchText = pattern; // Use debounced search instead of immediate search PrecisionSliderTimer.Stop(); @@ -805,7 +923,7 @@ private void ManageRegexButton_Click(object sender, RoutedEventArgs e) private void SavePatternButton_Click(object sender, RoutedEventArgs e) { // Get the current pattern from the FindTextBox - string pattern = FindTextBox.Text; + string pattern = SearchBar.SearchText; if (string.IsNullOrWhiteSpace(pattern)) return; @@ -827,10 +945,19 @@ private void SavePatternButton_Click(object sender, RoutedEventArgs e) regexManager.AddPatternFromText(pattern, sourceText, textEditWindow); } - private void UsePatternCheckBox_CheckedChanged(object sender, RoutedEventArgs e) + /// + /// Re-runs the search (debounced) whenever the shared search bar's text, regex/exact toggles, + /// or selected pattern change. Keyboard specifics (Enter, clearing an extracted pattern) are + /// handled in . + /// + private void SearchBar_SearchChanged(object? sender, EventArgs e) { - // Update save button visibility when regex mode is toggled + if (!IsLoaded) + return; + UpdateSaveButtonVisibility(); + ChangeFindTextTimer.Stop(); + ChangeFindTextTimer.Start(); } private void UpdateSaveButtonVisibility() @@ -840,9 +967,9 @@ private void UpdateSaveButtonVisibility() // 2. Find text is not empty // 3. Pattern doesn't already exist in saved patterns SavePatternButton.Visibility = - (UsePatternCheckBox.IsChecked is true && - !string.IsNullOrWhiteSpace(FindTextBox.Text) && - !IsPatternAlreadySaved(FindTextBox.Text)) + (SearchBar.UseRegex && + !string.IsNullOrWhiteSpace(SearchBar.SearchText) && + !IsPatternAlreadySaved(SearchBar.SearchText)) ? Visibility.Visible : Visibility.Collapsed; } @@ -866,7 +993,7 @@ internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null extractedPattern = pattern; // Ensure the pattern's case sensitivity matches the current checkbox state - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + bool ignoreCase = !SearchBar.ExactMatch; extractedPattern.IgnoreCase = ignoreCase; // If a precision level was provided, use it; otherwise use the current slider value @@ -875,9 +1002,9 @@ internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null // Update the slider to reflect the precision level being used PrecisionSlider.Value = levelToUse; - FindTextBox.Text = pattern.GetPattern(levelToUse); + SearchBar.SearchText = pattern.GetPattern(levelToUse); - UsePatternCheckBox.IsChecked = true; + SearchBar.UseRegex = true; // Show the slider now that we have an extracted pattern PrecisionSliderPanel.Visibility = Visibility.Visible; diff --git a/Text-Grab/Controls/InlinePickerItem.cs b/Text-Grab/Controls/InlinePickerItem.cs index da922a71..18b97340 100644 --- a/Text-Grab/Controls/InlinePickerItem.cs +++ b/Text-Grab/Controls/InlinePickerItem.cs @@ -1,3 +1,5 @@ +using Text_Grab.Models; + namespace Text_Grab.Controls; public class InlinePickerItem @@ -7,10 +9,17 @@ public class InlinePickerItem /// /// Optional group label used to render section headers in the picker popup - /// (e.g. "Regions", "Patterns"). + /// (e.g. "Regions", "Saved Patterns", "Smart Patterns"). /// public string Group { get; set; } = string.Empty; + /// + /// For pattern items, which engine backs this entry — drives whether selection emits a + /// {p:} (saved regex) or {r:} (recognizer) placeholder. Null for non-pattern + /// items such as region placeholders. + /// + public PatternKind? Kind { get; set; } + public InlinePickerItem() { } public InlinePickerItem(string displayName, string value, string group = "") diff --git a/Text-Grab/Controls/InlinePickerRichTextBox.cs b/Text-Grab/Controls/InlinePickerRichTextBox.cs index 0957745d..6240c61f 100644 --- a/Text-Grab/Controls/InlinePickerRichTextBox.cs +++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs @@ -10,6 +10,7 @@ using System.Windows.Media; using System.Windows.Media.Effects; using Text_Grab.Models; +using Text_Grab.Utilities; namespace Text_Grab.Controls; @@ -79,6 +80,13 @@ public string SerializedText /// public Func? PatternItemSelected { get; set; } + /// + /// Called when a recognizer-group item is selected. The handler should show the + /// match-mode dialog and return the configured , + /// or null to cancel. + /// + public Func? RecognizerItemSelected { get; set; } + static InlinePickerRichTextBox() { DefaultStyleKeyProperty.OverrideMetadata( @@ -474,10 +482,13 @@ private void CommitSelection() // which fires OnLostKeyboardFocus and nulls _triggerStart) TextPointer savedTriggerStart = _triggerStart; - // For pattern items, invoke the dialog callback to configure match mode - bool isPatternItem = string.Equals(selectedItem.Group, "Patterns", StringComparison.OrdinalIgnoreCase); + // Pattern items carry a Kind that decides which dialog/placeholder to use. + // (Group is only a display label now — "Saved Patterns" vs "Smart Patterns".) + bool isPatternItem = selectedItem.Kind == PatternKind.SavedRegex; InlinePickerItem itemToInsert = selectedItem; + bool isRecognizerItem = selectedItem.Kind == PatternKind.Recognizer; + if (isPatternItem && PatternItemSelected != null) { HidePopup(); @@ -494,6 +505,21 @@ private void CommitSelection() string displayLabel = $"{patternConfig.PatternName} ({patternConfig.MatchMode})"; itemToInsert = new InlinePickerItem(displayLabel, placeholderValue, selectedItem.Group); } + else if (isRecognizerItem && RecognizerItemSelected != null) + { + HidePopup(); + + TemplateRecognizerMatch? recognizerConfig = RecognizerItemSelected(selectedItem); + if (recognizerConfig == null) + { + _triggerStart = null; + return; // user cancelled + } + + string placeholderValue = BuildRecognizerPlaceholder(recognizerConfig); + string displayLabel = $"{recognizerConfig.RecognizerName} ({recognizerConfig.MatchMode})"; + itemToInsert = new InlinePickerItem(displayLabel, placeholderValue, selectedItem.Group); + } _isModifyingDocument = true; try @@ -545,6 +571,29 @@ private static string BuildPatternPlaceholder(TemplatePatternMatch config) return $"{{p:{config.PatternName}:{mode}}}"; } + private static string BuildRecognizerPlaceholder(TemplateRecognizerMatch config) + { + string mode = config.MatchMode; + + bool needsSeparator = (mode == "all" || (mode.Contains(',') && mode.Split(',').Length > 1)) + && config.Separator != ", "; + bool isText = config.OutputKind == RecognizerOutputKind.MatchedText; + + string result = $"{{r:{config.RecognizerName}:{mode}"; + + // Emit the output token when non-default, or when a separator follows it + // (so the separator is not misparsed as the output token). + if (isText) + result += ":text"; + else if (needsSeparator) + result += ":value"; + + if (needsSeparator) + result += $":{config.Separator}"; + + return result + "}"; + } + private void Chip_RemoveRequested(object? sender, EventArgs e) { if (sender is not InlineChipElement chip) diff --git a/Text-Grab/Controls/LanguagePicker.xaml.cs b/Text-Grab/Controls/LanguagePicker.xaml.cs index 1edc9075..2f9b1da2 100644 --- a/Text-Grab/Controls/LanguagePicker.xaml.cs +++ b/Text-Grab/Controls/LanguagePicker.xaml.cs @@ -44,14 +44,14 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) // it needs to represent real languages and not just OCR engine target languages // As new models are supported they will need to be caught and filtered here too - if (currentSelectedLanguage is UiAutomationLang or WindowsAiLang) + if (currentSelectedLanguage is UiAutomationLang or WindowsAiLang or WindowsAiDescriptionLang) currentSelectedLanguage = new GlobalLang(keyboardLanguage.Name); int selectedIndex = 0; int i = 0; foreach (ILanguage langFromUtil in LanguageUtilities.GetAllLanguages()) { - if (langFromUtil is UiAutomationLang or WindowsAiLang) + if (langFromUtil is UiAutomationLang or WindowsAiLang or WindowsAiDescriptionLang) continue; Languages.Add(langFromUtil); diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 2160ee93..035b7613 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -6,6 +6,7 @@ using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; +using System.Windows.Threading; using Text_Grab.Models; using Text_Grab.Properties; using Text_Grab.Services; @@ -51,7 +52,13 @@ private void Exit_Click(object sender, RoutedEventArgs e) private void NotifyIcon_LeftClick(NotifyIcon sender, RoutedEventArgs e) { e.Handled = true; - App.DefaultLaunch(); + RunAfterTrayIconInteraction(App.DefaultLaunch); + } + + private void RunAfterTrayIconInteraction(Action action) + { + // Let the shell's tray interaction release foreground ownership first. + Dispatcher.BeginInvoke(action, DispatcherPriority.Background); } private void Window_Loaded(object sender, RoutedEventArgs e) @@ -105,7 +112,7 @@ private void GrabFrameMenuItem_Click(object sender, RoutedEventArgs e) private void FullscreenGrabMenuItem_Click(object sender, RoutedEventArgs e) { - WindowUtilities.LaunchFullScreenGrab(); + RunAfterTrayIconInteraction(() => WindowUtilities.LaunchFullScreenGrab()); } private async void PreviousRegionMenuItem_Click(object sender, RoutedEventArgs e) diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml b/Text-Grab/Controls/PatternMatchModeDialog.xaml index 8dc28a7d..5194543e 100644 --- a/Text-Grab/Controls/PatternMatchModeDialog.xaml +++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml @@ -24,6 +24,7 @@ + + + + + + -/// Dialog shown after the user selects a regex pattern from the inline picker. -/// Lets them choose match mode (first, last, all, specific indices) and separator. +/// Dialog shown after the user selects a regex pattern (or recognizer) from the inline picker. +/// Lets them choose match mode (first, last, all, specific indices) and separator, and — for +/// recognizers — whether to emit the resolved value or the matched text. /// public partial class PatternMatchModeDialog : FluentWindow { @@ -18,6 +20,9 @@ public partial class PatternMatchModeDialog : FluentWindow /// public TemplatePatternMatch? Result { get; private set; } + /// The chosen output kind (only meaningful for recognizers). + public RecognizerOutputKind SelectedOutputKind { get; private set; } = RecognizerOutputKind.ResolvedValue; + private readonly string _patternId; private readonly string _patternName; @@ -29,6 +34,22 @@ public PatternMatchModeDialog(string patternId, string patternName) PatternNameLabel.Text = $"Pattern: {patternName}"; } + /// + /// Creates the dialog for a recognizer, optionally showing the resolved-value / + /// matched-text output selector. + /// + public PatternMatchModeDialog(string recognizerId, string recognizerName, bool isRecognizer) + : this(recognizerId, recognizerName) + { + if (!isRecognizer) + return; + + Title = "Pattern Match Options"; + DialogTitleBar.Title = "Pattern Match Options"; + PatternNameLabel.Text = $"Pattern: {recognizerName}"; + OutputPanel.Visibility = Visibility.Visible; + } + private void MatchModeRadioButton_Checked(object sender, RoutedEventArgs e) { if (SeparatorPanel == null || IndicesPanel == null) @@ -114,6 +135,10 @@ private void OkButton_Click(object sender, RoutedEventArgs e) mode = IndicesTextBox.Text.Trim(); } + SelectedOutputKind = OutputValueRadio.IsChecked is false + ? RecognizerOutputKind.MatchedText + : RecognizerOutputKind.ResolvedValue; + Result = new TemplatePatternMatch(_patternId, _patternName, mode, separator); DialogResult = true; Close(); diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml index eb639b8f..3c5d0095 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -78,17 +78,8 @@ ScrollViewer.VerticalScrollBarVisibility="Visible"> - - - - - - - - - @@ -197,17 +188,8 @@ ScrollViewer.VerticalScrollBarVisibility="Visible"> - - - - - - - - - diff --git a/Text-Grab/Controls/PreviousGrabWindow.xaml b/Text-Grab/Controls/PreviousGrabWindow.xaml index ffc35b7e..84352cdc 100644 --- a/Text-Grab/Controls/PreviousGrabWindow.xaml +++ b/Text-Grab/Controls/PreviousGrabWindow.xaml @@ -21,6 +21,10 @@ WindowStyle="None" mc:Ignorable="d"> + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs index 9181206a..e9703c90 100644 --- a/Text-Grab/Controls/PreviousGrabWindow.xaml.cs +++ b/Text-Grab/Controls/PreviousGrabWindow.xaml.cs @@ -1,5 +1,8 @@ using System; +using System.Threading.Tasks; using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Animation; using System.Windows.Threading; namespace Text_Grab.Controls; @@ -19,14 +22,34 @@ public enum PreviousGrabIndicator Loading, } +/// +/// The action a user picked from the overlay while a long-running grab was in progress or after it failed. +/// +public enum GrabChoice +{ + /// No choice was made. + None, + + /// Abandon the grab entirely. + Cancel, + + /// Cancel the current grab and return to selecting a new region. + ReGrab, + + /// Open a Grab Frame using the originally captured region image. + SendToGrabFrame, +} + /// /// Interaction logic for PreviousGrabWindow.xaml /// public partial class PreviousGrabWindow : Window { private static readonly TimeSpan flashDuration = TimeSpan.FromMilliseconds(300); + private static readonly Duration choiceBarSlideDuration = new(TimeSpan.FromMilliseconds(250)); + private const double choiceBarSlideDistance = 48; - public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousGrabIndicator.None) + public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousGrabIndicator.None, ImageSource? regionBackground = null) { InitializeComponent(); @@ -37,6 +60,15 @@ public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousG Left = rect.Left - borderThickness; Top = rect.Top - borderThickness; + // When supplied, freeze a snapshot of the selected region as the overlay's + // background so the user's selection stays visible even if the UI behind it + // changes while a long-running grab (e.g. Windows AI description) is working. + if (regionBackground is not null) + { + RegionBackgroundImage.Source = regionBackground; + RegionBackgroundImage.Visibility = Visibility.Visible; + } + switch (indicator) { case PreviousGrabIndicator.Success: @@ -53,16 +85,109 @@ public PreviousGrabWindow(Rect rect, PreviousGrabIndicator indicator = PreviousG } } + /// + /// Raised when the user picks an action from the overlay's choice bar. + /// + public event EventHandler? ChoiceSelected; + + /// + /// Raised when the user flips the "send to Edit Text Window" toggle in the choice bar. + /// + public event EventHandler? SendToEditTextToggled; + /// /// Swaps the loading spinner for the success checkmark, then closes shortly after. /// public void ShowSuccess() { + HideChoiceBar(); LoadingViewbox.Visibility = Visibility.Collapsed; SuccessViewbox.Visibility = Visibility.Visible; CloseAfterDelay(); } + /// + /// Shows the success checkmark and completes once this window has closed, so callers + /// can sequence work (like inserting text into another app) after the overlay has + /// released any focus it took while its choice bar was shown. + /// + public Task ShowSuccessAsync() + { + TaskCompletionSource closedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + Closed += (_, _) => closedSource.TrySetResult(); + ShowSuccess(); + return closedSource.Task; + } + + /// + /// Shows Cancel / Re-grab and the send-to-Edit-Window toggle while the grab is still + /// running, keeping the spinner visible, so the user can adjust where the result goes. + /// + public void ShowRunningChoices(bool sendToEditTextChecked) + { + LoadingViewbox.Visibility = Visibility.Visible; + SendToGrabFrameButton.Visibility = Visibility.Collapsed; + SendToEtwToggleButton.IsChecked = sendToEditTextChecked; + SendToEtwToggleButton.Visibility = Visibility.Visible; + ShowChoiceBar(); + } + + /// + /// Shows Cancel / Re-grab / Send to Grab Frame after the grab finished empty or failed. + /// + public void ShowFailedChoices() + { + LoadingViewbox.Visibility = Visibility.Collapsed; + SendToGrabFrameButton.Visibility = Visibility.Visible; + + // No text was produced, so there is nothing to send to an Edit Text Window. + SendToEtwToggleButton.Visibility = Visibility.Collapsed; + ShowChoiceBar(); + } + + private void ShowChoiceBar() + { + ChoiceBar.IsEnabled = true; + + // Animate only on the first reveal, not when the choices change while already visible. + if (ChoiceBar.Visibility != Visibility.Visible) + { + ChoiceBar.Visibility = Visibility.Visible; + + CubicEase easeOut = new() { EasingMode = EasingMode.EaseOut }; + DoubleAnimation slideUp = new(choiceBarSlideDistance, 0, choiceBarSlideDuration) { EasingFunction = easeOut }; + DoubleAnimation fadeIn = new(0, 1, choiceBarSlideDuration) { EasingFunction = easeOut }; + ChoiceBarSlide.BeginAnimation(TranslateTransform.YProperty, slideUp); + ChoiceBar.BeginAnimation(OpacityProperty, fadeIn); + } + + // The overlay is created non-interactive; enable hit testing so the buttons respond. + IsHitTestVisible = true; + Activate(); + } + + private void HideChoiceBar() + { + ChoiceBar.Visibility = Visibility.Collapsed; + IsHitTestVisible = false; + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) => RaiseChoice(GrabChoice.Cancel); + + private void ReGrabButton_Click(object sender, RoutedEventArgs e) => RaiseChoice(GrabChoice.ReGrab); + + private void SendToGrabFrameButton_Click(object sender, RoutedEventArgs e) => RaiseChoice(GrabChoice.SendToGrabFrame); + + private void SendToEtwToggleButton_Click(object sender, RoutedEventArgs e) + => SendToEditTextToggled?.Invoke(this, SendToEtwToggleButton.IsChecked is true); + + private void RaiseChoice(GrabChoice choice) + { + // Prevent a second click from racing another choice while the caller reacts. + ChoiceBar.IsEnabled = false; + ChoiceSelected?.Invoke(this, choice); + } + private void CloseAfterDelay() { DispatcherTimer timer = new() diff --git a/Text-Grab/Controls/RegExIcon.xaml b/Text-Grab/Controls/RegExIcon.xaml index 879064c2..90c6d5ff 100644 --- a/Text-Grab/Controls/RegExIcon.xaml +++ b/Text-Grab/Controls/RegExIcon.xaml @@ -9,6 +9,6 @@ d:DesignWidth="222" mc:Ignorable="d"> - + diff --git a/Text-Grab/Controls/RegExIcon.xaml.cs b/Text-Grab/Controls/RegExIcon.xaml.cs index bf96933c..dff59039 100644 --- a/Text-Grab/Controls/RegExIcon.xaml.cs +++ b/Text-Grab/Controls/RegExIcon.xaml.cs @@ -1,20 +1,9 @@ -using System.Windows; using System.Windows.Controls; -using System.Windows.Media; namespace Text_Grab.Controls; public partial class RegExIcon : UserControl { - public SolidColorBrush IconColor - { - get { return (SolidColorBrush)GetValue(IconColorProperty); } - set { SetValue(IconColorProperty, value); } - } - - public static readonly DependencyProperty IconColorProperty = - DependencyProperty.Register("IconColor", typeof(SolidColorBrush), typeof(RegExIcon), new PropertyMetadata(null)); - public RegExIcon() { DataContext = this; diff --git a/Text-Grab/Controls/RegexManager.xaml b/Text-Grab/Controls/RegexManager.xaml index 028b2a8e..2d21f3aa 100644 --- a/Text-Grab/Controls/RegexManager.xaml +++ b/Text-Grab/Controls/RegexManager.xaml @@ -6,7 +6,7 @@ xmlns:local="clr-namespace:Text_Grab.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - Title="Regular Expression Manager" + Title="Patterns Manager" Width="900" Height="600" MinWidth="700" @@ -28,7 +28,7 @@ + SelectionMode="Single" + SelectionUnit="FullRow"> + + + + Header="Name" /> + Binding="{Binding PatternDisplay}" + Header="Pattern" /> - + Header="Description" /> @@ -85,7 +90,7 @@ Padding="10,6" Click="ShowTestToggle_Click" Content="Show Test" - IsChecked="True" + IsChecked="False" Style="{DynamicResource ToggleSymbolButton}" /> + RegexPatterns { get; set; } = []; + private ObservableCollection DisplayedPatterns { get; set; } = []; + private HashSet HiddenRecognizerIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); public RegexManager() { @@ -23,7 +27,11 @@ public RegexManager() private void Window_Loaded(object sender, RoutedEventArgs e) { LoadRegexPatterns(); - RegexDataGrid.ItemsSource = RegexPatterns; + HiddenRecognizerIds = [.. AppUtilities.TextGrabSettingsService.LoadHiddenSmartPatternIds()]; + RebuildDisplayedPatterns(); + + RegexDataGrid.ItemsSource = DisplayedPatterns; + RegexDataGrid.Items.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PatternItem.GroupLabel))); } private void LoadRegexPatterns() @@ -48,17 +56,56 @@ private void SaveRegexPatterns() AppUtilities.TextGrabSettingsService.SaveStoredRegexes(RegexPatterns); } + private void SaveHiddenRecognizerIds() + { + AppUtilities.TextGrabSettingsService.SaveHiddenSmartPatternIds(HiddenRecognizerIds); + } + + /// Rebuilds the combined saved-regex + recognizer list shown in the grid. Does not touch selection. + private void RebuildDisplayedPatterns() + { + DisplayedPatterns.Clear(); + foreach (StoredRegex regex in RegexPatterns) + DisplayedPatterns.Add(new PatternItem(regex)); + foreach (BuiltInRecognizer recognizer in BuiltInRecognizer.GetAll()) + DisplayedPatterns.Add(new PatternItem(recognizer, isHidden: HiddenRecognizerIds.Contains(recognizer.Id))); + } + + private void SelectPatternById(string id) + { + RegexDataGrid.SelectedItem = DisplayedPatterns.FirstOrDefault(p => p.Id == id); + } + private void RegexDataGrid_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { - bool hasSelection = RegexDataGrid.SelectedItem is not null; + PatternItem? selected = RegexDataGrid.SelectedItem as PatternItem; + bool isSavedRegex = selected?.Kind == PatternKind.SavedRegex; + bool isRecognizer = selected?.Kind == PatternKind.Recognizer; + + EditButton.IsEnabled = isSavedRegex; + UseButton.IsEnabled = isSavedRegex; + ExplainButton.IsEnabled = selected is not null; + + DeleteButton.Visibility = isRecognizer ? Visibility.Collapsed : Visibility.Visible; + DeleteButton.IsEnabled = isSavedRegex; - EditButton.IsEnabled = hasSelection; - DeleteButton.IsEnabled = hasSelection; - ExplainButton.IsEnabled = hasSelection; - UseButton.IsEnabled = hasSelection; + HideButton.Visibility = isRecognizer ? Visibility.Visible : Visibility.Collapsed; + HideButton.IsEnabled = isRecognizer; + if (isRecognizer && selected is not null) + { + if (selected.IsHidden) + { + HideButton.Content = "Unhide"; + HideButton.Icon = new SymbolIcon(SymbolRegular.Eye24); + } + else + { + HideButton.Content = "Hide"; + HideButton.Icon = new SymbolIcon(SymbolRegular.EyeOff24); + } + } - if (hasSelection) - TestPattern(); + TestPattern(); } private void AddButton_Click(object sender, RoutedEventArgs e) @@ -72,13 +119,14 @@ private void AddButton_Click(object sender, RoutedEventArgs e) { RegexPatterns.Add(dialog.EditedRegex); SaveRegexPatterns(); - RegexDataGrid.SelectedItem = dialog.EditedRegex; + RebuildDisplayedPatterns(); + SelectPatternById(dialog.EditedRegex.Id); } } private void EditButton_Click(object sender, RoutedEventArgs e) { - if (RegexDataGrid.SelectedItem is not StoredRegex selectedRegex) + if (RegexDataGrid.SelectedItem is not PatternItem { Kind: PatternKind.SavedRegex, SavedRegex: StoredRegex selectedRegex }) return; RegexEditorDialog dialog = new(selectedRegex) @@ -93,14 +141,15 @@ private void EditButton_Click(object sender, RoutedEventArgs e) { RegexPatterns[index] = dialog.EditedRegex; SaveRegexPatterns(); - RegexDataGrid.Items.Refresh(); + RebuildDisplayedPatterns(); + SelectPatternById(dialog.EditedRegex.Id); } } } private void DeleteButton_Click(object sender, RoutedEventArgs e) { - if (RegexDataGrid.SelectedItem is not StoredRegex selectedRegex) + if (RegexDataGrid.SelectedItem is not PatternItem { Kind: PatternKind.SavedRegex, SavedRegex: StoredRegex selectedRegex }) return; Wpf.Ui.Controls.MessageBoxResult result = new Wpf.Ui.Controls.MessageBox @@ -115,12 +164,28 @@ private void DeleteButton_Click(object sender, RoutedEventArgs e) { RegexPatterns.Remove(selectedRegex); SaveRegexPatterns(); + RebuildDisplayedPatterns(); } } + private void HideButton_Click(object sender, RoutedEventArgs e) + { + if (RegexDataGrid.SelectedItem is not PatternItem { Kind: PatternKind.Recognizer } selected) + return; + + if (selected.IsHidden) + HiddenRecognizerIds.Remove(selected.Id); + else + HiddenRecognizerIds.Add(selected.Id); + + SaveHiddenRecognizerIds(); + RebuildDisplayedPatterns(); + SelectPatternById(selected.Id); + } + private void UseButton_Click(object sender, RoutedEventArgs e) { - if (RegexDataGrid.SelectedItem is not StoredRegex selectedRegex) + if (RegexDataGrid.SelectedItem is not PatternItem { Kind: PatternKind.SavedRegex, SavedRegex: StoredRegex selectedRegex }) return; // Update last used date @@ -130,18 +195,17 @@ private void UseButton_Click(object sender, RoutedEventArgs e) // Open Find and Replace window with this pattern FindAndReplaceWindow findWindow = WindowUtilities.OpenOrActivateWindow(); findWindow.TextEditWindow ??= SourceEditTextWindow; - findWindow.FindTextBox.Text = selectedRegex.Pattern; - findWindow.UsePatternCheckBox.IsChecked = true; + findWindow.SetFindText(selectedRegex.Pattern, useRegex: true); findWindow.Show(); findWindow.Activate(); findWindow.SearchForText(); - // Close the Regex Manager after opening Find and Replace + // Close the Patterns Manager after opening Find and Replace Close(); } /// - /// Opens the Regex Manager in "add mode" with a pre-filled pattern + /// Opens the Patterns Manager in "add mode" with a pre-filled pattern /// public void AddPatternFromText(string pattern, string sourceText, EditTextWindow? source = null) { @@ -159,7 +223,8 @@ public void AddPatternFromText(string pattern, string sourceText, EditTextWindow { RegexPatterns.Add(dialog.EditedRegex); SaveRegexPatterns(); - RegexDataGrid.SelectedItem = dialog.EditedRegex; + RebuildDisplayedPatterns(); + SelectPatternById(dialog.EditedRegex.Id); } } @@ -179,7 +244,7 @@ private void TestPattern() if (!IsLoaded) return; - if (RegexDataGrid.SelectedItem is not StoredRegex selectedRegex) + if (RegexDataGrid.SelectedItem is not PatternItem selected) { MatchCountText.Text = "0"; return; @@ -192,23 +257,39 @@ private void TestPattern() return; } + if (selected.Kind == PatternKind.SavedRegex && !IsValidRegexPattern(selected.SavedRegex?.Pattern)) + { + MatchCountText.Text = "Invalid Pattern"; + return; + } + + MatchCountText.Text = PatternExecutor.GetMatches(selected, testText).Count.ToString(); + } + + private static bool IsValidRegexPattern(string? pattern) + { + if (string.IsNullOrEmpty(pattern)) + return true; + try { - MatchCollection matches = Regex.Matches(testText, selectedRegex.Pattern, RegexOptions.Multiline); - MatchCountText.Text = matches.Count.ToString(); + _ = new System.Text.RegularExpressions.Regex(pattern); + return true; } catch (ArgumentException) { - MatchCountText.Text = "Invalid Pattern"; + return false; } } private void ExplainButton_Click(object sender, RoutedEventArgs e) { - if (RegexDataGrid.SelectedItem is not StoredRegex selectedRegex) + if (RegexDataGrid.SelectedItem is not PatternItem selected) return; - string explanation = StringMethods.ExplainRegexPattern(selectedRegex.Pattern); + string explanation = selected.Kind == PatternKind.SavedRegex && selected.SavedRegex is not null + ? StringMethods.ExplainRegexPattern(selected.SavedRegex.Pattern) + : selected.Description; Wpf.Ui.Controls.MessageBox messageBox = new() { diff --git a/Text-Grab/Controls/SearchBar.xaml b/Text-Grab/Controls/SearchBar.xaml new file mode 100644 index 00000000..85f9e7b0 --- /dev/null +++ b/Text-Grab/Controls/SearchBar.xaml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/SearchBar.xaml.cs b/Text-Grab/Controls/SearchBar.xaml.cs new file mode 100644 index 00000000..020d4b9a --- /dev/null +++ b/Text-Grab/Controls/SearchBar.xaml.cs @@ -0,0 +1,290 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using Text_Grab.Models; + +namespace Text_Grab.Controls; + +/// +/// A shared search input used by Quick Simple Lookup, Find & Replace, and Grab Frame. It bundles +/// the free-text box, a regex icon toggle (and optional exact-match toggle), a removable +/// smart-pattern "chip", and the unified picker so all three search +/// surfaces look and behave the same. Each host keeps its own search/filter engine and debounce — +/// this control only owns the inputs and raises when any of them change. +/// +public partial class SearchBar : UserControl +{ + private const string RegexToolTip = "Search using Regular Expression syntax"; + + /// Suppresses while several inputs are updated as one action. + private bool suppressSearchChanged; + + public SearchBar() + { + InitializeComponent(); + UpdateAdornments(); + } + + /// Raised whenever the search text, regex/exact toggles, or selected pattern change. + public event EventHandler? SearchChanged; + + /// Raised only when the exact-match toggle changes (hosts that adjust case handling subscribe to this). + public event EventHandler? ExactMatchChanged; + + #region Dependency properties + + public string SearchText + { + get => (string)GetValue(SearchTextProperty); + set => SetValue(SearchTextProperty, value); + } + + public static readonly DependencyProperty SearchTextProperty = + DependencyProperty.Register(nameof(SearchText), typeof(string), typeof(SearchBar), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSearchTextChanged)); + + public bool UseRegex + { + get => (bool)GetValue(UseRegexProperty); + set => SetValue(UseRegexProperty, value); + } + + public static readonly DependencyProperty UseRegexProperty = + DependencyProperty.Register(nameof(UseRegex), typeof(bool), typeof(SearchBar), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnUseRegexChanged)); + + public bool ExactMatch + { + get => (bool)GetValue(ExactMatchProperty); + set => SetValue(ExactMatchProperty, value); + } + + public static readonly DependencyProperty ExactMatchProperty = + DependencyProperty.Register(nameof(ExactMatch), typeof(bool), typeof(SearchBar), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnExactMatchChanged)); + + /// When true the exact-match toggle is visible (used by Grab Frame). Hidden by default. + public bool ShowExactMatchToggle + { + get => (bool)GetValue(ShowExactMatchToggleProperty); + set => SetValue(ShowExactMatchToggleProperty, value); + } + + public static readonly DependencyProperty ShowExactMatchToggleProperty = + DependencyProperty.Register(nameof(ShowExactMatchToggle), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + /// The active recognizer shown as a chip, or null. Saved regexes do not set this (they load into the text box). + public PatternItem? SelectedPattern + { + get => (PatternItem?)GetValue(SelectedPatternProperty); + set => SetValue(SelectedPatternProperty, value); + } + + public static readonly DependencyProperty SelectedPatternProperty = + DependencyProperty.Register(nameof(SelectedPattern), typeof(PatternItem), typeof(SearchBar), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedPatternChanged)); + + public string PlaceholderText + { + get => (string)GetValue(PlaceholderTextProperty); + set => SetValue(PlaceholderTextProperty, value); + } + + public static readonly DependencyProperty PlaceholderTextProperty = + DependencyProperty.Register(nameof(PlaceholderText), typeof(string), typeof(SearchBar), + new PropertyMetadata("Type to search...")); + + public bool AcceptsReturn + { + get => (bool)GetValue(AcceptsReturnProperty); + set => SetValue(AcceptsReturnProperty, value); + } + + public static readonly DependencyProperty AcceptsReturnProperty = + DependencyProperty.Register(nameof(AcceptsReturn), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + public bool AcceptsTab + { + get => (bool)GetValue(AcceptsTabProperty); + set => SetValue(AcceptsTabProperty, value); + } + + public static readonly DependencyProperty AcceptsTabProperty = + DependencyProperty.Register(nameof(AcceptsTab), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + #endregion Dependency properties + + #region Public API + + /// Colors the split-button border red on an invalid pattern and sets a matching tooltip. + public void SetRegexValidity(bool isValid, string? toolTip = null) + { + if (isValid) + RegexSplitContainer.ClearValue(Border.BorderBrushProperty); // let the style/checked trigger drive the border + else + RegexSplitContainer.BorderBrush = Brushes.Red; + + RegExToggleButton.ToolTip = toolTip ?? (isValid ? RegexToolTip : "Invalid Regular Expression"); + } + + /// The underlying text box, for hosts that need the control directly (focus, OCR target, caret, etc.). + public TextBox TextBox => InnerTextBox; + + /// Focuses the text box and places the caret at the end. + public void FocusInput() + { + InnerTextBox.Focus(); + InnerTextBox.CaretIndex = InnerTextBox.Text.Length; + } + + #endregion Public API + + #region Change handlers + + private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.UpdateAdornments(); + bar.RaiseSearchChanged(); + } + + private static void OnUseRegexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.SetRegexValidity(true); + bar.RaiseSearchChanged(); + } + + private static void OnExactMatchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.ExactMatchChanged?.Invoke(bar, EventArgs.Empty); + bar.RaiseSearchChanged(); + } + + private static void OnSelectedPatternChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.UpdateChip(); + bar.UpdateAdornments(); + bar.RaiseSearchChanged(); + } + + private void RaiseSearchChanged() + { + if (!suppressSearchChanged) + SearchChanged?.Invoke(this, EventArgs.Empty); + } + + private void UpdateChip() + { + if (PatternChip is null) + return; + + if (SelectedPattern is not null) + { + PatternChipText.Text = SelectedPattern.Name; + PatternChip.Visibility = Visibility.Visible; + } + else + { + PatternChip.Visibility = Visibility.Collapsed; + } + } + + private void UpdateAdornments() + { + if (ClearButton is null) + return; + + ClearButton.Visibility = string.IsNullOrEmpty(SearchText) ? Visibility.Collapsed : Visibility.Visible; + PlaceholderTextBlock.Visibility = string.IsNullOrEmpty(SearchText) && SelectedPattern is null + ? Visibility.Visible + : Visibility.Collapsed; + } + + private void PatternDropDownButton_Click(object sender, RoutedEventArgs e) + { + PatternMenu.PlacementTarget = PatternDropDownButton; + PatternMenu.Placement = PlacementMode.Bottom; + PatternMenu.IsOpen = true; + } + + // Rebuild on each open so newly saved regexes appear. Headers are non-selectable. + private void PatternMenu_Opened(object sender, RoutedEventArgs e) + { + PatternMenu.Items.Clear(); + + string? currentGroup = null; + foreach (PatternItem pattern in PatternItem.GetAll()) + { + if (pattern.GroupLabel != currentGroup) + { + currentGroup = pattern.GroupLabel; + if (PatternMenu.Items.Count > 0) + PatternMenu.Items.Add(new Separator()); + PatternMenu.Items.Add(new MenuItem { Header = currentGroup, IsEnabled = false }); + } + + MenuItem item = new() + { + Header = pattern.Name, + ToolTip = string.IsNullOrWhiteSpace(pattern.Description) ? null : pattern.Description, + Tag = pattern, + }; + item.Click += PatternMenuItem_Click; + PatternMenu.Items.Add(item); + } + } + + private void PatternMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem { Tag: PatternItem pattern }) + ApplyPickedPattern(pattern); + } + + /// + /// Applies a pattern chosen from the dropdown: a saved regex loads into the text box and turns + /// on the regex toggle; a smart pattern (recognizer) becomes a chip with typing allowed to narrow. + /// + private void ApplyPickedPattern(PatternItem pattern) + { + suppressSearchChanged = true; + + if (pattern is { Kind: PatternKind.SavedRegex, SavedRegex: { } savedRegex }) + { + SelectedPattern = null; + SearchText = savedRegex.Pattern; + UseRegex = true; + } + else + { + SearchText = string.Empty; + SelectedPattern = pattern; + } + + suppressSearchChanged = false; + + RaiseSearchChanged(); + FocusInput(); + } + + private void ChipClearButton_Click(object sender, RoutedEventArgs e) + { + SelectedPattern = null; + FocusInput(); + } + + private void ClearButton_Click(object sender, RoutedEventArgs e) + { + SearchText = string.Empty; + FocusInput(); + } + + #endregion Change handlers +} diff --git a/Text-Grab/Controls/SettingsCard.cs b/Text-Grab/Controls/SettingsCard.cs new file mode 100644 index 00000000..015769ad --- /dev/null +++ b/Text-Grab/Controls/SettingsCard.cs @@ -0,0 +1,84 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Text_Grab.Controls; + +/// +/// A settings row card in the style of the Windows Community Toolkit SettingsCard: +/// icon, header and description on the left, control (Content) on the right. +/// The header visuals are built in code so pages can use x:Name on child controls. +/// +public class SettingsCard : Wpf.Ui.Controls.CardControl +{ + public static readonly DependencyProperty HeaderTextProperty = + DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(SettingsCard), + new PropertyMetadata(string.Empty, OnHeaderTextChanged)); + + public static readonly DependencyProperty DescriptionProperty = + DependencyProperty.Register(nameof(Description), typeof(string), typeof(SettingsCard), + new PropertyMetadata(string.Empty, OnDescriptionChanged)); + + private readonly TextBlock headerTextBlock; + private readonly TextBlock descriptionTextBlock; + + public string HeaderText + { + get => (string)GetValue(HeaderTextProperty); + set => SetValue(HeaderTextProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public SettingsCard() + { + // WPF-UI's implicit styles are keyed by the exact base type, so a + // derived control has to resolve the CardControl style itself. + SetResourceReference(StyleProperty, typeof(Wpf.Ui.Controls.CardControl)); + + // CardControl derives from ButtonBase; keep it out of the tab order + // so focus goes straight to the inner control. + Focusable = false; + IsTabStop = false; + Margin = new Thickness(0, 0, 0, 3); + + headerTextBlock = new TextBlock + { + FontSize = 14, + TextWrapping = TextWrapping.Wrap, + }; + headerTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "TextFillColorPrimaryBrush"); + + descriptionTextBlock = new TextBlock + { + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + }; + descriptionTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "TextFillColorSecondaryBrush"); + + StackPanel headerPanel = new() { Margin = new Thickness(0, 0, 12, 0), VerticalAlignment = VerticalAlignment.Center }; + headerPanel.Children.Add(headerTextBlock); + headerPanel.Children.Add(descriptionTextBlock); + Header = headerPanel; + } + + private static void OnHeaderTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is SettingsCard card) + card.headerTextBlock.Text = e.NewValue as string ?? string.Empty; + } + + private static void OnDescriptionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not SettingsCard card) + return; + + string newText = e.NewValue as string ?? string.Empty; + card.descriptionTextBlock.Text = newText; + card.descriptionTextBlock.Visibility = string.IsNullOrEmpty(newText) ? Visibility.Collapsed : Visibility.Visible; + } +} diff --git a/Text-Grab/Controls/SettingsExpander.cs b/Text-Grab/Controls/SettingsExpander.cs new file mode 100644 index 00000000..8e7a99f6 --- /dev/null +++ b/Text-Grab/Controls/SettingsExpander.cs @@ -0,0 +1,113 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Text_Grab.Controls; + +/// +/// An expandable settings card in the style of the Windows Community Toolkit +/// SettingsExpander: icon, header and description on the left, an optional +/// control (SettingsContent) at the right edge of the header row, and related +/// sub-settings inside the expandable body (Content). +/// The header visuals are built in code so pages can use x:Name on child controls. +/// +public class SettingsExpander : Wpf.Ui.Controls.CardExpander +{ + public static readonly DependencyProperty HeaderTextProperty = + DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(SettingsExpander), + new PropertyMetadata(string.Empty, OnHeaderTextChanged)); + + public static readonly DependencyProperty DescriptionProperty = + DependencyProperty.Register(nameof(Description), typeof(string), typeof(SettingsExpander), + new PropertyMetadata(string.Empty, OnDescriptionChanged)); + + public static readonly DependencyProperty SettingsContentProperty = + DependencyProperty.Register(nameof(SettingsContent), typeof(object), typeof(SettingsExpander), + new PropertyMetadata(null, OnSettingsContentChanged)); + + private readonly TextBlock headerTextBlock; + private readonly TextBlock descriptionTextBlock; + private readonly ContentPresenter settingsContentPresenter; + + public string HeaderText + { + get => (string)GetValue(HeaderTextProperty); + set => SetValue(HeaderTextProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public object? SettingsContent + { + get => GetValue(SettingsContentProperty); + set => SetValue(SettingsContentProperty, value); + } + + public SettingsExpander() + { + // WPF-UI's implicit styles are keyed by the exact base type, so a + // derived control has to resolve the CardExpander style itself. + SetResourceReference(StyleProperty, typeof(Wpf.Ui.Controls.CardExpander)); + + Margin = new Thickness(0, 0, 0, 3); + ContentPadding = new Thickness(14, 10, 14, 12); + + headerTextBlock = new TextBlock + { + FontSize = 14, + TextWrapping = TextWrapping.Wrap, + }; + headerTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "TextFillColorPrimaryBrush"); + + descriptionTextBlock = new TextBlock + { + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + }; + descriptionTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "TextFillColorSecondaryBrush"); + + StackPanel textPanel = new() { VerticalAlignment = VerticalAlignment.Center }; + textPanel.Children.Add(headerTextBlock); + textPanel.Children.Add(descriptionTextBlock); + + settingsContentPresenter = new ContentPresenter + { + Margin = new Thickness(12, 0, 12, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + Grid headerGrid = new() { Margin = new Thickness(0, 0, 8, 0) }; + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + Grid.SetColumn(settingsContentPresenter, 1); + headerGrid.Children.Add(textPanel); + headerGrid.Children.Add(settingsContentPresenter); + Header = headerGrid; + } + + private static void OnHeaderTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is SettingsExpander expander) + expander.headerTextBlock.Text = e.NewValue as string ?? string.Empty; + } + + private static void OnDescriptionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not SettingsExpander expander) + return; + + string newText = e.NewValue as string ?? string.Empty; + expander.descriptionTextBlock.Text = newText; + expander.descriptionTextBlock.Visibility = string.IsNullOrEmpty(newText) ? Visibility.Collapsed : Visibility.Visible; + } + + private static void OnSettingsContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is SettingsExpander expander) + expander.settingsContentPresenter.Content = e.NewValue; + } +} diff --git a/Text-Grab/Controls/ShortcutControl.xaml b/Text-Grab/Controls/ShortcutControl.xaml index 773d6ca2..348e44ab 100644 --- a/Text-Grab/Controls/ShortcutControl.xaml +++ b/Text-Grab/Controls/ShortcutControl.xaml @@ -1,4 +1,4 @@ - - - - - - - + + + + + + + + + + + + @@ -58,6 +66,7 @@ Width="24" Height="24" Margin="4,0" + VerticalAlignment="Center" Background="White" CornerRadius="5"> @@ -136,10 +149,13 @@ - - + Style="{StaticResource TextBodyNormal}" + TextWrapping="Wrap" + Visibility="Collapsed" /> + diff --git a/Text-Grab/Controls/SplitColumnWindow.xaml b/Text-Grab/Controls/SplitColumnWindow.xaml new file mode 100644 index 00000000..9a526a19 --- /dev/null +++ b/Text-Grab/Controls/SplitColumnWindow.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ignore case + + + + + + + + + + + + + + + + Count from end + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/SplitColumnWindow.xaml.cs b/Text-Grab/Controls/SplitColumnWindow.xaml.cs new file mode 100644 index 00000000..c02a0439 --- /dev/null +++ b/Text-Grab/Controls/SplitColumnWindow.xaml.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Text_Grab.Views; + +namespace Text_Grab.Controls; + +/// +/// Interaction logic for SplitColumnWindow.xaml. +/// Lets the user break a spreadsheet cell into multiple columns by delimiter, +/// regex pattern, or fixed length, with a live preview of the result. +/// +public partial class SplitColumnWindow : Wpf.Ui.Controls.FluentWindow +{ + private const int PreviewDebounceDelayMs = 200; + private const int PreviewMaxColumnWidth = 24; + + private static readonly Regex PatternTokenRegex = new(@"^\{([pr]):(.+)\}$", RegexOptions.Compiled); + + private readonly DispatcherTimer previewDebounceTimer = new(); + private string lastSourceSelectedText = string.Empty; + private IReadOnlyList allPatternItems = []; + + public static RoutedCommand SplitCmd = new(); + public static RoutedCommand ApplyCmd = new(); + + /// + /// The contents of the sample cell shown in the read-only source box. + /// Set by the owner before the window is shown. + /// + public string SampleText { get; set; } = string.Empty; + + public SplitColumnWindow() + { + InitializeComponent(); + + previewDebounceTimer.Interval = TimeSpan.FromMilliseconds(PreviewDebounceDelayMs); + previewDebounceTimer.Tick += PreviewDebounceTimer_Tick; + } + + private void Window_Loaded(object sender, RoutedEventArgs e) + { + SourceTextBox.Text = SampleText; + LoadPatternPicker(); + UpdatePreview(); + DelimiterTextBox.Focus(); + } + + private void LoadPatternPicker() + { + // Feed the inline picker the same unified catalog the Grab Template editor uses: + // saved regexes (inserted as {p:Name}) and built-in smart patterns ({r:Name}). + allPatternItems = PatternItem.GetAll(); + PatternPickerBox.ItemsSource = + [ + .. allPatternItems.Select(p => new InlinePickerItem(p.Name, TokenFor(p), p.GroupLabel) + { + Kind = p.Kind, + }), + ]; + } + + private static string TokenFor(PatternItem pattern) + => pattern.Kind == PatternKind.SavedRegex ? $"{{p:{pattern.Name}}}" : $"{{r:{pattern.Name}}}"; + + private void Window_Closed(object? sender, EventArgs e) + { + previewDebounceTimer.Stop(); + PreviewTextBox.Clear(); + } + + private void Window_KeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Close(); + } + + private void Split_CanExecute(object sender, CanExecuteRoutedEventArgs e) + { + e.CanExecute = Owner is EditTextWindow; + } + + private void Split_Executed(object sender, ExecutedRoutedEventArgs e) + { + ApplySplit(); + Close(); + } + + private void Apply_Executed(object sender, ExecutedRoutedEventArgs e) + { + ApplySplit(); + } + + private void ApplySplit() + { + if (Owner is EditTextWindow etwOwner) + etwOwner.SplitSelectedSpreadsheetCells(BuildOptions()); + } + + private SplitColumnOptions BuildOptions() + { + if (RegexRadioButton.IsChecked is true) + { + (PatternItem? chosenPattern, string rawRegex) = ResolvePatternInput(); + return new SplitColumnOptions + { + Mode = SplitMode.Regex, + PatternItem = chosenPattern, + Pattern = rawRegex, + IgnoreCase = IgnoreCaseToggle.IsChecked is true, + SplitterHandling = GetSplitterHandling(), + }; + } + + if (FixedLengthRadioButton.IsChecked is true) + { + _ = int.TryParse(LengthTextBox.Text, out int length); + return new SplitColumnOptions + { + Mode = SplitMode.FixedLength, + Length = Math.Max(0, length), + SplitFromEnd = FromEndToggle.IsChecked is true, + }; + } + + return new SplitColumnOptions + { + Mode = SplitMode.Delimiter, + DelimiterText = DelimiterTextBox.Text, + SplitterHandling = GetSplitterHandling(), + }; + } + + private SplitterHandling GetSplitterHandling() + { + if (SplitterLeftRadio.IsChecked is true) + return SplitterHandling.KeepLeft; + if (SplitterRightRadio.IsChecked is true) + return SplitterHandling.KeepRight; + return SplitterHandling.Remove; + } + + private void SplitModeRadioButton_Checked(object sender, RoutedEventArgs e) + { + if (!IsLoaded) + return; + + DelimiterPanel.Visibility = DelimiterRadioButton.IsChecked is true ? Visibility.Visible : Visibility.Collapsed; + RegexPanel.Visibility = RegexRadioButton.IsChecked is true ? Visibility.Visible : Visibility.Collapsed; + FixedLengthPanel.Visibility = FixedLengthRadioButton.IsChecked is true ? Visibility.Visible : Visibility.Collapsed; + + // The splitter is removed/kept only when splitting on a delimiter or pattern; fixed-length keeps all text. + SplitterHandlingPanel.Visibility = FixedLengthRadioButton.IsChecked is true ? Visibility.Collapsed : Visibility.Visible; + + UpdatePreview(); + } + + private void SplitInputChanged(object sender, RoutedEventArgs e) + { + if (!IsLoaded) + return; + + previewDebounceTimer.Stop(); + previewDebounceTimer.Start(); + } + + private void SourceTextBox_SelectionChanged(object sender, RoutedEventArgs e) + { + if (SourceTextBox.SelectionLength > 0) + lastSourceSelectedText = SourceTextBox.SelectedText; + } + + private void PatternInputChanged(object sender, TextChangedEventArgs e) + { + if (!IsLoaded) + return; + + previewDebounceTimer.Stop(); + previewDebounceTimer.Start(); + } + + /// + /// Interprets the pattern picker's content: a lone {p:Name}/{r:Name} token resolves to that + /// saved/smart ; anything else is treated as a raw regex. + /// + private (PatternItem? Item, string RawRegex) ResolvePatternInput() + { + string serialized = PatternPickerBox.GetSerializedText().Trim(); + + Match tokenMatch = PatternTokenRegex.Match(serialized); + if (tokenMatch.Success) + { + PatternKind kind = tokenMatch.Groups[1].Value == "p" ? PatternKind.SavedRegex : PatternKind.Recognizer; + string name = tokenMatch.Groups[2].Value; + + PatternItem? item = allPatternItems.FirstOrDefault( + p => p.Kind == kind && p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (item is not null) + return (item, string.Empty); + } + + return (null, serialized); + } + + private void ExtractButton_Click(object sender, RoutedEventArgs e) + { + string selection = SourceTextBox.SelectionLength > 0 ? SourceTextBox.SelectedText : lastSourceSelectedText; + if (string.IsNullOrEmpty(selection)) + return; + + bool ignoreCase = IgnoreCaseToggle.IsChecked is true; + ExtractedPattern extractedPattern = new(selection, ignoreCase); + int level = ExtractedPattern.DetermineStartingLevel(selection); + + // Extraction produces a raw regex; drop it into the picker as plain text. + PatternPickerBox.SetSerializedText(extractedPattern.GetPattern(level), []); + + previewDebounceTimer.Stop(); + previewDebounceTimer.Start(); + } + + private void PreviewDebounceTimer_Tick(object? sender, EventArgs e) + { + previewDebounceTimer.Stop(); + UpdatePreview(); + } + + private void UpdatePreview() + { + IReadOnlyList parts = ColumnSplitUtilities.SplitCell(SampleText, BuildOptions()); + + // Render the resulting parts as side-by-side columns: a header row of + // column labels above a row of the values, aligned in a monospace grid. + StringBuilder headerRow = new(); + StringBuilder valueRow = new(); + + for (int i = 0; i < parts.Count; i++) + { + if (i > 0) + { + headerRow.Append(" │ "); + valueRow.Append(" │ "); + } + + string header = $"Col {i + 1}"; + string value = parts[i].ReplaceLineEndings(" "); + if (value.Length > PreviewMaxColumnWidth) + value = string.Concat(value.AsSpan(0, PreviewMaxColumnWidth - 1), "…"); + + int columnWidth = Math.Max(header.Length, value.Length); + headerRow.Append(header.PadRight(columnWidth)); + valueRow.Append(value.PadRight(columnWidth)); + } + + PreviewTextBox.Text = string.Concat(headerRow.ToString(), Environment.NewLine, valueRow.ToString()); + } +} diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml index 1c71e64c..d85c0d9c 100644 --- a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml @@ -63,7 +63,7 @@ Click="ManagePatternsButton_Click" Content="New Regex..." Icon="{ui:SymbolIcon Add24}" - ToolTip="Open the Regex Manager to create or edit patterns" /> + ToolTip="Open the Patterns Manager to create or edit patterns" /> + /// Builds a picker entry for a unified pattern: a saved regex emits a {p:} + /// placeholder, a recognizer emits {r:}, and the group label drives the + /// "Saved Patterns" / "Smart Patterns" subsection headers. + /// + internal static InlinePickerItem InlinePickerItemFor(PatternItem pattern) + { + string placeholder = pattern.Kind == PatternKind.SavedRegex + ? $"{{p:{pattern.Name}:first}}" + : $"{{r:{pattern.Name}:first}}"; + + return new InlinePickerItem(pattern.Name, placeholder, pattern.GroupLabel) + { + Kind = pattern.Kind, + }; + } + + private TemplateRecognizerMatch? OnRecognizerItemSelected(InlinePickerItem item) + { + BuiltInRecognizer? recognizer = BuiltInRecognizer.GetByName(item.DisplayName); + + PatternMatchModeDialog dialog = new(recognizer?.Id ?? string.Empty, item.DisplayName, isRecognizer: true) + { + Owner = this, + }; + + if (dialog.ShowDialog() is not true || dialog.Result is null) + return null; - OutputTemplateBox.ItemsSource = [.. patterns.Select(p => - new InlinePickerItem(p.Name, $"{{p:{p.Name}:first}}", "Patterns"))]; + return new TemplateRecognizerMatch( + recognizerId: recognizer?.Id ?? string.Empty, + recognizerName: item.DisplayName, + matchMode: dialog.Result.MatchMode, + separator: dialog.Result.Separator, + outputKind: dialog.SelectedOutputKind); } private TemplatePatternMatch? OnPatternItemSelected(InlinePickerItem item) @@ -70,7 +106,7 @@ private void LoadPatternItems() private void ManagePatternsButton_Click(object sender, RoutedEventArgs e) { - // Open the Regex Manager so the user can create a new pattern. When they return + // Open the Patterns Manager so the user can create a new pattern. When they return // focus to this dialog, OnActivated reloads the picker so the new pattern is usable. RegexManager regexManager = WindowUtilities.OpenOrActivateWindow(); regexManager.Show(); @@ -119,6 +155,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) newTemplate.Name = name; newTemplate.OutputTemplate = outputTemplate; newTemplate.PatternMatches = GrabTemplateExecutor.ParsePatternMatchesFromOutputTemplate(outputTemplate); + newTemplate.RecognizerMatches = GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate(outputTemplate); GrabTemplateManager.AddOrUpdateTemplate(newTemplate); DialogResult = true; diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 189c1f74..2129cc51 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -494,6 +494,10 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA private void EditWordTextBox_GotFocus(object sender, RoutedEventArgs e) { Select(); + + // The user focusing a word's edit box is a strong signal they are about to correct + // recognized text, so freeze the frame to keep it from resetting while they edit. + OwnerGrabFrame?.FreezeFrameForWordEditing(); } private void EditWordTextBox_MouseDown(object sender, MouseButtonEventArgs e) diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index 39399410..d02308ab 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -87,6 +87,16 @@ public enum ScrollBehavior ZoomWhenFrozen = 3, } +public enum GrabFrameBorderStyle +{ + // Follow the app light/dark theme (current behavior). + Theme = 0, + // Two-tone white+black border; one tone always contrasts with any background. + HighContrast = 1, + // A fixed user-picked color. + Color = 2, +} + public enum SpellCheckMode { // Enable spell check unless the text looks like it would choke the checker @@ -104,6 +114,7 @@ public enum LanguageKind Tesseract = 1, WindowsAi = 2, UiAutomation = 3, + WindowsAiDescription = 4, } public enum UiAutomationTraversalMode diff --git a/Text-Grab/Interfaces/ITtsEngine.cs b/Text-Grab/Interfaces/ITtsEngine.cs new file mode 100644 index 00000000..2ab4f43f --- /dev/null +++ b/Text-Grab/Interfaces/ITtsEngine.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Text_Grab.Interfaces; + +public interface ITtsEngine +{ + Task SpeakAsync(string text, CancellationToken ct); +} diff --git a/Text-Grab/Models/BuiltInRecognizer.cs b/Text-Grab/Models/BuiltInRecognizer.cs new file mode 100644 index 00000000..8e6096c8 --- /dev/null +++ b/Text-Grab/Models/BuiltInRecognizer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Recognizers.Text; +using Microsoft.Recognizers.Text.Choice; +using Microsoft.Recognizers.Text.DateTime; +using Microsoft.Recognizers.Text.Number; +using Microsoft.Recognizers.Text.NumberWithUnit; +using Microsoft.Recognizers.Text.Sequence; + +namespace Text_Grab.Models; + +/// +/// Represents one of the built-in, culture-aware recognizers from the +/// Microsoft Recognizers-Text library (numbers, dates, currencies, emails, …). +/// +/// Unlike , recognizers are a fixed catalog — there is no +/// editor. They are surfaced for selection in Grab Templates (via {r:Name:mode} +/// placeholders), applied in the Edit Text Window, and used for searching. +/// +/// Each recognizer wraps one Recognizers-Text "Recognize" method. The recognizer +/// returns matches that carry both the matched and a +/// normalized resolution (e.g. "next tuesday" → 2026-07-07, "$5" → 5 Dollar) — see +/// RecognizerExecutor for how the resolution is formatted. +/// +public class BuiltInRecognizer +{ + /// Stable identifier used in serialized templates (e.g. "datetime"). + public string Id { get; } + + /// Display name shown in menus and pickers (e.g. "Date / Time"). + public string Name { get; } + + /// Short description of what the recognizer matches. + public string Description { get; } + + /// Invokes the underlying Recognizers-Text method. (text, culture) → matches. + public Func> Recognize { get; } + + private BuiltInRecognizer(string id, string name, string description, + Func> recognize) + { + Id = id; + Name = name; + Description = description; + Recognize = recognize; + } + + private static readonly IReadOnlyList All = + [ + new("number", "Number", "Numbers like 25 or 3.5", + static (text, culture) => NumberRecognizer.RecognizeNumber(text, culture)), + new("ordinal", "Ordinal", "Ordinal numbers like 1st, 2nd, 3rd", + static (text, culture) => NumberRecognizer.RecognizeOrdinal(text, culture)), + new("percentage", "Percentage", "Percentages like 50%", + static (text, culture) => NumberRecognizer.RecognizePercentage(text, culture)), + new("age", "Age", "Ages like 25 years old", + static (text, culture) => NumberWithUnitRecognizer.RecognizeAge(text, culture)), + new("currency", "Currency", "Currency amounts like $5 or 10 dollars", + static (text, culture) => NumberWithUnitRecognizer.RecognizeCurrency(text, culture)), + new("dimension", "Dimension", "Dimensions like 3 miles or 5 kg", + static (text, culture) => NumberWithUnitRecognizer.RecognizeDimension(text, culture)), + new("temperature", "Temperature", "Temperatures like 90 degrees fahrenheit", + static (text, culture) => NumberWithUnitRecognizer.RecognizeTemperature(text, culture)), + new("datetime", "Date / Time", "Dates, times, durations and ranges like next tuesday at 3pm", + static (text, culture) => DateTimeRecognizer.RecognizeDateTime(text, culture)), + new("phonenumber", "Phone Number", "Phone numbers like (212) 555-0182", + static (text, culture) => SequenceRecognizer.RecognizePhoneNumber(text, culture)), + new("email", "Email", "Email addresses", + static (text, culture) => SequenceRecognizer.RecognizeEmail(text, culture)), + new("url", "URL", "Web URLs", + static (text, culture) => SequenceRecognizer.RecognizeURL(text, culture)), + new("ip", "IP Address", "IPv4 and IPv6 addresses", + static (text, culture) => SequenceRecognizer.RecognizeIpAddress(text, culture)), + new("guid", "GUID", "GUIDs / UUIDs", + static (text, culture) => SequenceRecognizer.RecognizeGUID(text, culture)), + new("boolean", "Boolean", "Yes / no style boolean values", + static (text, culture) => ChoiceRecognizer.RecognizeBoolean(text, culture)), + ]; + + /// Returns the full fixed catalog of recognizers. + public static IReadOnlyList GetAll() => All; + + /// Finds a recognizer by its stable (case-insensitive). Null if none. + public static BuiltInRecognizer? GetById(string id) => + All.FirstOrDefault(r => r.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + + /// Finds a recognizer by its display (case-insensitive). Null if none. + public static BuiltInRecognizer? GetByName(string name) => + All.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index b2ce5dbf..38eaf846 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -279,7 +279,7 @@ public static List AllButtons new() { OrderNumber = 1.61, - ButtonText = "Regex Manager", + ButtonText = "Patterns Manager", SymbolText = "", ClickEvent = "RegexManagerMenuItem_Click", SymbolIcon = SymbolRegular.Book24 diff --git a/Text-Grab/Models/FindResult.cs b/Text-Grab/Models/FindResult.cs index 69089d07..607f21cf 100644 --- a/Text-Grab/Models/FindResult.cs +++ b/Text-Grab/Models/FindResult.cs @@ -5,6 +5,8 @@ public class FindResult { public string Text { get; set; } = ""; + public string RawText { get; set; } = ""; + public int Count { get; set; } = 0; public int Index { get; set; } diff --git a/Text-Grab/Models/GeneratedOcrLinesWords.cs b/Text-Grab/Models/GeneratedOcrLinesWords.cs new file mode 100644 index 00000000..2f4565b6 --- /dev/null +++ b/Text-Grab/Models/GeneratedOcrLinesWords.cs @@ -0,0 +1,52 @@ +using Windows.Foundation; + +namespace Text_Grab.Models; + +public class GeneratedOcrLinesWords : IOcrLinesWords +{ + public string Text { get; set; } = string.Empty; + + public IOcrLine[] Lines { get; set; } = []; + + public float Angle { get; set; } + + public static GeneratedOcrLinesWords FromParagraph(string text, Rect boundingBox) + { + string normalizedText = text?.Trim() ?? string.Empty; + + return new GeneratedOcrLinesWords + { + Text = normalizedText, + Angle = 0, + Lines = string.IsNullOrWhiteSpace(normalizedText) + ? [] + : [GeneratedOcrLine.FromText(normalizedText, boundingBox)] + }; + } +} + +public class GeneratedOcrLine : IOcrLine +{ + public string Text { get; set; } = string.Empty; + + public IOcrWord[] Words { get; set; } = []; + + public Rect BoundingBox { get; set; } + + public static GeneratedOcrLine FromText(string text, Rect boundingBox) + { + return new GeneratedOcrLine + { + Text = text, + BoundingBox = boundingBox, + Words = [new GeneratedOcrWord { Text = text, BoundingBox = boundingBox }] + }; + } +} + +public class GeneratedOcrWord : IOcrWord +{ + public string Text { get; set; } = string.Empty; + + public Rect BoundingBox { get; set; } +} diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs index 92fc6e34..8f5521d4 100644 --- a/Text-Grab/Models/GrabTemplate.cs +++ b/Text-Grab/Models/GrabTemplate.cs @@ -78,6 +78,12 @@ public partial class GrabTemplate /// public List PatternMatches { get; set; } = []; + /// + /// Recognizer references used in the output template via {r:Name:mode} syntax. + /// Each maps a to a match-selection mode and output kind. + /// + public List RecognizerMatches { get; set; } = []; + public GrabTemplate() { } public GrabTemplate(string name) diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 39e1c095..d541eed8 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -31,6 +31,15 @@ public HistoryInfo() public string ImagePath { get; set; } = string.Empty; + public OpenContentKind SourceContentKind { get; set; } = OpenContentKind.Image; + + public string SourcePath { get; set; } = string.Empty; + + public int SourcePageIndex { get; set; } + + [JsonIgnore] + public bool IsPdfDocument => SourceContentKind == OpenContentKind.PdfDocument; + public bool IsTable { get; set; } = false; public double DpiScaleFactor { get; set; } = 1.0; @@ -75,6 +84,7 @@ public ILanguage OcrLanguage LanguageKind.Global => new GlobalLang(new Language(normalizedLanguageTag)), LanguageKind.Tesseract => new TessLang(normalizedLanguageTag), LanguageKind.WindowsAi => new WindowsAiLang(), + LanguageKind.WindowsAiDescription => new WindowsAiDescriptionLang(), LanguageKind.UiAutomation => CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(), _ => new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")), }; @@ -114,6 +124,13 @@ public Rect PositionRect #region Public Methods + /// + /// Returns a shallow copy of this instance. Reference-typed members (e.g. + /// , the separator lists) are shared, not cloned — callers + /// that only need to tweak value/string fields without mutating the original should use this. + /// + public HistoryInfo ShallowCopy() => (HistoryInfo)MemberwiseClone(); + public void ClearTransientImage() { // Do not Dispose() here — the bitmap may still be in use by a diff --git a/Text-Grab/Models/LookupItem.cs b/Text-Grab/Models/LookupItem.cs index 448e0a92..777310c3 100644 --- a/Text-Grab/Models/LookupItem.cs +++ b/Text-Grab/Models/LookupItem.cs @@ -13,6 +13,7 @@ public enum LookupItemKind Command = 4, Dynamic = 5, GrabTemplate = 6, + PdfDocument = 7, } public class LookupItem : IEquatable @@ -33,6 +34,7 @@ public Wpf.Ui.Controls.SymbolRegular UiSymbol LookupItemKind.Command => Wpf.Ui.Controls.SymbolRegular.WindowConsole20, LookupItemKind.Dynamic => Wpf.Ui.Controls.SymbolRegular.Flash24, LookupItemKind.GrabTemplate => Wpf.Ui.Controls.SymbolRegular.DocumentTableSearch24, + LookupItemKind.PdfDocument => Wpf.Ui.Controls.SymbolRegular.DocumentSearch24, _ => Wpf.Ui.Controls.SymbolRegular.Copy20, }; } @@ -60,7 +62,9 @@ public LookupItem(HistoryInfo historyInfo) HistoryItem = historyInfo; - if (string.IsNullOrEmpty(historyInfo.ImagePath)) + if (historyInfo.IsPdfDocument) + Kind = LookupItemKind.PdfDocument; + else if (string.IsNullOrEmpty(historyInfo.ImagePath)) Kind = LookupItemKind.EditWindow; else Kind = LookupItemKind.GrabFrame; diff --git a/Text-Grab/Models/PatternItem.cs b/Text-Grab/Models/PatternItem.cs new file mode 100644 index 00000000..0b395929 --- /dev/null +++ b/Text-Grab/Models/PatternItem.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Text_Grab.Utilities; + +namespace Text_Grab.Models; + +/// Whether a is backed by a user regex or a built-in recognizer. +public enum PatternKind +{ + /// A user-managed regular expression (). + SavedRegex, + + /// A built-in, culture-aware recognizer (). + Recognizer +} + +/// +/// A single entry in the unified, user-facing "Patterns" list. To the user, saved regexes +/// and built-in recognizers are both just "patterns" — ways to find/extract a kind of value +/// from text. This type wraps either kind so the UI surfaces (template picker, Edit Text +/// Window apply menu, and the three search features) can list and act on them uniformly. +/// +/// The two implementations stay separate underneath: patterns run +/// through , recognizers through +/// . dispatches over the two. +/// +public class PatternItem +{ + /// Subsection header for user regexes in the combined list. + public const string SavedGroup = "Saved Patterns"; + + /// Subsection header for built-in recognizers in the combined list. + public const string SmartGroup = "Smart Patterns"; + + /// Which implementation backs this item. + public PatternKind Kind { get; } + + /// Stable identifier — or . + public string Id { get; } + + /// Display name shown in menus and pickers. + public string Name { get; } + + /// Short description of what the pattern matches. + public string Description { get; } + + /// Subsection label for grouped rendering — or . + public string GroupLabel { get; } + + /// The backing regex when is ; otherwise null. + public StoredRegex? SavedRegex { get; } + + /// The backing recognizer when is ; otherwise null. + public BuiltInRecognizer? Recognizer { get; } + + /// + /// Whether the user has hidden this pattern from pickers. Only meaningful for + /// — saved regexes are deleted rather than hidden. + /// + public bool IsHidden { get; } + + /// The regex text for a saved pattern, or a placeholder for a built-in recognizer (which has no literal pattern). + public string PatternDisplay => SavedRegex?.Pattern ?? "(built-in)"; + + internal PatternItem(StoredRegex savedRegex) + { + Kind = PatternKind.SavedRegex; + Id = savedRegex.Id; + Name = savedRegex.Name; + Description = savedRegex.Description; + GroupLabel = SavedGroup; + SavedRegex = savedRegex; + } + + internal PatternItem(BuiltInRecognizer recognizer, bool isHidden = false) + { + Kind = PatternKind.Recognizer; + Id = recognizer.Id; + Name = recognizer.Name; + Description = recognizer.Description; + GroupLabel = SmartGroup; + Recognizer = recognizer; + IsHidden = isHidden; + } + + /// + /// Returns the combined catalog: the user's saved regexes first (falling back to the + /// built-in defaults when none are saved), then the built-in recognizers. Recognizers the + /// user has hidden are excluded unless is true — the + /// Patterns Manager passes true so it can offer an "unhide" action. + /// + public static IReadOnlyList GetAll(bool includeHidden = false) + { + StoredRegex[] saved = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (saved.Length == 0) + saved = StoredRegex.GetDefaultPatterns(); + + HashSet hiddenIds = [.. AppUtilities.TextGrabSettingsService.LoadHiddenSmartPatternIds()]; + + IEnumerable recognizers = BuiltInRecognizer.GetAll() + .Select(r => new PatternItem(r, isHidden: hiddenIds.Contains(r.Id))); + + if (!includeHidden) + recognizers = recognizers.Where(r => !r.IsHidden); + + return + [ + .. saved.Select(s => new PatternItem(s)), + .. recognizers, + ]; + } + + /// + /// Finds a pattern by display name (case-insensitive), preferring a saved regex over a + /// recognizer when both share a name. Null when no pattern matches. + /// + public static PatternItem? GetByName(string name) + => GetAll().FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Text-Grab/Models/StoredRegex.cs b/Text-Grab/Models/StoredRegex.cs index 0ba064d8..133c440b 100644 --- a/Text-Grab/Models/StoredRegex.cs +++ b/Text-Grab/Models/StoredRegex.cs @@ -55,27 +55,22 @@ public StoredRegex(string name, string pattern, bool isDefault = false, string d } /// - /// Gets the default regex patterns that come with Text Grab + /// Gets the default regex patterns that come with Text Grab. + /// + /// This list is intentionally limited to formats that the built-in + /// catalog does not already cover. Emails, phone + /// numbers, URLs, IP addresses, GUIDs, dates, times, currency, and plain numbers + /// are all handled better by the culture-aware Smart Patterns (recognizers), so + /// they are not duplicated here. /// public static StoredRegex[] GetDefaultPatterns() { return [ - new StoredRegex("Email Address", @"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", true, "Matches email addresses"), - new StoredRegex("Phone Number (US)", @"\b\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b", true, "Matches US phone numbers like (123) 456-7890"), - new StoredRegex("URL", @"https?://[^\s/$.?#].[^\s]*", true, "Matches http and https URLs"), - new StoredRegex("IP Address (IPv4)", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", true, "Matches IPv4 addresses like 192.168.1.1"), - new StoredRegex("GUID/UUID", @"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", true, "Matches GUIDs/UUIDs"), - new StoredRegex("Date (MM/DD/YYYY)", @"\b(0?[1-9]|1[0-2])/(0?[1-9]|[12][0-9]|3[01])/\d{4}\b", true, "Matches dates in MM/DD/YYYY format"), - new StoredRegex("Date (YYYY-MM-DD)", @"\b\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])\b", true, "Matches dates in ISO format YYYY-MM-DD"), - new StoredRegex("Time (HH:MM)", @"\b([01]?[0-9]|2[0-3]):[0-5][0-9]\b", true, "Matches time in 24-hour format"), new StoredRegex("Credit Card", @"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", true, "Matches credit card numbers"), new StoredRegex("Hex Color", @"#[0-9a-fA-F]{6}\b", true, "Matches hex color codes like #FFFFFF"), new StoredRegex("Social Security Number", @"\b\d{3}-\d{2}-\d{4}\b", true, "Matches SSN format XXX-XX-XXXX"), new StoredRegex("Zip Code (US)", @"\b\d{5}(-\d{4})?\b", true, "Matches US zip codes (5 or 9 digit)"), - new StoredRegex("Currency (USD)", @"\$\s?\d+(?:,\d{3})*(?:\.\d{2})?\b", true, "Matches US dollar amounts"), - new StoredRegex("Integer Number", @"\b-?\d+\b", true, "Matches integer numbers"), - new StoredRegex("Decimal Number", @"\b-?\d+\.\d+\b", true, "Matches decimal numbers"), ]; } } diff --git a/Text-Grab/Models/TemplateRecognizerMatch.cs b/Text-Grab/Models/TemplateRecognizerMatch.cs new file mode 100644 index 00000000..cd65d559 --- /dev/null +++ b/Text-Grab/Models/TemplateRecognizerMatch.cs @@ -0,0 +1,59 @@ +using Text_Grab.Utilities; + +namespace Text_Grab.Models; + +/// +/// Represents a reference to a built-in recognizer within a GrabTemplate. +/// During execution the recognizer is run against the source text and matches are +/// extracted according to , emitting either the resolved value +/// or the matched text per . +/// +/// Placeholder syntax in the output template: +/// {r:RecognizerName:first} — first match, resolved value +/// {r:RecognizerName:last} — last match +/// {r:RecognizerName:all} — all matches, default separator +/// {r:RecognizerName:all:text} — all matches, matched text instead of resolved value +/// {r:RecognizerName:all:value:; } — all matches, resolved value joined by "; " +/// {r:RecognizerName:2} — 2nd match (1-based) +/// {r:RecognizerName:1,3} — 1st and 3rd matches joined by separator +/// +public class TemplateRecognizerMatch +{ + /// The of the recognizer. + public string RecognizerId { get; set; } = string.Empty; + + /// + /// Display name of the recognizer (mirrors ). + /// Also used in the {r:RecognizerName:...} placeholder syntax. + /// + public string RecognizerName { get; set; } = string.Empty; + + /// + /// How to select from the recognized matches. + /// Values: "first", "last", "all", a single 1-based index like "2", + /// or comma-separated indices like "1,3,5". + /// + public string MatchMode { get; set; } = "first"; + + /// + /// Separator string used when is "all" or specifies + /// multiple indices. Defaults to ", ". + /// + public string Separator { get; set; } = ", "; + + /// Whether to emit the normalized value or the matched text. Defaults to resolved value. + public RecognizerOutputKind OutputKind { get; set; } = RecognizerOutputKind.ResolvedValue; + + public TemplateRecognizerMatch() { } + + public TemplateRecognizerMatch(string recognizerId, string recognizerName, + string matchMode = "first", string separator = ", ", + RecognizerOutputKind outputKind = RecognizerOutputKind.ResolvedValue) + { + RecognizerId = recognizerId; + RecognizerName = recognizerName; + MatchMode = matchMode; + Separator = separator; + OutputKind = outputKind; + } +} diff --git a/Text-Grab/Models/WindowsAiDescriptionLang.cs b/Text-Grab/Models/WindowsAiDescriptionLang.cs new file mode 100644 index 00000000..a6029d10 --- /dev/null +++ b/Text-Grab/Models/WindowsAiDescriptionLang.cs @@ -0,0 +1,26 @@ +using Text_Grab.Interfaces; +using Windows.Globalization; + +namespace Text_Grab.Models; + +public class WindowsAiDescriptionLang : ILanguage +{ + public const string Tag = "WinAI-Desc"; + public const string DisplayLabel = "Windows AI Description"; + + public string AbbreviatedName => "WinAI Desc"; + + public string DisplayName => DisplayLabel; + + public string CurrentInputMethodLanguageTag => string.Empty; + + public string CultureDisplayName => DisplayLabel; + + public string LanguageTag => Tag; + + public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr; + + public string NativeName => DisplayLabel; + + public string Script => string.Empty; +} diff --git a/Text-Grab/Pages/DangerSettings.xaml b/Text-Grab/Pages/DangerSettings.xaml index 95ccd2ad..e66fa6d8 100644 --- a/Text-Grab/Pages/DangerSettings.xaml +++ b/Text-Grab/Pages/DangerSettings.xaml @@ -1,4 +1,4 @@ - - - + + - - Shutdown Text Grab. - - + - - Reset Tray icon. - - + + + - - Generate bug report with app diagnostic information. - - + + + - - Export all settings and optionally history to a portable ZIP file. - - - + + + - - Import settings and history from a previously exported ZIP file. - - + + + + + + + + + + + + + + - - Check CPU Architecture before enabling Windows Local AI model features - - + + + + + - - Reset All settings to default settings. - - + + + - - Delete all history items. - - + + + diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml b/Text-Grab/Pages/EditTextWindowSettings.xaml index 844ea71b..9ef4f3c1 100644 --- a/Text-Grab/Pages/EditTextWindowSettings.xaml +++ b/Text-Grab/Pages/EditTextWindowSettings.xaml @@ -2,338 +2,270 @@ x:Class="Text_Grab.Pages.EditTextWindowSettings" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="clr-namespace:Text_Grab.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="EditTextWindowSettings" d:DesignHeight="900" d:DesignWidth="800" Loaded="Page_Loaded" mc:Ignorable="d"> - - + + - + - - + + + - - + + + - - + + + - - + + + - - + + + - + + + + - + + - - - + + + + - - + - - - + + + - + + + - - + + + - - + - - + + + + + + - + + + + + - - - - + + + + + + + + - - - - - + + + + - - - - - - - + + - + + - + + - - + + + + - - + - - + + + - - - - - - - - - - - - - + + + diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 7d00b59c..37243803 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -1,7 +1,8 @@ - - - + + - + - - - + + - + - - - + + - - + - - + + + - - - + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - + + + + + + + + + + + diff --git a/Text-Grab/Pages/GrabFrameSettings.xaml.cs b/Text-Grab/Pages/GrabFrameSettings.xaml.cs index 7e0e200b..d5a1c987 100644 --- a/Text-Grab/Pages/GrabFrameSettings.xaml.cs +++ b/Text-Grab/Pages/GrabFrameSettings.xaml.cs @@ -1,7 +1,6 @@ using System; using System.Windows; using System.Windows.Controls; -using Text_Grab; using Text_Grab.Properties; using Text_Grab.Utilities; @@ -50,9 +49,55 @@ private void Page_Loaded(object sender, RoutedEventArgs e) break; } + GrabFrameBorderStyle borderStyle = GrabFrameBorderStyle.Theme; + if (!string.IsNullOrWhiteSpace(DefaultSettings.GrabFrameBorderStyle)) + _ = Enum.TryParse(DefaultSettings.GrabFrameBorderStyle, out borderStyle); + + switch (borderStyle) + { + case GrabFrameBorderStyle.HighContrast: + HighContrastBorderRadio.IsChecked = true; + break; + case GrabFrameBorderStyle.Color: + ColorBorderRadio.IsChecked = true; + break; + case GrabFrameBorderStyle.Theme: + default: + ThemeBorderRadio.IsChecked = true; + break; + } + BorderColorSwatchPanel.IsEnabled = borderStyle == GrabFrameBorderStyle.Color; + _loaded = true; } + private void BorderStyleRadio_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + + GrabFrameBorderStyle borderStyle = GrabFrameBorderStyle.Theme; + if (HighContrastBorderRadio.IsChecked == true) borderStyle = GrabFrameBorderStyle.HighContrast; + else if (ColorBorderRadio.IsChecked == true) borderStyle = GrabFrameBorderStyle.Color; + + DefaultSettings.GrabFrameBorderStyle = borderStyle.ToString(); + DefaultSettings.Save(); + BorderColorSwatchPanel.IsEnabled = borderStyle == GrabFrameBorderStyle.Color; + } + + private void BorderColorSwatch_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + if (sender is not Button button || button.Tag is not string hex) + return; + + DefaultSettings.GrabFrameBorderColor = hex; + DefaultSettings.GrabFrameBorderStyle = GrabFrameBorderStyle.Color.ToString(); + DefaultSettings.Save(); + + ColorBorderRadio.IsChecked = true; + BorderColorSwatchPanel.IsEnabled = true; + } + private void GrabFrameAutoOcrCheckBox_Click(object sender, RoutedEventArgs e) { if (!_loaded) return; diff --git a/Text-Grab/Pages/KeysSettings.xaml b/Text-Grab/Pages/KeysSettings.xaml index aba2c285..543c6e90 100644 --- a/Text-Grab/Pages/KeysSettings.xaml +++ b/Text-Grab/Pages/KeysSettings.xaml @@ -1,4 +1,4 @@ - - - + + + + + + + + + + + + + + - - - Run Text Grab in the background and enable hotkeys - - - - For this setting to take effect close all instances of Text Grab. - - - - Global hotkeys - - - - - + + + + @@ -47,22 +44,68 @@ NavigateUri="https://www.microsoft.com/en-us/p/text-grab/9mznkqj7sl0b?cid=WindowsAICheck" Visibility="Collapsed" /> Windows AI OCR is a new state-of-the-art OCR engine first introduced on Copilot+ PCs. It supports a wide range of styles and languages, as well as being more accurate. + + + + - + + + + + + + + + Furigana are the small kana readings printed above kanji in Japanese (and similar ruby + annotations in Chinese). The OCR engine reads them as extra text, which clutters the + result. When enabled, Text Grab detects those small ruby lines by their size and position + and removes them, keeping only the main text. + + + + This is a work in progress. Furigana detection is based on a size-and-position + heuristic, so it can occasionally miss a reading or remove text it shouldn't. I'm + actively trying to improve how Text Grab handles Japanese and Chinese. If you have + experience with CJK text, OCR, or furigana and would be willing to help, please email + me at + joe@joefinapps.com. Thank you! + + + + + + - + @@ -146,82 +184,71 @@ FontFamily="Cascadia Code" Style="{StaticResource TextBodyNormal}" /> - - - When the Direct Text (Beta) language is selected, Text Grab will try to read native accessibility text from live application controls before falling back to OCR. - - - - - - - Show Direct Text (Beta) as a language option - - - - - - Fall back to OCR when UI Automation returns no text - - - - - Prefer the focused UI element before scanning the rest of the window - - - - - Include offscreen Direct Text elements - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + diff --git a/Text-Grab/Pages/LanguageSettings.xaml.cs b/Text-Grab/Pages/LanguageSettings.xaml.cs index 700ca866..11daf1b6 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml.cs +++ b/Text-Grab/Pages/LanguageSettings.xaml.cs @@ -22,7 +22,7 @@ namespace Text_Grab.Pages; public partial class LanguageSettings : Page { private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - private bool loadingUiAutomationSettings = false; + private bool loadingLanguageSettings = false; public LanguageSettings() @@ -32,13 +32,15 @@ public LanguageSettings() private async void Page_Loaded(object sender, RoutedEventArgs e) { - loadingUiAutomationSettings = true; + loadingLanguageSettings = true; LoadAiStatus(); - + LoadWindowsAiDescriptionSettings(); LoadWindowsLanguages(); LoadUiAutomationSettings(); + RemoveFuriganaToggle.IsChecked = DefaultSettings.RemoveFurigana; + if (DefaultSettings.UseTesseract) { TesseractLanguagesStackPanel.Visibility = Visibility.Visible; @@ -49,7 +51,7 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) TesseractLanguagesStackPanel.Visibility = Visibility.Collapsed; } - loadingUiAutomationSettings = false; + loadingLanguageSettings = false; } private void LoadAiStatus() @@ -138,6 +140,12 @@ private void LoadUiAutomationSettings() UpdateUiAutomationControlState(); } + private void LoadWindowsAiDescriptionSettings() + { + WindowsAiDescriptionEnabledToggle.IsChecked = DefaultSettings.WindowsAiDescriptionEnabled; + WindowsAiDescriptionEnabledToggle.IsEnabled = WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI(); + } + private async void InstallButton_Click(object sender, RoutedEventArgs e) { if (string.IsNullOrEmpty(AllLanguagesComboBox.Text)) @@ -165,7 +173,7 @@ private void HyperlinkButton_Click(object sender, RoutedEventArgs e) private void UiAutomationEnabledToggle_Checked(object sender, RoutedEventArgs e) { - if (loadingUiAutomationSettings) + if (loadingLanguageSettings) return; DefaultSettings.UiAutomationEnabled = UiAutomationEnabledToggle.IsChecked is true; @@ -176,7 +184,7 @@ private void UiAutomationEnabledToggle_Checked(object sender, RoutedEventArgs e) private void UiAutomationFallbackToggle_Checked(object sender, RoutedEventArgs e) { - if (loadingUiAutomationSettings) + if (loadingLanguageSettings) return; DefaultSettings.UiAutomationFallbackToOcr = UiAutomationFallbackToggle.IsChecked is true; @@ -185,7 +193,7 @@ private void UiAutomationFallbackToggle_Checked(object sender, RoutedEventArgs e private void UiAutomationPreferFocusedToggle_Checked(object sender, RoutedEventArgs e) { - if (loadingUiAutomationSettings) + if (loadingLanguageSettings) return; DefaultSettings.UiAutomationPreferFocusedElement = UiAutomationPreferFocusedToggle.IsChecked is true; @@ -194,7 +202,7 @@ private void UiAutomationPreferFocusedToggle_Checked(object sender, RoutedEventA private void UiAutomationIncludeOffscreenToggle_Checked(object sender, RoutedEventArgs e) { - if (loadingUiAutomationSettings) + if (loadingLanguageSettings) return; DefaultSettings.UiAutomationIncludeOffscreen = UiAutomationIncludeOffscreenToggle.IsChecked is true; @@ -203,7 +211,7 @@ private void UiAutomationIncludeOffscreenToggle_Checked(object sender, RoutedEve private void UiAutomationTraversalModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (loadingUiAutomationSettings + if (loadingLanguageSettings || UiAutomationTraversalModeComboBox.SelectedItem is not UiAutomationTraversalMode traversalMode) return; @@ -211,6 +219,32 @@ private void UiAutomationTraversalModeComboBox_SelectionChanged(object sender, S DefaultSettings.Save(); } + private void RemoveFuriganaToggle_Checked(object sender, RoutedEventArgs e) + { + if (loadingLanguageSettings) + return; + + DefaultSettings.RemoveFurigana = RemoveFuriganaToggle.IsChecked is true; + DefaultSettings.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + + private void WindowsAiDescriptionEnabledToggle_Checked(object sender, RoutedEventArgs e) + { + if (loadingLanguageSettings) + return; + + if (!WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + { + WindowsAiDescriptionEnabledToggle.IsChecked = false; + return; + } + + DefaultSettings.WindowsAiDescriptionEnabled = WindowsAiDescriptionEnabledToggle.IsChecked is true; + DefaultSettings.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + private void UpdateUiAutomationControlState() { UiAutomationAdvancedOptionsPanel.Visibility = DefaultSettings.UiAutomationEnabled diff --git a/Text-Grab/Pages/QuickLookupSettings.xaml b/Text-Grab/Pages/QuickLookupSettings.xaml index a14b3def..5e865278 100644 --- a/Text-Grab/Pages/QuickLookupSettings.xaml +++ b/Text-Grab/Pages/QuickLookupSettings.xaml @@ -2,102 +2,86 @@ x:Class="Text_Grab.Pages.QuickLookupSettings" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="clr-namespace:Text_Grab.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="QuickLookupSettings" d:DesignHeight="500" d:DesignWidth="800" Loaded="Page_Loaded" mc:Ignorable="d"> - - + + - + - + + + + + + + + - - winget install -e --id UB-Mannheim.TesseractOCR - - - + + + + + + + + Enter path to tesseract.exe here. ex: c:/tess/tesseract.exe + + + + - - - Tesseract is known for having the best OCR capabilities. While the Windows OCR is convenient and fast, it has not been updated in years and Microsoft has no plans to update it. - Feel free to try Tesseract and hopefully it will work well for you. Ideally Text Grab can bring together the convenience with the power of Tesseract. - - - - The default OCR Models installed by UB Mannheim are the 'fast' models which are not as accurate. Other more accurate models can be downloaded from the tessdata GitHub repository here: - https://github.com/tesseract-ocr/tessdata - - After downloading language files, place them in the "tessdata" folder in the installed location of Tesseract: - - - - + + winget install -e --id UB-Mannheim.TesseractOCR + - - - Enter path to tesseract.exe here. ex: c:/tess/tesseract.exe - - + + + The source repository is on GitHub: + https://github.com/tesseract-ocr/tesseract + + UB Mannheim maintains an installer for Windows: + https://github.com/UB-Mannheim/tesseract/wiki/ + + + + + + + + + Tesseract is an optical character recognition engine for various operating systems. It is free software, released under the Apache License. Originally developed by Hewlett-Packard as proprietary software in the 1980s, it was released as open source in 2005 and development has been sponsored by Google since 2006. + More: https://en.wikipedia.org/wiki/Tesseract_(software) + + + + + + Text Grab will capture the image then pass it to the Tesseract EXE. Then Tesseract returns the result of the OCR to Text Grab and error occurs according to user settings. + Does not use Tesseract: Table Recogintion and the Grab Frame. + + + + Tesseract is known for having the best OCR capabilities. While the Windows OCR is convenient and fast, it has not been updated in years and Microsoft has no plans to update it. + Feel free to try Tesseract and hopefully it will work well for you. Ideally Text Grab can bring together the convenience with the power of Tesseract. + + + + The default OCR Models installed by UB Mannheim are the 'fast' models which are not as accurate. Other more accurate models can be downloaded from the tessdata GitHub repository here: + https://github.com/tesseract-ocr/tessdata + + After downloading language files, place them in the "tessdata" folder in the installed location of Tesseract. + + diff --git a/Text-Grab/Pages/VoiceOutputSettings.xaml b/Text-Grab/Pages/VoiceOutputSettings.xaml new file mode 100644 index 00000000..02aba3a8 --- /dev/null +++ b/Text-Grab/Pages/VoiceOutputSettings.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -860,60 +1045,17 @@ + - - - - - - - - - - - - -