From bc850337a06608bd281bcf929e5dd29be1b7ce4e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 16:53:55 +0000
Subject: [PATCH 001/109] Initial plan
From 10d9f086c6019761855aa3bc0cccc6e737075ce3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 16:57:13 +0000
Subject: [PATCH 002/109] Add Windows context menu integration for grabbing
text from images
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Tests/ContextMenuTests.cs | 63 ++++++++
Text-Grab/Pages/GeneralSettings.xaml | 18 +++
Text-Grab/Pages/GeneralSettings.xaml.cs | 39 +++++
Text-Grab/Properties/Settings.Designer.cs | 12 ++
Text-Grab/Properties/Settings.settings | 3 +
Text-Grab/Utilities/ContextMenuUtilities.cs | 164 ++++++++++++++++++++
6 files changed, 299 insertions(+)
create mode 100644 Tests/ContextMenuTests.cs
create mode 100644 Text-Grab/Utilities/ContextMenuUtilities.cs
diff --git a/Tests/ContextMenuTests.cs b/Tests/ContextMenuTests.cs
new file mode 100644
index 00000000..7f22cfd3
--- /dev/null
+++ b/Tests/ContextMenuTests.cs
@@ -0,0 +1,63 @@
+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 - Use reflection to access private method for testing
+ var method = typeof(ContextMenuUtilities).GetMethod(
+ "GetShellKeyPath",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ string? result = method?.Invoke(null, [extension]) as string;
+
+ // 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 - Use reflection to access private method for testing
+ var method = typeof(ContextMenuUtilities).GetMethod(
+ "GetShellKeyPath",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ string? result = method?.Invoke(null, [extension]) as string;
+
+ // Assert
+ Assert.Equal(expectedPath, result);
+ }
+
+ [Fact]
+ public void ImageExtensions_ContainsExpectedFormats()
+ {
+ // Arrange - Use reflection to access private field
+ var field = typeof(ContextMenuUtilities).GetField(
+ "ImageExtensions",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ string[]? extensions = field?.GetValue(null) as string[];
+
+ // Assert
+ Assert.NotNull(extensions);
+ Assert.Contains(".png", extensions);
+ Assert.Contains(".jpg", extensions);
+ Assert.Contains(".jpeg", extensions);
+ Assert.Contains(".bmp", extensions);
+ Assert.Contains(".gif", extensions);
+ Assert.Contains(".tiff", extensions);
+ Assert.Contains(".tif", extensions);
+ }
+}
diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml
index 0b5dbe15..d997b79d 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml
+++ b/Text-Grab/Pages/GeneralSettings.xaml
@@ -225,6 +225,24 @@
+
+
+
+
+ Add "Grab text with Text Grab" to right-click menu for image files
+
+
+
+ Right-click on PNG, JPG, BMP, GIF, or TIFF files to quickly grab text.
+
+
.Instance.DefaultSearcher = newDefault;
}
+
+ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e)
+ {
+ if (!settingsSet)
+ return;
+
+ bool success = ContextMenuUtilities.AddToContextMenu();
+ if (success)
+ {
+ DefaultSettings.AddToContextMenu = true;
+ DefaultSettings.Save();
+ }
+ else
+ {
+ // Revert the checkbox if registration failed
+ AddToContextMenuCheckBox.IsChecked = false;
+ }
+ }
+
+ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e)
+ {
+ if (!settingsSet)
+ return;
+
+ ContextMenuUtilities.RemoveFromContextMenu();
+ DefaultSettings.AddToContextMenu = false;
+ DefaultSettings.Save();
+ }
}
diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs
index a22ca083..33a8986e 100644
--- a/Text-Grab/Properties/Settings.Designer.cs
+++ b/Text-Grab/Properties/Settings.Designer.cs
@@ -838,5 +838,17 @@ public bool PostGrabStayOpen {
this["PostGrabStayOpen"] = value;
}
}
+
+ [global::System.Configuration.UserScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("False")]
+ public bool AddToContextMenu {
+ get {
+ return ((bool)(this["AddToContextMenu"]));
+ }
+ set {
+ this["AddToContextMenu"] = value;
+ }
+ }
}
}
diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings
index e9eac4d3..52cb7cf6 100644
--- a/Text-Grab/Properties/Settings.settings
+++ b/Text-Grab/Properties/Settings.settings
@@ -206,5 +206,8 @@
False
+
+ False
+
\ No newline at end of file
diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs
new file mode 100644
index 00000000..988a92e9
--- /dev/null
+++ b/Text-Grab/Utilities/ContextMenuUtilities.cs
@@ -0,0 +1,164 @@
+using Microsoft.Win32;
+using System;
+
+namespace Text_Grab.Utilities;
+
+///
+/// Utility class for managing Windows context menu integration.
+/// Adds "Grab text with Text Grab" option to the right-click context menu for image files.
+///
+internal static class ContextMenuUtilities
+{
+ private const string ContextMenuRegistryKeyName = "Text-Grab.GrabText";
+ private const string ContextMenuDisplayText = "Grab text with Text Grab";
+
+ ///
+ /// 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".
+ ///
+ /// True if registration was successful, false otherwise.
+ public static bool AddToContextMenu()
+ {
+ string executablePath = FileUtilities.GetExePath();
+
+ if (string.IsNullOrEmpty(executablePath))
+ return false;
+
+ try
+ {
+ foreach (string extension in ImageExtensions)
+ {
+ RegisterContextMenuForExtension(extension, executablePath);
+ }
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Removes Text Grab from the Windows context menu for image files.
+ ///
+ /// True if removal was successful, false otherwise.
+ public static bool RemoveFromContextMenu()
+ {
+ try
+ {
+ foreach (string extension in ImageExtensions)
+ {
+ UnregisterContextMenuForExtension(extension);
+ }
+ return true;
+ }
+ catch (Exception)
+ {
+ 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);
+ using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath);
+ if (key is not null)
+ return true;
+ }
+ return false;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Registers the context menu entry for a specific file extension.
+ /// Uses the Shell registration approach under HKEY_CURRENT_USER for per-user installation.
+ ///
+ /// The file extension (e.g., ".png")
+ /// The path to the Text Grab executable
+ private static void RegisterContextMenuForExtension(string extension, string executablePath)
+ {
+ // Register under HKEY_CURRENT_USER\Software\Classes\
+ // This approach works for per-user installation without requiring admin rights
+ string shellKeyPath = GetShellKeyPath(extension);
+ string commandKeyPath = $@"{shellKeyPath}\command";
+
+ // Create the shell key with display name
+ using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath))
+ {
+ if (shellKey is null)
+ return;
+
+ shellKey.SetValue(string.Empty, ContextMenuDisplayText);
+ shellKey.SetValue("Icon", $"\"{executablePath}\"");
+ }
+
+ // Create the command key with the executable path
+ using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath))
+ {
+ if (commandKey is null)
+ return;
+
+ // %1 is replaced by Windows with the path to the file that was right-clicked
+ // --windowless flag will OCR and copy to clipboard without opening a window
+ commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\"");
+ }
+ }
+
+ ///
+ /// Removes the context menu entry for a specific file extension.
+ ///
+ /// The file extension (e.g., ".png")
+ private static void UnregisterContextMenuForExtension(string extension)
+ {
+ string shellKeyPath = GetShellKeyPath(extension);
+
+ try
+ {
+ // Delete the entire shell key and its subkeys
+ Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false);
+ }
+ catch (Exception)
+ {
+ // Ignore errors during unregistration
+ }
+ }
+
+ ///
+ /// Gets the registry path for the shell context menu key for a given extension.
+ ///
+ /// The file extension (e.g., ".png")
+ /// The registry key path
+ private static string GetShellKeyPath(string extension)
+ {
+ // Using SystemFileAssociations allows the context menu to work regardless of
+ // which application is associated with the file type
+ return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{ContextMenuRegistryKeyName}";
+ }
+}
From 11d0d893a068b3c6cac01b6ef9b7af746fa7cc08 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 17:00:55 +0000
Subject: [PATCH 003/109] Improve error handling and user feedback for context
menu registration
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/Pages/GeneralSettings.xaml.cs | 11 +++++++-
Text-Grab/Utilities/ContextMenuUtilities.cs | 30 ++++++++++++++++-----
2 files changed, 33 insertions(+), 8 deletions(-)
diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs
index 43e8e48b..0a45b639 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml.cs
+++ b/Text-Grab/Pages/GeneralSettings.xaml.cs
@@ -395,7 +395,7 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e)
if (!settingsSet)
return;
- bool success = ContextMenuUtilities.AddToContextMenu();
+ bool success = ContextMenuUtilities.AddToContextMenu(out string? errorMessage);
if (success)
{
DefaultSettings.AddToContextMenu = true;
@@ -404,7 +404,16 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e)
else
{
// Revert the checkbox if registration failed
+ settingsSet = false;
AddToContextMenuCheckBox.IsChecked = false;
+ settingsSet = true;
+
+ // Show error message to user
+ System.Windows.MessageBox.Show(
+ errorMessage ?? "Failed to add Text Grab to the context menu.",
+ "Context Menu Registration Failed",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
}
}
diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs
index 988a92e9..c1621aa8 100644
--- a/Text-Grab/Utilities/ContextMenuUtilities.cs
+++ b/Text-Grab/Utilities/ContextMenuUtilities.cs
@@ -1,5 +1,6 @@
using Microsoft.Win32;
using System;
+using System.Diagnostics;
namespace Text_Grab.Utilities;
@@ -30,13 +31,18 @@ internal static class ContextMenuUtilities
/// 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".
///
+ /// When the method returns false, contains an error message describing the failure.
/// True if registration was successful, false otherwise.
- public static bool AddToContextMenu()
+ 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
{
@@ -46,8 +52,16 @@ public static bool AddToContextMenu()
}
return true;
}
- catch (Exception)
+ 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;
}
}
@@ -66,8 +80,9 @@ public static bool RemoveFromContextMenu()
}
return true;
}
- catch (Exception)
+ catch (Exception ex)
{
+ Debug.WriteLine($"Context menu unregistration failed: {ex.Message}");
return false;
}
}
@@ -90,8 +105,9 @@ public static bool IsRegisteredInContextMenu()
}
return false;
}
- catch (Exception)
+ catch (Exception ex)
{
+ Debug.WriteLine($"Context menu registration check failed: {ex.Message}");
return false;
}
}
@@ -126,7 +142,6 @@ private static void RegisterContextMenuForExtension(string extension, string exe
return;
// %1 is replaced by Windows with the path to the file that was right-clicked
- // --windowless flag will OCR and copy to clipboard without opening a window
commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\"");
}
}
@@ -144,9 +159,10 @@ private static void UnregisterContextMenuForExtension(string extension)
// Delete the entire shell key and its subkeys
Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false);
}
- catch (Exception)
+ catch (Exception ex)
{
- // Ignore errors during unregistration
+ // Log but don't throw - we want to continue trying to remove other extensions
+ Debug.WriteLine($"Failed to unregister context menu for {extension}: {ex.Message}");
}
}
From 62d4e34575017720994190ee03c02a8e58fa959c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 17:03:29 +0000
Subject: [PATCH 004/109] Address code review feedback: improve error handling
and test quality
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Tests/ContextMenuTests.cs | 51 ++++++++++-----------
Text-Grab/AssemblyInfo.cs | 4 +-
Text-Grab/Pages/GeneralSettings.xaml.cs | 12 ++++-
Text-Grab/Utilities/ContextMenuUtilities.cs | 12 +++--
4 files changed, 48 insertions(+), 31 deletions(-)
diff --git a/Tests/ContextMenuTests.cs b/Tests/ContextMenuTests.cs
index 7f22cfd3..8f5e1f17 100644
--- a/Tests/ContextMenuTests.cs
+++ b/Tests/ContextMenuTests.cs
@@ -11,12 +11,8 @@ public void GetShellKeyPath_ReturnsCorrectPath_ForPngExtension()
string extension = ".png";
string expectedPath = @"Software\Classes\SystemFileAssociations\.png\shell\Text-Grab.GrabText";
- // Act - Use reflection to access private method for testing
- var method = typeof(ContextMenuUtilities).GetMethod(
- "GetShellKeyPath",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
-
- string? result = method?.Invoke(null, [extension]) as string;
+ // Act
+ string result = ContextMenuUtilities.GetShellKeyPath(extension);
// Assert
Assert.Equal(expectedPath, result);
@@ -29,35 +25,38 @@ public void GetShellKeyPath_ReturnsCorrectPath_ForJpgExtension()
string extension = ".jpg";
string expectedPath = @"Software\Classes\SystemFileAssociations\.jpg\shell\Text-Grab.GrabText";
- // Act - Use reflection to access private method for testing
- var method = typeof(ContextMenuUtilities).GetMethod(
- "GetShellKeyPath",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
-
- string? result = method?.Invoke(null, [extension]) as string;
+ // Act
+ string result = ContextMenuUtilities.GetShellKeyPath(extension);
// Assert
Assert.Equal(expectedPath, result);
}
[Fact]
- public void ImageExtensions_ContainsExpectedFormats()
+ public void GetShellKeyPath_ReturnsCorrectPath_ForTiffExtension()
{
- // Arrange - Use reflection to access private field
- var field = typeof(ContextMenuUtilities).GetField(
- "ImageExtensions",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ // Arrange
+ string extension = ".tiff";
+ string expectedPath = @"Software\Classes\SystemFileAssociations\.tiff\shell\Text-Grab.GrabText";
- string[]? extensions = field?.GetValue(null) as string[];
+ // Act
+ string result = ContextMenuUtilities.GetShellKeyPath(extension);
// Assert
- Assert.NotNull(extensions);
- Assert.Contains(".png", extensions);
- Assert.Contains(".jpg", extensions);
- Assert.Contains(".jpeg", extensions);
- Assert.Contains(".bmp", extensions);
- Assert.Contains(".gif", extensions);
- Assert.Contains(".tiff", extensions);
- Assert.Contains(".tif", extensions);
+ 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/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/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs
index 0a45b639..6dd88377 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml.cs
+++ b/Text-Grab/Pages/GeneralSettings.xaml.cs
@@ -422,8 +422,18 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e
if (!settingsSet)
return;
- ContextMenuUtilities.RemoveFromContextMenu();
+ bool success = ContextMenuUtilities.RemoveFromContextMenu();
DefaultSettings.AddToContextMenu = false;
DefaultSettings.Save();
+
+ if (!success)
+ {
+ // Show warning but don't revert - the setting should still be saved as disabled
+ System.Windows.MessageBox.Show(
+ "Some context menu entries could not be removed. They may be cleaned up manually in the Windows Registry.",
+ "Context Menu Removal Warning",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ }
}
}
diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs
index c1621aa8..46d3322d 100644
--- a/Text-Grab/Utilities/ContextMenuUtilities.cs
+++ b/Text-Grab/Utilities/ContextMenuUtilities.cs
@@ -129,7 +129,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe
using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath))
{
if (shellKey is null)
- return;
+ {
+ Debug.WriteLine($"Failed to create registry key: {shellKeyPath}");
+ throw new InvalidOperationException($"Could not create registry key for {extension}");
+ }
shellKey.SetValue(string.Empty, ContextMenuDisplayText);
shellKey.SetValue("Icon", $"\"{executablePath}\"");
@@ -139,7 +142,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe
using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath))
{
if (commandKey is null)
- return;
+ {
+ 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\"");
@@ -171,7 +177,7 @@ private static void UnregisterContextMenuForExtension(string extension)
///
/// The file extension (e.g., ".png")
/// The registry key path
- private static string GetShellKeyPath(string extension)
+ internal static string GetShellKeyPath(string extension)
{
// Using SystemFileAssociations allows the context menu to work regardless of
// which application is associated with the file type
From f3327996b414cb3222f071442cc5c82ccb25674e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 17:04:30 +0000
Subject: [PATCH 005/109] Final improvements: consistent error handling for
removal and proper setting state management
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/Pages/GeneralSettings.xaml.cs | 23 ++++++++++++++-------
Text-Grab/Utilities/ContextMenuUtilities.cs | 11 +++++++++-
2 files changed, 25 insertions(+), 9 deletions(-)
diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs
index 6dd88377..5081eb20 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml.cs
+++ b/Text-Grab/Pages/GeneralSettings.xaml.cs
@@ -422,16 +422,23 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e
if (!settingsSet)
return;
- bool success = ContextMenuUtilities.RemoveFromContextMenu();
- DefaultSettings.AddToContextMenu = false;
- DefaultSettings.Save();
-
- if (!success)
+ bool success = ContextMenuUtilities.RemoveFromContextMenu(out string? errorMessage);
+
+ if (success)
+ {
+ DefaultSettings.AddToContextMenu = false;
+ DefaultSettings.Save();
+ }
+ else
{
- // Show warning but don't revert - the setting should still be saved as disabled
+ // Revert the checkbox since removal failed - the context menu is still registered
+ settingsSet = false;
+ AddToContextMenuCheckBox.IsChecked = true;
+ settingsSet = true;
+
System.Windows.MessageBox.Show(
- "Some context menu entries could not be removed. They may be cleaned up manually in the Windows Registry.",
- "Context Menu Removal Warning",
+ errorMessage ?? "Some context menu entries could not be removed.",
+ "Context Menu Removal Failed",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs
index 46d3322d..32c9e110 100644
--- a/Text-Grab/Utilities/ContextMenuUtilities.cs
+++ b/Text-Grab/Utilities/ContextMenuUtilities.cs
@@ -69,9 +69,11 @@ public static bool AddToContextMenu(out string? errorMessage)
///
/// 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()
+ public static bool RemoveFromContextMenu(out string? errorMessage)
{
+ errorMessage = null;
try
{
foreach (string extension in ImageExtensions)
@@ -80,9 +82,16 @@ public static bool RemoveFromContextMenu()
}
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;
}
}
From bdc347694ae96ecb1918e4e2c5ffba2d2a0fcafa Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sat, 31 Jan 2026 12:33:39 -0600
Subject: [PATCH 006/109] Clarify MessageBoxButton enum usage in
MessageBox.Show
Updated MessageBox.Show calls to explicitly use System.Windows.MessageBoxButton.OK for improved clarity and to avoid ambiguity.
---
Text-Grab/Pages/GeneralSettings.xaml.cs | 4 ++--
Text-Grab/Views/SettingsWindow.xaml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs
index 5081eb20..3cde4e03 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml.cs
+++ b/Text-Grab/Pages/GeneralSettings.xaml.cs
@@ -412,7 +412,7 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e)
System.Windows.MessageBox.Show(
errorMessage ?? "Failed to add Text Grab to the context menu.",
"Context Menu Registration Failed",
- MessageBoxButton.OK,
+ System.Windows.MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
@@ -439,7 +439,7 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e
System.Windows.MessageBox.Show(
errorMessage ?? "Some context menu entries could not be removed.",
"Context Menu Removal Failed",
- MessageBoxButton.OK,
+ System.Windows.MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml
index ff773443..46adc14e 100644
--- a/Text-Grab/Views/SettingsWindow.xaml
+++ b/Text-Grab/Views/SettingsWindow.xaml
@@ -114,7 +114,7 @@
- F
+
Date: Sat, 31 Jan 2026 18:39:23 +0000
Subject: [PATCH 007/109] Add Windows 11 modern context menu support for
packaged apps and Open in Grab Frame option
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab-Package/Package.appxmanifest | 23 +++++-
Text-Grab/App.xaml.cs | 29 +++++++
Text-Grab/Utilities/ContextMenuUtilities.cs | 90 ++++++++++++++-------
Text-Grab/Views/GrabFrame.xaml.cs | 12 +++
4 files changed, 124 insertions(+), 30 deletions(-)
diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest
index 06b11690..2589e52c 100644
--- a/Text-Grab-Package/Package.appxmanifest
+++ b/Text-Grab-Package/Package.appxmanifest
@@ -3,11 +3,13 @@
+ IgnorableNamespaces="uap uap2 uap3 rescap com systemai desktop">
+
+
+
+
+ .png
+ .jpg
+ .jpeg
+ .bmp
+ .gif
+ .tiff
+ .tif
+
+
+ Grab text with Text Grab
+ Open in Grab Frame
+
+
+
+
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 flags starting with --)
+ string? filePath = null;
+ foreach (string arg in args)
+ {
+ if (!arg.StartsWith("--") && File.Exists(arg))
+ {
+ filePath = arg;
+ break;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(filePath))
+ {
+ GrabFrame gf = new(filePath);
+ gf.Show();
+ return true;
+ }
+ }
if (currentArgument.Contains("ToastActivated"))
{
diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs
index 32c9e110..4088aab2 100644
--- a/Text-Grab/Utilities/ContextMenuUtilities.cs
+++ b/Text-Grab/Utilities/ContextMenuUtilities.cs
@@ -6,12 +6,14 @@ namespace Text_Grab.Utilities;
///
/// Utility class for managing Windows context menu integration.
-/// Adds "Grab text with Text Grab" option to the right-click context menu for image files.
+/// 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 ContextMenuRegistryKeyName = "Text-Grab.GrabText";
- private const string ContextMenuDisplayText = "Grab text with Text Grab";
+ 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.
@@ -29,7 +31,7 @@ internal static class ContextMenuUtilities
///
/// 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".
+ /// 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.
@@ -48,7 +50,8 @@ public static bool AddToContextMenu(out string? errorMessage)
{
foreach (string extension in ImageExtensions)
{
- RegisterContextMenuForExtension(extension, executablePath);
+ RegisterGrabTextContextMenu(extension, executablePath);
+ RegisterGrabFrameContextMenu(extension, executablePath);
}
return true;
}
@@ -78,7 +81,8 @@ public static bool RemoveFromContextMenu(out string? errorMessage)
{
foreach (string extension in ImageExtensions)
{
- UnregisterContextMenuForExtension(extension);
+ UnregisterContextMenuForExtension(extension, GrabTextRegistryKeyName);
+ UnregisterContextMenuForExtension(extension, GrabFrameRegistryKeyName);
}
return true;
}
@@ -107,7 +111,7 @@ public static bool IsRegisteredInContextMenu()
// Check if at least one extension has the context menu registered
foreach (string extension in ImageExtensions)
{
- string keyPath = GetShellKeyPath(extension);
+ string keyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName);
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath);
if (key is not null)
return true;
@@ -122,19 +126,13 @@ public static bool IsRegisteredInContextMenu()
}
///
- /// Registers the context menu entry for a specific file extension.
- /// Uses the Shell registration approach under HKEY_CURRENT_USER for per-user installation.
+ /// Registers the "Grab text with Text Grab" context menu entry for a specific file extension.
///
- /// The file extension (e.g., ".png")
- /// The path to the Text Grab executable
- private static void RegisterContextMenuForExtension(string extension, string executablePath)
+ private static void RegisterGrabTextContextMenu(string extension, string executablePath)
{
- // Register under HKEY_CURRENT_USER\Software\Classes\
- // This approach works for per-user installation without requiring admin rights
- string shellKeyPath = GetShellKeyPath(extension);
+ string shellKeyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName);
string commandKeyPath = $@"{shellKeyPath}\command";
- // Create the shell key with display name
using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath))
{
if (shellKey is null)
@@ -143,11 +141,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe
throw new InvalidOperationException($"Could not create registry key for {extension}");
}
- shellKey.SetValue(string.Empty, ContextMenuDisplayText);
+ shellKey.SetValue(string.Empty, GrabTextDisplayText);
shellKey.SetValue("Icon", $"\"{executablePath}\"");
}
- // Create the command key with the executable path
using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath))
{
if (commandKey is null)
@@ -162,34 +159,69 @@ private static void RegisterContextMenuForExtension(string extension, string exe
}
///
- /// Removes the context menu entry for a specific file extension.
+ /// Registers the "Open in Grab Frame" context menu entry for a specific file extension.
///
- /// The file extension (e.g., ".png")
- private static void UnregisterContextMenuForExtension(string extension)
+ private static void RegisterGrabFrameContextMenu(string extension, string executablePath)
{
- string shellKeyPath = GetShellKeyPath(extension);
+ 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
{
- // Delete the entire shell key and its subkeys
Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false);
}
catch (Exception ex)
{
- // Log but don't throw - we want to continue trying to remove other extensions
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.
///
- /// The file extension (e.g., ".png")
- /// The registry key path
internal static string GetShellKeyPath(string extension)
{
- // Using SystemFileAssociations allows the context menu to work regardless of
- // which application is associated with the file type
- return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{ContextMenuRegistryKeyName}";
+ return GetShellKeyPath(extension, GrabTextRegistryKeyName);
}
}
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 9e3a06b7..16620c45 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -104,6 +104,18 @@ public GrabFrame(HistoryInfo historyInfo)
historyItem = historyInfo;
}
+ ///
+ /// Creates a GrabFrame and loads the specified image file.
+ ///
+ /// The path to the image file to load.
+ public GrabFrame(string imagePath)
+ {
+ StandardInitialize();
+
+ ShouldSaveOnClose = true;
+ Loaded += async (s, e) => await TryLoadImageFromPath(imagePath);
+ }
+
private async Task LoadContentFromHistory(HistoryInfo history)
{
FrameText = history.TextContent;
From dc96e4eb20db6d82fe30b508eaf54d9581919575 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 18:40:33 +0000
Subject: [PATCH 008/109] Address code review feedback: improve argument
parsing and path validation
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/App.xaml.cs | 11 +++++++++--
Text-Grab/Views/GrabFrame.xaml.cs | 14 ++++++++++++++
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs
index e067cb96..96f627e0 100644
--- a/Text-Grab/App.xaml.cs
+++ b/Text-Grab/App.xaml.cs
@@ -183,6 +183,8 @@ 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];
@@ -207,11 +209,11 @@ private static async Task HandleStartupArgs(string[] args)
// Handle --grabframe flag: open the next argument (file path) in GrabFrame
if (openInGrabFrame)
{
- // Find the file path argument (skip flags starting with --)
+ // Find the file path argument (skip known flags)
string? filePath = null;
foreach (string arg in args)
{
- if (!arg.StartsWith("--") && File.Exists(arg))
+ if (!KnownFlags.Contains(arg) && File.Exists(arg))
{
filePath = arg;
break;
@@ -224,6 +226,11 @@ private static async Task HandleStartupArgs(string[] args)
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"))
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 16620c45..a4bba7da 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -113,6 +113,20 @@ public GrabFrame(string imagePath)
StandardInitialize();
ShouldSaveOnClose = true;
+
+ // Validate the path before loading
+ if (string.IsNullOrEmpty(imagePath))
+ {
+ Debug.WriteLine("GrabFrame: Empty image path provided");
+ return;
+ }
+
+ if (!File.Exists(imagePath))
+ {
+ Debug.WriteLine($"GrabFrame: Image file not found: {imagePath}");
+ return;
+ }
+
Loaded += async (s, e) => await TryLoadImageFromPath(imagePath);
}
From 1dc80d355942391f82057a57092459026cd5d125 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 18:42:02 +0000
Subject: [PATCH 009/109] Improve path validation with GetFullPath and user
error messages
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/App.xaml.cs | 18 +++++++++++++++---
Text-Grab/Views/GrabFrame.xaml.cs | 11 ++++++++---
2 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs
index 96f627e0..706b4207 100644
--- a/Text-Grab/App.xaml.cs
+++ b/Text-Grab/App.xaml.cs
@@ -213,10 +213,22 @@ private static async Task HandleStartupArgs(string[] args)
string? filePath = null;
foreach (string arg in args)
{
- if (!KnownFlags.Contains(arg) && File.Exists(arg))
+ if (!KnownFlags.Contains(arg))
{
- filePath = arg;
- break;
+ // 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}");
+ }
}
}
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index a4bba7da..9bc9e02a 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -118,16 +118,21 @@ public GrabFrame(string imagePath)
if (string.IsNullOrEmpty(imagePath))
{
Debug.WriteLine("GrabFrame: Empty image path provided");
+ Loaded += (s, e) => MessageBox.Show("No image file path was provided.", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
- if (!File.Exists(imagePath))
+ // Convert to absolute path to handle relative paths correctly
+ string absolutePath = Path.GetFullPath(imagePath);
+
+ if (!File.Exists(absolutePath))
{
- Debug.WriteLine($"GrabFrame: Image file not found: {imagePath}");
+ Debug.WriteLine($"GrabFrame: Image file not found: {absolutePath}");
+ Loaded += (s, e) => MessageBox.Show($"Image file not found:\n{absolutePath}", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
- Loaded += async (s, e) => await TryLoadImageFromPath(imagePath);
+ Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath);
}
private async Task LoadContentFromHistory(HistoryInfo history)
From d5cac6a6d151ca455534f4b87dcdd796a449531a Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 11 Feb 2026 23:25:29 -0600
Subject: [PATCH 010/109] Add 'Paste in Grab Frame' to NotifyIcon context menu
Adds a new menu item for pasting clipboard images into a GrabFrame.
Enables/disables the item based on clipboard content. Handles both
image files and image data, saving to temp file for editing. Includes
helper for image file detection and required using directives.
---
Text-Grab/Controls/NotifyIconWindow.xaml | 11 ++-
Text-Grab/Controls/NotifyIconWindow.xaml.cs | 89 +++++++++++++++++++++
2 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml
index e5e4ccca..468c4ebd 100644
--- a/Text-Grab/Controls/NotifyIconWindow.xaml
+++ b/Text-Grab/Controls/NotifyIconWindow.xaml
@@ -27,7 +27,7 @@
LeftClick="NotifyIcon_LeftClick"
TooltipText="Text Grab">
-
+
+
+
+
+
+
+ Register Text Grab as an "Open with" app for image files
+
+
+
+ Opens images directly in Grab Frame when using "Open with" from File Explorer.
+
+
False
+
+ False
+
-
\ No newline at end of file
+
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..5d0a11ec 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)
{
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;
+ }
+}
From 42d79cf7eb3ced4a5726e4cab1ffe2683b6d0821 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Thu, 12 Feb 2026 18:11:36 -0600
Subject: [PATCH 012/109] Start redraw timer after image drop in GrabFrame
Added reDrawTimer.Start() to initiate UI updates when a new image is loaded via drag-and-drop in GrabFrame.xaml.cs. This ensures periodic actions or redraws occur for the newly loaded frame.
---
Text-Grab/Views/GrabFrame.xaml.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 9bc9e02a..d76bb84d 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -2173,6 +2173,8 @@ private async Task TryLoadImageFromPath(string path)
FreezeToggleButton.IsChecked = true;
FreezeGrabFrame();
FreezeToggleButton.Visibility = Visibility.Collapsed;
+
+ reDrawTimer.Start();
}
catch (Exception)
{
From 83cafd34dc81f6e794773f60f6bcab6cab7aa89d Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Mon, 23 Feb 2026 23:40:52 -0600
Subject: [PATCH 013/109] Improve handling of loaded vs. captured images in
GrabFrame
Add hasLoadedImageSource to track if an image is loaded from file, clipboard, or drag-and-drop versus captured from the screen. Update undo/redo and OCR logic to use this flag, preventing unnecessary screen captures and ensuring correct behavior when working with loaded images. Refactor ResetGrabFrame and UnfreezeGrabFrame to respect the image source, and add SetRefreshOrOcrFrameBtnVis to manage OCR/refresh button visibility. Improves reliability and user experience when switching between image sources.
---
Text-Grab/Views/GrabFrame.xaml.cs | 54 ++++++++++++++++++++++++-------
1 file changed, 42 insertions(+), 12 deletions(-)
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index d76bb84d..60953e89 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -54,6 +54,7 @@ public partial class GrabFrame : Window
private TextBox? destinationTextBox;
private ImageSource? frameContentImageSource;
private HistoryInfo? historyItem;
+ private bool hasLoadedImageSource = false;
private bool IsDragOver = false;
private bool isDrawing = false;
private bool isLanguageBoxLoaded = false;
@@ -113,7 +114,7 @@ public GrabFrame(string imagePath)
StandardInitialize();
ShouldSaveOnClose = true;
-
+
// Validate the path before loading
if (string.IsNullOrEmpty(imagePath))
{
@@ -154,6 +155,7 @@ private async Task LoadContentFromHistory(HistoryInfo history)
}
frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap);
+ hasLoadedImageSource = true;
GrabFrameImage.Source = frameContentImageSource;
FreezeGrabFrame();
@@ -1739,6 +1741,7 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul
frameContentImageSource = clipboardImage;
}
+ hasLoadedImageSource = true;
FreezeToggleButton.IsChecked = true;
FreezeGrabFrame();
FreezeToggleButton.Visibility = Visibility.Collapsed;
@@ -1949,18 +1952,38 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e =
UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.RemoveWordBorder,
new GrabFrameOperationArgs()
-{
- RemovingWordBorders = [.. wordBorders],
- WordBorders = wordBorders,
- GrabFrameCanvas = RectanglesCanvas
-});
+ {
+ RemovingWordBorders = [.. wordBorders],
+ WordBorders = wordBorders,
+ GrabFrameCanvas = RectanglesCanvas
+ });
- ResetGrabFrame();
+ if (hasLoadedImageSource)
+ {
+ // For loaded images, clear OCR results and re-run OCR on the same image.
+ // Zoom must be reset because the screen-capture-based OCR pipeline
+ // calculates word border positions assuming no zoom transform.
+ MainZoomBorder.Reset();
+ RectanglesCanvas.RenderTransform = Transform.Identity;
+ IsOcrValid = false;
+ ocrResultOfWindow = null;
+ RectanglesCanvas.Children.Clear();
+ wordBorders.Clear();
+ MatchesTXTBLK.Text = "- Matches";
+ UpdateFrameText();
+
+ // Allow WPF to repaint the unzoomed view before screen-capture OCR
+ await Task.Delay(200);
+ }
+ else
+ {
+ ResetGrabFrame();
- await Task.Delay(200);
+ await Task.Delay(200);
- frameContentImageSource = ImageMethods.GetWindowBoundsImage(this);
- GrabFrameImage.Source = frameContentImageSource;
+ frameContentImageSource = ImageMethods.GetWindowBoundsImage(this);
+ GrabFrameImage.Source = frameContentImageSource;
+ }
if (AutoOcrCheckBox.IsChecked is false)
FreezeGrabFrame();
@@ -2055,9 +2078,13 @@ private void ResetGrabFrame()
SetRefreshOrOcrFrameBtnVis();
MainZoomBorder.Reset();
+ RectanglesCanvas.RenderTransform = Transform.Identity;
IsOcrValid = false;
ocrResultOfWindow = null;
- frameContentImageSource = null;
+
+ if (!hasLoadedImageSource)
+ frameContentImageSource = null;
+
RectanglesCanvas.Children.Clear();
wordBorders.Clear();
MatchesTXTBLK.Text = "- Matches";
@@ -2109,6 +2136,7 @@ private void SetGrabFrameUserSettings()
DefaultSettings.GrabFrameUpdateEtw = AlwaysUpdateEtwCheckBox.IsChecked;
DefaultSettings.Save();
}
+
private void SetRefreshOrOcrFrameBtnVis()
{
if (AutoOcrCheckBox.IsChecked is false)
@@ -2170,6 +2198,7 @@ private async Task TryLoadImageFromPath(string path)
ImageMethods.RotateImage(droppedImage, rotateFlipType);
droppedImage.EndInit();
frameContentImageSource = droppedImage;
+ hasLoadedImageSource = true;
FreezeToggleButton.IsChecked = true;
FreezeGrabFrame();
FreezeToggleButton.Visibility = Visibility.Collapsed;
@@ -2178,6 +2207,7 @@ private async Task TryLoadImageFromPath(string path)
}
catch (Exception)
{
+ hasLoadedImageSource = false;
UnfreezeGrabFrame();
MessageBox.Show("Not an image");
}
@@ -2306,6 +2336,7 @@ private void UndoExecuted(object sender, ExecutedRoutedEventArgs e)
private void UnfreezeGrabFrame()
{
reDrawTimer.Stop();
+ hasLoadedImageSource = false;
ResetGrabFrame();
Topmost = true;
GrabFrameImage.Opacity = 0;
@@ -2499,7 +2530,6 @@ private void GrabTrimExecuted(object sender, ExecutedRoutedEventArgs e)
Close();
}
-
private void ScrollBehaviorMenuItem_Click(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem menuItem || !Enum.TryParse(menuItem.Tag.ToString(), out scrollBehavior))
From 7147cd245a48b9a93f3920e778d2f05a0bdf9ac4 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 25 Feb 2026 22:52:54 -0600
Subject: [PATCH 014/109] Code formatting
---
Text-Grab/Views/GrabFrame.xaml.cs | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 60953e89..f9db5caa 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -1952,11 +1952,11 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e =
UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.RemoveWordBorder,
new GrabFrameOperationArgs()
- {
- RemovingWordBorders = [.. wordBorders],
- WordBorders = wordBorders,
- GrabFrameCanvas = RectanglesCanvas
- });
+{
+ RemovingWordBorders = [.. wordBorders],
+ WordBorders = wordBorders,
+ GrabFrameCanvas = RectanglesCanvas
+});
if (hasLoadedImageSource)
{
From cc979facb066715c4f63d60a415e875a910dd87c Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 25 Feb 2026 23:27:33 -0600
Subject: [PATCH 015/109] Add date/time math support to calculator and tests
Implemented parsing and evaluation of date/time math expressions (e.g., "March 10, 2026 + 10 days", "today + 5 days") in the calculator logic. Supported a wide range of time units, fractional values, ordinal suffixes, and special keywords. Integrated this feature into the main calculation flow without breaking existing math, variable, or function support. Added a new CalculationService.DateTimeMath.cs for all related logic and comprehensive unit tests to ensure correctness and compatibility.
---
Tests/CalculatorTests.cs | 423 ++++++++++++++++++
.../CalculationService.DateTimeMath.cs | 222 +++++++++
Text-Grab/Services/CalculationService.cs | 10 +-
3 files changed, 652 insertions(+), 3 deletions(-)
create mode 100644 Text-Grab/Services/CalculationService.DateTimeMath.cs
diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs
index 829f88c0..09c52750 100644
--- a/Tests/CalculatorTests.cs
+++ b/Tests/CalculatorTests.cs
@@ -2349,4 +2349,427 @@ public async Task Percentage_WithSum_WorksCorrectly()
}
#endregion Percentage Tests
+
+ #region DateTime Math Tests
+
+ [Theory]
+ [InlineData("March 10, 2026 + 10 days", "3/20/2026")]
+ [InlineData("January 1, 2026 + 30 days", "1/31/2026")]
+ [InlineData("December 25, 2026 + 7 days", "1/1/2027")]
+ [InlineData("February 28, 2026 + 1 day", "3/1/2026")]
+ public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ Assert.Equal(expected, result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("January 1, 2026 + 2 weeks", "1/15/2026")]
+ [InlineData("March 1, 2026 + 1 week", "3/8/2026")]
+ [InlineData("March 14, 2026 - 2 weeks", "2/28/2026")]
+ public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ Assert.Equal(expected, result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("January 15, 2026 + 3 months", "4/15/2026")]
+ [InlineData("October 31, 2026 + 1 month", "11/30/2026")]
+ [InlineData("March 31, 2026 + 1 month", "4/30/2026")]
+ public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ Assert.Equal(expected, result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("January 1, 2020 + 5 years", "1/1/2025")]
+ [InlineData("February 29, 2024 + 1 year", "2/28/2025")]
+ [InlineData("June 15, 2026 + 10 years", "6/15/2036")]
+ public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ Assert.Equal(expected, result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("January 1, 2000 + 2 decades", "1/1/2020")]
+ [InlineData("June 15, 2010 + 1 decade", "6/15/2020")]
+ [InlineData("March 1, 2026 + 3 decades", "3/1/2056")]
+ public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ Assert.Equal(expected, result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("March 20, 2026 - 10 days", "3/10/2026")]
+ [InlineData("January 5, 2026 - 10 days", "12/26/2025")]
+ [InlineData("March 1, 2026 - 1 month", "2/1/2026")]
+ [InlineData("January 1, 2026 - 1 year", "1/1/2025")]
+ public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ 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);
+ Assert.Equal("3/20/2026", result.Output);
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ [Theory]
+ [InlineData("January 1st, 2026 + 5 days", "1/6/2026")]
+ [InlineData("February 2nd, 2026 + 5 days", "2/7/2026")]
+ [InlineData("March 3rd, 2026 + 5 days", "3/8/2026")]
+ [InlineData("April 4th, 2026 + 5 days", "4/9/2026")]
+ [InlineData("May 21st, 2026 + 1 day", "5/22/2026")]
+ public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ 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);
+ Assert.Equal("3/20/2026", 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);
+ Assert.Equal("3/20/2026", 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);
+ Assert.Contains("1/1/2026", 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);
+ Assert.Contains("1/1/2026", 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);
+ Assert.Contains("2/26/2026", 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);
+ Assert.Contains("1/2/2026", 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);
+ Assert.Equal("1/18/2026", 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);
+ Assert.Equal("3/27/2026", 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);
+ Assert.Equal("7/1/2027", 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);
+ Assert.Equal("3/12/2026", 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);
+ Assert.Contains("3/11/2026", 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);
+ Assert.Contains("1/1/2026", 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("3/20/2026", lines[0]);
+ Assert.Equal("8", lines[1]);
+ Assert.Equal("1/1/2027", 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", "1/6/2026")]
+ [InlineData("Feb 14, 2026 + 1 week", "2/21/2026")]
+ [InlineData("Dec 31, 2026 + 1 day", "1/1/2027")]
+ public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, string expected)
+ {
+ CalculationService service = new();
+ CalculationResult result = await service.EvaluateExpressionsAsync(input);
+ 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
+ Assert.Equal("12/15/2026", 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("3/15/2026", 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("7/5/2026", result.Output);
+ Assert.Contains("1:00pm", result.Output.ToLowerInvariant());
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ #endregion DateTime Math Tests
}
diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs
new file mode 100644
index 00000000..97e66224
--- /dev/null
+++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Text_Grab.Services;
+
+public partial class CalculationService
+{
+ ///
+ /// Attempts to evaluate a line as a date/time math expression.
+ /// Supports expressions like "March 10th + 10 days", "2/25/26 11:02pm + 800 mins", etc.
+ /// Supported units: days, weeks, months, years, decades, hours, minutes.
+ ///
+ /// The input line to evaluate
+ /// The formatted date/time result if successful
+ /// True if the line was successfully evaluated as a date/time math expression
+ public static bool TryEvaluateDateTimeMath(string line, out string result)
+ {
+ result = string.Empty;
+ if (string.IsNullOrWhiteSpace(line))
+ return false;
+
+ // Find all arithmetic operations with time units
+ MatchCollection matches = DateTimeArithmeticPattern().Matches(line);
+ if (matches.Count == 0)
+ return false;
+
+ // Everything before the first arithmetic match is the date part
+ string datePart = line[..matches[0].Index].Trim();
+
+ // Parse the base date
+ DateTime dateTime;
+ bool hasInputTime;
+
+ if (string.IsNullOrEmpty(datePart))
+ {
+ dateTime = DateTime.Today;
+ hasInputTime = false;
+ }
+ else if (!TryParseFlexibleDate(datePart, out dateTime, out hasInputTime))
+ {
+ return false;
+ }
+
+ // First pass: collect operations and detect characteristics
+ bool hasTimeUnits = false;
+ bool hasFractionalDayOrLarger = false;
+ List<(double Number, string Unit)> operations = [];
+
+ foreach (Match match in matches)
+ {
+ string op = match.Groups["op"].Value;
+ string numberStr = match.Groups["number"].Value;
+ string unit = match.Groups["unit"].Value.ToLowerInvariant();
+
+ if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number))
+ return false;
+
+ if (op == "-")
+ number = -number;
+
+ bool isTimeUnit = unit is "hour" or "hours" or "hr" or "hrs"
+ or "minute" or "minutes" or "min" or "mins";
+ if (isTimeUnit)
+ hasTimeUnits = true;
+ else if (number % 1 != 0)
+ hasFractionalDayOrLarger = true;
+
+ operations.Add((number, unit));
+ }
+
+ // If fractional day+ units and no explicit time, assume noon as starting time
+ if (hasFractionalDayOrLarger && !hasInputTime && dateTime.TimeOfDay == TimeSpan.Zero)
+ dateTime = dateTime.AddHours(12);
+
+ // Second pass: apply all operations
+ foreach ((double number, string unit) in operations)
+ dateTime = ApplyDateTimeOffset(dateTime, number, unit);
+
+ // Determine whether to include time in the output
+ bool showTime = hasInputTime || hasTimeUnits ||
+ (hasFractionalDayOrLarger && dateTime.TimeOfDay != TimeSpan.Zero);
+
+ result = FormatDateTimeResult(dateTime, showTime);
+ return true;
+ }
+
+ ///
+ /// Applies a numeric offset with a time unit to a DateTime.
+ ///
+ private static DateTime ApplyDateTimeOffset(DateTime dateTime, double number, string unit)
+ {
+ return unit switch
+ {
+ "decade" or "decades" => AddFractionalYears(dateTime, number * 10),
+ "year" or "years" => AddFractionalYears(dateTime, number),
+ "month" or "months" => AddFractionalMonths(dateTime, number),
+ "week" or "weeks" => dateTime.AddDays(number * 7),
+ "day" or "days" => dateTime.AddDays(number),
+ "hour" or "hours" or "hr" or "hrs" => dateTime.AddHours(number),
+ "minute" or "minutes" or "min" or "mins" => dateTime.AddMinutes(number),
+ _ => dateTime
+ };
+ }
+
+ private static DateTime AddFractionalYears(DateTime dateTime, double years)
+ {
+ int wholeYears = (int)years;
+ double fraction = years - wholeYears;
+
+ dateTime = dateTime.AddYears(wholeYears);
+ if (Math.Abs(fraction) > double.Epsilon)
+ dateTime = dateTime.AddDays(fraction * 365.25);
+
+ return dateTime;
+ }
+
+ private static DateTime AddFractionalMonths(DateTime dateTime, double months)
+ {
+ int wholeMonths = (int)months;
+ double fraction = months - wholeMonths;
+
+ dateTime = dateTime.AddMonths(wholeMonths);
+ if (Math.Abs(fraction) > double.Epsilon)
+ dateTime = dateTime.AddDays(fraction * 30.44);
+
+ return dateTime;
+ }
+
+ ///
+ /// Attempts to parse a date string flexibly, supporting various formats
+ /// including named months with ordinal suffixes, numeric dates, and special keywords.
+ /// Uses the current system culture for parsing.
+ ///
+ private static bool TryParseFlexibleDate(string input, out DateTime dateTime, out bool hasTime)
+ {
+ dateTime = default;
+ hasTime = false;
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ string cleaned = input.Trim();
+
+ // Handle special keywords
+ switch (cleaned.ToLowerInvariant())
+ {
+ case "today":
+ dateTime = DateTime.Today;
+ return true;
+ case "now":
+ dateTime = DateTime.Now;
+ hasTime = true;
+ return true;
+ case "tomorrow":
+ dateTime = DateTime.Today.AddDays(1);
+ return true;
+ case "yesterday":
+ dateTime = DateTime.Today.AddDays(-1);
+ return true;
+ }
+
+ // Remove ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
+ cleaned = OrdinalSuffixPattern().Replace(cleaned, "$1");
+
+ // Detect if input has a time component
+ hasTime = HasTimeComponent(input);
+
+ // Try parsing with current culture
+ if (DateTime.TryParse(cleaned, CultureInfo.CurrentCulture, DateTimeStyles.AllowWhiteSpaces, out dateTime))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Checks if a string contains a time component (am/pm indicators or HH:mm patterns).
+ ///
+ private static bool HasTimeComponent(string input)
+ {
+ // Check for am/pm indicators (e.g., 10am, 10 am, 10a.m., 10pm)
+ if (AmPmPattern().IsMatch(input))
+ return true;
+
+ // Check for colon-separated time (e.g., 11:02, 14:30)
+ if (ColonTimePattern().IsMatch(input))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Formats a DateTime result for display.
+ /// Uses the current culture's short date format for dates.
+ /// When time is included, appends 12-hour time with lowercase am/pm.
+ ///
+ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime)
+ {
+ CultureInfo culture = CultureInfo.CurrentCulture;
+
+ if (includeTime)
+ {
+ string datePart = dateTime.ToString("d", culture);
+ string timePart = dateTime.ToString("h:mmtt", culture).ToLowerInvariant();
+ return $"{datePart} {timePart}";
+ }
+
+ return dateTime.ToString("d", culture);
+ }
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern();
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"(\d+)(?:st|nd|rd|th)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex OrdinalSuffixPattern();
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"\d\s*[aApP]\.?[mM]\.?(?:\s|$|[^a-zA-Z])")]
+ private static partial System.Text.RegularExpressions.Regex AmPmPattern();
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"\d{1,2}:\d{2}")]
+ private static partial System.Text.RegularExpressions.Regex ColonTimePattern();
+}
diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs
index 342e28f2..76601016 100644
--- a/Text-Grab/Services/CalculationService.cs
+++ b/Text-Grab/Services/CalculationService.cs
@@ -61,11 +61,15 @@ public async Task EvaluateExpressionsAsync(string input)
try
{
- if (IsParameterAssignment(trimmedLine))
+ if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult))
+ {
+ results.Add(dateTimeResult);
+ }
+ else if (IsParameterAssignment(trimmedLine))
{
string resultLine = await HandleParameterAssignmentAsync(trimmedLine);
results.Add(resultLine);
-
+
// Extract variable name and add its value to output numbers
int equalIndex = trimmedLine.IndexOf('=');
string variableName = trimmedLine[..equalIndex].Trim();
@@ -86,7 +90,7 @@ public async Task EvaluateExpressionsAsync(string input)
{
string resultLine = await EvaluateStandardExpressionAsync(trimmedLine);
results.Add(resultLine);
-
+
// Try to parse the result as a number and add to output numbers
// Remove formatting characters before parsing
string cleanedResult = resultLine.Replace(",", "").Replace(" ", "").Trim();
From f9258d28c9f33619176390648487b41a85f072d4 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 25 Feb 2026 23:37:16 -0600
Subject: [PATCH 016/109] Add support for combined duration segments in date
math
Enhances date/time math parsing to allow combined duration segments
(e.g., "today + 5 weeks 3 days 8 hours"), where multiple units can
be chained after a single operator and inherit the previous operator.
Introduces a new regex for parsing these segments and updates the
evaluation logic accordingly. Adds comprehensive unit tests for
various combined segment scenarios and real-world use cases.
Existing explicit-operator and regular math expressions remain
fully supported. Improves natural language flexibility for users.
---
Tests/CalculatorTests.cs | 219 ++++++++++++++++++
.../CalculationService.DateTimeMath.cs | 37 ++-
2 files changed, 245 insertions(+), 11 deletions(-)
diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs
index 09c52750..d0c89596 100644
--- a/Tests/CalculatorTests.cs
+++ b/Tests/CalculatorTests.cs
@@ -2772,4 +2772,223 @@ public async Task DateTimeMath_RealWorldExample_FlightArrival()
}
#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("2/8/2026", 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("7/1/2027", 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("3/27/2026", 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("1/1/2026", 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("2/18/2026", 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("3/11/2027", 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("4/5/2026", 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("3/20/2026", 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("6/23/2026", 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("4/20/2032", 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("3/2/2026", 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("1/18/2026", 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("3/22/2027", 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("3/19/2026", 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("1/11/2026", result.Output);
+ Assert.Contains("9:15pm", result.Output.ToLowerInvariant());
+ Assert.Equal(0, result.ErrorCount);
+ }
+
+ #endregion Combined Duration Segment Tests
}
diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs
index 97e66224..3a6b1908 100644
--- a/Text-Grab/Services/CalculationService.DateTimeMath.cs
+++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs
@@ -10,6 +10,7 @@ public partial class CalculationService
///
/// Attempts to evaluate a line as a date/time math expression.
/// Supports expressions like "March 10th + 10 days", "2/25/26 11:02pm + 800 mins", etc.
+ /// Also supports combined duration segments: "today + 5 weeks 3 days 8 hours".
/// Supported units: days, weeks, months, years, decades, hours, minutes.
///
/// The input line to evaluate
@@ -21,13 +22,13 @@ public static bool TryEvaluateDateTimeMath(string line, out string result)
if (string.IsNullOrWhiteSpace(line))
return false;
- // Find all arithmetic operations with time units
- MatchCollection matches = DateTimeArithmeticPattern().Matches(line);
- if (matches.Count == 0)
+ // Find the first explicit arithmetic operation (requires +/-) to anchor where arithmetic starts
+ Match anchorMatch = DateTimeArithmeticPattern().Match(line);
+ if (!anchorMatch.Success)
return false;
// Everything before the first arithmetic match is the date part
- string datePart = line[..matches[0].Index].Trim();
+ string datePart = line[..anchorMatch.Index].Trim();
// Parse the base date
DateTime dateTime;
@@ -43,21 +44,32 @@ public static bool TryEvaluateDateTimeMath(string line, out string result)
return false;
}
- // First pass: collect operations and detect characteristics
+ // Parse all duration segments from the arithmetic portion.
+ // Supports both explicit operators (+ 5 days - 2 hours) and combined
+ // segments where implicit entries inherit the previous operator (+ 5 weeks 3 days 8 hours).
+ string arithmeticPortion = line[anchorMatch.Index..];
+ MatchCollection segments = DateTimeDurationSegmentPattern().Matches(arithmeticPortion);
+ if (segments.Count == 0)
+ return false;
+
bool hasTimeUnits = false;
bool hasFractionalDayOrLarger = false;
List<(double Number, string Unit)> operations = [];
+ string currentOp = "+";
- foreach (Match match in matches)
+ foreach (Match segment in segments)
{
- string op = match.Groups["op"].Value;
- string numberStr = match.Groups["number"].Value;
- string unit = match.Groups["unit"].Value.ToLowerInvariant();
+ string opValue = segment.Groups["op"].Value;
+ if (!string.IsNullOrEmpty(opValue))
+ currentOp = opValue;
+
+ string numberStr = segment.Groups["number"].Value;
+ string unit = segment.Groups["unit"].Value.ToLowerInvariant();
if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number))
return false;
- if (op == "-")
+ if (currentOp == "-")
number = -number;
bool isTimeUnit = unit is "hour" or "hours" or "hr" or "hrs"
@@ -74,7 +86,7 @@ public static bool TryEvaluateDateTimeMath(string line, out string result)
if (hasFractionalDayOrLarger && !hasInputTime && dateTime.TimeOfDay == TimeSpan.Zero)
dateTime = dateTime.AddHours(12);
- // Second pass: apply all operations
+ // Apply all operations
foreach ((double number, string unit) in operations)
dateTime = ApplyDateTimeOffset(dateTime, number, unit);
@@ -211,6 +223,9 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime)
[System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern();
+ [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])?\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex DateTimeDurationSegmentPattern();
+
[System.Text.RegularExpressions.GeneratedRegex(@"(\d+)(?:st|nd|rd|th)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex OrdinalSuffixPattern();
From 70888bc10fa5fd6545aaad3b78a2a22e3efcf07f Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Thu, 26 Feb 2026 17:56:12 -0600
Subject: [PATCH 017/109] Enhance DateTime Math Evaluation and Subtraction
Support
- Added a new method to evaluate date/time math expressions with an optional base DateTime.
- Implemented date subtraction functionality, allowing expressions like "March 10th - January 1st".
- Introduced a human-readable format for the duration between two dates.
- Updated the CalculationService to handle previous results for both numeric and date/time evaluations.
- Improved handling of binary operators to allow continuation from previous results.
---
Tests/CalculatorTests.cs | 1091 ++++++++++++-----
.../CalculationService.DateTimeMath.cs | 120 +-
Text-Grab/Services/CalculationService.cs | 55 +-
3 files changed, 968 insertions(+), 298 deletions(-)
diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs
index d0c89596..b40a1601 100644
--- a/Tests/CalculatorTests.cs
+++ b/Tests/CalculatorTests.cs
@@ -1960,395 +1960,395 @@ 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
+ #endregion Percentage Tests
#region DateTime Math Tests
@@ -2991,4 +2991,505 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary()
}
#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("3/15/2026", lines[0]);
+ // March 15 + 1 month = April 15
+ Assert.Contains("4/15/2026", 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("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1
+ Assert.Contains("3/1/2026", lines[1]); // Feb 1 + 1 month = Mar 1
+ Assert.Contains("4/1/2026", 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("4/15/2026", lines[0]); // March 15 + 1 month = April 15
+ Assert.Contains("4/8/2026", 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("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1
+ Assert.Equal("", lines[1]); // comment
+ Assert.Contains("3/1/2026", 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("2/1/2026", 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/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs
index 3a6b1908..2593fb3b 100644
--- a/Text-Grab/Services/CalculationService.DateTimeMath.cs
+++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs
@@ -17,11 +17,30 @@ public partial class CalculationService
/// The formatted date/time result if successful
/// True if the line was successfully evaluated as a date/time math expression
public static bool TryEvaluateDateTimeMath(string line, out string result)
+ {
+ return TryEvaluateDateTimeMath(line, out result, out _, null);
+ }
+
+ ///
+ /// Attempts to evaluate a line as a date/time math expression, optionally using a previous
+ /// DateTime result as the base when the line starts with an operator (e.g. "+ 2 weeks").
+ ///
+ /// The input line to evaluate
+ /// The formatted date/time result if successful
+ /// The parsed DateTime value of the result, for use as a base in subsequent lines
+ /// An optional base DateTime from a previous line's result, used when the date part is empty
+ /// True if the line was successfully evaluated as a date/time math expression
+ public static bool TryEvaluateDateTimeMath(string line, out string result, out DateTime? parsedDateTime, DateTime? baseDateTime)
{
result = string.Empty;
+ parsedDateTime = null;
if (string.IsNullOrWhiteSpace(line))
return false;
+ // Try date subtraction first (date - date = timespan)
+ if (TryEvaluateDateSubtraction(line, out result))
+ return true;
+
// Find the first explicit arithmetic operation (requires +/-) to anchor where arithmetic starts
Match anchorMatch = DateTimeArithmeticPattern().Match(line);
if (!anchorMatch.Success)
@@ -36,8 +55,17 @@ public static bool TryEvaluateDateTimeMath(string line, out string result)
if (string.IsNullOrEmpty(datePart))
{
- dateTime = DateTime.Today;
- hasInputTime = false;
+ if (baseDateTime.HasValue)
+ {
+ // Use the previous line's DateTime result as the base
+ dateTime = baseDateTime.Value;
+ hasInputTime = dateTime.TimeOfDay != TimeSpan.Zero;
+ }
+ else
+ {
+ dateTime = DateTime.Today;
+ hasInputTime = false;
+ }
}
else if (!TryParseFlexibleDate(datePart, out dateTime, out hasInputTime))
{
@@ -95,6 +123,7 @@ public static bool TryEvaluateDateTimeMath(string line, out string result)
(hasFractionalDayOrLarger && dateTime.TimeOfDay != TimeSpan.Zero);
result = FormatDateTimeResult(dateTime, showTime);
+ parsedDateTime = dateTime;
return true;
}
@@ -220,6 +249,90 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime)
return dateTime.ToString("d", culture);
}
+ ///
+ /// Attempts to evaluate a line as a date subtraction expression (date - date = timespan).
+ /// Supports expressions like "March 10th - January 1st", "today - yesterday", etc.
+ /// Returns the duration between the two dates in whole units down to seconds.
+ ///
+ private static bool TryEvaluateDateSubtraction(string line, out string result)
+ {
+ result = string.Empty;
+
+ MatchCollection matches = DateSubtractionSplitPattern().Matches(line);
+ if (matches.Count == 0)
+ return false;
+
+ foreach (Match splitMatch in matches)
+ {
+ string leftPart = line[..splitMatch.Index].Trim();
+ string rightPart = line[(splitMatch.Index + splitMatch.Length)..].Trim();
+
+ if (string.IsNullOrEmpty(leftPart) || string.IsNullOrEmpty(rightPart))
+ continue;
+
+ if (!TryParseFlexibleDate(leftPart, out DateTime date1, out _))
+ continue;
+ if (!TryParseFlexibleDate(rightPart, out DateTime date2, out _))
+ continue;
+
+ DateTime earlier, later;
+ if (date1 >= date2)
+ {
+ later = date1;
+ earlier = date2;
+ }
+ else
+ {
+ later = date2;
+ earlier = date1;
+ }
+
+ result = FormatTimeSpanHumanReadable(earlier, later);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Formats the difference between two dates as a human-readable string
+ /// with whole units from years down to seconds (e.g., "2 weeks 3 days 2 hours").
+ /// Only non-zero components are included.
+ ///
+ private static string FormatTimeSpanHumanReadable(DateTime earlier, DateTime later)
+ {
+ int years = later.Year - earlier.Year;
+ DateTime temp = earlier.AddYears(years);
+ if (temp > later)
+ {
+ years--;
+ temp = earlier.AddYears(years);
+ }
+
+ int months = 0;
+ while (temp.AddMonths(months + 1) <= later)
+ months++;
+ temp = temp.AddMonths(months);
+
+ TimeSpan remaining = later - temp;
+ int weeks = remaining.Days / 7;
+ int days = remaining.Days % 7;
+ int hours = remaining.Hours;
+ int minutes = remaining.Minutes;
+ int seconds = remaining.Seconds;
+
+ List parts = [];
+ if (years > 0) parts.Add($"{years} {(years == 1 ? "year" : "years")}");
+ if (months > 0) parts.Add($"{months} {(months == 1 ? "month" : "months")}");
+ if (weeks > 0) parts.Add($"{weeks} {(weeks == 1 ? "week" : "weeks")}");
+ if (days > 0) parts.Add($"{days} {(days == 1 ? "day" : "days")}");
+ if (hours > 0) parts.Add($"{hours} {(hours == 1 ? "hour" : "hours")}");
+ if (minutes > 0) parts.Add($"{minutes} {(minutes == 1 ? "minute" : "minutes")}");
+ if (seconds > 0) parts.Add($"{seconds} {(seconds == 1 ? "second" : "seconds")}");
+
+ return parts.Count == 0 ? "0 seconds" : string.Join(" ", parts);
+ }
+
[System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern();
@@ -234,4 +347,7 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime)
[System.Text.RegularExpressions.GeneratedRegex(@"\d{1,2}:\d{2}")]
private static partial System.Text.RegularExpressions.Regex ColonTimePattern();
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"\s+-\s+")]
+ private static partial System.Text.RegularExpressions.Regex DateSubtractionSplitPattern();
}
diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs
index 76601016..d3a5a70f 100644
--- a/Text-Grab/Services/CalculationService.cs
+++ b/Text-Grab/Services/CalculationService.cs
@@ -46,6 +46,8 @@ public async Task EvaluateExpressionsAsync(string input)
List results = [];
List outputNumbers = [];
int errorCount = 0;
+ double? previousLineResult = null;
+ DateTime? previousDateTimeResult = null;
// Clear parameters and rebuild from scratch for each evaluation
_parameters.Clear();
@@ -59,16 +61,27 @@ public async Task EvaluateExpressionsAsync(string input)
continue;
}
+ // If the line starts with a binary operator and we have a previous numeric result,
+ // prepend the previous result to form a complete expression
+ if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && StartsWithBinaryOperator(trimmedLine))
+ {
+ string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture);
+ trimmedLine = previousValueStr + " " + trimmedLine;
+ }
+
try
{
- if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult))
+ if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult, out DateTime? parsedDateTime, previousDateTimeResult))
{
results.Add(dateTimeResult);
+ previousDateTimeResult = parsedDateTime;
+ previousLineResult = null;
}
else if (IsParameterAssignment(trimmedLine))
{
string resultLine = await HandleParameterAssignmentAsync(trimmedLine);
results.Add(resultLine);
+ previousDateTimeResult = null;
// Extract variable name and add its value to output numbers
int equalIndex = trimmedLine.IndexOf('=');
@@ -79,17 +92,24 @@ public async Task EvaluateExpressionsAsync(string input)
{
double numValue = Convert.ToDouble(value);
outputNumbers.Add(numValue);
+ previousLineResult = numValue;
}
catch
{
// Skip non-numeric values
+ previousLineResult = null;
}
}
+ else
+ {
+ previousLineResult = null;
+ }
}
else
{
string resultLine = await EvaluateStandardExpressionAsync(trimmedLine);
results.Add(resultLine);
+ previousDateTimeResult = null;
// Try to parse the result as a number and add to output numbers
// Remove formatting characters before parsing
@@ -97,6 +117,11 @@ public async Task EvaluateExpressionsAsync(string input)
if (double.TryParse(cleanedResult, NumberStyles.Any, CultureInfo.InvariantCulture, out double numValue))
{
outputNumbers.Add(numValue);
+ previousLineResult = numValue;
+ }
+ else
+ {
+ previousLineResult = null;
}
}
}
@@ -111,6 +136,8 @@ public async Task EvaluateExpressionsAsync(string input)
results.Add(""); // Empty line when errors are hidden
}
errorCount++;
+ previousLineResult = null;
+ previousDateTimeResult = null;
}
}
@@ -551,6 +578,32 @@ private static void RegisterCustomFunctions(AsyncExpression expression)
};
}
+ ///
+ /// Checks if a line starts with a binary operator that could continue from a previous result.
+ /// For * / (which cannot be unary), matches when followed by a space, digit, paren, or letter.
+ /// For + - (which can be unary), only matches when followed by a space to distinguish from
+ /// negative numbers like "-3" or unary plus like "+5".
+ ///
+ public static bool StartsWithBinaryOperator(string trimmedLine)
+ {
+ if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.Length < 2)
+ return false;
+
+ char first = trimmedLine[0];
+ char second = trimmedLine[1];
+
+ // * / always require a left operand — treat as continuation operator
+ if (first is '*' or '/')
+ return second == ' ' || char.IsDigit(second) || second == '(' || char.IsLetter(second);
+
+ // + - can be unary, so only treat as continuation when followed by a space
+ // This distinguishes "- 3" (continuation) from "-3" (negative number)
+ if (first is '+' or '-')
+ return second == ' ';
+
+ return false;
+ }
+
[System.Text.RegularExpressions.GeneratedRegex(@"(\d)\.(?=\d{3}(?:\.|,|\D|$))")]
private static partial System.Text.RegularExpressions.Regex DigitGroupSeparator();
[System.Text.RegularExpressions.GeneratedRegex(@"(\d),(?=\d{3}(?:,|\D|$))")]
From 8da968c5b27b1c544a5cab750d132c656458b957 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Thu, 26 Feb 2026 18:40:31 -0600
Subject: [PATCH 018/109] Add unit conversion functionality and corresponding
unit tests
- Implemented CalculationService.UnitMath to handle various unit conversions including length, mass, temperature, volume, speed, and area.
- Created a comprehensive unit test suite in UnitConversionTests.cs to validate conversion accuracy and edge cases.
- Added support for explicit conversions, continuation conversions, and operator continuations.
- Enhanced unit recognition with plural forms and aliases, including British spellings.
- Established regex patterns for parsing unit expressions and operations.
---
Tests/UnitConversionTests.cs | 445 +++++++++++++
.../Services/CalculationService.UnitMath.cs | 602 ++++++++++++++++++
Text-Grab/Services/CalculationService.cs | 46 +-
Text-Grab/Text-Grab.csproj | 1 +
Text-Grab/Views/EditTextWindow.xaml | 478 +++++++-------
5 files changed, 1353 insertions(+), 219 deletions(-)
create mode 100644 Tests/UnitConversionTests.cs
create mode 100644 Text-Grab/Services/CalculationService.UnitMath.cs
diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs
new file mode 100644
index 00000000..90f8ef34
--- /dev/null
+++ b/Tests/UnitConversionTests.cs
@@ -0,0 +1,445 @@
+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("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")]
+ // 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("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/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs
new file mode 100644
index 00000000..6c2d65f0
--- /dev/null
+++ b/Text-Grab/Services/CalculationService.UnitMath.cs
@@ -0,0 +1,602 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using UnitsNet;
+using UnitsNet.Units;
+
+namespace Text_Grab.Services;
+
+public partial class CalculationService
+{
+ ///
+ /// Stores information about a resolved measurement unit, mapping to a UnitsNet enum value.
+ ///
+ private readonly record struct UnitInfo(Enum Unit, string QuantityName, string Abbreviation);
+
+ ///
+ /// Represents the result of a unit-bearing evaluation for tracking across lines.
+ /// Used for operator continuation (e.g., "5 km" then "+ 3 km" or "to miles").
+ ///
+ public class UnitResult
+ {
+ /// The numeric value in the current unit.
+ public double Value { get; set; }
+
+ /// The UnitsNet unit enum value (e.g., LengthUnit.Kilometer).
+ public Enum Unit { get; set; } = default!;
+
+ /// The quantity type name (e.g., "Length", "Mass").
+ public string QuantityName { get; set; } = string.Empty;
+
+ /// The display abbreviation (e.g., "km", "lb").
+ public string Abbreviation { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Maps common unit names and abbreviations to UnitsNet enum values.
+ /// Keys are case-insensitive. Includes singular, plural, and abbreviation forms.
+ ///
+ private static readonly Dictionary _unitLookup =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ // ══════════════════════════════════════════════════════════════
+ // LENGTH
+ // ══════════════════════════════════════════════════════════════
+ { "m", new(LengthUnit.Meter, "Length", "m") },
+ { "meter", new(LengthUnit.Meter, "Length", "m") },
+ { "meters", new(LengthUnit.Meter, "Length", "m") },
+ { "cm", new(LengthUnit.Centimeter, "Length", "cm") },
+ { "centimeter", new(LengthUnit.Centimeter, "Length", "cm") },
+ { "centimeters", new(LengthUnit.Centimeter, "Length", "cm") },
+ { "mm", new(LengthUnit.Millimeter, "Length", "mm") },
+ { "millimeter", new(LengthUnit.Millimeter, "Length", "mm") },
+ { "millimeters", new(LengthUnit.Millimeter, "Length", "mm") },
+ { "km", new(LengthUnit.Kilometer, "Length", "km") },
+ { "kilometer", new(LengthUnit.Kilometer, "Length", "km") },
+ { "kilometers", new(LengthUnit.Kilometer, "Length", "km") },
+ { "in", new(LengthUnit.Inch, "Length", "in") },
+ { "inch", new(LengthUnit.Inch, "Length", "in") },
+ { "inches", new(LengthUnit.Inch, "Length", "in") },
+ { "ft", new(LengthUnit.Foot, "Length", "ft") },
+ { "foot", new(LengthUnit.Foot, "Length", "ft") },
+ { "feet", new(LengthUnit.Foot, "Length", "ft") },
+ { "yd", new(LengthUnit.Yard, "Length", "yd") },
+ { "yard", new(LengthUnit.Yard, "Length", "yd") },
+ { "yards", new(LengthUnit.Yard, "Length", "yd") },
+ { "mi", new(LengthUnit.Mile, "Length", "mi") },
+ { "mile", new(LengthUnit.Mile, "Length", "mi") },
+ { "miles", new(LengthUnit.Mile, "Length", "mi") },
+ { "nmi", new(LengthUnit.NauticalMile, "Length", "nmi") },
+ { "nautical mile", new(LengthUnit.NauticalMile, "Length", "nmi") },
+ { "nautical miles", new(LengthUnit.NauticalMile, "Length", "nmi") },
+
+ // ══════════════════════════════════════════════════════════════
+ // MASS
+ // ══════════════════════════════════════════════════════════════
+ { "g", new(MassUnit.Gram, "Mass", "g") },
+ { "gram", new(MassUnit.Gram, "Mass", "g") },
+ { "grams", new(MassUnit.Gram, "Mass", "g") },
+ { "kg", new(MassUnit.Kilogram, "Mass", "kg") },
+ { "kilogram", new(MassUnit.Kilogram, "Mass", "kg") },
+ { "kilograms", new(MassUnit.Kilogram, "Mass", "kg") },
+ { "mg", new(MassUnit.Milligram, "Mass", "mg") },
+ { "milligram", new(MassUnit.Milligram, "Mass", "mg") },
+ { "milligrams", new(MassUnit.Milligram, "Mass", "mg") },
+ { "lb", new(MassUnit.Pound, "Mass", "lb") },
+ { "lbs", new(MassUnit.Pound, "Mass", "lb") },
+ { "pound", new(MassUnit.Pound, "Mass", "lb") },
+ { "pounds", new(MassUnit.Pound, "Mass", "lb") },
+ { "oz", new(MassUnit.Ounce, "Mass", "oz") },
+ { "ounce", new(MassUnit.Ounce, "Mass", "oz") },
+ { "ounces", new(MassUnit.Ounce, "Mass", "oz") },
+ { "ton", new(MassUnit.ShortTon, "Mass", "short tn") },
+ { "tons", new(MassUnit.ShortTon, "Mass", "short tn") },
+ { "short ton", new(MassUnit.ShortTon, "Mass", "short tn") },
+ { "short tons", new(MassUnit.ShortTon, "Mass", "short tn") },
+ { "tonne", new(MassUnit.Tonne, "Mass", "t") },
+ { "tonnes", new(MassUnit.Tonne, "Mass", "t") },
+ { "metric ton", new(MassUnit.Tonne, "Mass", "t") },
+ { "metric tons", new(MassUnit.Tonne, "Mass", "t") },
+ { "st", new(MassUnit.Stone, "Mass", "st") },
+ { "stone", new(MassUnit.Stone, "Mass", "st") },
+ { "stones", new(MassUnit.Stone, "Mass", "st") },
+
+ // ══════════════════════════════════════════════════════════════
+ // TEMPERATURE
+ // ══════════════════════════════════════════════════════════════
+ { "celsius", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
+ { "°C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
+ { "degC", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
+ { "fahrenheit", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
+ { "°F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
+ { "degF", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
+ { "kelvin", new(TemperatureUnit.Kelvin, "Temperature", "K") },
+
+ // ══════════════════════════════════════════════════════════════
+ // VOLUME
+ // ══════════════════════════════════════════════════════════════
+ { "liter", new(VolumeUnit.Liter, "Volume", "L") },
+ { "liters", new(VolumeUnit.Liter, "Volume", "L") },
+ { "litre", new(VolumeUnit.Liter, "Volume", "L") },
+ { "litres", new(VolumeUnit.Liter, "Volume", "L") },
+ { "L", new(VolumeUnit.Liter, "Volume", "L") },
+ { "mL", new(VolumeUnit.Milliliter, "Volume", "mL") },
+ { "milliliter", new(VolumeUnit.Milliliter, "Volume", "mL") },
+ { "milliliters", new(VolumeUnit.Milliliter, "Volume", "mL") },
+ { "millilitre", new(VolumeUnit.Milliliter, "Volume", "mL") },
+ { "millilitres", new(VolumeUnit.Milliliter, "Volume", "mL") },
+ { "gal", new(VolumeUnit.UsGallon, "Volume", "gal") },
+ { "gallon", new(VolumeUnit.UsGallon, "Volume", "gal") },
+ { "gallons", new(VolumeUnit.UsGallon, "Volume", "gal") },
+ { "qt", new(VolumeUnit.UsQuart, "Volume", "qt") },
+ { "quart", new(VolumeUnit.UsQuart, "Volume", "qt") },
+ { "quarts", new(VolumeUnit.UsQuart, "Volume", "qt") },
+ { "pt", new(VolumeUnit.UsPint, "Volume", "pt") },
+ { "pint", new(VolumeUnit.UsPint, "Volume", "pt") },
+ { "pints", new(VolumeUnit.UsPint, "Volume", "pt") },
+ { "cup", new(VolumeUnit.UsCustomaryCup, "Volume", "cup") },
+ { "cups", new(VolumeUnit.UsCustomaryCup, "Volume", "cup") },
+ { "fl oz", new(VolumeUnit.UsOunce, "Volume", "fl oz") },
+ { "floz", new(VolumeUnit.UsOunce, "Volume", "fl oz") },
+ { "fluid ounce", new(VolumeUnit.UsOunce, "Volume", "fl oz") },
+ { "fluid ounces", new(VolumeUnit.UsOunce, "Volume", "fl oz") },
+ { "tbsp", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") },
+ { "tablespoon", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") },
+ { "tablespoons", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") },
+ { "tsp", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") },
+ { "teaspoon", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") },
+ { "teaspoons", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") },
+
+ // ══════════════════════════════════════════════════════════════
+ // SPEED
+ // ══════════════════════════════════════════════════════════════
+ { "mph", new(SpeedUnit.MilePerHour, "Speed", "mph") },
+ { "miles per hour", new(SpeedUnit.MilePerHour, "Speed", "mph") },
+ { "km/h", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") },
+ { "kph", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") },
+ { "kilometers per hour", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") },
+ { "m/s", new(SpeedUnit.MeterPerSecond, "Speed", "m/s") },
+ { "meters per second", new(SpeedUnit.MeterPerSecond, "Speed", "m/s") },
+ { "knot", new(SpeedUnit.Knot, "Speed", "kn") },
+ { "knots", new(SpeedUnit.Knot, "Speed", "kn") },
+ { "kn", new(SpeedUnit.Knot, "Speed", "kn") },
+
+ // ══════════════════════════════════════════════════════════════
+ // AREA
+ // ══════════════════════════════════════════════════════════════
+ { "m²", new(AreaUnit.SquareMeter, "Area", "m²") },
+ { "sq m", new(AreaUnit.SquareMeter, "Area", "m²") },
+ { "square meter", new(AreaUnit.SquareMeter, "Area", "m²") },
+ { "square meters", new(AreaUnit.SquareMeter, "Area", "m²") },
+ { "km²", new(AreaUnit.SquareKilometer, "Area", "km²") },
+ { "sq km", new(AreaUnit.SquareKilometer, "Area", "km²") },
+ { "square kilometer", new(AreaUnit.SquareKilometer, "Area", "km²") },
+ { "square kilometers", new(AreaUnit.SquareKilometer, "Area", "km²") },
+ { "ft²", new(AreaUnit.SquareFoot, "Area", "ft²") },
+ { "sq ft", new(AreaUnit.SquareFoot, "Area", "ft²") },
+ { "square foot", new(AreaUnit.SquareFoot, "Area", "ft²") },
+ { "square feet", new(AreaUnit.SquareFoot, "Area", "ft²") },
+ { "mi²", new(AreaUnit.SquareMile, "Area", "mi²") },
+ { "sq mi", new(AreaUnit.SquareMile, "Area", "mi²") },
+ { "square mile", new(AreaUnit.SquareMile, "Area", "mi²") },
+ { "square miles", new(AreaUnit.SquareMile, "Area", "mi²") },
+ { "in²", new(AreaUnit.SquareInch, "Area", "in²") },
+ { "sq in", new(AreaUnit.SquareInch, "Area", "in²") },
+ { "square inch", new(AreaUnit.SquareInch, "Area", "in²") },
+ { "square inches", new(AreaUnit.SquareInch, "Area", "in²") },
+ { "yd²", new(AreaUnit.SquareYard, "Area", "yd²") },
+ { "sq yd", new(AreaUnit.SquareYard, "Area", "yd²") },
+ { "square yard", new(AreaUnit.SquareYard, "Area", "yd²") },
+ { "square yards", new(AreaUnit.SquareYard, "Area", "yd²") },
+ { "cm²", new(AreaUnit.SquareCentimeter, "Area", "cm²") },
+ { "sq cm", new(AreaUnit.SquareCentimeter, "Area", "cm²") },
+ { "acre", new(AreaUnit.Acre, "Area", "ac") },
+ { "acres", new(AreaUnit.Acre, "Area", "ac") },
+ { "ac", new(AreaUnit.Acre, "Area", "ac") },
+ { "hectare", new(AreaUnit.Hectare, "Area", "ha") },
+ { "hectares", new(AreaUnit.Hectare, "Area", "ha") },
+ { "ha", new(AreaUnit.Hectare, "Area", "ha") },
+ };
+
+ ///
+ /// Attempts to evaluate a line as a unit conversion or unit-bearing expression.
+ /// Supports patterns:
+ ///
+ /// - Explicit conversion: "5 miles to km", "100°F in celsius"
+ /// - Continuation conversion: "to km" (from previous unit result)
+ /// - Operator with units: "+ 3 km", "- 5 miles" (from previous unit result)
+ /// - Scale operator: "* 2", "/ 3" (from previous unit result, preserves unit)
+ /// - Standalone unit: "5 meters" (tracked for future continuation)
+ ///
+ ///
+ /// The input line to evaluate
+ /// The formatted display result if successful
+ /// The parsed unit result for tracking across lines
+ /// The previous line's unit result for continuation
+ /// True if the line was successfully evaluated as a unit expression
+ public bool TryEvaluateUnitConversion(
+ string line,
+ out string result,
+ out UnitResult? unitResult,
+ UnitResult? previousUnitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ if (string.IsNullOrWhiteSpace(line))
+ return false;
+
+ string trimmed = line.Trim();
+
+ // 1. Continuation conversion: "to km" / "in feet" (requires previous unit result)
+ if (previousUnitResult is not null
+ && TryContinuationConversion(trimmed, previousUnitResult, out result, out unitResult))
+ return true;
+
+ // 2. Operator with unit: "+ 3 km", "- 5 miles" (requires previous unit result)
+ if (previousUnitResult is not null
+ && TryOperatorWithUnit(trimmed, previousUnitResult, out result, out unitResult))
+ return true;
+
+ // 3. Scale operator: "* 2", "/ 3" (requires previous unit result, preserves unit)
+ if (previousUnitResult is not null
+ && TryScaleOperator(trimmed, previousUnitResult, out result, out unitResult))
+ return true;
+
+ // 4. Explicit conversion: "5 miles to km", "100°F in celsius"
+ if (TryExplicitConversion(trimmed, out result, out unitResult))
+ return true;
+
+ // 5. Standalone unit: "5 meters" (track for future continuation)
+ if (TryStandaloneUnit(trimmed, out result, out unitResult))
+ return true;
+
+ return false;
+ }
+
+ #region Unit Conversion Helpers
+
+ ///
+ /// Handles "to km" or "in feet" when there is a previous unit result to convert from.
+ ///
+ private bool TryContinuationConversion(
+ string trimmed,
+ UnitResult previous,
+ out string result,
+ out UnitResult? unitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ System.Text.RegularExpressions.Match match = ContinuationConversionPattern().Match(trimmed);
+ if (!match.Success)
+ return false;
+
+ string targetStr = match.Groups[1].Value.Trim();
+ if (!TryResolveUnit(targetStr, out UnitInfo target))
+ return false;
+
+ // Ensure compatible quantity types (e.g., both are Length)
+ if (previous.Unit.GetType() != target.Unit.GetType())
+ return false;
+
+ try
+ {
+ IQuantity source = Quantity.From(previous.Value, previous.Unit);
+ IQuantity converted = source.ToUnit(target.Unit);
+ double convertedValue = (double)converted.Value;
+
+ unitResult = new UnitResult
+ {
+ Value = convertedValue,
+ Unit = target.Unit,
+ QuantityName = target.QuantityName,
+ Abbreviation = target.Abbreviation
+ };
+ result = FormatUnitValue(convertedValue, target.Abbreviation);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Handles "+ 3 km" or "- 5 miles" operator continuation with units.
+ /// Converts the operand to the previous unit before adding/subtracting.
+ ///
+ private bool TryOperatorWithUnit(
+ string trimmed,
+ UnitResult previous,
+ out string result,
+ out UnitResult? unitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ System.Text.RegularExpressions.Match match = OperatorWithUnitPattern().Match(trimmed);
+ if (!match.Success)
+ return false;
+
+ string op = match.Groups["op"].Value;
+ string numberStr = match.Groups["number"].Value;
+ string unitStr = match.Groups["unit"].Value.Trim();
+
+ if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number))
+ return false;
+
+ if (!TryResolveUnit(unitStr, out UnitInfo operandUnit))
+ return false;
+
+ // Must be same quantity type
+ if (previous.Unit.GetType() != operandUnit.Unit.GetType())
+ return false;
+
+ try
+ {
+ // Convert operand to the previous result's unit
+ double operandInPreviousUnit;
+ if (operandUnit.Unit.Equals(previous.Unit))
+ {
+ operandInPreviousUnit = number;
+ }
+ else
+ {
+ IQuantity operandQuantity = Quantity.From(number, operandUnit.Unit);
+ IQuantity converted = operandQuantity.ToUnit(previous.Unit);
+ operandInPreviousUnit = (double)converted.Value;
+ }
+
+ double newValue = op == "+"
+ ? previous.Value + operandInPreviousUnit
+ : previous.Value - operandInPreviousUnit;
+
+ unitResult = new UnitResult
+ {
+ Value = newValue,
+ Unit = previous.Unit,
+ QuantityName = previous.QuantityName,
+ Abbreviation = previous.Abbreviation
+ };
+ result = FormatUnitValue(newValue, previous.Abbreviation);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Handles "* 2" or "/ 3" scaling operators that preserve the previous unit.
+ ///
+ private bool TryScaleOperator(
+ string trimmed,
+ UnitResult previous,
+ out string result,
+ out UnitResult? unitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ System.Text.RegularExpressions.Match match = ScaleOperatorPattern().Match(trimmed);
+ if (!match.Success)
+ return false;
+
+ string op = match.Groups["op"].Value;
+ string numberStr = match.Groups["number"].Value;
+
+ if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number))
+ return false;
+
+ if (op == "/" && number == 0)
+ return false; // Avoid division by zero
+
+ double newValue = op == "*"
+ ? previous.Value * number
+ : previous.Value / number;
+
+ unitResult = new UnitResult
+ {
+ Value = newValue,
+ Unit = previous.Unit,
+ QuantityName = previous.QuantityName,
+ Abbreviation = previous.Abbreviation
+ };
+ result = FormatUnitValue(newValue, previous.Abbreviation);
+ return true;
+ }
+
+ ///
+ /// Handles explicit "5 miles to km" or "100°F in celsius" conversion expressions.
+ /// Tries the "to" keyword first (unambiguous), then "in" as fallback.
+ ///
+ private bool TryExplicitConversion(
+ string trimmed,
+ out string result,
+ out UnitResult? unitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ // Try "to" keyword first (unambiguous, avoids conflict with "in" as inches)
+ System.Text.RegularExpressions.Match match = ToConversionPattern().Match(trimmed);
+ if (!match.Success)
+ {
+ // Fallback to "in" keyword
+ match = InConversionPattern().Match(trimmed);
+ }
+
+ if (!match.Success)
+ return false;
+
+ string sourcePart = match.Groups[1].Value.Trim();
+ string targetStr = match.Groups[2].Value.Trim();
+
+ if (!TryExtractValueAndUnit(sourcePart, out double value, out UnitInfo sourceUnit))
+ return false;
+
+ if (!TryResolveUnit(targetStr, out UnitInfo targetUnit))
+ return false;
+
+ // Ensure compatible quantity types
+ if (sourceUnit.Unit.GetType() != targetUnit.Unit.GetType())
+ return false;
+
+ try
+ {
+ IQuantity source = Quantity.From(value, sourceUnit.Unit);
+ IQuantity converted = source.ToUnit(targetUnit.Unit);
+ double convertedValue = (double)converted.Value;
+
+ unitResult = new UnitResult
+ {
+ Value = convertedValue,
+ Unit = targetUnit.Unit,
+ QuantityName = targetUnit.QuantityName,
+ Abbreviation = targetUnit.Abbreviation
+ };
+ result = FormatUnitValue(convertedValue, targetUnit.Abbreviation);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Handles standalone unit expressions like "5 meters" — tracks the unit for future
+ /// continuation but does not convert. Requires multi-character unit abbreviations
+ /// to avoid conflicts with single-letter variable names and quantity words.
+ ///
+ private bool TryStandaloneUnit(
+ string trimmed,
+ out string result,
+ out UnitResult? unitResult)
+ {
+ result = string.Empty;
+ unitResult = null;
+
+ if (!TryExtractValueAndUnit(trimmed, out double value, out UnitInfo unit))
+ return false;
+
+ // For standalone detection, skip single-char unit text that could conflict
+ // with variable names or quantity words (e.g., "5 m" where m could be a variable).
+ // Multi-char input like "meters" is unambiguous even if the abbreviation is "m".
+ // Single-char units still work in explicit conversions with "to"/"in" keyword.
+ System.Text.RegularExpressions.Match unitMatch = NumberWithUnitPattern().Match(trimmed);
+ string inputUnitText = unitMatch.Success ? unitMatch.Groups["unit"].Value.Trim() : string.Empty;
+ if (inputUnitText.Length <= 1)
+ return false;
+
+ // Don't capture if the unit text matches a defined variable name
+ string[] words = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (words.Length >= 2)
+ {
+ string unitText = string.Join(" ", words[1..]);
+ if (_parameters.ContainsKey(unitText))
+ return false;
+ }
+
+ unitResult = new UnitResult
+ {
+ Value = value,
+ Unit = unit.Unit,
+ QuantityName = unit.QuantityName,
+ Abbreviation = unit.Abbreviation
+ };
+ result = FormatUnitValue(value, unit.Abbreviation);
+ return true;
+ }
+
+ ///
+ /// Extracts a numeric value and unit from a string like "5 miles", "100°F", "3.5 gallons".
+ /// The number must appear at the beginning and the unit at the end.
+ ///
+ private static bool TryExtractValueAndUnit(string input, out double value, out UnitInfo unitInfo)
+ {
+ value = 0;
+ unitInfo = default;
+
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ System.Text.RegularExpressions.Match match = NumberWithUnitPattern().Match(input.Trim());
+ if (!match.Success)
+ return false;
+
+ string numberStr = match.Groups["number"].Value;
+ string unitStr = match.Groups["unit"].Value.Trim();
+
+ if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out value))
+ return false;
+
+ return TryResolveUnit(unitStr, out unitInfo);
+ }
+
+ ///
+ /// Looks up a unit string in the unit dictionary.
+ ///
+ private static bool TryResolveUnit(string unitString, out UnitInfo entry)
+ {
+ return _unitLookup.TryGetValue(unitString.Trim(), out entry);
+ }
+
+ ///
+ /// Formats a numeric value with a unit abbreviation for display.
+ /// Uses the standard FormatResult for the number portion.
+ ///
+ private string FormatUnitValue(double value, string abbreviation)
+ {
+ string formatted = FormatResult(value);
+ return $"{formatted} {abbreviation}";
+ }
+
+ #endregion Unit Conversion Helpers
+
+ #region Unit Math Regex Patterns
+
+ ///
+ /// Matches "to km" or "in feet" — continuation conversion from previous unit result.
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(?:to|in)\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex ContinuationConversionPattern();
+
+ ///
+ /// Matches "5 miles to km" — explicit conversion with "to" keyword.
+ /// Uses lazy matching on the source to prefer the earliest split point.
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(.+?)\s+to\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex ToConversionPattern();
+
+ ///
+ /// Matches "5 gallons in liters" — explicit conversion with "in" keyword.
+ /// Used as fallback after "to" pattern to avoid conflicts with "in" as inches.
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(.+?)\s+in\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex InConversionPattern();
+
+ ///
+ /// Matches a number followed by a unit: "5 miles", "100°F", "3.5 gallons".
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(?-?\d+\.?\d*)\s*(?.+)$")]
+ private static partial System.Text.RegularExpressions.Regex NumberWithUnitPattern();
+
+ ///
+ /// Matches operator continuation with unit: "+ 3 km", "- 5 miles".
+ /// Requires whitespace between operator, number, and unit.
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(?[+-])\s+(?\d+\.?\d*)\s+(?.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex OperatorWithUnitPattern();
+
+ ///
+ /// Matches scale operators without units: "* 2", "/ 3", "* 0.5".
+ /// Only used when there is a previous unit result to preserve.
+ ///
+ [System.Text.RegularExpressions.GeneratedRegex(@"^(?[*/])\s*(?\d+\.?\d*)$")]
+ private static partial System.Text.RegularExpressions.Regex ScaleOperatorPattern();
+
+ #endregion Unit Math Regex Patterns
+}
diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs
index d3a5a70f..10001f9b 100644
--- a/Text-Grab/Services/CalculationService.cs
+++ b/Text-Grab/Services/CalculationService.cs
@@ -48,6 +48,8 @@ public async Task EvaluateExpressionsAsync(string input)
int errorCount = 0;
double? previousLineResult = null;
DateTime? previousDateTimeResult = null;
+ UnitResult? previousUnitResult = null;
+ Dictionary unitCounts = [];
// Clear parameters and rebuild from scratch for each evaluation
_parameters.Clear();
@@ -62,8 +64,9 @@ public async Task EvaluateExpressionsAsync(string input)
}
// If the line starts with a binary operator and we have a previous numeric result,
- // prepend the previous result to form a complete expression
- if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && StartsWithBinaryOperator(trimmedLine))
+ // prepend the previous result to form a complete expression.
+ // Skip when we have a previous unit result — unit continuation is handled separately.
+ if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && previousUnitResult is null && StartsWithBinaryOperator(trimmedLine))
{
string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture);
trimmedLine = previousValueStr + " " + trimmedLine;
@@ -76,12 +79,34 @@ public async Task EvaluateExpressionsAsync(string input)
results.Add(dateTimeResult);
previousDateTimeResult = parsedDateTime;
previousLineResult = null;
+ previousUnitResult = null;
+ }
+ else if (TryEvaluateUnitConversion(trimmedLine, out string unitResultStr, out UnitResult? newUnitResult, previousUnitResult))
+ {
+ results.Add(unitResultStr);
+ previousUnitResult = newUnitResult;
+ previousDateTimeResult = null;
+
+ if (newUnitResult is not null)
+ {
+ outputNumbers.Add(newUnitResult.Value);
+ previousLineResult = newUnitResult.Value;
+
+ // Track unit frequency for DominantUnit
+ string unitKey = newUnitResult.Abbreviation;
+ unitCounts[unitKey] = unitCounts.GetValueOrDefault(unitKey) + 1;
+ }
+ else
+ {
+ previousLineResult = null;
+ }
}
else if (IsParameterAssignment(trimmedLine))
{
string resultLine = await HandleParameterAssignmentAsync(trimmedLine);
results.Add(resultLine);
previousDateTimeResult = null;
+ previousUnitResult = null;
// Extract variable name and add its value to output numbers
int equalIndex = trimmedLine.IndexOf('=');
@@ -110,6 +135,7 @@ public async Task EvaluateExpressionsAsync(string input)
string resultLine = await EvaluateStandardExpressionAsync(trimmedLine);
results.Add(resultLine);
previousDateTimeResult = null;
+ previousUnitResult = null;
// Try to parse the result as a number and add to output numbers
// Remove formatting characters before parsing
@@ -138,14 +164,21 @@ public async Task EvaluateExpressionsAsync(string input)
errorCount++;
previousLineResult = null;
previousDateTimeResult = null;
+ previousUnitResult = null;
}
}
+ // Determine the most common unit across results
+ string? dominantUnit = unitCounts.Count > 0
+ ? unitCounts.MaxBy(kv => kv.Value).Key
+ : null;
+
return new CalculationResult
{
Output = string.Join("\n", results),
ErrorCount = errorCount,
- OutputNumbers = outputNumbers
+ OutputNumbers = outputNumbers,
+ DominantUnit = dominantUnit
};
}
@@ -630,4 +663,11 @@ public class CalculationResult
/// Includes both direct expression results and variable assignment values.
///
public List OutputNumbers { get; set; } = [];
+
+ ///
+ /// Gets or sets the most common unit abbreviation across unit-bearing results.
+ /// Null when no unit conversions were evaluated. Can be used to annotate
+ /// aggregate displays (e.g., "Sum: 45.2 kg").
+ ///
+ public string? DominantUnit { get; set; }
}
diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj
index 310668fe..0fe29466 100644
--- a/Text-Grab/Text-Grab.csproj
+++ b/Text-Grab/Text-Grab.csproj
@@ -63,6 +63,7 @@
+
diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml
index 1d7426b4..e42947e9 100644
--- a/Text-Grab/Views/EditTextWindow.xaml
+++ b/Text-Grab/Views/EditTextWindow.xaml
@@ -24,124 +24,130 @@
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+ InputGestureText="Ctrl + Shift + V"/>
+
+ InputGestureText="Ctrl + I"/>
+ InputGestureText="Ctrl + L"/>
+ InputGestureText="Ctrl + A"/>
-
-
+ InputGestureText="Shift + F3"/>
+
+
+ InputGestureText="Alt + Up"/>
+ InputGestureText="Alt + Down"/>
+ Executed="SplitOnSelectionCmdExecuted"/>
+ Executed="SplitAfterSelectionCmdExecuted"/>
+ Executed="IsolateSelectionCmdExecuted"/>
+ Executed="SingleLineCmdExecuted"/>
+ Executed="ToggleCase"/>
+ Executed="ReplaceReservedCharsCmdExecuted"/>
+ Executed="UnstackExecuted"/>
+ Executed="UnstackGroupExecuted"/>
+ Executed="DeleteAllSelectionExecuted"/>
+ Executed="DeleteAllSelectionPatternExecuted"/>
+ Executed="InsertSelectionOnEveryLine"/>
+ Executed="PasteExecuted"/>
+ Executed="LaunchUriExecuted"/>
+ Executed="MakeQrCodeExecuted"/>
+ Executed="WebSearchExecuted"/>
+ Executed="DefaultWebSearchExecuted"/>
-
-
-
-
+
+
+
+
+ Icon="{StaticResource TextGrabIcon}"/>
-
-
+
+
-
-
+
+
+ VerticalScrollBarVisibility="Auto"/>
-
-
-
-
+
+
+
+
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
@@ -698,31 +715,31 @@
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
+ IsCheckable="True"/>
@@ -730,7 +747,7 @@
x:Name="CalcAggregateStatusText"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
- Text="" />
+ Text=""/>
+ Symbol="Copy24"/>
@@ -758,7 +775,8 @@
BorderThickness="0"
Click="CalcCopyAllButton_Click"
ToolTip="Copy All Results">
-
+
@@ -791,40 +810,62 @@
Margin="0,0,0,8"
FontSize="16"
FontWeight="Bold"
- Text="Calculation Pane" />
-
-
+ Text="Calculation Pane"/>
+
+
-
-
+ Text="Features:"/>
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+ Text="Examples:"/>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ TextWrapping="Wrap"/>
-
+
-
+
@@ -860,7 +901,7 @@
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
MouseDoubleClick="TextBoxSplitter_MouseDoubleClick"
ResizeDirection="Auto"
- ShowsPreview="True" />
+ ShowsPreview="True"/>
-
+
-
-
+
+
@@ -893,13 +937,13 @@
Grid.Row="2"
Background="Transparent">
-
-
+
+
+ Orientation="Horizontal"/>
+ Text="0 matches"/>
+ Text="0 similar"/>
+ Header="Save Pattern"/>
+ Header="Explain Pattern"/>
@@ -965,17 +1009,17 @@
+ Text="Regex: "/>
+ Header="Save Pattern"/>
+ Header="Explain Pattern"/>
@@ -993,12 +1037,12 @@
x:Name="CharDetailsButtonText"
FontFamily="Cascadia Mono"
FontSize="12"
- Text="U+0000" />
+ Text="U+0000"/>
+ Text="Ln 1, Col 0"/>
-
+
-
+
@@ -1034,14 +1080,14 @@
x:Name="ProgressRing"
Width="60"
Height="60"
- IsIndeterminate="True" />
+ IsIndeterminate="True"/>
+ Text="Working..."/>
From 1f7da7cd895df774816df5c02dad19279f1c9909 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sat, 28 Feb 2026 10:24:38 -0600
Subject: [PATCH 019/109] Add support for "C" and "F" temp unit abbreviations
Extend unit recognition to include "C" (Celsius) and "F" (Fahrenheit) abbreviations. Update unit conversion and detection tests to cover these cases. Add a check to return an empty string for empty expressions to prevent evaluation errors.
---
Tests/UnitConversionTests.cs | 6 ++++++
Text-Grab/Services/CalculationService.UnitMath.cs | 2 ++
Text-Grab/Services/CalculationService.cs | 3 +++
3 files changed, 11 insertions(+)
diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs
index 90f8ef34..e8d116a0 100644
--- a/Tests/UnitConversionTests.cs
+++ b/Tests/UnitConversionTests.cs
@@ -33,6 +33,8 @@ public async Task ExplicitConversion_ContainsTargetUnit(string input, string exp
[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)]
@@ -261,6 +263,8 @@ public async Task StandaloneUnit_CorrectNumericValue(string input, double expect
[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")]
@@ -368,6 +372,8 @@ public async Task DominantUnit_NullForPlainMath()
[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)]
diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs
index 6c2d65f0..50a4c010 100644
--- a/Text-Grab/Services/CalculationService.UnitMath.cs
+++ b/Text-Grab/Services/CalculationService.UnitMath.cs
@@ -107,9 +107,11 @@ public class UnitResult
{ "celsius", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
{ "°C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
{ "degC", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
+ { "C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") },
{ "fahrenheit", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
{ "°F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
{ "degF", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
+ { "F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") },
{ "kelvin", new(TemperatureUnit.Kelvin, "Temperature", "K") },
// ══════════════════════════════════════════════════════════════
diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs
index 10001f9b..6a457784 100644
--- a/Text-Grab/Services/CalculationService.cs
+++ b/Text-Grab/Services/CalculationService.cs
@@ -403,6 +403,9 @@ private async Task HandleParameterAssignmentAsync(string line)
CultureInfo = CultureInfo ?? CultureInfo.CurrentCulture
};
+ if (string.IsNullOrEmpty(expr.ExpressionString))
+ return string.Empty;
+
// Set up parameter handler for existing parameters
expr.EvaluateParameterAsync += (name, args) =>
{
From d8ed478ee937d76e5e056ac6cc46adb3066bd3d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Feb 2026 16:36:51 +0000
Subject: [PATCH 020/109] Initial plan
From a0fc61ed292f52e948e7ae8042f18e4758f6467f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Feb 2026 16:48:54 +0000
Subject: [PATCH 021/109] Apply all 5 PR review comment fixes
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Tests/CalculatorTests.cs | 180 ++++++++++--------
.../CalculationService.DateTimeMath.cs | 2 +-
.../Services/CalculationService.UnitMath.cs | 67 ++++++-
Text-Grab/Services/CalculationService.cs | 21 +-
4 files changed, 181 insertions(+), 89 deletions(-)
diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs
index b40a1601..2490cff2 100644
--- a/Tests/CalculatorTests.cs
+++ b/Tests/CalculatorTests.cs
@@ -2353,75 +2353,81 @@ public async Task Percentage_WithSum_WorksCorrectly()
#region DateTime Math Tests
[Theory]
- [InlineData("March 10, 2026 + 10 days", "3/20/2026")]
- [InlineData("January 1, 2026 + 30 days", "1/31/2026")]
- [InlineData("December 25, 2026 + 7 days", "1/1/2027")]
- [InlineData("February 28, 2026 + 1 day", "3/1/2026")]
- public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, string expected)
+ [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);
}
[Theory]
- [InlineData("January 1, 2026 + 2 weeks", "1/15/2026")]
- [InlineData("March 1, 2026 + 1 week", "3/8/2026")]
- [InlineData("March 14, 2026 - 2 weeks", "2/28/2026")]
- public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, string expected)
+ [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", "4/15/2026")]
- [InlineData("October 31, 2026 + 1 month", "11/30/2026")]
- [InlineData("March 31, 2026 + 1 month", "4/30/2026")]
- public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, string expected)
+ [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", "1/1/2025")]
- [InlineData("February 29, 2024 + 1 year", "2/28/2025")]
- [InlineData("June 15, 2026 + 10 years", "6/15/2036")]
- public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, string expected)
+ [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", "1/1/2020")]
- [InlineData("June 15, 2010 + 1 decade", "6/15/2020")]
- [InlineData("March 1, 2026 + 3 decades", "3/1/2056")]
- public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, string expected)
+ [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", "3/10/2026")]
- [InlineData("January 5, 2026 - 10 days", "12/26/2025")]
- [InlineData("March 1, 2026 - 1 month", "2/1/2026")]
- [InlineData("January 1, 2026 - 1 year", "1/1/2025")]
- public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, string expected)
+ [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);
}
@@ -2432,20 +2438,22 @@ public async Task DateTimeMath_WithOrdinalSuffix_ParsesCorrectly()
CalculationService service = new();
string input = "March 10th, 2026 + 10 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/20/2026", result.Output);
+ 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", "1/6/2026")]
- [InlineData("February 2nd, 2026 + 5 days", "2/7/2026")]
- [InlineData("March 3rd, 2026 + 5 days", "3/8/2026")]
- [InlineData("April 4th, 2026 + 5 days", "4/9/2026")]
- [InlineData("May 21st, 2026 + 1 day", "5/22/2026")]
- public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, string expected)
+ [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);
}
@@ -2456,7 +2464,8 @@ public async Task DateTimeMath_NumericDateFormat_ParsesCorrectly()
CalculationService service = new();
string input = "3/10/2026 + 10 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/20/2026", result.Output);
+ string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2466,7 +2475,8 @@ public async Task DateTimeMath_TwoDigitYear_ParsesCorrectly()
CalculationService service = new();
string input = "3/10/26 + 10 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/20/2026", result.Output);
+ string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2476,7 +2486,8 @@ public async Task DateTimeMath_WithTimePM_ShowsTimeInResult()
CalculationService service = new();
string input = "1/1/2026 2:00pm + 5 hours";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Contains("1/1/2026", result.Output);
+ 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);
}
@@ -2487,7 +2498,8 @@ public async Task DateTimeMath_With24HrTime_ShowsTimeInResult()
CalculationService service = new();
string input = "1/1/2026 14:30 + 2 hours";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Contains("1/1/2026", result.Output);
+ 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);
}
@@ -2498,7 +2510,8 @@ public async Task DateTimeMath_MinutesAddition_CrossesDayBoundary()
CalculationService service = new();
string input = "2/25/2026 11:02pm + 800 mins";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Contains("2/26/2026", result.Output);
+ 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);
}
@@ -2509,7 +2522,8 @@ public async Task DateTimeMath_HoursAddition_CrossesDayBoundary()
CalculationService service = new();
string input = "1/1/2026 10:00pm + 5 hours";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Contains("1/2/2026", result.Output);
+ 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);
}
@@ -2520,7 +2534,8 @@ public async Task DateTimeMath_MultipleOperations_WorkCorrectly()
CalculationService service = new();
string input = "January 1, 2026 + 2 weeks + 3 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("1/18/2026", result.Output);
+ string expected = new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2530,7 +2545,8 @@ public async Task DateTimeMath_MixedAddSubtract_WorkCorrectly()
CalculationService service = new();
string input = "March 1, 2026 + 1 month - 5 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/27/2026", result.Output);
+ string expected = new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2540,7 +2556,8 @@ public async Task DateTimeMath_MixedUnits_YearsAndMonths()
CalculationService service = new();
string input = "January 1, 2026 + 1 year + 6 months";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("7/1/2027", result.Output);
+ string expected = new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2585,7 +2602,8 @@ public async Task DateTimeMath_FractionalDays_AssumesNoon()
// Since result is midnight, only date is shown
string input = "March 10, 2026 + 1.5 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/12/2026", result.Output);
+ string expected = new DateTime(2026, 3, 12).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2596,7 +2614,8 @@ public async Task DateTimeMath_FractionalDays_ShowsTimeWhenNonMidnight()
// 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);
- Assert.Contains("3/11/2026", result.Output);
+ 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);
}
@@ -2608,7 +2627,8 @@ public async Task DateTimeMath_MinutesWithoutTime_ShowsTime()
// 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);
- Assert.Contains("1/1/2026", result.Output);
+ 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);
}
@@ -2676,9 +2696,9 @@ public async Task DateTimeMath_WithMixedExpressions_WorksCorrectly()
CalculationResult result = await service.EvaluateExpressionsAsync(input);
string[] lines = result.Output.Split('\n');
Assert.Equal(3, lines.Length);
- Assert.Equal("3/20/2026", lines[0]);
+ Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), lines[0]);
Assert.Equal("8", lines[1]);
- Assert.Equal("1/1/2027", lines[2]);
+ Assert.Equal(new DateTime(2027, 1, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]);
Assert.Equal(0, result.ErrorCount);
}
@@ -2726,13 +2746,14 @@ public async Task DateTimeMath_NoDateStartOperator_UsesToday()
}
[Theory]
- [InlineData("Jan 1, 2026 + 5 days", "1/6/2026")]
- [InlineData("Feb 14, 2026 + 1 week", "2/21/2026")]
- [InlineData("Dec 31, 2026 + 1 day", "1/1/2027")]
- public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, string expected)
+ [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);
}
@@ -2744,7 +2765,8 @@ public async Task DateTimeMath_RealWorldExample_ProjectDeadline()
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
- Assert.Equal("12/15/2026", result.Output);
+ string expected = new DateTime(2026, 12, 15).ToString("d", CultureInfo.CurrentCulture);
+ Assert.Equal(expected, result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2754,7 +2776,7 @@ 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("3/15/2026", result.Output);
+ 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);
}
@@ -2766,7 +2788,7 @@ public async Task DateTimeMath_RealWorldExample_FlightArrival()
// Flight departs 11pm, arrives after 14 hours
string input = "7/4/2026 11:00pm + 14 hours";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Contains("7/5/2026", result.Output);
+ 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);
}
@@ -2783,7 +2805,7 @@ public async Task DateTimeMath_CombinedSegments_WeeksDaysHours()
// = 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("2/8/2026", result.Output);
+ 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);
}
@@ -2807,7 +2829,7 @@ public async Task DateTimeMath_CombinedSegments_YearsMonths()
// 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("7/1/2027", result.Output);
+ Assert.Equal(new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2818,7 +2840,7 @@ public async Task DateTimeMath_CombinedSegments_WeeksDays()
// 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("3/27/2026", result.Output);
+ Assert.Equal(new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2829,7 +2851,7 @@ public async Task DateTimeMath_CombinedSegments_HoursMinutes()
// 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("1/1/2026", result.Output);
+ 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);
}
@@ -2842,7 +2864,7 @@ public async Task DateTimeMath_CombinedSegments_ThreeUnits()
// = 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("2/18/2026", result.Output);
+ Assert.Equal(new DateTime(2026, 2, 18).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2854,7 +2876,7 @@ public async Task DateTimeMath_CombinedSegments_FourUnits()
// = 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("3/11/2027", result.Output);
+ Assert.Equal(new DateTime(2027, 3, 11).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2866,7 +2888,7 @@ public async Task DateTimeMath_CombinedSegments_WithOperatorChange()
// = 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("4/5/2026", result.Output);
+ 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);
}
@@ -2879,7 +2901,7 @@ public async Task DateTimeMath_CombinedSegments_SubtractMultiple()
// = March 30 - 10 days = March 20
string input = "April 30, 2026 - 1 month 10 days";
CalculationResult result = await service.EvaluateExpressionsAsync(input);
- Assert.Equal("3/20/2026", result.Output);
+ Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2892,7 +2914,7 @@ public async Task DateTimeMath_CombinedSegments_OperatorInheritance()
// = 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("6/23/2026", result.Output);
+ Assert.Equal(new DateTime(2026, 6, 23).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2904,7 +2926,7 @@ public async Task DateTimeMath_CombinedSegments_AllDateUnits()
// = 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("4/20/2032", result.Output);
+ Assert.Equal(new DateTime(2032, 4, 20).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2916,7 +2938,7 @@ public async Task DateTimeMath_CombinedSegments_WithTimeInput()
// = 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("3/2/2026", result.Output);
+ 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);
}
@@ -2928,7 +2950,7 @@ public async Task DateTimeMath_CombinedSegments_DoesNotBreakExplicitOperators()
// 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("1/18/2026", result.Output);
+ Assert.Equal(new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2960,7 +2982,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_Pregnancy()
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("3/22/2027", result.Output);
+ Assert.Equal(new DateTime(2027, 3, 22).ToString("d", CultureInfo.CurrentCulture), result.Output);
Assert.Equal(0, result.ErrorCount);
}
@@ -2972,7 +2994,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_SprintPlanning()
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("3/19/2026", result.Output);
+ 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);
}
@@ -2985,7 +3007,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary()
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("1/11/2026", result.Output);
+ 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);
}
@@ -3147,9 +3169,9 @@ public async Task CalculationService_DatePlusDuration_ThenOperatorContinuation()
string[] lines = result.Output.Split('\n');
Assert.Equal(2, lines.Length);
// March 1 + 2 weeks = March 15
- Assert.Contains("3/15/2026", lines[0]);
+ Assert.Contains(new DateTime(2026, 3, 15).ToString("d", CultureInfo.CurrentCulture), lines[0]);
// March 15 + 1 month = April 15
- Assert.Contains("4/15/2026", lines[1]);
+ Assert.Contains(new DateTime(2026, 4, 15).ToString("d", CultureInfo.CurrentCulture), lines[1]);
Assert.Equal(0, result.ErrorCount);
}
@@ -3166,9 +3188,9 @@ public async Task CalculationService_DateChainedThreeLines()
// Assert
string[] lines = result.Output.Split('\n');
Assert.Equal(3, lines.Length);
- Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1
- Assert.Contains("3/1/2026", lines[1]); // Feb 1 + 1 month = Mar 1
- Assert.Contains("4/1/2026", lines[2]); // Mar 1 + 1 month = Apr 1
+ 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);
}
@@ -3185,8 +3207,8 @@ public async Task CalculationService_DateMinusDuration_OperatorContinuation()
// Assert
string[] lines = result.Output.Split('\n');
Assert.Equal(2, lines.Length);
- Assert.Contains("4/15/2026", lines[0]); // March 15 + 1 month = April 15
- Assert.Contains("4/8/2026", lines[1]); // April 15 - 1 week = April 8
+ 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);
}
@@ -3223,9 +3245,9 @@ public async Task CalculationService_DateContinuation_CommentDoesNotResetDate()
// Assert
string[] lines = result.Output.Split('\n');
Assert.Equal(3, lines.Length);
- Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1
+ 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("3/1/2026", lines[2]); // Feb 1 + 1 month = Mar 1
+ Assert.Contains(new DateTime(2026, 3, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); // Feb 1 + 1 month = Mar 1
Assert.Equal(0, result.ErrorCount);
}
@@ -3242,7 +3264,7 @@ public async Task CalculationService_DateFollowedByNumericExpression_ResetsDateC
// Assert
string[] lines = result.Output.Split('\n');
Assert.Equal(2, lines.Length);
- Assert.Contains("2/1/2026", lines[0]);
+ Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]);
Assert.Equal("8", lines[1]);
Assert.Equal(0, result.ErrorCount);
}
diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs
index 2593fb3b..04a16177 100644
--- a/Text-Grab/Services/CalculationService.DateTimeMath.cs
+++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs
@@ -242,7 +242,7 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime)
if (includeTime)
{
string datePart = dateTime.ToString("d", culture);
- string timePart = dateTime.ToString("h:mmtt", culture).ToLowerInvariant();
+ string timePart = dateTime.ToString("h:mmtt", CultureInfo.InvariantCulture).ToLowerInvariant();
return $"{datePart} {timePart}";
}
diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs
index 50a4c010..48173c2a 100644
--- a/Text-Grab/Services/CalculationService.UnitMath.cs
+++ b/Text-Grab/Services/CalculationService.UnitMath.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Text;
using UnitsNet;
using UnitsNet.Units;
@@ -532,12 +533,76 @@ private static bool TryExtractValueAndUnit(string input, out double value, out U
string numberStr = match.Groups["number"].Value;
string unitStr = match.Groups["unit"].Value.Trim();
- if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out value))
+ if (!TryParseFlexibleDouble(numberStr, out value))
return false;
return TryResolveUnit(unitStr, out unitInfo);
}
+ ///
+ /// Tries to parse a double from a string using flexible handling of decimal and group separators.
+ /// Supports inputs like "1,000", "1.000,5", "3,5", etc.
+ ///
+ private static bool TryParseFlexibleDouble(string input, out double value)
+ {
+ value = 0;
+
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ string normalized = NormalizeNumberString(input);
+ if (string.IsNullOrEmpty(normalized))
+ return false;
+
+ return double.TryParse(
+ normalized,
+ NumberStyles.Float | NumberStyles.AllowLeadingSign,
+ CultureInfo.InvariantCulture,
+ out value);
+ }
+
+ ///
+ /// Normalizes a numeric string to invariant form (dot as decimal, no group separators)
+ /// by inferring separator roles from the positions of '.' and ',' characters.
+ ///
+ private static string NormalizeNumberString(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return string.Empty;
+
+ // Remove spaces and underscores used as group separators
+ StringBuilder sb = new();
+ foreach (char c in input.Trim())
+ {
+ if (c != ' ' && c != '_')
+ sb.Append(c);
+ }
+
+ string compact = sb.ToString();
+ int commaIndex = compact.IndexOf(',');
+ int dotIndex = compact.IndexOf('.');
+
+ if (commaIndex >= 0 && dotIndex >= 0)
+ {
+ // Both present: whichever appears last is the decimal separator
+ if (compact.LastIndexOf('.') > compact.LastIndexOf(','))
+ compact = compact.Replace(",", string.Empty); // e.g. "1,234.56"
+ else
+ compact = compact.Replace(".", string.Empty).Replace(",", "."); // e.g. "1.234,56"
+ }
+ else if (commaIndex >= 0)
+ {
+ // Only comma: single comma is decimal ("3,5"), multiple are group separators ("1,000,000")
+ int lastCommaIndex = compact.LastIndexOf(',');
+ compact = commaIndex == lastCommaIndex
+ ? compact.Replace(",", ".")
+ : compact.Replace(",", string.Empty);
+ }
+ // Only dot or no separator: leave as-is for invariant parsing
+
+ return compact;
+ }
+
///
/// Looks up a unit string in the unit dictionary.
///
diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs
index 6a457784..26f10512 100644
--- a/Text-Grab/Services/CalculationService.cs
+++ b/Text-Grab/Services/CalculationService.cs
@@ -68,7 +68,7 @@ public async Task EvaluateExpressionsAsync(string input)
// Skip when we have a previous unit result — unit continuation is handled separately.
if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && previousUnitResult is null && StartsWithBinaryOperator(trimmedLine))
{
- string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture);
+ string previousValueStr = previousLineResult.Value.ToString(this.CultureInfo);
trimmedLine = previousValueStr + " " + trimmedLine;
}
@@ -189,12 +189,17 @@ public bool IsParameterAssignment(string line)
{
// Check for assignment pattern: variable = expression
// Avoid matching comparison operators (==, !=, <=, >=)
- return line.Contains('=') &&
- !line.Contains("==") &&
- !line.Contains("!=") &&
- !line.Contains("<=") &&
- !line.Contains(">=") &&
- line.IndexOf('=') == line.LastIndexOf('='); // Ensure single '='
+ if (!line.Contains('=') ||
+ line.Contains("==") ||
+ line.Contains("!=") ||
+ line.Contains("<=") ||
+ line.Contains(">=") ||
+ line.IndexOf('=') != line.LastIndexOf('=')) // Ensure single '='
+ return false;
+
+ // Require a non-empty RHS so that "x =" falls through to the normal error path
+ int equalIndex = line.IndexOf('=');
+ return equalIndex < line.Length - 1 && !string.IsNullOrWhiteSpace(line[(equalIndex + 1)..]);
}
///
@@ -404,7 +409,7 @@ private async Task HandleParameterAssignmentAsync(string line)
};
if (string.IsNullOrEmpty(expr.ExpressionString))
- return string.Empty;
+ throw new ArgumentException($"Expression for '{variableName}' is empty.");
// Set up parameter handler for existing parameters
expr.EvaluateParameterAsync += (name, args) =>
From da044ecba523cb5c84fda4a98832c1df245494e4 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:24:49 -0600
Subject: [PATCH 022/109] Add template-based capture system with region support
Introduce GrabTemplate, TemplateRegion, and PostGrabContext classes to enable reusable OCR templates for fixed-layout documents. Templates define named, numbered regions and an output format for assembling OCR results. PostGrabContext encapsulates all data from a grab action, supporting both simple and template-based post-processing. This lays the foundation for structured data extraction and advanced OCR workflows.
---
Text-Grab/Models/GrabTemplate.cs | 96 +++++++++++++++++++++++++++++
Text-Grab/Models/PostGrabContext.cs | 36 +++++++++++
Text-Grab/Models/TemplateRegion.cs | 66 ++++++++++++++++++++
3 files changed, 198 insertions(+)
create mode 100644 Text-Grab/Models/GrabTemplate.cs
create mode 100644 Text-Grab/Models/PostGrabContext.cs
create mode 100644 Text-Grab/Models/TemplateRegion.cs
diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs
new file mode 100644
index 00000000..e21ed33a
--- /dev/null
+++ b/Text-Grab/Models/GrabTemplate.cs
@@ -0,0 +1,96 @@
+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:
+/// {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
+/// \n — newline
+/// \t — tab
+/// \\ — literal backslash
+/// \{ — literal opening brace
+///
+public 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}, \n, \t.
+ /// Example: "Name: {1}\nEmail: {2}\nPhone: {3}"
+ ///
+ public string OutputTemplate { get; set; } = string.Empty;
+
+ public GrabTemplate() { }
+
+ public GrabTemplate(string name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// Returns whether this template has the minimum required data to be executed.
+ ///
+ public bool IsValid =>
+ !string.IsNullOrWhiteSpace(Name)
+ && Regions.Count > 0
+ && !string.IsNullOrWhiteSpace(OutputTemplate);
+
+ ///
+ /// Returns all region numbers referenced in the output template.
+ ///
+ public IEnumerable GetReferencedRegionNumbers()
+ {
+ System.Text.RegularExpressions.MatchCollection matches =
+ System.Text.RegularExpressions.Regex.Matches(
+ OutputTemplate,
+ @"\{(\d+)(?::[a-z]+)?\}");
+
+ foreach (System.Text.RegularExpressions.Match match in matches)
+ {
+ if (int.TryParse(match.Groups[1].Value, out int number))
+ yield return number;
+ }
+ }
+}
diff --git a/Text-Grab/Models/PostGrabContext.cs b/Text-Grab/Models/PostGrabContext.cs
new file mode 100644
index 00000000..63707a09
--- /dev/null
+++ b/Text-Grab/Models/PostGrabContext.cs
@@ -0,0 +1,36 @@
+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
+)
+{
+ /// Convenience factory for non-template actions that only need text.
+ public static PostGrabContext TextOnly(string text) =>
+ new(text, Rect.Empty, 1.0, null, null);
+}
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,
+ };
+ }
+}
From 2dc268c5ea6db7e26528e955867beea2f03701b1 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:25:54 -0600
Subject: [PATCH 023/109] Add file-based GrabTemplate storage and executor
Replaces settings-based template storage with a robust file-based system (GrabTemplateManager) supporting CRUD, migration, and error handling. Implements GrabTemplateExecutor for OCR region extraction and flexible output template formatting with modifiers and escapes. Includes comprehensive unit tests for both manager and executor.
---
Tests/GrabTemplateExecutorTests.cs | 146 +++++++++++++
Tests/GrabTemplateManagerTests.cs | 230 ++++++++++++++++++++
Text-Grab/Utilities/GrabTemplateExecutor.cs | 186 ++++++++++++++++
Text-Grab/Utilities/GrabTemplateManager.cs | 227 +++++++++++++++++++
4 files changed, 789 insertions(+)
create mode 100644 Tests/GrabTemplateExecutorTests.cs
create mode 100644 Tests/GrabTemplateManagerTests.cs
create mode 100644 Text-Grab/Utilities/GrabTemplateExecutor.cs
create mode 100644 Text-Grab/Utilities/GrabTemplateManager.cs
diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs
new file mode 100644
index 00000000..94f00754
--- /dev/null
+++ b/Tests/GrabTemplateExecutorTests.cs
@@ -0,0 +1,146 @@
+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);
+ }
+}
diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs
new file mode 100644
index 00000000..e99d92fd
--- /dev/null
+++ b/Tests/GrabTemplateManagerTests.cs
@@ -0,0 +1,230 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Text_Grab.Models;
+using Text_Grab.Utilities;
+
+namespace Tests;
+
+public class GrabTemplateManagerTests : IDisposable
+{
+ // Use a temp file so tests don't pollute each other or real user data
+ private readonly string _tempFilePath;
+
+ public GrabTemplateManagerTests()
+ {
+ _tempFilePath = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Test_{Guid.NewGuid()}.json");
+ GrabTemplateManager.TestFilePath = _tempFilePath;
+ }
+
+ public void Dispose()
+ {
+ GrabTemplateManager.TestFilePath = null;
+ if (File.Exists(_tempFilePath))
+ File.Delete(_tempFilePath);
+ }
+
+ // ── GetAllTemplates ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetAllTemplates_WhenEmpty_ReturnsEmptyList()
+ {
+ List templates = GrabTemplateManager.GetAllTemplates();
+ Assert.Empty(templates);
+ }
+
+ [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);
+ }
+
+ // ── GetTemplateById ───────────────────────────────────────────────────────
+
+ [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);
+ }
+
+ // ── AddOrUpdateTemplate ───────────────────────────────────────────────────
+
+ [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);
+ }
+
+ // ── DeleteTemplate ────────────────────────────────────────────────────────
+
+ [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");
+
+ // Should still have the original template
+ Assert.Single(GrabTemplateManager.GetAllTemplates());
+ }
+
+ // ── DuplicateTemplate ─────────────────────────────────────────────────────
+
+ [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);
+ }
+
+ // ── CreateButtonInfoForTemplate ───────────────────────────────────────────
+
+ [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);
+ }
+
+ // ── Corrupt JSON robustness ───────────────────────────────────────────────
+
+ [Fact]
+ public void GetAllTemplates_CorruptJson_ReturnsEmptyList()
+ {
+ File.WriteAllText(_tempFilePath, "{ this is not valid json }}}");
+
+ List templates = GrabTemplateManager.GetAllTemplates();
+ Assert.Empty(templates);
+ }
+
+ // ── GrabTemplate model ────────────────────────────────────────────────────
+
+ [Fact]
+ public void GrabTemplate_IsValid_TrueWhenNameRegionsAndOutputTemplateSet()
+ {
+ GrabTemplate template = CreateSampleTemplate("Valid");
+ Assert.True(template.IsValid);
+ }
+
+ [Fact]
+ public void GrabTemplate_IsValid_FalseWhenNameEmpty()
+ {
+ GrabTemplate template = CreateSampleTemplate(string.Empty);
+ Assert.False(template.IsValid);
+ }
+
+ [Fact]
+ public void GrabTemplate_IsValid_FalseWhenNoRegions()
+ {
+ GrabTemplate template = CreateSampleTemplate("No Regions");
+ template.Regions.Clear();
+ 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);
+ }
+
+ // ── Helper ────────────────────────────────────────────────────────────────
+
+ 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/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs
new file mode 100644
index 00000000..e3d3add4
--- /dev/null
+++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs
@@ -0,0 +1,186 @@
+using System;
+using System.Collections.Generic;
+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:
+/// {N} — OCR text from region N (1-based)
+/// {N:trim} — trimmed OCR text
+/// {N:upper} — uppercased OCR text
+/// {N:lower} — lowercased OCR text
+/// \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);
+
+ // ── 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 WPF units, pre-DPI-scaling applied by caller)
+ /// that bounds the user's selection. Template region ratios are applied to
+ /// this rectangle's width/height.
+ ///
+ /// 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;
+
+ // 1. OCR each region
+ ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage();
+ Dictionary regionResults = await OcrAllRegionsAsync(
+ template, captureRegion, resolvedLanguage);
+
+ // 2. Apply output template
+ return ApplyOutputTemplate(template.OutputTemplate, regionResults);
+ }
+
+ ///
+ /// 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;
+ }
+
+ // ── 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)
+ {
+ List issues = [];
+ HashSet available = [.. availableRegionNumbers];
+
+ MatchCollection matches = PlaceholderRegex.Matches(outputTemplate);
+ HashSet referenced = [];
+
+ foreach (Match match in matches)
+ {
+ 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.");
+ }
+
+ return issues;
+ }
+}
diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs
new file mode 100644
index 00000000..6f01f9f2
--- /dev/null
+++ b/Text-Grab/Utilities/GrabTemplateManager.cs
@@ -0,0 +1,227 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Text_Grab.Models;
+using Text_Grab.Properties;
+using Wpf.Ui.Controls;
+
+namespace Text_Grab.Utilities;
+
+///
+/// Provides CRUD operations for objects, persisted as
+/// a JSON file on disk. Previously stored in application settings, but moved to
+/// file-based storage because ApplicationDataContainer has an 8 KB per-value limit.
+/// Pattern follows .
+///
+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";
+ private static bool _migrated;
+
+ // Allow tests to override the file path
+ internal static string? TestFilePath { get; set; }
+
+ // ── 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);
+ }
+
+ // ── Migration from settings ───────────────────────────────────────────────
+
+ private static void MigrateFromSettingsIfNeeded()
+ {
+ if (_migrated)
+ return;
+
+ _migrated = true;
+
+ string filePath = GetTemplatesFilePath();
+ if (File.Exists(filePath))
+ return;
+
+ try
+ {
+ string settingsJson = DefaultSettings.GrabTemplatesJSON;
+ if (string.IsNullOrWhiteSpace(settingsJson))
+ return;
+
+ // Validate the JSON before migrating
+ List? templates = JsonSerializer.Deserialize>(settingsJson, JsonOptions);
+ if (templates is null || templates.Count == 0)
+ return;
+
+ string? dir = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ File.WriteAllText(filePath, settingsJson);
+
+ // Clear the setting so it no longer overflows the container
+ DefaultSettings.GrabTemplatesJSON = string.Empty;
+ DefaultSettings.Save();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to migrate GrabTemplates from settings to file: {ex.Message}");
+ }
+ }
+
+ // ── Read ──────────────────────────────────────────────────────────────────
+
+ /// Returns all saved templates, or an empty list if none exist.
+ public static List GetAllTemplates()
+ {
+ MigrateFromSettingsIfNeeded();
+
+ string filePath = GetTemplatesFilePath();
+
+ if (!File.Exists(filePath))
+ return [];
+
+ try
+ {
+ string json = File.ReadAllText(filePath);
+
+ 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);
+ string filePath = GetTemplatesFilePath();
+
+ string? dir = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ File.WriteAllText(filePath, json);
+ }
+
+ /// 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);
+ }
+}
From ae3aa3da7a618a6ce0bf8765e282a96228aad3f9 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:28:57 -0600
Subject: [PATCH 024/109] Add Grab Template support as post-grab actions
- Add TemplateId property to ButtonInfo for template actions
- Include Grab Templates in available post-grab actions
- Add ApplyTemplate_Click action handling in PostGrabActionManager
- Overload ExecutePostGrabAction to accept PostGrabContext
- Improve BarcodeUtilities with null checks and error handling
- Update comments and summaries for clarity and maintainability
---
Text-Grab/Models/ButtonInfo.cs | 6 +++
Text-Grab/Utilities/BarcodeUtilities.cs | 17 +++++++-
Text-Grab/Utilities/PostGrabActionManager.cs | 44 ++++++++++++++++++--
3 files changed, 62 insertions(+), 5 deletions(-)
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/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs
index 40385f79..45cd0928 100644
--- a/Text-Grab/Utilities/BarcodeUtilities.cs
+++ b/Text-Grab/Utilities/BarcodeUtilities.cs
@@ -14,13 +14,26 @@ public static class BarcodeUtilities
public static OcrOutput TryToReadBarcodes(Bitmap bitmap)
{
+ if (bitmap is null || bitmap.Width <= 0 || bitmap.Height <= 0)
+ return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty };
+
BarcodeReader barcodeReader = new()
{
AutoRotate = true,
Options = new ZXing.Common.DecodingOptions { TryHarder = true }
};
- ZXing.Result result = barcodeReader.Decode(bitmap);
+ Result? result = null;
+
+ try
+ {
+ result = barcodeReader.Decode(bitmap);
+
+ }
+ catch (System.Exception)
+ {
+ return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty };
+ }
string resultString = string.Empty;
if (result is not null)
@@ -81,4 +94,4 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor
return svg;
}
-}
\ No newline at end of file
+}
diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs
index c5242595..00693031 100644
--- a/Text-Grab/Utilities/PostGrabActionManager.cs
+++ b/Text-Grab/Utilities/PostGrabActionManager.cs
@@ -4,6 +4,8 @@
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;
@@ -15,7 +17,8 @@ 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 +27,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)];
}
@@ -142,7 +155,7 @@ public static bool GetCheckState(ButtonInfo action)
try
{
Dictionary? checkStates = JsonSerializer.Deserialize>(statesJson);
- if (checkStates is not null
+ if (checkStates is not null
&& checkStates.TryGetValue(action.ButtonText, out bool storedState)
&& action.DefaultCheckState == DefaultCheckState.LastUsed)
{
@@ -191,6 +204,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 +259,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;
From 376b0faefa24f41a4d85981e956c848f8ec960e6 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:30:03 -0600
Subject: [PATCH 025/109] Add new user settings for templates and context menu
options
Added three user-scoped settings: GrabTemplatesJSON (string), AddToContextMenu (bool), and RegisterOpenWith (bool). Updated App.config, Settings.settings, and Settings.Designer.cs to support these options. Also updated the code generation version in Settings.Designer.cs.
---
Text-Grab/App.config | 9 +++++++++
Text-Grab/Properties/Settings.Designer.cs | 16 ++++++++++++++--
Text-Grab/Properties/Settings.settings | 3 +++
3 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/Text-Grab/App.config b/Text-Grab/App.config
index ab9b17f7..4675d28e 100644
--- a/Text-Grab/App.config
+++ b/Text-Grab/App.config
@@ -211,6 +211,15 @@
False
+
+
+
+
+ False
+
+
+ False
+
\ No newline at end of file
diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs
index d223017a..6c4d60c4 100644
--- a/Text-Grab/Properties/Settings.Designer.cs
+++ b/Text-Grab/Properties/Settings.Designer.cs
@@ -12,7 +12,7 @@ namespace Text_Grab.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.14.0.0")]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.4.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
@@ -839,6 +839,18 @@ public bool PostGrabStayOpen {
}
}
+ [global::System.Configuration.UserScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("")]
+ public string GrabTemplatesJSON {
+ get {
+ return ((string)(this["GrabTemplatesJSON"]));
+ }
+ set {
+ this["GrabTemplatesJSON"] = value;
+ }
+ }
+
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
@@ -850,7 +862,7 @@ public bool AddToContextMenu {
this["AddToContextMenu"] = value;
}
}
-
+
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings
index 63fb72c1..ac20c5b4 100644
--- a/Text-Grab/Properties/Settings.settings
+++ b/Text-Grab/Properties/Settings.settings
@@ -206,6 +206,9 @@
False
+
+
+
False
From 6fb2a4b82cbc94abb49ebd04d7ddacbafab08eba Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:30:21 -0600
Subject: [PATCH 026/109] Handle ApplicationDataContainer size limit exception
Add specific handling for COMException when saving settings
exceeds the ApplicationDataContainer 8 KB size limit. Log a
clear debug message suggesting large data be stored in a file.
Also, clarify the generic exception debug message.
---
Text-Grab/Services/SettingsService.cs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs
index fd1cddd8..73bc7d24 100644
--- a/Text-Grab/Services/SettingsService.cs
+++ b/Text-Grab/Services/SettingsService.cs
@@ -98,9 +98,15 @@ public void SaveSettingInContainer(string name, T value)
{
_localSettings.Values[name] = value;
}
+ catch (System.Runtime.InteropServices.COMException ex) when (ex.HResult == unchecked((int)0x80073DC8))
+ {
+ // The value exceeds the ApplicationDataContainer size limit (8 KB).
+ // Large data should be stored in a file instead.
+ Debug.WriteLine($"Setting '{name}' exceeds ApplicationDataContainer size limit: {ex.Message}");
+ }
catch (Exception ex)
{
- Debug.WriteLine($"Failed to Save setting from ApplicationDataContainer {ex.Message}");
+ Debug.WriteLine($"Failed to Save setting in ApplicationDataContainer: {ex.Message}");
#if DEBUG
throw;
#endif
From 58b7ba37b1c2b31c7f8688444e24f7c5cf38af0c Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:32:04 -0600
Subject: [PATCH 027/109] Add Grab Templates management to Post-Grab Actions
editor
Introduce a new "Grab Templates" section in the Post-Grab Actions editor, allowing users to create, view, and delete OCR region templates. Add UI for managing templates, including a list view and actions to open the Grab Frame or delete templates. Update Fullscreen Grab settings to link to the template management UI, making template-based OCR more accessible and discoverable. Includes supporting code for loading, refreshing, and deleting templates, as well as minor UI consistency improvements.
---
Text-Grab/Controls/PostGrabActionEditor.xaml | 220 ++++++++++++++----
.../Controls/PostGrabActionEditor.xaml.cs | 69 ++++++
Text-Grab/Pages/FullscreenGrabSettings.xaml | 58 +++--
.../Pages/FullscreenGrabSettings.xaml.cs | 12 +
4 files changed, 297 insertions(+), 62 deletions(-)
diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml
index 3126b4c2..9f6416f8 100644
--- a/Text-Grab/Controls/PostGrabActionEditor.xaml
+++ b/Text-Grab/Controls/PostGrabActionEditor.xaml
@@ -1,4 +1,4 @@
-
-
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
+
+ Icon="{StaticResource TextGrabIcon}"/>
+ Text="Available Actions"/>
+ TextWrapping="Wrap"/>
-
+
-
+
@@ -82,7 +86,7 @@
+ Header="Action Name"/>
@@ -94,7 +98,7 @@
FontSize="14"
Opacity="0.6"
Text="All actions are currently enabled"
- Visibility="Collapsed" />
+ Visibility="Collapsed"/>
@@ -113,8 +117,10 @@
Click="AddButton_Click"
ToolTip="Add selected action to enabled list">
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
@@ -164,12 +176,12 @@
Margin="0,0,0,8"
FontSize="18"
FontWeight="SemiBold"
- Text="Enabled Actions" />
+ Text="Enabled Actions"/>
+ TextWrapping="Wrap"/>
-
+
-
+
@@ -195,14 +209,16 @@
-
+ Header="Action Name"/>
+
-
-
-
-
+
+
+
+
@@ -217,12 +233,130 @@
x:Name="StayOpenToggle"
Margin="0,16,0,0"
Content="Keep menu open after clicking an action"
- ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected" />
+ ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ToolTip="Reset to default post-grab actions"/>
+ ToolTip="Save changes and close"/>
+ ToolTip="Close without saving"/>
diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
index a050266a..c82d375d 100644
--- a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
+++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
@@ -7,6 +7,7 @@
using System.Windows.Data;
using Text_Grab.Models;
using Text_Grab.Utilities;
+using Text_Grab.Views;
using Wpf.Ui.Controls;
namespace Text_Grab.Controls;
@@ -73,6 +74,9 @@ public PostGrabActionEditor()
// Update empty state visibility
UpdateEmptyStateVisibility();
+
+ // Load templates
+ LoadTemplates();
}
#endregion Constructors
@@ -199,5 +203,70 @@ 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 OpenGrabFrameButton_Click(object sender, RoutedEventArgs e)
+ {
+ GrabFrame grabFrame = new();
+ grabFrame.Closed += (_, _) => RefreshTemplatesAndActions();
+ grabFrame.Show();
+ grabFrame.Activate();
+ }
+
+ private void DeleteTemplateButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TemplatesListBox.SelectedItem is not GrabTemplate selected)
+ return;
+
+ System.Windows.MessageBoxResult result = System.Windows.MessageBox.Show(
+ $"Delete template '{selected.Name}'?",
+ "Delete Template",
+ System.Windows.MessageBoxButton.YesNo,
+ System.Windows.MessageBoxImage.Question);
+
+ if (result != System.Windows.MessageBoxResult.Yes)
+ 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();
+ }
+
#endregion Methods
}
diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml
index 54a6c2a2..57d04d05 100644
--- a/Text-Grab/Pages/FullscreenGrabSettings.xaml
+++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml
@@ -10,18 +10,21 @@
Loaded="Page_Loaded"
mc:Ignorable="d">
-
-
+
+
+ Text="Defaults"/>
-
+
+ Text="Single Line outputs captures as a single line (same as pressing S). Table mode requires Windows OCR/AI languages."/>
+ Content="Send output to Edit Text Window by default"/>
+ Text="Automatically route capture text into the Edit Text Window (same as pressing E)."/>
+ Content="Dim screen with translucent overlay while selecting"/>
+ Text="Controls the transparent overlay shown during Fullscreen Grab selection. Turn off to disable screen dimming."/>
+ Text="Post-capture insert"/>
+ Content="Insert captured text into the focused app after closing Fullscreen Grab"/>
+ Text="Insert delay (seconds):"/>
+ ValueChanged="InsertDelaySlider_ValueChanged"/>
+ Style="{StaticResource TextBodyNormal}"/>
+ Text="How long to wait before simulating paste (Ctrl+V)."/>
+ Text="Post-capture actions"/>
+ Text="Configure which actions are performed automatically after text is captured (Fix GUIDs, Trim lines, Remove duplicates, etc.)."/>
+ ToolTip="Open editor to add, remove, and reorder post-capture actions"/>
+ Text="Current actions: Loading..."/>
+
+
+
+
diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml.cs b/Text-Grab/Pages/FullscreenGrabSettings.xaml.cs
index 8f10bcac..4d63762c 100644
--- a/Text-Grab/Pages/FullscreenGrabSettings.xaml.cs
+++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml.cs
@@ -134,6 +134,18 @@ private void CustomizeActionsButton_Click(object sender, RoutedEventArgs e)
}
}
+ private void ManageTemplatesButton_Click(object sender, RoutedEventArgs e)
+ {
+ PostGrabActionEditor editor = new()
+ {
+ Owner = Window.GetWindow(this)
+ };
+
+ // ShowDialog returns true only on Save — update count regardless for consistency
+ editor.ShowDialog();
+ UpdateActionsCountText();
+ }
+
private void UpdateActionsCountText()
{
List enabledActions = PostGrabActionManager.GetEnabledPostGrabActions();
From 61af2a08e1436473118196f59c07861efe96c1d3 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 11:32:36 -0600
Subject: [PATCH 028/109] Add Grab Template creation and overlay support
Users can now define and save "Grab Templates" in the Grab Frame window by Ctrl+dragging regions and using a new "Save as Template" button. Templates store region layouts for structured extraction in fullscreen grabs. Added UI for naming and formatting templates, and logic to display template region overlays during fullscreen selection. Post-grab actions now receive region context for template-based processing. Includes XAML cleanup and is fully opt-in via Post-Grab Actions settings.
---
Text-Grab/Views/FullscreenGrab.xaml | 3 +
Text-Grab/Views/FullscreenGrab.xaml.cs | 112 ++++-
Text-Grab/Views/GrabFrame.xaml | 585 ++++++++++++++-----------
Text-Grab/Views/GrabFrame.xaml.cs | 71 +++
4 files changed, 515 insertions(+), 256 deletions(-)
diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml
index 5011adc1..e6fedeb3 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml
+++ b/Text-Grab/Views/FullscreenGrab.xaml
@@ -112,6 +112,9 @@
+
+ /// Draws scaled template region overlays inside the current selection border.
+ /// Regions are scaled using UniformToFill semantics relative to the template's
+ /// reference image dimensions so they fill the selected area proportionally.
+ ///
+ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double selWidth, double selHeight)
+ {
+ TemplateOverlayHost.Children.Clear();
+ templateOverlayCanvas.Children.Clear();
+
+ GrabTemplate? template = GetActiveTemplate();
+ if (template is null || template.Regions.Count == 0)
+ return;
+
+ if (selWidth < 4 || selHeight < 4)
+ return;
+
+ // Stretch: scale each axis independently to fill the selection exactly
+ double scaleX = selWidth / template.ReferenceImageWidth;
+ double scaleY = selHeight / template.ReferenceImageHeight;
+
+ templateOverlayCanvas.Width = selWidth;
+ templateOverlayCanvas.Height = selHeight;
+ Canvas.SetLeft(templateOverlayCanvas, selLeft);
+ Canvas.SetTop(templateOverlayCanvas, selTop);
+
+ System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(220, 255, 180, 0);
+
+ foreach (TemplateRegion region in template.Regions)
+ {
+ double regionLeft = region.RatioLeft * selWidth;
+ double regionTop = region.RatioTop * selHeight;
+ double regionWidth = region.RatioWidth * selWidth;
+ double regionHeight = region.RatioHeight * selHeight;
+
+ if (regionWidth < 1 || regionHeight < 1)
+ continue;
+
+ Border regionBorder = new()
+ {
+ Width = regionWidth,
+ Height = regionHeight,
+ BorderBrush = new SolidColorBrush(borderColor),
+ BorderThickness = new Thickness(1.5),
+ };
+
+ Canvas.SetLeft(regionBorder, regionLeft);
+ Canvas.SetTop(regionBorder, regionTop);
+ templateOverlayCanvas.Children.Add(regionBorder);
+ }
+
+ TemplateOverlayHost.Children.Add(templateOverlayCanvas);
+ }
+
private void GetDpiAdjustedRegionOfSelectBorder(out DpiScale dpi, out double posLeft, out double posTop)
{
System.Windows.Point absPosPoint = this.GetAbsolutePosition();
@@ -580,6 +655,8 @@ private void PanSelection(System.Windows.Point movingPoint)
new System.Windows.Size(selectBorder.Width - 2, selectBorder.Height - 2));
Canvas.SetLeft(selectBorder, leftValue - 1);
Canvas.SetTop(selectBorder, topValue - 1);
+
+ UpdateTemplateRegionOverlays(leftValue - 1, topValue - 1, selectBorder.Width, selectBorder.Height);
}
private void PlaceGrabFrameInSelectionRect()
@@ -652,6 +729,8 @@ private void RegionClickCanvas_MouseDown(object sender, MouseButtonEventArgs e)
dpiScale = VisualTreeHelper.GetDpi(this);
try { RegionClickCanvas.Children.Remove(selectBorder); } catch (Exception) { }
+ TemplateOverlayHost.Children.Clear();
+ templateOverlayCanvas.Children.Clear();
selectBorder.BorderThickness = new Thickness(2);
System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(255, 40, 118, 126);
@@ -697,6 +776,8 @@ private void RegionClickCanvas_MouseMove(object sender, MouseEventArgs e)
new System.Windows.Size(selectBorder.Width - 2, selectBorder.Height - 2));
Canvas.SetLeft(selectBorder, left - 1);
Canvas.SetTop(selectBorder, top - 1);
+
+ UpdateTemplateRegionOverlays(left - 1, top - 1, selectBorder.Width, selectBorder.Height);
}
private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs e)
@@ -712,6 +793,9 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
new System.Windows.Point(0, 0),
new System.Windows.Size(0, 0));
+ TemplateOverlayHost.Children.Clear();
+ templateOverlayCanvas.Children.Clear();
+
System.Windows.Point movingPoint = e.GetPosition(this);
Matrix m = PresentationSource.FromVisual(this).CompositionTarget.TransformToDevice;
movingPoint.X *= m.M11;
@@ -729,6 +813,14 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
(int)(selectBorder.Width * m.M11),
(int)(selectBorder.Height * m.M22));
+ // Build the absolute capture rect for template execution (physical screen pixels)
+ System.Windows.Point absoluteWindowPos = this.GetAbsolutePosition();
+ Rect absoluteCaptureRect = new(
+ absoluteWindowPos.X + xDimScaled,
+ absoluteWindowPos.Y + yDimScaled,
+ selectBorder.Width * m.M11,
+ selectBorder.Height * m.M22);
+
TextFromOCR = string.Empty;
if (NewGrabFrameMenuItem.IsChecked is true)
@@ -779,7 +871,7 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
PositionRect = historyRect,
IsTable = TableToggleButton.IsChecked!.Value,
TextContent = TextFromOCR,
- ImageContent = Singleton.Instance.CachedBitmap,
+ ImageContent = Singleton.Instance.CachedBitmap is Bitmap cb ? new Bitmap(cb) : null,
SourceMode = TextGrabMode.Fullscreen,
};
}
@@ -807,8 +899,16 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
continue;
}
+ // Build context for this action (text may have changed from previous actions)
+ PostGrabContext grabContext = new(
+ Text: TextFromOCR ?? string.Empty,
+ CaptureRegion: absoluteCaptureRect,
+ DpiScale: m.M11,
+ CapturedImage: null,
+ Language: selectedOcrLang);
+
// Execute the action
- TextFromOCR = await PostGrabActionManager.ExecutePostGrabAction(action, TextFromOCR);
+ TextFromOCR = await PostGrabActionManager.ExecutePostGrabAction(action, grabContext);
}
}
@@ -1021,7 +1121,7 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
if (NextStepDropDownButton.Flyout is ContextMenu contextMenu)
{
contextMenu.PreviewKeyDown -= FullscreenGrab_KeyDown;
-
+
foreach (object item in contextMenu.Items)
{
if (item is MenuItem menuItem)
@@ -1043,7 +1143,7 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
}
}
}
-
+
contextMenu.Items.Clear();
}
@@ -1137,7 +1237,7 @@ private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e =
private void PostActionMenuItem_Click(object sender, RoutedEventArgs e)
{
// Save check state for LastUsed tracking
- if (sender is MenuItem menuItem
+ if (sender is MenuItem menuItem
&& menuItem.Tag is ButtonInfo action
&& action.DefaultCheckState == DefaultCheckState.LastUsed)
{
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index fecddb79..3eb7a778 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -37,14 +37,21 @@
WindowStyle="None"
mc:Ignorable="d">
-
+
-
@@ -52,31 +59,31 @@
+ Executed="PasteExecuted"/>
+ Executed="UndoExecuted"/>
+ Executed="RedoExecuted"/>
+ Executed="DeleteWordBordersExecuted"/>
+ Executed="MergeWordBordersExecuted"/>
+ Executed="GrabExecuted"/>
+ Executed="GrabTrimExecuted"/>
@@ -84,7 +91,7 @@
CaptionHeight="32"
CornerRadius="18,18,2,18"
GlassFrameThickness="0"
- ResizeBorderThickness="5" />
+ ResizeBorderThickness="5"/>
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
@@ -411,8 +424,10 @@
@@ -427,7 +442,7 @@
Padding="8,2"
HorizontalAlignment="Right"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
- WindowChrome.IsHitTestVisibleInChrome="True" />
+ WindowChrome.IsHitTestVisibleInChrome="True"/>
-
+
+ Text=""/>
@@ -470,7 +487,8 @@
Grid.Row="1"
ClipToBounds="True">
-
+
@@ -482,7 +500,7 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
Opacity="0"
- Stretch="Uniform" />
+ Stretch="Uniform"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+ IsCheckable="True"/>
+ IsCheckable="True"/>
@@ -672,7 +745,7 @@
Foreground="{ui:ThemeResource TextFillColorSecondaryBrush}"
IsHitTestVisible="False"
Opacity="0.5"
- Visibility="{Binding Visibility, ElementName=SearchBox, Mode=OneWay}" />
+ Visibility="{Binding Visibility, ElementName=SearchBox, Mode=OneWay}"/>
+ Visibility="Collapsed"/>
+ Text="Matches: 0"/>
-
-
+
+
+ Header="Edit Selected in new Window"/>
@@ -728,7 +802,7 @@
VerticalAlignment="Center"
FontFamily="Segoe UI"
Style="{StaticResource SymbolTextStyle}"
- Text="{Binding LanguageTag}" />
+ Text="{Binding LanguageTag}"/>
@@ -747,7 +821,7 @@
ButtonText="Re-OCR Frame"
Click="RefreshBTN_Click"
IsSymbol="True"
- ToolTip="Re-OCR Frame (Ctrl + R)" />
+ ToolTip="Re-OCR Frame (Ctrl + R)"/>
+ Visibility="Collapsed"/>
-
+
-
+
-
+
-
+
+ Visibility="Collapsed"/>
+
+
+
+
+ ToolTip="Grab text in the Grab Frame"/>
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index f9db5caa..82b4d31a 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -2178,6 +2178,77 @@ private void SettingsBTN_Click(object sender, RoutedEventArgs e)
WindowUtilities.OpenOrActivateWindow();
}
+ private void SaveAsTemplate_Click(object sender, RoutedEventArgs e)
+ {
+ bool show = SaveAsTemplateBTN.IsChecked == true;
+ TemplateSavePanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
+ if (show)
+ TemplateNameBox.Focus();
+ }
+
+ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
+ {
+ string name = TemplateNameBox.Text.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ TemplateNameBox.Focus();
+ return;
+ }
+
+ if (wordBorders.Count == 0)
+ {
+ MessageBox.Show(
+ "Use Ctrl+drag to draw at least one region before saving.",
+ "No Regions",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return;
+ }
+
+ double cw = RectanglesCanvas.ActualWidth;
+ double ch = RectanglesCanvas.ActualHeight;
+
+ // Sort regions in reading order: top-to-bottom, then left-to-right
+ List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
+
+ List regions = sorted.Select((wb, i) => new TemplateRegion
+ {
+ RegionNumber = i + 1,
+ Label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word,
+ RatioLeft = wb.Left / cw,
+ RatioTop = wb.Top / ch,
+ RatioWidth = wb.ActualWidth / cw,
+ RatioHeight = wb.ActualHeight / ch,
+ }).ToList();
+
+ GrabTemplate template = new(name)
+ {
+ OutputTemplate = OutputTemplateBox.Text,
+ ReferenceImageWidth = cw,
+ ReferenceImageHeight = ch,
+ Regions = regions,
+ };
+
+ GrabTemplateManager.AddOrUpdateTemplate(template);
+
+ SaveAsTemplateBTN.IsChecked = false;
+ TemplateSavePanel.Visibility = Visibility.Collapsed;
+ TemplateNameBox.Text = string.Empty;
+ OutputTemplateBox.Text = string.Empty;
+
+ MessageBox.Show(
+ $"Template \"{name}\" saved with {regions.Count} region(s).\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.",
+ "Template Saved",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+
+ private void SaveTemplateCancel_Click(object sender, RoutedEventArgs e)
+ {
+ SaveAsTemplateBTN.IsChecked = false;
+ TemplateSavePanel.Visibility = Visibility.Collapsed;
+ }
+
private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null)
{
RemoveTableLines();
From 66b78566b9f303d93753cb591039346e46580f72 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 16:28:51 -0600
Subject: [PATCH 029/109] Enhance Grab Template editing and region
visualization
- Add "Edit Grab Template" button to open templates in GrabFrame for region editing with reference image support
- Implement inline template editor for name/output template changes
- Show region index badges on template regions for clarity
- Save and load template reference images in a dedicated folder
- Improve XAML consistency, tooltips, and button labeling
- Ensure template IDs and creation dates are preserved on edit
- Update region badges dynamically as regions are modified
---
Text-Grab/Controls/PostGrabActionEditor.xaml | 246 +++++++++++-------
.../Controls/PostGrabActionEditor.xaml.cs | 71 +++++
Text-Grab/Controls/WordBorder.xaml | 15 ++
Text-Grab/Controls/WordBorder.xaml.cs | 23 ++
Text-Grab/Utilities/GrabTemplateManager.cs | 49 ++++
Text-Grab/Views/GrabFrame.xaml.cs | 118 ++++++++-
6 files changed, 428 insertions(+), 94 deletions(-)
diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml
index 9f6416f8..f061a50b 100644
--- a/Text-Grab/Controls/PostGrabActionEditor.xaml
+++ b/Text-Grab/Controls/PostGrabActionEditor.xaml
@@ -14,24 +14,23 @@
WindowStartupLocation="CenterOwner"
mc:Ignorable="d">
-
+
-
+
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+ Icon="{StaticResource TextGrabIcon}" />
+ Text="Available Actions" />
+ TextWrapping="Wrap" />
-
+
-
+
@@ -86,7 +83,7 @@
+ Header="Action Name" />
@@ -98,7 +95,7 @@
FontSize="14"
Opacity="0.6"
Text="All actions are currently enabled"
- Visibility="Collapsed"/>
+ Visibility="Collapsed" />
@@ -117,10 +114,8 @@
Click="AddButton_Click"
ToolTip="Add selected action to enabled list">
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
@@ -176,12 +165,12 @@
Margin="0,0,0,8"
FontSize="18"
FontWeight="SemiBold"
- Text="Enabled Actions"/>
+ Text="Enabled Actions" />
+ TextWrapping="Wrap" />
-
+
-
+
@@ -209,16 +196,14 @@
-
+ Header="Action Name" />
+
-
-
-
-
+
+
+
+
@@ -233,7 +218,7 @@
x:Name="StayOpenToggle"
Margin="0,16,0,0"
Content="Keep menu open after clicking an action"
- ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected"/>
+ ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected" />
@@ -242,51 +227,69 @@
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="16,0,16,8"
+ Background="{ui:ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ui:ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
- CornerRadius="8"
- Background="{ui:ThemeResource CardBackgroundFillColorSecondaryBrush}">
+ CornerRadius="8">
-
-
+
+
+
-
+
-
-
+
+
-
+
+ Text="Grab Templates" />
+ TextWrapping="Wrap" />
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ ToolTip="Open a blank Grab Frame to draw regions and save as a new template">
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
+
+
@@ -309,32 +310,29 @@
x:Name="TemplatesListBox"
MaxHeight="140"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
- ScrollViewer.VerticalScrollBarVisibility="Auto">
+ ScrollViewer.VerticalScrollBarVisibility="Auto"
+ SelectionChanged="TemplatesListBox_SelectionChanged">
-
+ Header="Template Name" />
+
-
+
-
+ Header="Output Template" />
+
-
+
@@ -349,8 +347,76 @@
Opacity="0.6"
Text="No templates defined yet. Click 'Open Grab Frame', use Ctrl+drag to draw regions, then click the Save as Template button in the Grab Frame."
TextWrapping="Wrap"
- Visibility="Collapsed"/>
+ Visibility="Collapsed" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -367,19 +433,19 @@
Margin="0,0,8,0"
Click="ResetButton_Click"
Content="Reset to Defaults"
- ToolTip="Reset to default post-grab actions"/>
+ ToolTip="Reset to default post-grab actions" />
+ ToolTip="Save changes and close" />
+ ToolTip="Close without saving" />
diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
index c82d375d..e7d0ebe2 100644
--- a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
+++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
@@ -38,6 +38,7 @@ public partial class PostGrabActionEditor : FluentWindow
private ObservableCollection AvailableActions { get; set; }
private ObservableCollection EnabledActions { get; set; }
+ private GrabTemplate? _editingTemplate;
#endregion Properties
@@ -225,6 +226,24 @@ private void OpenGrabFrameButton_Click(object sender, RoutedEventArgs e)
grabFrame.Activate();
}
+ private void EditTemplateRegionsButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TemplatesListBox.SelectedItem is not GrabTemplate selected)
+ {
+ System.Windows.MessageBox.Show(
+ "Select a template from the list first.",
+ "No Template Selected",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Information);
+ return;
+ }
+
+ GrabFrame grabFrame = new(selected);
+ grabFrame.Closed += (_, _) => RefreshTemplatesAndActions();
+ grabFrame.Show();
+ grabFrame.Activate();
+ }
+
private void DeleteTemplateButton_Click(object sender, RoutedEventArgs e)
{
if (TemplatesListBox.SelectedItem is not GrabTemplate selected)
@@ -268,5 +287,57 @@ private void RefreshTemplatesAndActions()
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;
+ 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/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
{
diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs
index 6f01f9f2..03d9126b 100644
--- a/Text-Grab/Utilities/GrabTemplateManager.cs
+++ b/Text-Grab/Utilities/GrabTemplateManager.cs
@@ -4,6 +4,8 @@
using System.IO;
using System.Linq;
using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Windows.Media.Imaging;
using Text_Grab.Models;
using Text_Grab.Properties;
using Wpf.Ui.Controls;
@@ -49,6 +51,53 @@ private static string GetTemplatesFilePath()
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 = Regex.Replace(templateName.ToLowerInvariant(), @"[^\w]+", "-").Trim('-');
+ string shortId = templateId.Length >= 8 ? templateId[..8] : templateId;
+ string filePath = Path.Combine(folder, $"{safeName}_{shortId}.png");
+
+ PngBitmapEncoder encoder = new();
+ encoder.Frames.Add(BitmapFrame.Create(imageSource));
+ using FileStream fs = new(filePath, FileMode.Create, FileAccess.Write);
+ encoder.Save(fs);
+
+ 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 (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");
+ }
+
// ── Migration from settings ───────────────────────────────────────────────
private static void MigrateFromSettingsIfNeeded()
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 82b4d31a..2cfaa412 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -54,6 +54,8 @@ public partial class GrabFrame : Window
private TextBox? destinationTextBox;
private ImageSource? frameContentImageSource;
private HistoryInfo? historyItem;
+ private GrabTemplate? _editingTemplate;
+ private string? _currentImagePath;
private bool hasLoadedImageSource = false;
private bool IsDragOver = false;
private bool isDrawing = false;
@@ -136,6 +138,79 @@ public GrabFrame(string imagePath)
Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath);
}
+ ///
+ /// Opens GrabFrame in template editing mode with existing regions pre-loaded.
+ ///
+ /// The template to edit.
+ public GrabFrame(GrabTemplate template)
+ {
+ StandardInitialize();
+
+ ShouldSaveOnClose = false;
+ _editingTemplate = template;
+ Title = $"Edit Template: {template.Name}";
+
+ Loaded += async (s, e) => await LoadTemplateForEditing(template);
+ }
+
+ private async Task LoadTemplateForEditing(GrabTemplate template)
+ {
+ TemplateNameBox.Text = template.Name;
+ OutputTemplateBox.Text = template.OutputTemplate;
+
+ SaveAsTemplateBTN.IsChecked = true;
+ TemplateSavePanel.Visibility = Visibility.Visible;
+
+ if (!string.IsNullOrEmpty(template.SourceImagePath) && File.Exists(template.SourceImagePath))
+ {
+ await TryLoadImageFromPath(template.SourceImagePath);
+ reDrawTimer.Stop();
+ }
+ else
+ {
+ // No reference image — freeze into a clean empty canvas without capturing the screen
+ GrabFrameImage.Opacity = 0;
+ FreezeToggleButton.IsChecked = true;
+ FreezeToggleButton.Visibility = Visibility.Collapsed;
+ Topmost = false;
+ Background = new SolidColorBrush(Colors.DimGray);
+ RectanglesBorder.Background.Opacity = 0;
+ IsFreezeMode = true;
+ }
+
+ // Allow WPF to measure the canvas after the image loads
+ await Task.Delay(150);
+
+ double cw = RectanglesCanvas.ActualWidth;
+ double ch = RectanglesCanvas.ActualHeight;
+
+ if (cw <= 0) cw = template.ReferenceImageWidth;
+ if (ch <= 0) ch = template.ReferenceImageHeight;
+
+ foreach (TemplateRegion region in template.Regions.OrderBy(r => r.RegionNumber))
+ {
+ Rect abs = region.ToAbsoluteRect(cw, ch);
+
+ WordBorder wb = new()
+ {
+ Width = Math.Max(abs.Width, 10),
+ Height = Math.Max(abs.Height, 10),
+ Left = abs.X,
+ Top = abs.Y,
+ Word = region.Label,
+ OwnerGrabFrame = this,
+ MatchingBackground = new SolidColorBrush(Colors.Black),
+ };
+
+ wordBorders.Add(wb);
+ _ = RectanglesCanvas.Children.Add(wb);
+ }
+
+ EnterEditMode();
+ UpdateTemplateBadges();
+ reSearchTimer.Start();
+ }
+
private async Task LoadContentFromHistory(HistoryInfo history)
{
FrameText = history.TextContent;
@@ -1435,7 +1510,7 @@ private bool HandleCtrlCombo(Key key)
private void HandleDelete(object? sender = null, RoutedEventArgs? e = null)
{
- if (SearchBox.IsFocused)
+ if (Keyboard.FocusedElement is TextBox)
return;
UndoRedo.StartTransaction();
@@ -2071,6 +2146,9 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e)
MatchesTXTBLK.Text = $"{numberOfMatches} Matches";
MatchesMenu.Visibility = Visibility.Visible;
LanguagesComboBox.Visibility = Visibility.Collapsed;
+
+ if (SaveAsTemplateBTN.IsChecked == true)
+ UpdateTemplateBadges();
}
private void ResetGrabFrame()
@@ -2184,6 +2262,8 @@ private void SaveAsTemplate_Click(object sender, RoutedEventArgs e)
TemplateSavePanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
if (show)
TemplateNameBox.Focus();
+
+ UpdateTemplateBadges();
}
private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
@@ -2211,7 +2291,7 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
// Sort regions in reading order: top-to-bottom, then left-to-right
List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
- List regions = sorted.Select((wb, i) => new TemplateRegion
+ List regions = [.. sorted.Select((wb, i) => new TemplateRegion
{
RegionNumber = i + 1,
Label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word,
@@ -2219,7 +2299,7 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
RatioTop = wb.Top / ch,
RatioWidth = wb.ActualWidth / cw,
RatioHeight = wb.ActualHeight / ch,
- }).ToList();
+ })];
GrabTemplate template = new(name)
{
@@ -2229,12 +2309,24 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
Regions = regions,
};
+ if (_editingTemplate is not null)
+ {
+ template.Id = _editingTemplate.Id;
+ template.CreatedDate = _editingTemplate.CreatedDate;
+ }
+
+ template.SourceImagePath = GrabTemplateManager.SaveTemplateReferenceImage(frameContentImageSource as BitmapSource, name, template.Id)
+ ?? _currentImagePath
+ ?? _editingTemplate?.SourceImagePath
+ ?? string.Empty;
+
GrabTemplateManager.AddOrUpdateTemplate(template);
SaveAsTemplateBTN.IsChecked = false;
TemplateSavePanel.Visibility = Visibility.Collapsed;
TemplateNameBox.Text = string.Empty;
OutputTemplateBox.Text = string.Empty;
+ UpdateTemplateBadges();
MessageBox.Show(
$"Template \"{name}\" saved with {regions.Count} region(s).\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.",
@@ -2247,6 +2339,23 @@ private void SaveTemplateCancel_Click(object sender, RoutedEventArgs e)
{
SaveAsTemplateBTN.IsChecked = false;
TemplateSavePanel.Visibility = Visibility.Collapsed;
+ UpdateTemplateBadges();
+ }
+
+ private void UpdateTemplateBadges()
+ {
+ bool isTemplateMode = SaveAsTemplateBTN.IsChecked == true;
+
+ if (!isTemplateMode)
+ {
+ foreach (WordBorder wb in wordBorders)
+ wb.TemplateIndex = 0;
+ return;
+ }
+
+ List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
+ for (int i = 0; i < sorted.Count; i++)
+ sorted[i].TemplateIndex = i + 1;
}
private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null)
@@ -2270,6 +2379,7 @@ private async Task TryLoadImageFromPath(string path)
droppedImage.EndInit();
frameContentImageSource = droppedImage;
hasLoadedImageSource = true;
+ _currentImagePath = path;
FreezeToggleButton.IsChecked = true;
FreezeGrabFrame();
FreezeToggleButton.Visibility = Visibility.Collapsed;
@@ -2485,7 +2595,7 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
if (IsCtrlDown)
RectanglesCanvas.Cursor = Cursors.Cross;
- if (IsEditingAnyWordBorders || SearchBox.IsFocused)
+ if (IsEditingAnyWordBorders || Keyboard.FocusedElement is TextBox)
return;
if (e.Key == Key.Delete)
From 092bf5d219de72a364df82b56e486e42c382122e Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 20:36:14 -0600
Subject: [PATCH 030/109] Add inline chip picker control for RichTextBox
Introduces InlinePickerRichTextBox, enabling users to insert and remove inline "chip" elements (with label and value) via a popup picker triggered by typing '{'. Includes supporting InlineChipElement and InlinePickerItem classes, plus XAML styles for a compact, modern UI. Chips are serializable to and from plain text, supporting scenarios like variable or tag insertion.
---
Text-Grab/Controls/InlineChipElement.cs | 48 ++
Text-Grab/Controls/InlinePickerItem.cs | 17 +
Text-Grab/Controls/InlinePickerRichTextBox.cs | 566 ++++++++++++++++++
Text-Grab/Themes/Generic.xaml | 122 ++++
4 files changed, 753 insertions(+)
create mode 100644 Text-Grab/Controls/InlineChipElement.cs
create mode 100644 Text-Grab/Controls/InlinePickerItem.cs
create mode 100644 Text-Grab/Controls/InlinePickerRichTextBox.cs
create mode 100644 Text-Grab/Themes/Generic.xaml
diff --git a/Text-Grab/Controls/InlineChipElement.cs b/Text-Grab/Controls/InlineChipElement.cs
new file mode 100644
index 00000000..da12f279
--- /dev/null
+++ b/Text-Grab/Controls/InlineChipElement.cs
@@ -0,0 +1,48 @@
+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;
+
+ static InlineChipElement()
+ {
+ DefaultStyleKeyProperty.OverrideMetadata(
+ typeof(InlineChipElement),
+ new FrameworkPropertyMetadata(typeof(InlineChipElement)));
+ }
+
+ public override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ if (GetTemplateChild(PartRemoveButton) is Button removeButton)
+ removeButton.Click += (s, 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..fc3f82d2
--- /dev/null
+++ b/Text-Grab/Controls/InlinePickerItem.cs
@@ -0,0 +1,17 @@
+namespace Text_Grab.Controls;
+
+public class InlinePickerItem
+{
+ public string DisplayName { get; set; } = string.Empty;
+ public string Value { get; set; } = string.Empty;
+
+ public InlinePickerItem() { }
+
+ public InlinePickerItem(string displayName, string value)
+ {
+ DisplayName = displayName;
+ Value = value;
+ }
+
+ 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..615c455d
--- /dev/null
+++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs
@@ -0,0 +1,566 @@
+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;
+
+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.
+///
+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;
+
+ 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 = 120,
+ MaxHeight = 150,
+ FontSize = 11,
+ BorderThickness = new Thickness(0),
+ Background = Brushes.Transparent,
+ FocusVisualStyle = null,
+ Focusable = false,
+ };
+ lb.SetResourceReference(ForegroundProperty, "TextFillColorPrimaryBrush");
+
+ // Item template: DisplayName + muted Value label side-by-side
+ 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;
+ lb.ItemTemplate = dt;
+
+ lb.PreviewMouseDown += ListBox_PreviewMouseDown;
+ lb.ItemContainerStyle = BuildCompactItemStyle();
+ return lb;
+ }
+
+ 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));
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 0a4df295d8393d22ac958a62b21e079ae4fb8816 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 20:36:27 -0600
Subject: [PATCH 031/109] Add chip picker for template output editing in
GrabFrame
Introduce InlinePickerRichTextBox for editing Grab Template output, enabling users to insert region chips via a picker UI. Hide the old plain TextBox and update all related logic to use the new control, including serialization and deserialization of template output. Ensure region chips are kept in sync with word borders, and improve file handling for template images. Also includes XAML cleanup and minor refactoring for maintainability.
---
Text-Grab/Utilities/GrabTemplateManager.cs | 13 +-
Text-Grab/Views/GrabFrame.xaml | 343 ++++++++++-----------
Text-Grab/Views/GrabFrame.xaml.cs | 40 ++-
3 files changed, 207 insertions(+), 189 deletions(-)
diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs
index 03d9126b..70e99ef2 100644
--- a/Text-Grab/Utilities/GrabTemplateManager.cs
+++ b/Text-Grab/Utilities/GrabTemplateManager.cs
@@ -67,15 +67,22 @@ private static string GetTemplatesFilePath()
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
- string safeName = Regex.Replace(templateName.ToLowerInvariant(), @"[^\w]+", "-").Trim('-');
+ 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(filePath, FileMode.Create, FileAccess.Write);
- encoder.Save(fs);
+ 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)
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index 3eb7a778..6f64338b 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -37,21 +37,14 @@
WindowStyle="None"
mc:Ignorable="d">
-
+
-
@@ -59,31 +52,31 @@
+ Executed="PasteExecuted" />
+ Executed="UndoExecuted" />
+ Executed="RedoExecuted" />
+ Executed="DeleteWordBordersExecuted" />
+ Executed="MergeWordBordersExecuted" />
+ Executed="GrabExecuted" />
+ Executed="GrabTrimExecuted" />
@@ -91,7 +84,7 @@
CaptionHeight="32"
CornerRadius="18,18,2,18"
GlassFrameThickness="0"
- ResizeBorderThickness="5"/>
+ ResizeBorderThickness="5" />
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
@@ -424,10 +412,8 @@
@@ -442,7 +428,7 @@
Padding="8,2"
HorizontalAlignment="Right"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
- WindowChrome.IsHitTestVisibleInChrome="True"/>
+ WindowChrome.IsHitTestVisibleInChrome="True" />
-
+
+ Text="" />
@@ -487,8 +471,7 @@
Grid.Row="1"
ClipToBounds="True">
-
+
@@ -500,7 +483,7 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
Opacity="0"
- Stretch="Uniform"/>
+ Stretch="Uniform" />
+ InputGestureText="Ctrl + Shift + V" />
-
+ InputGestureText="Ctrl + O" />
+
-
+ Header="Copy Text" />
+
+ Unchecked="AspectRationMI_Checked" />
-
+ InputGestureText="F" />
+
+ Header="Try To Make _Numbers" />
+ Header="Try To Make _Letters" />
+ InputGestureText="Ctrl + M" />
+ InputGestureText="Del" />
@@ -577,21 +560,21 @@
Direction="270"
Opacity="0.3"
ShadowDepth="2"
- Color="Black"/>
+ Color="Black" />
-
-
-
-
+
+
+
+
+ Symbol="LocalLanguage24" />
+ Text="Translating..." />
+ Value="0" />
+ Text="0/0" />
+ Symbol="Dismiss24" />
@@ -648,64 +631,69 @@
Visibility="Collapsed">
-
-
-
-
-
-
+
+
+
+
+
+
+ Text="Template Name:" />
+ VerticalContentAlignment="Center" />
+ Text="Output (type { to pick):"
+ ToolTip="Type { to insert a region chip. Plain text is also supported." />
+ ToolTip="Use {1}, {2}, etc. for region values."
+ Visibility="Collapsed" />
+
+ Content="Save Template" />
+ Content="Cancel" />
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+ IsCheckable="True" />
+ IsCheckable="True" />
@@ -745,7 +733,7 @@
Foreground="{ui:ThemeResource TextFillColorSecondaryBrush}"
IsHitTestVisible="False"
Opacity="0.5"
- Visibility="{Binding Visibility, ElementName=SearchBox, Mode=OneWay}"/>
+ Visibility="{Binding Visibility, ElementName=SearchBox, Mode=OneWay}" />
+ Visibility="Collapsed" />
+ Text="Matches: 0" />
-
-
+
+
+ Header="Edit Selected in new Window" />
@@ -802,7 +789,7 @@
VerticalAlignment="Center"
FontFamily="Segoe UI"
Style="{StaticResource SymbolTextStyle}"
- Text="{Binding LanguageTag}"/>
+ Text="{Binding LanguageTag}" />
@@ -821,7 +808,7 @@
ButtonText="Re-OCR Frame"
Click="RefreshBTN_Click"
IsSymbol="True"
- ToolTip="Re-OCR Frame (Ctrl + R)"/>
+ ToolTip="Re-OCR Frame (Ctrl + R)" />
+ Visibility="Collapsed" />
-
+
-
+
-
+
-
+
+ Visibility="Collapsed" />
-
+
@@ -921,7 +908,7 @@
ButtonSymbol="Copy24"
ButtonText="Grab"
Command="{x:Static local:GrabFrame.GrabCommand}"
- ToolTip="Grab text in the Grab Frame"/>
+ ToolTip="Grab text in the Grab Frame" />
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 2cfaa412..c63269e8 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -54,7 +54,7 @@ public partial class GrabFrame : Window
private TextBox? destinationTextBox;
private ImageSource? frameContentImageSource;
private HistoryInfo? historyItem;
- private GrabTemplate? _editingTemplate;
+ private readonly GrabTemplate? _editingTemplate;
private string? _currentImagePath;
private bool hasLoadedImageSource = false;
private bool IsDragOver = false;
@@ -156,7 +156,6 @@ public GrabFrame(GrabTemplate template)
private async Task LoadTemplateForEditing(GrabTemplate template)
{
TemplateNameBox.Text = template.Name;
- OutputTemplateBox.Text = template.OutputTemplate;
SaveAsTemplateBTN.IsChecked = true;
TemplateSavePanel.Visibility = Visibility.Visible;
@@ -208,6 +207,9 @@ private async Task LoadTemplateForEditing(GrabTemplate template)
EnterEditMode();
UpdateTemplateBadges();
+ UpdateTemplatePickerItems();
+ // Repopulate the output box AFTER ItemsSource is set so chips are recreated correctly
+ TemplateOutputBox.SetSerializedText(template.OutputTemplate);
reSearchTimer.Start();
}
@@ -2261,7 +2263,14 @@ private void SaveAsTemplate_Click(object sender, RoutedEventArgs e)
bool show = SaveAsTemplateBTN.IsChecked == true;
TemplateSavePanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
if (show)
+ {
+ if (!IsFreezeMode)
+ {
+ FreezeToggleButton.IsChecked = true;
+ FreezeGrabFrame();
+ }
TemplateNameBox.Focus();
+ }
UpdateTemplateBadges();
}
@@ -2303,7 +2312,7 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
GrabTemplate template = new(name)
{
- OutputTemplate = OutputTemplateBox.Text,
+ OutputTemplate = TemplateOutputBox.GetSerializedText(),
ReferenceImageWidth = cw,
ReferenceImageHeight = ch,
Regions = regions,
@@ -2325,7 +2334,7 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
SaveAsTemplateBTN.IsChecked = false;
TemplateSavePanel.Visibility = Visibility.Collapsed;
TemplateNameBox.Text = string.Empty;
- OutputTemplateBox.Text = string.Empty;
+ TemplateOutputBox.SetSerializedText(string.Empty);
UpdateTemplateBadges();
MessageBox.Show(
@@ -2356,6 +2365,20 @@ private void UpdateTemplateBadges()
List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
for (int i = 0; i < sorted.Count; i++)
sorted[i].TemplateIndex = i + 1;
+
+ UpdateTemplatePickerItems();
+ }
+
+ private void UpdateTemplatePickerItems()
+ {
+ List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
+ TemplateOutputBox.ItemsSource = sorted
+ .Select((wb, i) =>
+ {
+ string label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word;
+ return new InlinePickerItem(label, $"{{{i + 1}}}");
+ })
+ .ToList();
}
private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null)
@@ -2372,9 +2395,10 @@ private async Task TryLoadImageFromPath(string path)
ResetGrabFrame();
await Task.Delay(300);
BitmapImage droppedImage = new();
- droppedImage.BeginInit();
- droppedImage.UriSource = fileURI;
- System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path);
+ droppedImage.BeginInit();
+ droppedImage.UriSource = fileURI;
+ droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle
+ System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path);
ImageMethods.RotateImage(droppedImage, rotateFlipType);
droppedImage.EndInit();
frameContentImageSource = droppedImage;
@@ -2595,7 +2619,7 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
if (IsCtrlDown)
RectanglesCanvas.Cursor = Cursors.Cross;
- if (IsEditingAnyWordBorders || Keyboard.FocusedElement is TextBox)
+ if (IsEditingAnyWordBorders || Keyboard.FocusedElement is TextBox or RichTextBox)
return;
if (e.Key == Key.Delete)
From 64875dfa0256a6f31fa88d1f2a81b5bdcfe87a42 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 20:50:52 -0600
Subject: [PATCH 032/109] Remove unused OutputTemplateBox; enhance
TemplateOutputBox
Removed the hidden OutputTemplateBox TextBox from the UI. Updated TemplateOutputBox (InlinePickerRichTextBox) to support multi-line and tab input, and clarified its tooltip.
---
Text-Grab/Views/GrabFrame.xaml | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index 6f64338b..ec49cd0e 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -654,18 +654,12 @@
VerticalAlignment="Center"
Text="Output (type { to pick):"
ToolTip="Type { to insert a region chip. Plain text is also supported." />
-
@@ -775,8 +758,7 @@
BorderThickness="0"
Click="CalcCopyAllButton_Click"
ToolTip="Copy All Results">
-
+
-
+
@@ -810,62 +791,55 @@
Margin="0,0,0,8"
FontSize="16"
FontWeight="Bold"
- Text="Calculation Pane"/>
-
-
+ Text="Calculation Pane" />
+
+
-
-
+ Text="Features:" />
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+ Text="Examples:" />
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+ TextWrapping="Wrap" />
-
+
-
+
@@ -901,7 +875,7 @@
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
MouseDoubleClick="TextBoxSplitter_MouseDoubleClick"
ResizeDirection="Auto"
- ShowsPreview="True"/>
+ ShowsPreview="True" />
-
+
-
-
+
+
@@ -937,13 +908,13 @@
Grid.Row="2"
Background="Transparent">
-
-
+
+
+ Orientation="Horizontal" />
+ Text="0 matches" />
+ Text="0 similar" />
+ Header="Save Pattern" />
+ Header="Explain Pattern" />
@@ -1009,17 +980,17 @@
+ Text="Regex: " />
+ Header="Save Pattern" />
+ Header="Explain Pattern" />
@@ -1037,12 +1008,12 @@
x:Name="CharDetailsButtonText"
FontFamily="Cascadia Mono"
FontSize="12"
- Text="U+0000"/>
+ Text="U+0000" />
+ Text="Ln 1, Col 0" />
-
+
-
+
@@ -1080,14 +1049,14 @@
x:Name="ProgressRing"
Width="60"
Height="60"
- IsIndeterminate="True"/>
+ IsIndeterminate="True" />
+ Text="Working..." />
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index c63269e8..f2ab0375 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -2372,13 +2372,12 @@ private void UpdateTemplateBadges()
private void UpdateTemplatePickerItems()
{
List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
- TemplateOutputBox.ItemsSource = sorted
+ TemplateOutputBox.ItemsSource = [.. sorted
.Select((wb, i) =>
{
string label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word;
return new InlinePickerItem(label, $"{{{i + 1}}}");
- })
- .ToList();
+ })];
}
private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null)
@@ -2395,10 +2394,10 @@ private async Task TryLoadImageFromPath(string path)
ResetGrabFrame();
await Task.Delay(300);
BitmapImage droppedImage = new();
- droppedImage.BeginInit();
- droppedImage.UriSource = fileURI;
- droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle
- System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path);
+ droppedImage.BeginInit();
+ droppedImage.UriSource = fileURI;
+ droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle
+ System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path);
ImageMethods.RotateImage(droppedImage, rotateFlipType);
droppedImage.EndInit();
frameContentImageSource = droppedImage;
From 44116597d2ec18329839a818a6ad918a4b95a360 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 21:23:00 -0600
Subject: [PATCH 035/109] Add Microsoft.WindowsAppSDK.WinUI package reference
Added Microsoft.WindowsAppSDK.WinUI version 1.8.260204000 to the project file to enable or support WinUI features. No other changes were made.
---
Text-Grab/Text-Grab.csproj | 1 +
1 file changed, 1 insertion(+)
diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj
index 4c1bf1c7..23ed5387 100644
--- a/Text-Grab/Text-Grab.csproj
+++ b/Text-Grab/Text-Grab.csproj
@@ -61,6 +61,7 @@
+
From 7c8c8918edc1b9c6e3b5e316bd238a56d92d35fa Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sun, 1 Mar 2026 23:55:56 -0600
Subject: [PATCH 036/109] Improve GrabFrame content area calculation and DPI
accuracy
Refactor content area and image rect calculations to use WPF's layout engine and PointToScreen, eliminating hardcoded offsets and improving accuracy across DPI settings. Update border and resize settings for better appearance and usability. These changes ensure robust, layout-independent screen coordinate handling.
---
Text-Grab/Utilities/ImageMethods.cs | 16 +++++-----
Text-Grab/Views/GrabFrame.xaml | 6 ++--
Text-Grab/Views/GrabFrame.xaml.cs | 46 ++++++++++++++++++++---------
3 files changed, 43 insertions(+), 25 deletions(-)
diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs
index ae43afd5..e4a908c0 100644
--- a/Text-Grab/Utilities/ImageMethods.cs
+++ b/Text-Grab/Utilities/ImageMethods.cs
@@ -117,16 +117,16 @@ 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();
+ thisCorrectedLeft = contentRect.X;
+ thisCorrectedTop = contentRect.Y;
+ windowWidth = contentRect.Width;
+ windowHeight = contentRect.Height;
}
else
{
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index ec49cd0e..19f3a283 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -17,8 +17,8 @@
AllowDrop="True"
AllowsTransparency="True"
Background="Transparent"
- BorderBrush="Gray"
- BorderThickness="0.2"
+ BorderBrush="{ui:ThemeResource ApplicationBackgroundBrush}"
+ BorderThickness="1"
Closed="Window_Closed"
Closing="GrabFrameWindow_Closing"
Deactivated="GrabFrameWindow_Deactivated"
@@ -84,7 +84,7 @@
CaptionHeight="32"
CornerRadius="18,18,2,18"
GlassFrameThickness="0"
- ResizeBorderThickness="5" />
+ ResizeBorderThickness="8" />
+ /// Returns the physical-pixel screen rectangle that exactly covers the
+ /// transparent content area (RectanglesBorder, Row 1 of the grid).
+ /// Uses PointToScreen so it is always accurate regardless of border
+ /// thickness, DPI, or future layout changes.
+ ///
+ internal System.Drawing.Rectangle GetContentAreaScreenRect()
+ {
+ DpiScale dpi = VisualTreeHelper.GetDpi(this);
+ Point topLeft = RectanglesBorder.PointToScreen(new Point(0, 0));
+ return new System.Drawing.Rectangle(
+ (int)topLeft.X,
+ (int)topLeft.Y,
+ (int)(RectanglesBorder.ActualWidth * dpi.DpiScaleX),
+ (int)(RectanglesBorder.ActualHeight * dpi.DpiScaleY));
+ }
+
public Rect GetImageContentRect()
{
// This is a WIP to try to remove the gray letterboxes on either
@@ -1070,31 +1087,32 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
RectanglesCanvas.Children.Clear();
wordBorders.Clear();
- Point windowPosition = this.GetAbsolutePosition();
DpiScale dpi = VisualTreeHelper.GetDpi(this);
double canvasScale = CanvasViewBox.GetHorizontalScaleFactor();
- Point rectanglesPosition = RectanglesCanvas.TransformToAncestor(this)
- .Transform(new Point(0, 0));
-
if (double.IsNaN(canvasScale))
canvasScale = 1;
- double ContentWidth = RectanglesCanvas.RenderSize.Width;
- double ContentHeight = RectanglesCanvas.RenderSize.Height;
+ double contentWidth = RectanglesCanvas.RenderSize.Width;
+ double contentHeight = RectanglesCanvas.RenderSize.Height;
- if (ContentWidth == 4 || ContentHeight == 2)
+ // When the canvas hasn't been measured yet (Viewbox content is still
+ // at its minimum size), fall back to the containing border's dimensions.
+ if (contentWidth <= 4 || contentHeight <= 2)
{
- ContentWidth = RectanglesBorder.RenderSize.Width;
- ContentHeight = RectanglesBorder.RenderSize.Height;
- rectanglesPosition = new(-2, 32);
+ contentWidth = RectanglesBorder.ActualWidth;
+ contentHeight = RectanglesBorder.ActualHeight;
+ canvasScale = 1;
}
+ // PointToScreen gives the exact physical-pixel origin of the canvas
+ // regardless of border thickness, DPI, or layout changes.
+ Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0));
System.Drawing.Rectangle rectCanvasSize = new()
{
- Width = (int)(ContentWidth * dpi.DpiScaleX * canvasScale),
- Height = (int)(ContentHeight * dpi.DpiScaleY * canvasScale),
- X = (int)((windowPosition.X + rectanglesPosition.X) * dpi.DpiScaleX),
- Y = (int)((windowPosition.Y + rectanglesPosition.Y) * dpi.DpiScaleY)
+ X = (int)scanTopLeft.X,
+ Y = (int)scanTopLeft.Y,
+ Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale),
+ Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale),
};
if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0)
From 3cd300126aaa988b343499266cf1e27a040e6f7e Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Mon, 2 Mar 2026 20:02:43 -0600
Subject: [PATCH 037/109] Improve GrabFrame overlay scaling and image cropping
accuracy
- Fix Viewbox scaling to use both width and height (Uniform)
- Crop frozen image to selection for GrabFrame continuity
- Add GrabFrame constructor for pre-loaded cropped images
- Explicitly size overlay canvas to match image pixels
- Rescale word borders from history for accurate overlays
- Refine content/non-content area calculations for sizing
- Improve background color sampling for word borders
- Add robustness checks for invalid/unmeasured sizes
- Slightly increase GrabFrame title bar height for UI polish
These changes resolve overlay misalignment and scaling issues,
ensuring accurate OCR word border placement and a consistent
user experience when grabbing, freezing, and editing regions.
---
Text-Grab/Extensions/ControlExtensions.cs | 27 +-
Text-Grab/Views/FullscreenGrab.xaml | 4 +-
Text-Grab/Views/FullscreenGrab.xaml.cs | 34 ++-
Text-Grab/Views/GrabFrame.xaml | 2 +-
Text-Grab/Views/GrabFrame.xaml.cs | 316 ++++++++++++++++++----
5 files changed, 315 insertions(+), 68 deletions(-)
diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs
index 7ccfe990..4e3a32ab 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;
@@ -14,9 +15,33 @@ public static double GetHorizontalScaleFactor(this Viewbox viewbox)
return 1.0;
double outsideWidth = viewbox.ActualWidth;
+ double outsideHeight = viewbox.ActualHeight;
double insideWidth = childElement.ActualWidth;
+ double insideHeight = childElement.ActualHeight;
- return outsideWidth / insideWidth;
+ 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 scale;
}
public static Rect GetAbsolutePlacement(this FrameworkElement element, bool relativeToScreen = false)
diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml
index e6fedeb3..231ddc76 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml
+++ b/Text-Grab/Views/FullscreenGrab.xaml
@@ -112,9 +112,7 @@
-
+
0 && cropH > 0)
+ {
+ CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH));
+ croppedBitmap.Freeze();
+ grabFrame = new GrabFrame(croppedBitmap);
+ }
+ else
+ {
+ grabFrame = new GrabFrame();
+ }
+ }
+ else
{
- Left = posLeft,
- Top = posTop,
- };
+ grabFrame = new GrabFrame();
+ }
+
+ grabFrame.Left = posLeft;
+ grabFrame.Top = posTop;
grabFrame.Left -= (2 / dpi.PixelsPerDip);
grabFrame.Top -= (48 / dpi.PixelsPerDip);
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index 19f3a283..2ec53219 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -92,7 +92,7 @@
ClipToBounds="True">
-
+
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 4b9b8a4c..6f6bcad6 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -138,6 +138,27 @@ public GrabFrame(string imagePath)
Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath);
}
+ ///
+ /// Creates a GrabFrame pre-loaded with a frozen image cropped from a Fullscreen Grab selection.
+ /// The frame opens in freeze mode showing the provided bitmap and immediately runs OCR.
+ ///
+ /// The cropped bitmap to display as the initial frozen background.
+ public GrabFrame(BitmapSource frozenImage)
+ {
+ StandardInitialize();
+
+ ShouldSaveOnClose = true;
+ frameContentImageSource = frozenImage;
+ hasLoadedImageSource = true;
+
+ Loaded += (s, e) =>
+ {
+ FreezeToggleButton.IsChecked = true;
+ FreezeGrabFrame();
+ reDrawTimer.Start();
+ };
+ }
+
///
/// Opens GrabFrame in template editing mode with existing regions pre-loaded.
///
@@ -231,11 +252,30 @@ private async Task LoadContentFromHistory(HistoryInfo history)
return;
}
+ history.ImageContent = bgBitmap;
frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap);
hasLoadedImageSource = true;
GrabFrameImage.Source = frameContentImageSource;
FreezeGrabFrame();
+ if (history.PositionRect != Rect.Empty)
+ {
+ Left = history.PositionRect.Left;
+ Top = history.PositionRect.Top;
+
+ if (history.SourceMode == TextGrabMode.Fullscreen)
+ {
+ Size nonContentSize = GetGrabFrameNonContentSize();
+ Width = history.PositionRect.Width + nonContentSize.Width;
+ Height = history.PositionRect.Height + nonContentSize.Height;
+ }
+ else
+ {
+ Width = history.PositionRect.Width;
+ Height = history.PositionRect.Height;
+ }
+ }
+
List? wbInfoList = null;
if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson))
@@ -243,6 +283,8 @@ private async Task LoadContentFromHistory(HistoryInfo history)
if (wbInfoList is not null && wbInfoList.Count > 0)
{
+ ScaleHistoryWordBordersToCanvas(history, wbInfoList);
+
foreach (WordBorderInfo info in wbInfoList)
{
WordBorder wb = new(info)
@@ -263,26 +305,95 @@ private async Task LoadContentFromHistory(HistoryInfo history)
ShouldSaveOnClose = true;
}
- if (history.PositionRect != Rect.Empty)
+ TableToggleButton.IsChecked = history.IsTable;
+
+ UpdateFrameText();
+ }
+
+ private Size GetGrabFrameNonContentSize()
+ {
+ const double defaultNonContentWidth = 4;
+ const double defaultNonContentHeight = 74;
+
+ UpdateLayout();
+
+ if (ActualWidth <= 1 || ActualHeight <= 1
+ || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1)
{
- Left = history.PositionRect.Left;
- Top = history.PositionRect.Top;
- Height = history.PositionRect.Height;
- Width = history.PositionRect.Width;
+ return new Size(defaultNonContentWidth, defaultNonContentHeight);
+ }
- if (history.SourceMode == TextGrabMode.Fullscreen)
- {
- int borderThickness = 2;
- int titleBarHeight = 32;
- int bottomBarHeight = 42;
- Height += (titleBarHeight + bottomBarHeight);
- Width += (2 * borderThickness);
- }
+ double nonContentWidth = ActualWidth - RectanglesBorder.ActualWidth;
+ double nonContentHeight = ActualHeight - RectanglesBorder.ActualHeight;
+
+ if (!double.IsFinite(nonContentWidth) || nonContentWidth < 0 || nonContentWidth > 100)
+ nonContentWidth = defaultNonContentWidth;
+
+ if (!double.IsFinite(nonContentHeight) || nonContentHeight < 0 || nonContentHeight > 200)
+ nonContentHeight = defaultNonContentHeight;
+
+ return new Size(nonContentWidth, nonContentHeight);
+ }
+
+ private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List wbInfoList)
+ {
+ if (wbInfoList.Count == 0 || RectanglesCanvas.Width <= 0 || RectanglesCanvas.Height <= 0)
+ return;
+
+ Size savedContentSize = GetSavedHistoryContentSize(history);
+ if (savedContentSize.Width <= 0 || savedContentSize.Height <= 0)
+ return;
+
+ double scaleX = RectanglesCanvas.Width / savedContentSize.Width;
+ double scaleY = RectanglesCanvas.Height / savedContentSize.Height;
+ if (!double.IsFinite(scaleX) || !double.IsFinite(scaleY) || (scaleX <= 1.05 && scaleY <= 1.05))
+ return;
+
+ double maxRight = wbInfoList.Max(info => info.BorderRect.Right);
+ double maxBottom = wbInfoList.Max(info => info.BorderRect.Bottom);
+
+ // Scale only when saved word borders look like they were captured in
+ // the old window-content coordinate space rather than image-space.
+ if (maxRight > savedContentSize.Width * 1.1 || maxBottom > savedContentSize.Height * 1.1)
+ return;
+
+ foreach (WordBorderInfo info in wbInfoList)
+ {
+ Rect borderRect = info.BorderRect;
+ info.BorderRect = new Rect(
+ borderRect.Left * scaleX,
+ borderRect.Top * scaleY,
+ borderRect.Width * scaleX,
+ borderRect.Height * scaleY);
}
+ }
- TableToggleButton.IsChecked = history.IsTable;
+ private Size GetSavedHistoryContentSize(HistoryInfo history)
+ {
+ if (history.ImageContent is System.Drawing.Bitmap imageContentBitmap
+ && imageContentBitmap.Width > 0 && imageContentBitmap.Height > 0)
+ {
+ return new Size(imageContentBitmap.Width, imageContentBitmap.Height);
+ }
- UpdateFrameText();
+ Rect positionRect = history.PositionRect;
+ if (positionRect == Rect.Empty || positionRect.Width <= 0 || positionRect.Height <= 0)
+ return new Size(0, 0);
+
+ if (history.SourceMode == TextGrabMode.Fullscreen)
+ return new Size(positionRect.Width, positionRect.Height);
+
+ Size nonContentSize = GetGrabFrameNonContentSize();
+ double contentWidth = positionRect.Width - nonContentSize.Width;
+ double contentHeight = positionRect.Height - nonContentSize.Height;
+
+ if (!double.IsFinite(contentWidth) || contentWidth <= 0)
+ contentWidth = positionRect.Width;
+
+ if (!double.IsFinite(contentHeight) || contentHeight <= 0)
+ contentHeight = positionRect.Height;
+
+ return new Size(contentWidth, contentHeight);
}
///
@@ -307,17 +418,21 @@ public Rect GetImageContentRect()
// This is a WIP to try to remove the gray letterboxes on either
// side of the image when zooming it.
- Rect imageRect = Rect.Empty;
+ if (frameContentImageSource is null || !IsLoaded || !RectanglesCanvas.IsLoaded)
+ return Rect.Empty;
- if (frameContentImageSource is null)
- return imageRect;
+ Rect canvasPlacement = RectanglesCanvas.GetAbsolutePlacement(true);
+ if (canvasPlacement == Rect.Empty)
+ return Rect.Empty;
- imageRect = RectanglesCanvas.GetAbsolutePlacement(true);
Size rectCanvasSize = RectanglesCanvas.RenderSize;
- imageRect.Width = rectCanvasSize.Width;
- imageRect.Height = rectCanvasSize.Height;
+ if (!double.IsFinite(rectCanvasSize.Width) || !double.IsFinite(rectCanvasSize.Height)
+ || rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0)
+ {
+ return canvasPlacement;
+ }
- return imageRect;
+ return new Rect(canvasPlacement.X, canvasPlacement.Y, rectCanvasSize.Width, rectCanvasSize.Height);
}
private void StandardInitialize()
@@ -1088,33 +1203,14 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
wordBorders.Clear();
DpiScale dpi = VisualTreeHelper.GetDpi(this);
- double canvasScale = CanvasViewBox.GetHorizontalScaleFactor();
- if (double.IsNaN(canvasScale))
- canvasScale = 1;
-
- double contentWidth = RectanglesCanvas.RenderSize.Width;
- double contentHeight = RectanglesCanvas.RenderSize.Height;
-
- // When the canvas hasn't been measured yet (Viewbox content is still
- // at its minimum size), fall back to the containing border's dimensions.
- if (contentWidth <= 4 || contentHeight <= 2)
+ System.Drawing.Rectangle rectCanvasSize = GetContentAreaScreenRect();
+ if (rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0)
{
- contentWidth = RectanglesBorder.ActualWidth;
- contentHeight = RectanglesBorder.ActualHeight;
- canvasScale = 1;
+ isDrawing = false;
+ reDrawTimer.Start();
+ return;
}
- // PointToScreen gives the exact physical-pixel origin of the canvas
- // regardless of border thickness, DPI, or layout changes.
- Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0));
- System.Drawing.Rectangle rectCanvasSize = new()
- {
- X = (int)scanTopLeft.X,
- Y = (int)scanTopLeft.Y,
- Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale),
- Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale),
- };
-
if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0)
{
ILanguage lang = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage();
@@ -1126,13 +1222,22 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
isSpaceJoining = CurrentLanguage!.IsSpaceJoining();
- System.Drawing.Bitmap? bmp = null;
+ System.Drawing.Bitmap? bmp = Singleton.Instance.CachedBitmap;
+ bool shouldDisposeBmp = false;
- if (frameContentImageSource is BitmapSource bmpImg)
+ if (bmp is null && frameContentImageSource is BitmapSource bmpImg)
+ {
bmp = ImageMethods.BitmapSourceToBitmap(bmpImg);
+ shouldDisposeBmp = true;
+ }
int lineNumber = 0;
double viewBoxZoomFactor = CanvasViewBox.GetHorizontalScaleFactor();
+ if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4)
+ viewBoxZoomFactor = 1;
+ Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder);
+ double borderToCanvasX = -canvasOriginInBorder.X;
+ double borderToCanvasY = -canvasOriginInBorder.Y;
foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines)
{
@@ -1145,7 +1250,7 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
SolidColorBrush backgroundBrush = new(Colors.Black);
if (bmp is not null)
- backgroundBrush = GetBackgroundBrushFromBitmap(ref dpi, windowFrameImageScale, bmp, ref lineRect);
+ backgroundBrush = GetBackgroundBrushFromOcrBitmap(windowFrameImageScale, bmp, ref lineRect);
string ocrText = lineText.ToString();
@@ -1159,8 +1264,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
{
Width = ((lineRect.Width / (dpi.DpiScaleX * windowFrameImageScale)) + 2) / viewBoxZoomFactor,
Height = ((lineRect.Height / (dpi.DpiScaleY * windowFrameImageScale)) + 2) / viewBoxZoomFactor,
- Top = (lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) / viewBoxZoomFactor,
- Left = (lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) / viewBoxZoomFactor,
+ Top = ((lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) + borderToCanvasY) / viewBoxZoomFactor,
+ Left = ((lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) + borderToCanvasX) / viewBoxZoomFactor,
Word = ocrText,
OwnerGrabFrame = this,
LineNumber = lineNumber,
@@ -1203,7 +1308,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "")
isDrawing = false;
- bmp?.Dispose();
+ if (shouldDisposeBmp)
+ bmp?.Dispose();
reSearchTimer.Start();
// Trigger translation if enabled
@@ -1335,6 +1441,8 @@ private void FreezeGrabFrame()
GrabFrameImage.Source = frameContentImageSource;
}
+ SyncRectanglesCanvasSizeToImage();
+
FreezeToggleButton.IsChecked = true;
Topmost = false;
Background = new SolidColorBrush(Colors.DimGray);
@@ -1342,6 +1450,28 @@ private void FreezeGrabFrame()
IsFreezeMode = true;
}
+ private void SyncRectanglesCanvasSizeToImage()
+ {
+ if (GrabFrameImage.Source is not BitmapSource source)
+ return;
+
+ // Keep image and overlay in the same coordinate space (raw image pixels).
+ double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth : source.Width;
+ double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight : source.Height;
+
+ if (double.IsFinite(sourceWidth) && sourceWidth > 0)
+ {
+ GrabFrameImage.Width = sourceWidth;
+ RectanglesCanvas.Width = sourceWidth;
+ }
+
+ if (double.IsFinite(sourceHeight) && sourceHeight > 0)
+ {
+ GrabFrameImage.Height = sourceHeight;
+ RectanglesCanvas.Height = sourceHeight;
+ }
+ }
+
private async void FreezeMI_Click(object sender, RoutedEventArgs e)
{
if (IsFreezeMode)
@@ -1371,6 +1501,54 @@ private void FreezeToggleButton_Click(object? sender = null, RoutedEventArgs? e
UnfreezeGrabFrame();
}
+ private static SolidColorBrush GetBackgroundBrushFromOcrBitmap(double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect)
+ {
+ if (!double.IsFinite(scale) || scale <= 0)
+ scale = 1;
+
+ double boxLeft = lineRect.Left / scale;
+ double boxTop = lineRect.Top / scale;
+ double boxRight = lineRect.Right / scale;
+ double boxBottom = lineRect.Bottom / scale;
+ double boxWidth = Math.Max(0, boxRight - boxLeft);
+ double boxHeight = Math.Max(0, boxBottom - boxTop);
+ double insetX = Math.Min(boxWidth / 2, Math.Max(1, boxWidth * 0.12));
+ double insetY = Math.Min(boxHeight / 2, Math.Max(1, boxHeight * 0.12));
+
+ int pxLeft = Math.Clamp((int)(boxLeft + insetX), 0, bmp.Width - 1);
+ int pxTop = Math.Clamp((int)(boxTop + insetY), 0, bmp.Height - 1);
+ int pxRight = Math.Clamp((int)(boxRight - insetX), 0, bmp.Width - 1);
+ int pxBottom = Math.Clamp((int)(boxBottom - insetY), 0, bmp.Height - 1);
+
+ if (pxRight < pxLeft)
+ pxRight = pxLeft;
+
+ if (pxBottom < pxTop)
+ pxBottom = pxTop;
+
+ System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop);
+ System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop);
+ System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom);
+ System.Drawing.Color pxColorLeftBottom = bmp.GetPixel(pxLeft, pxBottom);
+
+ List mediaColorList =
+ [
+ ColorHelper.MediaColorFromDrawingColor(pxColorLeftTop),
+ ColorHelper.MediaColorFromDrawingColor(pxColorRightTop),
+ ColorHelper.MediaColorFromDrawingColor(pxColorRightBottom),
+ ColorHelper.MediaColorFromDrawingColor(pxColorLeftBottom),
+ ];
+
+ Color? mostCommonColor = mediaColorList.GroupBy(c => c)
+ .OrderBy(g => g.Count())
+ .LastOrDefault()?.Key;
+
+ if (mostCommonColor is not null)
+ return new SolidColorBrush(mostCommonColor.Value);
+
+ return ColorHelper.SolidColorBrushFromDrawingColor(pxColorLeftTop);
+ }
+
private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect)
{
SolidColorBrush backgroundBrush = new(Colors.Black);
@@ -1385,10 +1563,26 @@ private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double sc
double rightFraction = boxRight / RectanglesCanvas.ActualWidth;
double bottomFraction = boxBottom / RectanglesCanvas.ActualHeight;
- int pxLeft = Math.Clamp((int)(leftFraction * bmp.Width) - 1, 0, bmp.Width - 1);
- int pxTop = Math.Clamp((int)(topFraction * bmp.Height) - 2, 0, bmp.Height - 1);
- int pxRight = Math.Clamp((int)(rightFraction * bmp.Width) + 1, 0, bmp.Width - 1);
- int pxBottom = Math.Clamp((int)(bottomFraction * bmp.Height) + 1, 0, bmp.Height - 1);
+ int rawLeft = Math.Clamp((int)(leftFraction * bmp.Width), 0, bmp.Width - 1);
+ int rawTop = Math.Clamp((int)(topFraction * bmp.Height), 0, bmp.Height - 1);
+ int rawRight = Math.Clamp((int)(rightFraction * bmp.Width), 0, bmp.Width - 1);
+ int rawBottom = Math.Clamp((int)(bottomFraction * bmp.Height), 0, bmp.Height - 1);
+
+ int spanX = Math.Max(0, rawRight - rawLeft);
+ int spanY = Math.Max(0, rawBottom - rawTop);
+ int insetX = Math.Min(spanX / 2, Math.Max(1, spanX / 8));
+ int insetY = Math.Min(spanY / 2, Math.Max(1, spanY / 8));
+ int pxLeft = Math.Clamp(rawLeft + insetX, 0, bmp.Width - 1);
+ int pxTop = Math.Clamp(rawTop + insetY, 0, bmp.Height - 1);
+ int pxRight = Math.Clamp(rawRight - insetX, 0, bmp.Width - 1);
+ int pxBottom = Math.Clamp(rawBottom - insetY, 0, bmp.Height - 1);
+
+ if (pxRight < pxLeft)
+ pxRight = pxLeft;
+
+ if (pxBottom < pxTop)
+ pxBottom = pxTop;
+
System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop);
System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop);
System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom);
@@ -2018,6 +2212,12 @@ private async void ReDrawTimer_Tick(object? sender, EventArgs? e)
reDrawTimer.Stop();
SetRefreshOrOcrFrameBtnVis();
+ if (!IsLoaded || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1)
+ {
+ reDrawTimer.Start();
+ return;
+ }
+
if (CheckKey(VirtualKeyCodes.LeftButton) || CheckKey(VirtualKeyCodes.MiddleButton))
{
reDrawTimer.Start();
@@ -2177,6 +2377,10 @@ private void ResetGrabFrame()
MainZoomBorder.Reset();
RectanglesCanvas.RenderTransform = Transform.Identity;
+ RectanglesCanvas.ClearValue(WidthProperty);
+ RectanglesCanvas.ClearValue(HeightProperty);
+ GrabFrameImage.ClearValue(WidthProperty);
+ GrabFrameImage.ClearValue(HeightProperty);
IsOcrValid = false;
ocrResultOfWindow = null;
From bfe084bc1af0fe803ae003011fc5f9a9c3ec5541 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Tue, 3 Mar 2026 19:53:58 -0600
Subject: [PATCH 038/109] Highlight unused template regions in
GrabFrame/preview
Add visual dimming for template regions not referenced in the output template, both in the GrabFrame editor and FullscreenGrab preview. WordBorder controls now support a dimmed border state, and region highlighting updates dynamically as the template is edited. Also includes minor XAML cleanup for menu item formatting. This improves clarity when editing templates by making unused regions visually distinct.
---
Text-Grab/Controls/NotifyIconWindow.xaml | 4 ++--
Text-Grab/Controls/WordBorder.xaml.cs | 23 ++++++++++++++++++-
Text-Grab/Extensions/ControlExtensions.cs | 3 ---
Text-Grab/Views/FullscreenGrab.xaml.cs | 6 ++++-
Text-Grab/Views/GrabFrame.xaml | 1 +
Text-Grab/Views/GrabFrame.xaml.cs | 28 +++++++++++++++++++++++
6 files changed, 58 insertions(+), 7 deletions(-)
diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml
index 468c4ebd..bd6e13f7 100644
--- a/Text-Grab/Controls/NotifyIconWindow.xaml
+++ b/Text-Grab/Controls/NotifyIconWindow.xaml
@@ -40,8 +40,8 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
@@ -262,13 +277,13 @@
VerticalAlignment="Center"
Orientation="Horizontal">
+ Click="NewTemplateFromImageButton_Click"
+ ToolTip="Pick an image to use as reference for a new grab template">
-
-
+
+
-
-
-
-
-
-
diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
index e7d0ebe2..6f037f7b 100644
--- a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
+++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Data;
@@ -218,9 +219,27 @@ private void UpdateTemplateEmptyState(int count)
NoTemplatesText.Visibility = hasTemplates ? Visibility.Collapsed : Visibility.Visible;
}
- private void OpenGrabFrameButton_Click(object sender, RoutedEventArgs e)
+ private void NewTemplateFromImageButton_Click(object sender, RoutedEventArgs e)
{
- GrabFrame grabFrame = new();
+ 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();
diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs
index ec86d291..41f94eb6 100644
--- a/Text-Grab/Controls/WordBorder.xaml.cs
+++ b/Text-Grab/Controls/WordBorder.xaml.cs
@@ -174,26 +174,27 @@ public string Word
public void Deselect()
{
IsSelected = false;
- ApplyTemplateDimBorderBrush();
+ ApplyTemplateStateBorderBrush();
}
- private bool _isDimmedForTemplate = false;
+ private bool _isInOutputPattern = false;
///
- /// Dims the border brush to indicate this region is not referenced in the output template.
- /// Call with false to restore the normal border color.
+ /// 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 SetDimmedForTemplate(bool isDimmed)
+ public void SetHighlightedForOutput(bool isHighlighted)
{
- _isDimmedForTemplate = isDimmed;
+ _isInOutputPattern = isHighlighted;
if (!IsSelected)
- ApplyTemplateDimBorderBrush();
+ ApplyTemplateStateBorderBrush();
}
- private void ApplyTemplateDimBorderBrush()
+ private void ApplyTemplateStateBorderBrush()
{
- byte alpha = _isDimmedForTemplate ? (byte)80 : (byte)255;
- SolidColorBrush brush = new(Color.FromArgb(alpha, 48, 142, 152));
+ SolidColorBrush brush = _isInOutputPattern
+ ? new SolidColorBrush(Colors.Orange)
+ : new SolidColorBrush(Color.FromRgb(48, 142, 152));
WordBorderBorder.BorderBrush = brush;
MoveResizeBorder.BorderBrush = brush;
}
diff --git a/Text-Grab/Styles/ListViewScrollFix.xaml b/Text-Grab/Styles/ListViewScrollFix.xaml
new file mode 100644
index 00000000..3dad6541
--- /dev/null
+++ b/Text-Grab/Styles/ListViewScrollFix.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index 9616786c..bc42e3fe 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -2589,7 +2589,7 @@ private void UpdateTemplateBadges()
{
wb.TemplateIndex = 0;
wb.Opacity = 1.0;
- wb.SetDimmedForTemplate(false);
+ wb.SetHighlightedForOutput(false);
}
return;
}
@@ -2615,8 +2615,8 @@ private void UpdateTemplateRegionOpacities()
foreach (WordBorder wb in wordBorders)
{
bool isReferenced = referenced.Count == 0 || referenced.Contains(wb.TemplateIndex);
- wb.Opacity = isReferenced ? 1.0 : 0.5;
- wb.SetDimmedForTemplate(!isReferenced);
+ wb.Opacity = 1.0;
+ wb.SetHighlightedForOutput(isReferenced);
}
}
From aacf37ebcdc20707ee9441eb04738ac64cc2ba63 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Tue, 3 Mar 2026 22:33:00 -0600
Subject: [PATCH 041/109] Add regex pattern matching support to Grab Templates
Extend Grab Templates to support regex pattern placeholders alongside
existing region placeholders. Users can reference saved StoredRegex
patterns from the Regex Manager using {p:PatternName:mode} syntax.
New placeholder syntax:
- {p:Name:first} - first regex match
- {p:Name:last} - last regex match
- {p:Name:all:separator} - all matches joined by separator
- {p:Name:N} - Nth match (1-based)
- {p:Name:1,3} - specific matches joined by separator
Key changes:
- New TemplatePatternMatch model for pattern references
- GrabTemplate now supports pattern-only and hybrid templates
- GrabTemplateExecutor processes pattern placeholders via full-area OCR
- InlinePickerRichTextBox shows grouped popup (Regions/Patterns sections)
- PatternMatchModeDialog for configuring match mode after selection
- GrabFrame integrates pattern items in template editor
- 15 new tests covering all match modes and edge cases
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Tests/GrabTemplateExecutorTests.cs | 329 ++++++++++++++++++
Text-Grab/Controls/InlinePickerItem.cs | 9 +-
Text-Grab/Controls/InlinePickerRichTextBox.cs | 189 +++++++++-
.../Controls/PatternMatchModeDialog.xaml | 87 +++++
.../Controls/PatternMatchModeDialog.xaml.cs | 119 +++++++
Text-Grab/Models/GrabTemplate.cs | 42 ++-
Text-Grab/Models/TemplatePatternMatch.cs | 53 +++
Text-Grab/Utilities/GrabTemplateExecutor.cs | 243 ++++++++++++-
Text-Grab/Views/GrabFrame.xaml.cs | 164 ++++++++-
9 files changed, 1196 insertions(+), 39 deletions(-)
create mode 100644 Text-Grab/Controls/PatternMatchModeDialog.xaml
create mode 100644 Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
create mode 100644 Text-Grab/Models/TemplatePatternMatch.cs
diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs
index 94f00754..18f17917 100644
--- a/Tests/GrabTemplateExecutorTests.cs
+++ b/Tests/GrabTemplateExecutorTests.cs
@@ -1,3 +1,4 @@
+using Text_Grab.Models;
using Text_Grab.Utilities;
namespace Tests;
@@ -143,4 +144,332 @@ public void ValidateOutputTemplate_NoRegionsReferenced_ReturnsIssue()
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/Text-Grab/Controls/InlinePickerItem.cs b/Text-Grab/Controls/InlinePickerItem.cs
index fc3f82d2..da922a71 100644
--- a/Text-Grab/Controls/InlinePickerItem.cs
+++ b/Text-Grab/Controls/InlinePickerItem.cs
@@ -5,12 +5,19 @@ 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)
+ 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
index c07a3208..d53b7dba 100644
--- a/Text-Grab/Controls/InlinePickerRichTextBox.cs
+++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs
@@ -9,12 +9,14 @@
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
{
@@ -70,6 +72,13 @@ public string SerializedText
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(
@@ -121,8 +130,8 @@ private ListBox BuildPopupListBox()
{
ListBox lb = new()
{
- MinWidth = 120,
- MaxHeight = 150,
+ MinWidth = 140,
+ MaxHeight = 200,
FontSize = 11,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
@@ -131,7 +140,18 @@ private ListBox BuildPopupListBox()
};
lb.SetResourceReference(ForegroundProperty, "TextFillColorPrimaryBrush");
- // Item template: DisplayName + muted Value label side-by-side
+ // 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);
@@ -153,11 +173,22 @@ private ListBox BuildPopupListBox()
spFactory.AppendChild(nameFactory);
spFactory.AppendChild(valueFactory);
dt.VisualTree = spFactory;
- lb.ItemTemplate = dt;
+ return dt;
+ }
- lb.PreviewMouseDown += ListBox_PreviewMouseDown;
- lb.ItemContainerStyle = BuildCompactItemStyle();
- return lb;
+ 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()
@@ -233,16 +264,12 @@ private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
case Key.Down:
e.Handled = true;
- _listBox.SelectedIndex = (_listBox.SelectedIndex + 1) % Math.Max(1, _listBox.Items.Count);
- _listBox.ScrollIntoView(_listBox.SelectedItem);
+ MoveSelection(1);
break;
case Key.Up:
e.Handled = true;
- _listBox.SelectedIndex = _listBox.SelectedIndex > 0
- ? _listBox.SelectedIndex - 1
- : _listBox.Items.Count - 1;
- _listBox.ScrollIntoView(_listBox.SelectedItem);
+ MoveSelection(-1);
break;
case Key.Enter:
@@ -271,6 +298,30 @@ private void OnPreviewKeyDown(object sender, KeyEventArgs e)
#endregion Keyboard & Focus handling
+ ///
+ /// Moves the listbox selection by (+1 or -1),
+ /// skipping non-selectable section header items.
+ ///
+ private void MoveSelection(int direction)
+ {
+ int count = _listBox.Items.Count;
+ if (count == 0) return;
+
+ int start = _listBox.SelectedIndex;
+ int next = start;
+
+ for (int i = 0; i < count; i++)
+ {
+ next = (next + direction + count) % count;
+ if (_listBox.Items[next] is InlinePickerItem item && item.Group != HeaderGroupTag)
+ {
+ _listBox.SelectedIndex = next;
+ _listBox.ScrollIntoView(_listBox.SelectedItem);
+ return;
+ }
+ }
+ }
+
#region Text change & popup management
private void OnTextChanged(object sender, TextChangedEventArgs e)
@@ -317,8 +368,12 @@ private void RefreshPopup()
_listBox.ItemsSource = filtered;
+ // Auto-select the first non-header item
if (_listBox.SelectedIndex < 0 || _listBox.SelectedIndex >= filtered.Count)
- _listBox.SelectedIndex = 0;
+ {
+ int firstSelectable = filtered.FindIndex(i => i.Group != HeaderGroupTag);
+ _listBox.SelectedIndex = firstSelectable >= 0 ? firstSelectable : 0;
+ }
if (!_popup.IsOpen)
{
@@ -341,13 +396,52 @@ private List GetFilteredItems()
IEnumerable source = ItemsSource ?? [];
- return filterText.Length == 0
+ List filtered = filterText.Length == 0
? [.. source]
: [.. source.Where(i =>
i.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase) ||
i.Value.Contains(filterText, StringComparison.OrdinalIgnoreCase))];
+
+ // Insert section headers when items have different groups
+ return InsertGroupHeaders(filtered);
+ }
+
+ private static List InsertGroupHeaders(List items)
+ {
+ if (items.Count == 0)
+ return items;
+
+ // Check if there are multiple distinct groups
+ List distinctGroups = items
+ .Select(i => i.Group)
+ .Where(g => !string.IsNullOrEmpty(g))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (distinctGroups.Count <= 1)
+ return items;
+
+ // Build list with section headers
+ List result = [];
+ string? currentGroup = null;
+
+ foreach (InlinePickerItem item in items)
+ {
+ string group = string.IsNullOrEmpty(item.Group) ? "Other" : item.Group;
+ if (!string.Equals(group, currentGroup, StringComparison.OrdinalIgnoreCase))
+ {
+ currentGroup = group;
+ result.Add(new InlinePickerItem($"── {group} ──", "") { Group = HeaderGroupTag });
+ }
+ result.Add(item);
+ }
+
+ return result;
}
+ /// Sentinel group value for non-selectable section header items.
+ internal const string HeaderGroupTag = "__header__";
+
#endregion Text change & popup management
#region Selection & chip insertion
@@ -372,6 +466,26 @@ private void CommitSelection()
if (_listBox.SelectedItem is not InlinePickerItem selectedItem || _triggerStart == null)
return;
+ // Skip non-selectable header items
+ if (selectedItem.Group == HeaderGroupTag)
+ return;
+
+ // For pattern items, invoke the dialog callback to configure match mode
+ bool isPatternItem = string.Equals(selectedItem.Group, "Patterns", StringComparison.OrdinalIgnoreCase);
+ InlinePickerItem itemToInsert = selectedItem;
+
+ if (isPatternItem && PatternItemSelected != null)
+ {
+ TemplatePatternMatch? patternConfig = PatternItemSelected(selectedItem);
+ if (patternConfig == null)
+ return; // user cancelled
+
+ // Build the placeholder value from the dialog result
+ string placeholderValue = BuildPatternPlaceholder(patternConfig);
+ string displayLabel = $"{patternConfig.PatternName} ({patternConfig.MatchMode})";
+ itemToInsert = new InlinePickerItem(displayLabel, placeholderValue, selectedItem.Group);
+ }
+
_isModifyingDocument = true;
try
{
@@ -381,8 +495,8 @@ private void CommitSelection()
// CaretPosition is now at the insertion point (where trigger was)
InlineChipElement chip = new()
{
- DisplayName = selectedItem.DisplayName,
- Value = selectedItem.Value,
+ DisplayName = itemToInsert.DisplayName,
+ Value = itemToInsert.Value,
};
chip.RemoveRequested += Chip_RemoveRequested;
@@ -397,7 +511,7 @@ private void CommitSelection()
HidePopup();
_triggerStart = null;
- ItemInserted?.Invoke(this, selectedItem);
+ ItemInserted?.Invoke(this, itemToInsert);
}
finally
{
@@ -408,6 +522,20 @@ private void CommitSelection()
Focus();
}
+ private static string BuildPatternPlaceholder(TemplatePatternMatch config)
+ {
+ string mode = config.MatchMode;
+
+ // Include separator in placeholder for "all" and multi-index modes
+ bool needsSeparator = mode == "all"
+ || (mode.Contains(',') && mode.Split(',').Length > 1);
+
+ if (needsSeparator && config.Separator != ", ")
+ return $"{{p:{config.PatternName}:{mode}:{config.Separator}}}";
+
+ return $"{{p:{config.PatternName}:{mode}}}";
+ }
+
private void Chip_RemoveRequested(object? sender, EventArgs e)
{
if (sender is not InlineChipElement chip)
@@ -566,3 +694,28 @@ private static bool IsVisualDescendant(DependencyObject? root, DependencyObject
return false;
}
}
+
+///
+/// Selects between a selectable item template and a non-selectable section header template
+/// based on the value.
+///
+internal class PickerItemTemplateSelector : DataTemplateSelector
+{
+ private readonly DataTemplate _selectableTemplate;
+ private readonly DataTemplate _headerTemplate;
+
+ public PickerItemTemplateSelector(DataTemplate selectableTemplate, DataTemplate headerTemplate)
+ {
+ _selectableTemplate = selectableTemplate;
+ _headerTemplate = headerTemplate;
+ }
+
+ public override DataTemplate SelectTemplate(object item, DependencyObject container)
+ {
+ if (item is InlinePickerItem pickerItem
+ && pickerItem.Group == InlinePickerRichTextBox.HeaderGroupTag)
+ return _headerTemplate;
+
+ return _selectableTemplate;
+ }
+}
diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml b/Text-Grab/Controls/PatternMatchModeDialog.xaml
new file mode 100644
index 00000000..442428bc
--- /dev/null
+++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs b/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
new file mode 100644
index 00000000..2c6b2c8a
--- /dev/null
+++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using Text_Grab.Models;
+using Wpf.Ui.Controls;
+
+namespace Text_Grab.Controls;
+
+///
+/// Dialog shown after the user selects a regex pattern from the inline picker.
+/// Lets them choose match mode (first, last, all, specific indices) and separator.
+///
+public partial class PatternMatchModeDialog : FluentWindow
+{
+ ///
+ /// The configured result. Null if the user cancelled.
+ ///
+ public TemplatePatternMatch? Result { get; private set; }
+
+ private readonly string _patternId;
+ private readonly string _patternName;
+
+ public PatternMatchModeDialog(string patternId, string patternName)
+ {
+ InitializeComponent();
+ _patternId = patternId;
+ _patternName = patternName;
+ PatternNameLabel.Text = $"Pattern: {patternName}";
+ }
+
+ private void MatchModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (SeparatorPanel == null || IndicesPanel == null)
+ return;
+
+ string mode = GetSelectedMode();
+
+ SeparatorPanel.Visibility = mode is "all" or "nth" ? Visibility.Visible : Visibility.Collapsed;
+ IndicesPanel.Visibility = mode == "nth" ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void IndicesTextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ ValidateIndices();
+ }
+
+ private bool ValidateIndices()
+ {
+ string text = IndicesTextBox.Text.Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ ShowIndicesError("At least one index is required.");
+ return false;
+ }
+
+ string[] parts = text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length == 0)
+ {
+ ShowIndicesError("At least one index is required.");
+ return false;
+ }
+
+ foreach (string part in parts)
+ {
+ if (!int.TryParse(part, out int val) || val < 1)
+ {
+ ShowIndicesError($"\"{part}\" is not a valid positive integer.");
+ return false;
+ }
+ }
+
+ HideIndicesError();
+ return true;
+ }
+
+ private void ShowIndicesError(string message)
+ {
+ IndicesErrorText.Text = message;
+ IndicesErrorText.Visibility = Visibility.Visible;
+ OkButton.IsEnabled = false;
+ }
+
+ private void HideIndicesError()
+ {
+ IndicesErrorText.Visibility = Visibility.Collapsed;
+ OkButton.IsEnabled = true;
+ }
+
+ private string GetSelectedMode()
+ {
+ if (MatchModeComboBox.SelectedItem is ComboBoxItem item)
+ return item.Tag?.ToString() ?? "first";
+ return "first";
+ }
+
+ private void OkButton_Click(object sender, RoutedEventArgs e)
+ {
+ string mode = GetSelectedMode();
+ string separator = SeparatorTextBox.Text;
+
+ if (mode == "nth")
+ {
+ if (!ValidateIndices())
+ return;
+ mode = IndicesTextBox.Text.Trim();
+ }
+
+ Result = new TemplatePatternMatch(_patternId, _patternName, mode, separator);
+ DialogResult = true;
+ Close();
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs
index e21ed33a..24a32960 100644
--- a/Text-Grab/Models/GrabTemplate.cs
+++ b/Text-Grab/Models/GrabTemplate.cs
@@ -8,11 +8,20 @@ namespace Text_Grab.Models;
/// 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:
+/// 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
@@ -57,11 +66,18 @@ public class GrabTemplate
public List Regions { get; set; } = [];
///
- /// Output format string. Use {N}, {N:trim}, {N:upper}, {N:lower}, \n, \t.
- /// Example: "Name: {1}\nEmail: {2}\nPhone: {3}"
+ /// 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)
@@ -71,10 +87,12 @@ public GrabTemplate(string name)
///
/// Returns whether this template has the minimum required data to be executed.
+ /// A template is valid if it has a name, an output template, and at least one
+ /// region or pattern reference.
///
public bool IsValid =>
!string.IsNullOrWhiteSpace(Name)
- && Regions.Count > 0
+ && (Regions.Count > 0 || PatternMatches.Count > 0)
&& !string.IsNullOrWhiteSpace(OutputTemplate);
///
@@ -93,4 +111,20 @@ public IEnumerable GetReferencedRegionNumbers()
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 =
+ System.Text.RegularExpressions.Regex.Matches(
+ OutputTemplate,
+ @"\{p:([^:}]+):[^}]+\}");
+
+ foreach (System.Text.RegularExpressions.Match match in matches)
+ {
+ yield return match.Groups[1].Value;
+ }
+ }
}
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/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs
index e3d3add4..90b82141 100644
--- a/Text-Grab/Utilities/GrabTemplateExecutor.cs
+++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
@@ -13,11 +15,20 @@ namespace Text_Grab.Utilities;
/// OCRs each sub-region, then formats the results using the template's
/// string.
///
-/// Output template syntax:
+/// 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
@@ -29,6 +40,13 @@ public static class GrabTemplateExecutor
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 ────────────────────────────────────────────────────────────
///
@@ -51,13 +69,39 @@ public static async Task ExecuteTemplateAsync(
if (!template.IsValid)
return string.Empty;
- // 1. OCR each region
ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage();
- Dictionary regionResults = await OcrAllRegionsAsync(
- template, captureRegion, resolvedLanguage);
- // 2. Apply output template
- return ApplyOutputTemplate(template.OutputTemplate, regionResults);
+ // 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)
+ string? fullAreaText = null;
+ if (template.PatternMatches.Count > 0)
+ {
+ try
+ {
+ fullAreaText = await OcrUtilities.GetTextFromAbsoluteRectAsync(captureRegion, resolvedLanguage);
+ }
+ catch (Exception)
+ {
+ fullAreaText = string.Empty;
+ }
+ }
+
+ // 3. Resolve pattern regexes from saved patterns
+ Dictionary patternRegexes = [];
+ if (template.PatternMatches.Count > 0)
+ patternRegexes = ResolvePatternRegexes(template.PatternMatches);
+
+ // 4. Apply output template
+ string output = ApplyOutputTemplate(template.OutputTemplate, regionResults);
+
+ if (fullAreaText != null)
+ output = ApplyPatternPlaceholders(output, fullAreaText, template.PatternMatches, patternRegexes);
+
+ return output;
}
///
@@ -107,6 +151,151 @@ public static string ApplyOutputTemplate(
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;
+ }
+
+ private static StoredRegex[] LoadSavedPatterns()
+ {
+ try
+ {
+ string json = Properties.Settings.Default.RegexList;
+ if (string.IsNullOrWhiteSpace(json))
+ return StoredRegex.GetDefaultPatterns();
+
+ return JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns();
+ }
+ catch
+ {
+ return StoredRegex.GetDefaultPatterns();
+ }
+ }
+
// ── Private helpers ───────────────────────────────────────────────────────
private static async Task> OcrAllRegionsAsync(
@@ -153,15 +342,19 @@ private static async Task> OcrAllRegionsAsync(
/// 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)
+ public static List ValidateOutputTemplate(
+ string outputTemplate,
+ IEnumerable availableRegionNumbers,
+ IEnumerable? availablePatternNames = null)
{
List issues = [];
HashSet available = [.. availableRegionNumbers];
- MatchCollection matches = PlaceholderRegex.Matches(outputTemplate);
+ // Validate region placeholders
+ MatchCollection regionMatches = PlaceholderRegex.Matches(outputTemplate);
HashSet referenced = [];
- foreach (Match match in matches)
+ foreach (Match match in regionMatches)
{
if (!int.TryParse(match.Groups[1].Value, out int num))
{
@@ -181,6 +374,38 @@ public static List ValidateOutputTemplate(string outputTemplate, IEnumer
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/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index bc42e3fe..ab8c9d62 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -229,11 +229,39 @@ private async Task LoadTemplateForEditing(GrabTemplate template)
EnterEditMode();
UpdateTemplateBadges();
UpdateTemplatePickerItems();
+
+ // For editing, also add picker items for the template's specific pattern configurations
+ // so SetSerializedText can match the exact placeholder values and recreate chips
+ if (template.PatternMatches.Count > 0)
+ {
+ List items = [.. TemplateOutputBox.ItemsSource ?? []];
+ foreach (TemplatePatternMatch pm in template.PatternMatches)
+ {
+ string displayLabel = $"{pm.PatternName} ({pm.MatchMode})";
+ string value = BuildPatternPlaceholderValue(pm);
+ // Only add if not already in the list (avoid duplicates with the default "first" items)
+ if (!items.Any(i => i.Value == value))
+ items.Add(new InlinePickerItem(displayLabel, value, "Patterns"));
+ }
+ TemplateOutputBox.ItemsSource = items;
+ }
+
// Repopulate the output box AFTER ItemsSource is set so chips are recreated correctly
TemplateOutputBox.SetSerializedText(template.OutputTemplate);
reSearchTimer.Start();
}
+ private static string BuildPatternPlaceholderValue(TemplatePatternMatch config)
+ {
+ bool needsSeparator = config.MatchMode == "all"
+ || (config.MatchMode.Contains(',') && config.MatchMode.Split(',').Length > 1);
+
+ if (needsSeparator && config.Separator != ", ")
+ return $"{{p:{config.PatternName}:{config.MatchMode}:{config.Separator}}}";
+
+ return $"{{p:{config.PatternName}:{config.MatchMode}}}";
+ }
+
private async Task LoadContentFromHistory(HistoryInfo history)
{
FrameText = history.TextContent;
@@ -2512,11 +2540,16 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
return;
}
- if (wordBorders.Count == 0)
+ string outputTemplateText = TemplateOutputBox.GetSerializedText();
+
+ // Parse pattern references from the output template
+ List patternMatches = ParsePatternMatchesFromTemplate(outputTemplateText);
+
+ if (wordBorders.Count == 0 && patternMatches.Count == 0)
{
MessageBox.Show(
- "Use Ctrl+drag to draw at least one region before saving.",
- "No Regions",
+ "Use Ctrl+drag to draw at least one region, or add a pattern placeholder, before saving.",
+ "No Regions or Patterns",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -2540,10 +2573,11 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
GrabTemplate template = new(name)
{
- OutputTemplate = TemplateOutputBox.GetSerializedText(),
+ OutputTemplate = outputTemplateText,
ReferenceImageWidth = cw,
ReferenceImageHeight = ch,
Regions = regions,
+ PatternMatches = patternMatches,
};
if (_editingTemplate is not null)
@@ -2565,13 +2599,68 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e)
TemplateOutputBox.SetSerializedText(string.Empty);
UpdateTemplateBadges();
+ int totalItems = regions.Count + patternMatches.Count;
+ string itemsDesc = regions.Count > 0 && patternMatches.Count > 0
+ ? $"{regions.Count} region(s) and {patternMatches.Count} pattern(s)"
+ : regions.Count > 0
+ ? $"{regions.Count} region(s)"
+ : $"{patternMatches.Count} pattern(s)";
+
MessageBox.Show(
- $"Template \"{name}\" saved with {regions.Count} region(s).\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.",
+ $"Template \"{name}\" saved with {itemsDesc}.\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.",
"Template Saved",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
+ ///
+ /// Parses {p:Name:mode} and {p:Name:mode:separator} placeholders from the output template
+ /// and builds TemplatePatternMatch objects by resolving against saved patterns.
+ ///
+ private static List ParsePatternMatchesFromTemplate(string outputTemplate)
+ {
+ if (string.IsNullOrEmpty(outputTemplate))
+ return [];
+
+ MatchCollection matches = Regex.Matches(outputTemplate, @"\{p:([^:}]+):([^:}]+)(?::([^}]*))?\}");
+ Dictionary uniquePatterns = new(StringComparer.OrdinalIgnoreCase);
+
+ // Load saved patterns for ID resolution
+ StoredRegex[] savedPatterns;
+ try
+ {
+ string json = Settings.Default.RegexList;
+ savedPatterns = string.IsNullOrWhiteSpace(json)
+ ? StoredRegex.GetDefaultPatterns()
+ : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns();
+ }
+ catch
+ {
+ savedPatterns = StoredRegex.GetDefaultPatterns();
+ }
+
+ 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 void SaveTemplateCancel_Click(object sender, RoutedEventArgs e)
{
SaveAsTemplateBTN.IsChecked = false;
@@ -2628,12 +2717,73 @@ private void TemplateOutputBox_TextChanged(object sender, TextChangedEventArgs e
private void UpdateTemplatePickerItems()
{
List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)];
- TemplateOutputBox.ItemsSource = [.. sorted
+
+ // Region items
+ List items = [.. sorted
.Select((wb, i) =>
{
string label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word;
- return new InlinePickerItem(label, $"{{{i + 1}}}");
+ return new InlinePickerItem(label, $"{{{i + 1}}}", "Regions");
})];
+
+ // Pattern items from saved StoredRegex patterns
+ List patternItems = LoadPatternPickerItems();
+ items.AddRange(patternItems);
+
+ TemplateOutputBox.ItemsSource = items;
+
+ // Wire up the pattern selection callback
+ TemplateOutputBox.PatternItemSelected ??= OnPatternItemSelected;
+ }
+
+ private static List LoadPatternPickerItems()
+ {
+ StoredRegex[] patterns;
+ try
+ {
+ string json = Settings.Default.RegexList;
+ patterns = string.IsNullOrWhiteSpace(json)
+ ? StoredRegex.GetDefaultPatterns()
+ : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns();
+ }
+ catch
+ {
+ patterns = StoredRegex.GetDefaultPatterns();
+ }
+
+ return [.. patterns.Select(p =>
+ new InlinePickerItem(p.Name, $"{{p:{p.Name}:first}}", "Patterns"))];
+ }
+
+ private TemplatePatternMatch? OnPatternItemSelected(InlinePickerItem item)
+ {
+ // Extract pattern ID by looking up the name
+ StoredRegex[] patterns;
+ try
+ {
+ string json = Settings.Default.RegexList;
+ patterns = string.IsNullOrWhiteSpace(json)
+ ? StoredRegex.GetDefaultPatterns()
+ : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns();
+ }
+ catch
+ {
+ patterns = StoredRegex.GetDefaultPatterns();
+ }
+
+ StoredRegex? storedRegex = patterns.FirstOrDefault(
+ p => p.Name.Equals(item.DisplayName, StringComparison.OrdinalIgnoreCase));
+
+ string patternId = storedRegex?.Id ?? string.Empty;
+ string patternName = item.DisplayName;
+
+ PatternMatchModeDialog dialog = new(patternId, patternName)
+ {
+ Owner = this,
+ };
+
+ bool? dialogResult = dialog.ShowDialog();
+ return dialogResult == true ? dialog.Result : null;
}
private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null)
From 20bc475567d08d391debc12f962563f11ad547f5 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Tue, 3 Mar 2026 22:38:01 -0600
Subject: [PATCH 042/109] Fix NullReferenceException in PatternMatchModeDialog
Add null guards for controls accessed during InitializeComponent before
XAML template is fully loaded (IndicesErrorText, OkButton, IndicesTextBox).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Text-Grab/Controls/PatternMatchModeDialog.xaml.cs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs b/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
index 2c6b2c8a..47b80e7f 100644
--- a/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
+++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml.cs
@@ -47,6 +47,9 @@ private void IndicesTextBox_TextChanged(object sender, TextChangedEventArgs e)
private bool ValidateIndices()
{
+ if (IndicesTextBox == null)
+ return true;
+
string text = IndicesTextBox.Text.Trim();
if (string.IsNullOrEmpty(text))
{
@@ -76,6 +79,8 @@ private bool ValidateIndices()
private void ShowIndicesError(string message)
{
+ if (IndicesErrorText == null || OkButton == null)
+ return;
IndicesErrorText.Text = message;
IndicesErrorText.Visibility = Visibility.Visible;
OkButton.IsEnabled = false;
@@ -83,6 +88,8 @@ private void ShowIndicesError(string message)
private void HideIndicesError()
{
+ if (IndicesErrorText == null || OkButton == null)
+ return;
IndicesErrorText.Visibility = Visibility.Collapsed;
OkButton.IsEnabled = true;
}
From 6802c6ad4ee2067baf54af5bc24bff484baeafa3 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Tue, 3 Mar 2026 22:42:02 -0600
Subject: [PATCH 043/109] Fix ArgumentNullException in CommitSelection when
dialog steals focus
Save _triggerStart before opening the PatternMatchModeDialog. The modal
dialog causes OnLostKeyboardFocus to fire, which nulls _triggerStart.
Using the saved pointer prevents the TextRange null argument crash.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Text-Grab/Controls/InlinePickerRichTextBox.cs | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/Text-Grab/Controls/InlinePickerRichTextBox.cs b/Text-Grab/Controls/InlinePickerRichTextBox.cs
index d53b7dba..a3c34dbe 100644
--- a/Text-Grab/Controls/InlinePickerRichTextBox.cs
+++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs
@@ -470,15 +470,24 @@ private void CommitSelection()
if (selectedItem.Group == HeaderGroupTag)
return;
+ // Save trigger position before any dialog opens (dialogs steal focus,
+ // which fires OnLostKeyboardFocus and nulls _triggerStart)
+ TextPointer savedTriggerStart = _triggerStart;
+
// For pattern items, invoke the dialog callback to configure match mode
bool isPatternItem = string.Equals(selectedItem.Group, "Patterns", StringComparison.OrdinalIgnoreCase);
InlinePickerItem itemToInsert = selectedItem;
if (isPatternItem && PatternItemSelected != null)
{
+ HidePopup();
+
TemplatePatternMatch? patternConfig = PatternItemSelected(selectedItem);
if (patternConfig == null)
+ {
+ _triggerStart = null;
return; // user cancelled
+ }
// Build the placeholder value from the dialog result
string placeholderValue = BuildPatternPlaceholder(patternConfig);
@@ -490,7 +499,7 @@ private void CommitSelection()
try
{
// Delete from trigger position to current caret (removes "{" + typed filter text)
- new TextRange(_triggerStart, CaretPosition).Text = string.Empty;
+ new TextRange(savedTriggerStart, CaretPosition).Text = string.Empty;
// CaretPosition is now at the insertion point (where trigger was)
InlineChipElement chip = new()
From 209ca5fc70611c4b00a7b75d8cd4770d6850f03f Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Tue, 3 Mar 2026 22:51:29 -0600
Subject: [PATCH 044/109] Skip region overlays in FSG for pattern-only
templates
When a template's output references no regions (only pattern placeholders),
don't draw the region overlay rectangles during fullscreen grab selection.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Text-Grab/Views/FullscreenGrab.xaml.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index 64630178..7500d5b1 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -459,6 +459,11 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double
if (template is null || template.Regions.Count == 0)
return;
+ // If the output template references no regions (pattern-only), skip overlays
+ HashSet referencedRegions = [.. template.GetReferencedRegionNumbers()];
+ if (referencedRegions.Count == 0 && template.PatternMatches.Count > 0)
+ return;
+
if (selWidth < 4 || selHeight < 4)
return;
@@ -474,8 +479,6 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double
System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(220, 255, 180, 0);
System.Windows.Media.Color dimBorderColor = System.Windows.Media.Color.FromArgb(80, 255, 180, 0);
- HashSet referencedRegions = [.. template.GetReferencedRegionNumbers()];
-
foreach (TemplateRegion region in template.Regions)
{
double regionLeft = region.RatioLeft * selWidth;
From 5205db27b99ba2f53f271f5072295c7e108e7b3d Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 4 Mar 2026 22:34:40 -0600
Subject: [PATCH 045/109] Refactor GrabTemplate, improve regex usage, cleanup
code
Refactored GrabTemplate to a partial class and added a unique Id property. Replaced inline regex with [GeneratedRegex] partial methods for better performance. Simplified menu item check logic in FullscreenGrab. Removed an unused using directive from QuickSimpleLookup.
---
Text-Grab/Models/GrabTemplate.cs | 16 +++++++++-------
Text-Grab/Views/FullscreenGrab.xaml.cs | 4 +---
Text-Grab/Views/QuickSimpleLookup.xaml.cs | 1 -
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs
index 24a32960..986de995 100644
--- a/Text-Grab/Models/GrabTemplate.cs
+++ b/Text-Grab/Models/GrabTemplate.cs
@@ -27,7 +27,7 @@ namespace Text_Grab.Models;
/// \\ — literal backslash
/// \{ — literal opening brace
///
-public class GrabTemplate
+public partial class GrabTemplate
{
/// Unique persistent identifier.
public string Id { get; set; } = Guid.NewGuid().ToString();
@@ -101,9 +101,7 @@ public GrabTemplate(string name)
public IEnumerable GetReferencedRegionNumbers()
{
System.Text.RegularExpressions.MatchCollection matches =
- System.Text.RegularExpressions.Regex.Matches(
- OutputTemplate,
- @"\{(\d+)(?::[a-z]+)?\}");
+ RefRegionNumbers().Matches(OutputTemplate);
foreach (System.Text.RegularExpressions.Match match in matches)
{
@@ -118,13 +116,17 @@ public IEnumerable GetReferencedRegionNumbers()
public IEnumerable GetReferencedPatternNames()
{
System.Text.RegularExpressions.MatchCollection matches =
- System.Text.RegularExpressions.Regex.Matches(
- OutputTemplate,
- @"\{p:([^:}]+):[^}]+\}");
+ 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/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index 7500d5b1..92d4b375 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -284,9 +284,7 @@ private void LoadDynamicPostGrabActions()
foreach (ButtonInfo action in enabledActions)
{
// When a template is preselected, don't restore saved check state for other templates
- bool isChecked = !string.IsNullOrEmpty(PreselectedTemplateId) && !string.IsNullOrEmpty(action.TemplateId)
- ? false
- : PostGrabActionManager.GetCheckState(action);
+ bool isChecked = (string.IsNullOrEmpty(PreselectedTemplateId) || string.IsNullOrEmpty(action.TemplateId)) && PostGrabActionManager.GetCheckState(action);
MenuItem menuItem = new()
{
diff --git a/Text-Grab/Views/QuickSimpleLookup.xaml.cs b/Text-Grab/Views/QuickSimpleLookup.xaml.cs
index 1fb78a04..09a8ed18 100644
--- a/Text-Grab/Views/QuickSimpleLookup.xaml.cs
+++ b/Text-Grab/Views/QuickSimpleLookup.xaml.cs
@@ -14,7 +14,6 @@
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
-using Text_Grab.Controls;
using Text_Grab.Models;
using Text_Grab.Properties;
using Text_Grab.Services;
From 78c3762480969e192da0105a5be812fdbeb51f24 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 4 Mar 2026 22:57:46 -0600
Subject: [PATCH 046/109] Sync overlay canvas to DIP; use [GeneratedRegex]
patterns
Image/canvas sizing now uses device-independent pixels (DIPs)
to ensure overlays align correctly at all DPI scales. Replaces
inline regexes for template parsing with [GeneratedRegex] static
partial methods for improved performance and maintainability.
---
Text-Grab/Views/GrabFrame.xaml.cs | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs
index ab8c9d62..9bb01ab4 100644
--- a/Text-Grab/Views/GrabFrame.xaml.cs
+++ b/Text-Grab/Views/GrabFrame.xaml.cs
@@ -1483,9 +1483,14 @@ private void SyncRectanglesCanvasSizeToImage()
if (GrabFrameImage.Source is not BitmapSource source)
return;
- // Keep image and overlay in the same coordinate space (raw image pixels).
- double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth : source.Width;
- double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight : source.Height;
+ // Convert physical pixels to WPF device-independent pixels so the canvas
+ // coordinate space stays consistent with DrawRectanglesAroundWords, which
+ // divides OCR pixel coordinates by dpi.DpiScaleX/Y to produce DIP positions.
+ // Using raw PixelWidth would cause the Viewbox to scale down at DPI > 100%,
+ // shifting viewBoxZoomFactor and borderToCanvasX/Y, and misplacing word borders.
+ DpiScale dpi = VisualTreeHelper.GetDpi(this);
+ double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth / dpi.DpiScaleX : source.Width;
+ double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight / dpi.DpiScaleY : source.Height;
if (double.IsFinite(sourceWidth) && sourceWidth > 0)
{
@@ -2622,7 +2627,7 @@ private static List ParsePatternMatchesFromTemplate(string
if (string.IsNullOrEmpty(outputTemplate))
return [];
- MatchCollection matches = Regex.Matches(outputTemplate, @"\{p:([^:}]+):([^:}]+)(?::([^}]*))?\}");
+ MatchCollection matches = TemplatePattern().Matches(outputTemplate);
Dictionary uniquePatterns = new(StringComparer.OrdinalIgnoreCase);
// Load saved patterns for ID resolution
@@ -2697,7 +2702,7 @@ private void UpdateTemplateRegionOpacities()
return;
string outputTemplate = TemplateOutputBox.GetSerializedText();
- HashSet referenced = [.. Regex.Matches(outputTemplate, @"\{(\d+)(?::[a-z]+)?\}")
+ HashSet referenced = [.. OutputTemplateReferenced().Matches(outputTemplate)
.Select(m => int.TryParse(m.Groups[1].Value, out int n) ? n : 0)
.Where(n => n > 0)];
@@ -3722,5 +3727,10 @@ private void CancelTranslationButton_Click(object sender, RoutedEventArgs e)
UpdateFrameText();
}
+ [GeneratedRegex(@"\{p:([^:}]+):([^:}]+)(?::([^}]*))?\}")]
+ private static partial Regex TemplatePattern();
+ [GeneratedRegex(@"\{(\d+)(?::[a-z]+)?\}")]
+ private static partial Regex OutputTemplateReferenced();
+
#endregion Methods
}
From 87e0c38a3c338d1e94c10fbb5704d32d771e9c87 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 05:17:42 +0000
Subject: [PATCH 047/109] Initial plan
From 90a8704d368b7121c6df0c5edc5fe315ca6fc05b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 05:21:54 +0000
Subject: [PATCH 048/109] fix: address review feedback on docs, error messages,
and unused variables
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/Utilities/GrabTemplateExecutor.cs | 8 ++++----
Text-Grab/Views/FullscreenGrab.xaml.cs | 8 ++------
2 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs
index 90b82141..52ead768 100644
--- a/Text-Grab/Utilities/GrabTemplateExecutor.cs
+++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs
@@ -56,9 +56,9 @@ public static class GrabTemplateExecutor
///
/// The template to execute.
///
- /// The screen rectangle (in WPF units, pre-DPI-scaling applied by caller)
- /// that bounds the user's selection. Template region ratios are applied to
- /// this rectangle's width/height.
+ /// 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(
@@ -363,7 +363,7 @@ public static List ValidateOutputTemplate(
}
if (!available.Contains(num))
- issues.Add($"Placeholder {{{{num}}}} references region {num} which does not exist.");
+ issues.Add($"Placeholder {{{num}}} references region {num} which does not exist.");
referenced.Add(num);
}
diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index 92d4b375..787afee2 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -445,8 +445,8 @@ private void FullscreenGrab_KeyUp(object sender, KeyEventArgs e)
///
/// Draws scaled template region overlays inside the current selection border.
- /// Regions are scaled using UniformToFill semantics relative to the template's
- /// reference image dimensions so they fill the selected area proportionally.
+ /// Each region's stored ratio coordinates are applied directly to the current
+ /// selection dimensions (stretch-to-fill), so both axes scale independently.
///
private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double selWidth, double selHeight)
{
@@ -465,10 +465,6 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double
if (selWidth < 4 || selHeight < 4)
return;
- // Stretch: scale each axis independently to fill the selection exactly
- double scaleX = selWidth / template.ReferenceImageWidth;
- double scaleY = selHeight / template.ReferenceImageHeight;
-
templateOverlayCanvas.Width = selWidth;
templateOverlayCanvas.Height = selHeight;
Canvas.SetLeft(templateOverlayCanvas, selLeft);
From 8bf9482dad1b0d33ed280872f648faf5f02558c8 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Wed, 4 Mar 2026 23:46:52 -0600
Subject: [PATCH 049/109] Refactor context menu for post-grab actions and
templates
Separate regular actions and templates in the context menu, add section separators, and improve handling of preselected templates. Enforce exclusive selection among template actions and ensure check states are managed correctly. Improves menu clarity, usability, and robustness.
---
Text-Grab/Views/FullscreenGrab.xaml.cs | 69 ++++++++++++++++++++++----
1 file changed, 59 insertions(+), 10 deletions(-)
diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index 787afee2..b86117d9 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -1,6 +1,7 @@
using Dapplo.Windows.User32;
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows;
@@ -280,11 +281,14 @@ private void LoadDynamicPostGrabActions()
// Add keyboard handling once for the entire context menu
contextMenu.PreviewKeyDown += FullscreenGrab_KeyDown;
+ List regularActions = enabledActions.Where(a => string.IsNullOrEmpty(a.TemplateId)).ToList();
+ List templateActions = enabledActions.Where(a => !string.IsNullOrEmpty(a.TemplateId)).ToList();
+ bool templatePreselected = !string.IsNullOrEmpty(PreselectedTemplateId);
+
int index = 1;
- foreach (ButtonInfo action in enabledActions)
+ foreach (ButtonInfo action in regularActions)
{
- // When a template is preselected, don't restore saved check state for other templates
- bool isChecked = (string.IsNullOrEmpty(PreselectedTemplateId) || string.IsNullOrEmpty(action.TemplateId)) && PostGrabActionManager.GetCheckState(action);
+ bool isChecked = PostGrabActionManager.GetCheckState(action);
MenuItem menuItem = new()
{
@@ -296,9 +300,30 @@ private void LoadDynamicPostGrabActions()
InputGestureText = $"Ctrl+{index}"
};
- // Wire up click handler
menuItem.Click += PostActionMenuItem_Click;
+ contextMenu.Items.Add(menuItem);
+ index++;
+ }
+ // Separator between regular actions and grab templates
+ if (regularActions.Count > 0 && templateActions.Count > 0)
+ contextMenu.Items.Add(new Separator());
+
+ foreach (ButtonInfo action in templateActions)
+ {
+ bool isChecked = !templatePreselected && PostGrabActionManager.GetCheckState(action);
+
+ MenuItem menuItem = new()
+ {
+ Header = action.ButtonText,
+ IsCheckable = true,
+ Tag = action,
+ IsChecked = isChecked,
+ StaysOpenOnClick = stayOpen,
+ InputGestureText = $"Ctrl+{index}"
+ };
+
+ menuItem.Click += PostActionMenuItem_Click;
contextMenu.Items.Add(menuItem);
index++;
}
@@ -358,8 +383,10 @@ private void LoadDynamicPostGrabActions()
StaysOpenOnClick = DefaultSettings.PostGrabStayOpen,
};
templateMenuItem.Click += PostActionMenuItem_Click;
- contextMenu.Items.Insert(contextMenu.Items.Count > 0 ? contextMenu.Items.Count - 2 : 0, new Separator());
- contextMenu.Items.Insert(contextMenu.Items.Count > 0 ? contextMenu.Items.Count - 2 : 0, templateMenuItem);
+ // Add section separator before templates if none exist yet (trailing: Separator, Customize, Close)
+ if (!templateActions.Any())
+ contextMenu.Items.Insert(contextMenu.Items.Count - 3, new Separator());
+ contextMenu.Items.Insert(contextMenu.Items.Count - 3, templateMenuItem);
}
}
@@ -1303,12 +1330,34 @@ private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e =
private void PostActionMenuItem_Click(object sender, RoutedEventArgs e)
{
- // Save check state for LastUsed tracking
- if (sender is MenuItem menuItem
- && menuItem.Tag is ButtonInfo action
- && action.DefaultCheckState == DefaultCheckState.LastUsed)
+ if (sender is not MenuItem menuItem || menuItem.Tag is not ButtonInfo action)
{
+ CheckIfAnyPostActionsSelected();
+ return;
+ }
+
+ // Save check state for LastUsed tracking
+ if (action.DefaultCheckState == DefaultCheckState.LastUsed)
PostGrabActionManager.SaveCheckState(action, menuItem.IsChecked);
+
+ // Enforce exclusive template selection: when a template is checked, uncheck all others
+ if (!string.IsNullOrEmpty(action.TemplateId)
+ && menuItem.IsChecked
+ && menuItem.Parent is ContextMenu contextMenu)
+ {
+ foreach (object item in contextMenu.Items)
+ {
+ if (item is MenuItem otherItem
+ && otherItem != menuItem
+ && otherItem.Tag is ButtonInfo otherAction
+ && !string.IsNullOrEmpty(otherAction.TemplateId)
+ && otherItem.IsChecked)
+ {
+ otherItem.IsChecked = false;
+ if (otherAction.DefaultCheckState == DefaultCheckState.LastUsed)
+ PostGrabActionManager.SaveCheckState(otherAction, false);
+ }
+ }
}
CheckIfAnyPostActionsSelected();
From d606fecef461e8516886df49824155e664d22a72 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 6 Mar 2026 10:56:32 -0600
Subject: [PATCH 050/109] Synchronize post-grab actions across FullscreenGrab
windows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement centralized state management and synchronization for post-grab actions (including templates and custom actions) across all open FullscreenGrab windows. Refactor menu item handling to use snapshot-based logic, ensuring exclusive template selection and consistent "LastUsed" persistence. Add keyboard shortcut support (Ctrl+1–9) for toggling actions and propagate changes to all windows. Update UI refresh logic and introduce comprehensive unit and WPF tests for the new synchronization features.
---
Tests/FullscreenGrabPostGrabActionTests.cs | 127 +++++++
Text-Grab/Utilities/WindowUtilities.cs | 31 ++
Text-Grab/Views/FullscreenGrab.xaml.cs | 379 +++++++++++++--------
3 files changed, 393 insertions(+), 144 deletions(-)
create mode 100644 Tests/FullscreenGrabPostGrabActionTests.cs
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/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs
index b0a20563..3c61f623 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;
@@ -94,6 +96,9 @@ public static void LaunchFullScreenGrab(TextBox? destinationTextBox, string? pre
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++)
@@ -157,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 = "";
@@ -203,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));
diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index b86117d9..0a70f868 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -206,19 +206,7 @@ internal void KeyPressed(Key key, bool? isActive = null)
int numberPressed = (int)key - 34; // D1 casts to 35, D2 to 36, etc.
if (KeyboardExtensions.IsCtrlDown())
- {
- if (NextStepDropDownButton.Flyout is not ContextMenu flyoutMenu
- || !flyoutMenu.HasItems
- || numberPressed - 1 >= flyoutMenu.Items.Count
- || flyoutMenu.Items[numberPressed - 1] is not MenuItem selectedItem)
- {
- return;
- }
-
- selectedItem.IsChecked = !selectedItem.IsChecked;
- CheckIfAnyPostActionsSelected();
return;
- }
int numberOfLanguages = LanguagesComboBox.Items.Count;
if (numberPressed <= numberOfLanguages
@@ -232,14 +220,75 @@ internal void KeyPressed(Key key, bool? isActive = null)
}
}
+ internal static string GetPostGrabActionKey(ButtonInfo action)
+ {
+ if (!string.IsNullOrWhiteSpace(action.TemplateId))
+ return $"template:{action.TemplateId}";
+
+ if (!string.IsNullOrWhiteSpace(action.ClickEvent))
+ return $"click:{action.ClickEvent}";
+
+ return $"text:{action.ButtonText}";
+ }
+
+ internal static List GetActionablePostGrabMenuItems(ContextMenu contextMenu)
+ {
+ return [.. contextMenu.Items
+ .OfType()
+ .Where(static item => item.Tag is ButtonInfo)];
+ }
+
+ internal static Dictionary BuildPostGrabActionSnapshot(
+ IEnumerable actionableItems,
+ string? changedActionKey = null,
+ bool? changedIsChecked = null)
+ {
+ List<(MenuItem MenuItem, ButtonInfo Action, string ActionKey)> postGrabItems = [];
+
+ foreach (MenuItem menuItem in actionableItems)
+ {
+ if (menuItem.Tag is not ButtonInfo action)
+ continue;
+
+ postGrabItems.Add((menuItem, action, GetPostGrabActionKey(action)));
+ }
+
+ Dictionary actionStates = [];
+ foreach ((MenuItem menuItem, _, string actionKey) in postGrabItems)
+ {
+ bool isChecked = changedActionKey == actionKey && changedIsChecked.HasValue
+ ? changedIsChecked.Value
+ : menuItem.IsChecked;
+ actionStates[actionKey] = isChecked;
+ }
+
+ List checkedTemplateKeys = [.. postGrabItems
+ .Where(item => !string.IsNullOrWhiteSpace(item.Action.TemplateId) && actionStates[item.ActionKey])
+ .Select(item => item.ActionKey)];
+
+ if (checkedTemplateKeys.Count > 1)
+ {
+ string templateToKeep = !string.IsNullOrWhiteSpace(changedActionKey)
+ && changedIsChecked == true
+ && checkedTemplateKeys.Contains(changedActionKey)
+ ? changedActionKey
+ : checkedTemplateKeys[0];
+
+ foreach (string templateKey in checkedTemplateKeys.Where(key => key != templateToKeep))
+ actionStates[templateKey] = false;
+ }
+
+ return actionStates;
+ }
+
private void CheckIfAnyPostActionsSelected()
{
if (NextStepDropDownButton.Flyout is not ContextMenu flyoutMenu || !flyoutMenu.HasItems)
return;
- foreach (object anyItem in flyoutMenu.Items)
+ foreach (MenuItem item in GetActionablePostGrabMenuItems(flyoutMenu))
{
- if (anyItem is MenuItem item && item.IsChecked is true)
+ if (item.IsChecked)
{
if (FindResource("DarkTeal") is SolidColorBrush tealButtonStyle)
NextStepDropDownButton.Background = tealButtonStyle;
@@ -261,6 +310,131 @@ private static bool CheckIfCheckingOrUnchecking(object? sender)
return isActive;
}
+ private void RefreshPostGrabActionVisuals()
+ {
+ CheckIfAnyPostActionsSelected();
+
+ if (RegionClickCanvas.Children.Contains(selectBorder)
+ && selectBorder.Width > 2
+ && selectBorder.Height > 2)
+ {
+ double selLeft = Canvas.GetLeft(selectBorder);
+ double selTop = Canvas.GetTop(selectBorder);
+
+ if (!double.IsNaN(selLeft) && !double.IsNaN(selTop))
+ {
+ UpdateTemplateRegionOverlays(selLeft, selTop, selectBorder.Width, selectBorder.Height);
+ return;
+ }
+ }
+
+ TemplateOverlayHost.Children.Clear();
+ templateOverlayCanvas.Children.Clear();
+ }
+
+ private void SynchronizePostGrabActionShortcut(int actionIndex)
+ {
+ if (NextStepDropDownButton.Flyout is not ContextMenu contextMenu || !contextMenu.HasItems)
+ return;
+
+ List actionableItems = GetActionablePostGrabMenuItems(contextMenu);
+ if (actionIndex < 0 || actionIndex >= actionableItems.Count)
+ return;
+
+ MenuItem selectedItem = actionableItems[actionIndex];
+ SynchronizePostGrabActionSelection(selectedItem, !selectedItem.IsChecked);
+ }
+
+ private void SynchronizePostGrabActionSelection(MenuItem menuItem, bool isChecked)
+ {
+ if (menuItem.Tag is not ButtonInfo action
+ || menuItem.Parent is not ContextMenu contextMenu)
+ {
+ RefreshPostGrabActionVisuals();
+ return;
+ }
+
+ Dictionary actionStates = BuildPostGrabActionSnapshot(
+ GetActionablePostGrabMenuItems(contextMenu),
+ GetPostGrabActionKey(action),
+ isChecked);
+
+ ApplyPostGrabActionSnapshot(
+ actionStates,
+ persistLastUsed: true,
+ forcePersistActionKey: GetPostGrabActionKey(action));
+ WindowUtilities.SyncFullscreenPostGrabActionStates(actionStates, this);
+ }
+
+ internal static bool ShouldPersistLastUsedState(ButtonInfo action, bool previousChecked, bool isChecked, string? forcePersistActionKey = null)
+ {
+ if (action.DefaultCheckState != DefaultCheckState.LastUsed)
+ return false;
+
+ return previousChecked != isChecked || GetPostGrabActionKey(action) == forcePersistActionKey;
+ }
+
+ internal void ApplyPostGrabActionSnapshot(
+ IReadOnlyDictionary actionStates,
+ bool persistLastUsed = false,
+ string? forcePersistActionKey = null)
+ {
+ if (NextStepDropDownButton.Flyout is not ContextMenu contextMenu || !contextMenu.HasItems)
+ return;
+
+ foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(contextMenu))
+ {
+ if (menuItem.Tag is not ButtonInfo action)
+ continue;
+
+ bool previousChecked = menuItem.IsChecked;
+ bool isChecked = actionStates.TryGetValue(GetPostGrabActionKey(action), out bool syncedState) && syncedState;
+ menuItem.IsChecked = isChecked;
+
+ if (persistLastUsed
+ && ShouldPersistLastUsedState(action, previousChecked, isChecked, forcePersistActionKey))
+ {
+ PostGrabActionManager.SaveCheckState(action, isChecked);
+ }
+ }
+
+ RefreshPostGrabActionVisuals();
+ }
+
+ private void AddPostGrabActionMenuItem(ContextMenu contextMenu, ButtonInfo action, bool isChecked, bool stayOpen, int shortcutIndex)
+ {
+ MenuItem menuItem = new()
+ {
+ Header = action.ButtonText,
+ IsCheckable = true,
+ Tag = action,
+ IsChecked = isChecked,
+ StaysOpenOnClick = stayOpen,
+ InputGestureText = $"Ctrl+{shortcutIndex}"
+ };
+
+ menuItem.Click += PostActionMenuItem_Click;
+ contextMenu.Items.Add(menuItem);
+ }
+
+ private List GetEnabledPostGrabActionsForMenu()
+ {
+ List enabledActions = PostGrabActionManager.GetEnabledPostGrabActions();
+
+ if (string.IsNullOrWhiteSpace(PreselectedTemplateId)
+ || enabledActions.Any(action => action.TemplateId == PreselectedTemplateId))
+ {
+ return enabledActions;
+ }
+
+ GrabTemplate? template = GrabTemplateManager.GetTemplateById(PreselectedTemplateId);
+ if (template is null)
+ return enabledActions;
+
+ enabledActions.Add(GrabTemplateManager.CreateButtonInfoForTemplate(template));
+ return enabledActions;
+ }
+
private void LoadDynamicPostGrabActions()
{
if (NextStepDropDownButton.Flyout is not ContextMenu contextMenu)
@@ -269,16 +443,11 @@ private void LoadDynamicPostGrabActions()
// Clear existing items
contextMenu.Items.Clear();
- // Get enabled post-grab actions from settings
- List enabledActions = PostGrabActionManager.GetEnabledPostGrabActions();
+ List enabledActions = GetEnabledPostGrabActionsForMenu();
- // Get the PostGrabStayOpen setting
bool stayOpen = DefaultSettings.PostGrabStayOpen;
- // Remove any existing keyboard handler to avoid duplicates
contextMenu.PreviewKeyDown -= FullscreenGrab_KeyDown;
-
- // Add keyboard handling once for the entire context menu
contextMenu.PreviewKeyDown += FullscreenGrab_KeyDown;
List regularActions = enabledActions.Where(a => string.IsNullOrEmpty(a.TemplateId)).ToList();
@@ -288,49 +457,25 @@ private void LoadDynamicPostGrabActions()
int index = 1;
foreach (ButtonInfo action in regularActions)
{
- bool isChecked = PostGrabActionManager.GetCheckState(action);
-
- MenuItem menuItem = new()
- {
- Header = action.ButtonText,
- IsCheckable = true,
- Tag = action,
- IsChecked = isChecked,
- StaysOpenOnClick = stayOpen,
- InputGestureText = $"Ctrl+{index}"
- };
-
- menuItem.Click += PostActionMenuItem_Click;
- contextMenu.Items.Add(menuItem);
+ AddPostGrabActionMenuItem(contextMenu, action, PostGrabActionManager.GetCheckState(action), stayOpen, index);
index++;
}
- // Separator between regular actions and grab templates
if (regularActions.Count > 0 && templateActions.Count > 0)
contextMenu.Items.Add(new Separator());
foreach (ButtonInfo action in templateActions)
{
- bool isChecked = !templatePreselected && PostGrabActionManager.GetCheckState(action);
-
- MenuItem menuItem = new()
- {
- Header = action.ButtonText,
- IsCheckable = true,
- Tag = action,
- IsChecked = isChecked,
- StaysOpenOnClick = stayOpen,
- InputGestureText = $"Ctrl+{index}"
- };
+ bool isChecked = templatePreselected
+ ? action.TemplateId == PreselectedTemplateId
+ : PostGrabActionManager.GetCheckState(action);
- menuItem.Click += PostActionMenuItem_Click;
- contextMenu.Items.Add(menuItem);
+ AddPostGrabActionMenuItem(contextMenu, action, isChecked, stayOpen, index);
index++;
}
contextMenu.Items.Add(new Separator());
- // Add "Customize Actions & Templates..." menu item
MenuItem editPostGrabMenuItem = new()
{
Header = "✨ Customize Actions \u0026 Templates...",
@@ -348,50 +493,14 @@ private void LoadDynamicPostGrabActions()
hidePostGrabMenuItem.Click += HidePostGrabActions_Click;
contextMenu.Items.Add(hidePostGrabMenuItem);
- // Update the dropdown button appearance
- CheckIfAnyPostActionsSelected();
-
- // If a template was preselected (e.g. from Quick Simple Lookup), auto-check it
- if (!string.IsNullOrEmpty(PreselectedTemplateId))
+ IReadOnlyDictionary? synchronizedActionStates = WindowUtilities.GetFullscreenPostGrabActionStates();
+ if (synchronizedActionStates is not null)
{
- bool found = false;
- foreach (object item in contextMenu.Items)
- {
- if (item is MenuItem mi
- && mi.Tag is ButtonInfo bi
- && bi.TemplateId == PreselectedTemplateId)
- {
- mi.IsChecked = true;
- found = true;
- break;
- }
- }
-
- // Template not in the enabled list — add it dynamically
- if (!found)
- {
- GrabTemplate? template = GrabTemplateManager.GetTemplateById(PreselectedTemplateId);
- if (template is not null)
- {
- ButtonInfo templateAction = GrabTemplateManager.CreateButtonInfoForTemplate(template);
- MenuItem templateMenuItem = new()
- {
- Header = templateAction.ButtonText,
- IsCheckable = true,
- Tag = templateAction,
- IsChecked = true,
- StaysOpenOnClick = DefaultSettings.PostGrabStayOpen,
- };
- templateMenuItem.Click += PostActionMenuItem_Click;
- // Add section separator before templates if none exist yet (trailing: Separator, Customize, Close)
- if (!templateActions.Any())
- contextMenu.Items.Insert(contextMenu.Items.Count - 3, new Separator());
- contextMenu.Items.Insert(contextMenu.Items.Count - 3, templateMenuItem);
- }
- }
-
- CheckIfAnyPostActionsSelected();
+ ApplyPostGrabActionSnapshot(synchronizedActionStates);
+ return;
}
+
+ RefreshPostGrabActionVisuals();
}
private void CancelMenuItem_Click(object sender, RoutedEventArgs e)
@@ -433,6 +542,16 @@ private async void FreezeUnfreeze(bool Activate)
private void FullscreenGrab_KeyDown(object sender, KeyEventArgs e)
{
+ int keyValue = (int)e.Key;
+ if (KeyboardExtensions.IsCtrlDown()
+ && keyValue >= (int)Key.D1
+ && keyValue <= (int)Key.D9)
+ {
+ SynchronizePostGrabActionShortcut(keyValue - (int)Key.D1);
+ e.Handled = true;
+ return;
+ }
+
WindowUtilities.FullscreenKeyDown(e.Key);
}
@@ -455,10 +574,9 @@ private void FullscreenGrab_KeyUp(object sender, KeyEventArgs e)
if (NextStepDropDownButton.Flyout is not ContextMenu contextMenu)
return null;
- foreach (object item in contextMenu.Items)
+ foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(contextMenu))
{
- if (item is MenuItem menuItem
- && menuItem.IsChecked
+ if (menuItem.IsChecked
&& menuItem.Tag is ButtonInfo action
&& action.ClickEvent == "ApplyTemplate_Click"
&& !string.IsNullOrEmpty(action.TemplateId))
@@ -982,38 +1100,33 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
{
bool shouldInsert = false;
- foreach (object item in contextMenu.Items)
+ foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(contextMenu))
{
- if (item is MenuItem menuItem && menuItem.IsChecked && menuItem.Tag is ButtonInfo action)
+ if (!menuItem.IsChecked || menuItem.Tag is not ButtonInfo action)
+ continue;
+
+ if (action.ClickEvent == "Insert_Click")
{
- // Special handling for Insert action - defer until after window closes
- if (action.ClickEvent == "Insert_Click")
- {
- shouldInsert = true;
- continue;
- }
+ shouldInsert = true;
+ continue;
+ }
- // Build context for this action (text may have changed from previous actions)
- PostGrabContext grabContext = new(
- Text: TextFromOCR ?? string.Empty,
- CaptureRegion: absoluteCaptureRect,
- DpiScale: m.M11,
- CapturedImage: null,
- Language: selectedOcrLang);
+ PostGrabContext grabContext = new(
+ Text: TextFromOCR ?? string.Empty,
+ CaptureRegion: absoluteCaptureRect,
+ DpiScale: m.M11,
+ CapturedImage: null,
+ Language: selectedOcrLang);
- // Execute the action
- TextFromOCR = await PostGrabActionManager.ExecutePostGrabAction(action, grabContext);
- }
+ TextFromOCR = await PostGrabActionManager.ExecutePostGrabAction(action, grabContext);
}
- // Handle insert after all other actions
if (shouldInsert && !DefaultSettings.TryInsert)
{
- // Store for later execution after window closes
string textToInsert = TextFromOCR;
_ = Task.Run(async () =>
{
- await Task.Delay(100); // Small delay to ensure window is closed
+ await Task.Delay(100);
await WindowUtilities.TryInsertString(textToInsert);
});
}
@@ -1026,9 +1139,11 @@ private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs
bool isWebSearch = false;
if (NextStepDropDownButton.Flyout is ContextMenu cm)
{
- foreach (object item in cm.Items)
+ foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(cm))
{
- if (item is MenuItem mi && mi.IsChecked && mi.Tag is ButtonInfo act && act.ClickEvent == "WebSearch_Click")
+ if (menuItem.IsChecked
+ && menuItem.Tag is ButtonInfo action
+ && action.ClickEvent == "WebSearch_Click")
{
isWebSearch = true;
break;
@@ -1330,37 +1445,13 @@ private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e =
private void PostActionMenuItem_Click(object sender, RoutedEventArgs e)
{
- if (sender is not MenuItem menuItem || menuItem.Tag is not ButtonInfo action)
+ if (sender is not MenuItem menuItem)
{
- CheckIfAnyPostActionsSelected();
+ RefreshPostGrabActionVisuals();
return;
}
- // Save check state for LastUsed tracking
- if (action.DefaultCheckState == DefaultCheckState.LastUsed)
- PostGrabActionManager.SaveCheckState(action, menuItem.IsChecked);
-
- // Enforce exclusive template selection: when a template is checked, uncheck all others
- if (!string.IsNullOrEmpty(action.TemplateId)
- && menuItem.IsChecked
- && menuItem.Parent is ContextMenu contextMenu)
- {
- foreach (object item in contextMenu.Items)
- {
- if (item is MenuItem otherItem
- && otherItem != menuItem
- && otherItem.Tag is ButtonInfo otherAction
- && !string.IsNullOrEmpty(otherAction.TemplateId)
- && otherItem.IsChecked)
- {
- otherItem.IsChecked = false;
- if (otherAction.DefaultCheckState == DefaultCheckState.LastUsed)
- PostGrabActionManager.SaveCheckState(otherAction, false);
- }
- }
- }
-
- CheckIfAnyPostActionsSelected();
+ SynchronizePostGrabActionSelection(menuItem, menuItem.IsChecked);
}
#endregion Methods
From 2b7bfce4df16b611fc7fcacd360e1c27de404af8 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 6 Mar 2026 16:58:06 -0600
Subject: [PATCH 051/109] Add fullscreen grab selection styles
Introduce region, window, freeform, and adjust-after selection styles for Fullscreen Grab, wire the new UI and settings surfaces, and add focused tests for the supporting models and utilities.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Tests/FreeformCaptureUtilitiesTests.cs | 63 +
Tests/FullscreenCaptureResultTests.cs | 32 +
Tests/FullscreenGrabSelectionStyleTests.cs | 74 +
Tests/WindowSelectionUtilitiesTests.cs | 33 +
Text-Grab/Enums.cs | 8 +
Text-Grab/Models/FullscreenCaptureResult.cs | 18 +
Text-Grab/Models/HistoryInfo.cs | 2 +
Text-Grab/Models/PostGrabContext.cs | 7 +-
Text-Grab/Models/WindowSelectionCandidate.cs | 13 +
Text-Grab/OSInterop.cs | 41 +-
Text-Grab/Pages/FullscreenGrabSettings.xaml | 36 +
.../Pages/FullscreenGrabSettings.xaml.cs | 50 +
Text-Grab/Properties/Settings.Designer.cs | 12 +
Text-Grab/Properties/Settings.settings | 3 +
.../SelectionStyleComboBoxItemStyle.xaml | 52 +
.../Styles/SelectionStyleComboBoxStyle.xaml | 106 ++
Text-Grab/Styles/TextStyles.xaml | 12 +-
.../Utilities/FreeformCaptureUtilities.cs | 70 +
Text-Grab/Utilities/OcrUtilities.cs | 72 +-
.../Utilities/WindowSelectionUtilities.cs | 132 ++
.../Views/FullscreenGrab.SelectionStyles.cs | 1289 +++++++++++++++++
Text-Grab/Views/FullscreenGrab.xaml | 85 +-
Text-Grab/Views/FullscreenGrab.xaml.cs | 296 +---
Text-Grab/Views/GrabFrame.xaml | 8 -
24 files changed, 2239 insertions(+), 275 deletions(-)
create mode 100644 Tests/FreeformCaptureUtilitiesTests.cs
create mode 100644 Tests/FullscreenCaptureResultTests.cs
create mode 100644 Tests/FullscreenGrabSelectionStyleTests.cs
create mode 100644 Tests/WindowSelectionUtilitiesTests.cs
create mode 100644 Text-Grab/Models/FullscreenCaptureResult.cs
create mode 100644 Text-Grab/Models/WindowSelectionCandidate.cs
create mode 100644 Text-Grab/Styles/SelectionStyleComboBoxItemStyle.xaml
create mode 100644 Text-Grab/Styles/SelectionStyleComboBoxStyle.xaml
create mode 100644 Text-Grab/Utilities/FreeformCaptureUtilities.cs
create mode 100644 Text-Grab/Utilities/WindowSelectionUtilities.cs
create mode 100644 Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
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/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/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/Enums.cs b/Text-Grab/Enums.cs
index ebc238a0..272e1dcb 100644
--- a/Text-Grab/Enums.cs
+++ b/Text-Grab/Enums.cs
@@ -98,3 +98,11 @@ public enum FsgDefaultMode
SingleLine = 1,
Table = 2,
}
+
+public enum FsgSelectionStyle
+{
+ Region = 0,
+ Window = 1,
+ Freeform = 2,
+ AdjustAfter = 3,
+}
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/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs
index a848bb65..b335e397 100644
--- a/Text-Grab/Models/HistoryInfo.cs
+++ b/Text-Grab/Models/HistoryInfo.cs
@@ -35,6 +35,8 @@ 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;
diff --git a/Text-Grab/Models/PostGrabContext.cs b/Text-Grab/Models/PostGrabContext.cs
index 63707a09..0644634c 100644
--- a/Text-Grab/Models/PostGrabContext.cs
+++ b/Text-Grab/Models/PostGrabContext.cs
@@ -27,10 +27,13 @@ public record PostGrabContext(
BitmapSource? CapturedImage,
/// The OCR language used for the capture. Null means use the app default.
- ILanguage? Language = null
+ 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);
+ new(text, Rect.Empty, 1.0, null, null, FsgSelectionStyle.Region);
}
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/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/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml
index 57d04d05..19cbf5f1 100644
--- a/Text-Grab/Pages/FullscreenGrabSettings.xaml
+++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml
@@ -52,6 +52,42 @@
Text="Single Line outputs captures as a single line (same as pressing S). Table mode requires Windows OCR/AI languages."/>
+
+
+
+
+ Rectangle 1 - Drag a box
+
+
+ Window - Hover and click
+
+
+ Freeform - Draw any shape
+
+
+ Rectangle 2 - Drag, then adjust
+
+
+
+
+
Default
+
+ Region
+
True
diff --git a/Text-Grab/Styles/SelectionStyleComboBoxItemStyle.xaml b/Text-Grab/Styles/SelectionStyleComboBoxItemStyle.xaml
new file mode 100644
index 00000000..183257a0
--- /dev/null
+++ b/Text-Grab/Styles/SelectionStyleComboBoxItemStyle.xaml
@@ -0,0 +1,52 @@
+
+
+
diff --git a/Text-Grab/Styles/SelectionStyleComboBoxStyle.xaml b/Text-Grab/Styles/SelectionStyleComboBoxStyle.xaml
new file mode 100644
index 00000000..d16e7aeb
--- /dev/null
+++ b/Text-Grab/Styles/SelectionStyleComboBoxStyle.xaml
@@ -0,0 +1,106 @@
+
+
+
diff --git a/Text-Grab/Styles/TextStyles.xaml b/Text-Grab/Styles/TextStyles.xaml
index e05f30d3..56bd09d6 100644
--- a/Text-Grab/Styles/TextStyles.xaml
+++ b/Text-Grab/Styles/TextStyles.xaml
@@ -1,9 +1,17 @@
-
+
+
+
-
\ No newline at end of file
+
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/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs
index adffada9..74bb8859 100644
--- a/Text-Grab/Utilities/OcrUtilities.cs
+++ b/Text-Grab/Utilities/OcrUtilities.cs
@@ -131,6 +131,48 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow,
return sb.ToString();
}
+ public static async Task GetTextFromBitmapAsync(Bitmap bitmap, ILanguage 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)
+ {
+ double scale = await GetIdealScaleFactorForOcrAsync(bitmap, language);
+ using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale);
+ IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, language);
+ 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, language.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);
@@ -191,6 +233,9 @@ public static async void GetCopyTextFromPreviousRegion()
if (lastFsg is null)
return;
+ if (!CanReplayPreviousFullscreenSelection(lastFsg))
+ return;
+
Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor);
PreviousGrabWindow previousGrab = new(lastFsg.PositionRect);
@@ -224,6 +269,9 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio
if (lastFsg is null)
return;
+ if (!CanReplayPreviousFullscreenSelection(lastFsg))
+ return;
+
Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor);
PreviousGrabWindow previousGrab = new(lastFsg.PositionRect);
@@ -254,13 +302,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;
}
@@ -303,9 +344,9 @@ 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);
}
@@ -483,4 +524,17 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag
[GeneratedRegex(@"(^[\p{L}-[\p{Lo}]]|\p{Nd}$)|.{2,}")]
private static partial Regex SpaceJoiningWordRegex();
+
+ private static bool CanReplayPreviousFullscreenSelection(HistoryInfo history)
+ {
+ if (history.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter)
+ return true;
+
+ MessageBox.Show(
+ "Repeat previous fullscreen capture is currently available only for Region and Adjust After selections.",
+ "Text Grab",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return false;
+ }
}
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/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
new file mode 100644
index 00000000..665d8d75
--- /dev/null
+++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
@@ -0,0 +1,1289 @@
+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 = 10.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();
+ selectionOutlineBorder.Width = Math.Max(0, rect.Width);
+ selectionOutlineBorder.Height = Math.Max(0, rect.Height);
+ Canvas.SetLeft(selectionOutlineBorder, rect.Left);
+ Canvas.SetTop(selectionOutlineBorder, rect.Top);
+ }
+
+ 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();
+
+ int cropX = Math.Max(0, (int)Math.Round(selectionRect.Left * transformToDevice.M11));
+ int cropY = Math.Max(0, (int)Math.Round(selectionRect.Top * transformToDevice.M22));
+ int cropW = Math.Min((int)Math.Round(selectionRect.Width * transformToDevice.M11), backgroundBitmap.PixelWidth - cropX);
+ int cropH = Math.Min((int)Math.Round(selectionRect.Height * transformToDevice.M22), backgroundBitmap.PixelHeight - cropY);
+
+ if (cropW > 0 && cropH > 0)
+ {
+ CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH));
+ croppedBitmap.Freeze();
+ return croppedBitmap;
+ }
+ }
+
+ using Bitmap capturedBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle(), cacheResult: false);
+ return ImageMethods.BitmapToImageSource(capturedBitmap);
+ }
+
+ private Task PlaceGrabFrameInSelectionRectAsync(FullscreenCaptureResult selection)
+ {
+ BitmapSource? frozenImage = GetBitmapSourceForGrabFrame(selection);
+ GrabFrame grabFrame = frozenImage is not null ? new GrabFrame(frozenImage) : 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();
+ return Task.CompletedTask;
+ }
+
+ 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;
+
+ 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 = new(
+ Math.Round(selectionRect.Left * transformToDevice.M11),
+ Math.Round(selectionRect.Top * transformToDevice.M22));
+
+ TextFromOCR = await OcrUtilities.GetClickedWordAsync(this, clickedPointForOcr, selectedOcrLang);
+ }
+ else if (selection.CapturedImage is not null)
+ {
+ TextFromOCR = isTable
+ ? await OcrUtilities.GetTextFromBitmapSourceAsTableAsync(selection.CapturedImage, selectedOcrLang)
+ : await OcrUtilities.GetTextFromBitmapSourceAsync(selection.CapturedImage, selectedOcrLang);
+ }
+ else if (isTable)
+ {
+ using Bitmap selectionBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle());
+ TextFromOCR = await OcrUtilities.GetTextFromBitmapAsTableAsync(selectionBitmap, selectedOcrLang);
+ }
+ else
+ {
+ TextFromOCR = await OcrUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang);
+ }
+
+ 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;
+
+ historyInfo = new HistoryInfo
+ {
+ ID = Guid.NewGuid().ToString(),
+ DpiScaleFactor = GetCurrentDeviceScale(),
+ LanguageTag = LanguageUtilities.GetLanguageTag(selectedOcrLang),
+ LanguageKind = LanguageUtilities.GetLanguageKind(selectedOcrLang),
+ 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)
+ {
+ MessageBox.Show(
+ "Grab Templates are currently available only for rectangular selections. Freeform captures will keep their OCR text without applying templates.",
+ "Text Grab",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ 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 231ddc76..90717c49 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" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
2
&& selectBorder.Height > 2)
@@ -450,8 +468,8 @@ private void LoadDynamicPostGrabActions()
contextMenu.PreviewKeyDown -= FullscreenGrab_KeyDown;
contextMenu.PreviewKeyDown += FullscreenGrab_KeyDown;
- List regularActions = enabledActions.Where(a => string.IsNullOrEmpty(a.TemplateId)).ToList();
- List templateActions = enabledActions.Where(a => !string.IsNullOrEmpty(a.TemplateId)).ToList();
+ List regularActions = [.. enabledActions.Where(a => string.IsNullOrEmpty(a.TemplateId))];
+ List templateActions = [.. enabledActions.Where(a => !string.IsNullOrEmpty(a.TemplateId))];
bool templatePreselected = !string.IsNullOrEmpty(PreselectedTemplateId);
int index = 1;
@@ -542,6 +560,13 @@ private async void FreezeUnfreeze(bool Activate)
private void FullscreenGrab_KeyDown(object sender, KeyEventArgs e)
{
+ if (e.Key == Key.Enter && isAwaitingAdjustAfterCommit)
+ {
+ AcceptSelectionButton_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ return;
+ }
+
int keyValue = (int)e.Key;
if (KeyboardExtensions.IsCtrlDown()
&& keyValue >= (int)Key.D1
@@ -598,6 +623,9 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double
TemplateOverlayHost.Children.Clear();
templateOverlayCanvas.Children.Clear();
+ if (CurrentSelectionStyle == FsgSelectionStyle.Freeform)
+ return;
+
GrabTemplate? template = GetActiveTemplate();
if (template is null || template.Regions.Count == 0)
return;
@@ -844,11 +872,11 @@ private void PanSelection(System.Windows.Point movingPoint)
clippingGeometry.Rect = new Rect(
new System.Windows.Point(leftValue, topValue),
- new System.Windows.Size(selectBorder.Width - 2, selectBorder.Height - 2));
- Canvas.SetLeft(selectBorder, leftValue - 1);
- Canvas.SetTop(selectBorder, topValue - 1);
+ new System.Windows.Size(selectBorder.Width, selectBorder.Height));
+ Canvas.SetLeft(selectBorder, leftValue);
+ Canvas.SetTop(selectBorder, topValue);
- UpdateTemplateRegionOverlays(leftValue - 1, topValue - 1, selectBorder.Width, selectBorder.Height);
+ UpdateTemplateRegionOverlays(leftValue, topValue, selectBorder.Width, selectBorder.Height);
}
private void PlaceGrabFrameInSelectionRect()
@@ -917,253 +945,29 @@ private void RegionClickCanvas_ContextMenuOpening(object sender, ContextMenuEven
private void RegionClickCanvas_MouseLeave(object sender, MouseEventArgs e)
{
- TopButtonsStackPanel.Visibility = Visibility.Collapsed;
+ UpdateTopToolbarVisibility(isPointerOverSelectionSurface: false);
}
private void RegionClickCanvas_MouseEnter(object sender, MouseEventArgs e)
{
- TopButtonsStackPanel.Visibility = Visibility.Visible;
+ UpdateTopToolbarVisibility(isPointerOverSelectionSurface: true);
}
private void RegionClickCanvas_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.RightButton == MouseButtonState.Pressed)
return;
-
- isSelecting = true;
- TopButtonsStackPanel.Visibility = Visibility.Collapsed;
- RegionClickCanvas.CaptureMouse();
- CursorClipper.ClipCursor(this);
- clickedPoint = e.GetPosition(this);
- selectBorder.Height = 2;
- selectBorder.Width = 2;
-
- dpiScale = VisualTreeHelper.GetDpi(this);
-
- try { RegionClickCanvas.Children.Remove(selectBorder); } catch (Exception) { }
- TemplateOverlayHost.Children.Clear();
- templateOverlayCanvas.Children.Clear();
-
- selectBorder.BorderThickness = new Thickness(2);
- System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(255, 40, 118, 126);
- selectBorder.BorderBrush = new SolidColorBrush(borderColor);
- _ = RegionClickCanvas.Children.Add(selectBorder);
- Canvas.SetLeft(selectBorder, clickedPoint.X);
- Canvas.SetTop(selectBorder, clickedPoint.Y);
-
- WindowUtilities.GetMousePosition(out System.Windows.Point mousePoint);
- foreach (DisplayInfo? screen in DisplayInfo.AllDisplayInfos)
- {
- Rect bound = screen.ScaledBounds();
- if (bound.Contains(mousePoint))
- CurrentScreen = screen;
- }
+ HandleRegionCanvasMouseDown(e);
}
private void RegionClickCanvas_MouseMove(object sender, MouseEventArgs e)
{
- if (!isSelecting)
- return;
-
- System.Windows.Point movingPoint = e.GetPosition(this);
-
- 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);
-
- selectBorder.Height = Math.Max(clickedPoint.Y, movingPoint.Y) - top;
- selectBorder.Width = Math.Max(clickedPoint.X, movingPoint.X) - left;
- selectBorder.Height += 2;
- selectBorder.Width += 2;
-
- clippingGeometry.Rect = new Rect(
- new System.Windows.Point(left, top),
- new System.Windows.Size(selectBorder.Width - 2, selectBorder.Height - 2));
- Canvas.SetLeft(selectBorder, left - 1);
- Canvas.SetTop(selectBorder, top - 1);
-
- UpdateTemplateRegionOverlays(left - 1, top - 1, selectBorder.Width, selectBorder.Height);
+ HandleRegionCanvasMouseMove(e);
}
private async void RegionClickCanvas_MouseUp(object sender, MouseButtonEventArgs e)
{
- if (!isSelecting)
- return;
-
- isSelecting = false;
- CurrentScreen = null;
- CursorClipper.UnClipCursor();
- RegionClickCanvas.ReleaseMouseCapture();
- clippingGeometry.Rect = new Rect(
- new System.Windows.Point(0, 0),
- new System.Windows.Size(0, 0));
-
- TemplateOverlayHost.Children.Clear();
- templateOverlayCanvas.Children.Clear();
-
- System.Windows.Point movingPoint = e.GetPosition(this);
- Matrix m = PresentationSource.FromVisual(this).CompositionTarget.TransformToDevice;
- movingPoint.X *= m.M11;
- movingPoint.Y *= m.M22;
-
- movingPoint.X = Math.Round(movingPoint.X);
- movingPoint.Y = Math.Round(movingPoint.Y);
-
- double xDimScaled = Canvas.GetLeft(selectBorder) * m.M11;
- double yDimScaled = Canvas.GetTop(selectBorder) * m.M22;
-
- Rectangle regionScaled = new(
- (int)xDimScaled,
- (int)yDimScaled,
- (int)(selectBorder.Width * m.M11),
- (int)(selectBorder.Height * m.M22));
-
- // Build the absolute capture rect for template execution (physical screen pixels)
- System.Windows.Point absoluteWindowPos = this.GetAbsolutePosition();
- Rect absoluteCaptureRect = new(
- absoluteWindowPos.X + xDimScaled,
- absoluteWindowPos.Y + yDimScaled,
- selectBorder.Width * m.M11,
- selectBorder.Height * m.M22);
-
- TextFromOCR = string.Empty;
-
- if (NewGrabFrameMenuItem.IsChecked is true)
- {
- PlaceGrabFrameInSelectionRect();
- return;
- }
-
- try { RegionClickCanvas.Children.Remove(selectBorder); } catch { }
-
- if (LanguagesComboBox.SelectedItem is not ILanguage selectedOcrLang)
- selectedOcrLang = LanguageUtilities.GetOCRLanguage();
-
- bool isSmallClick = (selectBorder.Width < 3 || selectBorder.Height < 3);
-
- bool isSingleLine = SingleLineMenuItem is not null && SingleLineMenuItem.IsChecked;
- bool isTable = TableMenuItem is not null && TableMenuItem.IsChecked;
-
- if (isSmallClick)
- {
- BackgroundBrush.Opacity = 0;
- TextFromOCR = await OcrUtilities.GetClickedWordAsync(this, new System.Windows.Point(xDimScaled, yDimScaled), selectedOcrLang);
- }
- else if (isTable)
- TextFromOCR = await OcrUtilities.GetRegionsTextAsTableAsync(this, regionScaled, selectedOcrLang);
- else
- TextFromOCR = await OcrUtilities.GetRegionsTextAsync(this, regionScaled, selectedOcrLang);
-
- if (DefaultSettings.UseHistory && !isSmallClick)
- {
- GetDpiAdjustedRegionOfSelectBorder(out _, out double posLeft, out double posTop);
-
- Rect historyRect = new()
- {
- X = posLeft,
- Y = posTop,
- Width = selectBorder.Width,
- Height = selectBorder.Height,
- };
-
- historyInfo = new()
- {
- ID = Guid.NewGuid().ToString(),
- DpiScaleFactor = m.M11,
- LanguageTag = LanguageUtilities.GetLanguageTag(selectedOcrLang),
- LanguageKind = LanguageUtilities.GetLanguageKind(selectedOcrLang),
- CaptureDateTime = DateTimeOffset.Now,
- PositionRect = historyRect,
- IsTable = TableToggleButton.IsChecked!.Value,
- TextContent = TextFromOCR,
- ImageContent = Singleton.Instance.CachedBitmap is Bitmap cb ? new Bitmap(cb) : null,
- SourceMode = TextGrabMode.Fullscreen,
- };
- }
-
- if (string.IsNullOrWhiteSpace(TextFromOCR))
- {
- BackgroundBrush.Opacity = DefaultSettings.FsgShadeOverlay ? .2 : 0.0;
- TopButtonsStackPanel.Visibility = Visibility.Visible;
- return;
- }
-
- // Execute enabled post-grab actions dynamically
- if (NextStepDropDownButton.Flyout is ContextMenu contextMenu)
- {
- bool shouldInsert = 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;
- }
-
- PostGrabContext grabContext = new(
- Text: TextFromOCR ?? string.Empty,
- CaptureRegion: absoluteCaptureRect,
- DpiScale: m.M11,
- CapturedImage: null,
- Language: selectedOcrLang);
-
- 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)
- {
- // Only open ETW if we're not doing a web search
- bool isWebSearch = false;
- if (NextStepDropDownButton.Flyout is ContextMenu cm)
- {
- foreach (MenuItem menuItem in GetActionablePostGrabMenuItems(cm))
- {
- 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();
+ await HandleRegionCanvasMouseUpAsync(e);
}
private void SendToEditTextToggleButton_Click(object sender, RoutedEventArgs e)
@@ -1301,6 +1105,13 @@ private async void Window_Loaded(object sender, RoutedEventArgs e)
if (IsMouseOver)
TopButtonsStackPanel.Visibility = Visibility.Visible;
+
+ FsgSelectionStyle selectionStyle = FsgSelectionStyle.Region;
+ if (!string.IsNullOrWhiteSpace(DefaultSettings.FsgSelectionStyle))
+ Enum.TryParse(DefaultSettings.FsgSelectionStyle, true, out selectionStyle);
+
+ ApplySelectionStyle(selectionStyle, persistToSettings: false);
+ windowSelectionTimer.Start();
}
private void DisposeBitmapSource(System.Windows.Controls.Image image)
@@ -1316,6 +1127,8 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
{
edgePanTimer.Stop();
edgePanTimer.Tick -= EdgePanTimer_Tick;
+ windowSelectionTimer.Stop();
+ windowSelectionTimer.Tick -= WindowSelectionTimer_Tick;
DisposeBitmapSource(BackgroundImage);
@@ -1326,6 +1139,9 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
if (RegionClickCanvas.Children.Contains(selectBorder))
RegionClickCanvas.Children.Remove(selectBorder);
+ if (SelectionOutlineHost.Children.Contains(selectionOutlineBorder))
+ SelectionOutlineHost.Children.Remove(selectionOutlineBorder);
+
// Clean up dynamically created post-grab action menu items
if (NextStepDropDownButton.Flyout is ContextMenu contextMenu)
{
@@ -1375,6 +1191,10 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
SingleLineMenuItem.Click -= SingleLineMenuItem_Click;
FreezeMenuItem.Click -= FreezeMenuItem_Click;
+ RegionSelectionMenuItem.Click -= SelectionStyleMenuItem_Click;
+ WindowSelectionMenuItem.Click -= SelectionStyleMenuItem_Click;
+ FreeformSelectionMenuItem.Click -= SelectionStyleMenuItem_Click;
+ AdjustAfterSelectionMenuItem.Click -= SelectionStyleMenuItem_Click;
NewGrabFrameMenuItem.Click -= NewGrabFrameMenuItem_Click;
SendToEtwMenuItem.Click -= NewEditTextMenuItem_Click;
SettingsMenuItem.Click -= SettingsMenuItem_Click;
@@ -1383,10 +1203,12 @@ private void Window_Unloaded(object sender, RoutedEventArgs e)
LanguagesComboBox.SelectionChanged -= LanguagesComboBox_SelectionChanged;
LanguagesComboBox.PreviewMouseDown -= LanguagesComboBox_PreviewMouseDown;
+ SelectionStyleComboBox.SelectionChanged -= SelectionStyleComboBox_SelectionChanged;
SingleLineToggleButton.Click -= SingleLineMenuItem_Click;
FreezeToggleButton.Click -= FreezeMenuItem_Click;
NewGrabFrameToggleButton.Click -= NewGrabFrameMenuItem_Click;
+ AcceptSelectionButton.Click -= AcceptSelectionButton_Click;
SendToEditTextToggleButton.Click -= SendToEditTextToggleButton_Click;
TableToggleButton.Click -= TableToggleButton_Click;
StandardModeToggleButton.Click -= StandardModeToggleButton_Click;
diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml
index 3acf7ae8..a768d5ea 100644
--- a/Text-Grab/Views/GrabFrame.xaml
+++ b/Text-Grab/Views/GrabFrame.xaml
@@ -38,14 +38,6 @@
mc:Ignorable="d">
-
-
From 9c50c38251640291e9ac4f4c6256def80ab295c1 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 6 Mar 2026 16:58:29 -0600
Subject: [PATCH 052/109] Harden barcode bitmap handling
Guard barcode reads against invalid or disposed bitmaps, ensure random-access-stream bitmap copies stay usable after the source stream is released, and add regression coverage for both cases.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
Tests/BarcodeUtilitiesTests.cs | 44 ++++++++++++++++++
Text-Grab/Utilities/BarcodeUtilities.cs | 60 +++++++++++++++++++++----
Text-Grab/Utilities/ImageMethods.cs | 14 ++++--
3 files changed, 105 insertions(+), 13 deletions(-)
create mode 100644 Tests/BarcodeUtilitiesTests.cs
diff --git a/Tests/BarcodeUtilitiesTests.cs b/Tests/BarcodeUtilitiesTests.cs
new file mode 100644
index 00000000..169f8e67
--- /dev/null
+++ b/Tests/BarcodeUtilitiesTests.cs
@@ -0,0 +1,44 @@
+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 Windows.Storage.Streams;
+
+namespace Tests;
+
+public class BarcodeUtilitiesTests
+{
+ [Fact]
+ public void TryToReadBarcodes_WithDisposedBitmap_ReturnsEmptyBarcodeOutput()
+ {
+ Bitmap disposedBitmap = new(8, 8);
+ disposedBitmap.Dispose();
+
+ OcrOutput result = BarcodeUtilities.TryToReadBarcodes(disposedBitmap);
+
+ Assert.Equal(OcrOutputKind.Barcode, result.Kind);
+ Assert.Equal(string.Empty, result.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/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs
index 45cd0928..e0e9ebe0 100644
--- a/Text-Grab/Utilities/BarcodeUtilities.cs
+++ b/Text-Grab/Utilities/BarcodeUtilities.cs
@@ -1,4 +1,7 @@
+using System;
+using System.Diagnostics;
using System.Drawing;
+using System.Runtime.InteropServices;
using Text_Grab.Models;
using ZXing;
using ZXing.Common;
@@ -11,16 +14,17 @@ namespace Text_Grab.Utilities;
public static class BarcodeUtilities
{
+ private static OcrOutput EmptyBarcodeOutput => new() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty };
public static OcrOutput TryToReadBarcodes(Bitmap bitmap)
{
- if (bitmap is null || bitmap.Width <= 0 || bitmap.Height <= 0)
- return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty };
+ if (!CanReadBitmapDimensions(bitmap))
+ return EmptyBarcodeOutput;
BarcodeReader barcodeReader = new()
{
AutoRotate = true,
- Options = new ZXing.Common.DecodingOptions { TryHarder = true }
+ Options = new DecodingOptions { TryHarder = true }
};
Result? result = null;
@@ -28,11 +32,21 @@ public static OcrOutput TryToReadBarcodes(Bitmap bitmap)
try
{
result = barcodeReader.Decode(bitmap);
-
}
- catch (System.Exception)
+ catch (ArgumentException ex)
+ {
+ Debug.WriteLine($"Unable to decode barcode from bitmap: {ex.Message}");
+ return EmptyBarcodeOutput;
+ }
+ catch (ObjectDisposedException ex)
+ {
+ Debug.WriteLine($"Unable to decode barcode from disposed bitmap: {ex.Message}");
+ return EmptyBarcodeOutput;
+ }
+ catch (ExternalException ex)
{
- return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty };
+ Debug.WriteLine($"Unable to decode barcode from GDI+ bitmap: {ex.Message}");
+ return EmptyBarcodeOutput;
}
string resultString = string.Empty;
@@ -47,11 +61,39 @@ public static OcrOutput TryToReadBarcodes(Bitmap bitmap)
};
}
+ private static bool CanReadBitmapDimensions(Bitmap? bitmap)
+ {
+ if (bitmap is null)
+ return false;
+
+ try
+ {
+ 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()
{
diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs
index e4a908c0..831b5b21 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;
}
@@ -218,8 +220,12 @@ public static Bitmap BitmapSourceToBitmap(BitmapSource source)
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)
From 2df0c51fd9424824ec3b67eee5d262bb9d8391c6 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 6 Mar 2026 17:29:41 -0600
Subject: [PATCH 053/109] Improve selection cropping for zoom/pan and add tests
Refactor cropping logic to use a new TryGetBitmapCropRectForSelection method, which accurately maps selection rectangles to bitmap coordinates even when zoom or pan transforms are applied. Remove code that forcibly resets zoom/pan state. Increase selection handle size for better usability. Add unit tests to verify cropping correctness under various transform scenarios.
---
Tests/FullscreenGrabZoomCaptureTests.cs | 90 +++++++++++++++++++
.../Views/FullscreenGrab.SelectionStyles.cs | 17 ++--
Text-Grab/Views/FullscreenGrab.xaml.cs | 87 ++++++++++++++----
3 files changed, 167 insertions(+), 27 deletions(-)
create mode 100644 Tests/FullscreenGrabZoomCaptureTests.cs
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/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
index 665d8d75..ef17d018 100644
--- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
+++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs
@@ -40,7 +40,7 @@ private enum SelectionInteractionMode
}
private const double MinimumSelectionSize = 6.0;
- private const double AdjustHandleSize = 10.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));
@@ -960,14 +960,15 @@ private Rect GetHistoryPositionRect(FullscreenCaptureResult selection)
Matrix transformToDevice = presentationSource?.CompositionTarget?.TransformToDevice ?? Matrix.Identity;
Rect selectionRect = GetCurrentSelectionRect();
- int cropX = Math.Max(0, (int)Math.Round(selectionRect.Left * transformToDevice.M11));
- int cropY = Math.Max(0, (int)Math.Round(selectionRect.Top * transformToDevice.M22));
- int cropW = Math.Min((int)Math.Round(selectionRect.Width * transformToDevice.M11), backgroundBitmap.PixelWidth - cropX);
- int cropH = Math.Min((int)Math.Round(selectionRect.Height * transformToDevice.M22), backgroundBitmap.PixelHeight - cropY);
-
- if (cropW > 0 && cropH > 0)
+ if (TryGetBitmapCropRectForSelection(
+ selectionRect,
+ transformToDevice,
+ BackgroundImage.RenderTransform,
+ backgroundBitmap.PixelWidth,
+ backgroundBitmap.PixelHeight,
+ out Int32Rect cropRect))
{
- CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH));
+ CroppedBitmap croppedBitmap = new(backgroundBitmap, cropRect);
croppedBitmap.Freeze();
return croppedBitmap;
}
diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs
index d8cf5f2e..fd60f535 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml.cs
+++ b/Text-Grab/Views/FullscreenGrab.xaml.cs
@@ -836,11 +836,6 @@ private void NewGrabFrameMenuItem_Click(object sender, RoutedEventArgs e)
bool isActive = CheckIfCheckingOrUnchecking(sender);
WindowUtilities.FullscreenKeyDown(Key.G, isActive);
SelectSingleToggleButton(sender);
-
- // null out any zoom/scaling because it does not translate into GF Size
- // TODO: when placing the Grab Frame consider zoom
- BackgroundImage.RenderTransform = null;
- edgePanTimer.Stop();
}
private void PanSelection(System.Windows.Point movingPoint)
@@ -893,14 +888,17 @@ private void PlaceGrabFrameInSelectionRect()
if (BackgroundImage.Source is BitmapSource backgroundBitmap)
{
Matrix m = PresentationSource.FromVisual(this).CompositionTarget.TransformToDevice;
- int cropX = Math.Max(0, (int)(Canvas.GetLeft(selectBorder) * m.M11));
- int cropY = Math.Max(0, (int)(Canvas.GetTop(selectBorder) * m.M22));
- int cropW = Math.Min((int)(selectBorder.Width * m.M11), backgroundBitmap.PixelWidth - cropX);
- int cropH = Math.Min((int)(selectBorder.Height * m.M22), backgroundBitmap.PixelHeight - cropY);
-
- if (cropW > 0 && cropH > 0)
+ Rect selectionRect = GetCurrentSelectionRect();
+
+ if (TryGetBitmapCropRectForSelection(
+ selectionRect,
+ m,
+ BackgroundImage.RenderTransform,
+ backgroundBitmap.PixelWidth,
+ backgroundBitmap.PixelHeight,
+ out Int32Rect cropRect))
{
- CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH));
+ CroppedBitmap croppedBitmap = new(backgroundBitmap, cropRect);
croppedBitmap.Freeze();
grabFrame = new GrabFrame(croppedBitmap);
}
@@ -1407,13 +1405,6 @@ private void PanBackgroundImage(double deltaX, double deltaY, TransformGroup tra
private void RegionClickCanvas_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
- if (NewGrabFrameMenuItem.IsChecked)
- {
- BackgroundImage.RenderTransform = null;
- edgePanTimer.Stop();
- return;
- }
-
System.Windows.Point point = Mouse.GetPosition(this);
if (BackgroundImage.RenderTransform is TransformGroup transformGroup)
@@ -1491,6 +1482,64 @@ private void HidePostGrabActions_Click(object sender, RoutedEventArgs e)
menu.IsOpen = false;
}
+ internal static bool TryGetBitmapCropRectForSelection(
+ Rect selectionRect,
+ Matrix transformToDevice,
+ Transform? backgroundRenderTransform,
+ int bitmapPixelWidth,
+ int bitmapPixelHeight,
+ out Int32Rect cropRect)
+ {
+ cropRect = default;
+
+ if (selectionRect.IsEmpty
+ || selectionRect.Width <= 0
+ || selectionRect.Height <= 0
+ || bitmapPixelWidth <= 0
+ || bitmapPixelHeight <= 0)
+ {
+ return false;
+ }
+
+ Matrix selectionToBackground = backgroundRenderTransform?.Value ?? Matrix.Identity;
+ if (selectionToBackground.HasInverse)
+ selectionToBackground.Invert();
+ else
+ selectionToBackground = Matrix.Identity;
+
+ Point[] backgroundPoints =
+ [
+ selectionToBackground.Transform(selectionRect.TopLeft),
+ selectionToBackground.Transform(new Point(selectionRect.Right, selectionRect.Top)),
+ selectionToBackground.Transform(new Point(selectionRect.Left, selectionRect.Bottom)),
+ selectionToBackground.Transform(selectionRect.BottomRight)
+ ];
+
+ Point[] bitmapPoints =
+ [
+ transformToDevice.Transform(backgroundPoints[0]),
+ transformToDevice.Transform(backgroundPoints[1]),
+ transformToDevice.Transform(backgroundPoints[2]),
+ transformToDevice.Transform(backgroundPoints[3])
+ ];
+
+ double left = bitmapPoints.Min(static point => point.X);
+ double top = bitmapPoints.Min(static point => point.Y);
+ double right = bitmapPoints.Max(static point => point.X);
+ double bottom = bitmapPoints.Max(static point => point.Y);
+
+ int cropLeft = Math.Max(0, (int)Math.Floor(left));
+ int cropTop = Math.Max(0, (int)Math.Floor(top));
+ int cropRight = Math.Min(bitmapPixelWidth, (int)Math.Ceiling(right));
+ int cropBottom = Math.Min(bitmapPixelHeight, (int)Math.Ceiling(bottom));
+
+ if (cropRight <= cropLeft || cropBottom <= cropTop)
+ return false;
+
+ cropRect = new Int32Rect(cropLeft, cropTop, cropRight - cropLeft, cropBottom - cropTop);
+ return true;
+ }
+
private void EditPostGrabActions_Click(object sender, RoutedEventArgs e)
{
PostGrabActionEditor postGrabActionEditor = new();
From d28f1b45e55b18632e600dd93ffeb5f16a4675a9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 01:45:27 +0000
Subject: [PATCH 054/109] Initial plan
From 28a021d685279d0adc153c8ebffbd9b8fff688d4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 01:50:35 +0000
Subject: [PATCH 055/109] Fix 5 review comments: shortcut cap, ItemsDictionary
clear, null ItemsSource, duplicate handlers, Freeform key hint
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Text-Grab/Controls/InlineChipElement.cs | 14 ++++++++++++--
Text-Grab/Controls/InlinePickerRichTextBox.cs | 2 +-
Text-Grab/Views/FullscreenGrab.xaml | 2 +-
Text-Grab/Views/FullscreenGrab.xaml.cs | 2 +-
Text-Grab/Views/QuickSimpleLookup.xaml.cs | 1 +
5 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/Text-Grab/Controls/InlineChipElement.cs b/Text-Grab/Controls/InlineChipElement.cs
index da12f279..beab9d6b 100644
--- a/Text-Grab/Controls/InlineChipElement.cs
+++ b/Text-Grab/Controls/InlineChipElement.cs
@@ -31,6 +31,8 @@ public string Value
public event EventHandler? RemoveRequested;
+ private Button? _removeButton;
+
static InlineChipElement()
{
DefaultStyleKeyProperty.OverrideMetadata(
@@ -42,7 +44,15 @@ public override void OnApplyTemplate()
{
base.OnApplyTemplate();
- if (GetTemplateChild(PartRemoveButton) is Button removeButton)
- removeButton.Click += (s, e) => RemoveRequested?.Invoke(this, EventArgs.Empty);
+ 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/InlinePickerRichTextBox.cs b/Text-Grab/Controls/InlinePickerRichTextBox.cs
index a3c34dbe..0957745d 100644
--- a/Text-Grab/Controls/InlinePickerRichTextBox.cs
+++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs
@@ -50,7 +50,7 @@ public class InlinePickerRichTextBox : RichTextBox
public IEnumerable ItemsSource
{
- get => (IEnumerable)GetValue(ItemsSourceProperty);
+ get => (IEnumerable?)GetValue(ItemsSourceProperty) ?? [];
set => SetValue(ItemsSourceProperty, value);
}
diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml
index 90717c49..35902a56 100644
--- a/Text-Grab/Views/FullscreenGrab.xaml
+++ b/Text-Grab/Views/FullscreenGrab.xaml
@@ -95,7 +95,7 @@
x:Name="FreeformSelectionMenuItem"
Click="SelectionStyleMenuItem_Click"
Header="Freeform - Draw any shape"
- InputGestureText="P"
+ InputGestureText="D"
IsCheckable="True"
Tag="Freeform" />
Date: Fri, 6 Mar 2026 20:15:17 -0600
Subject: [PATCH 056/109] Switch pattern match mode selection to RadioButtons
Replaced ComboBox with a set of RadioButtons for selecting the pattern match mode in the dialog, improving visibility and usability. Updated event handling and logic to support the new selection method. Increased dialog height to fit the revised layout.
---
.../Controls/PatternMatchModeDialog.xaml | 47 +++++++++++++------
.../Controls/PatternMatchModeDialog.xaml.cs | 7 +--
2 files changed, 37 insertions(+), 17 deletions(-)
diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml b/Text-Grab/Controls/PatternMatchModeDialog.xaml
index 442428bc..8dc28a7d 100644
--- a/Text-Grab/Controls/PatternMatchModeDialog.xaml
+++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml
@@ -7,7 +7,7 @@
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Pattern Match Options"
Width="420"
- Height="320"
+ Height="450"
MinWidth="380"
MinHeight="280"
WindowStartupLocation="CenterOwner"
@@ -32,29 +32,48 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
().FirstOrDefault(button => button.IsChecked == true) is RadioButton button)
+ return button.Tag?.ToString() ?? "first";
+
return "first";
}
From db483a6f60f9c002fe25c347454a6f646d993f29 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sat, 7 Mar 2026 11:53:04 -0600
Subject: [PATCH 057/109] Add UiAutomation language support and options
Added a new LanguageKind.UiAutomation to HistoryInfo and implemented the UiAutomationLang class for "UI Automation Text" language support. Introduced UiAutomationOptions record to configure UI Automation traversal and filtering behavior.
---
Text-Grab/Models/HistoryInfo.cs | 1 +
Text-Grab/Models/UiAutomationLang.cs | 25 +++++++++++++++++++++++++
Text-Grab/Models/UiAutomationOptions.cs | 9 +++++++++
3 files changed, 35 insertions(+)
create mode 100644 Text-Grab/Models/UiAutomationLang.cs
create mode 100644 Text-Grab/Models/UiAutomationOptions.cs
diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs
index b335e397..5c422553 100644
--- a/Text-Grab/Models/HistoryInfo.cs
+++ b/Text-Grab/Models/HistoryInfo.cs
@@ -58,6 +58,7 @@ public ILanguage OcrLanguage
LanguageKind.Global => new GlobalLang(new Language(LanguageTag)),
LanguageKind.Tesseract => new TessLang(LanguageTag),
LanguageKind.WindowsAi => new WindowsAiLang(),
+ LanguageKind.UiAutomation => new UiAutomationLang(),
_ => new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")),
};
}
diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab/Models/UiAutomationLang.cs
new file mode 100644
index 00000000..fb993a60
--- /dev/null
+++ b/Text-Grab/Models/UiAutomationLang.cs
@@ -0,0 +1,25 @@
+using Text_Grab.Interfaces;
+using Windows.Globalization;
+
+namespace Text_Grab.Models;
+
+public class UiAutomationLang : ILanguage
+{
+ public const string Tag = "UIAutomation";
+
+ public string AbbreviatedName => "UIA";
+
+ public string DisplayName => "UI Automation Text";
+
+ public string CurrentInputMethodLanguageTag => string.Empty;
+
+ public string CultureDisplayName => "UI Automation Text";
+
+ public string LanguageTag => Tag;
+
+ public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr;
+
+ public string NativeName => "UI Automation Text";
+
+ 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);
From 2a4d5d5a2a11de23556d732e5a4131f9e2dad6cb Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Sat, 7 Mar 2026 11:54:39 -0600
Subject: [PATCH 058/109] Add UI Automation as OCR language option and
utilities
Added support for UI Automation as a selectable OCR language. Integrated UiAutomationLang into language selection, caching, and kind/type checks. Introduced UIAutomationUtilities for extracting text from screen regions, points, and windows using Windows UI Automation APIs. Updated OcrUtilities to route requests to UIAutomationUtilities when appropriate, with fallback logic to traditional OCR. Added CaptureLanguageUtilities for language enumeration and compatibility checks. Improved settings import/export robustness to handle property-based settings. These changes enable text extraction from UI elements as an alternative to image-based OCR.
---
Text-Grab/Services/LanguageService.cs | 13 +
.../Utilities/CaptureLanguageUtilities.cs | 82 ++
Text-Grab/Utilities/OcrUtilities.cs | 69 +-
.../SettingsImportExportUtilities.cs | 43 +-
Text-Grab/Utilities/UIAutomationUtilities.cs | 731 ++++++++++++++++++
5 files changed, 921 insertions(+), 17 deletions(-)
create mode 100644 Text-Grab/Utilities/CaptureLanguageUtilities.cs
create mode 100644 Text-Grab/Utilities/UIAutomationUtilities.cs
diff --git a/Text-Grab/Services/LanguageService.cs b/Text-Grab/Services/LanguageService.cs
index 3b4cb88f..b1d8e1ce 100644
--- a/Text-Grab/Services/LanguageService.cs
+++ b/Text-Grab/Services/LanguageService.cs
@@ -30,6 +30,8 @@ public class LanguageService
// Static instance of WindowsAiLang to avoid allocations
private static readonly WindowsAiLang _windowsAiLangInstance = new();
private static readonly string _windowsAiLangTag = _windowsAiLangInstance.LanguageTag;
+ private static readonly UiAutomationLang _uiAutomationLangInstance = new();
+ private static readonly string _uiAutomationLangTag = _uiAutomationLangInstance.LanguageTag;
#endregion Fields
@@ -71,6 +73,9 @@ public IList GetAllLanguages()
List languages = [];
+ if (AppUtilities.TextGrabSettings.UiAutomationEnabled)
+ languages.Add(_uiAutomationLangInstance);
+
if (WindowsAiUtilities.CanDeviceUseWinAI())
{
// Add Windows AI languages - use static instance
@@ -97,6 +102,7 @@ public static string GetLanguageTag(object language)
{
Language lang => lang.LanguageTag,
WindowsAiLang => _windowsAiLangTag,
+ UiAutomationLang => _uiAutomationLangTag,
TessLang tessLang => tessLang.RawTag,
GlobalLang gLang => gLang.LanguageTag,
_ => throw new ArgumentException("Unsupported language type", nameof(language)),
@@ -112,6 +118,7 @@ public static LanguageKind GetLanguageKind(object language)
{
Language => LanguageKind.Global,
WindowsAiLang => LanguageKind.WindowsAi,
+ UiAutomationLang => LanguageKind.UiAutomation,
TessLang => LanguageKind.Tesseract,
_ => LanguageKind.Global, // Default fallback
};
@@ -145,6 +152,12 @@ public ILanguage GetOCRLanguage()
return _cachedOcrLanguage;
}
+ if (lastUsedLang == _uiAutomationLangTag && AppUtilities.TextGrabSettings.UiAutomationEnabled)
+ {
+ _cachedOcrLanguage = _uiAutomationLangInstance;
+ return _cachedOcrLanguage;
+ }
+
try
{
selectedLanguage = new GlobalLang(lastUsedLang);
diff --git a/Text-Grab/Utilities/CaptureLanguageUtilities.cs b/Text-Grab/Utilities/CaptureLanguageUtilities.cs
new file mode 100644
index 00000000..f1762764
--- /dev/null
+++ b/Text-Grab/Utilities/CaptureLanguageUtilities.cs
@@ -0,0 +1,82 @@
+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;
+}
diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs
index 74bb8859..4a1439d4 100644
--- a/Text-Grab/Utilities/OcrUtilities.cs
+++ b/Text-Grab/Utilities/OcrUtilities.cs
@@ -32,6 +32,16 @@ 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;
+ }
+
public static void GetTextFromOcrLine(this IOcrLine ocrLine, bool isSpaceJoiningOCRLang, StringBuilder text)
{
// (when OCR language is zh or ja)
@@ -79,6 +89,15 @@ public static void GetTextFromOcrLine(this IOcrLine ocrLine, bool isSpaceJoining
public static async Task GetTextFromAbsoluteRectAsync(Rect rect, ILanguage language)
{
+ if (IsUiAutomationLanguage(language))
+ {
+ string uiAutomationText = await UIAutomationUtilities.GetTextFromRegionAsync(rect);
+ if (!string.IsNullOrWhiteSpace(uiAutomationText) || !DefaultSettings.UiAutomationFallbackToOcr)
+ return uiAutomationText;
+
+ language = GetCompatibleOcrLanguage(language);
+ }
+
Rectangle selectedRegion = rect.AsRectangle();
Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(selectedRegion);
@@ -93,13 +112,12 @@ 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);
}
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;
@@ -107,10 +125,10 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow,
Rectangle correctedRegion = new(thisCorrectedLeft, thisCorrectedTop, selectedRegion.Width, selectedRegion.Height);
Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion);
- double scale = await GetIdealScaleFactorForOcrAsync(bmp, objLang);
+ 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,12 +145,20 @@ 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));
}
@@ -144,9 +170,10 @@ public static async Task GetTextFromBitmapSourceAsync(BitmapSource bitma
public static async Task GetTextFromBitmapAsTableAsync(Bitmap bitmap, ILanguage language)
{
- double scale = await GetIdealScaleFactorForOcrAsync(bitmap, language);
+ ILanguage compatibleLanguage = GetCompatibleOcrLanguage(language);
+ double scale = await GetIdealScaleFactorForOcrAsync(bitmap, compatibleLanguage);
using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale);
- IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, language);
+ IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, compatibleLanguage);
DpiScale bitmapDpiScale = new(1.0, 1.0);
List wordBorderInfos = ResultTable.ParseOcrResultIntoWordBorderInfos(ocrResult, bitmapDpiScale);
@@ -163,7 +190,7 @@ public static async Task GetTextFromBitmapAsTableAsync(Bitmap bitmap, IL
table.AnalyzeAsTable(wordBorderInfos, rectCanvasSize);
StringBuilder textBuilder = new();
- ResultTable.GetTextFromTabledWordBorders(textBuilder, wordBorderInfos, language.IsSpaceJoining());
+ ResultTable.GetTextFromTabledWordBorders(textBuilder, wordBorderInfos, compatibleLanguage.IsSpaceJoining());
return textBuilder.ToString();
}
@@ -175,6 +202,7 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc
public static async Task<(IOcrLinesWords?, double)> GetOcrResultFromRegionAsync(Rectangle region, ILanguage language)
{
+ language = GetCompatibleOcrLanguage(language);
Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region);
if (language is WindowsAiLang)
@@ -196,6 +224,8 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc
public static async Task GetOcrResultFromImageAsync(SoftwareBitmap scaledBitmap, ILanguage language)
{
+ language = GetCompatibleOcrLanguage(language);
+
if (language is WindowsAiLang winAiLang)
{
return new WinAiOcrLinesWords(await WindowsAiUtilities.GetOcrResultAsync(scaledBitmap));
@@ -213,6 +243,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);
@@ -331,6 +362,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);
@@ -417,6 +456,17 @@ public static async Task OcrAbsoluteFilePathAsync(string absolutePath, I
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);
+ 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();
@@ -441,6 +491,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);
}
diff --git a/Text-Grab/Utilities/SettingsImportExportUtilities.cs b/Text-Grab/Utilities/SettingsImportExportUtilities.cs
index b49743ef..e87b2c5d 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;
@@ -116,6 +117,12 @@ private static async Task ExportSettingsToJsonAsync(string filePath)
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 +148,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 +160,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)
{
@@ -252,10 +270,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/UIAutomationUtilities.cs b/Text-Grab/Utilities/UIAutomationUtilities.cs
new file mode 100644
index 00000000..ec9b30b5
--- /dev/null
+++ b/Text-Grab/Utilities/UIAutomationUtilities.cs
@@ -0,0 +1,731 @@
+using System;
+using System.Collections.Generic;
+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);
+
+ public static Task GetTextFromPointAsync(Point screenPoint)
+ {
+ UiAutomationOptions options = GetOptionsFromSettings();
+ return Task.Run(() => GetTextFromPoint(screenPoint, options));
+ }
+
+ public static Task GetTextFromRegionAsync(Rect screenRect)
+ {
+ UiAutomationOptions options = GetOptionsFromSettings(screenRect);
+ return Task.Run(() => GetTextFromRegion(screenRect, options));
+ }
+
+ public static Task