diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 448bb646..210267d5 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -12,7 +12,13 @@
"Bash(dotnet test:*)",
"Bash(gh pr list:*)",
"Bash(/mnt/c/Program Files/dotnet/dotnet.exe:*)",
- "WebFetch(domain:github.com)"
+ "WebFetch(domain:github.com)",
+ "Skill(pdm)",
+ "Bash(mkdir -p bin)",
+ "Bash(curl -o bin/pdm https://app.produckmap.com/cli/pdm)",
+ "Bash(chmod +x bin/pdm)",
+ "Bash(bin/pdm ui-element:*)",
+ "Bash(bin/pdm *)"
],
"deny": []
}
diff --git a/Tests/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs
index bab8d29e..5ef92f34 100644
--- a/Tests/SettingsServiceTests.cs
+++ b/Tests/SettingsServiceTests.cs
@@ -133,6 +133,77 @@ public void ClearingManagedSettingClearsLegacyAndSidecar()
Assert.Empty(service.LoadStoredRegexes());
}
+ [Fact]
+ public void Constructor_FileBackedModeReflectsSettingsValueSetBeforeConstruction()
+ {
+ // EnableFileBackedManagedSettings must be read AFTER any migration so the
+ // persisted user preference is honoured for the current session.
+ Settings settings = new()
+ {
+ FirstRun = false,
+ EnableFileBackedManagedSettings = true
+ };
+
+ SettingsService service = CreateService(settings);
+
+ Assert.True(service.IsFileBackedManagedSettingsEnabled);
+ }
+
+ [Fact]
+ public void Constructor_FileBackedModeDefaultsToFalseWhenNotSet()
+ {
+ Settings settings = new()
+ {
+ FirstRun = false,
+ EnableFileBackedManagedSettings = false
+ };
+
+ SettingsService service = CreateService(settings);
+
+ Assert.False(service.IsFileBackedManagedSettingsEnabled);
+ }
+
+ [Fact]
+ public void Constructor_UnpackagedUpgradePathDoesNotThrowWhenNoPreviousVersion()
+ {
+ // When saveClassicSettingsChanges is false (test mode) the Upgrade() code path is
+ // skipped, so this simply verifies that the constructor completes successfully when
+ // FirstRun is true and localSettings is null.
+ Settings settings = new()
+ {
+ FirstRun = true,
+ EnableFileBackedManagedSettings = false
+ };
+
+ SettingsService service = CreateService(settings);
+
+ // Service initialises without throwing; FileBackedMode reflects the setting.
+ Assert.False(service.IsFileBackedManagedSettingsEnabled);
+ }
+
+ [Fact]
+ public void LoadStoredRegexes_SidecarSurvivesSimulatedPackageUpgrade()
+ {
+ // Simulate a package upgrade: sidecar file already exists (from the previous
+ // version) but ClassicSettings.RegexList is empty (reset by the upgrade).
+ // The service must load from the sidecar and backfill ClassicSettings.
+ Settings settings = new()
+ {
+ FirstRun = false,
+ EnableFileBackedManagedSettings = false,
+ RegexList = string.Empty
+ };
+ string regexFilePath = Path.Combine(_tempFolder, "RegexList.json");
+ File.WriteAllText(regexFilePath, SerializeRegexes("survived-upgrade"));
+
+ SettingsService service = CreateService(settings);
+
+ StoredRegex loaded = Assert.Single(service.LoadStoredRegexes());
+ Assert.Equal("survived-upgrade", loaded.Id);
+ // Verify backfill into ClassicSettings so the next migration has something to copy.
+ Assert.Contains("survived-upgrade", settings.RegexList);
+ }
+
private SettingsService CreateService(Settings settings) =>
new(
settings,
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 69c54639..3a93deb7 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -17,7 +17,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest
index 2e5db546..bccb2398 100644
--- a/Text-Grab-Package/Package.appxmanifest
+++ b/Text-Grab-Package/Package.appxmanifest
@@ -14,7 +14,7 @@
+ Version="4.13.2.0" />
Text Grab
diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs
index ce3706c8..442af591 100644
--- a/Text-Grab/Models/ButtonInfo.cs
+++ b/Text-Grab/Models/ButtonInfo.cs
@@ -127,13 +127,6 @@ public static List DefaultButtonList
SymbolIcon = SymbolRegular.Copy24
},
new()
- {
- ButtonText = "Save to File...",
- SymbolText = "",
- ClickEvent = "SaveBTN_Click",
- SymbolIcon = SymbolRegular.Save24
- },
- new()
{
ButtonText = "Make Single Line",
SymbolText = "",
@@ -215,6 +208,14 @@ public static List AllButtons
SymbolIcon = SymbolRegular.DocumentSave24
},
new()
+ {
+ OrderNumber = 1.21,
+ ButtonText = "Save As...",
+ SymbolText = "",
+ ClickEvent = "SaveAsBTN_Click",
+ SymbolIcon = SymbolRegular.DocumentEdit24
+ },
+ new()
{
OrderNumber = 1.3,
ButtonText = "Make Single Line",
@@ -239,6 +240,14 @@ public static List AllButtons
SymbolIcon = SymbolRegular.Timer324
},
new()
+ {
+ OrderNumber = 1.42,
+ ButtonText = "Manage Grab Templates...",
+ SymbolText = "",
+ ClickEvent = "ManageGrabTemplates_Click",
+ SymbolIcon = SymbolRegular.GridDots24
+ },
+ new()
{
OrderNumber = 1.5,
ButtonText = "Open Grab Frame",
@@ -415,6 +424,14 @@ public static List AllButtons
SymbolIcon = SymbolRegular.TextWrap24
},
new()
+ {
+ OrderNumber = 4.51,
+ ButtonText = "Split Lines After Each Selection",
+ SymbolText = "",
+ Command = "SplitAfterSelectionCmd",
+ SymbolIcon = SymbolRegular.TextWrapOff24
+ },
+ new()
{
OrderNumber = 4.6,
ButtonText = "Isolate Selection",
diff --git a/Text-Grab/Models/ShortcutKeySet.cs b/Text-Grab/Models/ShortcutKeySet.cs
index be0544e4..9fbb6a93 100644
--- a/Text-Grab/Models/ShortcutKeySet.cs
+++ b/Text-Grab/Models/ShortcutKeySet.cs
@@ -175,6 +175,14 @@ public override int GetHashCode()
Name = "Edit last Grab Frame",
Action = ShortcutKeyActions.PreviousGrabFrame
},
+ new()
+ {
+ Modifiers = {KeyModifiers.Windows, KeyModifiers.Shift, KeyModifiers.Control},
+ NonModifierKey = Key.V,
+ IsEnabled = true,
+ Name = "Open Clipboard Content",
+ Action = ShortcutKeyActions.OpenClipboardContent
+ },
};
}
@@ -189,4 +197,5 @@ public enum ShortcutKeyActions
PreviousRegionGrab = 6,
PreviousEditWindow = 7,
PreviousGrabFrame = 8,
+ OpenClipboardContent = 9,
}
diff --git a/Text-Grab/Pages/KeysSettings.xaml b/Text-Grab/Pages/KeysSettings.xaml
index 405462fc..aba2c285 100644
--- a/Text-Grab/Pages/KeysSettings.xaml
+++ b/Text-Grab/Pages/KeysSettings.xaml
@@ -79,6 +79,11 @@
KeySetChanged="ShortcutControl_KeySetChanged"
RecordingStarted="ShortcutControl_Recording"
ShortcutName="Quick Simple Lookup" />
+
diff --git a/Text-Grab/Pages/KeysSettings.xaml.cs b/Text-Grab/Pages/KeysSettings.xaml.cs
index 4ec8532c..c2a29059 100644
--- a/Text-Grab/Pages/KeysSettings.xaml.cs
+++ b/Text-Grab/Pages/KeysSettings.xaml.cs
@@ -136,6 +136,9 @@ private void Page_Loaded(object sender, RoutedEventArgs e)
case ShortcutKeyActions.PreviousGrabFrame:
LgfShortcutControl.KeySet = keySet;
break;
+ case ShortcutKeyActions.OpenClipboardContent:
+ OccShortcutControl.KeySet = keySet;
+ break;
default:
break;
}
diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs
index 04c52f40..fc28f999 100644
--- a/Text-Grab/Services/SettingsService.cs
+++ b/Text-Grab/Services/SettingsService.cs
@@ -64,10 +64,27 @@ internal SettingsService(
_localSettings = localSettings;
_managedJsonSettingsFolderPath = managedJsonSettingsFolderPath ?? GetManagedJsonSettingsFolderPath();
_saveClassicSettingsChanges = saveClassicSettingsChanges;
- _preferFileBackedManagedSettings = ClassicSettings.EnableFileBackedManagedSettings;
- if (ClassicSettings.FirstRun && _localSettings is not null && _localSettings.Values.Count > 0)
- MigrateLocalSettingsToClassic();
+ if (ClassicSettings.FirstRun)
+ {
+ if (_localSettings is not null && _localSettings.Values.Count > 0)
+ {
+ // Packaged: ApplicationDataContainer persists across package upgrades,
+ // so copy saved values back into the freshly-reset classic settings.
+ MigrateLocalSettingsToClassic();
+ if (_saveClassicSettingsChanges)
+ ClassicSettings.Save();
+ }
+ else if (_localSettings is null && _saveClassicSettingsChanges)
+ {
+ // Unpackaged: Properties.Settings stores data in a version-specific path,
+ // so call Upgrade() to carry forward values from the previous version.
+ ClassicSettings.Upgrade();
+ }
+ }
+
+ // Must be read after any migration so the user's saved preference is respected.
+ _preferFileBackedManagedSettings = ClassicSettings.EnableFileBackedManagedSettings;
// copy settings from classic to local settings
// so that when app updates they can be copied forward
diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj
index b745efce..8ebd0208 100644
--- a/Text-Grab/Text-Grab.csproj
+++ b/Text-Grab/Text-Grab.csproj
@@ -23,7 +23,7 @@
true
win-x86;win-x64;win-arm64
false
- 4.13.0
+ 4.13.2
@@ -52,7 +52,7 @@
-
+
diff --git a/Text-Grab/Utilities/NotifyIconUtilities.cs b/Text-Grab/Utilities/NotifyIconUtilities.cs
index 40e96bbb..889841ca 100644
--- a/Text-Grab/Utilities/NotifyIconUtilities.cs
+++ b/Text-Grab/Utilities/NotifyIconUtilities.cs
@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using System.Windows.Media.Imaging;
using Text_Grab.Controls;
using Text_Grab.Models;
using Text_Grab.Services;
@@ -139,6 +142,63 @@ private static void HotKeyManager_HotKeyPressed(object? sender, HotKeyEventArgs
Singleton.Instance.GetLastHistoryAsGrabFrame();
}));
break;
+ case ShortcutKeyActions.OpenClipboardContent:
+ System.Windows.Application.Current.Dispatcher.Invoke(new Action(() =>
+ {
+ if (System.Windows.Clipboard.ContainsText())
+ {
+ string text = System.Windows.Clipboard.GetText();
+ EditTextWindow etw = new(text, false);
+ etw.Show();
+ etw.Activate();
+ return;
+ }
+
+ if (System.Windows.Clipboard.ContainsFileDropList())
+ {
+ StringCollection files = System.Windows.Clipboard.GetFileDropList();
+ string? imagePath = files.Cast().FirstOrDefault(f => f is not null && IoUtilities.IsImageFile(f!));
+ if (imagePath is not null)
+ {
+ GrabFrame gf = new(imagePath);
+ gf.Show();
+ gf.Activate();
+ return;
+ }
+ }
+
+ (bool success, System.Windows.Media.ImageSource? clipboardImage) = ClipboardUtilities.TryGetImageFromClipboard();
+ if (!success || clipboardImage is null)
+ return;
+
+ BitmapSource? bitmapSource = null;
+ if (clipboardImage is System.Windows.Interop.InteropBitmap interopBitmap)
+ {
+ System.Drawing.Bitmap bmp = ImageMethods.InteropBitmapToBitmap(interopBitmap);
+ bitmapSource = ImageMethods.BitmapToImageSource(bmp);
+ bmp.Dispose();
+ }
+ else if (clipboardImage is BitmapSource source)
+ {
+ bitmapSource = source;
+ }
+
+ if (bitmapSource is null)
+ return;
+
+ string tempPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Clipboard_{Guid.NewGuid()}.png");
+ using (FileStream fileStream = new(tempPath, FileMode.Create))
+ {
+ PngBitmapEncoder encoder = new();
+ encoder.Frames.Add(BitmapFrame.Create(bitmapSource));
+ encoder.Save(fileStream);
+ }
+
+ GrabFrame grabFrame = new(tempPath);
+ grabFrame.Show();
+ grabFrame.Activate();
+ }));
+ break;
default:
break;
}
diff --git a/Text-Grab/Utilities/UIAutomationUtilities.cs b/Text-Grab/Utilities/UIAutomationUtilities.cs
index 0987874c..bac31adf 100644
--- a/Text-Grab/Utilities/UIAutomationUtilities.cs
+++ b/Text-Grab/Utilities/UIAutomationUtilities.cs
@@ -332,6 +332,10 @@ private static string GetTextFromRegion(Rect screenRect, UiAutomationOptions opt
return string.Join(Environment.NewLine, extractedText);
}
+ catch (AccessViolationException)
+ {
+ return string.Empty;
+ }
catch (ElementNotAvailableException)
{
return string.Empty;
@@ -833,6 +837,10 @@ private static bool TryExtractTextPatternText(AutomationElement element, Rect? f
return !string.IsNullOrWhiteSpace(text);
}
}
+ catch (AccessViolationException)
+ {
+ // Native UIA AV — treat as unavailable
+ }
catch (ElementNotAvailableException)
{
}
@@ -861,12 +869,24 @@ private static bool TryExtractVisibleTextPatternText(TextPattern textPattern, Re
if (!RangeIntersectsBounds(range, filterBounds))
continue;
- TryAddUniqueText(range.GetText(-1), seenText, extractedText);
+ try
+ {
+ TryAddUniqueText(range.GetText(-1), seenText, extractedText);
+ }
+ catch (AccessViolationException)
+ {
+ // Native UIAutomation can AV when a range handle becomes stale,
+ // especially in self-contained builds. Skip the range and continue.
+ }
}
text = string.Join(Environment.NewLine, extractedText);
return !string.IsNullOrWhiteSpace(text);
}
+ catch (AccessViolationException)
+ {
+ // Native UIA AV — treat as unavailable
+ }
catch (ElementNotAvailableException)
{
}
diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs
index a5af817a..55afd1a0 100644
--- a/Text-Grab/Views/EditTextWindow.xaml.cs
+++ b/Text-Grab/Views/EditTextWindow.xaml.cs
@@ -169,6 +169,7 @@ public static Dictionary GetRoutedCommands()
{nameof(DeleteAllSelectionCmd), DeleteAllSelectionCmd},
{nameof(DeleteAllSelectionPatternCmd), DeleteAllSelectionPatternCmd},
{nameof(InsertSelectionOnEveryLineCmd), InsertSelectionOnEveryLineCmd},
+ {nameof(SplitAfterSelectionCmd), SplitAfterSelectionCmd},
{nameof(OcrPasteCommand), OcrPasteCommand},
{nameof(MakeQrCodeCmd), MakeQrCodeCmd},
{nameof(WebSearchCmd), WebSearchCmd},