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},