diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3d297a84..448bb646 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(dotnet build:*)", "Bash(dotnet test:*)", "Bash(gh pr list:*)", - "Bash(/mnt/c/Program Files/dotnet/dotnet.exe:*)" + "Bash(/mnt/c/Program Files/dotnet/dotnet.exe:*)", + "WebFetch(domain:github.com)" ], "deny": [] } diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 5f89f4c1..db3fba66 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -14,6 +14,7 @@ on: permissions: contents: write + id-token: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -27,6 +28,9 @@ env: BUILD_X64_SC: 'bld/x64/Text-Grab-Self-Contained' BUILD_ARM64: 'bld/arm64' BUILD_ARM64_SC: 'bld/arm64/Text-Grab-Self-Contained' + ARTIFACT_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net/' + ARTIFACT_SIGNING_ACCOUNT_NAME: 'JoeFinAppsSigningCerts' + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: 'JoeFinApps' jobs: build: @@ -77,7 +81,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-x64 - --self-contained false + --no-self-contained -c Release -v minimal -o ${{ env.BUILD_X64 }} @@ -91,7 +95,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-x64 - --self-contained true + --self-contained -c Release -v minimal -o ${{ env.BUILD_X64_SC }} @@ -105,7 +109,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-arm64 - --self-contained false + --no-self-contained -c Release -v minimal -o ${{ env.BUILD_ARM64 }} @@ -118,7 +122,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-arm64 - --self-contained true + --self-contained -c Release -v minimal -o ${{ env.BUILD_ARM64_SC }} @@ -137,6 +141,73 @@ jobs: Rename-Item "${{ env.BUILD_ARM64_SC }}/${{ env.PROJECT }}.exe" 'Text-Grab-arm64.exe' } + - name: Validate Azure Trusted Signing configuration + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + run: | + $requiredSecrets = @{ + AZURE_TENANT_ID = $env:AZURE_TENANT_ID + AZURE_CLIENT_ID = $env:AZURE_CLIENT_ID + AZURE_SUBSCRIPTION_ID = $env:AZURE_SUBSCRIPTION_ID + } + + $missingSecrets = @( + $requiredSecrets.GetEnumerator() | + Where-Object { [string]::IsNullOrWhiteSpace($_.Value) } | + ForEach-Object { $_.Key } + ) + + if ($missingSecrets.Count -gt 0) { + throw "Configure these repository secrets before running the release workflow: $($missingSecrets -join ', ')" + } + + $signingConfig = @{ + ARTIFACT_SIGNING_ENDPOINT = '${{ env.ARTIFACT_SIGNING_ENDPOINT }}' + ARTIFACT_SIGNING_ACCOUNT_NAME = '${{ env.ARTIFACT_SIGNING_ACCOUNT_NAME }}' + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME = '${{ env.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}' + } + + $missing = @( + $signingConfig.GetEnumerator() | + Where-Object { + [string]::IsNullOrWhiteSpace($_.Value) -or + $_.Value.StartsWith('REPLACE_WITH_') -or + $_.Value.Contains('REPLACE_WITH_') + } | + ForEach-Object { $_.Key } + ) + + if ($missing.Count -gt 0) { + throw "Update the Azure Trusted Signing placeholders in .github/workflows/Release.yml before running the release workflow: $($missing -join ', ')" + } + + - name: Azure login for Trusted Signing + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign release executables + uses: azure/artifact-signing-action@v1 + with: + endpoint: ${{ env.ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: | + ${{ github.workspace }}\${{ env.BUILD_X64 }}\${{ env.PROJECT }}.exe + ${{ github.workspace }}\${{ env.BUILD_X64_SC }}\${{ env.PROJECT }}.exe + ${{ github.workspace }}\${{ env.BUILD_ARM64 }}\Text-Grab-arm64.exe + ${{ github.workspace }}\${{ env.BUILD_ARM64_SC }}\Text-Grab-arm64.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Text Grab + description-url: https://github.com/TheJoeFin/Text-Grab + - name: Create self-contained archives shell: pwsh run: | diff --git a/Tests/BarcodeUtilitiesTests.cs b/Tests/BarcodeUtilitiesTests.cs new file mode 100644 index 00000000..d4856aff --- /dev/null +++ b/Tests/BarcodeUtilitiesTests.cs @@ -0,0 +1,82 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Utilities; +using UnitsNet; +using Windows.Storage.Streams; +using static System.Net.Mime.MediaTypeNames; + +namespace Tests; + +public class BarcodeUtilitiesTests +{ + [Fact] + public void TryToReadBarcodes_WithDisposedBitmap_ReturnsEmptyList() + { + Bitmap disposedBitmap = new(8, 8); + disposedBitmap.Dispose(); + + List results = BarcodeUtilities.TryToReadBarcodes(disposedBitmap); + + Assert.Empty(results); + } + + [Fact] + public void TryToReadBarcodes_WithTwoQrCodes_ReturnsTwoResults() + { + // Build a side-by-side bitmap containing two different QR codes + using Bitmap qr1 = BarcodeUtilities.GetQrCodeForText("https://example.com", ZXing.QrCode.Internal.ErrorCorrectionLevel.M); + using Bitmap qr2 = BarcodeUtilities.GetQrCodeForText("https://example.org", ZXing.QrCode.Internal.ErrorCorrectionLevel.M); + + using Bitmap combined = new(qr1.Width + qr2.Width, Math.Max(qr1.Height, qr2.Height)); + using (Graphics g = Graphics.FromImage(combined)) + { + g.Clear(Color.White); + g.DrawImage(qr1, 0, 0); + g.DrawImage(qr2, qr1.Width, 0); + } + + List results = BarcodeUtilities.TryToReadBarcodes(combined); + + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.Equal(OcrOutputKind.Barcode, r.Kind)); + Assert.Contains(results, r => r.RawOutput == "https://example.com"); + Assert.Contains(results, r => r.RawOutput == "https://example.org"); + } + + [WpfFact] + public void ReadTestSingleQRCode() + { + string expectedOutput = "This is a test of the QR Code system"; + string testFilePath = FileUtilities.GetPathToLocalFile(@".\Images\QrCodeTestImage.png"); + + Bitmap testBmp = new(testFilePath); + + List result = BarcodeUtilities.TryToReadBarcodes(testBmp); + + Assert.Single(result); + Assert.Equal(expectedOutput, result[0].RawOutput); + } + + [Fact] + public async Task GetBitmapFromIRandomAccessStream_ReturnsBitmapIndependentOfSourceStream() + { + using Bitmap sourceBitmap = new(8, 8); + sourceBitmap.SetPixel(0, 0, Color.Red); + + using MemoryStream memoryStream = new(); + sourceBitmap.Save(memoryStream, ImageFormat.Png); + + using InMemoryRandomAccessStream randomAccessStream = new(); + _ = await randomAccessStream.WriteAsync(memoryStream.ToArray().AsBuffer()); + + Bitmap clonedBitmap = ImageMethods.GetBitmapFromIRandomAccessStream(randomAccessStream); + + Assert.Equal(8, clonedBitmap.Width); + Assert.Equal(8, clonedBitmap.Height); + Assert.Equal(Color.Red.ToArgb(), clonedBitmap.GetPixel(0, 0).ToArgb()); + } +} diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index 829f88c0..2490cff2 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -1960,393 +1960,1558 @@ public void ParseQuantityWords_ParsesCorrectly(string input, string expected) Assert.Equal(expected, result); } - #endregion ParseQuantityWords Direct Tests + #endregion ParseQuantityWords Direct Tests - #region Percentage Tests + #region Percentage Tests - [Theory] - [InlineData("4 * 25%", "1")] - [InlineData("4*25%", "1")] - [InlineData("100 * 15%", "15")] - [InlineData("200 * 50%", "100")] - [InlineData("80 * 10%", "8")] - public async Task Percentage_BasicMultiplication_ReturnsCorrectResult(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("4 * 25%", "1")] + [InlineData("4*25%", "1")] + [InlineData("100 * 15%", "15")] + [InlineData("200 * 50%", "100")] + [InlineData("80 * 10%", "8")] + public async Task Percentage_BasicMultiplication_ReturnsCorrectResult(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25%", "0.25")] - [InlineData("50%", "0.5")] - [InlineData("100%", "1")] - [InlineData("10%", "0.1")] - [InlineData("1%", "0.01")] - [InlineData("0.5%", "0.005")] - public async Task Percentage_Standalone_ConvertsToDecimal(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("25%", "0.25")] + [InlineData("50%", "0.5")] + [InlineData("100%", "1")] + [InlineData("10%", "0.1")] + [InlineData("1%", "0.01")] + [InlineData("0.5%", "0.005")] + public async Task Percentage_Standalone_ConvertsToDecimal(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25 %", "0.25")] - [InlineData("50 %", "0.5")] - [InlineData("15.5 %", "0.155")] - [InlineData("0.5 %", "0.005")] - public async Task Percentage_WithWhitespace_ConvertsCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("25 %", "0.25")] + [InlineData("50 %", "0.5")] + [InlineData("15.5 %", "0.155")] + [InlineData("0.5 %", "0.005")] + public async Task Percentage_WithWhitespace_ConvertsCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("1000 + 20%", "1,000.2")] - [InlineData("100 - 10%", "99.9")] - [InlineData("50 / 25%", "200")] - public async Task Percentage_WithDifferentOperators_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("1000 + 20%", "1,000.2")] + [InlineData("100 - 10%", "99.9")] + [InlineData("50 / 25%", "200")] + public async Task Percentage_WithDifferentOperators_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_SalesTax() - { - // Arrange - CalculationService service = new(); - string input = @"price = 100 + [Fact] + public async Task Percentage_RealWorldExample_SalesTax() + { + // Arrange + CalculationService service = new(); + string input = @"price = 100 taxRate = 8.5% tax = price * taxRate total = price + tax total"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("108.5", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("108.5", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_Discount() - { - // Arrange - CalculationService service = new(); - string input = @"originalPrice = 200 + [Fact] + public async Task Percentage_RealWorldExample_Discount() + { + // Arrange + CalculationService service = new(); + string input = @"originalPrice = 200 discount = 25% discountAmount = originalPrice * discount finalPrice = originalPrice - discountAmount finalPrice"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("150", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("150", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_TipCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"billAmount = 85 + [Fact] + public async Task Percentage_RealWorldExample_TipCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"billAmount = 85 tipRate = 18% tip = billAmount * tipRate totalWithTip = billAmount + tip totalWithTip"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("100.3", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("100.3", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_MultiplePercentages_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"100 * 10% + [Fact] + public async Task Percentage_MultiplePercentages_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"100 * 10% 200 * 15% 500 * 5%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal(3, lines.Length); - Assert.Equal("10", lines[0]); - Assert.Equal("30", lines[1]); - Assert.Equal("25", lines[2]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("30", lines[1]); + Assert.Equal("25", lines[2]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithVariables_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"base = 1000 + [Fact] + public async Task Percentage_WithVariables_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"base = 1000 rate = 15% result = base * rate result"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("150", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("150", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_ComplexExpression_PercentageOfSum() - { - // Arrange - CalculationService service = new(); - string input = "(100 + 200) * 10%"; + [Fact] + public async Task Percentage_ComplexExpression_PercentageOfSum() + { + // Arrange + CalculationService service = new(); + string input = "(100 + 200) * 10%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("30", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("30", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_CompoundPercentages_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"initial = 1000 + [Fact] + public async Task Percentage_CompoundPercentages_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"initial = 1000 afterFirst = initial * (1 - 20%) afterSecond = afterFirst * (1 + 15%) afterSecond"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - // 1000 * 0.8 = 800, then 800 * 1.15 = 920 - Assert.Equal("920", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + // 1000 * 0.8 = 800, then 800 * 1.15 = 920 + Assert.Equal("920", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("-10%", "-0.1")] - [InlineData("4 * -25%", "-1")] - [InlineData("-100 * 10%", "-10")] - public async Task Percentage_NegativePercentages_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("-10%", "-0.1")] + [InlineData("4 * -25%", "-1")] + [InlineData("-100 * 10%", "-10")] + public async Task Percentage_NegativePercentages_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("150%", "1.5")] - [InlineData("200%", "2")] - [InlineData("500%", "5")] - [InlineData("100 * 150%", "150")] - public async Task Percentage_OverHundredPercent_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("150%", "1.5")] + [InlineData("200%", "2")] + [InlineData("500%", "5")] + [InlineData("100 * 150%", "150")] + public async Task Percentage_OverHundredPercent_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithDecimalBase_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "45.50 * 15%"; + [Fact] + public async Task Percentage_WithDecimalBase_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "45.50 * 15%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("6.825", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("6.825", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithDecimalPercentage_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "100 * 12.5%"; + [Fact] + public async Task Percentage_WithDecimalPercentage_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "100 * 12.5%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("12.5", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("12.5", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25%", "0.25")] - [InlineData("25 %", "0.25")] - [InlineData("4 * 25%", "0.25")] - [InlineData("4 * 25 %", "0.25")] - public void ParsePercentages_DirectCall_ConvertsCorrectly(string input, string expectedContains) - { - // Arrange & Act - string result = CalculationService.ParsePercentages(input); + [Theory] + [InlineData("25%", "0.25")] + [InlineData("25 %", "0.25")] + [InlineData("4 * 25%", "0.25")] + [InlineData("4 * 25 %", "0.25")] + public void ParsePercentages_DirectCall_ConvertsCorrectly(string input, string expectedContains) + { + // Arrange & Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Contains(expectedContains, result); - } + // Assert + Assert.Contains(expectedContains, result); + } - [Fact] - public void ParsePercentages_EmptyInput_ReturnsEmpty() - { - // Arrange - string input = ""; + [Fact] + public void ParsePercentages_EmptyInput_ReturnsEmpty() + { + // Arrange + string input = ""; - // Act - string result = CalculationService.ParsePercentages(input); + // Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Equal("", result); - } + // Assert + Assert.Equal("", result); + } - [Fact] - public void ParsePercentages_NoPercentages_ReturnsUnchanged() - { - // Arrange - string input = "100 + 50"; + [Fact] + public void ParsePercentages_NoPercentages_ReturnsUnchanged() + { + // Arrange + string input = "100 + 50"; - // Act - string result = CalculationService.ParsePercentages(input); + // Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Equal("100 + 50", result); - } + // Assert + Assert.Equal("100 + 50", result); + } - [Fact] - public async Task Percentage_RealWorldExample_InterestCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"principal = 10000 + [Fact] + public async Task Percentage_RealWorldExample_InterestCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"principal = 10000 annualRate = 5% interest = principal * annualRate totalAmount = principal + interest totalAmount"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("10,500", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("10,500", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_GradeCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"totalQuestions = 50 + [Fact] + public async Task Percentage_RealWorldExample_GradeCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"totalQuestions = 50 correctAnswers = 42 percentage = (correctAnswers / totalQuestions) * 100% percentage"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - // (42/50) * 1 = 0.84 (since 100% = 1.0) - Assert.Equal("0.84", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + // (42/50) * 1 = 0.84 (since 100% = 1.0) + Assert.Equal("0.84", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithMathFunctions_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "Sqrt(100) * 25%"; + [Fact] + public async Task Percentage_WithMathFunctions_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "Sqrt(100) * 25%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("2.5", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("2.5", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithSum_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "Sum(100, 200, 300) * 10%"; + [Fact] + public async Task Percentage_WithSum_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "Sum(100, 200, 300) * 10%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("60", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("60", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Percentage Tests + + #region DateTime Math Tests + + [Theory] + [InlineData("March 10, 2026 + 10 days", 2026, 3, 20)] + [InlineData("January 1, 2026 + 30 days", 2026, 1, 31)] + [InlineData("December 25, 2026 + 7 days", 2027, 1, 1)] + [InlineData("February 28, 2026 + 1 day", 2026, 3, 1)] + public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - #endregion Percentage Tests + [Theory] + [InlineData("January 1, 2026 + 2 weeks", 2026, 1, 15)] + [InlineData("March 1, 2026 + 1 week", 2026, 3, 8)] + [InlineData("March 14, 2026 - 2 weeks", 2026, 2, 28)] + public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); } + + [Theory] + [InlineData("January 15, 2026 + 3 months", 2026, 4, 15)] + [InlineData("October 31, 2026 + 1 month", 2026, 11, 30)] + [InlineData("March 31, 2026 + 1 month", 2026, 4, 30)] + public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1, 2020 + 5 years", 2025, 1, 1)] + [InlineData("February 29, 2024 + 1 year", 2025, 2, 28)] + [InlineData("June 15, 2026 + 10 years", 2036, 6, 15)] + public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1, 2000 + 2 decades", 2020, 1, 1)] + [InlineData("June 15, 2010 + 1 decade", 2020, 6, 15)] + [InlineData("March 1, 2026 + 3 decades", 2056, 3, 1)] + public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("March 20, 2026 - 10 days", 2026, 3, 10)] + [InlineData("January 5, 2026 - 10 days", 2025, 12, 26)] + [InlineData("March 1, 2026 - 1 month", 2026, 2, 1)] + [InlineData("January 1, 2026 - 1 year", 2025, 1, 1)] + public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithOrdinalSuffix_ParsesCorrectly() + { + CalculationService service = new(); + string input = "March 10th, 2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1st, 2026 + 5 days", 2026, 1, 6)] + [InlineData("February 2nd, 2026 + 5 days", 2026, 2, 7)] + [InlineData("March 3rd, 2026 + 5 days", 2026, 3, 8)] + [InlineData("April 4th, 2026 + 5 days", 2026, 4, 9)] + [InlineData("May 21st, 2026 + 1 day", 2026, 5, 22)] + public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_NumericDateFormat_ParsesCorrectly() + { + CalculationService service = new(); + string input = "3/10/2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TwoDigitYear_ParsesCorrectly() + { + CalculationService service = new(); + string input = "3/10/26 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithTimePM_ShowsTimeInResult() + { + CalculationService service = new(); + string input = "1/1/2026 2:00pm + 5 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("7:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_With24HrTime_ShowsTimeInResult() + { + CalculationService service = new(); + string input = "1/1/2026 14:30 + 2 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("4:30pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MinutesAddition_CrossesDayBoundary() + { + CalculationService service = new(); + string input = "2/25/2026 11:02pm + 800 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 2, 26).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("12:22pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_HoursAddition_CrossesDayBoundary() + { + CalculationService service = new(); + string input = "1/1/2026 10:00pm + 5 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 1, 2).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("3:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MultipleOperations_WorkCorrectly() + { + CalculationService service = new(); + string input = "January 1, 2026 + 2 weeks + 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MixedAddSubtract_WorkCorrectly() + { + CalculationService service = new(); + string input = "March 1, 2026 + 1 month - 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MixedUnits_YearsAndMonths() + { + CalculationService service = new(); + string input = "January 1, 2026 + 1 year + 6 months"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TodayKeyword_Works() + { + CalculationService service = new(); + string input = "today + 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(5).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TomorrowKeyword_Works() + { + CalculationService service = new(); + string input = "tomorrow + 1 week"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(8).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_YesterdayKeyword_Works() + { + CalculationService service = new(); + string input = "yesterday + 2 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(1).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_FractionalDays_AssumesNoon() + { + CalculationService service = new(); + // March 10 noon + 1.5 days = March 10 12:00 + 36 hours = March 12 00:00 (midnight) + // Since result is midnight, only date is shown + string input = "March 10, 2026 + 1.5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(2026, 3, 12).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_FractionalDays_ShowsTimeWhenNonMidnight() + { + CalculationService service = new(); + // March 10 noon + 1.3 days = March 10 12:00 + 31.2 hours = March 11 19:12 + string input = "March 10, 2026 + 1.3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 3, 11).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("7:12pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MinutesWithoutTime_ShowsTime() + { + CalculationService service = new(); + // No time specified, so base is midnight; adding minutes produces a time result + string input = "January 1, 2026 + 90 minutes"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); + Assert.Contains("1:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakRegularMath() + { + CalculationService service = new(); + string input = "5 + 3"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("8", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakExistingFunctions() + { + CalculationService service = new(); + string input = "Sin(Pi/2)\nSqrt(16)\nAbs(-10)"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Equal("1", lines[0]); + Assert.Equal("4", lines[1]); + Assert.Equal("10", lines[2]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakVariableAssignment() + { + CalculationService service = new(); + string input = "x = 10\nx * 2"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Contains("x = 10", lines[0]); + Assert.Equal("20", lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakPercentages() + { + CalculationService service = new(); + string input = "100 * 15%"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("15", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakQuantityWords() + { + CalculationService service = new(); + string input = "5 million + 3 thousand"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("5003000", result.Output.Replace(",", "")); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithMixedExpressions_WorksCorrectly() + { + CalculationService service = new(); + string input = "March 10, 2026 + 10 days\n5 + 3\nJanuary 1, 2026 + 1 year"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), lines[0]); + Assert.Equal("8", lines[1]); + Assert.Equal(new DateTime(2027, 1, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public void TryEvaluateDateTimeMath_EmptyInput_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("", out _); + Assert.False(result); + } + + [Fact] + public void TryEvaluateDateTimeMath_NoTimeUnits_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("5 + 3", out _); + Assert.False(result); + } + + [Fact] + public void TryEvaluateDateTimeMath_InvalidDate_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("notadate + 5 days", out _); + Assert.False(result); + } + + [Fact] + public async Task DateTimeMath_DateOnly_DoesNotIncludeTime() + { + CalculationService service = new(); + string input = "March 10, 2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Should not contain am/pm since it's date-only + Assert.DoesNotContain("am", result.Output.ToLowerInvariant()); + Assert.DoesNotContain("pm", result.Output.ToLowerInvariant()); + } + + [Fact] + public async Task DateTimeMath_NoDateStartOperator_UsesToday() + { + CalculationService service = new(); + string input = "+ 7 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(7).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("Jan 1, 2026 + 5 days", 2026, 1, 6)] + [InlineData("Feb 14, 2026 + 1 week", 2026, 2, 21)] + [InlineData("Dec 31, 2026 + 1 day", 2027, 1, 1)] + public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, int year, int month, int day) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_ProjectDeadline() + { + CalculationService service = new(); + string input = "June 1st, 2026 + 6 months + 2 weeks"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // June 1 + 6 months = Dec 1, + 2 weeks = Dec 15 + string expected = new DateTime(2026, 12, 15).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_MeetingTime() + { + CalculationService service = new(); + string input = "3/15/2026 9:00am + 90 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 3, 15).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("10:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_FlightArrival() + { + CalculationService service = new(); + // Flight departs 11pm, arrives after 14 hours + string input = "7/4/2026 11:00pm + 14 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 7, 5).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("1:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + #endregion DateTime Math Tests + + #region Combined Duration Segment Tests + + [Fact] + public async Task DateTimeMath_CombinedSegments_WeeksDaysHours() + { + CalculationService service = new(); + // January 1, 2026 + 5 weeks 3 days 8 hours + // = Jan 1 + 35 days + 3 days + 8 hours = Feb 8 2026 8:00am + string input = "January 1, 2026 + 5 weeks 3 days 8 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 2, 8).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("8:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_TodayKeyword() + { + CalculationService service = new(); + string input = "today + 5 weeks 3 days 8 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + DateTime expected = DateTime.Today.AddDays(5 * 7 + 3).AddHours(8); + Assert.Contains(expected.ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("8:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_YearsMonths() + { + CalculationService service = new(); + // January 1, 2026 + 1 year 6 months = July 1, 2027 + string input = "January 1, 2026 + 1 year 6 months"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WeeksDays() + { + CalculationService service = new(); + // March 10, 2026 + 2 weeks 3 days = March 10 + 14 + 3 = March 27 + string input = "March 10, 2026 + 2 weeks 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_HoursMinutes() + { + CalculationService service = new(); + // 1/1/2026 9:00am + 2 hours 30 mins = 11:30am + string input = "1/1/2026 9:00am + 2 hours 30 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("11:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_ThreeUnits() + { + CalculationService service = new(); + // January 1, 2026 + 1 month 2 weeks 3 days + // = Feb 1 + 14 days + 3 days = Feb 18 + string input = "January 1, 2026 + 1 month 2 weeks 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2026, 2, 18).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_FourUnits() + { + CalculationService service = new(); + // January 1, 2026 + 1 year 2 months 1 week 3 days + // = Jan 1 2027 + 2 months = Mar 1 2027 + 7 days = Mar 8 + 3 days = Mar 11 + string input = "January 1, 2026 + 1 year 2 months 1 week 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2027, 3, 11).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WithOperatorChange() + { + CalculationService service = new(); + // March 1, 2026 + 1 month 5 days - 2 hours + // = April 1 + 5 days = April 6, then - 2 hours crosses to April 5 10:00pm + string input = "March 1, 2026 + 1 month 5 days - 2 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 4, 5).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("10:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_SubtractMultiple() + { + CalculationService service = new(); + // April 30, 2026 - 1 month 10 days + // = March 30 - 10 days = March 20 + string input = "April 30, 2026 - 1 month 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_OperatorInheritance() + { + CalculationService service = new(); + // Implicit segments inherit the most recent operator. + // June 15, 2026 + 2 weeks 3 days - 1 week 2 days + // = June 15 + 14 + 3 = July 2, then - 7 - 2 = June 23 + string input = "June 15, 2026 + 2 weeks 3 days - 1 week 2 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2026, 6, 23).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_AllDateUnits() + { + CalculationService service = new(); + // January 1, 2020 + 1 decade 2 years 3 months 2 weeks 5 days + // = Jan 1 2030 + 2 years = Jan 1 2032 + 3 months = Apr 1 2032 + 14 days = Apr 15 + 5 days = Apr 20 + string input = "January 1, 2020 + 1 decade 2 years 3 months 2 weeks 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2032, 4, 20).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WithTimeInput() + { + CalculationService service = new(); + // 3/1/2026 8:00am + 1 day 4 hours 30 mins + // = 3/2/2026 8:00am + 4:30 = 12:30pm + string input = "3/1/2026 8:00am + 1 day 4 hours 30 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains(new DateTime(2026, 3, 2).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("12:30pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakExplicitOperators() + { + CalculationService service = new(); + // Existing explicit-operator style should still work identically + string input = "January 1, 2026 + 2 weeks + 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakRegularMath() + { + CalculationService service = new(); + string input = "5 + 3"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("8", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakQuantityWords() + { + CalculationService service = new(); + string input = "5 million + 3 thousand"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("5003000", result.Output.Replace(",", "")); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_Pregnancy() + { + CalculationService service = new(); + // Due date calculation: conception + 9 months 1 week + string input = "June 15, 2026 + 9 months 1 week"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // June 15 + 9 months = March 15 2027, + 1 week = March 22 2027 + Assert.Equal(new DateTime(2027, 3, 22).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_SprintPlanning() + { + CalculationService service = new(); + // Sprint starts Monday 9am, lasts 2 weeks 3 days 6 hours + string input = "3/2/2026 9:00am + 2 weeks 3 days 6 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // March 2 + 14 + 3 = March 19, 9am + 6h = 3pm + Assert.Contains(new DateTime(2026, 3, 19).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("3:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary() + { + CalculationService service = new(); + // Depart Jan 10 at 6:30am, travel 1 day 14 hours 45 mins + string input = "1/10/2026 6:30am + 1 day 14 hours 45 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Jan 10 6:30am + 1d 14h 45m = Jan 11 6:30am + 14h 45m = Jan 11 9:15pm + Assert.Contains(new DateTime(2026, 1, 11).ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("9:15pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Combined Duration Segment Tests + + #region Date Subtraction (Date - Date = Timespan) Tests + + [Fact] + public async Task DateTimeMath_DateSubtraction_TwoDatesYieldTimespan() + { + CalculationService service = new(); + // March 10, 2026 - January 1, 2026 = 2 months 1 week 2 days + string input = "March 10, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 months", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("2 days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_SameDateReturnsZeroSeconds() + { + CalculationService service = new(); + string input = "January 1, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("0 seconds", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_ReversedOrderStillWorks() + { + CalculationService service = new(); + // Earlier date first should still give positive result + string input = "January 1, 2026 - March 10, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 months", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("2 days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WithKeywords() + { + CalculationService service = new(); + // today - yesterday = 1 day + string input = "today - yesterday"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 day", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_ExactlyOneYear() + { + CalculationService service = new(); + string input = "January 1, 2027 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 year", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_MultipleYearsMonthsDays() + { + CalculationService service = new(); + // July 15, 2028 - January 1, 2026 = 2 years 6 months 2 weeks + string input = "July 15, 2028 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 years", result.Output); + Assert.Contains("6 months", result.Output); + Assert.Contains("2 weeks", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WithTimes_IncludesHoursMinutesSeconds() + { + CalculationService service = new(); + // 3/1/2026 10:30:45am - 3/1/2026 8:00:00am = 2 hours 30 minutes 45 seconds + string input = "3/1/2026 10:30:45am - 3/1/2026 8:00:00am"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 hours", result.Output); + Assert.Contains("30 minutes", result.Output); + Assert.Contains("45 seconds", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_NumericDateFormat() + { + CalculationService service = new(); + // 6/15/2026 - 6/1/2026 = 2 weeks + string input = "6/15/2026 - 6/1/2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("2 weeks", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_OneDayDifference() + { + CalculationService service = new(); + string input = "March 2, 2026 - March 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 day", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WeeksAndDays() + { + CalculationService service = new(); + // 10 days = 1 week 3 days + string input = "January 11, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1 week", result.Output); + Assert.Contains("3 days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_SingularUnits() + { + CalculationService service = new(); + // 1 year 1 month 1 week 1 day + // Feb 1 2026 + 1y = Feb 1 2027, + 1m = Mar 1 2027, + 1w = Mar 8, + 1d = Mar 9 + string input = "March 9, 2027 - February 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1 year", result.Output); + Assert.Contains("1 month", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("1 day", result.Output); + Assert.DoesNotContain("years", result.Output); + Assert.DoesNotContain("months", result.Output); + Assert.DoesNotContain("weeks", result.Output); + Assert.DoesNotContain("days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Date Subtraction (Date - Date = Timespan) Tests + + #region Date Operator Continuation Tests + + [Fact] + public async Task CalculationService_DatePlusDuration_ThenOperatorContinuation() + { + // Arrange - "March 1, 2026 + 2 weeks" then "+ 1 month" should chain + CalculationService service = new(); + string input = "March 1, 2026 + 2 weeks\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + // March 1 + 2 weeks = March 15 + Assert.Contains(new DateTime(2026, 3, 15).ToString("d", CultureInfo.CurrentCulture), lines[0]); + // March 15 + 1 month = April 15 + Assert.Contains(new DateTime(2026, 4, 15).ToString("d", CultureInfo.CurrentCulture), lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateChainedThreeLines() + { + // Arrange - chain three date operations + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n+ 1 month\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Contains(new DateTime(2026, 3, 1).ToString("d", CultureInfo.CurrentCulture), lines[1]); // Feb 1 + 1 month = Mar 1 + Assert.Contains(new DateTime(2026, 4, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); // Mar 1 + 1 month = Apr 1 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateMinusDuration_OperatorContinuation() + { + // Arrange - subtract duration from previous date result + CalculationService service = new(); + string input = "March 15, 2026 + 1 month\n- 1 week"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains(new DateTime(2026, 4, 15).ToString("d", CultureInfo.CurrentCulture), lines[0]); // March 15 + 1 month = April 15 + Assert.Contains(new DateTime(2026, 4, 8).ToString("d", CultureInfo.CurrentCulture), lines[1]); // April 15 - 1 week = April 8 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateWithTime_OperatorContinuation() + { + // Arrange - date with time then add hours + CalculationService service = new(); + string input = "March 1, 2026 10:00am + 5 hours\n+ 3 hours"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + // March 1 10am + 5 hours = March 1 3pm + Assert.Contains("3:00pm", lines[0].ToLowerInvariant()); + // 3pm + 3 hours = 6pm + Assert.Contains("6:00pm", lines[1].ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateContinuation_CommentDoesNotResetDate() + { + // Arrange - comment between date lines should preserve the date + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n// add another month\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Equal("", lines[1]); // comment + Assert.Contains(new DateTime(2026, 3, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); // Feb 1 + 1 month = Mar 1 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateFollowedByNumericExpression_ResetsDateContext() + { + // Arrange - numeric expression after date should not carry date forward + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n5 + 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); + Assert.Equal("8", lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Date Operator Continuation Tests + + #region Operator Continuation Tests + + [Fact] + public async Task CalculationService_LineStartingWithMultiply_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "2 + 3\n* 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("5", lines[0]); // 2 + 3 = 5 + Assert.Equal("20", lines[1]); // 5 * 4 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithDivide_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "100\n/ 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("100", lines[0]); + Assert.Equal("25", lines[1]); // 100 / 4 = 25 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithPlus_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("15", lines[1]); // 10 + 5 = 15 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithMinus_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n- 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("7", lines[1]); // 10 - 3 = 7 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_ChainedOperatorContinuation_AccumulatesResults() + { + // Arrange - running total: 10 -> 20 -> 15 -> 45 + CalculationService service = new(); + string input = "10\n+ 10\n- 5\n* 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(4, lines.Length); + Assert.Equal("10", lines[0]); // 10 + Assert.Equal("20", lines[1]); // 10 + 10 = 20 + Assert.Equal("15", lines[2]); // 20 - 5 = 15 + Assert.Equal("45", lines[3]); // 15 * 3 = 45 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorAfterVariableAssignment_UsesAssignmentValue() + { + // Arrange + CalculationService service = new(); + string input = "x = 10\n* 2"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("x = 10", lines[0]); + Assert.Equal("20", lines[1]); // 10 * 2 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithNoPreviousResult_FirstLine() + { + // Arrange - + with space but no previous result on first line + // This will try to evaluate "+ 5" which NCalc can't handle + CalculationService service = new() { ShowErrors = true }; + string input = "+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert - should produce an error since there's no left operand + Assert.Contains("Error", result.Output); + Assert.Equal(1, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorSkipsComments_UsesPreviousResult() + { + // Arrange - comments/empty lines don't reset previous result + CalculationService service = new(); + string input = "10\n// this is a comment\n+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("", lines[1]); // comment line + Assert.Equal("15", lines[2]); // 10 + 5 = 15 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorAfterExpression_UsesPreviousOutput() + { + // Arrange - operator continues from previous expression output + CalculationService service = new(); + string input = "3 * 4\n+ 8"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("12", lines[0]); // 3 * 4 = 12 + Assert.Equal("20", lines[1]); // 12 + 8 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithParentheses_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n* (2 + 3)"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("50", lines[1]); // 10 * (2 + 3) = 50 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_MultiplyWithNoSpace_UsesPreviousResult() + { + // Arrange - *2 (no space) should still work for * since it can't be unary + CalculationService service = new(); + string input = "10\n*2"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("20", lines[1]); // 10 * 2 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithDecimalPreviousResult() + { + // Arrange - previous result is a decimal + CalculationService service = new(); + string input = "2.5\n* 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("2.5", lines[0]); + Assert.Equal("10", lines[1]); // 2.5 * 4 = 10 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public void StartsWithBinaryOperator_ValidOperators_ReturnsTrue() + { + Assert.True(CalculationService.StartsWithBinaryOperator("+ 5")); + Assert.True(CalculationService.StartsWithBinaryOperator("- 3")); + Assert.True(CalculationService.StartsWithBinaryOperator("* 2")); + Assert.True(CalculationService.StartsWithBinaryOperator("/ 4")); + Assert.True(CalculationService.StartsWithBinaryOperator("*2")); + Assert.True(CalculationService.StartsWithBinaryOperator("*(2+3)")); + } + + [Fact] + public void StartsWithBinaryOperator_InvalidPatterns_ReturnsFalse() + { + Assert.False(CalculationService.StartsWithBinaryOperator("")); + Assert.False(CalculationService.StartsWithBinaryOperator("5 + 3")); + Assert.False(CalculationService.StartsWithBinaryOperator("x")); + Assert.False(CalculationService.StartsWithBinaryOperator("+")); // single char, too short + Assert.False(CalculationService.StartsWithBinaryOperator("+5")); // unary plus (no space) + Assert.False(CalculationService.StartsWithBinaryOperator("-3")); // negative number (no space) + Assert.False(CalculationService.StartsWithBinaryOperator("-10 + 5")); // negative number expression + } + + #endregion Operator Continuation Tests +} diff --git a/Tests/CaptureLanguageUtilitiesTests.cs b/Tests/CaptureLanguageUtilitiesTests.cs new file mode 100644 index 00000000..b992513d --- /dev/null +++ b/Tests/CaptureLanguageUtilitiesTests.cs @@ -0,0 +1,125 @@ +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Utilities; + +namespace Tests; + +[Collection("Settings isolation")] +public class CaptureLanguageUtilitiesTests : IDisposable +{ + private readonly bool _originalUiAutomationEnabled; + + public CaptureLanguageUtilitiesTests() + { + _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + } + + public void Dispose() + { + Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + + [Fact] + public void MatchesPersistedLanguage_MatchesByLanguageTag() + { + UiAutomationLang language = new(); + + bool matches = CaptureLanguageUtilities.MatchesPersistedLanguage(language, UiAutomationLang.Tag); + + Assert.True(matches); + } + + [Fact] + public void MatchesPersistedLanguage_MatchesLegacyTesseractDisplayName() + { + TessLang language = new("eng"); + + bool matches = CaptureLanguageUtilities.MatchesPersistedLanguage(language, language.CultureDisplayName); + + Assert.True(matches); + } + + [Fact] + public void FindPreferredLanguageIndex_PrefersPersistedMatchBeforeFallbackLanguage() + { + List languages = + [ + new UiAutomationLang(), + new WindowsAiLang(), + new GlobalLang("en-US") + ]; + + int index = CaptureLanguageUtilities.FindPreferredLanguageIndex( + languages, + UiAutomationLang.Tag, + new GlobalLang("en-US")); + + Assert.Equal(0, index); + } + + [WpfFact] + public async Task GetCaptureLanguagesAsync_ExcludesUiAutomationByDefault() + { + Settings.Default.UiAutomationEnabled = false; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.DoesNotContain(languages, language => language is UiAutomationLang); + } + + [WpfFact] + public async Task GetCaptureLanguagesAsync_IncludesUiAutomationWhenEnabled() + { + Settings.Default.UiAutomationEnabled = true; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.Contains(languages, language => language is UiAutomationLang); + } + + [Fact] + public void SupportsTableOutput_ReturnsFalseForUiAutomation() + { + Assert.False(CaptureLanguageUtilities.SupportsTableOutput(new UiAutomationLang())); + } + + [Fact] + public void RequiresLiveUiAutomationSource_ReturnsTrueForStaticUiAutomationWithoutSnapshot() + { + bool requiresLiveSource = CaptureLanguageUtilities.RequiresLiveUiAutomationSource( + new UiAutomationLang(), + isStaticImageSource: true, + hasFrozenUiAutomationSnapshot: false); + + Assert.True(requiresLiveSource); + } + + [Fact] + public void RequiresLiveUiAutomationSource_ReturnsFalseWhenFrozenSnapshotExists() + { + bool requiresLiveSource = CaptureLanguageUtilities.RequiresLiveUiAutomationSource( + new UiAutomationLang(), + isStaticImageSource: true, + hasFrozenUiAutomationSnapshot: true); + + Assert.False(requiresLiveSource); + } + + [Fact] + public void RequiresLiveUiAutomationSource_ReturnsFalseForOcrLanguageOnStaticImage() + { + bool requiresLiveSource = CaptureLanguageUtilities.RequiresLiveUiAutomationSource( + new GlobalLang("en-US"), + isStaticImageSource: true, + hasFrozenUiAutomationSnapshot: false); + + Assert.False(requiresLiveSource); + } +} diff --git a/Tests/ContextMenuTests.cs b/Tests/ContextMenuTests.cs new file mode 100644 index 00000000..8f5e1f17 --- /dev/null +++ b/Tests/ContextMenuTests.cs @@ -0,0 +1,62 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class ContextMenuTests +{ + [Fact] + public void GetShellKeyPath_ReturnsCorrectPath_ForPngExtension() + { + // Arrange + string extension = ".png"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.png\shell\Text-Grab.GrabText"; + + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void GetShellKeyPath_ReturnsCorrectPath_ForJpgExtension() + { + // Arrange + string extension = ".jpg"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.jpg\shell\Text-Grab.GrabText"; + + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void GetShellKeyPath_ReturnsCorrectPath_ForTiffExtension() + { + // Arrange + string extension = ".tiff"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.tiff\shell\Text-Grab.GrabText"; + + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void GetShellKeyPath_ReturnsConsistentFormat() + { + // Act + string pngPath = ContextMenuUtilities.GetShellKeyPath(".png"); + string jpgPath = ContextMenuUtilities.GetShellKeyPath(".jpg"); + + // Assert - Both should follow the same pattern + Assert.StartsWith(@"Software\Classes\SystemFileAssociations\", pngPath); + Assert.StartsWith(@"Software\Classes\SystemFileAssociations\", jpgPath); + Assert.EndsWith(@"\shell\Text-Grab.GrabText", pngPath); + Assert.EndsWith(@"\shell\Text-Grab.GrabText", jpgPath); + } +} diff --git a/Tests/DiagnosticsTests.cs b/Tests/DiagnosticsTests.cs index 613633f1..cafd2b8b 100644 --- a/Tests/DiagnosticsTests.cs +++ b/Tests/DiagnosticsTests.cs @@ -59,14 +59,17 @@ public async Task BugReport_ContainsStartupPathDiagnostics() // Act string bugReport = await DiagnosticsUtilities.GenerateBugReportAsync(); - // Assert - Should contain startup diagnostics to verify the fix + // Assert - Should contain startup diagnostics (PII-safe fields only) Assert.Contains("startupDetails", bugReport); - Assert.Contains("calculatedRegistryValue", bugReport); - Assert.Contains("actualRegistryValue", bugReport); - Assert.Contains("baseDirectory", bugReport); + Assert.Contains("executableFileName", bugReport); + Assert.Contains("registryValueStatus", bugReport); - // The bug report should help verify that startup path fix is working + // The executable filename (without path) should be present — full paths are redacted Assert.Contains("Text-Grab.exe", bugReport); + // Full paths that would expose the local username must NOT appear + Assert.DoesNotContain("baseDirectory", bugReport); + Assert.DoesNotContain("calculatedRegistryValue", bugReport); + Assert.DoesNotContain("actualRegistryValue", bugReport); } [Fact] @@ -77,11 +80,86 @@ public async Task BugReport_IncludesAllRequestedInformation() // Assert - Should contain all requested information from issue #553 Assert.Contains("settingsInfo", bugReport); // Settings - Assert.Contains("installationType", bugReport); // Type of install + Assert.Contains("installationType", bugReport); // Type of install Assert.Contains("startupDetails", bugReport); // Startup location details Assert.Contains("windowsVersion", bugReport); // Windows version Assert.Contains("historyInfo", bugReport); // Amount of history Assert.Contains("languageInfo", bugReport); // Installed languages Assert.Contains("tesseractInfo", bugReport); // Tesseract details + Assert.Contains("managedSettingsSummary", bugReport); // Post-grab actions, patterns, shortcuts + } + + [Fact] + public async Task BugReport_SettingsInfo_ContainsAllKeySettings() + { + string bugReport = await DiagnosticsUtilities.GenerateBugReportAsync(); + + // Grab behavior + Assert.Contains("\"tryInsert\"", bugReport); + Assert.Contains("\"insertDelay\"", bugReport); + Assert.Contains("\"closeFrameOnGrab\"", bugReport); + Assert.Contains("\"postGrabStayOpen\"", bugReport); + + // OCR + Assert.Contains("\"correctErrors\"", bugReport); + Assert.Contains("\"correctToLatin\"", bugReport); + Assert.Contains("\"useTesseract\"", bugReport); + Assert.Contains("\"tesseractPathConfigured\"", bugReport); // bool only — no path exposed + Assert.Contains("\"uiAutomationEnabled\"", bugReport); + Assert.Contains("\"uiAutomationFallbackToOcr\"", bugReport); + + // Display + Assert.Contains("\"appTheme\"", bugReport); + Assert.Contains("\"fontSizeSetting\"", bugReport); + + // Edit Text Window + Assert.Contains("\"editWindowIsWordWrapOn\"", bugReport); + Assert.Contains("\"etwShowWordCount\"", bugReport); + Assert.Contains("\"etwUseMargins\"", bugReport); + + // Fullscreen grab + Assert.Contains("\"fsgDefaultMode\"", bugReport); + Assert.Contains("\"fsgSelectionStyle\"", bugReport); + + // Grab Frame + Assert.Contains("\"grabFrameTranslationEnabled\"", bugReport); + Assert.Contains("\"grabFrameScrollBehavior\"", bugReport); + } + + [Fact] + public async Task BugReport_ManagedSettingsSummary_ContainsExpectedFields() + { + string bugReport = await DiagnosticsUtilities.GenerateBugReportAsync(); + + Assert.Contains("\"regexPatternCount\"", bugReport); + Assert.Contains("\"regexCustomPatternCount\"", bugReport); + Assert.Contains("\"regexCustomPatternNames\"", bugReport); + Assert.Contains("\"postGrabActionCount\"", bugReport); + Assert.Contains("\"postGrabActionNames\"", bugReport); + Assert.Contains("\"shortcutKeySetCount\"", bugReport); + Assert.Contains("\"bottomBarButtonCount\"", bugReport); + Assert.Contains("\"webSearchUrlCount\"", bugReport); + Assert.Contains("\"grabTemplateCount\"", bugReport); + } + + [Fact] + public async Task BugReport_DoesNotContainPii() + { + string bugReport = await DiagnosticsUtilities.GenerateBugReportAsync(); + + // Fields removed from StartupDetailsModel (contained full paths with username) + Assert.DoesNotContain("\"baseDirectory\"", bugReport); + Assert.DoesNotContain("\"calculatedRegistryValue\"", bugReport); + Assert.DoesNotContain("\"actualRegistryValue\"", bugReport); + + // No absolute Windows paths should appear anywhere in the report + Assert.DoesNotContain(@"C:\Users\", bugReport); + Assert.DoesNotContain(@"C:\Program", bugReport); + + // The old TesseractPath string field must not appear (replaced by bool TesseractPathConfigured) + Assert.DoesNotContain("\"tesseractPath\"", bugReport); + + // Web search URLs must not appear (only the count is included) + Assert.DoesNotContain("\"webSearchUrls\"", bugReport); } } diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 24b980a6..18808438 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -1,5 +1,6 @@ using System.Drawing; using Text_Grab; +using Text_Grab.Models; using Text_Grab.Utilities; namespace Tests; @@ -19,6 +20,35 @@ public async Task CanSaveImagesWithHistory() Assert.True(couldSave); } + [WpfFact] + public async Task SaveImageFile_SucceedsAfterClearTransientImage() + { + // Reproduces the race condition: SaveImageFile returns a Task that + // may still be running when ClearTransientImage nulls the bitmap. + // The save must complete successfully even when ClearTransientImage + // is called immediately after the fire-and-forget pattern used by + // HistoryService.SaveToHistory. + string path = FileUtilities.GetPathToLocalFile(fontSamplePath); + Bitmap bitmap = new(path); + + HistoryInfo historyInfo = new() + { + ID = "save-race-test", + ImageContent = bitmap, + ImagePath = $"race_test_{Guid.NewGuid()}.bmp", + }; + + Task saveTask = FileUtilities.SaveImageFile( + historyInfo.ImageContent, historyInfo.ImagePath, FileStorageKind.WithHistory); + + // Mirrors what HistoryService.SaveToHistory does right after the + // fire-and-forget call — must not cause saveTask to fail. + historyInfo.ClearTransientImage(); + + bool couldSave = await saveTask; + Assert.True(couldSave); + } + [WpfFact] public async Task CanSaveTextFilesWithExe() { diff --git a/Tests/FreeformCaptureUtilitiesTests.cs b/Tests/FreeformCaptureUtilitiesTests.cs new file mode 100644 index 00000000..3cf860ff --- /dev/null +++ b/Tests/FreeformCaptureUtilitiesTests.cs @@ -0,0 +1,63 @@ +using System.Drawing; +using System.Windows; +using System.Windows.Media; +using Text_Grab.Utilities; +using Point = System.Windows.Point; + +namespace Tests; + +public class FreeformCaptureUtilitiesTests +{ + [WpfFact] + public void GetBounds_RoundsOutwardToIncludeAllPoints() + { + List points = + [ + new(1.2, 2.8), + new(10.1, 4.2), + new(4.6, 9.9) + ]; + + Rect bounds = FreeformCaptureUtilities.GetBounds(points); + + Assert.Equal(new Rect(new Point(1, 2), new Point(11, 10)), bounds); + } + + [WpfFact] + public void BuildGeometry_CreatesClosedFigure() + { + List points = + [ + new(0, 0), + new(4, 0), + new(4, 4) + ]; + + PathGeometry geometry = FreeformCaptureUtilities.BuildGeometry(points); + + Assert.Single(geometry.Figures); + Assert.Equal(points[0], geometry.Figures[0].StartPoint); + Assert.True(geometry.Figures[0].IsClosed); + Assert.Equal(2, geometry.Figures[0].Segments.Count); + } + + [WpfFact] + public void CreateMaskedBitmap_WhitensPixelsOutsideThePolygon() + { + using Bitmap sourceBitmap = new(10, 10); + using Graphics graphics = Graphics.FromImage(sourceBitmap); + graphics.Clear(System.Drawing.Color.Black); + + using Bitmap maskedBitmap = FreeformCaptureUtilities.CreateMaskedBitmap( + sourceBitmap, + [ + new Point(2, 2), + new Point(7, 2), + new Point(7, 7), + new Point(2, 7) + ]); + + Assert.Equal(System.Drawing.Color.Gray.ToArgb(), maskedBitmap.GetPixel(0, 0).ToArgb()); + Assert.Equal(System.Drawing.Color.Black.ToArgb(), maskedBitmap.GetPixel(4, 4).ToArgb()); + } +} diff --git a/Tests/FullscreenCaptureResultTests.cs b/Tests/FullscreenCaptureResultTests.cs new file mode 100644 index 00000000..c2eaf52e --- /dev/null +++ b/Tests/FullscreenCaptureResultTests.cs @@ -0,0 +1,32 @@ +using System.Windows; +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class FullscreenCaptureResultTests +{ + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, true)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void SupportsTemplateActions_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + FullscreenCaptureResult result = new(selectionStyle, Rect.Empty); + + Assert.Equal(expected, result.SupportsTemplateActions); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void SupportsPreviousRegionReplay_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + FullscreenCaptureResult result = new(selectionStyle, Rect.Empty); + + Assert.Equal(expected, result.SupportsPreviousRegionReplay); + } +} diff --git a/Tests/FullscreenGrabPostGrabActionTests.cs b/Tests/FullscreenGrabPostGrabActionTests.cs new file mode 100644 index 00000000..e40bcc4e --- /dev/null +++ b/Tests/FullscreenGrabPostGrabActionTests.cs @@ -0,0 +1,127 @@ +using System.Windows.Controls; +using Text_Grab.Models; +using Text_Grab.Views; +using Wpf.Ui.Controls; +using MenuItem = System.Windows.Controls.MenuItem; + +namespace Tests; + +public class FullscreenGrabPostGrabActionTests +{ + [Fact] + public void GetPostGrabActionKey_UsesTemplateIdForTemplateActions() + { + ButtonInfo action = new("Template Action", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-123" + }; + + string key = FullscreenGrab.GetPostGrabActionKey(action); + + Assert.Equal("template:template-123", key); + } + + [Fact] + public void GetPostGrabActionKey_FallsBackToButtonTextWhenClickEventMissing() + { + ButtonInfo action = new() + { + ButtonText = "Custom action" + }; + + string key = FullscreenGrab.GetPostGrabActionKey(action); + + Assert.Equal("text:Custom action", key); + } + + [WpfFact] + public void GetActionablePostGrabMenuItems_ExcludesUtilityEntriesAndPreservesOrder() + { + ContextMenu contextMenu = new(); + MenuItem firstAction = new() + { + Header = "First action", + Tag = new ButtonInfo("First action", "First_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + }; + MenuItem utilityItem = new() + { + Header = "Customize", + Tag = "EditPostGrabActions" + }; + MenuItem secondAction = new() + { + Header = "Second action", + Tag = new ButtonInfo("Second action", "Second_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + }; + + contextMenu.Items.Add(firstAction); + contextMenu.Items.Add(new Separator()); + contextMenu.Items.Add(utilityItem); + contextMenu.Items.Add(secondAction); + contextMenu.Items.Add(new MenuItem + { + Header = "Close this menu", + Tag = "ClosePostGrabMenu" + }); + + List actionableItems = FullscreenGrab.GetActionablePostGrabMenuItems(contextMenu); + + Assert.Collection(actionableItems, + item => Assert.Same(firstAction, item), + item => Assert.Same(secondAction, item)); + } + + [WpfFact] + public void BuildPostGrabActionSnapshot_KeepsChangedTemplateCheckedAndUnchecksOthers() + { + ButtonInfo regularAction = new("Trim each line", "TrimEachLine_Click", SymbolRegular.Apps24, DefaultCheckState.Off); + ButtonInfo firstTemplate = new("Template A", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-a" + }; + ButtonInfo secondTemplate = new("Template B", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-b" + }; + + Dictionary snapshot = FullscreenGrab.BuildPostGrabActionSnapshot( + [ + new MenuItem { Tag = regularAction, IsCheckable = true, IsChecked = true }, + new MenuItem { Tag = firstTemplate, IsCheckable = true, IsChecked = true }, + new MenuItem { Tag = secondTemplate, IsCheckable = true, IsChecked = false } + ], + FullscreenGrab.GetPostGrabActionKey(secondTemplate), + true); + + Assert.True(snapshot[FullscreenGrab.GetPostGrabActionKey(regularAction)]); + Assert.False(snapshot[FullscreenGrab.GetPostGrabActionKey(firstTemplate)]); + Assert.True(snapshot[FullscreenGrab.GetPostGrabActionKey(secondTemplate)]); + } + + [Fact] + public void ShouldPersistLastUsedState_ForForcedSourceAction_ReturnsTrue() + { + ButtonInfo lastUsedAction = new("Remove duplicate lines", "RemoveDuplicateLines_Click", SymbolRegular.Apps24, DefaultCheckState.LastUsed); + + bool shouldPersist = FullscreenGrab.ShouldPersistLastUsedState( + lastUsedAction, + previousChecked: true, + isChecked: true, + forcePersistActionKey: FullscreenGrab.GetPostGrabActionKey(lastUsedAction)); + + Assert.True(shouldPersist); + } + + [Fact] + public void ShouldPersistLastUsedState_DoesNotPersistUnchangedNonSourceAction() + { + ButtonInfo lastUsedAction = new("Remove duplicate lines", "RemoveDuplicateLines_Click", SymbolRegular.Apps24, DefaultCheckState.LastUsed); + + bool shouldPersist = FullscreenGrab.ShouldPersistLastUsedState( + lastUsedAction, + previousChecked: true, + isChecked: true); + + Assert.False(shouldPersist); + } +} diff --git a/Tests/FullscreenGrabSelectionStyleTests.cs b/Tests/FullscreenGrabSelectionStyleTests.cs new file mode 100644 index 00000000..9ab30270 --- /dev/null +++ b/Tests/FullscreenGrabSelectionStyleTests.cs @@ -0,0 +1,74 @@ +using System.Windows; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Views; + +namespace Tests; + +public class FullscreenGrabSelectionStyleTests +{ + [Theory] + [InlineData(FsgSelectionStyle.Window, false, true)] + [InlineData(FsgSelectionStyle.Window, true, true)] + [InlineData(FsgSelectionStyle.Region, true, true)] + [InlineData(FsgSelectionStyle.Region, false, false)] + [InlineData(FsgSelectionStyle.Freeform, false, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, false, false)] + public void ShouldKeepTopToolbarVisible_MatchesSelectionState( + FsgSelectionStyle selectionStyle, + bool isAwaitingAdjustAfterCommit, + bool expected) + { + bool shouldKeepVisible = FullscreenGrab.ShouldKeepTopToolbarVisible( + selectionStyle, + isAwaitingAdjustAfterCommit); + + Assert.Equal(expected, shouldKeepVisible); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void ShouldUseOverlayCutout_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + bool shouldUseCutout = FullscreenGrab.ShouldUseOverlayCutout(selectionStyle); + + Assert.Equal(expected, shouldUseCutout); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void ShouldDrawSelectionOutline_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + bool shouldDrawOutline = FullscreenGrab.ShouldDrawSelectionOutline(selectionStyle); + + Assert.Equal(expected, shouldDrawOutline); + } + + [Fact] + public void ShouldCommitWindowSelection_RequiresSameWindowHandleOnMouseUp() + { + WindowSelectionCandidate pressedCandidate = new((nint)1, new Rect(0, 0, 40, 40), "Target", 100); + WindowSelectionCandidate releasedSameCandidate = new((nint)1, new Rect(0, 0, 40, 40), "Target", 100); + WindowSelectionCandidate releasedDifferentCandidate = new((nint)2, new Rect(0, 0, 40, 40), "Other", 200); + + Assert.True(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, releasedSameCandidate)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, releasedDifferentCandidate)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, null)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(null, releasedSameCandidate)); + } + + [Fact] + public void WindowSelectionCandidate_DisplayText_UsesFallbacksWhenMetadataMissing() + { + WindowSelectionCandidate candidate = new((nint)1, new Rect(0, 0, 40, 40), string.Empty, 100); + + Assert.Equal("Application", candidate.DisplayAppName); + Assert.Equal("Untitled window", candidate.DisplayTitle); + } +} diff --git a/Tests/FullscreenGrabZoomCaptureTests.cs b/Tests/FullscreenGrabZoomCaptureTests.cs new file mode 100644 index 00000000..df437ebb --- /dev/null +++ b/Tests/FullscreenGrabZoomCaptureTests.cs @@ -0,0 +1,90 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Text_Grab.Views; + +namespace Tests; + +public class FullscreenGrabZoomCaptureTests +{ + [Fact] + public void TryGetBitmapCropRectForSelection_UsesSelectionRectWithoutZoom() + { + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + new Rect(10, 20, 30, 40), + Matrix.Identity, + null, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(10, cropRect.X); + Assert.Equal(20, cropRect.Y); + Assert.Equal(30, cropRect.Width); + Assert.Equal(40, cropRect.Height); + } + + [Fact] + public void TryGetBitmapCropRectForSelection_MapsZoomedSelectionBackToFrozenBitmap() + { + TransformGroup zoomTransform = new(); + zoomTransform.Children.Add(new ScaleTransform(2, 2, 50, 50)); + zoomTransform.Children.Add(new TranslateTransform(-10, 15)); + + Rect sourceRect = new(40, 50, 20, 10); + Rect displayedSelectionRect = TransformRect(sourceRect, zoomTransform.Value); + + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + displayedSelectionRect, + Matrix.Identity, + zoomTransform, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(40, cropRect.X); + Assert.Equal(50, cropRect.Y); + Assert.Equal(20, cropRect.Width); + Assert.Equal(10, cropRect.Height); + } + + [Fact] + public void TryGetBitmapCropRectForSelection_AppliesDeviceScalingAfterUndoingZoom() + { + ScaleTransform zoomTransform = new(2, 2); + + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + new Rect(20, 30, 40, 50), + new Matrix(1.5, 0, 0, 1.5, 0, 0), + zoomTransform, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(15, cropRect.X); + Assert.Equal(22, cropRect.Y); + Assert.Equal(30, cropRect.Width); + Assert.Equal(38, cropRect.Height); + } + + private static Rect TransformRect(Rect rect, Matrix matrix) + { + Point[] points = + [ + matrix.Transform(rect.TopLeft), + matrix.Transform(new Point(rect.Right, rect.Top)), + matrix.Transform(new Point(rect.Left, rect.Bottom)), + matrix.Transform(rect.BottomRight) + ]; + + double left = points.Min(static point => point.X); + double top = points.Min(static point => point.Y); + double right = points.Max(static point => point.X); + double bottom = points.Max(static point => point.Y); + + return new Rect(new Point(left, top), new Point(right, bottom)); + } +} diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs new file mode 100644 index 00000000..18f17917 --- /dev/null +++ b/Tests/GrabTemplateExecutorTests.cs @@ -0,0 +1,475 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateExecutorTests +{ + // ── ApplyOutputTemplate – basic substitution ────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_SingleRegion_SubstitutesCorrectly() + { + Dictionary regions = new() { [1] = "Alice" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("Name: {1}", regions); + Assert.Equal("Name: Alice", result); + } + + [Fact] + public void ApplyOutputTemplate_MultipleRegions_SubstitutesAll() + { + Dictionary regions = new() + { + [1] = "Alice", + [2] = "alice@example.com" + }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} <{2}>", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_MissingRegion_ReplacesWithEmpty() + { + Dictionary regions = new() { [1] = "Alice" }; + // Region 2 not present + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} {2}", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_EmptyTemplate_ReturnsEmpty() + { + Dictionary regions = new() { [1] = "value" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate(string.Empty, regions); + Assert.Equal(string.Empty, result); + } + + // ── Modifiers ────────────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_TrimModifier_TrimsWhitespace() + { + Dictionary regions = new() { [1] = " hello " }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:trim}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UpperModifier_ConvertsToUpper() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:upper}", regions); + Assert.Equal("HELLO", result); + } + + [Fact] + public void ApplyOutputTemplate_LowerModifier_ConvertsToLower() + { + Dictionary regions = new() { [1] = "HELLO" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:lower}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UnknownModifier_LeavesTextAsIs() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:unknown}", regions); + Assert.Equal("hello", result); + } + + // ── Escape sequences ────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_NewlineEscape_InsertsNewline() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\n{2}", regions); + Assert.Equal("A\nB", result); + } + + [Fact] + public void ApplyOutputTemplate_TabEscape_InsertsTab() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\t{2}", regions); + Assert.Equal("A\tB", result); + } + + [Fact] + public void ApplyOutputTemplate_LiteralBraceEscape_PreservesBrace() + { + Dictionary regions = new() { [1] = "value" }; + // \{ in template produces literal {, then {1} → value, then literal text } + string result = GrabTemplateExecutor.ApplyOutputTemplate("\\{{1}}", regions); + Assert.Equal("{value}", result); + } + + [Fact] + public void ApplyOutputTemplate_DoubleBackslash_PreservesBackslash() + { + Dictionary regions = new() { [1] = "A" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\\\{1}", regions); + Assert.Equal(@"A\A", result); + } + + // ── ValidateOutputTemplate ──────────────────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidTemplate_ReturnsNoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{1} {2}", [1, 2]); + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_OutOfRangeRegion_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{3}", [1, 2]); + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains('3')); + } + + [Fact] + public void ValidateOutputTemplate_EmptyTemplate_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate(string.Empty, [1]); + Assert.NotEmpty(issues); + } + + [Fact] + public void ValidateOutputTemplate_NoRegionsReferenced_ReturnsIssue() + { + // Template has no {N} references + List issues = GrabTemplateExecutor.ValidateOutputTemplate("static text", [1, 2]); + Assert.NotEmpty(issues); + } + + // ── Pattern placeholder – ApplyPatternPlaceholders ──────────────────────── + + [Fact] + public void ApplyPatternPlaceholders_FirstMatch_ReturnsFirstOccurrence() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: alice@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_LastMatch_ReturnsLastOccurrence() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "last") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:last}", fullText, patterns, regexes); + + Assert.Equal("Email: bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_AllMatches_JoinsWithDefaultSeparator() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "all", ", ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Emails: {p:Email:all}", fullText, patterns, regexes); + + Assert.Equal("Emails: alice@test.com, bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_AllMatchesCustomSeparator_UsesOverride() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "all", ", ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Emails: {p:Email:all: | }", fullText, patterns, regexes); + + Assert.Equal("Emails: alice@test.com | bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_NthMatch_ReturnsSingleIndex() + { + string fullText = "Numbers: 100 200 300"; + List patterns = + [ + new("id1", "Integer", "2") + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Second: {p:Integer:2}", fullText, patterns, regexes); + + Assert.Equal("Second: 200", result); + } + + [Fact] + public void ApplyPatternPlaceholders_MultipleIndices_JoinsSelected() + { + string fullText = "Numbers: 100 200 300 400"; + List patterns = + [ + new("id1", "Integer", "1,3", "; ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Selected: {p:Integer:1,3}", fullText, patterns, regexes); + + Assert.Equal("Selected: 100; 300", result); + } + + [Fact] + public void ApplyPatternPlaceholders_NoMatches_ReturnsEmpty() + { + string fullText = "No emails here"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: ", result); + } + + [Fact] + public void ApplyPatternPlaceholders_PatternNotFound_ReturnsEmpty() + { + string fullText = "Some text"; + List patterns = + [ + new("id1", "Email", "first") + ]; + // No regexes provided for this pattern + Dictionary regexes = []; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: ", result); + } + + [Fact] + public void ApplyPatternPlaceholders_UnknownPatternName_LeavesPlaceholder() + { + string fullText = "Some text"; + List patterns = []; // no patterns registered + Dictionary regexes = []; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Data: {p:Unknown:first}", fullText, patterns, regexes); + + Assert.Equal("Data: {p:Unknown:first}", result); + } + + [Fact] + public void ApplyPatternPlaceholders_IndexOutOfRange_ReturnsEmpty() + { + string fullText = "One: 100"; + List patterns = + [ + new("id1", "Integer", "5") // only 1 match, requesting 5th + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Fifth: {p:Integer:5}", fullText, patterns, regexes); + + Assert.Equal("Fifth: ", result); + } + + // ── ExtractMatchesByMode ────────────────────────────────────────────────── + + [Fact] + public void ExtractMatchesByMode_First_ReturnsFirst() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "first", ", "); + Assert.Equal("abc", result); + } + + [Fact] + public void ExtractMatchesByMode_Last_ReturnsLast() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "last", ", "); + Assert.Equal("ghi", result); + } + + [Fact] + public void ExtractMatchesByMode_All_JoinsAll() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "all", " | "); + Assert.Equal("abc | def | ghi", result); + } + + // ── Hybrid template (regions + patterns) ────────────────────────────────── + + [Fact] + public void HybridTemplate_RegionsAndPatterns_BothResolved() + { + // First apply regions + Dictionary regions = new() { [1] = "John Doe" }; + string template = "Name: {1}\\nEmail: {p:Email:first}"; + string afterRegions = GrabTemplateExecutor.ApplyOutputTemplate(template, regions); + + // Then apply patterns + string fullText = "Contact john@example.com for details"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + afterRegions, fullText, patterns, regexes); + + Assert.Equal("Name: John Doe\nEmail: john@example.com", result); + } + + // ── GrabTemplate model ──────────────────────────────────────────────────── + + [Fact] + public void GrabTemplate_IsValid_PatternOnlyTemplate() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "{p:Email:first}", + PatternMatches = [new("id1", "Email", "first")] + }; + + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_RequiresNameAndOutput() + { + GrabTemplate template = new() + { + PatternMatches = [new("id1", "Email", "first")] + }; + + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_GetReferencedPatternNames_ParsesNames() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "Email: {p:Email Address:first}\\nPhone: {p:Phone Number:all:, }" + }; + + List names = [.. template.GetReferencedPatternNames()]; + Assert.Equal(2, names.Count); + Assert.Contains("Email Address", names); + Assert.Contains("Phone Number", names); + } + + // ── ValidateOutputTemplate with patterns ────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidPatternPlaceholder_NoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Email:first}", + [], + ["Email"]); + + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_UnknownPatternName_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Unknown Pattern:first}", + [], + ["Email"]); + + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains("Unknown Pattern")); + } + + [Fact] + public void ValidateOutputTemplate_InvalidMatchMode_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Email:invalid_mode}", + [], + ["Email"]); + + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains("invalid_mode")); + } +} diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs new file mode 100644 index 00000000..90ea2ef3 --- /dev/null +++ b/Tests/GrabTemplateManagerTests.cs @@ -0,0 +1,287 @@ +using System.IO; +using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Utilities; + +namespace Tests; + +[Collection("Settings isolation")] +public class GrabTemplateManagerTests : IDisposable +{ + private readonly string _tempFilePath; + private readonly string _tempImagesFolder; + private readonly string _originalGrabTemplatesJson; + private readonly bool _originalEnableFileBackedManagedSettings; + private readonly bool? _originalTestPreferFileBackedMode; + + public GrabTemplateManagerTests() + { + _tempFilePath = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Test_{Guid.NewGuid()}.json"); + _tempImagesFolder = Path.Combine(Path.GetTempPath(), $"GrabTemplateImages_Test_{Guid.NewGuid()}"); + _originalGrabTemplatesJson = Settings.Default.GrabTemplatesJSON; + _originalEnableFileBackedManagedSettings = Settings.Default.EnableFileBackedManagedSettings; + _originalTestPreferFileBackedMode = GrabTemplateManager.TestPreferFileBackedMode; + + GrabTemplateManager.TestFilePath = _tempFilePath; + GrabTemplateManager.TestImagesFolderPath = _tempImagesFolder; + GrabTemplateManager.TestPreferFileBackedMode = false; + + Settings.Default.GrabTemplatesJSON = string.Empty; + Settings.Default.EnableFileBackedManagedSettings = false; + Settings.Default.Save(); + } + + public void Dispose() + { + GrabTemplateManager.TestFilePath = null; + GrabTemplateManager.TestImagesFolderPath = null; + GrabTemplateManager.TestPreferFileBackedMode = _originalTestPreferFileBackedMode; + + Settings.Default.GrabTemplatesJSON = _originalGrabTemplatesJson; + Settings.Default.EnableFileBackedManagedSettings = _originalEnableFileBackedManagedSettings; + Settings.Default.Save(); + + if (File.Exists(_tempFilePath)) + File.Delete(_tempFilePath); + + if (Directory.Exists(_tempImagesFolder)) + Directory.Delete(_tempImagesFolder, true); + } + + [Fact] + public void GetAllTemplates_WhenEmpty_ReturnsEmptyList() + { + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void GetAllTemplates_BackfillsLegacyFromSidecarWhenLegacyMissing() + { + GrabTemplate template = CreateSampleTemplate("Recovered"); + File.WriteAllText(_tempFilePath, JsonSerializer.Serialize(new[] { template })); + + List templates = GrabTemplateManager.GetAllTemplates(); + + GrabTemplate recoveredTemplate = Assert.Single(templates); + Assert.Equal(template.Id, recoveredTemplate.Id); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + } + + [Fact] + public void GetAllTemplates_FileBackedModePrefersFileAndBackfillsLegacy() + { + GrabTemplateManager.TestPreferFileBackedMode = true; + GrabTemplate legacyTemplate = CreateSampleTemplate("Legacy"); + GrabTemplate sidecarTemplate = CreateSampleTemplate("Sidecar"); + + Settings.Default.GrabTemplatesJSON = JsonSerializer.Serialize(new[] { legacyTemplate }); + Settings.Default.Save(); + File.WriteAllText(_tempFilePath, JsonSerializer.Serialize(new[] { sidecarTemplate })); + + List templates = GrabTemplateManager.GetAllTemplates(); + + GrabTemplate preferredTemplate = Assert.Single(templates); + Assert.Equal(sidecarTemplate.Id, preferredTemplate.Id); + Assert.Contains(sidecarTemplate.Id, Settings.Default.GrabTemplatesJSON); + } + + [Fact] + public void SaveTemplates_WritesBothFileAndLegacySetting() + { + GrabTemplate template = CreateSampleTemplate("Invoice"); + + GrabTemplateManager.SaveTemplates([template]); + + Assert.True(File.Exists(_tempFilePath)); + Assert.Contains(template.Id, File.ReadAllText(_tempFilePath)); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + } + + [Fact] + public void GetAllTemplates_AfterAddingTemplate_ReturnsSavedTemplate() + { + GrabTemplate template = CreateSampleTemplate("Invoice"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Invoice", templates[0].Name); + } + + [Fact] + public void GetTemplateById_ExistingId_ReturnsTemplate() + { + GrabTemplate template = CreateSampleTemplate("Business Card"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplate? found = GrabTemplateManager.GetTemplateById(template.Id); + + Assert.NotNull(found); + Assert.Equal(template.Id, found.Id); + Assert.Equal("Business Card", found.Name); + } + + [Fact] + public void GetTemplateById_NonExistentId_ReturnsNull() + { + GrabTemplate? found = GrabTemplateManager.GetTemplateById("non-existent-id"); + Assert.Null(found); + } + + [Fact] + public void AddOrUpdateTemplate_AddNew_IncrementsCount() + { + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T1")); + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T2")); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Equal(2, templates.Count); + } + + [Fact] + public void AddOrUpdateTemplate_UpdateExisting_ReplacesByIdNotDuplicate() + { + GrabTemplate original = CreateSampleTemplate("Original Name"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + original.Name = "Updated Name"; + GrabTemplateManager.AddOrUpdateTemplate(original); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Updated Name", templates[0].Name); + } + + [Fact] + public void DeleteTemplate_ExistingId_RemovesTemplate() + { + GrabTemplate template = CreateSampleTemplate("ToDelete"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplateManager.DeleteTemplate(template.Id); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void DeleteTemplate_NonExistentId_DoesNotThrow() + { + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("Keeper")); + GrabTemplateManager.DeleteTemplate("does-not-exist"); + + Assert.Single(GrabTemplateManager.GetAllTemplates()); + } + + [Fact] + public void DuplicateTemplate_ValidId_CreatesNewTemplateWithCopyPrefix() + { + GrabTemplate original = CreateSampleTemplate("My Template"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate(original.Id); + + Assert.NotNull(copy); + Assert.NotEqual(original.Id, copy.Id); + Assert.Contains("(copy)", copy.Name); + Assert.Equal(2, GrabTemplateManager.GetAllTemplates().Count); + } + + [Fact] + public void DuplicateTemplate_NonExistentId_ReturnsNull() + { + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate("not-there"); + Assert.Null(copy); + } + + [Fact] + public void CreateButtonInfoForTemplate_SetsTemplateId() + { + GrabTemplate template = CreateSampleTemplate("Card"); + + Text_Grab.Models.ButtonInfo button = GrabTemplateManager.CreateButtonInfoForTemplate(template); + + Assert.Equal(template.Id, button.TemplateId); + Assert.Equal("ApplyTemplate_Click", button.ClickEvent); + Assert.Equal(template.Name, button.ButtonText); + } + + [Fact] + public void GetAllTemplates_CorruptJson_ReturnsEmptyList() + { + File.WriteAllText(_tempFilePath, "{ this is not valid json }}}"); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void GrabTemplate_IsValid_TrueWhenNameAndOutputTemplateSet() + { + GrabTemplate template = CreateSampleTemplate("Valid"); + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_TrueWhenNoRegionsButHasNameAndOutputTemplate() + { + GrabTemplate template = CreateSampleTemplate("Text Only"); + template.Regions.Clear(); + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenNameEmpty() + { + GrabTemplate template = CreateSampleTemplate(string.Empty); + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenOutputTemplateEmpty() + { + GrabTemplate template = CreateSampleTemplate("No Output"); + template.OutputTemplate = string.Empty; + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_GetReferencedRegionNumbers_ParsesPlaceholders() + { + GrabTemplate template = CreateSampleTemplate("Multi"); + template.OutputTemplate = "{1} {2} {1:upper}"; + + HashSet referenced = template.GetReferencedRegionNumbers().ToHashSet(); + + Assert.Contains(1, referenced); + Assert.Contains(2, referenced); + Assert.Equal(2, referenced.Count); + } + + private static GrabTemplate CreateSampleTemplate(string name) + { + return new GrabTemplate + { + Id = Guid.NewGuid().ToString(), + Name = name, + Description = "Test template", + OutputTemplate = "{1}", + ReferenceImageWidth = 800, + ReferenceImageHeight = 600, + Regions = + [ + new Text_Grab.Models.TemplateRegion + { + RegionNumber = 1, + Label = "Field 1", + RatioLeft = 0.1, + RatioTop = 0.1, + RatioWidth = 0.5, + RatioHeight = 0.1, + } + ] + }; + } +} diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs new file mode 100644 index 00000000..c2965c80 --- /dev/null +++ b/Tests/HistoryServiceTests.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Services; +using Text_Grab.Utilities; + +namespace Tests; + +[Collection("History service")] +public class HistoryServiceTests +{ + private static readonly JsonSerializerOptions HistoryJsonOptions = new() + { + AllowTrailingCommas = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + [WpfFact] + public async Task TextHistory_LazyLoadsAgainAfterRelease() + { + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "text-1", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), + TextContent = "first text history", + SourceMode = TextGrabMode.EditText + } + ]); + + HistoryService historyService = new(); + + Assert.Equal("first text history", historyService.GetLastTextHistory()); + + historyService.ReleaseLoadedHistories(); + + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "text-2", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), + TextContent = "second text history", + SourceMode = TextGrabMode.EditText + } + ]); + + Assert.Equal("second text history", historyService.GetLastTextHistory()); + } + + [WpfFact] + public async Task ImageHistory_LazyLoadsAgainAfterRelease() + { + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-1", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), + TextContent = "first image history", + ImagePath = "one.bmp", + SourceMode = TextGrabMode.GrabFrame + } + ]); + + HistoryService historyService = new(); + + Assert.Equal("one.bmp", Assert.Single(historyService.GetRecentGrabs()).ImagePath); + + historyService.ReleaseLoadedHistories(); + + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-2", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), + TextContent = "second image history", + ImagePath = "two.bmp", + SourceMode = TextGrabMode.Fullscreen + } + ]); + + Assert.Equal("two.bmp", Assert.Single(historyService.GetRecentGrabs()).ImagePath); + Assert.Equal("image-2", historyService.GetLastFullScreenGrabInfo()?.ID); + } + + [WpfFact] + public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarStorage() + { + string inlineWordBorderJson = JsonSerializer.Serialize( + new List + { + new() + { + Word = "hello", + BorderRect = new Rect(1, 2, 30, 40), + LineNumber = 1, + ResultColumnID = 2, + ResultRowID = 3 + } + }, + HistoryJsonOptions); + + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-with-borders", + CaptureDateTime = new DateTimeOffset(2024, 1, 3, 12, 0, 0, TimeSpan.Zero), + TextContent = "history with borders", + ImagePath = "borders.bmp", + SourceMode = TextGrabMode.GrabFrame, + WordBorderInfoJson = inlineWordBorderJson + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetRecentGrabs()); + + Assert.Equal(inlineWordBorderJson, historyItem.WordBorderInfoJson); + Assert.Equal("image-with-borders.wordborders.json", historyItem.WordBorderInfoFileName); + + List wordBorderInfos = await historyService.GetWordBorderInfosAsync(historyItem); + WordBorderInfo wordBorderInfo = Assert.Single(wordBorderInfos); + Assert.Equal("hello", wordBorderInfo.Word); + Assert.Equal(30d, wordBorderInfo.BorderRect.Width); + Assert.Equal(40d, wordBorderInfo.BorderRect.Height); + + historyService.ReleaseLoadedHistories(); + + string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); + Assert.Contains("\"WordBorderInfoJson\"", savedHistoryJson); + Assert.Contains("\"WordBorderInfoFileName\"", savedHistoryJson); + + string savedWordBorderJson = await FileUtilities.GetTextFileAsync(historyItem.WordBorderInfoFileName!, FileStorageKind.WithHistory); + Assert.Contains("hello", savedWordBorderJson); + } + + [WpfFact] + public async Task ImageHistory_NormalizesPreviewUiAutomationEntriesToRollbackSafeValues() + { + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "uia-preview", + CaptureDateTime = new DateTimeOffset(2024, 1, 4, 12, 0, 0, TimeSpan.Zero), + TextContent = "direct text history", + ImagePath = "uia.bmp", + SourceMode = TextGrabMode.Fullscreen, + LanguageTag = UiAutomationLang.Tag, + LanguageKind = LanguageKind.UiAutomation, + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetRecentGrabs()); + + Assert.True(historyItem.UsedUiAutomation); + Assert.Equal(LanguageKind.Global, historyItem.LanguageKind); + Assert.NotEqual(UiAutomationLang.Tag, historyItem.LanguageTag); + Assert.IsNotType(historyItem.OcrLanguage); + + historyService.WriteHistory(); + historyService.ReleaseLoadedHistories(); + + string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); + Assert.DoesNotContain("\"LanguageKind\": \"UiAutomation\"", savedHistoryJson); + Assert.DoesNotContain($"\"LanguageTag\": \"{UiAutomationLang.Tag}\"", savedHistoryJson); + Assert.Contains("\"UsedUiAutomation\": true", savedHistoryJson); + } + + private static Task SaveHistoryFileAsync(string fileName, List historyItems) + { + string historyJson = JsonSerializer.Serialize(historyItems, HistoryJsonOptions); + return FileUtilities.SaveTextFile(historyJson, fileName, FileStorageKind.WithHistory); + } +} + +[CollectionDefinition("History service", DisableParallelization = true)] +public class HistoryServiceCollectionDefinition +{ +} diff --git a/Tests/ImageMethodsTests.cs b/Tests/ImageMethodsTests.cs new file mode 100644 index 00000000..4d166e39 --- /dev/null +++ b/Tests/ImageMethodsTests.cs @@ -0,0 +1,49 @@ +using System.Drawing; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Text_Grab; + +namespace Tests; + +public class ImageMethodsTests +{ + [WpfFact] + public void ImageSourceToBitmap_ConvertsBitmapSourceDerivedImages() + { + byte[] pixels = + [ + 0, 0, 255, 255, + 0, 255, 0, 255, + 255, 0, 0, 255, + 255, 255, 255, 255 + ]; + + BitmapSource source = BitmapSource.Create( + 2, + 2, + 96, + 96, + PixelFormats.Bgra32, + null, + pixels, + 8); + CroppedBitmap cropped = new(source, new Int32Rect(1, 0, 1, 2)); + + using Bitmap? bitmap = ImageMethods.ImageSourceToBitmap(cropped); + + Assert.NotNull(bitmap); + Assert.Equal(1, bitmap!.Width); + Assert.Equal(2, bitmap.Height); + } + + [WpfFact] + public void ImageSourceToBitmap_ReturnsNullForNonBitmapImageSources() + { + DrawingImage drawingImage = new(); + + Bitmap? bitmap = ImageMethods.ImageSourceToBitmap(drawingImage); + + Assert.Null(bitmap); + } +} diff --git a/Tests/LanguageServiceTests.cs b/Tests/LanguageServiceTests.cs index 649331f3..17109145 100644 --- a/Tests/LanguageServiceTests.cs +++ b/Tests/LanguageServiceTests.cs @@ -1,152 +1,196 @@ using Text_Grab; +using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Properties; using Text_Grab.Services; using Text_Grab.Utilities; using Windows.Globalization; namespace Tests; -public class LanguageServiceTests +[Collection("Settings isolation")] +public class LanguageServiceTests : IDisposable { + private readonly string _originalLastUsedLang; + private readonly bool _originalUiAutomationEnabled; + + public LanguageServiceTests() + { + _originalLastUsedLang = Settings.Default.LastUsedLang; + _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + } + + public void Dispose() + { + Settings.Default.LastUsedLang = _originalLastUsedLang; + Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + [Fact] public void GetLanguageTag_WithGlobalLang_ReturnsCorrectTag() { - // Arrange GlobalLang globalLang = new("en-US"); - // Act string tag = LanguageService.GetLanguageTag(globalLang); - // Assert Assert.Equal("en-US", tag); } [Fact] public void GetLanguageTag_WithWindowsAiLang_ReturnsWinAI() { - // Arrange WindowsAiLang windowsAiLang = new(); - // Act string tag = LanguageService.GetLanguageTag(windowsAiLang); - // Assert Assert.Equal("WinAI", tag); } + [Fact] + public void GetLanguageTag_WithUiAutomationLang_ReturnsUiAutomationTag() + { + UiAutomationLang uiAutomationLang = new(); + + string tag = LanguageService.GetLanguageTag(uiAutomationLang); + + Assert.Equal(UiAutomationLang.Tag, tag); + } + [Fact] public void GetLanguageTag_WithTessLang_ReturnsRawTag() { - // Arrange TessLang tessLang = new("eng"); - // Act string tag = LanguageService.GetLanguageTag(tessLang); - // Assert Assert.Equal("eng", tag); } [Fact] public void GetLanguageTag_WithLanguage_ReturnsLanguageTag() { - // Arrange Language language = new("en-US"); - // Act string tag = LanguageService.GetLanguageTag(language); - // Assert Assert.Equal("en-US", tag); } [Fact] public void GetLanguageKind_WithGlobalLang_ReturnsGlobal() { - // Arrange GlobalLang globalLang = new("en-US"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(globalLang); - // Assert Assert.Equal(LanguageKind.Global, kind); } [Fact] public void GetLanguageKind_WithWindowsAiLang_ReturnsWindowsAi() { - // Arrange WindowsAiLang windowsAiLang = new(); - // Act LanguageKind kind = LanguageService.GetLanguageKind(windowsAiLang); - // Assert Assert.Equal(LanguageKind.WindowsAi, kind); } + [Fact] + public void GetLanguageKind_WithUiAutomationLang_ReturnsUiAutomation() + { + UiAutomationLang uiAutomationLang = new(); + + LanguageKind kind = LanguageService.GetLanguageKind(uiAutomationLang); + + Assert.Equal(LanguageKind.UiAutomation, kind); + } + [Fact] public void GetLanguageKind_WithTessLang_ReturnsTesseract() { - // Arrange TessLang tessLang = new("eng"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(tessLang); - // Assert Assert.Equal(LanguageKind.Tesseract, kind); } [Fact] public void GetLanguageKind_WithLanguage_ReturnsGlobal() { - // Arrange Language language = new("en-US"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(language); - // Assert Assert.Equal(LanguageKind.Global, kind); } [Fact] public void GetLanguageKind_WithUnknownType_ReturnsGlobal() { - // Arrange object unknownLang = "some string"; - // Act LanguageKind kind = LanguageService.GetLanguageKind(unknownLang); - // Assert - Assert.Equal(LanguageKind.Global, kind); // Default fallback + Assert.Equal(LanguageKind.Global, kind); + } + + [Fact] + public void GetPersistedLanguageIdentity_ForUiAutomationUsesRollbackSafeGlobalLanguage() + { + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageService.GetPersistedLanguageIdentity(new UiAutomationLang()); + + Assert.True(usedUiAutomation); + Assert.Equal(LanguageKind.Global, languageKind); + Assert.NotEqual(UiAutomationLang.Tag, languageTag); + } + + [Fact] + public void GetOCRLanguage_WhenUiAutomationWasLastUsedButFeatureIsDisabled_FallsBack() + { + Settings.Default.UiAutomationEnabled = false; + Settings.Default.LastUsedLang = UiAutomationLang.Tag; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + ILanguage language = Singleton.Instance.GetOCRLanguage(); + + Assert.IsNotType(language); } [Fact] public void LanguageService_IsSingleton() { - // Act LanguageService instance1 = Singleton.Instance; LanguageService instance2 = Singleton.Instance; - // Assert Assert.Same(instance1, instance2); } [Fact] public void LanguageUtilities_DelegatesTo_LanguageService() { - // This test ensures backward compatibility - static methods should work - // Arrange & Act GlobalLang globalLang = new("en-US"); string tag = LanguageUtilities.GetLanguageTag(globalLang); LanguageKind kind = LanguageUtilities.GetLanguageKind(globalLang); - // Assert Assert.Equal("en-US", tag); Assert.Equal(LanguageKind.Global, kind); } + + [Fact] + public void HistoryInfo_OcrLanguage_FallsBackForUiAutomationPersistence() + { + HistoryInfo historyInfo = new() + { + LanguageTag = UiAutomationLang.Tag, + LanguageKind = LanguageKind.UiAutomation, + }; + + Assert.IsNotType(historyInfo.OcrLanguage); + } } diff --git a/Tests/SettingsImportExportTests.cs b/Tests/SettingsImportExportTests.cs index 2e37f487..c2191879 100644 --- a/Tests/SettingsImportExportTests.cs +++ b/Tests/SettingsImportExportTests.cs @@ -1,9 +1,14 @@ +using System; using System.IO; using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Services; using Text_Grab.Utilities; namespace Tests; +[Collection("Settings isolation")] public class SettingsImportExportTests { [WpfFact] @@ -145,4 +150,250 @@ public async Task RoundTripSettingsExportImportPreservesAllValues() if (Directory.Exists(modifiedTempDir)) Directory.Delete(modifiedTempDir, true); if (Directory.Exists(reimportedTempDir)) Directory.Delete(reimportedTempDir, true); } + + [WpfFact] + public async Task ManagedJsonSettingWithDataSurvivesRoundTrip() + { + SettingsService settingsService = AppUtilities.TextGrabSettingsService; + StoredRegex[] originalRegexes = settingsService.LoadStoredRegexes(); + + StoredRegex[] testRegexes = + [ + new StoredRegex + { + Id = "export-roundtrip-1", + Name = "Date Pattern", + Pattern = @"\d{4}-\d{2}-\d{2}", + Description = "ISO date for export round-trip test", + } + ]; + settingsService.SaveStoredRegexes(testRegexes); + + string zipPath = string.Empty; + string verifyDir = string.Empty; + + try + { + // Export and confirm the managed setting's file content appears in settings.json + zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + + verifyDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Verify_{Guid.NewGuid()}"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, verifyDir); + string exportedJson = await File.ReadAllTextAsync(Path.Combine(verifyDir, "settings.json")); + Assert.Contains("export-roundtrip-1", exportedJson); + + // Clear the managed setting to simulate import on a clean machine + settingsService.SaveStoredRegexes([]); + Assert.Empty(settingsService.LoadStoredRegexes()); + + // Import from the previously exported ZIP + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(zipPath); + + // The regex must be restored from the imported data + StoredRegex[] restoredRegexes = settingsService.LoadStoredRegexes(); + StoredRegex restored = Assert.Single(restoredRegexes); + Assert.Equal("export-roundtrip-1", restored.Id); + Assert.Equal(@"\d{4}-\d{2}-\d{2}", restored.Pattern); + } + finally + { + settingsService.SaveStoredRegexes(originalRegexes); + + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (Directory.Exists(verifyDir)) + Directory.Delete(verifyDir, true); + } + } + + [WpfFact] + public async Task ExportedSettingsJsonIncludesManagedSettingKeys() + { + string zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + string tempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Test_{Guid.NewGuid()}"); + + try + { + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir); + string jsonContent = await File.ReadAllTextAsync(Path.Combine(tempDir, "settings.json")); + + // All six managed-JSON setting names must appear as keys in the export + Assert.True(jsonContent.Contains("regexList", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("shortcutKeySets", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("bottomButtonsJson", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("webSearchItemsJson", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("postGrabJSON", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("postGrabCheckStates", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + /// + /// Simulates importing a ZIP that was produced by the old (memory-inefficient) app, + /// where managed JSON settings were stored as inline strings inside Properties.Settings + /// rather than in sidecar files. The new import pipeline must route those inline blobs + /// to the correct sidecar files so the SettingsService can load them normally. + /// + [WpfFact] + public async Task LegacyExportWithInlineManagedSettingsIsImportedToSidecarFiles() + { + SettingsService settingsService = AppUtilities.TextGrabSettingsService; + + StoredRegex[] originalRegexes = settingsService.LoadStoredRegexes(); + Dictionary originalCheckStates = settingsService.LoadPostGrabCheckStates(); + + // Build a legacy-style settings.json: managed JSON blobs stored directly as + // string values under camelCase keys, exactly as the old export produced them. + StoredRegex legacyRegex = new() + { + Id = "legacy-regex-001", + Name = "Legacy Invoice", + Pattern = @"INV-\d{5}", + Description = "Imported from legacy export", + }; + string regexArrayJson = JsonSerializer.Serialize(new[] { legacyRegex }); + + Dictionary legacyCheckStates = new() { ["Legacy Action"] = true }; + string checkStatesJson = JsonSerializer.Serialize(legacyCheckStates); + + // The old export wrote settings with camelCase keys and plain string values + // for what are now managed-JSON settings. + Dictionary legacySettings = new() + { + // managed settings stored inline (old behaviour) + ["regexList"] = regexArrayJson, + ["postGrabCheckStates"] = checkStatesJson, + // a normal boolean setting to confirm regular settings still import + ["correctErrors"] = false, + }; + + string legacyJson = JsonSerializer.Serialize(legacySettings, new JsonSerializerOptions { WriteIndented = true }); + + string legacyDir = Path.Combine(Path.GetTempPath(), $"TextGrab_LegacyDir_{Guid.NewGuid()}"); + string legacyZipPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Legacy_{Guid.NewGuid()}.zip"); + Directory.CreateDirectory(legacyDir); + + try + { + await File.WriteAllTextAsync(Path.Combine(legacyDir, "settings.json"), legacyJson); + System.IO.Compression.ZipFile.CreateFromDirectory(legacyDir, legacyZipPath); + + // Start from a clean state so the assertion is unambiguous + settingsService.SaveStoredRegexes([]); + settingsService.SavePostGrabCheckStates(new Dictionary()); + Assert.Empty(settingsService.LoadStoredRegexes()); + Assert.Empty(settingsService.LoadPostGrabCheckStates()); + + // Act: import the legacy ZIP + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(legacyZipPath); + + // Assert – array-type managed setting + StoredRegex[] importedRegexes = settingsService.LoadStoredRegexes(); + StoredRegex importedRegex = Assert.Single(importedRegexes); + Assert.Equal("legacy-regex-001", importedRegex.Id); + Assert.Equal(@"INV-\d{5}", importedRegex.Pattern); + + // Assert – dictionary-type managed setting + Dictionary importedCheckStates = settingsService.LoadPostGrabCheckStates(); + Assert.True(importedCheckStates.ContainsKey("Legacy Action")); + Assert.True(importedCheckStates["Legacy Action"]); + + // Assert – a plain (non-managed) setting came through too + Assert.False(AppUtilities.TextGrabSettings.CorrectErrors); + } + finally + { + // Restore originals regardless of pass/fail + settingsService.SaveStoredRegexes(originalRegexes); + settingsService.SavePostGrabCheckStates(originalCheckStates); + AppUtilities.TextGrabSettings.CorrectErrors = true; + + if (File.Exists(legacyZipPath)) + File.Delete(legacyZipPath); + if (Directory.Exists(legacyDir)) + Directory.Delete(legacyDir, true); + } + } + + [WpfFact] + public async Task ExportImportRoundTripsGrabTemplatesAndTemplateImages() + { + string tempTemplateFile = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Export_{Guid.NewGuid():N}.json"); + string tempImagesFolder = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Images_{Guid.NewGuid():N}"); + string zipPath = string.Empty; + string originalGrabTemplatesJson = Settings.Default.GrabTemplatesJSON; + string? originalTestFilePath = GrabTemplateManager.TestFilePath; + string? originalTestImagesFolderPath = GrabTemplateManager.TestImagesFolderPath; + bool? originalTestPreferFileBackedMode = GrabTemplateManager.TestPreferFileBackedMode; + + GrabTemplateManager.TestFilePath = tempTemplateFile; + GrabTemplateManager.TestImagesFolderPath = tempImagesFolder; + GrabTemplateManager.TestPreferFileBackedMode = false; + + try + { + Directory.CreateDirectory(tempImagesFolder); + + string referenceImagePath = Path.Combine(tempImagesFolder, "reference.png"); + await File.WriteAllBytesAsync(referenceImagePath, [1, 2, 3, 4]); + + GrabTemplate template = new() + { + Id = "template-export-1", + Name = "Invoice Template", + OutputTemplate = "{1}", + SourceImagePath = referenceImagePath, + Regions = + [ + new TemplateRegion + { + RegionNumber = 1, + Label = "Amount", + RatioLeft = 0.1, + RatioTop = 0.1, + RatioWidth = 0.3, + RatioHeight = 0.1, + } + ] + }; + + GrabTemplateManager.SaveTemplates([template]); + + zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + + GrabTemplateManager.SaveTemplates([]); + + if (File.Exists(referenceImagePath)) + File.Delete(referenceImagePath); + + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(zipPath); + + GrabTemplate restoredTemplate = Assert.Single(GrabTemplateManager.GetAllTemplates()); + Assert.Equal(template.Id, restoredTemplate.Id); + Assert.Equal(template.Name, restoredTemplate.Name); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + Assert.True(File.Exists(referenceImagePath)); + } + finally + { + GrabTemplateManager.TestFilePath = originalTestFilePath; + GrabTemplateManager.TestImagesFolderPath = originalTestImagesFolderPath; + GrabTemplateManager.TestPreferFileBackedMode = originalTestPreferFileBackedMode; + Settings.Default.GrabTemplatesJSON = originalGrabTemplatesJson; + Settings.Default.Save(); + + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (File.Exists(tempTemplateFile)) + File.Delete(tempTemplateFile); + if (Directory.Exists(tempImagesFolder)) + Directory.Delete(tempImagesFolder, true); + } + } } diff --git a/Tests/SettingsIsolationCollection.cs b/Tests/SettingsIsolationCollection.cs new file mode 100644 index 00000000..06d87e14 --- /dev/null +++ b/Tests/SettingsIsolationCollection.cs @@ -0,0 +1,6 @@ +namespace Tests; + +[CollectionDefinition("Settings isolation", DisableParallelization = true)] +public class SettingsIsolationCollectionDefinition +{ +} diff --git a/Tests/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs new file mode 100644 index 00000000..bab8d29e --- /dev/null +++ b/Tests/SettingsServiceTests.cs @@ -0,0 +1,154 @@ +using System.IO; +using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Services; + +namespace Tests; + +public class SettingsServiceTests : IDisposable +{ + private readonly string _tempFolder; + + public SettingsServiceTests() + { + _tempFolder = Path.Combine(Path.GetTempPath(), $"TextGrab_SettingsService_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempFolder); + } + + public void Dispose() + { + if (Directory.Exists(_tempFolder)) + Directory.Delete(_tempFolder, true); + } + + [Fact] + public void LoadStoredRegexes_DefaultModePrefersLegacyAndKeepsLegacyPopulated() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false, + RegexList = SerializeRegexes("legacy-regex") + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("sidecar-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("legacy-regex", loadedRegex.Id); + Assert.Contains("legacy-regex", settings.RegexList); + Assert.Contains("legacy-regex", File.ReadAllText(regexFilePath)); + } + + [Fact] + public void LoadStoredRegexes_DefaultModeBackfillsLegacyFromSidecarWhenNeeded() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false, + RegexList = string.Empty + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("recovered-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("recovered-regex", loadedRegex.Id); + Assert.Contains("recovered-regex", settings.RegexList); + Assert.Equal(File.ReadAllText(regexFilePath), settings.RegexList); + } + + [Fact] + public void LoadStoredRegexes_FileBackedModePrefersSidecarAndBackfillsLegacy() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = true, + RegexList = SerializeRegexes("legacy-regex") + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("sidecar-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("sidecar-regex", loadedRegex.Id); + Assert.Contains("sidecar-regex", settings.RegexList); + Assert.Contains("sidecar-regex", File.ReadAllText(regexFilePath)); + } + + [Fact] + public void SavePostGrabCheckStates_FileBackedModeWritesBothStores() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = true + }; + SettingsService service = CreateService(settings); + + service.SavePostGrabCheckStates(new Dictionary + { + ["Fix GUIDs"] = true + }); + + string filePath = Path.Combine(_tempFolder, "PostGrabCheckStates.json"); + Assert.Contains("Fix GUIDs", settings.PostGrabCheckStates); + Assert.True(File.Exists(filePath)); + Assert.Contains("Fix GUIDs", File.ReadAllText(filePath)); + Assert.True(service.LoadPostGrabCheckStates()["Fix GUIDs"]); + } + + [Fact] + public void ClearingManagedSettingClearsLegacyAndSidecar() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false + }; + SettingsService service = CreateService(settings); + + service.SaveStoredRegexes( + [ + new StoredRegex + { + Id = "clear-me", + Name = "Clear Me", + Pattern = ".*" + } + ]); + + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + Assert.NotEmpty(settings.RegexList); + Assert.True(File.Exists(regexFilePath)); + + settings.RegexList = string.Empty; + + Assert.Equal(string.Empty, settings.RegexList); + Assert.False(File.Exists(regexFilePath)); + Assert.Empty(service.LoadStoredRegexes()); + } + + private SettingsService CreateService(Settings settings) => + new( + settings, + localSettings: null, + managedJsonSettingsFolderPath: _tempFolder, + saveClassicSettingsChanges: false); + + private static string SerializeRegexes(string id) => + JsonSerializer.Serialize(new[] + { + new StoredRegex + { + Id = id, + Name = $"{id} name", + Pattern = @"INV-\d+", + Description = "transition test pattern" + } + }); +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index f248d8ed..69c54639 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + diff --git a/Tests/UiAutomationUtilitiesTests.cs b/Tests/UiAutomationUtilitiesTests.cs new file mode 100644 index 00000000..7d073e32 --- /dev/null +++ b/Tests/UiAutomationUtilitiesTests.cs @@ -0,0 +1,165 @@ +using System.Linq; +using System.Windows; +using System.Windows.Automation; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class UiAutomationUtilitiesTests +{ + [Fact] + public void NormalizeText_TrimsWhitespaceAndCollapsesEmptyLines() + { + string normalized = UIAutomationUtilities.NormalizeText(" Hello world \r\n\r\n Second\tline "); + + Assert.Equal($"Hello world{Environment.NewLine}Second line", normalized); + } + + [Fact] + public void TryAddUniqueText_DeduplicatesNormalizedValues() + { + HashSet seen = []; + List output = []; + + bool addedFirst = UIAutomationUtilities.TryAddUniqueText(" Hello world ", seen, output); + bool addedSecond = UIAutomationUtilities.TryAddUniqueText("Hello world", seen, output); + + Assert.True(addedFirst); + Assert.False(addedSecond); + Assert.Single(output); + } + + [Fact] + public void FindTargetWindowCandidate_PrefersCenterPointHit() + { + WindowSelectionCandidate first = new((nint)1, new Rect(0, 0, 80, 80), "First", 1); + WindowSelectionCandidate second = new((nint)2, new Rect(90, 0, 80, 80), "Second", 2); + + WindowSelectionCandidate? candidate = UIAutomationUtilities.FindTargetWindowCandidate( + new Rect(100, 10, 20, 20), + [first, second]); + + Assert.Same(second, candidate); + } + + [Fact] + public void FindTargetWindowCandidate_FallsBackToLargestIntersection() + { + WindowSelectionCandidate first = new((nint)1, new Rect(0, 0, 50, 50), "First", 1); + WindowSelectionCandidate second = new((nint)2, new Rect(60, 0, 80, 80), "Second", 2); + + WindowSelectionCandidate? candidate = UIAutomationUtilities.FindTargetWindowCandidate( + new Rect(40, 40, 30, 30), + [first, second]); + + Assert.Same(second, candidate); + } + + [Fact] + public void ShouldUseNameFallback_SkipsStructuralControls() + { + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Window)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Group)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Pane)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Custom)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Button)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.SplitButton)); + Assert.False(UIAutomationUtilities.ShouldUseNameFallback(ControlType.ComboBox)); + } + + [Fact] + public void ShouldUseNameFallback_AllowsVisibleTextContainers() + { + Assert.True(UIAutomationUtilities.ShouldUseNameFallback(ControlType.Text)); + Assert.True(UIAutomationUtilities.ShouldUseNameFallback(ControlType.ListItem)); + Assert.True(UIAutomationUtilities.ShouldUseNameFallback(ControlType.MenuItem)); + Assert.True(UIAutomationUtilities.ShouldUseNameFallback(ControlType.TabItem)); + } + + [Fact] + public void GetSamplePoints_UsesCenterPointForSmallSelections() + { + IReadOnlyList samplePoints = UIAutomationUtilities.GetSamplePoints(new Rect(10, 20, 40, 30)); + + Point samplePoint = Assert.Single(samplePoints); + Assert.Equal(new Point(30, 35), samplePoint); + } + + [Fact] + public void GetSamplePoints_UsesGridForLargerSelections() + { + IReadOnlyList samplePoints = UIAutomationUtilities.GetSamplePoints(new Rect(0, 0, 100, 100)); + + Assert.Equal(9, samplePoints.Count); + Assert.Contains(new Point(50, 50), samplePoints); + Assert.Contains(new Point(20, 20), samplePoints); + Assert.Contains(new Point(80, 80), samplePoints); + } + + [Fact] + public void GetPointProbePoints_ReturnsCenterThenCrosshairNeighbors() + { + IReadOnlyList probePoints = UIAutomationUtilities.GetPointProbePoints(new Point(25, 40)); + + Assert.Equal(5, probePoints.Count); + Assert.Equal(new Point(25, 40), probePoints[0]); + Assert.Contains(new Point(23, 40), probePoints); + Assert.Contains(new Point(27, 40), probePoints); + Assert.Contains(new Point(25, 38), probePoints); + Assert.Contains(new Point(25, 42), probePoints); + } + + [Fact] + public void TryClipBounds_ReturnsIntersectionForOverlappingRects() + { + bool clipped = UIAutomationUtilities.TryClipBounds( + new Rect(10, 10, 50, 50), + new Rect(30, 25, 50, 50), + out Rect result); + + Assert.True(clipped); + Assert.Equal(new Rect(30, 25, 30, 35), result); + } + + [Fact] + public void TryClipBounds_ReturnsFalseWhenBoundsDoNotIntersect() + { + bool clipped = UIAutomationUtilities.TryClipBounds( + new Rect(10, 10, 20, 20), + new Rect(100, 100, 20, 20), + out Rect result); + + Assert.False(clipped); + Assert.Equal(Rect.Empty, result); + } + + [Fact] + public void TryAddUniqueOverlayItem_DeduplicatesNormalizedTextAndBounds() + { + HashSet seen = []; + List output = []; + UiAutomationOverlayItem first = new(" Hello world ", new Rect(10.01, 20.01, 30.01, 40.01), UiAutomationOverlaySource.ElementBounds); + UiAutomationOverlayItem second = new("Hello world", new Rect(10.04, 20.04, 30.04, 40.04), UiAutomationOverlaySource.VisibleTextRange); + + bool addedFirst = UIAutomationUtilities.TryAddUniqueOverlayItem(first, seen, output); + bool addedSecond = UIAutomationUtilities.TryAddUniqueOverlayItem(second, seen, output); + + Assert.True(addedFirst); + Assert.False(addedSecond); + Assert.Single(output); + } + + [Fact] + public void SortOverlayItems_OrdersTopThenLeft() + { + IReadOnlyList sorted = UIAutomationUtilities.SortOverlayItems( + [ + new UiAutomationOverlayItem("Bottom", new Rect(40, 30, 10, 10), UiAutomationOverlaySource.ElementBounds), + new UiAutomationOverlayItem("Right", new Rect(25, 10, 10, 10), UiAutomationOverlaySource.ElementBounds), + new UiAutomationOverlayItem("Left", new Rect(10, 10, 10, 10), UiAutomationOverlaySource.ElementBounds), + ]); + + Assert.Equal(["Left", "Right", "Bottom"], sorted.Select(item => item.Text)); + } +} diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs new file mode 100644 index 00000000..e8d116a0 --- /dev/null +++ b/Tests/UnitConversionTests.cs @@ -0,0 +1,451 @@ +using System.Globalization; +using Text_Grab.Services; + +namespace Tests; + +public class UnitConversionTests +{ + private readonly CalculationService _service = new(); + + #region Explicit Conversion Tests + + [Theory] + [InlineData("5 miles to km", "km")] + [InlineData("100 fahrenheit to celsius", "°C")] + [InlineData("1 kg to pounds", "lb")] + [InlineData("3.5 gallons to liters", "L")] + [InlineData("60 mph to km/h", "km/h")] + [InlineData("1 acre to sq m", "m²")] + [InlineData("12 inches to feet", "ft")] + [InlineData("1000 grams to kg", "kg")] + [InlineData("50 celsius to fahrenheit", "°F")] + [InlineData("1 nautical mile to km", "km")] + public async Task ExplicitConversion_ContainsTargetUnit(string input, string expectedUnit) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedUnit, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("5 miles to km", 8.047, 0.01)] + [InlineData("1 kg to pounds", 2.205, 0.01)] + [InlineData("100 fahrenheit to celsius", 37.778, 0.01)] + [InlineData("0 celsius to fahrenheit", 32, 0.01)] + [InlineData("100 F to C", 37.778, 0.01)] + [InlineData("0 C to F", 32, 0.01)] + [InlineData("1 foot to inches", 12, 0.01)] + [InlineData("1 mile to feet", 5280, 1)] + [InlineData("1 gallon to liters", 3.785, 0.01)] + [InlineData("1 kg to grams", 1000, 0.01)] + [InlineData("100 cm to meters", 1, 0.01)] + [InlineData("1 tonne to kg", 1000, 0.01)] + public async Task ExplicitConversion_CorrectNumericValue(string input, double expectedValue, double tolerance) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance); + } + + [Theory] + [InlineData("5 in to cm")] // "in" is both inches and keyword — "to" takes priority + [InlineData("10 ft to m")] + [InlineData("3 yd to meters")] + public async Task ExplicitConversion_WithShortAbbreviations(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + [Fact] + public async Task ExplicitConversion_InKeyword_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("5 gallons in liters"); + + Assert.Contains("L", result.Output); + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], 18.9, 18.95); + } + + [Fact] + public async Task ExplicitConversion_ZeroValue_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("0 km to miles"); + + Assert.Contains("mi", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(0, result.OutputNumbers[0], 3); + } + + [Fact] + public async Task ExplicitConversion_NegativeValue_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("-40 celsius to fahrenheit"); + + Assert.Contains("°F", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(-40, result.OutputNumbers[0], 1); + } + + [Fact] + public async Task ExplicitConversion_IncompatibleTypes_FallsThrough() + { + // "5 kg to km" — mass to length should not convert + CalculationResult result = await _service.EvaluateExpressionsAsync("5 kg to km"); + + // Should not produce a clean unit result — falls through to NCalc (which will error) + Assert.True(result.ErrorCount > 0 || !result.Output.Contains("km")); + } + + #endregion Explicit Conversion Tests + + #region Continuation Conversion Tests + + [Fact] + public async Task ContinuationConversion_ToKeyword() + { + string input = "5 miles\nto km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("mi", lines[0]); + Assert.Contains("km", lines[1]); + } + + [Fact] + public async Task ContinuationConversion_CorrectValue() + { + string input = "100 celsius\nto fahrenheit"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(100, result.OutputNumbers[0], 1); + Assert.Equal(212, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task ContinuationConversion_ChainedConversions() + { + string input = "1 mile\nto km\nto meters"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + // 1 mile → 1.609 km → 1609.34 m + Assert.InRange(result.OutputNumbers[2], 1609, 1610); + } + + #endregion Continuation Conversion Tests + + #region Operator Continuation Tests + + [Fact] + public async Task OperatorContinuation_AddSameUnit() + { + string input = "5 km\n+ 3 km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(8, result.OutputNumbers[1], 1); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task OperatorContinuation_SubtractSameUnit() + { + string input = "10 kg\n- 3 kg"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(7, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task OperatorContinuation_AddDifferentUnit_SameType() + { + string input = "5 km\n+ 3 miles"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + // 3 miles ≈ 4.828 km, so 5 + 4.828 ≈ 9.828 km + Assert.InRange(result.OutputNumbers[1], 9.8, 9.9); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task ScaleOperator_Multiply() + { + string input = "5 km\n* 3"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(15, result.OutputNumbers[1], 1); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task ScaleOperator_Divide() + { + string input = "10 meters\n/ 2"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(5, result.OutputNumbers[1], 1); + Assert.Contains("m", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task OperatorContinuation_ThenConvert() + { + string input = "5 km\n+ 3 km\nto miles"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + // 8 km ≈ 4.971 miles + Assert.InRange(result.OutputNumbers[2], 4.9, 5.0); + Assert.Contains("mi", result.Output.Split('\n')[2]); + } + + #endregion Operator Continuation Tests + + #region Standalone Unit Tests + + [Theory] + [InlineData("5 meters", "m")] + [InlineData("100 kg", "kg")] + [InlineData("3.5 gallons", "gal")] + [InlineData("10 miles", "mi")] + [InlineData("25 mph", "mph")] + public async Task StandaloneUnit_DetectedAndDisplayed(string input, string expectedAbbrev) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedAbbrev, result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("5 meters", 5)] + [InlineData("100 kg", 100)] + [InlineData("3.5 gallons", 3.5)] + public async Task StandaloneUnit_CorrectNumericValue(string input, double expected) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Single(result.OutputNumbers); + Assert.Equal(expected, result.OutputNumbers[0], 3); + } + + #endregion Standalone Unit Tests + + #region Unit Category Tests + + [Theory] + // Length + [InlineData("1 meter to feet", "ft")] + [InlineData("1 km to miles", "mi")] + [InlineData("1 inch to cm", "cm")] + [InlineData("1 yard to meters", "m")] + [InlineData("1 nautical mile to km", "km")] + // Mass + [InlineData("1 kg to pounds", "lb")] + [InlineData("1 ounce to grams", "g")] + [InlineData("1 stone to kg", "kg")] + [InlineData("1 ton to kg", "kg")] + [InlineData("1 tonne to pounds", "lb")] + // Temperature + [InlineData("100 celsius to fahrenheit", "°F")] + [InlineData("212 fahrenheit to celsius", "°C")] + [InlineData("0 celsius to kelvin", "K")] + [InlineData("100 C to F", "°F")] + [InlineData("212 F to C", "°C")] + // Volume + [InlineData("1 gallon to liters", "L")] + [InlineData("1 cup to mL", "mL")] + [InlineData("1 tablespoon to teaspoons", "tsp")] + [InlineData("1 pint to cups", "cup")] + [InlineData("1 quart to pints", "pt")] + [InlineData("1 fl oz to mL", "mL")] + // Speed + [InlineData("60 mph to km/h", "km/h")] + [InlineData("100 km/h to mph", "mph")] + [InlineData("1 m/s to km/h", "km/h")] + [InlineData("1 knot to mph", "mph")] + // Area + [InlineData("1 acre to sq m", "m²")] + [InlineData("1 hectare to acres", "ac")] + [InlineData("1 sq mi to sq km", "km²")] + [InlineData("1 sq ft to sq m", "m²")] + public async Task AllUnitCategories_ConvertSuccessfully(string input, string expectedUnit) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedUnit, result.Output); + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + #endregion Unit Category Tests + + #region Ambiguity & Edge Case Tests + + [Fact] + public async Task VariableTakesPriorityOverUnit() + { + // When a variable "km" is defined, "5 km" should use the variable, not the unit + string input = "km = 10\n5 * km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + // 5 * 10 = 50 (not "5 km") + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(50, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task QuantityWords_StillWork() + { + // "5 million" should still be handled by quantity words, not units + CalculationResult result = await _service.EvaluateExpressionsAsync("5 million"); + + Assert.Single(result.OutputNumbers); + Assert.Equal(5_000_000, result.OutputNumbers[0], 0); + } + + [Fact] + public async Task DateTimeMath_TakesPriority() + { + // Date math should still work as before — "today + 5 days" is not a unit expression + CalculationResult result = await _service.EvaluateExpressionsAsync("today + 5 days"); + + // Should produce a date, not a unit result + Assert.Equal(0, result.ErrorCount); + Assert.DoesNotContain("days", result.Output.ToLowerInvariant().Split('\n')[0].Split("days")[0]); + } + + [Fact] + public async Task PlainNumbersStillWork() + { + // Regular math should be unaffected + CalculationResult result = await _service.EvaluateExpressionsAsync("2 + 3"); + + Assert.Single(result.OutputNumbers); + Assert.Equal(5, result.OutputNumbers[0], 1); + } + + [Fact] + public async Task MultipleConversions_TracksOutputNumbers() + { + string input = "5 miles to km\n10 kg to pounds\n100 celsius to fahrenheit"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DominantUnit_SetCorrectly() + { + string input = "5 km\n+ 3 km\n+ 2 km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal("km", result.DominantUnit); + } + + [Fact] + public async Task DominantUnit_NullForPlainMath() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("2 + 3"); + + Assert.Null(result.DominantUnit); + } + + #endregion Ambiguity & Edge Case Tests + + #region TryEvaluateUnitConversion Direct Tests + + [Theory] + [InlineData("5 miles to km", true)] + [InlineData("100 fahrenheit to celsius", true)] + [InlineData("100 F to C", true)] + [InlineData("32 C to F", true)] + [InlineData("2 + 3", false)] + [InlineData("hello world", false)] + [InlineData("x = 10", false)] + [InlineData("", false)] + public void TryEvaluateUnitConversion_DetectsCorrectly(string input, bool expected) + { + bool result = _service.TryEvaluateUnitConversion( + input, out _, out _, null); + + Assert.Equal(expected, result); + } + + [Fact] + public void TryEvaluateUnitConversion_ContinuationWithoutPrevious_ReturnsFalse() + { + // "to km" without a previous unit result should not match + bool result = _service.TryEvaluateUnitConversion( + "to km", out _, out _, null); + + Assert.False(result); + } + + [Fact] + public void TryEvaluateUnitConversion_ContinuationWithPrevious_ReturnsTrue() + { + var previous = new CalculationService.UnitResult + { + Value = 5, + Unit = UnitsNet.Units.LengthUnit.Mile, + QuantityName = "Length", + Abbreviation = "mi" + }; + + bool result = _service.TryEvaluateUnitConversion( + "to km", out string output, out _, previous); + + Assert.True(result); + Assert.Contains("km", output); + } + + #endregion TryEvaluateUnitConversion Direct Tests + + #region Plural & Alias Tests + + [Theory] + [InlineData("1 meter to feet")] + [InlineData("1 meters to feet")] + [InlineData("1 m to ft")] + [InlineData("1 foot to meters")] + [InlineData("1 feet to meters")] + [InlineData("1 ft to m")] + public async Task UnitAliases_AllResolveCorrectly(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + [Theory] + [InlineData("1 liter to mL")] + [InlineData("1 litre to mL")] + [InlineData("1 liters to mL")] + [InlineData("1 litres to mL")] + public async Task BritishSpellings_Work(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.Equal(1000, result.OutputNumbers[0], 1); + } + + #endregion Plural & Alias Tests +} diff --git a/Tests/WindowSelectionUtilitiesTests.cs b/Tests/WindowSelectionUtilitiesTests.cs new file mode 100644 index 00000000..7eefcff3 --- /dev/null +++ b/Tests/WindowSelectionUtilitiesTests.cs @@ -0,0 +1,33 @@ +using System.Windows; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class WindowSelectionUtilitiesTests +{ + [Fact] + public void FindWindowAtPoint_ReturnsFirstMatchingCandidate() + { + WindowSelectionCandidate topCandidate = new((nint)1, new Rect(0, 0, 40, 40), "Top", 100); + WindowSelectionCandidate lowerCandidate = new((nint)2, new Rect(0, 0, 60, 60), "Lower", 101); + + WindowSelectionCandidate? found = WindowSelectionUtilities.FindWindowAtPoint( + [topCandidate, lowerCandidate], + new Point(20, 20)); + + Assert.Same(topCandidate, found); + } + + [Fact] + public void FindWindowAtPoint_ReturnsNullWhenPointIsOutsideEveryCandidate() + { + WindowSelectionCandidate candidate = new((nint)1, new Rect(0, 0, 40, 40), "Only", 100); + + WindowSelectionCandidate? found = WindowSelectionUtilities.FindWindowAtPoint( + [candidate], + new Point(80, 80)); + + Assert.Null(found); + } +} diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 4ac63bfb..2e5db546 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -3,16 +3,18 @@ + IgnorableNamespaces="uap uap2 uap3 rescap com systemai desktop"> + Version="4.13.0.0" /> Text Grab @@ -59,6 +61,43 @@ DisplayName="Text Grab" /> + + + + + .png + .jpg + .jpeg + .bmp + .gif + .tiff + .tif + + + Grab text with Text Grab + Open in Grab Frame + + + + + + + + + .png + .jpg + .jpeg + .bmp + .gif + .tiff + .tif + + Text + URI + Bitmap + + + Default + + Region + True @@ -196,6 +199,24 @@ False + + False + + + False + + + True + + + Balanced + + + False + + + True + False @@ -211,6 +232,15 @@ False + + + + + False + + + False + - \ No newline at end of file + diff --git a/Text-Grab/App.xaml b/Text-Grab/App.xaml index 9cd6c706..2f2e0bd6 100644 --- a/Text-Grab/App.xaml +++ b/Text-Grab/App.xaml @@ -40,6 +40,7 @@ + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 90ffe544..85738b95 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -183,19 +183,67 @@ private static async Task CheckForOcringFolder(string currentArgument) return true; } + private static readonly HashSet KnownFlags = ["--windowless", "--grabframe"]; + private static async Task HandleStartupArgs(string[] args) { string currentArgument = args[0]; bool isQuiet = false; + bool openInGrabFrame = false; foreach (string arg in args) + { if (arg == "--windowless") { isQuiet = true; _defaultSettings.FirstRun = false; _defaultSettings.Save(); } + else if (arg == "--grabframe") + { + openInGrabFrame = true; + } + } + + // Handle --grabframe flag: open the next argument (file path) in GrabFrame + if (openInGrabFrame) + { + // Find the file path argument (skip known flags) + string? filePath = null; + foreach (string arg in args) + { + if (!KnownFlags.Contains(arg)) + { + // Convert to absolute path to handle relative paths correctly + try + { + string absolutePath = Path.GetFullPath(arg); + if (File.Exists(absolutePath)) + { + filePath = absolutePath; + break; + } + } + catch (Exception ex) + { + Debug.WriteLine($"Invalid path argument: {arg}, error: {ex.Message}"); + } + } + } + + if (!string.IsNullOrEmpty(filePath)) + { + GrabFrame gf = new(filePath); + gf.Show(); + return true; + } + else + { + Debug.WriteLine("--grabframe flag specified but no valid image file path provided"); + // Fall through to default launch behavior + } + } if (currentArgument.Contains("ToastActivated")) { @@ -262,7 +310,6 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) if (!File.Exists(possiblePath)) return false; - if (isQuiet) { (string pathContent, _) = await IoUtilities.GetContentFromPath(possiblePath); @@ -271,6 +318,12 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) false, false); } + else if (IoUtilities.IsImageFile(possiblePath)) + { + GrabFrame gf = new(possiblePath); + gf.Show(); + gf.Activate(); + } else { EditTextWindow manipulateTextWindow = new(); @@ -294,8 +347,6 @@ private async void appStartup(object sender, StartupEventArgs e) // Register COM server and activator type bool handledArgument = false; - await Singleton.Instance.LoadHistories(); - ToastNotificationManagerCompat.OnActivated += toastArgs => { LaunchFromToast(toastArgs); @@ -303,6 +354,9 @@ private async void appStartup(object sender, StartupEventArgs e) handledArgument = HandleNotifyIcon(); + if (!handledArgument) + handledArgument = await ShareTargetUtilities.HandleShareTargetActivationAsync(); + if (!handledArgument && e.Args.Length > 0) handledArgument = await HandleStartupArgs(e.Args); diff --git a/Text-Grab/AssemblyInfo.cs b/Text-Grab/AssemblyInfo.cs index 262cb38c..5c6da42e 100644 --- a/Text-Grab/AssemblyInfo.cs +++ b/Text-Grab/AssemblyInfo.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Windows; using System.Windows.Media; @@ -11,4 +12,5 @@ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) -)] \ No newline at end of file +)] +[assembly: InternalsVisibleTo("Tests")] \ No newline at end of file diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index c6af55aa..851735d3 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -620,27 +620,12 @@ private bool IsPatternAlreadySaved(string pattern) if (string.IsNullOrWhiteSpace(pattern)) return false; - try - { - Settings settings = AppUtilities.TextGrabSettings; - string regexListJson = settings.RegexList; - - if (string.IsNullOrWhiteSpace(regexListJson)) - return false; - - StoredRegex[]? savedPatterns = JsonSerializer.Deserialize(regexListJson); - - if (savedPatterns is null || savedPatterns.Length == 0) - return false; - - // Check if any saved pattern matches the current pattern exactly - return savedPatterns.Any(p => p.Pattern == pattern); - } - catch (Exception) - { - // If there's any error loading patterns, assume it's not saved + StoredRegex[] savedPatterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (savedPatterns.Length == 0) return false; - } + + // Check if any saved pattern matches the current pattern exactly + return savedPatterns.Any(p => p.Pattern == pattern); } internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null) diff --git a/Text-Grab/Controls/InlineChipElement.cs b/Text-Grab/Controls/InlineChipElement.cs new file mode 100644 index 00000000..beab9d6b --- /dev/null +++ b/Text-Grab/Controls/InlineChipElement.cs @@ -0,0 +1,58 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace Text_Grab.Controls; + +[TemplatePart(Name = PartRemoveButton, Type = typeof(Button))] +public class InlineChipElement : Control +{ + private const string PartRemoveButton = "PART_RemoveButton"; + + public static readonly DependencyProperty DisplayNameProperty = + DependencyProperty.Register(nameof(DisplayName), typeof(string), typeof(InlineChipElement), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), typeof(string), typeof(InlineChipElement), + new PropertyMetadata(string.Empty)); + + public string DisplayName + { + get => (string)GetValue(DisplayNameProperty); + set => SetValue(DisplayNameProperty, value); + } + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public event EventHandler? RemoveRequested; + + private Button? _removeButton; + + static InlineChipElement() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(InlineChipElement), + new FrameworkPropertyMetadata(typeof(InlineChipElement))); + } + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + if (_removeButton is not null) + _removeButton.Click -= RemoveButton_Click; + + _removeButton = GetTemplateChild(PartRemoveButton) as Button; + + if (_removeButton is not null) + _removeButton.Click += RemoveButton_Click; + } + + private void RemoveButton_Click(object sender, RoutedEventArgs e) + => RemoveRequested?.Invoke(this, EventArgs.Empty); +} diff --git a/Text-Grab/Controls/InlinePickerItem.cs b/Text-Grab/Controls/InlinePickerItem.cs new file mode 100644 index 00000000..da922a71 --- /dev/null +++ b/Text-Grab/Controls/InlinePickerItem.cs @@ -0,0 +1,24 @@ +namespace Text_Grab.Controls; + +public class InlinePickerItem +{ + public string DisplayName { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + + /// + /// Optional group label used to render section headers in the picker popup + /// (e.g. "Regions", "Patterns"). + /// + public string Group { get; set; } = string.Empty; + + public InlinePickerItem() { } + + public InlinePickerItem(string displayName, string value, string group = "") + { + DisplayName = displayName; + Value = value; + Group = group; + } + + public override string ToString() => DisplayName; +} diff --git a/Text-Grab/Controls/InlinePickerRichTextBox.cs b/Text-Grab/Controls/InlinePickerRichTextBox.cs new file mode 100644 index 00000000..0957745d --- /dev/null +++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Effects; +using Text_Grab.Models; + +namespace Text_Grab.Controls; + +/// +/// A RichTextBox that shows a compact inline picker popup when the trigger character +/// (default '{') is typed, allowing users to insert named value chips into the document. +/// Supports grouped items with section headers (e.g. "Regions" and "Patterns"). +/// +public class InlinePickerRichTextBox : RichTextBox +{ + private readonly Popup _popup; + private readonly ListBox _listBox; + + private TextPointer? _triggerStart; + private bool _isModifyingDocument; + + #region Dependency Properties + + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + nameof(ItemsSource), + typeof(IEnumerable), + typeof(InlinePickerRichTextBox), + new PropertyMetadata(null)); + + public static readonly DependencyProperty SerializedTextProperty = + DependencyProperty.Register( + nameof(SerializedText), + typeof(string), + typeof(InlinePickerRichTextBox), + new FrameworkPropertyMetadata( + string.Empty, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + #endregion Dependency Properties + + #region Properties + + public IEnumerable ItemsSource + { + get => (IEnumerable?)GetValue(ItemsSourceProperty) ?? []; + set => SetValue(ItemsSourceProperty, value); + } + + /// + /// The document serialized back to a plain string, where each chip is replaced + /// with its (e.g. "{1}"). + /// Supports two-way binding. + /// + public string SerializedText + { + get => (string)GetValue(SerializedTextProperty); + set => SetValue(SerializedTextProperty, value); + } + + /// Character that opens the picker popup. Default is '{'. + public char TriggerChar { get; set; } = '{'; + + #endregion Properties + + public event EventHandler? ItemInserted; + + /// + /// Called when a pattern-group item is selected. The handler should show the + /// and return the configured + /// , or null to cancel. + /// + public Func? PatternItemSelected { get; set; } + + static InlinePickerRichTextBox() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(InlinePickerRichTextBox), + new FrameworkPropertyMetadata(typeof(InlinePickerRichTextBox))); + } + + public InlinePickerRichTextBox() + { + AcceptsReturn = false; + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + + _listBox = BuildPopupListBox(); + + Border popupBorder = new() + { + Child = _listBox, + CornerRadius = new CornerRadius(8), + BorderThickness = new Thickness(1), + Padding = new Thickness(2), + Effect = new DropShadowEffect + { + BlurRadius = 6, + Direction = 270, + Opacity = 0.2, + ShadowDepth = 2, + Color = Colors.Black + } + }; + popupBorder.SetResourceReference(BackgroundProperty, "SolidBackgroundFillColorBaseBrush"); + popupBorder.SetResourceReference(BorderBrushProperty, "Teal"); + + _popup = new Popup + { + Child = popupBorder, + StaysOpen = true, + AllowsTransparency = true, + Placement = PlacementMode.RelativePoint, + PlacementTarget = this, + }; + + TextChanged += OnTextChanged; + PreviewKeyDown += OnPreviewKeyDown; + LostKeyboardFocus += OnLostKeyboardFocus; + } + + private ListBox BuildPopupListBox() + { + ListBox lb = new() + { + MinWidth = 140, + MaxHeight = 200, + FontSize = 11, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + FocusVisualStyle = null, + Focusable = false, + }; + lb.SetResourceReference(ForegroundProperty, "TextFillColorPrimaryBrush"); + + // Use a template selector to render headers vs selectable items + lb.ItemTemplateSelector = new PickerItemTemplateSelector( + BuildSelectableItemTemplate(), + BuildHeaderItemTemplate()); + + lb.PreviewMouseDown += ListBox_PreviewMouseDown; + lb.ItemContainerStyle = BuildCompactItemStyle(); + return lb; + } + + private static DataTemplate BuildSelectableItemTemplate() + { + DataTemplate dt = new(); + FrameworkElementFactory spFactory = new(typeof(StackPanel)); + spFactory.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal); + + FrameworkElementFactory nameFactory = new(typeof(TextBlock)); + nameFactory.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.DisplayName))); + nameFactory.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); + nameFactory.SetValue(FrameworkElement.MarginProperty, new Thickness(6, 2, 4, 2)); + + FrameworkElementFactory valueFactory = new(typeof(TextBlock)); + valueFactory.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.Value))); + valueFactory.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); + valueFactory.SetValue(TextBlock.FontSizeProperty, 9.0); + valueFactory.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 2, 6, 2)); + valueFactory.SetValue(TextBlock.OpacityProperty, 0.55); + + spFactory.AppendChild(nameFactory); + spFactory.AppendChild(valueFactory); + dt.VisualTree = spFactory; + return dt; + } + + private static DataTemplate BuildHeaderItemTemplate() + { + DataTemplate dt = new(); + FrameworkElementFactory tb = new(typeof(TextBlock)); + tb.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.DisplayName))); + tb.SetValue(TextBlock.FontSizeProperty, 9.5); + tb.SetValue(TextBlock.FontWeightProperty, FontWeights.SemiBold); + tb.SetValue(TextBlock.OpacityProperty, 0.6); + tb.SetValue(FrameworkElement.MarginProperty, new Thickness(4, 4, 4, 2)); + tb.SetValue(UIElement.IsHitTestVisibleProperty, false); + dt.VisualTree = tb; + return dt; + } + + private static Style BuildCompactItemStyle() + { + // Provide a minimal ControlTemplate so WPF-UI's touch-sized ListBoxItem + // template (large MinHeight + padding) is completely replaced. + FrameworkElementFactory border = new(typeof(Border)) + { + Name = "Bd" + }; + border.SetValue(Border.BackgroundProperty, Brushes.Transparent); + border.SetValue(Border.CornerRadiusProperty, new CornerRadius(4)); + border.SetValue(FrameworkElement.MarginProperty, new Thickness(1, 1, 1, 0)); + + FrameworkElementFactory cp = new(typeof(ContentPresenter)); + cp.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + border.AppendChild(cp); + + ControlTemplate template = new(typeof(ListBoxItem)) { VisualTree = border }; + + Trigger hoverTrigger = new() { Property = UIElement.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x22, 0x30, 0x8E, 0x98)), "Bd")); + template.Triggers.Add(hoverTrigger); + + Trigger selectedTrigger = new() { Property = ListBoxItem.IsSelectedProperty, Value = true }; + selectedTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x44, 0x30, 0x8E, 0x98)), "Bd")); + template.Triggers.Add(selectedTrigger); + + Style style = new(typeof(ListBoxItem)); + style.Setters.Add(new Setter(Control.TemplateProperty, template)); + style.Setters.Add(new Setter(FrameworkElement.MinHeightProperty, 0.0)); + style.Setters.Add(new Setter(Control.FocusVisualStyleProperty, (Style?)null)); + return style; + } + + #region Keyboard & Focus handling + + private void OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + // Keep popup open if focus moved into it (e.g. scrollbar click) + if (e.NewFocus is DependencyObject target && IsVisualDescendant(_popup.Child, target)) + return; + + HidePopup(); + _triggerStart = null; + } + + protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e) + { + // RichTextBox intercepts mouse-down for cursor placement before child controls receive it. + // Detect clicks on a chip's remove button and route them manually. + if (e.OriginalSource is DependencyObject source) + { + Button? btn = FindVisualAncestor + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AvailableActions { get; set; } private ObservableCollection EnabledActions { get; set; } + private GrabTemplate? _editingTemplate; #endregion Properties @@ -73,12 +76,20 @@ public PostGrabActionEditor() // Update empty state visibility UpdateEmptyStateVisibility(); + + // Load templates + LoadTemplates(); } #endregion Constructors #region Methods + private void TemplateInfoButton_Click(object sender, RoutedEventArgs e) + { + TemplateInfoPopup.IsOpen = !TemplateInfoPopup.IsOpen; + } + private void AddButton_Click(object sender, RoutedEventArgs e) { if (AvailableActionsListBox.SelectedItem is not ButtonInfo selectedAction) @@ -132,15 +143,17 @@ private void MoveDownButton_Click(object sender, RoutedEventArgs e) EnabledActionsListBox.SelectedIndex = index + 1; } - private void ResetButton_Click(object sender, RoutedEventArgs e) + private async void ResetButton_Click(object sender, RoutedEventArgs e) { - System.Windows.MessageBoxResult result = System.Windows.MessageBox.Show( - "This will reset to the default post-grab actions. Continue?", - "Reset to Defaults", - System.Windows.MessageBoxButton.YesNo, - System.Windows.MessageBoxImage.Question); + Wpf.Ui.Controls.MessageBoxResult result = await new Wpf.Ui.Controls.MessageBox + { + Title = "Reset to Defaults", + Content = "This will reset to the default post-grab actions. Continue?", + PrimaryButtonText = "Yes", + CloseButtonText = "No" + }.ShowDialogAsync(); - if (result != System.Windows.MessageBoxResult.Yes) + if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) return; // Get defaults @@ -199,5 +212,189 @@ private void UpdateEmptyStateVisibility() } } + private void LoadTemplates() + { + List templates = GrabTemplateManager.GetAllTemplates(); + TemplatesListBox.ItemsSource = templates; + UpdateTemplateEmptyState(templates.Count); + } + + private void UpdateTemplateEmptyState(int count) + { + bool hasTemplates = count > 0; + TemplatesListBox.Visibility = hasTemplates ? Visibility.Visible : Visibility.Collapsed; + NoTemplatesText.Visibility = hasTemplates ? Visibility.Collapsed : Visibility.Visible; + } + + private void NewTextOnlyTemplateButton_Click(object sender, RoutedEventArgs e) + { + TextOnlyTemplateDialog dialog = new() + { + Owner = this, + }; + + if (dialog.ShowDialog() is true) + RefreshTemplatesAndActions(); + } + + private void NewTemplateFromImageButton_Click(object sender, RoutedEventArgs e) + { + Microsoft.Win32.OpenFileDialog dlg = new() + { + Filter = FileUtilities.GetImageFilter() + }; + + if (dlg.ShowDialog() is not true) + return; + + string imagePath = dlg.FileName; + if (!File.Exists(imagePath)) + return; + + GrabTemplate newTemplate = new() + { + Name = Path.GetFileNameWithoutExtension(imagePath), + SourceImagePath = imagePath + }; + + GrabFrame grabFrame = new(newTemplate); + grabFrame.Closed += (_, _) => RefreshTemplatesAndActions(); + grabFrame.Show(); + grabFrame.Activate(); + } + + private async void EditTemplateRegionsButton_Click(object sender, RoutedEventArgs e) + { + if (TemplatesListBox.SelectedItem is not GrabTemplate selected) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "No Template Selected", + Content = "Select a template from the list first.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(selected.SourceImagePath)) + { + TextOnlyTemplateDialog dialog = new() + { + Owner = this, + }; + dialog.TemplateNameBox.Text = selected.Name; + dialog.OutputTemplateBox.SetSerializedText(selected.OutputTemplate); + dialog.EditingTemplate = selected; + + if (dialog.ShowDialog() is true) + RefreshTemplatesAndActions(); + + return; + } + + GrabFrame grabFrame = new(selected); + grabFrame.Closed += (_, _) => RefreshTemplatesAndActions(); + grabFrame.Show(); + grabFrame.Activate(); + } + + private async void DeleteTemplateButton_Click(object sender, RoutedEventArgs e) + { + if (TemplatesListBox.SelectedItem is not GrabTemplate selected) + return; + + Wpf.Ui.Controls.MessageBoxResult result = await new Wpf.Ui.Controls.MessageBox + { + Title = "Delete Template", + Content = $"Delete template '{selected.Name}'?", + PrimaryButtonText = "Yes", + CloseButtonText = "No" + }.ShowDialogAsync(); + + if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) + return; + + GrabTemplateManager.DeleteTemplate(selected.Id); + + // Also remove any enabled action tied to this template + ButtonInfo? toRemove = EnabledActions.FirstOrDefault(a => a.TemplateId == selected.Id); + if (toRemove is not null) + EnabledActions.Remove(toRemove); + + RefreshTemplatesAndActions(); + } + + private void RefreshTemplatesAndActions() + { + LoadTemplates(); + + // Rebuild available actions list to include/exclude updated templates + List allActions = PostGrabActionManager.GetAvailablePostGrabActions(); + List enabledIds = [.. EnabledActions]; + + AvailableActions.Clear(); + foreach (ButtonInfo action in allActions + .Where(a => !enabledIds.Any(e => e.ButtonText == a.ButtonText && e.TemplateId == a.TemplateId)) + .OrderBy(a => a.OrderNumber)) + { + AvailableActions.Add(action); + } + + UpdateEmptyStateVisibility(); + } + + private void TemplatesListBox_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + if (_editingTemplate is null) + return; + + if (TemplatesListBox.SelectedItem is GrabTemplate selected && selected.Id != _editingTemplate.Id) + { + _editingTemplate = null; + TemplateEditPanel.Visibility = Visibility.Collapsed; + } + } + + private void EditTemplateButton_Click(object sender, RoutedEventArgs e) + { + if (TemplatesListBox.SelectedItem is not GrabTemplate selected) + return; + + _editingTemplate = selected; + EditTemplateNameBox.Text = selected.Name; + EditOutputTemplateBox.Text = selected.OutputTemplate; + TemplateEditPanel.Visibility = Visibility.Visible; + EditTemplateNameBox.Focus(); + EditTemplateNameBox.SelectAll(); + } + + private void ApplyTemplateEdit_Click(object sender, RoutedEventArgs e) + { + if (_editingTemplate is null) + return; + + string newName = EditTemplateNameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(newName)) + { + EditTemplateNameBox.Focus(); + return; + } + + _editingTemplate.Name = newName; + _editingTemplate.OutputTemplate = EditOutputTemplateBox.Text; + _editingTemplate.PatternMatches = GrabTemplateExecutor.ParsePatternMatchesFromOutputTemplate(_editingTemplate.OutputTemplate); + GrabTemplateManager.AddOrUpdateTemplate(_editingTemplate); + + _editingTemplate = null; + TemplateEditPanel.Visibility = Visibility.Collapsed; + RefreshTemplatesAndActions(); + } + + private void CancelTemplateEdit_Click(object sender, RoutedEventArgs e) + { + _editingTemplate = null; + TemplateEditPanel.Visibility = Visibility.Collapsed; + } + #endregion Methods } diff --git a/Text-Grab/Controls/RegexManager.xaml.cs b/Text-Grab/Controls/RegexManager.xaml.cs index 3748c3dc..5cf6b564 100644 --- a/Text-Grab/Controls/RegexManager.xaml.cs +++ b/Text-Grab/Controls/RegexManager.xaml.cs @@ -1,12 +1,10 @@ -using Humanizer; +using Humanizer; using System; using System.Collections.ObjectModel; using System.Linq; -using System.Text.Json; using System.Text.RegularExpressions; using System.Windows; using Text_Grab.Models; -using Text_Grab.Properties; using Text_Grab.Utilities; using Wpf.Ui.Controls; @@ -14,8 +12,6 @@ namespace Text_Grab.Controls; public partial class RegexManager : FluentWindow { - private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - public EditTextWindow? SourceEditTextWindow; private ObservableCollection RegexPatterns { get; set; } = []; @@ -33,26 +29,9 @@ private void Window_Loaded(object sender, RoutedEventArgs e) private void LoadRegexPatterns() { RegexPatterns.Clear(); - - // Load from settings - string regexListJson = DefaultSettings.RegexList; - - if (!string.IsNullOrWhiteSpace(regexListJson)) - { - try - { - StoredRegex[]? loadedPatterns = JsonSerializer.Deserialize(regexListJson); - if (loadedPatterns is not null) - { - foreach (StoredRegex pattern in loadedPatterns) - RegexPatterns.Add(pattern); - } - } - catch (JsonException) - { - // If deserialization fails, start fresh - } - } + StoredRegex[] loadedPatterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + foreach (StoredRegex pattern in loadedPatterns) + RegexPatterns.Add(pattern); // Add default patterns if list is empty if (RegexPatterns.Count == 0) @@ -66,16 +45,7 @@ private void LoadRegexPatterns() private void SaveRegexPatterns() { - try - { - string json = JsonSerializer.Serialize(RegexPatterns.ToArray()); - DefaultSettings.RegexList = json; - DefaultSettings.Save(); - } - catch (Exception) - { - // Handle save error silently or show message - } + AppUtilities.TextGrabSettingsService.SaveStoredRegexes(RegexPatterns); } private void RegexDataGrid_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml new file mode 100644 index 00000000..7f633da0 --- /dev/null +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs new file mode 100644 index 00000000..b8d3f3c8 --- /dev/null +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Controls; + +public partial class TextOnlyTemplateDialog : FluentWindow +{ + /// When set, Save updates this template instead of creating a new one. + public GrabTemplate? EditingTemplate { get; set; } + + public TextOnlyTemplateDialog() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (EditingTemplate is not null) + { + Title = "Edit Text-Only Template"; + TitleBarControl.Title = "Edit Text-Only Template"; + } + + TemplateNameBox.Focus(); + LoadPatternItems(); + OutputTemplateBox.PatternItemSelected = OnPatternItemSelected; + } + + private void LoadPatternItems() + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (patterns.Length == 0) + patterns = StoredRegex.GetDefaultPatterns(); + + OutputTemplateBox.ItemsSource = [.. patterns.Select(p => + new InlinePickerItem(p.Name, $"{{p:{p.Name}:first}}", "Patterns"))]; + } + + private TemplatePatternMatch? OnPatternItemSelected(InlinePickerItem item) + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (patterns.Length == 0) + patterns = StoredRegex.GetDefaultPatterns(); + + StoredRegex? storedRegex = patterns.FirstOrDefault( + p => p.Name.Equals(item.DisplayName, StringComparison.OrdinalIgnoreCase)); + + PatternMatchModeDialog dialog = new(storedRegex?.Id ?? string.Empty, item.DisplayName) + { + Owner = this, + }; + + return dialog.ShowDialog() is true ? dialog.Result : null; + } + + private void ValidateInput(object sender, TextChangedEventArgs e) => UpdateSaveButton(); + + private void OutputTemplateBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSaveButton(); + + private void UpdateSaveButton() + { + if (SaveButton is null) + return; + + bool nameOk = !string.IsNullOrWhiteSpace(TemplateNameBox.Text); + bool templateOk = !string.IsNullOrWhiteSpace(OutputTemplateBox.GetSerializedText()); + SaveButton.IsEnabled = nameOk && templateOk; + + if (ErrorText is not null) + ErrorText.Visibility = Visibility.Collapsed; + } + + private void SaveButton_Click(object sender, RoutedEventArgs e) + { + string name = TemplateNameBox.Text.Trim(); + string outputTemplate = OutputTemplateBox.GetSerializedText(); + + if (string.IsNullOrWhiteSpace(name)) + { + ErrorText.Text = "Template name is required."; + ErrorText.Visibility = Visibility.Visible; + TemplateNameBox.Focus(); + return; + } + + if (string.IsNullOrWhiteSpace(outputTemplate)) + { + ErrorText.Text = "Output template is required."; + ErrorText.Visibility = Visibility.Visible; + OutputTemplateBox.Focus(); + return; + } + + GrabTemplate newTemplate = EditingTemplate ?? new(); + newTemplate.Name = name; + newTemplate.OutputTemplate = outputTemplate; + newTemplate.PatternMatches = GrabTemplateExecutor.ParsePatternMatchesFromOutputTemplate(outputTemplate); + + GrabTemplateManager.AddOrUpdateTemplate(newTemplate); + DialogResult = true; + Close(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } +} diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml index fdd869cd..833e9588 100644 --- a/Text-Grab/Controls/WordBorder.xaml +++ b/Text-Grab/Controls/WordBorder.xaml @@ -160,6 +160,21 @@ + + + (int)GetValue(TemplateIndexProperty); + set => SetValue(TemplateIndexProperty, value); + } + + public Visibility TemplateBadgeVisibility => TemplateIndex > 0 ? Visibility.Visible : Visibility.Collapsed; + + public string TemplateBadgeText => TemplateIndex > 0 ? $"{{{TemplateIndex}}}" : string.Empty; + public bool WasRegionSelected { get; set; } = false; public string Word { @@ -151,7 +174,29 @@ public string Word public void Deselect() { IsSelected = false; - WordBorderBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(255, 48, 142, 152)); + ApplyTemplateStateBorderBrush(); + } + + private bool _isInOutputPattern = false; + + /// + /// Highlights the border orange when this region is referenced in the output template. + /// Call with false to restore the normal teal border color. + /// + public void SetHighlightedForOutput(bool isHighlighted) + { + _isInOutputPattern = isHighlighted; + if (!IsSelected) + ApplyTemplateStateBorderBrush(); + } + + private void ApplyTemplateStateBorderBrush() + { + SolidColorBrush brush = _isInOutputPattern + ? new SolidColorBrush(Colors.Orange) + : new SolidColorBrush(Color.FromRgb(48, 142, 152)); + WordBorderBorder.BorderBrush = brush; + MoveResizeBorder.BorderBrush = brush; } public void EnterEdit() @@ -437,8 +482,12 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) if (!WindowsAiUtilities.CanDeviceUseWinAI()) { - MessageBox.Show("Windows AI is not available on this device.", - "Translation Not Available", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Not Available", + Content = "Windows AI is not available on this device.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -468,8 +517,12 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Translation failed: {ex.Message}"); - MessageBox.Show($"Translation failed: {ex.Message}", - "Translation Error", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Error", + Content = $"Translation failed: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index ebc238a0..52824fa6 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -83,6 +83,7 @@ public enum ScrollBehavior None = 0, Resize = 1, Zoom = 2, + ZoomWhenFrozen = 3, } public enum LanguageKind @@ -90,6 +91,14 @@ public enum LanguageKind Global = 0, Tesseract = 1, WindowsAi = 2, + UiAutomation = 3, +} + +public enum UiAutomationTraversalMode +{ + Fast = 0, + Balanced = 1, + Thorough = 2, } public enum FsgDefaultMode @@ -98,3 +107,11 @@ public enum FsgDefaultMode SingleLine = 1, Table = 2, } + +public enum FsgSelectionStyle +{ + Region = 0, + Window = 1, + Freeform = 2, + AdjustAfter = 3, +} diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs index 7ccfe990..7174705d 100644 --- a/Text-Grab/Extensions/ControlExtensions.cs +++ b/Text-Grab/Extensions/ControlExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Windows; using System.Windows.Controls; @@ -5,18 +6,39 @@ namespace Text_Grab; public static class ControlExtensions { - - - public static double GetHorizontalScaleFactor(this Viewbox viewbox) { if (viewbox.Child is not FrameworkElement childElement) return 1.0; double outsideWidth = viewbox.ActualWidth; + double outsideHeight = viewbox.ActualHeight; double insideWidth = childElement.ActualWidth; + double insideHeight = childElement.ActualHeight; + + if (!double.IsFinite(outsideWidth) || !double.IsFinite(insideWidth) + || outsideWidth <= 0 || insideWidth <= 4) + { + return 1.0; + } + + // A Viewbox with Stretch="Uniform" applies min(width_ratio, height_ratio) so that + // the content fits in both dimensions. Using only the width ratio produces the wrong + // scale when the image is height-limited (taller relative to the window than it is + // wide), which causes OCR word borders to be placed at incorrect canvas positions. + double scale = outsideWidth / insideWidth; + + if (double.IsFinite(outsideHeight) && double.IsFinite(insideHeight) + && outsideHeight > 0 && insideHeight > 4) + { + double scaleY = outsideHeight / insideHeight; + scale = Math.Min(scale, scaleY); + } + + if (!double.IsFinite(scale) || scale <= 0) + return 1.0; - return outsideWidth / insideWidth; + return scale; } public static Rect GetAbsolutePlacement(this FrameworkElement element, bool relativeToScreen = false) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index a21b39e9..ce3706c8 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -30,6 +30,12 @@ public class ButtonInfo public bool IsRelevantForEditWindow { get; set; } = true; // Default to true for backward compatibility public DefaultCheckState DefaultCheckState { get; set; } = DefaultCheckState.Off; + /// + /// When this ButtonInfo represents a Grab Template action, this holds the template's + /// unique ID so the executor can look it up. Empty for non-template actions. + /// + public string TemplateId { get; set; } = string.Empty; + public ButtonInfo() { diff --git a/Text-Grab/Models/FullscreenCaptureResult.cs b/Text-Grab/Models/FullscreenCaptureResult.cs new file mode 100644 index 00000000..a452aaa0 --- /dev/null +++ b/Text-Grab/Models/FullscreenCaptureResult.cs @@ -0,0 +1,18 @@ +using System.Windows; +using System.Windows.Media.Imaging; + +namespace Text_Grab.Models; + +public record FullscreenCaptureResult( + FsgSelectionStyle SelectionStyle, + Rect CaptureRegion, + BitmapSource? CapturedImage = null, + string? WindowTitle = null) +{ + public bool SupportsTemplateActions => SelectionStyle != FsgSelectionStyle.Freeform; + + public bool SupportsPreviousRegionReplay => + SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter; + + public bool UsesCapturedImage => CapturedImage is not null; +} diff --git a/Text-Grab/Models/GlobalLang.cs b/Text-Grab/Models/GlobalLang.cs index c06f56ac..ebee655d 100644 --- a/Text-Grab/Models/GlobalLang.cs +++ b/Text-Grab/Models/GlobalLang.cs @@ -15,19 +15,19 @@ public GlobalLang(Windows.Globalization.Language lang) OriginalLanguage = lang; } - public GlobalLang(string inputLang) + public GlobalLang(string inputLangTag) { - if (inputLang == "English") - inputLang = "en-US"; + if (inputLangTag == "English") + inputLangTag = "en-US"; Windows.Globalization.Language language = new(System.Globalization.CultureInfo.CurrentCulture.Name); try { - language = new(inputLang); + language = new(inputLangTag); } catch (System.ArgumentException ex) { - System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLang}': {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLangTag}': {ex.Message}"); // return the language of the keyboard language = new(System.Globalization.CultureInfo.CurrentCulture.Name); } diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs new file mode 100644 index 00000000..ea063af1 --- /dev/null +++ b/Text-Grab/Models/GrabTemplate.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Models; + +/// +/// Defines a reusable capture template: a set of numbered named regions on a fixed-layout +/// document (e.g. a business card or invoice) and an output format string that assembles +/// the OCR results from those regions into final text. +/// +/// Output template syntax — region placeholders: +/// {N} — replaced by the OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text from region N +/// {N:upper} — uppercased OCR text from region N +/// {N:lower} — lowercased OCR text from region N +/// +/// Output template syntax — pattern placeholders (regex): +/// {p:Name:first} — first regex match of the named pattern +/// {p:Name:last} — last regex match +/// {p:Name:all:, } — all matches joined by separator +/// {p:Name:2} — 2nd match (1-based) +/// {p:Name:1,3} — 1st and 3rd matches joined by separator +/// +/// Escape sequences: +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public partial class GrabTemplate +{ + /// Unique persistent identifier. + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// Human-readable name shown in menus and list boxes. + public string Name { get; set; } = string.Empty; + + /// Optional description shown as a tooltip. + public string Description { get; set; } = string.Empty; + + /// Date this template was created. + public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now; + + /// Date this template was last used for capture. + public DateTimeOffset? LastUsedDate { get; set; } + + /// + /// Optional path to a reference image the designer shows as the canvas background. + /// May be empty if no reference image was loaded. + /// + public string SourceImagePath { get; set; } = string.Empty; + + /// + /// Width of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageWidth { get; set; } = 800; + + /// + /// Height of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageHeight { get; set; } = 600; + + /// + /// The capture regions, each with a 1-based . + /// + public List Regions { get; set; } = []; + + /// + /// Output format string. Use {N}, {N:trim}, {N:upper}, {N:lower} for regions + /// and {p:Name:mode} or {p:Name:mode:separator} for pattern matches. + /// Example: "Name: {1}\nEmail: {p:Email Address:first}\nPhone: {3}" + /// + public string OutputTemplate { get; set; } = string.Empty; + + /// + /// Pattern references used in the output template. + /// Each maps a saved to a match-selection mode. + /// + public List PatternMatches { get; set; } = []; + + public GrabTemplate() { } + + public GrabTemplate(string name) + { + Name = name; + } + + /// + /// Returns whether this template has the minimum required data to be executed. + /// A template is valid if it has a name and an output template. + /// + public bool IsValid => + !string.IsNullOrWhiteSpace(Name) + && !string.IsNullOrWhiteSpace(OutputTemplate); + + /// + /// Returns all region numbers referenced in the output template. + /// + public IEnumerable GetReferencedRegionNumbers() + { + System.Text.RegularExpressions.MatchCollection matches = + RefRegionNumbers().Matches(OutputTemplate); + + foreach (System.Text.RegularExpressions.Match match in matches) + { + if (int.TryParse(match.Groups[1].Value, out int number)) + yield return number; + } + } + + /// + /// Returns all pattern names referenced in the output template via {p:Name:mode} syntax. + /// + public IEnumerable GetReferencedPatternNames() + { + System.Text.RegularExpressions.MatchCollection matches = + RefPatternNames().Matches(OutputTemplate); + + foreach (System.Text.RegularExpressions.Match match in matches) + { + yield return match.Groups[1].Value; + } + } + + [System.Text.RegularExpressions.GeneratedRegex(@"\{(\d+)(?::[a-z]+)?\}")] + private static partial System.Text.RegularExpressions.Regex RefRegionNumbers(); + + [System.Text.RegularExpressions.GeneratedRegex(@"\{p:([^:}]+):[^}]+\}")] + private static partial System.Text.RegularExpressions.Regex RefPatternNames(); +} diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index a848bb65..69f3e52c 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -35,10 +35,15 @@ public HistoryInfo() public double DpiScaleFactor { get; set; } = 1.0; + public FsgSelectionStyle SelectionStyle { get; set; } = FsgSelectionStyle.Region; + public string LanguageTag { get; set; } = string.Empty; public LanguageKind LanguageKind { get; set; } = LanguageKind.Global; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool UsedUiAutomation { get; set; } + public bool HasCalcPaneOpen { get; set; } = false; public int CalcPaneWidth { get; set; } = 0; @@ -48,14 +53,18 @@ public ILanguage OcrLanguage { get { - if (string.IsNullOrWhiteSpace(LanguageTag)) + (string normalizedLanguageTag, LanguageKind normalizedLanguageKind, _) = + LanguageUtilities.NormalizePersistedLanguageIdentity(LanguageKind, LanguageTag, UsedUiAutomation); + + if (string.IsNullOrWhiteSpace(normalizedLanguageTag)) return new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")); - return LanguageKind switch + return normalizedLanguageKind switch { - LanguageKind.Global => new GlobalLang(new Language(LanguageTag)), - LanguageKind.Tesseract => new TessLang(LanguageTag), + LanguageKind.Global => new GlobalLang(new Language(normalizedLanguageTag)), + LanguageKind.Tesseract => new TessLang(normalizedLanguageTag), LanguageKind.WindowsAi => new WindowsAiLang(), + LanguageKind.UiAutomation => CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(), _ => new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")), }; } @@ -82,7 +91,11 @@ public Rect PositionRect public string TextContent { get; set; } = string.Empty; - public string WordBorderInfoJson { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WordBorderInfoJson { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WordBorderInfoFileName { get; set; } public string RectAsString { get; set; } = string.Empty; @@ -90,6 +103,21 @@ public Rect PositionRect #region Public Methods + public void ClearTransientImage() + { + // Do not Dispose() here — the bitmap may still be in use by a + // fire-and-forget SaveImageFile task (the packaged path is async). + // Nulling the reference lets the GC collect once all consumers finish. + // The HistoryService.DisposeCachedBitmap() path handles deterministic + // cleanup of the captured fullscreen bitmap via its GDI handle. + ImageContent = null; + } + + public void ClearTransientWordBorderData() + { + WordBorderInfoJson = null; + } + public static bool operator !=(HistoryInfo? left, HistoryInfo? right) { return !(left == right); diff --git a/Text-Grab/Models/LookupItem.cs b/Text-Grab/Models/LookupItem.cs index b37a599e..448e0a92 100644 --- a/Text-Grab/Models/LookupItem.cs +++ b/Text-Grab/Models/LookupItem.cs @@ -12,6 +12,7 @@ public enum LookupItemKind Link = 3, Command = 4, Dynamic = 5, + GrabTemplate = 6, } public class LookupItem : IEquatable @@ -31,6 +32,7 @@ public Wpf.Ui.Controls.SymbolRegular UiSymbol LookupItemKind.Link => Wpf.Ui.Controls.SymbolRegular.Link24, LookupItemKind.Command => Wpf.Ui.Controls.SymbolRegular.WindowConsole20, LookupItemKind.Dynamic => Wpf.Ui.Controls.SymbolRegular.Flash24, + LookupItemKind.GrabTemplate => Wpf.Ui.Controls.SymbolRegular.DocumentTableSearch24, _ => Wpf.Ui.Controls.SymbolRegular.Copy20, }; } @@ -66,6 +68,8 @@ public LookupItem(HistoryInfo historyInfo) public HistoryInfo? HistoryItem { get; set; } + public string? TemplateId { get; set; } + public override string ToString() { if (HistoryItem is not null) diff --git a/Text-Grab/Models/OcrDirectoryOptions.cs b/Text-Grab/Models/OcrDirectoryOptions.cs index 99f77d31..fefc64e4 100644 --- a/Text-Grab/Models/OcrDirectoryOptions.cs +++ b/Text-Grab/Models/OcrDirectoryOptions.cs @@ -8,4 +8,5 @@ public record OcrDirectoryOptions public bool OutputFileNames { get; set; } = true; public bool OutputFooter { get; set; } = true; public bool OutputHeader { get; set; } = true; + public GrabTemplate? GrabTemplate { get; set; } = null; } \ No newline at end of file diff --git a/Text-Grab/Models/PostGrabContext.cs b/Text-Grab/Models/PostGrabContext.cs new file mode 100644 index 00000000..0644634c --- /dev/null +++ b/Text-Grab/Models/PostGrabContext.cs @@ -0,0 +1,39 @@ +using System.Windows; +using System.Windows.Media.Imaging; +using Text_Grab.Interfaces; + +namespace Text_Grab.Models; + +/// +/// Carries all context data produced by a grab action and passed through +/// the post-grab action pipeline. This allows actions that need only the +/// OCR text to ignore the extra fields, while template actions can use +/// the capture region and DPI to re-run sub-region OCR. +/// +public record PostGrabContext( + /// The OCR text extracted from the full capture region. + string Text, + + /// + /// The screen rectangle (in physical pixels) that was captured. + /// Used by template execution to derive sub-region rectangles. + /// + Rect CaptureRegion, + + /// The DPI scale factor at capture time. + double DpiScale, + + /// Optional in-memory copy of the captured image. + BitmapSource? CapturedImage, + + /// The OCR language used for the capture. Null means use the app default. + ILanguage? Language = null, + + /// The selection style used to produce the capture. + FsgSelectionStyle SelectionStyle = FsgSelectionStyle.Region +) +{ + /// Convenience factory for non-template actions that only need text. + public static PostGrabContext TextOnly(string text) => + new(text, Rect.Empty, 1.0, null, null, FsgSelectionStyle.Region); +} diff --git a/Text-Grab/Models/TemplatePatternMatch.cs b/Text-Grab/Models/TemplatePatternMatch.cs new file mode 100644 index 00000000..340641d6 --- /dev/null +++ b/Text-Grab/Models/TemplatePatternMatch.cs @@ -0,0 +1,53 @@ +using System; + +namespace Text_Grab.Models; + +/// +/// Represents a reference to a saved regex pattern within a GrabTemplate. +/// During execution the pattern is applied to the full-area OCR text and +/// matches are extracted according to . +/// +/// Placeholder syntax in the output template: +/// {p:PatternName:first} — first match +/// {p:PatternName:last} — last match +/// {p:PatternName:all:, } — all matches joined by separator +/// {p:PatternName:2} — 2nd match (1-based) +/// {p:PatternName:1,3} — 1st and 3rd matches joined by separator +/// +public class TemplatePatternMatch +{ + /// + /// The of the saved pattern. + /// Used for durable resolution even if the pattern is renamed. + /// + public string PatternId { get; set; } = string.Empty; + + /// + /// Display name of the pattern (mirrors at creation time). + /// Also used in the {p:PatternName:...} placeholder syntax. + /// + public string PatternName { get; set; } = string.Empty; + + /// + /// How to select from the regex 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; } = ", "; + + public TemplatePatternMatch() { } + + public TemplatePatternMatch(string patternId, string patternName, string matchMode = "first", string separator = ", ") + { + PatternId = patternId; + PatternName = patternName; + MatchMode = matchMode; + Separator = separator; + } +} diff --git a/Text-Grab/Models/TemplateRegion.cs b/Text-Grab/Models/TemplateRegion.cs new file mode 100644 index 00000000..f2a16117 --- /dev/null +++ b/Text-Grab/Models/TemplateRegion.cs @@ -0,0 +1,66 @@ +using System.Windows; + +namespace Text_Grab.Models; + +/// +/// Defines a named, numbered capture region within a GrabTemplate. +/// Positions are stored as ratios (0.0–1.0) of the reference image dimensions +/// so the template scales to any screen size or DPI. +/// +public class TemplateRegion +{ + /// + /// 1-based number shown on the region border and used in the output template as {RegionNumber}. + /// + public int RegionNumber { get; set; } = 1; + + /// + /// Optional friendly label for this region (e.g. "Name", "Email"). + /// Displayed on the border in the designer. + /// + public string Label { get; set; } = string.Empty; + + /// + /// Position and size as ratios of the reference image dimensions (each value 0.0–1.0). + /// X, Y, Width, Height correspond to left, top, width, height proportions. + /// + public double RatioLeft { get; set; } = 0; + public double RatioTop { get; set; } = 0; + public double RatioWidth { get; set; } = 0; + public double RatioHeight { get; set; } = 0; + + /// + /// Optional default/fallback value used when OCR returns empty for this region. + /// + public string DefaultValue { get; set; } = string.Empty; + + public TemplateRegion() { } + + /// + /// Returns the absolute pixel Rect for this region given the canvas/image dimensions. + /// + public Rect ToAbsoluteRect(double imageWidth, double imageHeight) + { + return new Rect( + x: RatioLeft * imageWidth, + y: RatioTop * imageHeight, + width: RatioWidth * imageWidth, + height: RatioHeight * imageHeight); + } + + /// + /// Sets ratio values from an absolute Rect and canvas dimensions. + /// + public static TemplateRegion FromAbsoluteRect(Rect rect, double imageWidth, double imageHeight, int regionNumber, string label = "") + { + return new TemplateRegion + { + RegionNumber = regionNumber, + Label = label, + RatioLeft = imageWidth > 0 ? rect.X / imageWidth : 0, + RatioTop = imageHeight > 0 ? rect.Y / imageHeight : 0, + RatioWidth = imageWidth > 0 ? rect.Width / imageWidth : 0, + RatioHeight = imageHeight > 0 ? rect.Height / imageHeight : 0, + }; + } +} diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab/Models/UiAutomationLang.cs new file mode 100644 index 00000000..a819e64d --- /dev/null +++ b/Text-Grab/Models/UiAutomationLang.cs @@ -0,0 +1,26 @@ +using Text_Grab.Interfaces; +using Windows.Globalization; + +namespace Text_Grab.Models; + +public class UiAutomationLang : ILanguage +{ + public const string Tag = "Direct-Txt"; + public const string BetaDisplayName = "Direct Text (Beta)"; + + public string AbbreviatedName => "DT"; + + public string DisplayName => BetaDisplayName; + + public string CurrentInputMethodLanguageTag => string.Empty; + + public string CultureDisplayName => BetaDisplayName; + + public string LanguageTag => Tag; + + public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr; + + public string NativeName => BetaDisplayName; + + public string Script => string.Empty; +} diff --git a/Text-Grab/Models/UiAutomationOptions.cs b/Text-Grab/Models/UiAutomationOptions.cs new file mode 100644 index 00000000..fdf5a722 --- /dev/null +++ b/Text-Grab/Models/UiAutomationOptions.cs @@ -0,0 +1,9 @@ +using System.Windows; + +namespace Text_Grab.Models; + +public record UiAutomationOptions( + UiAutomationTraversalMode TraversalMode, + bool IncludeOffscreen, + bool PreferFocusedElement, + Rect? FilterBounds = null); diff --git a/Text-Grab/Models/UiAutomationOverlayItem.cs b/Text-Grab/Models/UiAutomationOverlayItem.cs new file mode 100644 index 00000000..9522d699 --- /dev/null +++ b/Text-Grab/Models/UiAutomationOverlayItem.cs @@ -0,0 +1,18 @@ +using System.Windows; + +namespace Text_Grab.Models; + +public enum UiAutomationOverlaySource +{ + PointTextRange = 0, + VisibleTextRange = 1, + ElementBounds = 2, +} + +public record UiAutomationOverlayItem( + string Text, + Rect ScreenBounds, + UiAutomationOverlaySource Source, + string ControlTypeProgrammaticName = "", + string AutomationId = "", + string RuntimeId = ""); diff --git a/Text-Grab/Models/UiAutomationOverlaySnapshot.cs b/Text-Grab/Models/UiAutomationOverlaySnapshot.cs new file mode 100644 index 00000000..2bb4df59 --- /dev/null +++ b/Text-Grab/Models/UiAutomationOverlaySnapshot.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Windows; + +namespace Text_Grab.Models; + +public record UiAutomationOverlaySnapshot( + Rect CaptureBounds, + WindowSelectionCandidate TargetWindow, + IReadOnlyList Items) +{ + public bool HasItems => Items.Count > 0; +} diff --git a/Text-Grab/Models/WebSearchUrlModel.cs b/Text-Grab/Models/WebSearchUrlModel.cs index 7053d22f..e70caaa8 100644 --- a/Text-Grab/Models/WebSearchUrlModel.cs +++ b/Text-Grab/Models/WebSearchUrlModel.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using System.Text.Json; using Text_Grab.Utilities; namespace Text_Grab.Models; @@ -79,11 +78,8 @@ private static List GetDefaultWebSearchUrls() public static List GetWebSearchUrls() { - string json = AppUtilities.TextGrabSettings.WebSearchItemsJson; - if (string.IsNullOrWhiteSpace(json)) - return GetDefaultWebSearchUrls(); - List? webSearchUrls = JsonSerializer.Deserialize>(json); - if (webSearchUrls is null || webSearchUrls.Count == 0) + List webSearchUrls = AppUtilities.TextGrabSettingsService.LoadWebSearchUrls(); + if (webSearchUrls.Count == 0) return GetDefaultWebSearchUrls(); return webSearchUrls; @@ -91,8 +87,6 @@ public static List GetWebSearchUrls() public static void SaveWebSearchUrls(List webSearchUrls) { - string json = JsonSerializer.Serialize(webSearchUrls); - AppUtilities.TextGrabSettings.WebSearchItemsJson = json; - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveWebSearchUrls(webSearchUrls); } } diff --git a/Text-Grab/Models/WindowSelectionCandidate.cs b/Text-Grab/Models/WindowSelectionCandidate.cs new file mode 100644 index 00000000..983716a4 --- /dev/null +++ b/Text-Grab/Models/WindowSelectionCandidate.cs @@ -0,0 +1,13 @@ +using System; +using System.Windows; + +namespace Text_Grab.Models; + +public record WindowSelectionCandidate(IntPtr Handle, Rect Bounds, string Title, int ProcessId, string AppName = "") +{ + public bool Contains(Point point) => Bounds.Contains(point); + + public string DisplayAppName => string.IsNullOrWhiteSpace(AppName) ? "Application" : AppName; + + public string DisplayTitle => string.IsNullOrWhiteSpace(Title) ? "Untitled window" : Title; +} diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index cb15c71c..ed8beccb 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -22,4 +22,7 @@ internal static partial class NativeMethods [LibraryImport("shcore.dll")] public static partial void GetScaleFactorForMonitor(IntPtr hMon, out uint pScale); -} \ No newline at end of file + + [LibraryImport("shell32.dll")] + public static partial void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); +} diff --git a/Text-Grab/OSInterop.cs b/Text-Grab/OSInterop.cs index 6fa9a8ae..578aa536 100644 --- a/Text-Grab/OSInterop.cs +++ b/Text-Grab/OSInterop.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Runtime.InteropServices; +using System.Text; internal static partial class OSInterop { @@ -24,6 +25,42 @@ internal static partial class OSInterop [DllImport("user32.dll")] public static extern bool ClipCursor([In()] IntPtr lpRect); + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetShellWindow(); + + [DllImport("user32.dll")] + public static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); + + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + public struct RECT { public int left; @@ -56,6 +93,8 @@ public class MONITORINFOEX public const int WM_KEYDOWN = 0x0100; public const int WM_KEYUP = 0x0101; + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + [LibraryImport("kernel32.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool FreeLibrary(IntPtr hModule); diff --git a/Text-Grab/Pages/DangerSettings.xaml b/Text-Grab/Pages/DangerSettings.xaml index c665046e..95ccd2ad 100644 --- a/Text-Grab/Pages/DangerSettings.xaml +++ b/Text-Grab/Pages/DangerSettings.xaml @@ -70,6 +70,39 @@ ButtonText="Import Settings" Click="ImportSettingsButton_Click" /> + + + + + + + Backup your settings + + + + + Enable experimental file-backed settings storage (restart required) + + + + + Check CPU Architecture before enabling Windows Local AI model features diff --git a/Text-Grab/Pages/DangerSettings.xaml.cs b/Text-Grab/Pages/DangerSettings.xaml.cs index 945a1f67..b04e99c6 100644 --- a/Text-Grab/Pages/DangerSettings.xaml.cs +++ b/Text-Grab/Pages/DangerSettings.xaml.cs @@ -2,6 +2,7 @@ using Microsoft.Win32; using System; using System.Diagnostics; +using System.Threading.Tasks; using System.Windows; using Text_Grab.Properties; using Text_Grab.Services; @@ -15,6 +16,7 @@ namespace Text_Grab.Pages; public partial class DangerSettings : System.Windows.Controls.Page { private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private bool _loadingDangerSettings; public DangerSettings() { @@ -23,7 +25,10 @@ public DangerSettings() private void Page_Loaded(object sender, RoutedEventArgs e) { + _loadingDangerSettings = true; OverrideArchCheckWinAI.IsChecked = DefaultSettings.OverrideAiArchCheck; + EnableFileBackedManagedSettingsToggle.IsChecked = DefaultSettings.EnableFileBackedManagedSettings; + _loadingDangerSettings = false; } private async void ExportBugReportButton_Click(object sender, RoutedEventArgs e) @@ -92,6 +97,11 @@ private async void ClearHistoryButton_Click(object sender, RoutedEventArgs e) } private async void ExportSettingsButton_Click(object sender, RoutedEventArgs e) + { + await ExportSettingsAsync(); + } + + private async Task ExportSettingsAsync() { try { @@ -123,6 +133,11 @@ private async void ExportSettingsButton_Click(object sender, RoutedEventArgs e) } } + private async void BackupSettingsHyperlink_Click(object sender, RoutedEventArgs e) + { + await ExportSettingsAsync(); + } + private async void ImportSettingsButton_Click(object sender, RoutedEventArgs e) { try @@ -193,4 +208,28 @@ private void OverrideArchCheckWinAI_Click(object sender, RoutedEventArgs e) DefaultSettings.OverrideAiArchCheck = ts.IsChecked ?? false; DefaultSettings.Save(); } + + private async void EnableFileBackedManagedSettingsToggle_Checked(object sender, RoutedEventArgs e) + { + if (_loadingDangerSettings) + return; + + bool isEnabled = EnableFileBackedManagedSettingsToggle.IsChecked is true; + if (DefaultSettings.EnableFileBackedManagedSettings == isEnabled) + return; + + DefaultSettings.EnableFileBackedManagedSettings = isEnabled; + DefaultSettings.Save(); + + string message = isEnabled + ? "Experimental file-backed settings storage will be preferred after you restart Text Grab. Restart is required because Text Grab applies this storage preference when it starts so it can safely keep the legacy strings and file-backed copies in sync. Backup your settings before using it if you have not already." + : "Legacy settings storage will be preferred again after you restart Text Grab."; + + await new Wpf.Ui.Controls.MessageBox + { + Title = "Restart Required", + Content = message, + CloseButtonText = "OK" + }.ShowDialogAsync(); + } } diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml b/Text-Grab/Pages/EditTextWindowSettings.xaml new file mode 100644 index 00000000..4f3917de --- /dev/null +++ b/Text-Grab/Pages/EditTextWindowSettings.xaml @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml.cs b/Text-Grab/Pages/EditTextWindowSettings.xaml.cs new file mode 100644 index 00000000..b7091fdf --- /dev/null +++ b/Text-Grab/Pages/EditTextWindowSettings.xaml.cs @@ -0,0 +1,203 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using Text_Grab.Properties; +using Text_Grab.Utilities; + +namespace Text_Grab.Pages; +/// +/// Interaction logic for EditTextWindowSettings.xaml +/// +public partial class EditTextWindowSettings : Page +{ + private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private bool _loaded = false; + + public EditTextWindowSettings() + { + InitializeComponent(); + } + + private void Page_Loaded(object sender, RoutedEventArgs e) + { + // Window behavior + EditWindowStartFullscreenCheckBox.IsChecked = DefaultSettings.EditWindowStartFullscreen; + EditWindowIsOnTopCheckBox.IsChecked = DefaultSettings.EditWindowIsOnTop; + EditWindowIsWordWrapOnCheckBox.IsChecked = DefaultSettings.EditWindowIsWordWrapOn; + RestoreEtwPositionsCheckBox.IsChecked = DefaultSettings.RestoreEtwPositions; + + // Toolbar & UI + EditWindowBottomBarIsHiddenCheckBox.IsChecked = DefaultSettings.EditWindowBottomBarIsHidden; + EtwShowLangPickerCheckBox.IsChecked = DefaultSettings.EtwShowLangPicker; + EtwUseMarginsCheckBox.IsChecked = DefaultSettings.EtwUseMargins; + + // Font + FontFamilyTextBox.Text = DefaultSettings.FontFamilySetting; + double fontSize = Math.Clamp(DefaultSettings.FontSizeSetting, FontSizeSlider.Minimum, FontSizeSlider.Maximum); + FontSizeSlider.Value = fontSize; + FontSizeValueText.Text = fontSize.ToString("0", CultureInfo.InvariantCulture); + IsFontBoldCheckBox.IsChecked = DefaultSettings.IsFontBold; + IsFontItalicCheckBox.IsChecked = DefaultSettings.IsFontItalic; + IsFontUnderlineCheckBox.IsChecked = DefaultSettings.IsFontUnderline; + IsFontStrikeoutCheckBox.IsChecked = DefaultSettings.IsFontStrikeout; + + // Status bar + EtwShowWordCountCheckBox.IsChecked = DefaultSettings.EtwShowWordCount; + EtwShowCharDetailsCheckBox.IsChecked = DefaultSettings.EtwShowCharDetails; + EtwShowMatchCountCheckBox.IsChecked = DefaultSettings.EtwShowMatchCount; + EtwShowRegexPatternCheckBox.IsChecked = DefaultSettings.EtwShowRegexPattern; + EtwShowSimilarMatchesCheckBox.IsChecked = DefaultSettings.EtwShowSimilarMatches; + + // Calculator + CalcShowPaneCheckBox.IsChecked = DefaultSettings.CalcShowPane; + CalcShowErrorsCheckBox.IsChecked = DefaultSettings.CalcShowErrors; + CalcShowErrorsCheckBox.IsEnabled = DefaultSettings.CalcShowPane; + + _loaded = true; + } + + private void EditWindowStartFullscreenCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EditWindowStartFullscreen = EditWindowStartFullscreenCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EditWindowIsOnTopCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EditWindowIsOnTop = EditWindowIsOnTopCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EditWindowIsWordWrapOnCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EditWindowIsWordWrapOn = EditWindowIsWordWrapOnCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void RestoreEtwPositionsCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.RestoreEtwPositions = RestoreEtwPositionsCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EditWindowBottomBarIsHiddenCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EditWindowBottomBarIsHidden = EditWindowBottomBarIsHiddenCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowLangPickerCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowLangPicker = EtwShowLangPickerCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwUseMarginsCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwUseMargins = EtwUseMarginsCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void FontFamilyTextBox_LostFocus(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.FontFamilySetting = FontFamilyTextBox.Text; + DefaultSettings.Save(); + } + + private void FontSizeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (!_loaded) return; + double newVal = Math.Round(FontSizeSlider.Value); + DefaultSettings.FontSizeSetting = newVal; + DefaultSettings.Save(); + FontSizeValueText.Text = newVal.ToString("0", CultureInfo.InvariantCulture); + } + + private void IsFontBoldCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.IsFontBold = IsFontBoldCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void IsFontItalicCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.IsFontItalic = IsFontItalicCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void IsFontUnderlineCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.IsFontUnderline = IsFontUnderlineCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void IsFontStrikeoutCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.IsFontStrikeout = IsFontStrikeoutCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowWordCountCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowWordCount = EtwShowWordCountCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowCharDetailsCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowCharDetails = EtwShowCharDetailsCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowMatchCountCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowMatchCount = EtwShowMatchCountCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowRegexPatternCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowRegexPattern = EtwShowRegexPatternCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void EtwShowSimilarMatchesCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.EtwShowSimilarMatches = EtwShowSimilarMatchesCheckBox.IsChecked == true; + DefaultSettings.Save(); + } + + private void CalcShowPaneCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + bool enabled = CalcShowPaneCheckBox.IsChecked == true; + DefaultSettings.CalcShowPane = enabled; + DefaultSettings.Save(); + CalcShowErrorsCheckBox.IsEnabled = enabled; + } + + private void CalcShowErrorsCheckBox_Click(object sender, RoutedEventArgs e) + { + if (!_loaded) return; + DefaultSettings.CalcShowErrors = CalcShowErrorsCheckBox.IsChecked == true; + DefaultSettings.Save(); + } +} diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 54a6c2a2..7d00b59c 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:wpfui="http://schemas.lepo.co/wpfui/2022/xaml" Title="FullscreenGrabSettings" d:DesignHeight="450" d:DesignWidth="800" @@ -33,20 +34,62 @@ x:Name="SingleLineModeRadio" Click="SingleLineModeRadio_Click" GroupName="FsgStartMode"> - Single Line + Single Line (S) - Table + Table (T) + + + + + + + + + + + Region select (R) + + + + + + Window select (W) + + + + + + Draw freeform shape (D) + + + + + + Adjust after region selection (A) + + Text="Choose how Fullscreen Grab defines the capture area by default. Region select is the standard drag-a-box capture." /> + Text="Configure which actions are available for selection to preform after text is captured (Trim lines, Remove duplicates, etc.)." /> + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Utilities/AppUtilities.cs b/Text-Grab/Utilities/AppUtilities.cs index e1228dcd..73a49926 100644 --- a/Text-Grab/Utilities/AppUtilities.cs +++ b/Text-Grab/Utilities/AppUtilities.cs @@ -19,7 +19,9 @@ internal static bool IsPackaged() } } - internal static Settings TextGrabSettings => Singleton.Instance.ClassicSettings; + internal static SettingsService TextGrabSettingsService => Singleton.Instance; + + internal static Settings TextGrabSettings => TextGrabSettingsService.ClassicSettings; internal static string GetAppVersion() { diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs index 40385f79..186c1a1f 100644 --- a/Text-Grab/Utilities/BarcodeUtilities.cs +++ b/Text-Grab/Utilities/BarcodeUtilities.cs @@ -1,4 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Drawing; +using System.Linq; +using System.Runtime.InteropServices; using Text_Grab.Models; using ZXing; using ZXing.Common; @@ -11,34 +16,86 @@ namespace Text_Grab.Utilities; public static class BarcodeUtilities { - - public static OcrOutput TryToReadBarcodes(Bitmap bitmap) + public static List TryToReadBarcodes(Bitmap bitmap) { + if (!CanReadBitmapDimensions(bitmap)) + return []; + BarcodeReader barcodeReader = new() { AutoRotate = true, - Options = new ZXing.Common.DecodingOptions { TryHarder = true } + Options = new DecodingOptions { TryHarder = true } }; - ZXing.Result result = barcodeReader.Decode(bitmap); + Result[]? results = null; - string resultString = string.Empty; - if (result is not null) - resultString = result.Text; + try + { + results = barcodeReader.DecodeMultiple(bitmap); + } + catch (ArgumentException ex) + { + Debug.WriteLine($"Unable to decode barcode from bitmap: {ex.Message}"); + return []; + } + catch (ObjectDisposedException ex) + { + Debug.WriteLine($"Unable to decode barcode from disposed bitmap: {ex.Message}"); + return []; + } + catch (ExternalException ex) + { + Debug.WriteLine($"Unable to decode barcode from GDI+ bitmap: {ex.Message}"); + return []; + } + + if (results is null) + return []; + + return results + .Where(r => r?.Text is not null) + .Select(r => new OcrOutput() + { + Kind = OcrOutputKind.Barcode, + RawOutput = r.Text, + SourceBitmap = bitmap, + }) + .ToList(); + } - return new OcrOutput() + private static bool CanReadBitmapDimensions(Bitmap? bitmap) + { + if (bitmap is null) + return false; + + try { - Kind = OcrOutputKind.Barcode, - RawOutput = resultString, - SourceBitmap = bitmap, - }; + return bitmap.Width > 0 && bitmap.Height > 0; + } + catch (ArgumentException ex) + { + Debug.WriteLine($"Unable to read bitmap dimensions for barcode scanning: {ex.Message}"); + return false; + } + catch (ObjectDisposedException ex) + { + Debug.WriteLine($"Unable to read bitmap dimensions for disposed barcode bitmap: {ex.Message}"); + return false; + } + catch (ExternalException ex) + { + Debug.WriteLine($"Unable to read barcode bitmap dimensions due to GDI+ error: {ex.Message}"); + return false; + } } public static Bitmap GetQrCodeForText(string text, ErrorCorrectionLevel correctionLevel) { - BitmapRenderer bitmapRenderer = new(); - bitmapRenderer.Foreground = System.Drawing.Color.Black; - bitmapRenderer.Background = System.Drawing.Color.White; + BitmapRenderer bitmapRenderer = new() + { + Foreground = System.Drawing.Color.Black, + Background = System.Drawing.Color.White + }; BarcodeWriter barcodeWriter = new() { @@ -81,4 +138,4 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor return svg; } -} \ No newline at end of file +} diff --git a/Text-Grab/Utilities/CaptureLanguageUtilities.cs b/Text-Grab/Utilities/CaptureLanguageUtilities.cs new file mode 100644 index 00000000..564177c3 --- /dev/null +++ b/Text-Grab/Utilities/CaptureLanguageUtilities.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Windows.Media.Ocr; + +namespace Text_Grab.Utilities; + +internal static class CaptureLanguageUtilities +{ + public static async Task> GetCaptureLanguagesAsync(bool includeTesseract) + { + List languages = []; + + if (AppUtilities.TextGrabSettings.UiAutomationEnabled) + languages.Add(new UiAutomationLang()); + + if (WindowsAiUtilities.CanDeviceUseWinAI()) + languages.Add(new WindowsAiLang()); + + if (includeTesseract + && AppUtilities.TextGrabSettings.UseTesseract + && TesseractHelper.CanLocateTesseractExe()) + { + languages.AddRange(await TesseractHelper.TesseractLanguages()); + } + + foreach (Windows.Globalization.Language language in OcrEngine.AvailableRecognizerLanguages) + languages.Add(new GlobalLang(language)); + + return languages; + } + + public static bool MatchesPersistedLanguage(ILanguage language, string persistedLanguage) + { + if (string.IsNullOrWhiteSpace(persistedLanguage)) + return false; + + return string.Equals(language.LanguageTag, persistedLanguage, StringComparison.CurrentCultureIgnoreCase) + || string.Equals(language.CultureDisplayName, persistedLanguage, StringComparison.CurrentCultureIgnoreCase) + || string.Equals(language.DisplayName, persistedLanguage, StringComparison.CurrentCultureIgnoreCase); + } + + public static int FindPreferredLanguageIndex(IReadOnlyList languages, string persistedLanguage, ILanguage fallbackLanguage) + { + for (int i = 0; i < languages.Count; i++) + { + if (MatchesPersistedLanguage(languages[i], persistedLanguage)) + return i; + } + + for (int i = 0; i < languages.Count; i++) + { + if (string.Equals(languages[i].LanguageTag, fallbackLanguage.LanguageTag, StringComparison.CurrentCultureIgnoreCase)) + return i; + } + + return languages.Count > 0 ? 0 : -1; + } + + public static void PersistSelectedLanguage(ILanguage language) + { + AppUtilities.TextGrabSettings.LastUsedLang = language.LanguageTag; + AppUtilities.TextGrabSettings.Save(); + LanguageUtilities.InvalidateOcrLanguageCache(); + } + + public static ILanguage GetUiAutomationFallbackLanguage() + { + ILanguage currentInputLanguage = LanguageUtilities.GetCurrentInputLanguage(); + + return currentInputLanguage as GlobalLang ?? new GlobalLang(currentInputLanguage.LanguageTag); + } + + public static bool SupportsTableOutput(ILanguage language) + => language is not TessLang && language is not UiAutomationLang; + + public static bool IsStaticImageCompatible(ILanguage language) + => language is not UiAutomationLang; + + public static bool RequiresLiveUiAutomationSource(ILanguage language, bool isStaticImageSource, bool hasFrozenUiAutomationSnapshot) + => language is UiAutomationLang && isStaticImageSource && !hasFrozenUiAutomationSnapshot; +} diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs new file mode 100644 index 00000000..4088aab2 --- /dev/null +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -0,0 +1,227 @@ +using Microsoft.Win32; +using System; +using System.Diagnostics; + +namespace Text_Grab.Utilities; + +/// +/// Utility class for managing Windows context menu integration. +/// Adds "Grab text with Text Grab" and "Open in Grab Frame" options to the right-click context menu for image files. +/// +internal static class ContextMenuUtilities +{ + private const string GrabTextRegistryKeyName = "Text-Grab.GrabText"; + private const string GrabTextDisplayText = "Grab text with Text Grab"; + private const string GrabFrameRegistryKeyName = "Text-Grab.OpenInGrabFrame"; + private const string GrabFrameDisplayText = "Open in Grab Frame"; + + /// + /// Supported image file extensions for context menu integration. + /// + private static readonly string[] ImageExtensions = + [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".tiff", + ".tif" + ]; + + /// + /// Adds Text Grab to the Windows context menu for image files. + /// This allows users to right-click on an image and select "Grab text with Text Grab" or "Open in Grab Frame". + /// + /// When the method returns false, contains an error message describing the failure. + /// True if registration was successful, false otherwise. + public static bool AddToContextMenu(out string? errorMessage) + { + errorMessage = null; + string executablePath = FileUtilities.GetExePath(); + + if (string.IsNullOrEmpty(executablePath)) + { + errorMessage = "Could not determine the application executable path."; + return false; + } + + try + { + foreach (string extension in ImageExtensions) + { + RegisterGrabTextContextMenu(extension, executablePath); + RegisterGrabFrameContextMenu(extension, executablePath); + } + return true; + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Context menu registration failed due to permissions: {ex.Message}"); + errorMessage = "Permission denied. Please run Text Grab as administrator or check your registry permissions."; + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"Context menu registration failed: {ex.Message}"); + errorMessage = $"Failed to register context menu: {ex.Message}"; + return false; + } + } + + /// + /// Removes Text Grab from the Windows context menu for image files. + /// + /// When the method returns false, contains an error message describing the failure. + /// True if removal was successful, false otherwise. + public static bool RemoveFromContextMenu(out string? errorMessage) + { + errorMessage = null; + try + { + foreach (string extension in ImageExtensions) + { + UnregisterContextMenuForExtension(extension, GrabTextRegistryKeyName); + UnregisterContextMenuForExtension(extension, GrabFrameRegistryKeyName); + } + return true; + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Context menu unregistration failed due to permissions: {ex.Message}"); + errorMessage = "Permission denied. Some context menu entries could not be removed."; + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"Context menu unregistration failed: {ex.Message}"); + errorMessage = $"Failed to remove context menu entries: {ex.Message}"; + return false; + } + } + + /// + /// Checks if Text Grab is currently registered in the context menu. + /// + /// True if registered, false otherwise. + public static bool IsRegisteredInContextMenu() + { + try + { + // Check if at least one extension has the context menu registered + foreach (string extension in ImageExtensions) + { + string keyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName); + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath); + if (key is not null) + return true; + } + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"Context menu registration check failed: {ex.Message}"); + return false; + } + } + + /// + /// Registers the "Grab text with Text Grab" context menu entry for a specific file extension. + /// + private static void RegisterGrabTextContextMenu(string extension, string executablePath) + { + string shellKeyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName); + string commandKeyPath = $@"{shellKeyPath}\command"; + + using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) + { + if (shellKey is null) + { + Debug.WriteLine($"Failed to create registry key: {shellKeyPath}"); + throw new InvalidOperationException($"Could not create registry key for {extension}"); + } + + shellKey.SetValue(string.Empty, GrabTextDisplayText); + shellKey.SetValue("Icon", $"\"{executablePath}\""); + } + + using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) + { + if (commandKey is null) + { + Debug.WriteLine($"Failed to create registry key: {commandKeyPath}"); + throw new InvalidOperationException($"Could not create command registry key for {extension}"); + } + + // %1 is replaced by Windows with the path to the file that was right-clicked + commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\""); + } + } + + /// + /// Registers the "Open in Grab Frame" context menu entry for a specific file extension. + /// + private static void RegisterGrabFrameContextMenu(string extension, string executablePath) + { + string shellKeyPath = GetShellKeyPath(extension, GrabFrameRegistryKeyName); + string commandKeyPath = $@"{shellKeyPath}\command"; + + using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) + { + if (shellKey is null) + { + Debug.WriteLine($"Failed to create registry key: {shellKeyPath}"); + throw new InvalidOperationException($"Could not create registry key for {extension}"); + } + + shellKey.SetValue(string.Empty, GrabFrameDisplayText); + shellKey.SetValue("Icon", $"\"{executablePath}\""); + } + + using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) + { + if (commandKey is null) + { + Debug.WriteLine($"Failed to create registry key: {commandKeyPath}"); + throw new InvalidOperationException($"Could not create command registry key for {extension}"); + } + + // --grabframe flag opens the image in GrabFrame instead of EditTextWindow + commandKey.SetValue(string.Empty, $"\"{executablePath}\" --grabframe \"%1\""); + } + } + + /// + /// Removes a context menu entry for a specific file extension. + /// + private static void UnregisterContextMenuForExtension(string extension, string registryKeyName) + { + string shellKeyPath = GetShellKeyPath(extension, registryKeyName); + + try + { + Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to unregister context menu for {extension}: {ex.Message}"); + } + } + + /// + /// Gets the registry path for the shell context menu key for a given extension and registry key name. + /// + internal static string GetShellKeyPath(string extension, string registryKeyName = GrabTextRegistryKeyName) + { + return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{registryKeyName}"; + } + + /// + /// Gets the registry path for the shell context menu key for a given extension. + /// Uses the default GrabText registry key name for backward compatibility with tests. + /// + internal static string GetShellKeyPath(string extension) + { + return GetShellKeyPath(extension, GrabTextRegistryKeyName); + } +} diff --git a/Text-Grab/Utilities/CustomBottomBarUtilities.cs b/Text-Grab/Utilities/CustomBottomBarUtilities.cs index 279540f3..c646c2dc 100644 --- a/Text-Grab/Utilities/CustomBottomBarUtilities.cs +++ b/Text-Grab/Utilities/CustomBottomBarUtilities.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Windows; using System.Windows.Input; @@ -15,23 +14,14 @@ namespace Text_Grab.Utilities; public class CustomBottomBarUtilities { - private static readonly JsonSerializerOptions ButtonInfoJsonOptions = new(); private static readonly Dictionary> _methodCache = []; private static readonly Lock _methodCacheLock = new(); private static readonly BrushConverter BrushConverter = new(); public static List GetCustomBottomBarItemsSetting() { - string json = AppUtilities.TextGrabSettings.BottomButtonsJson; - - if (string.IsNullOrWhiteSpace(json)) - return ButtonInfo.DefaultButtonList; - - List? customBottomBarItems = []; - - customBottomBarItems = JsonSerializer.Deserialize>(json, ButtonInfoJsonOptions); - - if (customBottomBarItems is null || customBottomBarItems.Count == 0) + List customBottomBarItems = AppUtilities.TextGrabSettingsService.LoadBottomBarButtons(); + if (customBottomBarItems.Count == 0) return ButtonInfo.DefaultButtonList; // SymbolIcon is not serialized (marked with [JsonIgnore]), so reconstruct it from ButtonText @@ -58,9 +48,7 @@ public static void SaveCustomBottomBarItemsSetting(List botto public static void SaveCustomBottomBarItemsSetting(List bottomBarButtons) { - string json = JsonSerializer.Serialize(bottomBarButtons, ButtonInfoJsonOptions); - AppUtilities.TextGrabSettings.BottomButtonsJson = json; - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveBottomBarButtons(bottomBarButtons); } public static List GetBottomBarButtons(EditTextWindow editTextWindow) diff --git a/Text-Grab/Utilities/DiagnosticsUtilities.cs b/Text-Grab/Utilities/DiagnosticsUtilities.cs index 0f35f58a..f552ac3e 100644 --- a/Text-Grab/Utilities/DiagnosticsUtilities.cs +++ b/Text-Grab/Utilities/DiagnosticsUtilities.cs @@ -27,6 +27,7 @@ public static async Task GenerateBugReportAsync() WindowsVersion = GetWindowsVersion(), StartupDetails = GetStartupDetails(), SettingsInfo = GetSettingsInfo(), + ManagedSettingsSummary = GetManagedSettingsSummary(), HistoryInfo = GetHistoryInfo(), LanguageInfo = GetLanguageInfo(), TesseractInfo = await GetTesseractInfoAsync(), @@ -97,33 +98,37 @@ private static StartupDetailsModel GetStartupDetails() StartupDetailsModel details = new() { IsPackaged = AppUtilities.IsPackaged(), - BaseDirectory = AppContext.BaseDirectory, - ExecutablePath = Environment.ProcessPath ?? "Unknown" + // Sanitize: only include the executable filename, not the full path (which contains username) + ExecutableFileName = Path.GetFileName(Environment.ProcessPath ?? "Text-Grab.exe") }; if (AppUtilities.IsPackaged()) { details.StartupMethod = "StartupTask API (packaged apps)"; details.RegistryPath = "N/A (uses StartupTask)"; - details.RegistryValue = "N/A (uses StartupTask)"; + details.RegistryValueStatus = "N/A (uses StartupTask)"; } else { details.StartupMethod = "Registry Run key (unpackaged apps)"; details.RegistryPath = @"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; - string exeName = Path.GetFileName(Environment.ProcessPath ?? "Text-Grab.exe"); - string executablePath = FileUtilities.GetExePath(); - details.CalculatedRegistryValue = $"{executablePath}"; - try { using RegistryKey? key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"); - details.ActualRegistryValue = key?.GetValue("Text-Grab")?.ToString() ?? "Not set"; + string? actualValue = key?.GetValue("Text-Grab")?.ToString(); + string expectedExeName = Path.GetFileName(FileUtilities.GetExePath()); + + if (actualValue is null) + details.RegistryValueStatus = "Not set"; + else if (Path.GetFileName(actualValue.Trim('"')) == expectedExeName) + details.RegistryValueStatus = "Configured correctly"; + else + details.RegistryValueStatus = "Mismatch (points to different executable)"; } catch (Exception ex) { - details.ActualRegistryValue = $"Error reading registry: {ex.Message}"; + details.RegistryValueStatus = $"Error reading registry: {ex.Message}"; } } @@ -132,29 +137,160 @@ private static StartupDetailsModel GetStartupDetails() private static SettingsInfoModel GetSettingsInfo() { - Settings settings = AppUtilities.TextGrabSettings; + Settings s = AppUtilities.TextGrabSettings; return new SettingsInfoModel { - FirstRun = settings.FirstRun, - ShowToast = settings.ShowToast, - StartupOnLogin = settings.StartupOnLogin, - RunInBackground = settings.RunInTheBackground, - GlobalHotkeysEnabled = settings.GlobalHotkeysEnabled, - TryToReadBarcodes = false, - CorrectErrors = settings.CorrectErrors, - CorrectToLatin = false, - UseTesseract = settings.UseTesseract, + // Core behavior + FirstRun = s.FirstRun, + ShowToast = s.ShowToast, + StartupOnLogin = s.StartupOnLogin, + RunInBackground = s.RunInTheBackground, + NeverAutoUseClipboard = s.NeverAutoUseClipboard, + UseHistory = s.UseHistory, + AddToContextMenu = s.AddToContextMenu, + RegisterOpenWith = s.RegisterOpenWith, + + // Grab behavior + DefaultLaunch = s.DefaultLaunch ?? "Unknown", + TryInsert = s.TryInsert, + InsertDelay = s.InsertDelay, + CloseFrameOnGrab = s.CloseFrameOnGrab, + PostGrabStayOpen = s.PostGrabStayOpen, + + // OCR / error correction + CorrectErrors = s.CorrectErrors, + CorrectToLatin = s.CorrectToLatin, + TryToReadBarcodes = s.TryToReadBarcodes, + UseTesseract = s.UseTesseract, + TesseractPathConfigured = !string.IsNullOrWhiteSpace(s.TesseractPath), WindowsAiAvailable = WindowsAiUtilities.CanDeviceUseWinAI(), - DefaultLaunch = settings.DefaultLaunch?.ToString() ?? "Unknown", - DefaultLanguage = "Not configured in settings", - TesseractPath = settings.TesseractPath ?? string.Empty, - NeverAutoUseClipboard = settings.NeverAutoUseClipboard, - FontFamilySetting = settings.FontFamilySetting ?? "Default", - IsFontBold = settings.IsFontBold, + LastUsedLang = s.LastUsedLang ?? string.Empty, + + // Global hotkeys + GlobalHotkeysEnabled = s.GlobalHotkeysEnabled, + FullscreenGrabHotKey = s.FullscreenGrabHotKey ?? string.Empty, + GrabFrameHotkey = s.GrabFrameHotkey ?? string.Empty, + EditWindowHotKey = s.EditWindowHotKey ?? string.Empty, + LookupHotKey = s.LookupHotKey ?? string.Empty, + + // Lookup tool + LookupSearchHistory = s.LookupSearchHistory, + LookupFileConfigured = !string.IsNullOrWhiteSpace(s.LookupFileLocation), + + // Display / font + AppTheme = s.AppTheme ?? "System", + FontFamilySetting = s.FontFamilySetting ?? "Default", + FontSizeSetting = s.FontSizeSetting, + IsFontBold = s.IsFontBold, + IsFontItalic = s.IsFontItalic, + IsFontUnderline = s.IsFontUnderline, + IsFontStrikeout = s.IsFontStrikeout, + + // Grab Frame + GrabFrameAutoOcr = s.GrabFrameAutoOcr, + GrabFrameUpdateEtw = s.GrabFrameUpdateEtw, + GrabFrameScrollBehavior = s.GrabFrameScrollBehavior ?? string.Empty, + GrabFrameReadBarcodes = s.GrabFrameReadBarcodes, + GrabFrameTranslationEnabled = s.GrabFrameTranslationEnabled, + GrabFrameTranslationLanguage = s.GrabFrameTranslationLanguage ?? string.Empty, + + // Fullscreen grab + FSGMakeSingleLineToggle = s.FSGMakeSingleLineToggle, + FsgDefaultMode = s.FsgDefaultMode ?? string.Empty, + FsgSelectionStyle = s.FsgSelectionStyle ?? string.Empty, + FsgShadeOverlay = s.FsgShadeOverlay, + FsgSendEtwToggle = s.FsgSendEtwToggle, + + // Edit Text Window + EditWindowIsWordWrapOn = s.EditWindowIsWordWrapOn, + EditWindowIsOnTop = s.EditWindowIsOnTop, + EditWindowBottomBarIsHidden = s.EditWindowBottomBarIsHidden, + EditWindowStartFullscreen = s.EditWindowStartFullscreen, + RestoreEtwPositions = s.RestoreEtwPositions, + EtwUseMargins = s.EtwUseMargins, + ShowCursorText = s.ShowCursorText, + ScrollBottomBar = s.ScrollBottomBar, + EtwShowLangPicker = s.EtwShowLangPicker, + EtwShowWordCount = s.EtwShowWordCount, + EtwShowCharDetails = s.EtwShowCharDetails, + EtwShowMatchCount = s.EtwShowMatchCount, + EtwShowRegexPattern = s.EtwShowRegexPattern, + EtwShowSimilarMatches = s.EtwShowSimilarMatches, + + // Calculator pane + CalcShowErrors = s.CalcShowErrors, + CalcShowPane = s.CalcShowPane, + CalcPaneWidth = s.CalcPaneWidth, + + // Web search (name only, not URLs) + DefaultWebSearch = s.DefaultWebSearch ?? string.Empty, + + // UI Automation + UiAutomationEnabled = s.UiAutomationEnabled, + UiAutomationFallbackToOcr = s.UiAutomationFallbackToOcr, + UiAutomationTraversalMode = s.UiAutomationTraversalMode ?? string.Empty, + UiAutomationIncludeOffscreen = s.UiAutomationIncludeOffscreen, + UiAutomationPreferFocusedElement = s.UiAutomationPreferFocusedElement, + + // Advanced + OverrideAiArchCheck = s.OverrideAiArchCheck, + EnableFileBackedManagedSettings = s.EnableFileBackedManagedSettings, }; } + private static ManagedSettingsSummaryModel GetManagedSettingsSummary() + { + try + { + SettingsService svc = AppUtilities.TextGrabSettingsService; + + StoredRegex[] regexes = svc.LoadStoredRegexes(); + StoredRegex[] customRegexes = regexes.Where(r => !r.IsDefault).ToArray(); + + List postGrabActions = svc.LoadPostGrabActions(); + Dictionary postGrabCheckStates = svc.LoadPostGrabCheckStates(); + int enabledPostGrabCount = postGrabCheckStates.Values.Count(v => v); + + List shortcuts = svc.LoadShortcutKeySets(); + int enabledShortcutCount = shortcuts.Count(s => s.IsEnabled); + + List bottomButtons = svc.LoadBottomBarButtons(); + + List webSearchUrls = svc.LoadWebSearchUrls(); + + List templates = GrabTemplateManager.GetAllTemplates(); + + return new ManagedSettingsSummaryModel + { + RegexPatternCount = regexes.Length, + RegexDefaultPatternCount = regexes.Length - customRegexes.Length, + RegexCustomPatternCount = customRegexes.Length, + RegexCustomPatternNames = [.. customRegexes.Select(r => r.Name)], + + PostGrabActionCount = postGrabActions.Count, + PostGrabActionNames = [.. postGrabActions.Select(a => a.ButtonText)], + PostGrabEnabledCount = enabledPostGrabCount, + + ShortcutKeySetCount = shortcuts.Count, + EnabledShortcutKeySetCount = enabledShortcutCount, + + BottomBarButtonCount = bottomButtons.Count, + + WebSearchUrlCount = webSearchUrls.Count, + + GrabTemplateCount = templates.Count, + }; + } + catch (Exception ex) + { + return new ManagedSettingsSummaryModel + { + ErrorMessage = $"Error reading managed settings: {ex.Message}" + }; + } + } + private static HistoryInfoModel GetHistoryInfo() { try @@ -266,7 +402,7 @@ private static async Task GetTesseractInfoAsync() return new TesseractInfoModel { IsInstalled = canLocate, - ExecutablePath = canLocate ? "Located (path private)" : "Not found", + ExecutablePath = canLocate ? "Located (path redacted)" : "Not found", Version = "Version info not publicly available", AvailableLanguages = availableLanguages, ConfiguredLanguages = ["Will be populated from Tesseract installation"] @@ -333,6 +469,7 @@ public class BugReportModel public string WindowsVersion { get; set; } = string.Empty; public StartupDetailsModel StartupDetails { get; set; } = new(); public SettingsInfoModel SettingsInfo { get; set; } = new(); + public ManagedSettingsSummaryModel ManagedSettingsSummary { get; set; } = new(); public HistoryInfoModel HistoryInfo { get; set; } = new(); public LanguageInfoModel LanguageInfo { get; set; } = new(); public TesseractInfoModel TesseractInfo { get; set; } = new(); @@ -343,32 +480,139 @@ public class StartupDetailsModel { public bool IsPackaged { get; set; } public string StartupMethod { get; set; } = string.Empty; - public string BaseDirectory { get; set; } = string.Empty; - public string ExecutablePath { get; set; } = string.Empty; + // Full paths are redacted to avoid exposing the local username/directory structure + public string ExecutableFileName { get; set; } = string.Empty; public string RegistryPath { get; set; } = string.Empty; - public string CalculatedRegistryValue { get; set; } = string.Empty; - public string ActualRegistryValue { get; set; } = string.Empty; - public string RegistryValue { get; set; } = string.Empty; + public string RegistryValueStatus { get; set; } = string.Empty; } public class SettingsInfoModel { + // Core behavior public bool FirstRun { get; set; } public bool ShowToast { get; set; } public bool StartupOnLogin { get; set; } public bool RunInBackground { get; set; } - public bool GlobalHotkeysEnabled { get; set; } - public bool TryToReadBarcodes { get; set; } + public bool NeverAutoUseClipboard { get; set; } + public bool UseHistory { get; set; } + public bool AddToContextMenu { get; set; } + public bool RegisterOpenWith { get; set; } + + // Grab behavior + public string DefaultLaunch { get; set; } = string.Empty; + public bool TryInsert { get; set; } + public double InsertDelay { get; set; } + public bool CloseFrameOnGrab { get; set; } + public bool PostGrabStayOpen { get; set; } + + // OCR / error correction public bool CorrectErrors { get; set; } public bool CorrectToLatin { get; set; } + public bool TryToReadBarcodes { get; set; } public bool UseTesseract { get; set; } + public bool TesseractPathConfigured { get; set; } // true/false only — full path is PII public bool WindowsAiAvailable { get; set; } - public string DefaultLaunch { get; set; } = string.Empty; - public string DefaultLanguage { get; set; } = string.Empty; - public string TesseractPath { get; set; } = string.Empty; - public bool NeverAutoUseClipboard { get; set; } + public string LastUsedLang { get; set; } = string.Empty; + + // Global hotkeys + public bool GlobalHotkeysEnabled { get; set; } + public string FullscreenGrabHotKey { get; set; } = string.Empty; + public string GrabFrameHotkey { get; set; } = string.Empty; + public string EditWindowHotKey { get; set; } = string.Empty; + public string LookupHotKey { get; set; } = string.Empty; + + // Lookup tool + public bool LookupSearchHistory { get; set; } + public bool LookupFileConfigured { get; set; } // true/false only — full path is PII + + // Display / font + public string AppTheme { get; set; } = string.Empty; public string FontFamilySetting { get; set; } = string.Empty; + public double FontSizeSetting { get; set; } public bool IsFontBold { get; set; } + public bool IsFontItalic { get; set; } + public bool IsFontUnderline { get; set; } + public bool IsFontStrikeout { get; set; } + + // Grab Frame + public bool GrabFrameAutoOcr { get; set; } + public bool GrabFrameUpdateEtw { get; set; } + public string GrabFrameScrollBehavior { get; set; } = string.Empty; + public bool GrabFrameReadBarcodes { get; set; } + public bool GrabFrameTranslationEnabled { get; set; } + public string GrabFrameTranslationLanguage { get; set; } = string.Empty; + + // Fullscreen grab + public bool FSGMakeSingleLineToggle { get; set; } + public string FsgDefaultMode { get; set; } = string.Empty; + public string FsgSelectionStyle { get; set; } = string.Empty; + public bool FsgShadeOverlay { get; set; } + public bool FsgSendEtwToggle { get; set; } + + // Edit Text Window + public bool EditWindowIsWordWrapOn { get; set; } + public bool EditWindowIsOnTop { get; set; } + public bool EditWindowBottomBarIsHidden { get; set; } + public bool EditWindowStartFullscreen { get; set; } + public bool RestoreEtwPositions { get; set; } + public bool EtwUseMargins { get; set; } + public bool ShowCursorText { get; set; } + public bool ScrollBottomBar { get; set; } + public bool EtwShowLangPicker { get; set; } + public bool EtwShowWordCount { get; set; } + public bool EtwShowCharDetails { get; set; } + public bool EtwShowMatchCount { get; set; } + public bool EtwShowRegexPattern { get; set; } + public bool EtwShowSimilarMatches { get; set; } + + // Calculator pane + public bool CalcShowErrors { get; set; } + public bool CalcShowPane { get; set; } + public int CalcPaneWidth { get; set; } + + // Web search (name of default search only — URLs are not included) + public string DefaultWebSearch { get; set; } = string.Empty; + + // UI Automation + public bool UiAutomationEnabled { get; set; } + public bool UiAutomationFallbackToOcr { get; set; } + public string UiAutomationTraversalMode { get; set; } = string.Empty; + public bool UiAutomationIncludeOffscreen { get; set; } + public bool UiAutomationPreferFocusedElement { get; set; } + + // Advanced + public bool OverrideAiArchCheck { get; set; } + public bool EnableFileBackedManagedSettings { get; set; } +} + +public class ManagedSettingsSummaryModel +{ + // Regex patterns + public int RegexPatternCount { get; set; } + public int RegexDefaultPatternCount { get; set; } + public int RegexCustomPatternCount { get; set; } + // Names only — actual pattern strings are omitted as they may reveal sensitive data domains + public List RegexCustomPatternNames { get; set; } = []; + + // Post-grab actions + public int PostGrabActionCount { get; set; } + public List PostGrabActionNames { get; set; } = []; + public int PostGrabEnabledCount { get; set; } + + // Shortcut key sets + public int ShortcutKeySetCount { get; set; } + public int EnabledShortcutKeySetCount { get; set; } + + // Bottom bar buttons + public int BottomBarButtonCount { get; set; } + + // Web search URLs (count only — URLs are not included as they may reveal research interests) + public int WebSearchUrlCount { get; set; } + + // Grab templates + public int GrabTemplateCount { get; set; } + + public string? ErrorMessage { get; set; } } public class HistoryInfoModel diff --git a/Text-Grab/Utilities/FreeformCaptureUtilities.cs b/Text-Grab/Utilities/FreeformCaptureUtilities.cs new file mode 100644 index 00000000..02383864 --- /dev/null +++ b/Text-Grab/Utilities/FreeformCaptureUtilities.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using Point = System.Windows.Point; + +namespace Text_Grab.Utilities; + +public static class FreeformCaptureUtilities +{ + public static Rect GetBounds(IReadOnlyList points) + { + if (points is null || points.Count == 0) + return Rect.Empty; + + double left = points.Min(static point => point.X); + double top = points.Min(static point => point.Y); + double right = points.Max(static point => point.X); + double bottom = points.Max(static point => point.Y); + + return new Rect( + new Point(Math.Floor(left), Math.Floor(top)), + new Point(Math.Ceiling(right), Math.Ceiling(bottom))); + } + + public static PathGeometry BuildGeometry(IReadOnlyList points) + { + PathGeometry geometry = new(); + if (points is null || points.Count < 2) + return geometry; + + PathFigure figure = new() + { + StartPoint = points[0], + IsClosed = true, + IsFilled = true + }; + + foreach (Point point in points.Skip(1)) + figure.Segments.Add(new LineSegment(point, true)); + + geometry.Figures.Add(figure); + geometry.Freeze(); + return geometry; + } + + public static Bitmap CreateMaskedBitmap(Bitmap sourceBitmap, IReadOnlyList pointsRelativeToBounds) + { + ArgumentNullException.ThrowIfNull(sourceBitmap); + + if (pointsRelativeToBounds is null || pointsRelativeToBounds.Count < 3) + return new Bitmap(sourceBitmap); + + Bitmap maskedBitmap = new(sourceBitmap.Width, sourceBitmap.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using Graphics graphics = Graphics.FromImage(maskedBitmap); + using GraphicsPath graphicsPath = new(); + + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(System.Drawing.Color.Gray); + + graphicsPath.AddPolygon([.. pointsRelativeToBounds.Select(static point => new PointF((float)point.X, (float)point.Y))]); + graphics.SetClip(graphicsPath); + graphics.DrawImage(sourceBitmap, new Rectangle(0, 0, sourceBitmap.Width, sourceBitmap.Height)); + + return maskedBitmap; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs new file mode 100644 index 00000000..cea400d4 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Executes a against a captured screen region: +/// OCRs each sub-region, then formats the results using the template's +/// string. +/// +/// Output template syntax — region placeholders: +/// {N} — OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text +/// {N:upper} — uppercased OCR text +/// {N:lower} — lowercased OCR text +/// +/// Output template syntax — pattern placeholders: +/// {p:Name:first} — first regex match +/// {p:Name:last} — last regex match +/// {p:Name:all:, } — all matches joined by separator +/// {p:Name:2} — 2nd match (1-based) +/// {p:Name:1,3} — 1st and 3rd matches joined by separator +/// +/// Escape sequences: +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public static class GrabTemplateExecutor +{ + // Matches {N} or {N:modifier} where N is one or more digits + private static readonly Regex PlaceholderRegex = + new(@"\{(\d+)(?::([a-z]+))?\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // Matches {p:PatternName:mode} or {p:PatternName:mode:separator} + // Group 1 = pattern name, Group 2 = match mode, Group 3 = optional separator + private static readonly Regex PatternPlaceholderRegex = + new(@"\{p:([^:}]+):([^:}]+)(?::([^}]*))?\}", RegexOptions.Compiled); + + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(5); + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Executes the given template using as the + /// coordinate space. Each template region is mapped to a sub-rectangle of + /// , OCR'd, then assembled via the output template. + /// + /// The template to execute. + /// + /// The screen rectangle in physical screen pixels that bounds the user's + /// selection. Template region ratios are applied to this rectangle's + /// width/height to derive each sub-region's capture bounds. + /// + /// The OCR language to use. Pass null to use the app default. + public static async Task ExecuteTemplateAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + + // 1. OCR each region (if any) + Dictionary regionResults = template.Regions.Count > 0 + ? await OcrAllRegionsAsync(template, captureRegion, resolvedLanguage) + : []; + + // 2. OCR full capture area for pattern matching (if any pattern references exist) + // Also check if output template has {p:...} placeholders in case PatternMatches wasn't populated on save + bool hasPatternRefs = template.PatternMatches.Count > 0 + || PatternPlaceholderRegex.IsMatch(template.OutputTemplate); + List effectivePatternMatches = template.PatternMatches.Count > 0 + ? template.PatternMatches + : (hasPatternRefs ? ParsePatternMatchesFromOutputTemplate(template.OutputTemplate) : []); + + string? fullAreaText = null; + if (hasPatternRefs) + { + try + { + fullAreaText = await OcrUtilities.GetTextFromAbsoluteRectAsync(captureRegion, resolvedLanguage); + } + catch (Exception) + { + fullAreaText = string.Empty; + } + } + + // 3. Resolve pattern regexes from saved patterns + Dictionary patternRegexes = []; + if (effectivePatternMatches.Count > 0) + patternRegexes = ResolvePatternRegexes(effectivePatternMatches); + + // 4. Apply output template + string output = ApplyOutputTemplate(template.OutputTemplate, regionResults); + + if (fullAreaText != null) + output = ApplyPatternPlaceholders(output, fullAreaText, effectivePatternMatches, patternRegexes); + + return output; + } + + /// + /// Executes the given template against a file-loaded . + /// Each template region is mapped to a cropped sub-bitmap, OCR'd, then + /// assembled via the output template. + /// + public static async Task ExecuteTemplateOnBitmapAsync( + GrabTemplate template, + Bitmap bitmap, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + + // 1. OCR each region (if any) + Dictionary regionResults = []; + if (template.Regions.Count > 0) + { + foreach (TemplateRegion region in template.Regions) + { + int x = (int)(region.RatioLeft * bitmap.Width); + int y = (int)(region.RatioTop * bitmap.Height); + int width = (int)(region.RatioWidth * bitmap.Width); + int height = (int)(region.RatioHeight * bitmap.Height); + + if (width <= 0 || height <= 0) + { + regionResults[region.RegionNumber] = region.DefaultValue; + continue; + } + + // Clamp to bitmap bounds + x = Math.Max(0, Math.Min(x, bitmap.Width - 1)); + y = Math.Max(0, Math.Min(y, bitmap.Height - 1)); + width = Math.Min(width, bitmap.Width - x); + height = Math.Min(height, bitmap.Height - y); + + try + { + using Bitmap regionBitmap = bitmap.Clone( + new Rectangle(x, y, width, height), bitmap.PixelFormat); + string regionText = OcrUtilities.GetStringFromOcrOutputs( + await OcrUtilities.GetTextFromImageAsync(regionBitmap, resolvedLanguage)); + regionResults[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + regionResults[region.RegionNumber] = region.DefaultValue; + } + } + } + + // 2. OCR full bitmap for pattern matching (if any) + // Also check if output template has {p:...} placeholders in case PatternMatches wasn't populated on save + bool hasPatternRefs = template.PatternMatches.Count > 0 + || PatternPlaceholderRegex.IsMatch(template.OutputTemplate); + List effectivePatternMatches = template.PatternMatches.Count > 0 + ? template.PatternMatches + : (hasPatternRefs ? ParsePatternMatchesFromOutputTemplate(template.OutputTemplate) : []); + + string? fullAreaText = null; + if (hasPatternRefs) + { + try + { + fullAreaText = OcrUtilities.GetStringFromOcrOutputs( + await OcrUtilities.GetTextFromImageAsync(bitmap, resolvedLanguage)); + } + catch (Exception) + { + fullAreaText = string.Empty; + } + } + + // 3. Resolve pattern regexes + Dictionary patternRegexes = []; + if (effectivePatternMatches.Count > 0) + patternRegexes = ResolvePatternRegexes(effectivePatternMatches); + + // 4. Apply output template + string output = ApplyOutputTemplate(template.OutputTemplate, regionResults); + + if (fullAreaText != null) + output = ApplyPatternPlaceholders(output, fullAreaText, effectivePatternMatches, patternRegexes); + + return output; + } + + /// + /// Applies the output template string with the provided region text values. + /// Useful for unit testing the string processing independently of OCR. + /// + public static string ApplyOutputTemplate( + string outputTemplate, + IReadOnlyDictionary regionResults) + { + if (string.IsNullOrEmpty(outputTemplate)) + return string.Empty; + + // Replace escape sequences first + string processed = outputTemplate + .Replace(@"\\", "\x00BACKSLASH\x00") // protect real backslashes + .Replace(@"\n", "\n") + .Replace(@"\t", "\t") + .Replace(@"\{", "\x00LBRACE\x00") // protect literal braces + .Replace("\x00BACKSLASH\x00", @"\"); + + // Replace {N} / {N:modifier} placeholders + string result = PlaceholderRegex.Replace(processed, match => + { + if (!int.TryParse(match.Groups[1].Value, out int regionNumber)) + return match.Value; // leave unknown placeholders as-is + + regionResults.TryGetValue(regionNumber, out string? text); + text ??= string.Empty; + + string modifier = match.Groups[2].Success + ? match.Groups[2].Value.ToLowerInvariant() + : string.Empty; + + return modifier switch + { + "trim" => text.Trim(), + "upper" => text.ToUpper(), + "lower" => text.ToLower(), + _ => text + }; + }); + + // Restore protected literal characters + result = result.Replace("\x00LBRACE\x00", "{"); + + return result; + } + + // ── Pattern placeholder processing ────────────────────────────────────── + + /// + /// Replaces {p:PatternName:mode} and {p:PatternName:mode:separator} + /// placeholders in the template with regex match results from the full-area OCR text. + /// + public static string ApplyPatternPlaceholders( + string template, + string fullText, + IReadOnlyList patternMatches, + IReadOnlyDictionary patternRegexes) + { + if (string.IsNullOrEmpty(template) || patternMatches.Count == 0) + return template; + + return PatternPlaceholderRegex.Replace(template, match => + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + string separatorOverride = match.Groups[3].Success ? match.Groups[3].Value : null!; + + // Find the matching pattern config + TemplatePatternMatch? patternMatch = patternMatches + .FirstOrDefault(p => p.PatternName.Equals(patternName, StringComparison.OrdinalIgnoreCase)); + + if (patternMatch == null) + return match.Value; // leave unresolved + + // Resolve the regex string + if (!patternRegexes.TryGetValue(patternMatch.PatternId, out string? regexPattern) + && !patternRegexes.TryGetValue(patternMatch.PatternName, out regexPattern)) + return string.Empty; // pattern not found + + string separator = separatorOverride ?? patternMatch.Separator; + + try + { + MatchCollection regexMatches = Regex.Matches(fullText, regexPattern, RegexOptions.Multiline, RegexTimeout); + + if (regexMatches.Count == 0) + return string.Empty; + + return ExtractMatchesByMode(regexMatches, mode, separator); + } + catch (RegexMatchTimeoutException) + { + return string.Empty; + } + catch (ArgumentException) + { + return string.Empty; // invalid regex + } + }); + } + + /// + /// Extracts match values based on the mode string. + /// + internal static string ExtractMatchesByMode(MatchCollection matches, string mode, string separator) + { + List allValues = matches.Select(m => m.Value).ToList(); + + return mode.ToLowerInvariant() switch + { + "first" => allValues[0], + "last" => allValues[^1], + "all" => string.Join(separator, allValues), + _ => ExtractByIndices(allValues, mode, separator) + }; + } + + private static string ExtractByIndices(List values, string mode, string separator) + { + // mode is either a single index like "2" or comma-separated like "1,3,5" + string[] parts = mode.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + List selected = []; + + foreach (string part in parts) + { + if (int.TryParse(part, out int index) && index >= 1 && index <= values.Count) + selected.Add(values[index - 1]); // convert 1-based to 0-based + } + + return string.Join(separator, selected); + } + + /// + /// Resolves entries to their actual regex strings + /// by loading saved patterns from settings. + /// Returns a dictionary keyed by both PatternId and PatternName for flexible lookup. + /// + internal static Dictionary ResolvePatternRegexes( + IReadOnlyList patternMatches) + { + Dictionary result = []; + + StoredRegex[] savedPatterns = LoadSavedPatterns(); + Dictionary byId = []; + Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + foreach (StoredRegex sr in savedPatterns) + { + byId[sr.Id] = sr; + byName[sr.Name] = sr; + } + + foreach (TemplatePatternMatch pm in patternMatches) + { + StoredRegex? resolved = null; + + // Prefer lookup by ID (survives renames) + if (!string.IsNullOrEmpty(pm.PatternId) && byId.TryGetValue(pm.PatternId, out resolved)) + { + result[pm.PatternId] = resolved.Pattern; + result[pm.PatternName] = resolved.Pattern; + continue; + } + + // Fallback to name + if (byName.TryGetValue(pm.PatternName, out resolved)) + { + result[pm.PatternId] = resolved.Pattern; + result[pm.PatternName] = resolved.Pattern; + } + } + + return result; + } + + /// + /// Parses {p:Name:mode} and {p:Name:mode:separator} placeholders from + /// and builds objects + /// by resolving against saved patterns. Useful when a template was saved without populating + /// (e.g. via the text-only template dialog). + /// + public static List ParsePatternMatchesFromOutputTemplate(string outputTemplate) + { + if (string.IsNullOrEmpty(outputTemplate)) + return []; + + MatchCollection matches = PatternPlaceholderRegex.Matches(outputTemplate); + Dictionary uniquePatterns = new(StringComparer.OrdinalIgnoreCase); + StoredRegex[] savedPatterns = LoadSavedPatterns(); + + foreach (Match match in matches) + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + string separator = match.Groups[3].Success ? match.Groups[3].Value : ", "; + + if (uniquePatterns.ContainsKey(patternName)) + continue; + + StoredRegex? stored = savedPatterns.FirstOrDefault( + p => p.Name.Equals(patternName, StringComparison.OrdinalIgnoreCase)); + + uniquePatterns[patternName] = new TemplatePatternMatch( + patternId: stored?.Id ?? string.Empty, + patternName: patternName, + matchMode: mode, + separator: separator); + } + + return [.. uniquePatterns.Values]; + } + + private static StoredRegex[] LoadSavedPatterns() + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + return patterns.Length == 0 ? StoredRegex.GetDefaultPatterns() : patterns; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static async Task> OcrAllRegionsAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage language) + { + Dictionary results = []; + + foreach (TemplateRegion region in template.Regions) + { + // Compute absolute screen rect from capture region + region ratios + Rect absoluteRegionRect = new( + x: captureRegion.X + region.RatioLeft * captureRegion.Width, + y: captureRegion.Y + region.RatioTop * captureRegion.Height, + width: region.RatioWidth * captureRegion.Width, + height: region.RatioHeight * captureRegion.Height); + + if (absoluteRegionRect.Width <= 0 || absoluteRegionRect.Height <= 0) + { + results[region.RegionNumber] = region.DefaultValue; + continue; + } + + try + { + // GetTextFromAbsoluteRectAsync uses absolute screen coordinates + string regionText = await OcrUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); + // Use default value when OCR returns nothing + results[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + results[region.RegionNumber] = region.DefaultValue; + } + } + + return results; + } + + /// + /// Validates the output template syntax and returns a list of issues. + /// Returns an empty list when valid. + /// + public static List ValidateOutputTemplate( + string outputTemplate, + IEnumerable availableRegionNumbers, + IEnumerable? availablePatternNames = null) + { + List issues = []; + HashSet available = [.. availableRegionNumbers]; + + // Validate region placeholders + MatchCollection regionMatches = PlaceholderRegex.Matches(outputTemplate); + HashSet referenced = []; + + foreach (Match match in regionMatches) + { + if (!int.TryParse(match.Groups[1].Value, out int num)) + { + issues.Add($"Invalid placeholder: {match.Value}"); + continue; + } + + if (!available.Contains(num)) + issues.Add($"Placeholder {{{num}}} references region {num} which does not exist."); + + referenced.Add(num); + } + + foreach (int availableNum in available) + { + if (!referenced.Contains(availableNum)) + issues.Add($"Region {availableNum} is defined but not used in the output template."); + } + + // Validate pattern placeholders + if (availablePatternNames != null) + { + HashSet availableNames = new(availablePatternNames, StringComparer.OrdinalIgnoreCase); + MatchCollection patternMatches = PatternPlaceholderRegex.Matches(outputTemplate); + + foreach (Match match in patternMatches) + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + + if (!availableNames.Contains(patternName)) + issues.Add($"Pattern placeholder references \"{patternName}\" which is not a saved pattern."); + + if (!IsValidMatchMode(mode)) + issues.Add($"Invalid match mode \"{mode}\" for pattern \"{patternName}\". Use first, last, all, or numeric indices."); + } + } + + return issues; + } + + private static bool IsValidMatchMode(string mode) + { + if (string.IsNullOrEmpty(mode)) + return false; + + return mode.ToLowerInvariant() switch + { + "first" or "last" or "all" => true, + _ => mode.Split(',', StringSplitOptions.RemoveEmptyEntries) + .All(p => int.TryParse(p.Trim(), out int v) && v >= 1) + }; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs new file mode 100644 index 00000000..087dffca --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Windows.Media.Imaging; +using Text_Grab.Models; +using Text_Grab.Properties; +using Wpf.Ui.Controls; + +namespace Text_Grab.Utilities; + +/// +/// Provides CRUD operations for objects, keeping the +/// legacy settings string and the file-backed JSON representation in sync during +/// the transition release. Pattern follows . +/// +/// +/// TODO: This class has no thread-safety guards. All current callers are UI-thread +/// methods so this is safe today, but if templates are ever read/written from +/// background threads a lock (like SettingsService._managedJsonLock) should be added. +/// +public static class GrabTemplateManager +{ + private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private const string TemplatesFileName = "GrabTemplates.json"; + + // Allow tests to override the file path. + // TODO: If more test seams are needed, consider consolidating these into a small + // options/config object instead of individual static properties. + internal static string? TestFilePath { get; set; } + internal static string? TestImagesFolderPath { get; set; } + internal static bool? TestPreferFileBackedMode { get; set; } + + private static bool PreferFileBackedTemplates => + TestPreferFileBackedMode ?? AppUtilities.TextGrabSettingsService.IsFileBackedManagedSettingsEnabled; + + // ── File path ───────────────────────────────────────────────────────────── + + private static string GetTemplatesFilePath() + { + if (TestFilePath is not null) + return TestFilePath; + + if (AppUtilities.IsPackaged()) + { + string localFolder = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + return Path.Combine(localFolder, TemplatesFileName); + } + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", TemplatesFileName); + } + + /// + /// Saves as a PNG in the template-images folder, named + /// after and the first 8 characters of . + /// Returns the full file path on success, or null if the source is null or the write fails. + /// + public static string? SaveTemplateReferenceImage(BitmapSource? imageSource, string templateName, string templateId) + { + if (imageSource is null) + return null; + + try + { + string folder = GetTemplateImagesFolder(); + if (!Directory.Exists(folder)) + Directory.CreateDirectory(folder); + + string safeName = templateName.ReplaceReservedCharacters(); + string shortId = templateId.Length >= 8 ? templateId[..8] : templateId; + string filePath = Path.Combine(folder, $"{safeName}_{shortId}.png"); + + // Write to a temp file first so the encoder never contends with WPF's + // read lock on filePath (held when BitmapImage was loaded without OnLoad). + string tempPath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp"); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(imageSource)); + using (FileStream fs = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + encoder.Save(fs); + + // Atomically replace the destination; succeeds even when the target file + // is open for reading by another process (e.g. WPF's BitmapImage). + File.Move(tempPath, filePath, overwrite: true); + return filePath; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save template reference image: {ex.Message}"); + return null; + } + } + + /// Returns the folder where template reference images are stored alongside the templates JSON. + public static string GetTemplateImagesFolder() + { + if (TestImagesFolderPath is not null) + return TestImagesFolderPath; + + if (TestFilePath is not null) + { + string? testDir = Path.GetDirectoryName(TestFilePath); + return Path.Combine(testDir ?? Path.GetTempPath(), "template-images"); + } + + if (AppUtilities.IsPackaged()) + { + string localFolder = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + return Path.Combine(localFolder, "template-images"); + } + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", "template-images"); + } + + // ── Read ────────────────────────────────────────────────────────────────── + + /// Returns all saved templates, or an empty list if none exist. + public static List GetAllTemplates() + { + try + { + string json = ResolveTemplatesJson(); + + if (string.IsNullOrWhiteSpace(json)) + return []; + + List? templates = JsonSerializer.Deserialize>(json, JsonOptions); + if (templates is not null) + return templates; + } + catch (JsonException) + { + // Return empty list if deserialization fails — never crash + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read GrabTemplates file: {ex.Message}"); + } + + return []; + } + + /// Returns the template with the given ID, or null. + public static GrabTemplate? GetTemplateById(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return GetAllTemplates().FirstOrDefault(t => t.Id == id); + } + + // ── Write ───────────────────────────────────────────────────────────────── + + /// Replaces the entire saved template list. + public static void SaveTemplates(List templates) + { + string json = JsonSerializer.Serialize(templates, JsonOptions); + SaveTemplatesJson(json); + } + + internal static string GetTemplatesJsonForExport() + { + List templates = GetAllTemplates(); + return JsonSerializer.Serialize(templates, JsonOptions); + } + + internal static void ImportTemplatesFromJson(string templatesJson) + { + List templates = string.IsNullOrWhiteSpace(templatesJson) + ? [] + : JsonSerializer.Deserialize>(templatesJson, JsonOptions) ?? []; + + SaveTemplates(templates); + } + + /// Adds a new template (or updates an existing one with the same ID). + public static void AddOrUpdateTemplate(GrabTemplate template) + { + List templates = GetAllTemplates(); + int existing = templates.FindIndex(t => t.Id == template.Id); + if (existing >= 0) + templates[existing] = template; + else + templates.Add(template); + + SaveTemplates(templates); + } + + /// Removes the template with the given ID. No-op if not found. + public static void DeleteTemplate(string id) + { + List templates = GetAllTemplates(); + int removed = templates.RemoveAll(t => t.Id == id); + if (removed > 0) + SaveTemplates(templates); + } + + /// Creates and saves a shallow copy of an existing template with a new ID and name. + public static GrabTemplate? DuplicateTemplate(string id) + { + GrabTemplate? original = GetTemplateById(id); + if (original is null) + return null; + + string json = JsonSerializer.Serialize(original, JsonOptions); + GrabTemplate? copy = JsonSerializer.Deserialize(json, JsonOptions); + if (copy is null) + return null; + + copy.Id = Guid.NewGuid().ToString(); + copy.Name = $"{original.Name} (copy)"; + copy.CreatedDate = DateTimeOffset.Now; + copy.LastUsedDate = null; + + AddOrUpdateTemplate(copy); + return copy; + } + + // ── ButtonInfo bridge ───────────────────────────────────────────────────── + + /// + /// Generates a post-grab action that executes the given template. + /// + public static ButtonInfo CreateButtonInfoForTemplate(GrabTemplate template) + { + return new ButtonInfo( + buttonText: template.Name, + clickEvent: "ApplyTemplate_Click", + symbolIcon: SymbolRegular.DocumentTableSearch24, + defaultCheckState: DefaultCheckState.Off) + { + TemplateId = template.Id, + IsRelevantForFullscreenGrab = true, + IsRelevantForEditWindow = false, + OrderNumber = 7.0, + }; + } + + /// + /// Updates a 's LastUsedDate and persists it. + /// + public static void RecordUsage(string templateId) + { + List templates = GetAllTemplates(); + GrabTemplate? template = templates.FirstOrDefault(t => t.Id == templateId); + if (template is null) + return; + + template.LastUsedDate = DateTimeOffset.Now; + SaveTemplates(templates); + } + + private static string ResolveTemplatesJson() + { + string settingsJson = DefaultSettings.GrabTemplatesJSON; + string fileJson = TryReadTemplatesFileText(); + string preferredJson = PreferFileBackedTemplates ? fileJson : settingsJson; + string secondaryJson = PreferFileBackedTemplates ? settingsJson : fileJson; + string selectedJson = string.IsNullOrWhiteSpace(preferredJson) + ? secondaryJson + : preferredJson; + + if (string.IsNullOrWhiteSpace(selectedJson)) + return string.Empty; + + if (!string.Equals(settingsJson, selectedJson, StringComparison.Ordinal)) + SetLegacyTemplatesJson(selectedJson); + + if (!string.Equals(fileJson, selectedJson, StringComparison.Ordinal)) + TryWriteTemplatesFile(selectedJson); + + return selectedJson; + } + + private static string TryReadTemplatesFileText() + { + string filePath = GetTemplatesFilePath(); + if (!File.Exists(filePath)) + return string.Empty; + + try + { + return File.ReadAllText(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read GrabTemplates file: {ex.Message}"); + return string.Empty; + } + } + + private static void SaveTemplatesJson(string json) + { + SetLegacyTemplatesJson(json); + TryWriteTemplatesFile(json); + } + + private static void SetLegacyTemplatesJson(string json) + { + if (string.Equals(DefaultSettings.GrabTemplatesJSON, json, StringComparison.Ordinal)) + return; + + DefaultSettings.GrabTemplatesJSON = json; + DefaultSettings.Save(); + } + + private static bool TryWriteTemplatesFile(string json) + { + string filePath = GetTemplatesFilePath(); + + try + { + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, json); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to persist GrabTemplates file: {ex.Message}"); + return false; + } + } +} diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index ae43afd5..2c181a9b 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -90,7 +90,7 @@ public static BitmapImage CachedBitmapToBitmapImage(System.Windows.Media.Imaging return bitmapImage; } - public static Bitmap GetRegionOfScreenAsBitmap(Rectangle region) + public static Bitmap GetRegionOfScreenAsBitmap(Rectangle region, bool cacheResult = true) { Bitmap bmp = new(region.Width, region.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); using Graphics g = Graphics.FromImage(bmp); @@ -98,7 +98,9 @@ public static Bitmap GetRegionOfScreenAsBitmap(Rectangle region) g.CopyFromScreen(region.Left, region.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); bmp = PadImage(bmp); - Singleton.Instance.CacheLastBitmap(bmp); + if (cacheResult) + Singleton.Instance.CacheLastBitmap(bmp); + return bmp; } @@ -117,16 +119,18 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) { Rect imageRect = grabFrame.GetImageContentRect(); - int borderThickness = 2; - int titleBarHeight = 32; - int bottomBarHeight = 42; - if (imageRect == Rect.Empty) { - thisCorrectedLeft = (int)((absPosPoint.X + borderThickness) * dpi.DpiScaleX); - thisCorrectedTop = (int)((absPosPoint.Y + (titleBarHeight + borderThickness)) * dpi.DpiScaleY); - windowWidth -= (int)((2 * borderThickness) * dpi.DpiScaleX); - windowHeight -= (int)((titleBarHeight + bottomBarHeight + (2 * borderThickness)) * dpi.DpiScaleY); + // Ask WPF's layout engine for the exact physical-pixel bounds of the + // transparent content area. This is always correct regardless of DPI, + // border thickness, or title/bottom bar heights. + Rectangle contentRect = grabFrame.GetContentAreaScreenRect(); + if (contentRect == Rectangle.Empty) + return new Bitmap(1, 1); + thisCorrectedLeft = contentRect.X; + thisCorrectedTop = contentRect.Y; + windowWidth = contentRect.Width; + windowHeight = contentRect.Height; } else { @@ -216,10 +220,23 @@ public static Bitmap BitmapSourceToBitmap(BitmapSource source) return bmp; } + public static Bitmap? ImageSourceToBitmap(ImageSource? source) + { + return source switch + { + BitmapSource bitmapSource => BitmapSourceToBitmap(bitmapSource), + _ => null + }; + } + public static Bitmap GetBitmapFromIRandomAccessStream(IRandomAccessStream stream) { - Bitmap bitmap = new(stream.AsStream()); - return bitmap; + Stream managedStream = stream.AsStream(); + if (managedStream.CanSeek) + managedStream.Position = 0; + + using Bitmap bitmap = new(managedStream); + return new Bitmap(bitmap); } public static BitmapImage GetBitmapImageFromIRandomAccessStream(IRandomAccessStream stream) diff --git a/Text-Grab/Utilities/ImplementAppOptions.cs b/Text-Grab/Utilities/ImplementAppOptions.cs index ea0aa8d4..50ec062e 100644 --- a/Text-Grab/Utilities/ImplementAppOptions.cs +++ b/Text-Grab/Utilities/ImplementAppOptions.cs @@ -1,5 +1,6 @@ using Microsoft.Win32; using System; +using System.Diagnostics; using System.Threading.Tasks; using Windows.ApplicationModel; @@ -7,6 +8,8 @@ namespace Text_Grab.Utilities; internal class ImplementAppOptions { + private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif", ".webp", ".ico"]; + public static async Task ImplementStartupOption(bool startupOnLogin) { if (startupOnLogin) @@ -24,11 +27,106 @@ public static void ImplementBackgroundOption(bool runInBackground) else { App app = (App)App.Current; - if (app.TextGrabIcon != null) + app.TextGrabIcon?.Close(); + app.TextGrabIcon = null; + } + } + + public static void RegisterAsImageOpenWithApp() + { + if (AppUtilities.IsPackaged()) + return; // Packaged apps use the appxmanifest for file associations + + string executablePath = FileUtilities.GetExePath(); + if (string.IsNullOrEmpty(executablePath)) + return; + + try + { + // Register the application in the App Paths registry + string appKey = @"SOFTWARE\Classes\Text-Grab.Image"; + using (RegistryKey? key = Registry.CurrentUser.CreateSubKey(appKey)) + { + if (key is null) + return; + + key.SetValue("", "Text Grab - Image OCR"); + key.SetValue("FriendlyTypeName", "Text Grab Image"); + + using RegistryKey? shellKey = key.CreateSubKey(@"shell\open\command"); + shellKey?.SetValue("", $"\"{executablePath}\" \"%1\""); + + using RegistryKey? iconKey = key.CreateSubKey("DefaultIcon"); + iconKey?.SetValue("", $"\"{executablePath}\",0"); + } + + // Register Text Grab in OpenWithProgids for each image extension + foreach (string ext in ImageExtensions) + { + string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; + using RegistryKey? key = Registry.CurrentUser.CreateSubKey(extKey); + key?.SetValue("Text-Grab.Image", Array.Empty(), RegistryValueKind.None); + } + + // Register in the Applications key so Windows recognizes it + string appRegKey = @"SOFTWARE\Classes\Applications\Text-Grab.exe"; + using (RegistryKey? key = Registry.CurrentUser.CreateSubKey(appRegKey)) + { + if (key is null) + return; + + key.SetValue("FriendlyAppName", "Text Grab"); + + using RegistryKey? supportedTypes = key.CreateSubKey("SupportedTypes"); + if (supportedTypes is not null) + { + foreach (string ext in ImageExtensions) + supportedTypes.SetValue(ext, ""); + } + + using RegistryKey? shellKey = key.CreateSubKey(@"shell\open\command"); + shellKey?.SetValue("", $"\"{executablePath}\" \"%1\""); + } + + // Notify the shell of the change + NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to register file associations: {ex.Message}"); + } + } + + public static void UnregisterAsImageOpenWithApp() + { + if (AppUtilities.IsPackaged()) + return; + + try + { + // Remove the ProgId + Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\Classes\Text-Grab.Image", false); + + // Remove OpenWithProgids entries for each extension + foreach (string ext in ImageExtensions) { - app.TextGrabIcon.Close(); - app.TextGrabIcon = null; + string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(extKey, true); + if (key is not null) + { + try { key.DeleteValue("Text-Grab.Image", false); } + catch (Exception) { } + } } + + // Remove the Applications key + Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\Classes\Applications\Text-Grab.exe", false); + + NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to unregister file associations: {ex.Message}"); } } @@ -60,9 +158,9 @@ private static async Task SetForStartup() } else { - string path = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + string path = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; string executablePath = FileUtilities.GetExePath(); - + RegistryKey? key = Registry.CurrentUser.OpenSubKey(path, true); if (key is not null && !string.IsNullOrEmpty(executablePath)) { diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index b57eab38..748eb472 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -9,7 +9,23 @@ namespace Text_Grab.Utilities; public class IoUtilities { - public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif"]; + public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif", ".tif", ".webp", ".ico"]; + + public static bool IsImageFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + return IsImageFileExtension(Path.GetExtension(path)); + } + + public static bool IsImageFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return ImageExtensions.Contains(extension.ToLowerInvariant()); + } public static async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) { @@ -27,7 +43,12 @@ public class IoUtilities } catch (Exception) { - System.Windows.MessageBox.Show($"Failed to read {pathOfFileToOpen}"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = $"Failed to read {pathOfFileToOpen}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } else diff --git a/Text-Grab/Utilities/LanguageUtilities.cs b/Text-Grab/Utilities/LanguageUtilities.cs index eb2db94e..bafabfaf 100644 --- a/Text-Grab/Utilities/LanguageUtilities.cs +++ b/Text-Grab/Utilities/LanguageUtilities.cs @@ -42,6 +42,15 @@ public static LanguageKind GetLanguageKind(object language) public static ILanguage GetOCRLanguage() => Singleton.Instance.GetOCRLanguage(); + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) GetPersistedLanguageIdentity(object language) + => LanguageService.GetPersistedLanguageIdentity(language); + + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) NormalizePersistedLanguageIdentity( + LanguageKind languageKind, + string languageTag, + bool usedUiAutomation = false) + => LanguageService.NormalizePersistedLanguageIdentity(languageKind, languageTag, usedUiAutomation); + /// /// Checks if the current input language is Latin-based. /// diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index adffada9..55864e52 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -32,6 +32,22 @@ public static partial class OcrUtilities // Cache the SpaceJoiningWordRegex to avoid creating it on every method call private static readonly Regex _cachedSpaceJoiningWordRegex = SpaceJoiningWordRegex(); + private static bool IsUiAutomationLanguage(ILanguage language) => language is UiAutomationLang; + + private static ILanguage GetCompatibleOcrLanguage(ILanguage language) + { + if (language is UiAutomationLang) + return CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(); + + return language; + } + + private static IReadOnlyCollection? GetExcludedWindowHandles(Window passedWindow) + { + IntPtr handle = new System.Windows.Interop.WindowInteropHelper(passedWindow).Handle; + return handle == IntPtr.Zero ? null : [handle]; + } + public static void GetTextFromOcrLine(this IOcrLine ocrLine, bool isSpaceJoiningOCRLang, StringBuilder text) { // (when OCR language is zh or ja) @@ -77,8 +93,20 @@ public static void GetTextFromOcrLine(this IOcrLine ocrLine, bool isSpaceJoining text.ReplaceGreekOrCyrillicWithLatin(); } - public static async Task GetTextFromAbsoluteRectAsync(Rect rect, ILanguage language) + public static async Task GetTextFromAbsoluteRectAsync( + Rect rect, + ILanguage language, + IReadOnlyCollection? excludedHandles = null) { + if (IsUiAutomationLanguage(language)) + { + string uiAutomationText = await UIAutomationUtilities.GetTextFromRegionAsync(rect, excludedHandles); + if (!string.IsNullOrWhiteSpace(uiAutomationText) || !DefaultSettings.UiAutomationFallbackToOcr) + return uiAutomationText; + + language = GetCompatibleOcrLanguage(language); + } + Rectangle selectedRegion = rect.AsRectangle(); Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(selectedRegion); @@ -93,24 +121,23 @@ public static async Task GetRegionsTextAsync(Window passedWindow, Rectan int thisCorrectedTop = (int)absPosPoint.Y + selectedRegion.Top; Rectangle correctedRegion = new(thisCorrectedLeft, thisCorrectedTop, selectedRegion.Width, selectedRegion.Height); - Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); - - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + return await GetTextFromAbsoluteRectAsync(correctedRegion.AsRect(), language, GetExcludedWindowHandles(passedWindow)); } public static async Task GetRegionsTextAsTableAsync(Window passedWindow, Rectangle selectedRegion, ILanguage objLang) { + ILanguage compatibleLanguage = GetCompatibleOcrLanguage(objLang); Point absPosPoint = passedWindow.GetAbsolutePosition(); int thisCorrectedLeft = (int)absPosPoint.X + selectedRegion.Left; int thisCorrectedTop = (int)absPosPoint.Y + selectedRegion.Top; Rectangle correctedRegion = new(thisCorrectedLeft, thisCorrectedTop, selectedRegion.Width, selectedRegion.Height); - Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); - double scale = await GetIdealScaleFactorForOcrAsync(bmp, objLang); + using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); + double scale = await GetIdealScaleFactorForOcrAsync(bmp, compatibleLanguage); using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bmp, scale); DpiScale dpiScale = VisualTreeHelper.GetDpi(passedWindow); - IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, objLang); + IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, compatibleLanguage); // New model-only flow List wordBorderInfos = ResultTable.ParseOcrResultIntoWordBorderInfos(ocrResult, dpiScale); @@ -127,13 +154,65 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow, table.AnalyzeAsTable(wordBorderInfos, rectCanvasSize); StringBuilder sb = new(); - ResultTable.GetTextFromTabledWordBorders(sb, wordBorderInfos, objLang.IsSpaceJoining()); + ResultTable.GetTextFromTabledWordBorders(sb, wordBorderInfos, compatibleLanguage.IsSpaceJoining()); return sb.ToString(); } + public static async Task GetTextFromBitmapAsync(Bitmap bitmap, ILanguage language) + { + if (IsUiAutomationLanguage(language)) + { + if (!DefaultSettings.UiAutomationFallbackToOcr) + return string.Empty; + + language = GetCompatibleOcrLanguage(language); + } + + return GetStringFromOcrOutputs(await GetTextFromImageAsync(bitmap, language)); + } + + public static async Task GetTextFromBitmapSourceAsync(BitmapSource bitmapSource, ILanguage language) + { + using Bitmap bitmap = ImageMethods.BitmapSourceToBitmap(bitmapSource); + return await GetTextFromBitmapAsync(bitmap, language); + } + + public static async Task GetTextFromBitmapAsTableAsync(Bitmap bitmap, ILanguage language) + { + ILanguage compatibleLanguage = GetCompatibleOcrLanguage(language); + double scale = await GetIdealScaleFactorForOcrAsync(bitmap, compatibleLanguage); + using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale); + IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, compatibleLanguage); + DpiScale bitmapDpiScale = new(1.0, 1.0); + + List wordBorderInfos = ResultTable.ParseOcrResultIntoWordBorderInfos(ocrResult, bitmapDpiScale); + + Rectangle rectCanvasSize = new() + { + Width = scaledBitmap.Width, + Height = scaledBitmap.Height, + X = 0, + Y = 0 + }; + + ResultTable table = new(); + table.AnalyzeAsTable(wordBorderInfos, rectCanvasSize); + + StringBuilder textBuilder = new(); + ResultTable.GetTextFromTabledWordBorders(textBuilder, wordBorderInfos, compatibleLanguage.IsSpaceJoining()); + return textBuilder.ToString(); + } + + public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSource bitmapSource, ILanguage language) + { + using Bitmap bitmap = ImageMethods.BitmapSourceToBitmap(bitmapSource); + return await GetTextFromBitmapAsTableAsync(bitmap, language); + } + public static async Task<(IOcrLinesWords?, double)> GetOcrResultFromRegionAsync(Rectangle region, ILanguage language) { - Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region); + language = GetCompatibleOcrLanguage(language); + using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region); if (language is WindowsAiLang) { @@ -152,8 +231,26 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow, } + public static async Task<(IOcrLinesWords?, double)> GetOcrResultFromBitmapAsync(Bitmap bmp, ILanguage language) + { + language = GetCompatibleOcrLanguage(language); + + if (language is WindowsAiLang) + return (await WindowsAiUtilities.GetOcrResultAsync(bmp), 1.0); + + if (language is not GlobalLang globalLang) + globalLang = new GlobalLang(language.LanguageTag); + + double scale = await GetIdealScaleFactorForOcrAsync(bmp, language); + using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bmp, scale); + IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, globalLang); + return (ocrResult, scale); + } + public static async Task GetOcrResultFromImageAsync(SoftwareBitmap scaledBitmap, ILanguage language) { + language = GetCompatibleOcrLanguage(language); + if (language is WindowsAiLang winAiLang) { return new WinAiOcrLinesWords(await WindowsAiUtilities.GetOcrResultAsync(scaledBitmap)); @@ -171,6 +268,7 @@ public static async Task GetOcrResultFromImageAsync(SoftwareBitm public static async Task GetOcrResultFromImageAsync(Bitmap scaledBitmap, ILanguage language) { + language = GetCompatibleOcrLanguage(language); await using MemoryStream memory = new(); using WrappingStream wrapper = new(memory); @@ -191,6 +289,9 @@ public static async void GetCopyTextFromPreviousRegion() if (lastFsg is null) return; + if (!await CanReplayPreviousFullscreenSelection(lastFsg)) + return; + Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); PreviousGrabWindow previousGrab = new(lastFsg.PositionRect); @@ -198,6 +299,8 @@ public static async void GetCopyTextFromPreviousRegion() ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); HistoryInfo newPrevRegionHistory = new() { @@ -206,8 +309,9 @@ public static async void GetCopyTextFromPreviousRegion() ImageContent = Singleton.Instance.CachedBitmap, TextContent = grabbedText, PositionRect = lastFsg.PositionRect, - LanguageTag = language.LanguageTag, - LanguageKind = LanguageUtilities.GetLanguageKind(language), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, IsTable = lastFsg.IsTable, SourceMode = TextGrabMode.Fullscreen, DpiScaleFactor = lastFsg.DpiScaleFactor, @@ -224,6 +328,9 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio if (lastFsg is null) return; + if (!await CanReplayPreviousFullscreenSelection(lastFsg)) + return; + Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); PreviousGrabWindow previousGrab = new(lastFsg.PositionRect); @@ -231,6 +338,8 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); HistoryInfo newPrevRegionHistory = new() { @@ -239,8 +348,9 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio ImageContent = Singleton.Instance.CachedBitmap, TextContent = grabbedText, PositionRect = lastFsg.PositionRect, - LanguageTag = language.LanguageTag, - LanguageKind = LanguageUtilities.GetLanguageKind(language), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, IsTable = lastFsg.IsTable, SourceMode = TextGrabMode.Fullscreen, DpiScaleFactor = lastFsg.DpiScaleFactor, @@ -254,13 +364,6 @@ public static async Task> GetTextFromRandomAccessStream(IRandomA { Bitmap bitmap = ImageMethods.GetBitmapFromIRandomAccessStream(randomAccessStream); List outputs = await GetTextFromImageAsync(bitmap, language); - - if (DefaultSettings.TryToReadBarcodes) - { - OcrOutput barcodeResult = BarcodeUtilities.TryToReadBarcodes(bitmap); - outputs.Add(barcodeResult); - } - return outputs; } @@ -290,6 +393,14 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I { List outputs = []; + if (IsUiAutomationLanguage(language)) + { + if (!DefaultSettings.UiAutomationFallbackToOcr) + return outputs; + + language = GetCompatibleOcrLanguage(language); + } + if (language is TessLang tessLang) { OcrOutput tesseractOutput = await TesseractHelper.GetOcrOutputFromBitmap(bitmap, tessLang); @@ -303,17 +414,14 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I { GlobalLang ocrLanguageFromILang = language as GlobalLang ?? new GlobalLang("en-US"); double scale = await GetIdealScaleFactorForOcrAsync(bitmap, ocrLanguageFromILang); - Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale); + using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale); IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(scaledBitmap, ocrLanguageFromILang); - OcrOutput paragraphsOutput = GetTextFromOcrResult(ocrLanguageFromILang, scaledBitmap, ocrResult); + OcrOutput paragraphsOutput = GetTextFromOcrResult(ocrLanguageFromILang, new Bitmap(scaledBitmap), ocrResult); outputs.Add(paragraphsOutput); } if (DefaultSettings.TryToReadBarcodes) - { - OcrOutput barcodeResult = BarcodeUtilities.TryToReadBarcodes(bitmap); - outputs.Add(barcodeResult); - } + outputs.AddRange(BarcodeUtilities.TryToReadBarcodes(bitmap)); return outputs; } @@ -358,9 +466,15 @@ public static string GetStringFromOcrOutputs(List outputs) } public static async Task OcrAbsoluteFilePathAsync(string absolutePath, ILanguage? language = null) + { + Bitmap bmp = LoadBitmapFromFile(absolutePath); + language ??= LanguageUtilities.GetCurrentInputLanguage(); + return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + } + + private static Bitmap LoadBitmapFromFile(string absolutePath) { Uri fileURI = new(absolutePath, UriKind.Absolute); - FileInfo fileInfo = new(fileURI.LocalPath); RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(absolutePath); BitmapImage droppedImage = new(); droppedImage.BeginInit(); @@ -369,13 +483,22 @@ public static async Task OcrAbsoluteFilePathAsync(string absolutePath, I droppedImage.CacheOption = BitmapCacheOption.None; droppedImage.EndInit(); droppedImage.Freeze(); - Bitmap bmp = ImageMethods.BitmapImageToBitmap(droppedImage); - language ??= LanguageUtilities.GetCurrentInputLanguage(); - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + return ImageMethods.BitmapImageToBitmap(droppedImage); } public static async Task GetClickedWordAsync(Window passedWindow, Point clickedPoint, ILanguage OcrLang) { + if (IsUiAutomationLanguage(OcrLang)) + { + Point absoluteWindowPosition = passedWindow.GetAbsolutePosition(); + Point absoluteClickedPoint = new(absoluteWindowPosition.X + clickedPoint.X, absoluteWindowPosition.Y + clickedPoint.Y); + string uiAutomationText = await UIAutomationUtilities.GetTextFromPointAsync(absoluteClickedPoint, GetExcludedWindowHandles(passedWindow)); + if (!string.IsNullOrWhiteSpace(uiAutomationText) || !DefaultSettings.UiAutomationFallbackToOcr) + return uiAutomationText.Trim(); + + OcrLang = GetCompatibleOcrLanguage(OcrLang); + } + using Bitmap bmp = ImageMethods.GetWindowsBoundsBitmap(passedWindow); string ocrText = await GetTextFromClickedWordAsync(clickedPoint, bmp, OcrLang); return ocrText.Trim(); @@ -400,6 +523,7 @@ private static string GetTextFromClickedWord(Point singlePoint, IOcrLinesWords o public static async Task GetIdealScaleFactorForOcrAsync(Bitmap bitmap, ILanguage selectedLanguage) { + selectedLanguage = GetCompatibleOcrLanguage(selectedLanguage); IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(bitmap, selectedLanguage); return GetIdealScaleFactorForOcrResult(ocrResult, bitmap.Height, bitmap.Width); } @@ -457,7 +581,14 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag returnString.AppendLine(Path.GetFileName(path)); try { - string ocrText = await OcrAbsoluteFilePathAsync(path, selectedLanguage); + string ocrText; + if (options.GrabTemplate is GrabTemplate grabTemplate) + { + Bitmap bmp = LoadBitmapFromFile(path); + ocrText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync(grabTemplate, bmp, selectedLanguage); + } + else + ocrText = await OcrAbsoluteFilePathAsync(path, selectedLanguage); if (!string.IsNullOrWhiteSpace(ocrText)) { @@ -483,4 +614,18 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag [GeneratedRegex(@"(^[\p{L}-[\p{Lo}]]|\p{Nd}$)|.{2,}")] private static partial Regex SpaceJoiningWordRegex(); + + private static async Task CanReplayPreviousFullscreenSelection(HistoryInfo history) + { + if (history.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter) + return true; + + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Repeat previous fullscreen capture is currently available only for Region and Adjust After selections.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return false; + } } diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index c5242595..3b3c91c1 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -2,20 +2,19 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Text.Json; using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; using Text_Grab.Models; -using Text_Grab.Properties; using Wpf.Ui.Controls; namespace Text_Grab.Utilities; public class PostGrabActionManager { - private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - /// - /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance + /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance. + /// Also includes a ButtonInfo for each saved Grab Template. /// public static List GetAvailablePostGrabActions() { @@ -24,9 +23,19 @@ public static List GetAvailablePostGrabActions() // Add other relevant actions from AllButtons that are marked as relevant for FullscreenGrab IEnumerable relevantActions = ButtonInfo.AllButtons .Where(button => button.IsRelevantForFullscreenGrab && !allPostGrabActions.Any(b => b.ButtonText == button.ButtonText)); - + allPostGrabActions.AddRange(relevantActions); + // Add a ButtonInfo for each saved Grab Template + List templates = GrabTemplateManager.GetAllTemplates(); + foreach (GrabTemplate template in templates) + { + ButtonInfo templateAction = GrabTemplateManager.CreateButtonInfoForTemplate(template); + // Avoid duplicates if it's somehow already in the list + if (!allPostGrabActions.Any(b => b.TemplateId == template.Id)) + allPostGrabActions.Add(templateAction); + } + return [.. allPostGrabActions.OrderBy(b => b.OrderNumber)]; } @@ -100,23 +109,11 @@ public static List GetDefaultPostGrabActions() /// public static List GetEnabledPostGrabActions() { - string json = DefaultSettings.PostGrabJSON; - - if (string.IsNullOrWhiteSpace(json)) + List customActions = AppUtilities.TextGrabSettingsService.LoadPostGrabActions(); + if (customActions.Count == 0) return GetDefaultPostGrabActions(); - try - { - List? customActions = JsonSerializer.Deserialize>(json); - if (customActions is not null && customActions.Count > 0) - return customActions; - } - catch (JsonException) - { - // If deserialization fails, return defaults - } - - return GetDefaultPostGrabActions(); + return customActions; } /// @@ -124,9 +121,7 @@ public static List GetEnabledPostGrabActions() /// public static void SavePostGrabActions(List actions) { - string json = JsonSerializer.Serialize(actions); - DefaultSettings.PostGrabJSON = json; - DefaultSettings.Save(); + AppUtilities.TextGrabSettingsService.SavePostGrabActions(actions); } /// @@ -135,25 +130,13 @@ public static void SavePostGrabActions(List actions) public static bool GetCheckState(ButtonInfo action) { // First check if there's a stored check state from last usage - string statesJson = DefaultSettings.PostGrabCheckStates; - - if (!string.IsNullOrWhiteSpace(statesJson)) + Dictionary checkStates = AppUtilities.TextGrabSettingsService.LoadPostGrabCheckStates(); + if (checkStates.Count > 0 + && checkStates.TryGetValue(action.ButtonText, out bool storedState) + && action.DefaultCheckState == DefaultCheckState.LastUsed) { - try - { - Dictionary? checkStates = JsonSerializer.Deserialize>(statesJson); - if (checkStates is not null - && checkStates.TryGetValue(action.ButtonText, out bool storedState) - && action.DefaultCheckState == DefaultCheckState.LastUsed) - { - // If the action is set to LastUsed, use the stored state - return storedState; - } - } - catch (JsonException) - { - // If deserialization fails, fall through to default behavior - } + // If the action is set to LastUsed, use the stored state + return storedState; } // Otherwise use the default check state @@ -165,25 +148,9 @@ public static bool GetCheckState(ButtonInfo action) /// public static void SaveCheckState(ButtonInfo action, bool isChecked) { - string statesJson = DefaultSettings.PostGrabCheckStates; - Dictionary checkStates = []; - - if (!string.IsNullOrWhiteSpace(statesJson)) - { - try - { - checkStates = JsonSerializer.Deserialize>(statesJson) ?? []; - } - catch (JsonException) - { - // Start fresh if deserialization fails - } - } - + Dictionary checkStates = AppUtilities.TextGrabSettingsService.LoadPostGrabCheckStates(); checkStates[action.ButtonText] = isChecked; - string updatedJson = JsonSerializer.Serialize(checkStates); - DefaultSettings.PostGrabCheckStates = updatedJson; - DefaultSettings.Save(); + AppUtilities.TextGrabSettingsService.SavePostGrabCheckStates(checkStates); } /// @@ -191,6 +158,16 @@ public static void SaveCheckState(ButtonInfo action, bool isChecked) /// public static async Task ExecutePostGrabAction(ButtonInfo action, string text) { + return await ExecutePostGrabAction(action, PostGrabContext.TextOnly(text)); + } + + /// + /// Executes a post-grab action using the full . + /// Template actions use the context's CaptureRegion and DpiScale to re-OCR sub-regions. + /// + public static async Task ExecutePostGrabAction(ButtonInfo action, PostGrabContext context) + { + string text = context.Text; string result = text; switch (action.ClickEvent) @@ -236,6 +213,21 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, string } break; + case "ApplyTemplate_Click": + if (!string.IsNullOrWhiteSpace(action.TemplateId) + && context.CaptureRegion != Rect.Empty) + { + GrabTemplate? template = GrabTemplateManager.GetTemplateById(action.TemplateId); + if (template is not null) + { + result = await GrabTemplateExecutor.ExecuteTemplateAsync( + template, context.CaptureRegion, context.Language); + GrabTemplateManager.RecordUsage(action.TemplateId); + } + } + // If no capture region (e.g. called from EditTextWindow), skip template + break; + default: // Unknown action - return text unchanged break; diff --git a/Text-Grab/Utilities/SettingsImportExportUtilities.cs b/Text-Grab/Utilities/SettingsImportExportUtilities.cs index b49743ef..b35ceb8b 100644 --- a/Text-Grab/Utilities/SettingsImportExportUtilities.cs +++ b/Text-Grab/Utilities/SettingsImportExportUtilities.cs @@ -4,6 +4,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading.Tasks; using Text_Grab.Properties; @@ -22,6 +23,9 @@ public static class SettingsImportExportUtilities private const string HistoryTextOnlyFileName = "HistoryTextOnly.json"; private const string HistoryWithImageFileName = "HistoryWithImage.json"; private const string HistoryFolderName = "history"; + private const string GrabTemplatesFileName = "GrabTemplates.json"; + private const string TemplateImagesFolderName = "template-images"; + private const string ManagedSettingsFolderName = "settings-data"; /// /// Exports all application settings and optionally history to a ZIP file. @@ -35,12 +39,20 @@ public static async Task ExportSettingsToZipAsync(bool includeHistory) try { - // Export settings to JSON + // Export settings to JSON and sidecar files await ExportSettingsToJsonAsync(Path.Combine(tempDir, SettingsFileName)); + ExportManagedJsonSettingsFolder(tempDir); + await ExportGrabTemplatesAsync(tempDir); // Export history if requested if (includeHistory) { + // Flush any pending in-memory history changes to disk before + // reading the files. The lazy-loading HistoryService may have + // normalized IDs, migrated word-border data, or accepted new + // entries that haven't been written yet. + Singleton.Instance.WriteHistory(); + await ExportHistoryAsync(tempDir); } @@ -85,6 +97,9 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) await ImportSettingsFromJsonAsync(settingsPath); } + ImportManagedJsonSettingsFolder(tempDir); + await ImportGrabTemplatesAsync(tempDir); + // Import history if present string historyTextOnlyPath = Path.Combine(tempDir, HistoryTextOnlyFileName); string historyWithImagePath = Path.Combine(tempDir, HistoryWithImageFileName); @@ -106,6 +121,7 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) private static async Task ExportSettingsToJsonAsync(string filePath) { Settings settings = AppUtilities.TextGrabSettings; + SettingsService settingsService = AppUtilities.TextGrabSettingsService; Dictionary settingsDict = new(); // Iterate through all settings properties using reflection @@ -113,9 +129,18 @@ private static async Task ExportSettingsToJsonAsync(string filePath) { string propertyName = property.Name; object? value = settings[propertyName]; + if (SettingsService.IsManagedJsonSetting(propertyName)) + value = settingsService.GetManagedJsonSettingValueForExport(propertyName); + settingsDict[propertyName] = value; } + if (settingsDict.Count == 0) + { + foreach (PropertyInfo propertyInfo in GetSerializableSettingProperties(settings.GetType())) + settingsDict[propertyInfo.Name] = propertyInfo.GetValue(settings); + } + JsonSerializerOptions options = new() { WriteIndented = true, @@ -141,6 +166,8 @@ private static async Task ImportSettingsFromJsonAsync(string filePath) return; Settings settings = AppUtilities.TextGrabSettings; + Dictionary reflectedSettings = GetSerializableSettingProperties(settings.GetType()) + .ToDictionary(property => property.Name, property => property, StringComparer.Ordinal); // Apply each setting foreach (var kvp in settingsDict) @@ -151,14 +178,23 @@ private static async Task ImportSettingsFromJsonAsync(string filePath) try { SettingsProperty? property = settings.Properties[propertyName]; - if (property is null) - continue; - - object? value = ConvertJsonElementToSettingValue(kvp.Value, property); - if (value is not null) + if (property is not null) { - settings[propertyName] = value; + object? value = ConvertJsonElementToSettingValue(kvp.Value, property.PropertyType); + if (value is not null) + { + settings[propertyName] = value; + } + + continue; } + + if (!reflectedSettings.TryGetValue(propertyName, out PropertyInfo? propertyInfo)) + continue; + + object? reflectedValue = ConvertJsonElementToSettingValue(kvp.Value, propertyInfo.PropertyType); + if (reflectedValue is not null) + propertyInfo.SetValue(settings, reflectedValue); } catch (Exception ex) { @@ -169,6 +205,92 @@ private static async Task ImportSettingsFromJsonAsync(string filePath) settings.Save(); } + private static async Task ExportGrabTemplatesAsync(string tempDir) + { + string templatesJson = GrabTemplateManager.GetTemplatesJsonForExport(); + await File.WriteAllTextAsync(Path.Combine(tempDir, GrabTemplatesFileName), templatesJson); + + string sourceImagesDir = GrabTemplateManager.GetTemplateImagesFolder(); + if (!Directory.Exists(sourceImagesDir)) + return; + + string destinationImagesDir = Path.Combine(tempDir, TemplateImagesFolderName); + Directory.CreateDirectory(destinationImagesDir); + + foreach (string imagePath in Directory.GetFiles(sourceImagesDir)) + { + string destinationPath = Path.Combine(destinationImagesDir, Path.GetFileName(imagePath)); + File.Copy(imagePath, destinationPath, true); + } + } + + private static async Task ImportGrabTemplatesAsync(string tempDir) + { + string templatesPath = Path.Combine(tempDir, GrabTemplatesFileName); + string sourceImagesDir = Path.Combine(tempDir, TemplateImagesFolderName); + + if (File.Exists(templatesPath)) + { + string templatesJson = await File.ReadAllTextAsync(templatesPath); + GrabTemplateManager.ImportTemplatesFromJson(templatesJson); + } + else if (GrabTemplateManager.GetAllTemplates() is { Count: > 0 }) + { + // No templates in the ZIP — trigger a read so the dual-store sync + // reconciles the legacy setting and sidecar file for any existing + // templates that were already on this machine. + GrabTemplateManager.SaveTemplates(GrabTemplateManager.GetAllTemplates()); + } + + if (!Directory.Exists(sourceImagesDir)) + return; + + string destinationImagesDir = GrabTemplateManager.GetTemplateImagesFolder(); + Directory.CreateDirectory(destinationImagesDir); + + foreach (string imagePath in Directory.GetFiles(sourceImagesDir)) + { + string destinationPath = Path.Combine(destinationImagesDir, Path.GetFileName(imagePath)); + File.Copy(imagePath, destinationPath, true); + } + } + + private static void ExportManagedJsonSettingsFolder(string tempDir) + { + string sourceFolderPath = AppUtilities.TextGrabSettingsService.ManagedJsonSettingsFolderPath; + if (!Directory.Exists(sourceFolderPath)) + return; + + string[] sourceFiles = Directory.GetFiles(sourceFolderPath, "*.json"); + if (sourceFiles.Length == 0) + return; + + string destinationFolder = Path.Combine(tempDir, ManagedSettingsFolderName); + Directory.CreateDirectory(destinationFolder); + + foreach (string sourceFile in sourceFiles) + { + string destinationPath = Path.Combine(destinationFolder, Path.GetFileName(sourceFile)); + File.Copy(sourceFile, destinationPath, true); + } + } + + private static void ImportManagedJsonSettingsFolder(string tempDir) + { + string sourceFolder = Path.Combine(tempDir, ManagedSettingsFolderName); + if (!Directory.Exists(sourceFolder)) + return; + + string destinationFolder = AppUtilities.TextGrabSettingsService.ManagedJsonSettingsFolderPath; + Directory.CreateDirectory(destinationFolder); + + foreach (string sourceFile in Directory.GetFiles(sourceFolder, "*.json")) + { + string destinationPath = Path.Combine(destinationFolder, Path.GetFileName(sourceFile)); + File.Copy(sourceFile, destinationPath, true); + } + } + private static async Task ExportHistoryAsync(string tempDir) { // Get history file paths @@ -194,13 +316,21 @@ private static async Task ExportHistoryAsync(string tempDir) string historyDestDir = Path.Combine(tempDir, HistoryFolderName); Directory.CreateDirectory(historyDestDir); - // Copy all .bmp files from history directory - string[] imageFiles = Directory.GetFiles(historyBasePath, "*.bmp"); - foreach (string imageFile in imageFiles) + string[] historyArtifactFiles = Directory + .GetFiles(historyBasePath) + .Where(filePath => + { + string fileName = Path.GetFileName(filePath); + return !fileName.Equals(HistoryTextOnlyFileName, StringComparison.OrdinalIgnoreCase) + && !fileName.Equals(HistoryWithImageFileName, StringComparison.OrdinalIgnoreCase); + }) + .ToArray(); + + foreach (string historyFile in historyArtifactFiles) { - string fileName = Path.GetFileName(imageFile); + string fileName = Path.GetFileName(historyFile); string destPath = Path.Combine(historyDestDir, fileName); - File.Copy(imageFile, destPath, true); + File.Copy(historyFile, destPath, true); } } } @@ -231,12 +361,12 @@ private static async Task ImportHistoryAsync(string tempDir) string historySourceDir = Path.Combine(tempDir, HistoryFolderName); if (Directory.Exists(historySourceDir)) { - string[] imageFiles = Directory.GetFiles(historySourceDir, "*.bmp"); - foreach (string imageFile in imageFiles) + string[] historyArtifactFiles = Directory.GetFiles(historySourceDir); + foreach (string historyFile in historyArtifactFiles) { - string fileName = Path.GetFileName(imageFile); + string fileName = Path.GetFileName(historyFile); string destPath = Path.Combine(historyBasePath, fileName); - File.Copy(imageFile, destPath, true); + File.Copy(historyFile, destPath, true); } } @@ -252,10 +382,19 @@ private static string ConvertToPascalCase(string camelCase) return char.ToUpper(camelCase[0]) + camelCase.Substring(1); } - private static object? ConvertJsonElementToSettingValue(JsonElement jsonElement, SettingsProperty property) + private static IEnumerable GetSerializableSettingProperties(Type settingsType) { - Type propertyType = property.PropertyType; + return settingsType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => + property.CanRead + && property.CanWrite + && property.GetIndexParameters().Length == 0 + && property.GetCustomAttribute() is not null); + } + private static object? ConvertJsonElementToSettingValue(JsonElement jsonElement, Type propertyType) + { try { if (propertyType == typeof(string)) diff --git a/Text-Grab/Utilities/ShareTargetUtilities.cs b/Text-Grab/Utilities/ShareTargetUtilities.cs new file mode 100644 index 00000000..75e66070 --- /dev/null +++ b/Text-Grab/Utilities/ShareTargetUtilities.cs @@ -0,0 +1,159 @@ +using Microsoft.Windows.AppLifecycle; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Views; +using Windows.ApplicationModel.Activation; +using Windows.ApplicationModel.DataTransfer; +using Windows.ApplicationModel.DataTransfer.ShareTarget; +using Windows.Storage; + +namespace Text_Grab.Utilities; + +public static class ShareTargetUtilities +{ + public static bool IsShareTargetActivation() + { + try + { + AppActivationArguments args = AppInstance.GetCurrent().GetActivatedEventArgs(); + return args.Kind == ExtendedActivationKind.ShareTarget; + } + catch (Exception ex) + { + Debug.WriteLine($"Error checking share target activation: {ex.Message}"); + return false; + } + } + + public static async Task HandleShareTargetActivationAsync() + { + try + { + AppActivationArguments args = AppInstance.GetCurrent().GetActivatedEventArgs(); + + if (args.Kind != ExtendedActivationKind.ShareTarget) + return false; + + if (args.Data is not ShareTargetActivatedEventArgs shareArgs) + return false; + + ShareOperation shareOperation = shareArgs.ShareOperation; + DataPackageView data = shareOperation.Data; + + bool handled = false; + + if (data.Contains(StandardDataFormats.StorageItems)) + { + handled = await HandleSharedStorageItemsAsync(data); + } + else if (data.Contains(StandardDataFormats.Bitmap)) + { + handled = await HandleSharedBitmapAsync(data); + } + else if (data.Contains(StandardDataFormats.Text)) + { + handled = await HandleSharedTextAsync(data); + } + else if (data.Contains(StandardDataFormats.Uri)) + { + handled = await HandleSharedUriAsync(data); + } + + shareOperation.ReportCompleted(); + return handled; + } + catch (Exception ex) + { + Debug.WriteLine($"Error handling share target activation: {ex.Message}"); + return false; + } + } + + private static async Task HandleSharedStorageItemsAsync(DataPackageView data) + { + var items = await data.GetStorageItemsAsync(); + + foreach (IStorageItem item in items) + { + if (item is StorageFile file && IoUtilities.IsImageFileExtension(Path.GetExtension(file.Path))) + { + GrabFrame gf = new(file.Path); + gf.Show(); + gf.Activate(); + return true; + } + } + + // If non-image files were shared, try to read as text + foreach (IStorageItem item in items) + { + if (item is StorageFile file) + { + try + { + string text = await FileIO.ReadTextAsync(file); + EditTextWindow etw = new(); + etw.AddThisText(text); + etw.Show(); + etw.Activate(); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to read shared file as text: {ex.Message}"); + } + } + } + + return false; + } + + private static async Task HandleSharedBitmapAsync(DataPackageView data) + { + var bitmapRef = await data.GetBitmapAsync(); + using var stream = await bitmapRef.OpenReadAsync(); + + string tempPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Share_{Guid.NewGuid():N}.png"); + + using (var fileStream = File.Create(tempPath)) + { + var inputStream = stream.GetInputStreamAt(0); + using var reader = new Windows.Storage.Streams.DataReader(inputStream); + ulong size = stream.Size; + await reader.LoadAsync((uint)size); + byte[] buffer = new byte[size]; + reader.ReadBytes(buffer); + await fileStream.WriteAsync(buffer); + } + + GrabFrame gf = new(tempPath); + gf.Show(); + gf.Activate(); + return true; + } + + private static async Task HandleSharedTextAsync(DataPackageView data) + { + string text = await data.GetTextAsync(); + + EditTextWindow etw = new(); + etw.AddThisText(text); + etw.Show(); + etw.Activate(); + return true; + } + + private static async Task HandleSharedUriAsync(DataPackageView data) + { + Uri uri = await data.GetUriAsync(); + + EditTextWindow etw = new(); + etw.AddThisText(uri.ToString()); + etw.Show(); + etw.Activate(); + return true; + } +} diff --git a/Text-Grab/Utilities/ShortcutKeysUtilities.cs b/Text-Grab/Utilities/ShortcutKeysUtilities.cs index 5e1f133c..cdaf77ae 100644 --- a/Text-Grab/Utilities/ShortcutKeysUtilities.cs +++ b/Text-Grab/Utilities/ShortcutKeysUtilities.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Windows.Input; using Text_Grab.Models; @@ -11,34 +10,18 @@ internal class ShortcutKeysUtilities { public static void SaveShortcutKeySetSettings(IEnumerable shortcutKeySets) { - string json = JsonSerializer.Serialize(shortcutKeySets); - - // save the json string to the settings - AppUtilities.TextGrabSettings.ShortcutKeySets = json; - - // save the settings - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveShortcutKeySets(shortcutKeySets); } public static IEnumerable GetShortcutKeySetsFromSettings() { - string json = AppUtilities.TextGrabSettings.ShortcutKeySets; - List defaultKeys = ShortcutKeySet.DefaultShortcutKeySets; + List shortcutKeySets = AppUtilities.TextGrabSettingsService.LoadShortcutKeySets(); - if (string.IsNullOrWhiteSpace(json)) + if (shortcutKeySets.Count == 0) return ParseFromPreviousAndDefaultsSettings(); - // create a list of custom bottom bar items - List? shortcutKeySets = new(); - - // deserialize the json string into a list of custom bottom bar items - shortcutKeySets = JsonSerializer.Deserialize>(json); - // return the list of custom bottom bar items - if (shortcutKeySets is null || shortcutKeySets.Count == 0) - return defaultKeys; - List actionsList = shortcutKeySets.Select(x => x.Action).ToList(); return shortcutKeySets.Concat(defaultKeys.Where(x => !actionsList.Contains(x.Action)).ToList()).ToList(); } diff --git a/Text-Grab/Utilities/UIAutomationUtilities.cs b/Text-Grab/Utilities/UIAutomationUtilities.cs new file mode 100644 index 00000000..0987874c --- /dev/null +++ b/Text-Grab/Utilities/UIAutomationUtilities.cs @@ -0,0 +1,1342 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Automation; +using Text_Grab.Models; +using TextPatternRange = System.Windows.Automation.Text.TextPatternRange; +using TextUnit = System.Windows.Automation.Text.TextUnit; + +namespace Text_Grab.Utilities; + +public static class UIAutomationUtilities +{ + private const int FastMaxDepth = 2; + private const int BalancedMaxDepth = 6; + private const int ThoroughMaxDepth = 12; + private const int MaxPointAncestorDepth = 5; + + private enum AutomationTextSource + { + None = 0, + NameFallback = 1, + TextPattern = 2, + ValuePattern = 3, + PointTextPattern = 4, + } + + private readonly record struct TextExtractionCandidate(string Text, AutomationTextSource Source, int Depth); + private readonly record struct WindowPointCandidate(TextExtractionCandidate Candidate, double Area); + private readonly record struct OverlayCandidate(UiAutomationOverlayItem Item, AutomationTextSource Source, int Depth); + + public static Task GetTextFromPointAsync(Point screenPoint) + => GetTextFromPointAsync(screenPoint, null); + + public static Task GetTextFromPointAsync(Point screenPoint, IReadOnlyCollection? excludedHandles) + { + UiAutomationOptions options = GetOptionsFromSettings(); + return Task.Run(() => GetTextFromPoint(screenPoint, options, excludedHandles)); + } + + public static Task GetTextFromRegionAsync(Rect screenRect) + => GetTextFromRegionAsync(screenRect, null); + + public static Task GetTextFromRegionAsync(Rect screenRect, IReadOnlyCollection? excludedHandles) + { + UiAutomationOptions options = GetOptionsFromSettings(screenRect); + return Task.Run(() => GetTextFromRegion(screenRect, options, excludedHandles)); + } + + public static Task GetTextFromWindowAsync(IntPtr windowHandle, Rect? filterBounds = null) + { + UiAutomationOptions options = GetOptionsFromSettings(filterBounds); + return Task.Run(() => GetTextFromWindow(windowHandle, options)); + } + + public static Task GetOverlaySnapshotFromRegionAsync(Rect screenRect) + => GetOverlaySnapshotFromRegionAsync(screenRect, null); + + public static Task GetOverlaySnapshotFromRegionAsync(Rect screenRect, IReadOnlyCollection? excludedHandles) + { + UiAutomationOptions options = GetOptionsFromSettings(screenRect); + return Task.Run(() => GetOverlaySnapshotFromRegion(screenRect, options, excludedHandles)); + } + + internal static UiAutomationOptions GetOptionsFromSettings(Rect? filterBounds = null) + { + UiAutomationTraversalMode traversalMode = UiAutomationTraversalMode.Balanced; + Enum.TryParse(AppUtilities.TextGrabSettings.UiAutomationTraversalMode, true, out traversalMode); + + return new UiAutomationOptions( + traversalMode, + AppUtilities.TextGrabSettings.UiAutomationIncludeOffscreen, + AppUtilities.TextGrabSettings.UiAutomationPreferFocusedElement, + filterBounds); + } + + internal static WindowSelectionCandidate? FindTargetWindowCandidate(Rect selectionRect, IEnumerable candidates) + { + Point centerPoint = new(selectionRect.X + (selectionRect.Width / 2), selectionRect.Y + (selectionRect.Height / 2)); + WindowSelectionCandidate? centerCandidate = WindowSelectionUtilities.FindWindowAtPoint(candidates, centerPoint); + if (centerCandidate is not null) + return centerCandidate; + + return candidates + .Select(candidate => new + { + Candidate = candidate, + Area = GetIntersectionArea(selectionRect, candidate.Bounds) + }) + .Where(entry => entry.Area > 0) + .OrderByDescending(entry => entry.Area) + .Select(entry => entry.Candidate) + .FirstOrDefault(); + } + + internal static WindowSelectionCandidate? FindPointTargetWindowCandidate(Point screenPoint, IReadOnlyCollection? excludedHandles) + { + List candidates = WindowSelectionUtilities.GetCapturableWindows(excludedHandles); + WindowSelectionCandidate? directCandidate = WindowSelectionUtilities.FindWindowAtPoint(candidates, screenPoint); + if (directCandidate is not null) + return directCandidate; + + Rect searchRect = new(screenPoint.X - 1, screenPoint.Y - 1, 2, 2); + return FindTargetWindowCandidate(searchRect, candidates); + } + + internal static string NormalizeText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + return string.Join( + Environment.NewLine, + text.Split([Environment.NewLine, "\r", "\n"], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(line => string.Join(' ', line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)))); + } + + internal static bool TryAddUniqueText(string? text, ISet seenText, List output) + { + string normalizedText = NormalizeText(text); + if (string.IsNullOrWhiteSpace(normalizedText)) + return false; + + if (!seenText.Add(normalizedText)) + return false; + + output.Add(normalizedText); + return true; + } + + internal static bool ShouldUseNameFallback(ControlType controlType) + { + return controlType == ControlType.Text + || controlType == ControlType.Hyperlink + || controlType == ControlType.ListItem + || controlType == ControlType.DataItem + || controlType == ControlType.TreeItem + || controlType == ControlType.MenuItem + || controlType == ControlType.TabItem + || controlType == ControlType.HeaderItem; + } + + internal static IReadOnlyList GetSamplePoints(Rect selectionRect) + { + if (selectionRect == Rect.Empty || selectionRect.Width <= 0 || selectionRect.Height <= 0) + return []; + + double[] xRatios = selectionRect.Width < 80 ? [0.5] : [0.2, 0.5, 0.8]; + double[] yRatios = selectionRect.Height < 80 ? [0.5] : [0.2, 0.5, 0.8]; + + HashSet seen = new(StringComparer.Ordinal); + List samplePoints = []; + + foreach (double yRatio in yRatios) + { + foreach (double xRatio in xRatios) + { + Point point = new( + selectionRect.Left + (selectionRect.Width * xRatio), + selectionRect.Top + (selectionRect.Height * yRatio)); + + string key = $"{Math.Round(point.X, 2)}|{Math.Round(point.Y, 2)}"; + if (seen.Add(key)) + samplePoints.Add(point); + } + } + + return samplePoints; + } + + internal static IReadOnlyList GetPointProbePoints(Point screenPoint) + { + const double probeOffset = 2.0; + + return + [ + screenPoint, + new Point(screenPoint.X - probeOffset, screenPoint.Y), + new Point(screenPoint.X + probeOffset, screenPoint.Y), + new Point(screenPoint.X, screenPoint.Y - probeOffset), + new Point(screenPoint.X, screenPoint.Y + probeOffset), + ]; + } + + internal static bool TryClipBounds(Rect bounds, Rect? filterBounds, out Rect clippedBounds) + { + clippedBounds = bounds; + + if (bounds == Rect.Empty || bounds.Width < 1 || bounds.Height < 1) + return false; + + if (filterBounds is Rect clipBounds) + { + clippedBounds = Rect.Intersect(bounds, clipBounds); + if (clippedBounds == Rect.Empty || clippedBounds.Width < 1 || clippedBounds.Height < 1) + return false; + } + + return true; + } + + internal static string BuildOverlayDedupKey(UiAutomationOverlayItem item) + { + return string.Join( + '|', + NormalizeText(item.Text), + Math.Round(item.ScreenBounds.X, 1).ToString(CultureInfo.InvariantCulture), + Math.Round(item.ScreenBounds.Y, 1).ToString(CultureInfo.InvariantCulture), + Math.Round(item.ScreenBounds.Width, 1).ToString(CultureInfo.InvariantCulture), + Math.Round(item.ScreenBounds.Height, 1).ToString(CultureInfo.InvariantCulture)); + } + + internal static bool TryAddUniqueOverlayItem(UiAutomationOverlayItem item, ISet seenItems, List output) + { + if (string.IsNullOrWhiteSpace(NormalizeText(item.Text))) + return false; + + string dedupKey = BuildOverlayDedupKey(item); + if (!seenItems.Add(dedupKey)) + return false; + + output.Add(item); + return true; + } + + internal static IReadOnlyList SortOverlayItems(IEnumerable items) + { + return + [ + .. items.OrderBy(item => Math.Round(item.ScreenBounds.Top, 1)) + .ThenBy(item => Math.Round(item.ScreenBounds.Left, 1)) + .ThenBy(item => item.Text, StringComparer.CurrentCulture) + ]; + } + + private static string GetTextFromPoint(Point screenPoint, UiAutomationOptions options, IReadOnlyCollection? excludedHandles) + { + if (excludedHandles is not null && excludedHandles.Count > 0) + { + string excludedWindowText = GetTextFromPointInUnderlyingWindow(screenPoint, options, excludedHandles); + if (!string.IsNullOrWhiteSpace(excludedWindowText)) + return excludedWindowText; + } + + TextExtractionCandidate? bestCandidate = null; + + foreach (Point probePoint in GetPointProbePoints(screenPoint)) + { + AutomationElement? element = GetElementAtPoint(probePoint); + if (element is null) + continue; + + TextExtractionCandidate? probeCandidate = GetBestPointTextCandidate(element, probePoint, options, TextUnit.Line); + if (probeCandidate is not null && IsBetterCandidate(probeCandidate.Value, bestCandidate)) + { + bestCandidate = probeCandidate; + + if (probePoint == screenPoint + && probeCandidate.Value.Source == AutomationTextSource.PointTextPattern + && probeCandidate.Value.Depth == 0) + { + break; + } + } + } + + return bestCandidate?.Text ?? string.Empty; + } + + private static string GetTextFromPointInUnderlyingWindow( + Point screenPoint, + UiAutomationOptions options, + IReadOnlyCollection excludedHandles) + { + WindowSelectionCandidate? targetWindow = FindPointTargetWindowCandidate(screenPoint, excludedHandles); + if (targetWindow is null || targetWindow.Handle == IntPtr.Zero) + return string.Empty; + + try + { + AutomationElement root = AutomationElement.FromHandle(targetWindow.Handle); + WindowPointCandidate? bestCandidate = null; + + foreach ((AutomationElement element, _) in EnumerateElementsWithDepth(root, options)) + { + if (ShouldSkipElementText(element, options)) + continue; + + if (!TryGetElementBounds(element, options.FilterBounds, out Rect bounds) || !bounds.Contains(screenPoint)) + continue; + + if (!TryCreatePointTextCandidate(element, screenPoint, 0, TextUnit.Line, out TextExtractionCandidate candidate)) + continue; + + WindowPointCandidate windowPointCandidate = new(candidate, Math.Max(1, bounds.Width * bounds.Height)); + if (IsBetterWindowPointCandidate(windowPointCandidate, bestCandidate)) + bestCandidate = windowPointCandidate; + } + + return bestCandidate?.Candidate.Text ?? string.Empty; + } + catch (ElementNotAvailableException) + { + return string.Empty; + } + catch (ArgumentException) + { + return string.Empty; + } + } + + private static string GetTextFromRegion(Rect screenRect, UiAutomationOptions options, IReadOnlyCollection? excludedHandles) + { + List candidates = WindowSelectionUtilities.GetCapturableWindows(excludedHandles); + WindowSelectionCandidate? targetWindow = FindTargetWindowCandidate(screenRect, candidates); + if (targetWindow is null) + return string.Empty; + + if (targetWindow.Handle == IntPtr.Zero) + return string.Empty; + + try + { + AutomationElement root = AutomationElement.FromHandle(targetWindow.Handle); + HashSet seenText = new(StringComparer.CurrentCulture); + List extractedText = []; + + AppendTextFromSamplePoints(root, screenRect, options, seenText, extractedText); + AppendTextFromElementTree(root, options, seenText, extractedText); + + return string.Join(Environment.NewLine, extractedText); + } + catch (ElementNotAvailableException) + { + return string.Empty; + } + catch (ArgumentException) + { + return string.Empty; + } + } + + private static string GetTextFromWindow(IntPtr windowHandle, UiAutomationOptions options) + { + if (windowHandle == IntPtr.Zero) + return string.Empty; + + try + { + AutomationElement root = AutomationElement.FromHandle(windowHandle); + return ExtractTextFromElementTree(root, options); + } + catch (ElementNotAvailableException) + { + return string.Empty; + } + catch (ArgumentException) + { + return string.Empty; + } + } + + private static UiAutomationOverlaySnapshot? GetOverlaySnapshotFromRegion( + Rect screenRect, + UiAutomationOptions options, + IReadOnlyCollection? excludedHandles) + { + if (screenRect == Rect.Empty || screenRect.Width <= 0 || screenRect.Height <= 0) + return null; + + List candidates = WindowSelectionUtilities.GetCapturableWindows(excludedHandles); + WindowSelectionCandidate? targetWindow = FindTargetWindowCandidate(screenRect, candidates); + if (targetWindow is null || targetWindow.Handle == IntPtr.Zero) + return null; + + try + { + AutomationElement root = AutomationElement.FromHandle(targetWindow.Handle); + HashSet seenItems = new(StringComparer.CurrentCulture); + List items = []; + + AppendOverlayItemsFromSamplePoints(root, screenRect, options, seenItems, items); + AppendOverlayItemsFromElementTree(root, options, seenItems, items); + + return new UiAutomationOverlaySnapshot(screenRect, targetWindow, SortOverlayItems(items)); + } + catch (ElementNotAvailableException) + { + return null; + } + catch (ArgumentException) + { + return null; + } + } + + private static string ExtractTextFromElementTree(AutomationElement root, UiAutomationOptions options) + { + HashSet seenText = new(StringComparer.CurrentCulture); + List extractedText = []; + AppendTextFromElementTree(root, options, seenText, extractedText); + return string.Join(Environment.NewLine, extractedText); + } + + private static void AppendTextFromElementTree( + AutomationElement root, + UiAutomationOptions options, + ISet seenText, + List extractedText) + { + if (options.PreferFocusedElement) + TryExtractFocusedElementText(root, options, seenText, extractedText); + + foreach (AutomationElement element in EnumerateElements(root, options)) + { + if (ShouldSkipElementText(element, options)) + continue; + + TryAddUniqueText(ExtractTextFromElement(element, options.FilterBounds), seenText, extractedText); + } + } + + private static void AppendOverlayItemsFromElementTree( + AutomationElement root, + UiAutomationOptions options, + ISet seenItems, + List overlayItems) + { + if (options.PreferFocusedElement) + TryExtractFocusedElementOverlayItems(root, options, seenItems, overlayItems); + + foreach (AutomationElement element in EnumerateElements(root, options)) + { + if (ShouldSkipElementText(element, options)) + continue; + + TryAddOverlayItemsFromElement(element, options, seenItems, overlayItems); + } + } + + private static void AppendTextFromSamplePoints( + AutomationElement root, + Rect selectionRect, + UiAutomationOptions options, + ISet seenText, + List extractedText) + { + foreach (Point samplePoint in GetSamplePoints(selectionRect)) + { + AutomationElement? element = GetElementAtPoint(samplePoint); + if (element is null || !IsDescendantOrSelf(root, element)) + continue; + + TryAddUniqueText( + GetBestPointText(element, samplePoint, options, TextUnit.Line), + seenText, + extractedText); + } + } + + private static void AppendOverlayItemsFromSamplePoints( + AutomationElement root, + Rect selectionRect, + UiAutomationOptions options, + ISet seenItems, + List overlayItems) + { + foreach (Point samplePoint in GetSamplePoints(selectionRect)) + { + AutomationElement? element = GetElementAtPoint(samplePoint); + if (element is null || !IsDescendantOrSelf(root, element)) + continue; + + OverlayCandidate? candidate = GetBestPointOverlayCandidate(element, samplePoint, options, TextUnit.Line); + if (candidate is not null) + TryAddUniqueOverlayItem(candidate.Value.Item, seenItems, overlayItems); + } + } + + private static string GetBestPointText( + AutomationElement element, + Point screenPoint, + UiAutomationOptions options, + TextUnit pointTextUnit) + { + return GetBestPointTextCandidate(element, screenPoint, options, pointTextUnit)?.Text ?? string.Empty; + } + + private static TextExtractionCandidate? GetBestPointTextCandidate( + AutomationElement element, + Point screenPoint, + UiAutomationOptions options, + TextUnit pointTextUnit) + { + TextExtractionCandidate? bestCandidate = null; + AutomationElement? current = element; + + for (int depth = 0; current is not null && depth <= MaxPointAncestorDepth; depth++) + { + if (!ShouldSkipElementText(current, options) + && TryCreatePointTextCandidate(current, screenPoint, depth, pointTextUnit, out TextExtractionCandidate candidate) + && IsBetterCandidate(candidate, bestCandidate)) + { + bestCandidate = candidate; + + if (candidate.Source == AutomationTextSource.PointTextPattern && candidate.Depth == 0) + break; + } + + current = GetParentElement(current); + } + + return bestCandidate; + } + + private static OverlayCandidate? GetBestPointOverlayCandidate( + AutomationElement element, + Point screenPoint, + UiAutomationOptions options, + TextUnit pointTextUnit) + { + OverlayCandidate? bestCandidate = null; + AutomationElement? current = element; + + for (int depth = 0; current is not null && depth <= MaxPointAncestorDepth; depth++) + { + if (!ShouldSkipElementText(current, options) + && TryCreatePointOverlayCandidate(current, screenPoint, depth, pointTextUnit, options.FilterBounds, out OverlayCandidate candidate) + && IsBetterCandidate(candidate, bestCandidate)) + { + bestCandidate = candidate; + + if (candidate.Source == AutomationTextSource.PointTextPattern && candidate.Depth == 0) + break; + } + + current = GetParentElement(current); + } + + return bestCandidate; + } + + private static bool TryCreatePointTextCandidate( + AutomationElement element, + Point screenPoint, + int depth, + TextUnit pointTextUnit, + out TextExtractionCandidate candidate) + { + candidate = default; + + if (TryExtractTextPatternTextAtPoint(element, screenPoint, pointTextUnit, out string pointText)) + { + candidate = new(NormalizeText(pointText), AutomationTextSource.PointTextPattern, depth); + return true; + } + + if (TryExtractValuePatternText(element, out string valuePatternText)) + { + candidate = new(NormalizeText(valuePatternText), AutomationTextSource.ValuePattern, depth); + return true; + } + + if (TryExtractTextPatternText(element, null, out string textPatternText)) + { + candidate = new(NormalizeText(textPatternText), AutomationTextSource.TextPattern, depth); + return true; + } + + if (TryExtractNameText(element, out string nameText)) + { + candidate = new(NormalizeText(nameText), AutomationTextSource.NameFallback, depth); + return true; + } + + return false; + } + + private static bool TryCreatePointOverlayCandidate( + AutomationElement element, + Point screenPoint, + int depth, + TextUnit pointTextUnit, + Rect? filterBounds, + out OverlayCandidate candidate) + { + candidate = default; + + if (TryCreatePointTextRangeOverlayItem(element, screenPoint, pointTextUnit, filterBounds, out UiAutomationOverlayItem pointTextItem)) + { + candidate = new(pointTextItem, AutomationTextSource.PointTextPattern, depth); + return true; + } + + if (TryCreateElementBoundsOverlayItem(element, filterBounds, out UiAutomationOverlayItem elementBoundsItem, out AutomationTextSource source)) + { + candidate = new(elementBoundsItem, source, depth); + return true; + } + + return false; + } + + private static bool IsBetterCandidate(TextExtractionCandidate candidate, TextExtractionCandidate? currentBest) + { + if (currentBest is null) + return true; + + if (candidate.Source != currentBest.Value.Source) + return candidate.Source > currentBest.Value.Source; + + return candidate.Depth < currentBest.Value.Depth; + } + + private static bool IsBetterCandidate(OverlayCandidate candidate, OverlayCandidate? currentBest) + { + if (currentBest is null) + return true; + + if (candidate.Source != currentBest.Value.Source) + return candidate.Source > currentBest.Value.Source; + + return candidate.Depth < currentBest.Value.Depth; + } + + private static bool IsBetterWindowPointCandidate(WindowPointCandidate candidate, WindowPointCandidate? currentBest) + { + if (currentBest is null) + return true; + + if (candidate.Candidate.Source != currentBest.Value.Candidate.Source) + return candidate.Candidate.Source > currentBest.Value.Candidate.Source; + + return candidate.Area < currentBest.Value.Area; + } + + private static void TryExtractFocusedElementText( + AutomationElement root, + UiAutomationOptions options, + ISet seenText, + List extractedText) + { + try + { + AutomationElement? focusedElement = AutomationElement.FocusedElement; + if (focusedElement is null || !IsDescendantOrSelf(root, focusedElement)) + return; + + if (!ShouldSkipElementText(focusedElement, options)) + TryAddUniqueText(ExtractTextFromElement(focusedElement, options.FilterBounds), seenText, extractedText); + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + } + + private static void TryExtractFocusedElementOverlayItems( + AutomationElement root, + UiAutomationOptions options, + ISet seenItems, + List overlayItems) + { + try + { + AutomationElement? focusedElement = AutomationElement.FocusedElement; + if (focusedElement is null || !IsDescendantOrSelf(root, focusedElement)) + return; + + if (!ShouldSkipElementText(focusedElement, options)) + TryAddOverlayItemsFromElement(focusedElement, options, seenItems, overlayItems); + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + } + + private static IEnumerable<(AutomationElement Element, int Depth)> EnumerateElementsWithDepth(AutomationElement root, UiAutomationOptions options) + { + Queue<(AutomationElement Element, int Depth)> queue = new(); + queue.Enqueue((root, 0)); + TreeWalker walker = options.TraversalMode == UiAutomationTraversalMode.Thorough + ? TreeWalker.RawViewWalker + : TreeWalker.ControlViewWalker; + int maxDepth = GetMaxDepth(options.TraversalMode); + + while (queue.Count > 0) + { + (AutomationElement element, int depth) = queue.Dequeue(); + yield return (element, depth); + + if (depth >= maxDepth) + continue; + + AutomationElement? child = null; + try + { + child = walker.GetFirstChild(element); + } + catch (ElementNotAvailableException) + { + } + + while (child is not null) + { + queue.Enqueue((child, depth + 1)); + + try + { + child = walker.GetNextSibling(child); + } + catch (ElementNotAvailableException) + { + child = null; + } + } + } + } + + private static IEnumerable EnumerateElements(AutomationElement root, UiAutomationOptions options) + { + foreach ((AutomationElement element, _) in EnumerateElementsWithDepth(root, options)) + yield return element; + } + + private static bool ShouldSkipElementText(AutomationElement element, UiAutomationOptions options) + { + try + { + AutomationElement.AutomationElementInformation current = element.Current; + + if (!options.IncludeOffscreen && current.IsOffscreen) + return true; + + Rect bounds = current.BoundingRectangle; + if (bounds == Rect.Empty || bounds.Width < 1 || bounds.Height < 1) + return true; + + if (!current.IsContentElement && !IsTextBearingControlType(current.ControlType)) + return true; + + if (options.FilterBounds is Rect filterBounds && !bounds.IntersectsWith(filterBounds)) + return true; + + return false; + } + catch (ElementNotAvailableException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + } + + private static string ExtractTextFromElement(AutomationElement element, Rect? filterBounds = null) + { + if (TryExtractTextPatternText(element, filterBounds, out string textPatternText)) + return textPatternText; + + if (TryExtractValuePatternText(element, out string valuePatternText)) + return valuePatternText; + + if (TryExtractNameText(element, out string nameText)) + return nameText; + + return string.Empty; + } + + private static bool TryExtractTextPatternTextAtPoint( + AutomationElement element, + Point screenPoint, + TextUnit preferredUnit, + out string text) + { + text = string.Empty; + + try + { + if (element.TryGetCurrentPattern(TextPattern.Pattern, out object pattern) + && pattern is TextPattern textPattern) + { + TextPatternRange range = textPattern.RangeFromPoint(screenPoint); + range.ExpandToEnclosingUnit(preferredUnit); + text = range.GetText(-1); + + if (!string.IsNullOrWhiteSpace(text)) + return true; + + if (preferredUnit != TextUnit.Line) + { + range = textPattern.RangeFromPoint(screenPoint); + range.ExpandToEnclosingUnit(TextUnit.Line); + text = range.GetText(-1); + return !string.IsNullOrWhiteSpace(text); + } + } + } + catch (ArgumentException) + { + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + return false; + } + + private static bool TryExtractTextPatternText(AutomationElement element, Rect? filterBounds, out string text) + { + text = string.Empty; + + try + { + if (element.TryGetCurrentPattern(TextPattern.Pattern, out object pattern) + && pattern is TextPattern textPattern) + { + if (filterBounds is Rect bounds) + return TryExtractVisibleTextPatternText(textPattern, bounds, out text); + + text = textPattern.DocumentRange.GetText(-1); + return !string.IsNullOrWhiteSpace(text); + } + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + return false; + } + + private static bool TryExtractVisibleTextPatternText(TextPattern textPattern, Rect filterBounds, out string text) + { + text = string.Empty; + + try + { + TextPatternRange[] visibleRanges = textPattern.GetVisibleRanges(); + if (visibleRanges.Length == 0) + return false; + + HashSet seenText = new(StringComparer.CurrentCulture); + List extractedText = []; + + foreach (TextPatternRange range in visibleRanges) + { + if (!RangeIntersectsBounds(range, filterBounds)) + continue; + + TryAddUniqueText(range.GetText(-1), seenText, extractedText); + } + + text = string.Join(Environment.NewLine, extractedText); + return !string.IsNullOrWhiteSpace(text); + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + return false; + } + + private static bool RangeIntersectsBounds(TextPatternRange range, Rect filterBounds) + { + try + { + return range.GetBoundingRectangles().Any(textBounds => textBounds != Rect.Empty && textBounds.IntersectsWith(filterBounds)); + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool TryExtractValuePatternText(AutomationElement element, out string text) + { + text = string.Empty; + + try + { + if (element.TryGetCurrentPattern(ValuePattern.Pattern, out object pattern) + && pattern is ValuePattern valuePattern) + { + text = valuePattern.Current.Value; + return !string.IsNullOrWhiteSpace(text); + } + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + return false; + } + + private static void TryAddOverlayItemsFromElement( + AutomationElement element, + UiAutomationOptions options, + ISet seenItems, + List overlayItems) + { + bool hasVisibleTextRanges = options.FilterBounds is Rect filterBounds + && TryAddVisibleTextRangeOverlayItems(element, filterBounds, seenItems, overlayItems); + + if (hasVisibleTextRanges) + return; + + if (TryCreateElementBoundsOverlayItem(element, options.FilterBounds, out UiAutomationOverlayItem overlayItem, out _)) + TryAddUniqueOverlayItem(overlayItem, seenItems, overlayItems); + } + + private static bool TryAddVisibleTextRangeOverlayItems( + AutomationElement element, + Rect filterBounds, + ISet seenItems, + List overlayItems) + { + try + { + if (!element.TryGetCurrentPattern(TextPattern.Pattern, out object pattern) + || pattern is not TextPattern textPattern) + { + return false; + } + + TextPatternRange[] visibleRanges = textPattern.GetVisibleRanges(); + bool createdAnyRange = false; + + foreach (TextPatternRange range in visibleRanges) + { + if (!TryCreateTextRangeOverlayItem(element, range, filterBounds, UiAutomationOverlaySource.VisibleTextRange, out UiAutomationOverlayItem overlayItem)) + continue; + + createdAnyRange = true; + TryAddUniqueOverlayItem(overlayItem, seenItems, overlayItems); + } + + return createdAnyRange; + } + catch (ElementNotAvailableException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool TryCreatePointTextRangeOverlayItem( + AutomationElement element, + Point screenPoint, + TextUnit preferredUnit, + Rect? filterBounds, + out UiAutomationOverlayItem overlayItem) + { + overlayItem = default!; + + try + { + if (!element.TryGetCurrentPattern(TextPattern.Pattern, out object pattern) + || pattern is not TextPattern textPattern) + { + return false; + } + + TextPatternRange range = textPattern.RangeFromPoint(screenPoint); + range.ExpandToEnclosingUnit(preferredUnit); + + if (TryCreateTextRangeOverlayItem(element, range, filterBounds, UiAutomationOverlaySource.PointTextRange, out overlayItem)) + return true; + + if (preferredUnit == TextUnit.Line) + return false; + + range = textPattern.RangeFromPoint(screenPoint); + range.ExpandToEnclosingUnit(TextUnit.Line); + return TryCreateTextRangeOverlayItem(element, range, filterBounds, UiAutomationOverlaySource.PointTextRange, out overlayItem); + } + catch (ArgumentException) + { + return false; + } + catch (ElementNotAvailableException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool TryCreateTextRangeOverlayItem( + AutomationElement element, + TextPatternRange range, + Rect? filterBounds, + UiAutomationOverlaySource source, + out UiAutomationOverlayItem overlayItem) + { + overlayItem = default!; + + string rawText; + try + { + rawText = range.GetText(-1); + } + catch (Exception ex) when (ex is InvalidOperationException or ElementNotAvailableException or System.Runtime.InteropServices.COMException) + { + return false; + } + + string text = NormalizeText(rawText); + if (string.IsNullOrWhiteSpace(text)) + return false; + + if (!TryGetRangeBounds(range, filterBounds, out Rect rangeBounds)) + return false; + + GetElementMetadata(element, out string controlTypeProgrammaticName, out string automationId, out string runtimeId); + overlayItem = new UiAutomationOverlayItem(text, rangeBounds, source, controlTypeProgrammaticName, automationId, runtimeId); + return true; + } + + private static bool TryGetRangeBounds(TextPatternRange range, Rect? filterBounds, out Rect bounds) + { + bounds = Rect.Empty; + + try + { + Rect aggregateBounds = Rect.Empty; + + foreach (Rect rectangle in range.GetBoundingRectangles()) + { + if (!TryClipBounds(rectangle, filterBounds, out Rect clippedBounds)) + continue; + + aggregateBounds = aggregateBounds == Rect.Empty ? clippedBounds : Rect.Union(aggregateBounds, clippedBounds); + } + + return TryClipBounds(aggregateBounds, filterBounds, out bounds); + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool TryCreateElementBoundsOverlayItem( + AutomationElement element, + Rect? filterBounds, + out UiAutomationOverlayItem overlayItem, + out AutomationTextSource source) + { + overlayItem = default!; + source = AutomationTextSource.None; + + if (!TryGetElementBounds(element, filterBounds, out Rect bounds)) + return false; + + string text; + if (TryExtractValuePatternText(element, out string valuePatternText)) + { + text = NormalizeText(valuePatternText); + source = AutomationTextSource.ValuePattern; + } + else if (TryExtractTextPatternText(element, filterBounds, out string textPatternText)) + { + text = NormalizeText(textPatternText); + source = AutomationTextSource.TextPattern; + } + else if (TryExtractNameText(element, out string nameText)) + { + text = NormalizeText(nameText); + source = AutomationTextSource.NameFallback; + } + else + { + return false; + } + + if (string.IsNullOrWhiteSpace(text)) + return false; + + GetElementMetadata(element, out string controlTypeProgrammaticName, out string automationId, out string runtimeId); + overlayItem = new UiAutomationOverlayItem(text, bounds, UiAutomationOverlaySource.ElementBounds, controlTypeProgrammaticName, automationId, runtimeId); + return true; + } + + private static bool TryGetElementBounds(AutomationElement element, Rect? filterBounds, out Rect bounds) + { + bounds = Rect.Empty; + + try + { + return TryClipBounds(element.Current.BoundingRectangle, filterBounds, out bounds); + } + catch (ElementNotAvailableException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool HasVisibleTextDescendant(AutomationElement element) + { + const int maxDepth = 2; + Queue<(AutomationElement Element, int Depth)> queue = new(); + + try + { + AutomationElement? child = TreeWalker.ControlViewWalker.GetFirstChild(element); + while (child is not null) + { + queue.Enqueue((child, 1)); + child = TreeWalker.ControlViewWalker.GetNextSibling(child); + } + } + catch (ElementNotAvailableException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + + while (queue.Count > 0) + { + (AutomationElement currentElement, int depth) = queue.Dequeue(); + + try + { + ControlType controlType = currentElement.Current.ControlType; + if (controlType == ControlType.Text + || controlType == ControlType.Edit + || controlType == ControlType.Document) + { + return true; + } + } + catch (ElementNotAvailableException) + { + continue; + } + catch (InvalidOperationException) + { + continue; + } + + if (depth >= maxDepth) + continue; + + try + { + AutomationElement? child = TreeWalker.ControlViewWalker.GetFirstChild(currentElement); + while (child is not null) + { + queue.Enqueue((child, depth + 1)); + child = TreeWalker.ControlViewWalker.GetNextSibling(child); + } + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + } + + return false; + } + + private static void GetElementMetadata( + AutomationElement element, + out string controlTypeProgrammaticName, + out string automationId, + out string runtimeId) + { + controlTypeProgrammaticName = string.Empty; + automationId = string.Empty; + runtimeId = string.Empty; + + try + { + AutomationElement.AutomationElementInformation current = element.Current; + controlTypeProgrammaticName = current.ControlType?.ProgrammaticName ?? string.Empty; + automationId = current.AutomationId ?? string.Empty; + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + try + { + int[]? rawRuntimeId = element.GetRuntimeId(); + if (rawRuntimeId is { Length: > 0 }) + runtimeId = string.Join('-', rawRuntimeId); + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + } + + private static bool TryExtractNameText(AutomationElement element, out string text) + { + text = string.Empty; + + try + { + AutomationElement.AutomationElementInformation current = element.Current; + if (!ShouldUseNameFallback(current.ControlType)) + return false; + + if (current.ControlType != ControlType.Text && HasVisibleTextDescendant(element)) + return false; + + text = current.Name; + return !string.IsNullOrWhiteSpace(text); + } + catch (ElementNotAvailableException) + { + } + catch (InvalidOperationException) + { + } + + return false; + } + + private static bool IsTextBearingControlType(ControlType controlType) + { + return controlType == ControlType.Text + || controlType == ControlType.Edit + || controlType == ControlType.Document + || controlType == ControlType.Button + || controlType == ControlType.CheckBox + || controlType == ControlType.RadioButton + || controlType == ControlType.Hyperlink + || controlType == ControlType.ListItem + || controlType == ControlType.DataItem + || controlType == ControlType.TreeItem + || controlType == ControlType.MenuItem + || controlType == ControlType.TabItem + || controlType == ControlType.HeaderItem + || controlType == ControlType.ComboBox + || controlType == ControlType.SplitButton; + } + + private static AutomationElement? GetParentElement(AutomationElement element) + { + try + { + return TreeWalker.RawViewWalker.GetParent(element); + } + catch (ElementNotAvailableException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static AutomationElement? GetElementAtPoint(Point screenPoint) + { + try + { + return AutomationElement.FromPoint(screenPoint); + } + catch (ElementNotAvailableException) + { + return null; + } + catch (ArgumentException) + { + return null; + } + } + + private static bool IsDescendantOrSelf(AutomationElement root, AutomationElement candidate) + { + AutomationElement? current = candidate; + while (current is not null) + { + if (current.Equals(root)) + return true; + + current = GetParentElement(current); + } + + return false; + } + + private static int GetMaxDepth(UiAutomationTraversalMode traversalMode) + { + return traversalMode switch + { + UiAutomationTraversalMode.Fast => FastMaxDepth, + UiAutomationTraversalMode.Thorough => ThoroughMaxDepth, + _ => BalancedMaxDepth, + }; + } + + private static double GetIntersectionArea(Rect first, Rect second) + { + Rect intersection = Rect.Intersect(first, second); + if (intersection == Rect.Empty) + return 0; + + return intersection.Width * intersection.Height; + } +} diff --git a/Text-Grab/Utilities/WindowSelectionUtilities.cs b/Text-Grab/Utilities/WindowSelectionUtilities.cs new file mode 100644 index 00000000..45706e4e --- /dev/null +++ b/Text-Grab/Utilities/WindowSelectionUtilities.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +public static class WindowSelectionUtilities +{ + private const int DwmwaExtendedFrameBounds = 9; + private const int DwmwaCloaked = 14; + private const int GwlExStyle = -20; + private const int WsExToolWindow = 0x00000080; + private const int WsExNoActivate = 0x08000000; + + public static List GetCapturableWindows(IReadOnlyCollection? excludedHandles = null) + { + HashSet excluded = excludedHandles is null ? [] : [.. excludedHandles]; + IntPtr shellWindow = OSInterop.GetShellWindow(); + List candidates = []; + + _ = OSInterop.EnumWindows((windowHandle, _) => + { + WindowSelectionCandidate? candidate = CreateCandidate(windowHandle, shellWindow, excluded); + if (candidate is not null) + candidates.Add(candidate); + + return true; + }, IntPtr.Zero); + + return candidates; + } + + public static WindowSelectionCandidate? FindWindowAtPoint(IEnumerable candidates, Point screenPoint) + { + return candidates.FirstOrDefault(candidate => candidate.Contains(screenPoint)); + } + + internal static bool IsValidWindowBounds(Rect bounds) + { + return bounds != Rect.Empty && bounds.Width > 20 && bounds.Height > 20; + } + + private static WindowSelectionCandidate? CreateCandidate(IntPtr windowHandle, IntPtr shellWindow, ISet excludedHandles) + { + if (windowHandle == IntPtr.Zero || windowHandle == shellWindow || excludedHandles.Contains(windowHandle)) + return null; + + if (!OSInterop.IsWindowVisible(windowHandle) || OSInterop.IsIconic(windowHandle)) + return null; + + if (IsCloaked(windowHandle)) + return null; + + int extendedStyle = OSInterop.GetWindowLong(windowHandle, GwlExStyle); + if ((extendedStyle & WsExToolWindow) != 0 || (extendedStyle & WsExNoActivate) != 0) + return null; + + Rect bounds = GetWindowBounds(windowHandle); + if (!IsValidWindowBounds(bounds)) + return null; + + _ = OSInterop.GetWindowThreadProcessId(windowHandle, out uint processId); + + return new WindowSelectionCandidate( + windowHandle, + bounds, + GetWindowTitle(windowHandle), + (int)processId, + GetProcessName((int)processId)); + } + + private static Rect GetWindowBounds(IntPtr windowHandle) + { + int rectSize = Marshal.SizeOf(); + + if (OSInterop.DwmGetWindowAttribute(windowHandle, DwmwaExtendedFrameBounds, out OSInterop.RECT frameBounds, rectSize) == 0) + { + Rect extendedBounds = new(frameBounds.left, frameBounds.top, frameBounds.width, frameBounds.height); + if (IsValidWindowBounds(extendedBounds)) + return extendedBounds; + } + + if (OSInterop.GetWindowRect(windowHandle, out OSInterop.RECT windowRect)) + return new Rect(windowRect.left, windowRect.top, windowRect.width, windowRect.height); + + return Rect.Empty; + } + + private static string GetWindowTitle(IntPtr windowHandle) + { + int titleLength = OSInterop.GetWindowTextLength(windowHandle); + if (titleLength <= 0) + return string.Empty; + + StringBuilder titleBuilder = new(titleLength + 1); + _ = OSInterop.GetWindowText(windowHandle, titleBuilder, titleBuilder.Capacity); + return titleBuilder.ToString(); + } + + private static bool IsCloaked(IntPtr windowHandle) + { + return OSInterop.DwmGetWindowAttribute(windowHandle, DwmwaCloaked, out int cloakedState, sizeof(int)) == 0 + && cloakedState != 0; + } + + private static string GetProcessName(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + return process.ProcessName; + } + catch (ArgumentException) + { + return string.Empty; + } + catch (InvalidOperationException) + { + return string.Empty; + } + catch (Win32Exception) + { + return string.Empty; + } + } +} diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index 8f7e9a3a..bcf95aed 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -16,6 +16,8 @@ namespace Text_Grab.Utilities; public static partial class WindowUtilities { + private static Dictionary? fullscreenPostGrabActionStates; + public static void AddTextToOpenWindow(string textToAdd) { WindowCollection allWindows = Application.Current.Windows; @@ -77,6 +79,11 @@ public static void SetWindowPosition(Window passedWindow) } public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) + { + LaunchFullScreenGrab(destinationTextBox, null); + } + + public static void LaunchFullScreenGrab(TextBox? destinationTextBox, string? preselectedTemplateId) { DisplayInfo[] allScreens = DisplayInfo.AllDisplayInfos; WindowCollection allWindows = Application.Current.Windows; @@ -89,6 +96,9 @@ public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) if (window is FullscreenGrab grab) allFullscreenGrab.Add(grab); + if (allFullscreenGrab.Count == 0) + ClearFullscreenPostGrabActionStates(); + int numberOfFullscreenGrabWindowsToCreate = numberOfScreens - allFullscreenGrab.Count; for (int i = 0; i < numberOfFullscreenGrabWindowsToCreate; i++) @@ -107,6 +117,7 @@ public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) fullScreenGrab.Width = sideLength; fullScreenGrab.Height = sideLength; fullScreenGrab.DestinationTextBox = destinationTextBox; + fullScreenGrab.PreselectedTemplateId = preselectedTemplateId; fullScreenGrab.WindowState = WindowState.Normal; Point screenCenterPoint = screen.ScaledCenterPoint(); @@ -151,6 +162,7 @@ public static void CenterOverThisWindow(this Window newWindow, Window bottomWind internal static async void CloseAllFullscreenGrabs() { WindowCollection allWindows = Application.Current.Windows; + ClearFullscreenPostGrabActionStates(); bool isFromEditWindow = false; string stringFromOCR = ""; @@ -197,6 +209,31 @@ internal static void FullscreenKeyDown(Key key, bool? isActive = null) fsg.KeyPressed(key, isActive); } + internal static void SyncFullscreenPostGrabActionStates(IReadOnlyDictionary actionStates, FullscreenGrab? sourceWindow = null) + { + fullscreenPostGrabActionStates = new Dictionary(actionStates); + + WindowCollection allWindows = Application.Current.Windows; + foreach (Window window in allWindows) + { + if (window is FullscreenGrab fsg && fsg != sourceWindow) + fsg.ApplyPostGrabActionSnapshot(fullscreenPostGrabActionStates); + } + } + + internal static IReadOnlyDictionary? GetFullscreenPostGrabActionStates() + { + if (fullscreenPostGrabActionStates is null || fullscreenPostGrabActionStates.Count == 0) + return null; + + return new Dictionary(fullscreenPostGrabActionStates); + } + + internal static void ClearFullscreenPostGrabActionStates() + { + fullscreenPostGrabActionStates = null; + } + internal static async Task TryInsertString(string stringToInsert) { await Task.Delay(TimeSpan.FromSeconds(AppUtilities.TextGrabSettings.InsertDelay)); @@ -280,7 +317,12 @@ private static void TryInjectModifierKeyUp(ref List inputs, VirtualKeySho } catch (Exception ex) { - MessageBox.Show("An error occurred while trying to open a new window. Please try again.", ex.Message); + _ = new Wpf.Ui.Controls.MessageBox + { + Title = ex.Message, + Content = "An error occurred while trying to open a new window. Please try again.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } return newWindow; } diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 1d7426b4..335f9357 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -457,6 +457,14 @@ Click="GrabFrameMenuItem_Click" Header="New _Grab Frame" InputGestureText="Ctrl + G" /> + + + + + + + + + + Text="Examples:" /> + + + + + () + .FirstOrDefault(m => m.IsChecked && m.Tag is GrabTemplate) + ?.Tag as GrabTemplate; + + grabTemplateMenuItem.Items.Clear(); + + MenuItem noneItem = new() + { + Header = "(None)", + IsCheckable = true, + IsChecked = previouslySelected is null, + }; + noneItem.Click += GrabTemplateMenuItem_Click; + grabTemplateMenuItem.Items.Add(noneItem); + + foreach (GrabTemplate template in GrabTemplateManager.GetAllTemplates()) + { + MenuItem templateMenuItem = new() + { + Header = template.Name, + IsCheckable = true, + IsChecked = previouslySelected?.Id == template.Id, + Tag = template, + }; + templateMenuItem.Click += GrabTemplateMenuItem_Click; + grabTemplateMenuItem.Items.Add(templateMenuItem); + } + } + + private void GrabTemplateMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem clickedItem) + return; + + foreach (MenuItem item in GrabTemplateMenuItem.Items) + item.IsChecked = false; + + clickedItem.IsChecked = true; + } + private void LaunchFindAndReplace() { FindAndReplaceWindow findAndReplaceWindow = WindowUtilities.OpenOrActivateWindow(); @@ -1243,81 +1311,26 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) if (captureMenuItem.Items.Count > 0) return; - bool haveSetLastLang = false; - string lastTextLang = DefaultSettings.LastUsedLang; bool usingTesseract = DefaultSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe(); + List availableLanguages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(usingTesseract); + availableLanguages = availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible).ToList(); + int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( + availableLanguages, + DefaultSettings.LastUsedLang, + LanguageUtilities.GetOCRLanguage()); - if (WindowsAiUtilities.CanDeviceUseWinAI()) - { - WindowsAiLang windowsAiLang = new(); - - MenuItem languageMenuItem = new() - { - Header = windowsAiLang.DisplayName, - Tag = windowsAiLang, - IsCheckable = true, - }; - - languageMenuItem.Click += LanguageMenuItem_Click; - captureMenuItem.Items.Add(languageMenuItem); - if (!haveSetLastLang && windowsAiLang.CultureDisplayName == lastTextLang) - { - languageMenuItem.IsChecked = true; - haveSetLastLang = true; - } - } - - if (usingTesseract) - { - List tesseractLanguages = await TesseractHelper.TesseractLanguages(); - - foreach (TessLang language in tesseractLanguages.Cast()) - { - MenuItem languageMenuItem = new() - { - Header = language.DisplayName, - Tag = language, - IsCheckable = true, - }; - languageMenuItem.Click += LanguageMenuItem_Click; - - captureMenuItem.Items.Add(languageMenuItem); - - if (!haveSetLastLang && language.CultureDisplayName == lastTextLang) - { - languageMenuItem.IsChecked = true; - haveSetLastLang = true; - } - } - } - - IReadOnlyList possibleOCRLanguages = OcrEngine.AvailableRecognizerLanguages; - - ILanguage firstLang = LanguageUtilities.GetOCRLanguage(); - - foreach (Language language in possibleOCRLanguages) + for (int i = 0; i < availableLanguages.Count; i++) { + ILanguage language = availableLanguages[i]; MenuItem languageMenuItem = new() { Header = language.DisplayName, - Tag = new GlobalLang(language), + Tag = language, IsCheckable = true, + IsChecked = i == selectedIndex, }; languageMenuItem.Click += LanguageMenuItem_Click; - captureMenuItem.Items.Add(languageMenuItem); - - if (!haveSetLastLang && - (language.AbbreviatedName.Equals(firstLang?.AbbreviatedName.ToLower(), StringComparison.CurrentCultureIgnoreCase) - || language.LanguageTag.Equals(firstLang?.LanguageTag.ToLower(), StringComparison.CurrentCultureIgnoreCase))) - { - languageMenuItem.IsChecked = true; - haveSetLastLang = true; - } - } - if (!haveSetLastLang && captureMenuItem.Items[0] is MenuItem firstMenuItem) - { - firstMenuItem.IsChecked = true; } } @@ -1337,15 +1350,24 @@ private void LoadRecentTextHistory() foreach (HistoryInfo history in grabsHistories) { MenuItem menuItem = new(); + string historyId = history.ID; menuItem.Click += (sender, args) => { + HistoryInfo? selectedHistory = Singleton.Instance.GetTextHistoryById(historyId); + + if (selectedHistory is null) + { + menuItem.IsEnabled = false; + return; + } + if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) { - PassedTextControl.Text = history.TextContent; + PassedTextControl.Text = selectedHistory.TextContent; return; } - EditTextWindow etw = new(history); + EditTextWindow etw = new(selectedHistory); etw.Show(); }; @@ -1814,6 +1836,16 @@ private async void ReadFolderOfImages_Click(object sender, RoutedEventArgs e) string chosenFolderPath = folderBrowserDialog.SelectedPath; + GrabTemplate? selectedTemplate = null; + foreach (MenuItem item in GrabTemplateMenuItem.Items) + { + if (item.IsChecked && item.Tag is GrabTemplate grabTemplate) + { + selectedTemplate = grabTemplate; + break; + } + } + OcrDirectoryOptions ocrDirectoryOptions = new() { Path = chosenFolderPath, @@ -1821,7 +1853,8 @@ private async void ReadFolderOfImages_Click(object sender, RoutedEventArgs e) WriteTxtFiles = ReadFolderOfImagesWriteTxtFiles.IsChecked is true, OutputFileNames = OutputFilenamesCheck.IsChecked is true, OutputFooter = OutputFooterCheck.IsChecked is true, - OutputHeader = OutputHeaderCheck.IsChecked is true + OutputHeader = OutputHeaderCheck.IsChecked is true, + GrabTemplate = selectedTemplate, }; if (Directory.Exists(chosenFolderPath)) @@ -2195,13 +2228,18 @@ private void SplitOnSelectionCmdCanExecute(object sender, CanExecuteRoutedEventA e.CanExecute = true; } - private void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) + private async void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) { string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) { - System.Windows.MessageBox.Show("No text selected", "Did not split lines"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Did not split lines", + Content = "No text selected", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -2212,13 +2250,18 @@ private void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs PassedTextControl.Text = textToManipulate.ToString(); } - private void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) + private async void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) { string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) { - System.Windows.MessageBox.Show("No text selected", "Did not split lines"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Did not split lines", + Content = "No text selected", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3379,8 +3422,12 @@ private async Task PerformTranslationAsync(string targetLanguage) } catch (Exception ex) { - System.Windows.MessageBox.Show($"Translation failed: {ex.Message}", - "Translation Error", MessageBoxButton.OK, MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Error", + Content = $"Translation failed: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } finally { @@ -3394,8 +3441,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(textDescription)) { - System.Windows.MessageBox.Show("Please enter or select text to extract a regex pattern from.", - "No Text", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "No Text", + Content = "Please enter or select text to extract a regex pattern from.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3409,8 +3460,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Regex extraction exception: {ex.Message}"); - System.Windows.MessageBox.Show($"An error occurred while extracting regex: {ex.Message}", - "Error", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = $"An error occurred while extracting regex: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); SetToLoaded(); return; } @@ -3419,8 +3474,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(regexPattern)) { - System.Windows.MessageBox.Show("Failed to extract a regex pattern. The AI service may not be available or could not generate a pattern.", - "Extraction Failed", MessageBoxButton.OK, MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Extraction Failed", + Content = "Failed to extract a regex pattern. The AI service may not be available or could not generate a pattern.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3461,8 +3520,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Failed to copy regex to clipboard: {ex.Message}"); - System.Windows.MessageBox.Show("Failed to copy regex pattern to clipboard.", - "Copy Failed", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Copy Failed", + Content = "Failed to copy regex pattern to clipboard.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } } @@ -3782,26 +3845,7 @@ private void PatternContextOpening(object sender, ContextMenuEventArgs e) private List LoadRegexPatterns() { List returnRegexes = []; - - // Load from settings - string regexListJson = DefaultSettings.RegexList; - - if (!string.IsNullOrWhiteSpace(regexListJson)) - { - try - { - StoredRegex[]? loadedPatterns = JsonSerializer.Deserialize(regexListJson); - if (loadedPatterns is not null) - { - foreach (StoredRegex pattern in loadedPatterns) - returnRegexes.Add(pattern); - } - } - catch (JsonException) - { - // If deserialization fails, start fresh - } - } + returnRegexes.AddRange(AppUtilities.TextGrabSettingsService.LoadStoredRegexes()); // Add default patterns if list is empty if (returnRegexes.Count == 0) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs new file mode 100644 index 00000000..38c3f0c6 --- /dev/null +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -0,0 +1,1312 @@ +using Dapplo.Windows.User32; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Shapes; +using System.Windows.Threading; +using Text_Grab.Extensions; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Services; +using Text_Grab.Utilities; +using Bitmap = System.Drawing.Bitmap; +using Point = System.Windows.Point; + +namespace Text_Grab.Views; + +public partial class FullscreenGrab +{ + private enum SelectionInteractionMode + { + None = 0, + CreatingRectangle = 1, + CreatingFreeform = 2, + MovingSelection = 3, + ResizeLeft = 4, + ResizeTop = 5, + ResizeRight = 6, + ResizeBottom = 7, + ResizeTopLeft = 8, + ResizeTopRight = 9, + ResizeBottomLeft = 10, + ResizeBottomRight = 11, + } + + private const double MinimumSelectionSize = 6.0; + private const double AdjustHandleSize = 12.0; + private static readonly SolidColorBrush SelectionBorderBrush = new(System.Windows.Media.Color.FromArgb(255, 40, 118, 126)); + private static readonly SolidColorBrush WindowSelectionFillBrush = new(System.Windows.Media.Color.FromArgb(52, 255, 255, 255)); + private static readonly SolidColorBrush WindowSelectionLabelBackgroundBrush = new(System.Windows.Media.Color.FromArgb(224, 20, 27, 46)); + private static readonly SolidColorBrush FreeformFillBrush = new(System.Windows.Media.Color.FromArgb(36, 40, 118, 126)); + private readonly DispatcherTimer windowSelectionTimer = new() { Interval = TimeSpan.FromMilliseconds(100) }; + private readonly Path freeformSelectionPath = new() + { + Stroke = SelectionBorderBrush, + Fill = FreeformFillBrush, + StrokeThickness = 2, + Visibility = Visibility.Collapsed, + IsHitTestVisible = false + }; + + private readonly List freeformSelectionPoints = []; + private readonly List selectionHandleBorders = []; + private readonly Border selectionOutlineBorder = new(); + private readonly Grid windowSelectionHighlightContent = new() { ClipToBounds = false, IsHitTestVisible = false }; + private readonly Border windowSelectionInfoBadge = new(); + private readonly TextBlock windowSelectionAppNameText = new(); + private readonly TextBlock windowSelectionTitleText = new(); + private Point adjustmentStartPoint = new(); + private Rect selectionRectBeforeDrag = Rect.Empty; + private WindowSelectionCandidate? clickedWindowCandidate; + private WindowSelectionCandidate? hoveredWindowCandidate; + private SelectionInteractionMode selectionInteractionMode = SelectionInteractionMode.None; + private FsgSelectionStyle currentSelectionStyle = FsgSelectionStyle.Region; + private bool isAwaitingAdjustAfterCommit = false; + private bool suppressSelectionStyleComboBoxSelectionChanged = false; + + private FsgSelectionStyle CurrentSelectionStyle => currentSelectionStyle; + + private void InitializeSelectionStyles() + { + selectBorder.BorderThickness = new Thickness(2); + selectBorder.BorderBrush = SelectionBorderBrush; + selectBorder.Background = Brushes.Transparent; + selectBorder.CornerRadius = new CornerRadius(6); + selectBorder.IsHitTestVisible = false; + selectBorder.SnapsToDevicePixels = true; + + selectionOutlineBorder.BorderThickness = new Thickness(2); + selectionOutlineBorder.BorderBrush = SelectionBorderBrush; + selectionOutlineBorder.Background = Brushes.Transparent; + selectionOutlineBorder.CornerRadius = new CornerRadius(0); + selectionOutlineBorder.IsHitTestVisible = false; + selectionOutlineBorder.SnapsToDevicePixels = true; + + windowSelectionAppNameText.FontWeight = FontWeights.SemiBold; + windowSelectionAppNameText.Foreground = Brushes.White; + windowSelectionAppNameText.TextTrimming = TextTrimming.CharacterEllipsis; + + windowSelectionTitleText.Margin = new Thickness(0, 2, 0, 0); + windowSelectionTitleText.Foreground = Brushes.White; + windowSelectionTitleText.TextTrimming = TextTrimming.CharacterEllipsis; + windowSelectionTitleText.TextWrapping = TextWrapping.NoWrap; + + StackPanel windowSelectionTextStack = new() + { + MaxWidth = 360, + Orientation = Orientation.Vertical + }; + windowSelectionTextStack.Children.Add(windowSelectionAppNameText); + windowSelectionTextStack.Children.Add(windowSelectionTitleText); + + windowSelectionInfoBadge.Background = WindowSelectionLabelBackgroundBrush; + windowSelectionInfoBadge.CornerRadius = new CornerRadius(4); + windowSelectionInfoBadge.HorizontalAlignment = HorizontalAlignment.Left; + windowSelectionInfoBadge.Margin = new Thickness(8); + windowSelectionInfoBadge.Padding = new Thickness(8, 5, 8, 6); + windowSelectionInfoBadge.VerticalAlignment = VerticalAlignment.Top; + windowSelectionInfoBadge.Child = windowSelectionTextStack; + + windowSelectionHighlightContent.Children.Add(windowSelectionInfoBadge); + windowSelectionTimer.Tick += WindowSelectionTimer_Tick; + } + + private void ApplySelectionStyle(FsgSelectionStyle selectionStyle, bool persistToSettings = true) + { + currentSelectionStyle = selectionStyle; + SyncSelectionStyleComboBox(selectionStyle); + + RegionSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Region; + WindowSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Window; + FreeformSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Freeform; + AdjustAfterSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.AdjustAfter; + + if (persistToSettings) + { + DefaultSettings.FsgSelectionStyle = selectionStyle.ToString(); + DefaultSettings.Save(); + } + + ResetSelectionVisualState(); + RegionClickCanvas.Cursor = selectionStyle == FsgSelectionStyle.Window ? Cursors.Hand : Cursors.Cross; + UpdateTopToolbarVisibility(RegionClickCanvas.IsMouseOver || TopButtonsStackPanel.IsMouseOver); + + if (selectionStyle == FsgSelectionStyle.Window) + UpdateWindowSelectionHighlight(); + } + + internal static bool ShouldKeepTopToolbarVisible(FsgSelectionStyle selectionStyle, bool isAwaitingAdjustAfterCommit) + { + return selectionStyle == FsgSelectionStyle.Window || isAwaitingAdjustAfterCommit; + } + + internal static bool ShouldCommitWindowSelection(WindowSelectionCandidate? pressedWindowCandidate, WindowSelectionCandidate? releasedWindowCandidate) + { + return pressedWindowCandidate is not null + && releasedWindowCandidate is not null + && pressedWindowCandidate.Handle == releasedWindowCandidate.Handle; + } + + internal static bool ShouldUseOverlayCutout(FsgSelectionStyle selectionStyle) + { + return selectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter; + } + + internal static bool ShouldDrawSelectionOutline(FsgSelectionStyle selectionStyle) + { + return ShouldUseOverlayCutout(selectionStyle); + } + + private static Key GetSelectionStyleKey(FsgSelectionStyle selectionStyle) + { + return selectionStyle switch + { + FsgSelectionStyle.Region => Key.R, + FsgSelectionStyle.Window => Key.W, + FsgSelectionStyle.Freeform => Key.D, + FsgSelectionStyle.AdjustAfter => Key.A, + _ => Key.R, + }; + } + + private bool TryGetSelectionStyle(object? sender, out FsgSelectionStyle selectionStyle) + { + selectionStyle = FsgSelectionStyle.Region; + if (sender is not FrameworkElement element || element.Tag is not string tag) + return false; + + return Enum.TryParse(tag, true, out selectionStyle); + } + + private void SelectionStyleMenuItem_Click(object sender, RoutedEventArgs e) + { + if (TryGetSelectionStyle(sender, out FsgSelectionStyle selectionStyle)) + WindowUtilities.FullscreenKeyDown(GetSelectionStyleKey(selectionStyle)); + } + + private void SelectionStyleComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (suppressSelectionStyleComboBoxSelectionChanged + || SelectionStyleComboBox.SelectedItem is not ComboBoxItem selectedItem) + return; + + if (TryGetSelectionStyle(selectedItem, out FsgSelectionStyle selectionStyle)) + WindowUtilities.FullscreenKeyDown(GetSelectionStyleKey(selectionStyle)); + } + + private void SyncSelectionStyleComboBox(FsgSelectionStyle selectionStyle) + { + suppressSelectionStyleComboBoxSelectionChanged = true; + + try + { + foreach (ComboBoxItem comboBoxItem in SelectionStyleComboBox.Items.OfType()) + { + if (!TryGetSelectionStyle(comboBoxItem, out FsgSelectionStyle comboBoxItemStyle)) + continue; + + if (comboBoxItemStyle == selectionStyle) + { + SelectionStyleComboBox.SelectedItem = comboBoxItem; + return; + } + } + + SelectionStyleComboBox.SelectedIndex = -1; + } + finally + { + suppressSelectionStyleComboBoxSelectionChanged = false; + } + } + + private void WindowSelectionTimer_Tick(object? sender, EventArgs e) + { + if (CurrentSelectionStyle != FsgSelectionStyle.Window || selectionInteractionMode != SelectionInteractionMode.None) + { + if (hoveredWindowCandidate is not null) + { + hoveredWindowCandidate = null; + clickedWindowCandidate = null; + ClearSelectionBorderVisual(); + } + + return; + } + + UpdateWindowSelectionHighlight(); + } + + private void UpdateWindowSelectionHighlight() + { + ApplyWindowSelectionHighlight(GetWindowSelectionCandidateAtCurrentMousePosition()); + } + + private void UpdateTopToolbarVisibility(bool isPointerOverSelectionSurface) + { + if (ShouldKeepTopToolbarVisible(CurrentSelectionStyle, isAwaitingAdjustAfterCommit)) + { + TopButtonsStackPanel.Visibility = Visibility.Visible; + return; + } + + if (isSelecting) + { + TopButtonsStackPanel.Visibility = Visibility.Collapsed; + return; + } + + TopButtonsStackPanel.Visibility = isPointerOverSelectionSurface + ? Visibility.Visible + : Visibility.Collapsed; + } + + private WindowSelectionCandidate? GetWindowSelectionCandidateAtCurrentMousePosition() + { + if (!WindowUtilities.GetMousePosition(out Point mousePosition)) + return null; + + return WindowSelectionUtilities.FindWindowAtPoint( + WindowSelectionUtilities.GetCapturableWindows(GetExcludedWindowHandles()), + mousePosition); + } + + private void ApplyWindowSelectionHighlight(WindowSelectionCandidate? candidate) + { + hoveredWindowCandidate = candidate; + + if (candidate is null) + { + ClearSelectionBorderVisual(); + return; + } + + Rect windowBounds = GetWindowDeviceBounds(); + Rect intersection = Rect.Intersect(candidate.Bounds, windowBounds); + if (intersection == Rect.Empty) + { + ClearSelectionBorderVisual(); + return; + } + + Rect localRect = ConvertAbsoluteDeviceRectToLocal(intersection); + ApplySelectionRect(localRect, WindowSelectionFillBrush, updateTemplateOverlays: false); + UpdateWindowSelectionInfo(candidate, localRect); + } + + private IReadOnlyCollection GetExcludedWindowHandles() + { + List handles = []; + foreach (Window window in Application.Current.Windows) + { + IntPtr handle = new WindowInteropHelper(window).Handle; + if (handle != IntPtr.Zero) + handles.Add(handle); + } + + return handles; + } + + private double GetCurrentDeviceScale() + { + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + return presentationSource?.CompositionTarget is null + ? 1.0 + : presentationSource.CompositionTarget.TransformToDevice.M11; + } + + private Rect GetWindowDeviceBounds() + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + Point absolutePosition = this.GetAbsolutePosition(); + return new Rect(absolutePosition.X, absolutePosition.Y, ActualWidth * dpi.DpiScaleX, ActualHeight * dpi.DpiScaleY); + } + + private Rect ConvertAbsoluteDeviceRectToLocal(Rect absoluteRect) + { + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + if (presentationSource?.CompositionTarget is null) + return Rect.Empty; + + Point absoluteWindowPosition = this.GetAbsolutePosition(); + Matrix fromDevice = presentationSource.CompositionTarget.TransformFromDevice; + + Point topLeft = fromDevice.Transform(new Point( + absoluteRect.Left - absoluteWindowPosition.X, + absoluteRect.Top - absoluteWindowPosition.Y)); + + Point bottomRight = fromDevice.Transform(new Point( + absoluteRect.Right - absoluteWindowPosition.X, + absoluteRect.Bottom - absoluteWindowPosition.Y)); + + return new Rect(topLeft, bottomRight); + } + + private Rect GetCurrentSelectionRect() + { + double left = Canvas.GetLeft(selectBorder); + double top = Canvas.GetTop(selectBorder); + + if (double.IsNaN(left) || double.IsNaN(top)) + return Rect.Empty; + + return new Rect(left, top, selectBorder.Width, selectBorder.Height); + } + + private void ApplySelectionRect( + Rect rect, + Brush? selectionFillBrush = null, + bool updateTemplateOverlays = true, + bool? useOverlayCutout = null) + { + EnsureSelectionBorderVisible(); + bool shouldUseCutout = useOverlayCutout ?? ShouldUseOverlayCutout(CurrentSelectionStyle); + + selectBorder.Width = Math.Max(0, rect.Width); + selectBorder.Height = Math.Max(0, rect.Height); + selectBorder.Background = selectionFillBrush ?? Brushes.Transparent; + Canvas.SetLeft(selectBorder, rect.Left); + Canvas.SetTop(selectBorder, rect.Top); + clippingGeometry.Rect = shouldUseCutout + ? rect + : Rect.Empty; + UpdateSelectionOutline(rect, shouldUseCutout && ShouldDrawSelectionOutline(CurrentSelectionStyle)); + + if (updateTemplateOverlays) + UpdateTemplateRegionOverlays(rect.Left, rect.Top, rect.Width, rect.Height); + } + + private void UpdateWindowSelectionInfo(WindowSelectionCandidate candidate, Rect localRect) + { + windowSelectionAppNameText.Text = candidate.DisplayAppName; + windowSelectionTitleText.Text = candidate.DisplayTitle; + windowSelectionInfoBadge.MaxWidth = Math.Max(72, localRect.Width - 16); + selectBorder.Child = windowSelectionHighlightContent; + } + + private void EnsureSelectionBorderVisible() + { + if (!RegionClickCanvas.Children.Contains(selectBorder)) + _ = RegionClickCanvas.Children.Add(selectBorder); + } + + private void EnsureSelectionOutlineVisible() + { + if (!SelectionOutlineHost.Children.Contains(selectionOutlineBorder)) + _ = SelectionOutlineHost.Children.Add(selectionOutlineBorder); + } + + private void ClearSelectionBorderVisual() + { + if (RegionClickCanvas.Children.Contains(selectBorder)) + RegionClickCanvas.Children.Remove(selectBorder); + + ClearSelectionOutline(); + selectBorder.Background = Brushes.Transparent; + selectBorder.Child = null; + clippingGeometry.Rect = new Rect(new Point(0, 0), new Size(0, 0)); + TemplateOverlayHost.Children.Clear(); + templateOverlayCanvas.Children.Clear(); + } + + private void UpdateSelectionOutline(Rect rect, bool shouldShowOutline) + { + if (!shouldShowOutline || rect.Width <= 0 || rect.Height <= 0) + { + ClearSelectionOutline(); + return; + } + + EnsureSelectionOutlineVisible(); + double t = selectionOutlineBorder.BorderThickness.Left; + selectionOutlineBorder.Width = Math.Max(0, rect.Width + 2 * t); + selectionOutlineBorder.Height = Math.Max(0, rect.Height + 2 * t); + Canvas.SetLeft(selectionOutlineBorder, rect.Left - t); + Canvas.SetTop(selectionOutlineBorder, rect.Top - t); + } + + private void ClearSelectionOutline() + { + if (SelectionOutlineHost.Children.Contains(selectionOutlineBorder)) + SelectionOutlineHost.Children.Remove(selectionOutlineBorder); + } + + private void ResetSelectionVisualState() + { + isSelecting = false; + isShiftDown = false; + isAwaitingAdjustAfterCommit = false; + selectionInteractionMode = SelectionInteractionMode.None; + clickedWindowCandidate = null; + hoveredWindowCandidate = null; + CurrentScreen = null; + + CursorClipper.UnClipCursor(); + RegionClickCanvas.ReleaseMouseCapture(); + + ClearSelectionBorderVisual(); + ClearFreeformSelection(); + ClearSelectionHandles(); + + AcceptSelectionButton.Visibility = Visibility.Collapsed; + } + + private void ClearFreeformSelection() + { + freeformSelectionPoints.Clear(); + freeformSelectionPath.Visibility = Visibility.Collapsed; + + if (RegionClickCanvas.Children.Contains(freeformSelectionPath)) + RegionClickCanvas.Children.Remove(freeformSelectionPath); + } + + private void EnsureFreeformSelectionPath() + { + if (!RegionClickCanvas.Children.Contains(freeformSelectionPath)) + _ = RegionClickCanvas.Children.Add(freeformSelectionPath); + + freeformSelectionPath.Visibility = Visibility.Visible; + } + + private void ClearSelectionHandles() + { + foreach (Border handleBorder in selectionHandleBorders) + RegionClickCanvas.Children.Remove(handleBorder); + + selectionHandleBorders.Clear(); + } + + private void UpdateSelectionHandles() + { + ClearSelectionHandles(); + + if (!isAwaitingAdjustAfterCommit) + return; + + Rect selectionRect = GetCurrentSelectionRect(); + if (selectionRect == Rect.Empty) + return; + + foreach (SelectionInteractionMode handle in new[] + { + SelectionInteractionMode.ResizeTopLeft, + SelectionInteractionMode.ResizeTop, + SelectionInteractionMode.ResizeTopRight, + SelectionInteractionMode.ResizeRight, + SelectionInteractionMode.ResizeBottomRight, + SelectionInteractionMode.ResizeBottom, + SelectionInteractionMode.ResizeBottomLeft, + SelectionInteractionMode.ResizeLeft, + }) + { + Rect handleRect = GetHandleRect(selectionRect, handle); + Border handleBorder = new() + { + Width = handleRect.Width, + Height = handleRect.Height, + Background = SelectionBorderBrush, + BorderBrush = Brushes.White, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(2), + IsHitTestVisible = false + }; + + selectionHandleBorders.Add(handleBorder); + _ = RegionClickCanvas.Children.Add(handleBorder); + Canvas.SetLeft(handleBorder, handleRect.Left); + Canvas.SetTop(handleBorder, handleRect.Top); + } + } + + private Rect GetHandleRect(Rect selectionRect, SelectionInteractionMode handle) + { + double halfHandle = AdjustHandleSize / 2.0; + return handle switch + { + SelectionInteractionMode.ResizeTopLeft => new Rect(selectionRect.Left - halfHandle, selectionRect.Top - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeTop => new Rect(selectionRect.Left + (selectionRect.Width / 2.0) - halfHandle, selectionRect.Top - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeTopRight => new Rect(selectionRect.Right - halfHandle, selectionRect.Top - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeRight => new Rect(selectionRect.Right - halfHandle, selectionRect.Top + (selectionRect.Height / 2.0) - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeBottomRight => new Rect(selectionRect.Right - halfHandle, selectionRect.Bottom - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeBottom => new Rect(selectionRect.Left + (selectionRect.Width / 2.0) - halfHandle, selectionRect.Bottom - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeBottomLeft => new Rect(selectionRect.Left - halfHandle, selectionRect.Bottom - halfHandle, AdjustHandleSize, AdjustHandleSize), + SelectionInteractionMode.ResizeLeft => new Rect(selectionRect.Left - halfHandle, selectionRect.Top + (selectionRect.Height / 2.0) - halfHandle, AdjustHandleSize, AdjustHandleSize), + _ => Rect.Empty, + }; + } + + private SelectionInteractionMode GetSelectionInteractionModeForPoint(Point point) + { + Rect selectionRect = GetCurrentSelectionRect(); + if (selectionRect == Rect.Empty) + return SelectionInteractionMode.None; + + foreach (SelectionInteractionMode handle in new[] + { + SelectionInteractionMode.ResizeTopLeft, + SelectionInteractionMode.ResizeTopRight, + SelectionInteractionMode.ResizeBottomRight, + SelectionInteractionMode.ResizeBottomLeft, + SelectionInteractionMode.ResizeTop, + SelectionInteractionMode.ResizeRight, + SelectionInteractionMode.ResizeBottom, + SelectionInteractionMode.ResizeLeft, + }) + { + if (GetHandleRect(selectionRect, handle).Contains(point)) + return handle; + } + + return selectionRect.Contains(point) + ? SelectionInteractionMode.MovingSelection + : SelectionInteractionMode.None; + } + + private static Cursor GetCursorForInteractionMode(SelectionInteractionMode mode) + { + return mode switch + { + SelectionInteractionMode.MovingSelection => Cursors.SizeAll, + SelectionInteractionMode.ResizeLeft => Cursors.SizeWE, + SelectionInteractionMode.ResizeRight => Cursors.SizeWE, + SelectionInteractionMode.ResizeTop => Cursors.SizeNS, + SelectionInteractionMode.ResizeBottom => Cursors.SizeNS, + SelectionInteractionMode.ResizeTopLeft => Cursors.SizeNWSE, + SelectionInteractionMode.ResizeBottomRight => Cursors.SizeNWSE, + SelectionInteractionMode.ResizeTopRight => Cursors.SizeNESW, + SelectionInteractionMode.ResizeBottomLeft => Cursors.SizeNESW, + _ => Cursors.Cross, + }; + } + + private void UpdateAdjustAfterCursor(Point point) + { + if (!isAwaitingAdjustAfterCommit) + return; + + SelectionInteractionMode interactionMode = GetSelectionInteractionModeForPoint(point); + RegionClickCanvas.Cursor = interactionMode == SelectionInteractionMode.None + ? Cursors.Cross + : GetCursorForInteractionMode(interactionMode); + } + + private void BeginRectangleSelection(MouseEventArgs e) + { + ResetSelectionVisualState(); + clickedPoint = e.GetPosition(this); + dpiScale = VisualTreeHelper.GetDpi(this); + selectionInteractionMode = SelectionInteractionMode.CreatingRectangle; + isSelecting = true; + TopButtonsStackPanel.Visibility = Visibility.Collapsed; + RegionClickCanvas.CaptureMouse(); + CursorClipper.ClipCursor(this); + ApplySelectionRect(new Rect(clickedPoint, clickedPoint)); + SetCurrentScreenFromMouse(); + } + + private void BeginFreeformSelection(MouseEventArgs e) + { + ResetSelectionVisualState(); + selectionInteractionMode = SelectionInteractionMode.CreatingFreeform; + isSelecting = true; + TopButtonsStackPanel.Visibility = Visibility.Collapsed; + RegionClickCanvas.CaptureMouse(); + CursorClipper.ClipCursor(this); + + freeformSelectionPoints.Add(e.GetPosition(this)); + EnsureFreeformSelectionPath(); + freeformSelectionPath.Data = FreeformCaptureUtilities.BuildGeometry(freeformSelectionPoints); + } + + private bool TryBeginAdjustAfterInteraction(MouseButtonEventArgs e) + { + if (!isAwaitingAdjustAfterCommit || !RegionClickCanvas.Children.Contains(selectBorder)) + return false; + + SelectionInteractionMode interactionMode = GetSelectionInteractionModeForPoint(e.GetPosition(this)); + if (interactionMode == SelectionInteractionMode.None) + return false; + + adjustmentStartPoint = e.GetPosition(this); + selectionRectBeforeDrag = GetCurrentSelectionRect(); + selectionInteractionMode = interactionMode; + isSelecting = true; + RegionClickCanvas.CaptureMouse(); + CursorClipper.ClipCursor(this); + return true; + } + + private void SetCurrentScreenFromMouse() + { + WindowUtilities.GetMousePosition(out Point mousePoint); + foreach (DisplayInfo? screen in DisplayInfo.AllDisplayInfos) + { + Rect bound = screen.ScaledBounds(); + if (bound.Contains(mousePoint)) + { + CurrentScreen = screen; + break; + } + } + } + + private void UpdateRectangleSelection(Point movingPoint) + { + if (Keyboard.Modifiers == ModifierKeys.Shift) + { + PanSelection(movingPoint); + return; + } + + isShiftDown = false; + + double left = Math.Min(clickedPoint.X, movingPoint.X); + double top = Math.Min(clickedPoint.Y, movingPoint.Y); + double width = Math.Abs(clickedPoint.X - movingPoint.X); + double height = Math.Abs(clickedPoint.Y - movingPoint.Y); + + ApplySelectionRect(new Rect(left, top, width, height)); + } + + private void UpdateFreeformSelection(Point movingPoint) + { + if (freeformSelectionPoints.Count > 0 && (movingPoint - freeformSelectionPoints[^1]).Length < 2) + return; + + freeformSelectionPoints.Add(movingPoint); + EnsureFreeformSelectionPath(); + freeformSelectionPath.Data = FreeformCaptureUtilities.BuildGeometry(freeformSelectionPoints); + } + + private void UpdateAdjustedSelection(Point movingPoint) + { + Rect surfaceRect = new(0, 0, RegionClickCanvas.ActualWidth, RegionClickCanvas.ActualHeight); + if (surfaceRect.Width <= 0 || surfaceRect.Height <= 0) + surfaceRect = new Rect(0, 0, ActualWidth, ActualHeight); + + Rect updatedRect = selectionRectBeforeDrag; + if (selectionInteractionMode == SelectionInteractionMode.MovingSelection) + { + double newLeft = Math.Clamp(selectionRectBeforeDrag.Left + (movingPoint.X - adjustmentStartPoint.X), 0, Math.Max(0, surfaceRect.Width - selectionRectBeforeDrag.Width)); + double newTop = Math.Clamp(selectionRectBeforeDrag.Top + (movingPoint.Y - adjustmentStartPoint.Y), 0, Math.Max(0, surfaceRect.Height - selectionRectBeforeDrag.Height)); + updatedRect = new Rect(newLeft, newTop, selectionRectBeforeDrag.Width, selectionRectBeforeDrag.Height); + } + else + { + double left = selectionRectBeforeDrag.Left; + double top = selectionRectBeforeDrag.Top; + double right = selectionRectBeforeDrag.Right; + double bottom = selectionRectBeforeDrag.Bottom; + + switch (selectionInteractionMode) + { + case SelectionInteractionMode.ResizeLeft: + case SelectionInteractionMode.ResizeTopLeft: + case SelectionInteractionMode.ResizeBottomLeft: + left = Math.Clamp(movingPoint.X, 0, right - MinimumSelectionSize); + break; + } + + switch (selectionInteractionMode) + { + case SelectionInteractionMode.ResizeRight: + case SelectionInteractionMode.ResizeTopRight: + case SelectionInteractionMode.ResizeBottomRight: + right = Math.Clamp(movingPoint.X, left + MinimumSelectionSize, surfaceRect.Width); + break; + } + + switch (selectionInteractionMode) + { + case SelectionInteractionMode.ResizeTop: + case SelectionInteractionMode.ResizeTopLeft: + case SelectionInteractionMode.ResizeTopRight: + top = Math.Clamp(movingPoint.Y, 0, bottom - MinimumSelectionSize); + break; + } + + switch (selectionInteractionMode) + { + case SelectionInteractionMode.ResizeBottom: + case SelectionInteractionMode.ResizeBottomLeft: + case SelectionInteractionMode.ResizeBottomRight: + bottom = Math.Clamp(movingPoint.Y, top + MinimumSelectionSize, surfaceRect.Height); + break; + } + + updatedRect = new Rect(new Point(left, top), new Point(right, bottom)); + } + + ApplySelectionRect(updatedRect); + UpdateSelectionHandles(); + } + + private void EndSelectionInteraction() + { + isSelecting = false; + CursorClipper.UnClipCursor(); + RegionClickCanvas.ReleaseMouseCapture(); + selectionInteractionMode = SelectionInteractionMode.None; + CurrentScreen = null; + } + + private async Task FinalizeRectangleSelectionAsync() + { + EndSelectionInteraction(); + + Rect selectionRect = GetCurrentSelectionRect(); + bool isSmallClick = selectionRect.Width < MinimumSelectionSize || selectionRect.Height < MinimumSelectionSize; + + if (CurrentSelectionStyle == FsgSelectionStyle.AdjustAfter) + { + if (isSmallClick) + { + ResetSelectionVisualState(); + TopButtonsStackPanel.Visibility = Visibility.Visible; + return; + } + + EnterAdjustAfterMode(); + return; + } + + FullscreenCaptureResult selection = CreateRectangleSelectionResult(CurrentSelectionStyle); + await CommitSelectionAsync(selection, isSmallClick); + } + + private async Task FinalizeFreeformSelectionAsync() + { + EndSelectionInteraction(); + + Rect bounds = FreeformCaptureUtilities.GetBounds(freeformSelectionPoints); + if (bounds == Rect.Empty || bounds.Width < MinimumSelectionSize || bounds.Height < MinimumSelectionSize) + { + ResetSelectionVisualState(); + TopButtonsStackPanel.Visibility = Visibility.Visible; + return; + } + + FullscreenCaptureResult? selection = CreateFreeformSelectionResult(); + ResetSelectionVisualState(); + + if (selection is not null) + await CommitSelectionAsync(selection, false); + } + + private FullscreenCaptureResult CreateRectangleSelectionResult(FsgSelectionStyle selectionStyle) + { + Rect selectionRect = GetCurrentSelectionRect(); + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + Matrix transformToDevice = presentationSource?.CompositionTarget?.TransformToDevice ?? Matrix.Identity; + Point absoluteWindowPosition = this.GetAbsolutePosition(); + + double left = Math.Round(selectionRect.Left * transformToDevice.M11); + double top = Math.Round(selectionRect.Top * transformToDevice.M22); + double width = Math.Max(1, Math.Round(selectionRect.Width * transformToDevice.M11)); + double height = Math.Max(1, Math.Round(selectionRect.Height * transformToDevice.M22)); + + return new FullscreenCaptureResult( + selectionStyle, + new Rect(absoluteWindowPosition.X + left, absoluteWindowPosition.Y + top, width, height)); + } + + private FullscreenCaptureResult? CreateFreeformSelectionResult() + { + if (freeformSelectionPoints.Count < 3) + return null; + + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + Matrix transformToDevice = presentationSource?.CompositionTarget?.TransformToDevice ?? Matrix.Identity; + Point absoluteWindowPosition = this.GetAbsolutePosition(); + + List devicePoints = [.. freeformSelectionPoints.Select(point => + { + Point devicePoint = transformToDevice.Transform(point); + return new Point(Math.Round(devicePoint.X), Math.Round(devicePoint.Y)); + })]; + + Rect deviceBounds = FreeformCaptureUtilities.GetBounds(devicePoints); + if (deviceBounds == Rect.Empty) + return null; + + Rect absoluteCaptureRect = new( + absoluteWindowPosition.X + deviceBounds.X, + absoluteWindowPosition.Y + deviceBounds.Y, + deviceBounds.Width, + deviceBounds.Height); + + List relativePoints = [.. devicePoints.Select(point => new Point(point.X - deviceBounds.X, point.Y - deviceBounds.Y))]; + + using Bitmap rawBitmap = ImageMethods.GetRegionOfScreenAsBitmap(absoluteCaptureRect.AsRectangle(), cacheResult: false); + Bitmap maskedBitmap = FreeformCaptureUtilities.CreateMaskedBitmap(rawBitmap, relativePoints); + Singleton.Instance.CacheLastBitmap(maskedBitmap); + + BitmapSource captureImage = ImageMethods.BitmapToImageSource(maskedBitmap); + + return new FullscreenCaptureResult( + FsgSelectionStyle.Freeform, + absoluteCaptureRect, + captureImage); + } + + private FullscreenCaptureResult CreateWindowSelectionResult(WindowSelectionCandidate candidate) + { + BitmapSource? capturedImage = ComposeCapturedImageFromFullscreenBackgrounds(candidate.Bounds); + return new FullscreenCaptureResult( + FsgSelectionStyle.Window, + candidate.Bounds, + capturedImage, + candidate.Title); + } + + private static BitmapSource? ComposeCapturedImageFromFullscreenBackgrounds(Rect absoluteCaptureRect) + { + if (Application.Current is null || absoluteCaptureRect.IsEmpty || absoluteCaptureRect.Width <= 0 || absoluteCaptureRect.Height <= 0) + return null; + + int targetWidth = Math.Max(1, (int)Math.Ceiling(absoluteCaptureRect.Width)); + int targetHeight = Math.Max(1, (int)Math.Ceiling(absoluteCaptureRect.Height)); + int drawnSegments = 0; + + DrawingVisual drawingVisual = new(); + using (DrawingContext drawingContext = drawingVisual.RenderOpen()) + { + drawingContext.DrawRectangle(Brushes.White, null, new Rect(0, 0, targetWidth, targetHeight)); + + foreach (FullscreenGrab fullscreenGrab in Application.Current.Windows.OfType()) + { + if (fullscreenGrab.BackgroundImage.Source is not BitmapSource backgroundBitmap) + continue; + + Rect windowBounds = fullscreenGrab.GetWindowDeviceBounds(); + Rect intersection = Rect.Intersect(windowBounds, absoluteCaptureRect); + if (intersection.IsEmpty || intersection.Width <= 0 || intersection.Height <= 0) + continue; + + int cropX = Math.Max(0, (int)Math.Round(intersection.Left - windowBounds.Left)); + int cropY = Math.Max(0, (int)Math.Round(intersection.Top - windowBounds.Top)); + int cropW = Math.Min((int)Math.Round(intersection.Width), backgroundBitmap.PixelWidth - cropX); + int cropH = Math.Min((int)Math.Round(intersection.Height), backgroundBitmap.PixelHeight - cropY); + + if (cropW <= 0 || cropH <= 0) + continue; + + CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH)); + croppedBitmap.Freeze(); + + Rect destinationRect = new( + intersection.Left - absoluteCaptureRect.Left, + intersection.Top - absoluteCaptureRect.Top, + cropW, + cropH); + + drawingContext.DrawImage(croppedBitmap, destinationRect); + drawnSegments++; + } + } + + if (drawnSegments == 0) + return null; + + RenderTargetBitmap renderedBitmap = new(targetWidth, targetHeight, 96, 96, PixelFormats.Pbgra32); + renderedBitmap.Render(drawingVisual); + renderedBitmap.Freeze(); + return renderedBitmap; + } + + private void EnterAdjustAfterMode() + { + isAwaitingAdjustAfterCommit = true; + selectionInteractionMode = SelectionInteractionMode.None; + selectBorder.Background = Brushes.Transparent; + AcceptSelectionButton.Visibility = Visibility.Visible; + TopButtonsStackPanel.Visibility = Visibility.Visible; + UpdateSelectionHandles(); + UpdateAdjustAfterCursor(Mouse.GetPosition(this)); + } + + private Rect GetHistoryPositionRect(FullscreenCaptureResult selection) + { + if (selection.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter) + { + GetDpiAdjustedRegionOfSelectBorder(out _, out double posLeft, out double posTop); + return new Rect(posLeft, posTop, selectBorder.Width, selectBorder.Height); + } + + DpiScale dpi = VisualTreeHelper.GetDpi(this); + return new Rect( + selection.CaptureRegion.X / dpi.DpiScaleX, + selection.CaptureRegion.Y / dpi.DpiScaleY, + selection.CaptureRegion.Width / dpi.DpiScaleX, + selection.CaptureRegion.Height / dpi.DpiScaleY); + } + + private BitmapSource? GetBitmapSourceForGrabFrame(FullscreenCaptureResult selection) + { + if (selection.CapturedImage is not null) + return selection.CapturedImage; + + if (selection.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter + && BackgroundImage.Source is BitmapSource backgroundBitmap + && RegionClickCanvas.Children.Contains(selectBorder)) + { + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + Matrix transformToDevice = presentationSource?.CompositionTarget?.TransformToDevice ?? Matrix.Identity; + Rect selectionRect = GetCurrentSelectionRect(); + + if (TryGetBitmapCropRectForSelection( + selectionRect, + transformToDevice, + BackgroundImage.RenderTransform, + backgroundBitmap.PixelWidth, + backgroundBitmap.PixelHeight, + out Int32Rect cropRect)) + { + CroppedBitmap croppedBitmap = new(backgroundBitmap, cropRect); + croppedBitmap.Freeze(); + return croppedBitmap; + } + } + + using Bitmap capturedBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle(), cacheResult: false); + return ImageMethods.BitmapToImageSource(capturedBitmap); + } + + private async Task PlaceGrabFrameInSelectionRectAsync(FullscreenCaptureResult selection) + { + BitmapSource? frozenImage = GetBitmapSourceForGrabFrame(selection); + ILanguage selectedLanguage = LanguagesComboBox.SelectedItem as ILanguage ?? LanguageUtilities.GetOCRLanguage(); + IntPtr fullscreenGrabHandle = new WindowInteropHelper(this).Handle; + IReadOnlyCollection? excludedHandles = fullscreenGrabHandle == IntPtr.Zero ? null : [fullscreenGrabHandle]; + UiAutomationOverlaySnapshot? uiAutomationSnapshot = selectedLanguage is UiAutomationLang + ? await UIAutomationUtilities.GetOverlaySnapshotFromRegionAsync(selection.CaptureRegion, excludedHandles) + : null; + GrabFrame grabFrame = frozenImage is not null ? new GrabFrame(frozenImage, uiAutomationSnapshot) : new GrabFrame(); + + DpiScale dpi = VisualTreeHelper.GetDpi(this); + Rect selectionRect = new( + selection.CaptureRegion.X / dpi.DpiScaleX, + selection.CaptureRegion.Y / dpi.DpiScaleY, + selection.CaptureRegion.Width / dpi.DpiScaleX, + selection.CaptureRegion.Height / dpi.DpiScaleY); + + grabFrame.Left = selectionRect.Left - (2 / dpi.PixelsPerDip); + grabFrame.Top = selectionRect.Top - (48 / dpi.PixelsPerDip); + + if (destinationTextBox is not null) + grabFrame.DestinationTextBox = destinationTextBox; + + grabFrame.TableToggleButton.IsChecked = TableToggleButton.IsChecked; + if (selectionRect.Width > 20 && selectionRect.Height > 20) + { + grabFrame.Width = selectionRect.Width + 4; + grabFrame.Height = selectionRect.Height + 74; + } + + grabFrame.Show(); + grabFrame.Activate(); + + DisposeBitmapSource(BackgroundImage); + WindowUtilities.CloseAllFullscreenGrabs(); + } + + private static bool IsTemplateAction(ButtonInfo action) => action.ClickEvent == "ApplyTemplate_Click"; + + private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool isSmallClick) + { + clickedWindowCandidate = null; + + if (NewGrabFrameMenuItem.IsChecked is true) + { + await PlaceGrabFrameInSelectionRectAsync(selection); + return; + } + + if (LanguagesComboBox.SelectedItem is not ILanguage selectedOcrLang) + selectedOcrLang = LanguageUtilities.GetOCRLanguage(); + + bool isSingleLine = SingleLineMenuItem is not null && SingleLineMenuItem.IsChecked; + bool isTable = TableMenuItem is not null && TableMenuItem.IsChecked; + TextFromOCR = string.Empty; + IntPtr fullscreenGrabHandle = new WindowInteropHelper(this).Handle; + IReadOnlyCollection? excludedHandles = fullscreenGrabHandle == IntPtr.Zero ? null : [fullscreenGrabHandle]; + + if (isSmallClick && selection.SelectionStyle == FsgSelectionStyle.Region) + { + BackgroundBrush.Opacity = 0; + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + Matrix transformToDevice = presentationSource?.CompositionTarget?.TransformToDevice ?? Matrix.Identity; + Rect selectionRect = GetCurrentSelectionRect(); + Point clickedPointForOcr = transformToDevice.Transform(new Point( + selectionRect.Left + (selectionRect.Width / 2.0), + selectionRect.Top + (selectionRect.Height / 2.0))); + clickedPointForOcr = new Point( + Math.Round(clickedPointForOcr.X), + Math.Round(clickedPointForOcr.Y)); + + TextFromOCR = await OcrUtilities.GetClickedWordAsync(this, clickedPointForOcr, selectedOcrLang); + } + else if (selectedOcrLang is UiAutomationLang) + { + TextFromOCR = await OcrUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); + } + else if (selection.CapturedImage is not null) + { + TextFromOCR = isTable + ? await OcrUtilities.GetTextFromBitmapSourceAsTableAsync(selection.CapturedImage, selectedOcrLang) + : await OcrUtilities.GetTextFromBitmapSourceAsync(selection.CapturedImage, selectedOcrLang); + } + else if (isTable) + { + // TODO: Look into why this happens and find a better way to dispose the bitmap + // DO NOT add a using statement to this selected bitmap, it crashes the app + Bitmap selectionBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle()); + TextFromOCR = await OcrUtilities.GetTextFromBitmapAsTableAsync(selectionBitmap, selectedOcrLang); + } + else + { + TextFromOCR = await OcrUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); + } + + if (DefaultSettings.UseHistory && !isSmallClick) + { + Bitmap? historyBitmap = selection.CapturedImage is not null + ? ImageMethods.BitmapSourceToBitmap(selection.CapturedImage) + : Singleton.Instance.CachedBitmap is Bitmap cachedBitmap + ? new Bitmap(cachedBitmap) + : null; + + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(selectedOcrLang); + + historyInfo = new HistoryInfo + { + ID = Guid.NewGuid().ToString(), + DpiScaleFactor = GetCurrentDeviceScale(), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, + CaptureDateTime = DateTimeOffset.Now, + PositionRect = GetHistoryPositionRect(selection), + IsTable = TableToggleButton.IsChecked!.Value, + TextContent = TextFromOCR, + ImageContent = historyBitmap, + SourceMode = TextGrabMode.Fullscreen, + SelectionStyle = selection.SelectionStyle, + }; + } + + if (string.IsNullOrWhiteSpace(TextFromOCR)) + { + BackgroundBrush.Opacity = DefaultSettings.FsgShadeOverlay ? .2 : 0.0; + TopButtonsStackPanel.Visibility = Visibility.Visible; + + if (selection.SelectionStyle == FsgSelectionStyle.AdjustAfter) + EnterAdjustAfterMode(); + else + ResetSelectionVisualState(); + + return; + } + + if (NextStepDropDownButton.Flyout is ContextMenu contextMenu) + { + bool shouldInsert = false; + bool showedFreeformTemplateMessage = false; + + foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(contextMenu)) + { + if (!menuItem.IsChecked || menuItem.Tag is not ButtonInfo action) + continue; + + if (action.ClickEvent == "Insert_Click") + { + shouldInsert = true; + continue; + } + + if (!selection.SupportsTemplateActions && IsTemplateAction(action)) + { + if (!showedFreeformTemplateMessage) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Grab Templates are currently available only for rectangular selections. Freeform captures will keep their OCR text without applying templates.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + showedFreeformTemplateMessage = true; + } + + continue; + } + + PostGrabContext grabContext = new( + Text: TextFromOCR ?? string.Empty, + CaptureRegion: selection.CaptureRegion, + DpiScale: GetCurrentDeviceScale(), + CapturedImage: selection.CapturedImage, + Language: selectedOcrLang, + SelectionStyle: selection.SelectionStyle); + + TextFromOCR = await PostGrabActionManager.ExecutePostGrabAction(action, grabContext); + } + + if (shouldInsert && !DefaultSettings.TryInsert) + { + string textToInsert = TextFromOCR; + _ = Task.Run(async () => + { + await Task.Delay(100); + await WindowUtilities.TryInsertString(textToInsert); + }); + } + } + + if (SendToEditTextToggleButton.IsChecked is true + && destinationTextBox is null) + { + bool isWebSearch = false; + if (NextStepDropDownButton.Flyout is ContextMenu postCaptureMenu) + { + foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(postCaptureMenu)) + { + if (menuItem.IsChecked + && menuItem.Tag is ButtonInfo action + && action.ClickEvent == "WebSearch_Click") + { + isWebSearch = true; + break; + } + } + } + + if (!isWebSearch) + { + EditTextWindow etw = WindowUtilities.OpenOrActivateWindow(); + destinationTextBox = etw.PassedTextControl; + } + } + + OutputUtilities.HandleTextFromOcr( + TextFromOCR, + isSingleLine, + isTable, + destinationTextBox); + WindowUtilities.CloseAllFullscreenGrabs(); + } + + private async void AcceptSelectionButton_Click(object sender, RoutedEventArgs e) + { + if (!isAwaitingAdjustAfterCommit) + return; + + isAwaitingAdjustAfterCommit = false; + ClearSelectionHandles(); + AcceptSelectionButton.Visibility = Visibility.Collapsed; + + await CommitSelectionAsync(CreateRectangleSelectionResult(FsgSelectionStyle.AdjustAfter), false); + } + + private void HandleRegionCanvasMouseDown(MouseButtonEventArgs e) + { + switch (CurrentSelectionStyle) + { + case FsgSelectionStyle.Window: + clickedWindowCandidate = GetWindowSelectionCandidateAtCurrentMousePosition() ?? hoveredWindowCandidate; + ApplyWindowSelectionHighlight(clickedWindowCandidate); + + if (clickedWindowCandidate is not null) + RegionClickCanvas.CaptureMouse(); + break; + case FsgSelectionStyle.Freeform: + BeginFreeformSelection(e); + break; + case FsgSelectionStyle.AdjustAfter: + if (!TryBeginAdjustAfterInteraction(e)) + BeginRectangleSelection(e); + break; + case FsgSelectionStyle.Region: + default: + BeginRectangleSelection(e); + break; + } + } + + private void HandleRegionCanvasMouseMove(MouseEventArgs e) + { + Point movingPoint = e.GetPosition(this); + + switch (selectionInteractionMode) + { + case SelectionInteractionMode.CreatingRectangle: + UpdateRectangleSelection(movingPoint); + break; + case SelectionInteractionMode.CreatingFreeform: + UpdateFreeformSelection(movingPoint); + break; + case SelectionInteractionMode.None: + if (CurrentSelectionStyle == FsgSelectionStyle.AdjustAfter) + UpdateAdjustAfterCursor(movingPoint); + break; + default: + UpdateAdjustedSelection(movingPoint); + break; + } + } + + private async Task HandleRegionCanvasMouseUpAsync(MouseButtonEventArgs e) + { + switch (selectionInteractionMode) + { + case SelectionInteractionMode.CreatingRectangle: + await FinalizeRectangleSelectionAsync(); + break; + case SelectionInteractionMode.CreatingFreeform: + await FinalizeFreeformSelectionAsync(); + break; + case SelectionInteractionMode.MovingSelection: + case SelectionInteractionMode.ResizeLeft: + case SelectionInteractionMode.ResizeTop: + case SelectionInteractionMode.ResizeRight: + case SelectionInteractionMode.ResizeBottom: + case SelectionInteractionMode.ResizeTopLeft: + case SelectionInteractionMode.ResizeTopRight: + case SelectionInteractionMode.ResizeBottomLeft: + case SelectionInteractionMode.ResizeBottomRight: + EndSelectionInteraction(); + UpdateSelectionHandles(); + UpdateAdjustAfterCursor(e.GetPosition(this)); + break; + default: + if (CurrentSelectionStyle == FsgSelectionStyle.Window) + { + WindowSelectionCandidate? pressedWindowCandidate = clickedWindowCandidate; + WindowSelectionCandidate? releasedWindowCandidate = GetWindowSelectionCandidateAtCurrentMousePosition() ?? hoveredWindowCandidate; + + if (RegionClickCanvas.IsMouseCaptured) + RegionClickCanvas.ReleaseMouseCapture(); + + ApplyWindowSelectionHighlight(releasedWindowCandidate); + + if (ShouldCommitWindowSelection(pressedWindowCandidate, releasedWindowCandidate) + && pressedWindowCandidate is not null) + { + await CommitSelectionAsync( + CreateWindowSelectionResult(pressedWindowCandidate), + false); + } + } + + clickedWindowCandidate = null; + break; + } + } +} diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml index 5011adc1..35902a56 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml +++ b/Text-Grab/Views/FullscreenGrab.xaml @@ -21,22 +21,12 @@ mc:Ignorable="d"> - - - - + + + + + + @@ -85,6 +75,37 @@ Header="Freeze" IsCheckable="True" IsChecked="True" /> + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +