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/Tests/FreeformCaptureUtilitiesTests.cs b/Tests/FreeformCaptureUtilitiesTests.cs new file mode 100644 index 00000000..3cf860ff --- /dev/null +++ b/Tests/FreeformCaptureUtilitiesTests.cs @@ -0,0 +1,63 @@ +using System.Drawing; +using System.Windows; +using System.Windows.Media; +using Text_Grab.Utilities; +using Point = System.Windows.Point; + +namespace Tests; + +public class FreeformCaptureUtilitiesTests +{ + [WpfFact] + public void GetBounds_RoundsOutwardToIncludeAllPoints() + { + List points = + [ + new(1.2, 2.8), + new(10.1, 4.2), + new(4.6, 9.9) + ]; + + Rect bounds = FreeformCaptureUtilities.GetBounds(points); + + Assert.Equal(new Rect(new Point(1, 2), new Point(11, 10)), bounds); + } + + [WpfFact] + public void BuildGeometry_CreatesClosedFigure() + { + List points = + [ + new(0, 0), + new(4, 0), + new(4, 4) + ]; + + PathGeometry geometry = FreeformCaptureUtilities.BuildGeometry(points); + + Assert.Single(geometry.Figures); + Assert.Equal(points[0], geometry.Figures[0].StartPoint); + Assert.True(geometry.Figures[0].IsClosed); + Assert.Equal(2, geometry.Figures[0].Segments.Count); + } + + [WpfFact] + public void CreateMaskedBitmap_WhitensPixelsOutsideThePolygon() + { + using Bitmap sourceBitmap = new(10, 10); + using Graphics graphics = Graphics.FromImage(sourceBitmap); + graphics.Clear(System.Drawing.Color.Black); + + using Bitmap maskedBitmap = FreeformCaptureUtilities.CreateMaskedBitmap( + sourceBitmap, + [ + new Point(2, 2), + new Point(7, 2), + new Point(7, 7), + new Point(2, 7) + ]); + + Assert.Equal(System.Drawing.Color.Gray.ToArgb(), maskedBitmap.GetPixel(0, 0).ToArgb()); + Assert.Equal(System.Drawing.Color.Black.ToArgb(), maskedBitmap.GetPixel(4, 4).ToArgb()); + } +} diff --git a/Tests/FullscreenCaptureResultTests.cs b/Tests/FullscreenCaptureResultTests.cs new file mode 100644 index 00000000..c2eaf52e --- /dev/null +++ b/Tests/FullscreenCaptureResultTests.cs @@ -0,0 +1,32 @@ +using System.Windows; +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class FullscreenCaptureResultTests +{ + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, true)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void SupportsTemplateActions_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + FullscreenCaptureResult result = new(selectionStyle, Rect.Empty); + + Assert.Equal(expected, result.SupportsTemplateActions); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void SupportsPreviousRegionReplay_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + FullscreenCaptureResult result = new(selectionStyle, Rect.Empty); + + Assert.Equal(expected, result.SupportsPreviousRegionReplay); + } +} diff --git a/Tests/FullscreenGrabPostGrabActionTests.cs b/Tests/FullscreenGrabPostGrabActionTests.cs new file mode 100644 index 00000000..e40bcc4e --- /dev/null +++ b/Tests/FullscreenGrabPostGrabActionTests.cs @@ -0,0 +1,127 @@ +using System.Windows.Controls; +using Text_Grab.Models; +using Text_Grab.Views; +using Wpf.Ui.Controls; +using MenuItem = System.Windows.Controls.MenuItem; + +namespace Tests; + +public class FullscreenGrabPostGrabActionTests +{ + [Fact] + public void GetPostGrabActionKey_UsesTemplateIdForTemplateActions() + { + ButtonInfo action = new("Template Action", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-123" + }; + + string key = FullscreenGrab.GetPostGrabActionKey(action); + + Assert.Equal("template:template-123", key); + } + + [Fact] + public void GetPostGrabActionKey_FallsBackToButtonTextWhenClickEventMissing() + { + ButtonInfo action = new() + { + ButtonText = "Custom action" + }; + + string key = FullscreenGrab.GetPostGrabActionKey(action); + + Assert.Equal("text:Custom action", key); + } + + [WpfFact] + public void GetActionablePostGrabMenuItems_ExcludesUtilityEntriesAndPreservesOrder() + { + ContextMenu contextMenu = new(); + MenuItem firstAction = new() + { + Header = "First action", + Tag = new ButtonInfo("First action", "First_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + }; + MenuItem utilityItem = new() + { + Header = "Customize", + Tag = "EditPostGrabActions" + }; + MenuItem secondAction = new() + { + Header = "Second action", + Tag = new ButtonInfo("Second action", "Second_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + }; + + contextMenu.Items.Add(firstAction); + contextMenu.Items.Add(new Separator()); + contextMenu.Items.Add(utilityItem); + contextMenu.Items.Add(secondAction); + contextMenu.Items.Add(new MenuItem + { + Header = "Close this menu", + Tag = "ClosePostGrabMenu" + }); + + List actionableItems = FullscreenGrab.GetActionablePostGrabMenuItems(contextMenu); + + Assert.Collection(actionableItems, + item => Assert.Same(firstAction, item), + item => Assert.Same(secondAction, item)); + } + + [WpfFact] + public void BuildPostGrabActionSnapshot_KeepsChangedTemplateCheckedAndUnchecksOthers() + { + ButtonInfo regularAction = new("Trim each line", "TrimEachLine_Click", SymbolRegular.Apps24, DefaultCheckState.Off); + ButtonInfo firstTemplate = new("Template A", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-a" + }; + ButtonInfo secondTemplate = new("Template B", "ApplyTemplate_Click", SymbolRegular.Apps24, DefaultCheckState.Off) + { + TemplateId = "template-b" + }; + + Dictionary snapshot = FullscreenGrab.BuildPostGrabActionSnapshot( + [ + new MenuItem { Tag = regularAction, IsCheckable = true, IsChecked = true }, + new MenuItem { Tag = firstTemplate, IsCheckable = true, IsChecked = true }, + new MenuItem { Tag = secondTemplate, IsCheckable = true, IsChecked = false } + ], + FullscreenGrab.GetPostGrabActionKey(secondTemplate), + true); + + Assert.True(snapshot[FullscreenGrab.GetPostGrabActionKey(regularAction)]); + Assert.False(snapshot[FullscreenGrab.GetPostGrabActionKey(firstTemplate)]); + Assert.True(snapshot[FullscreenGrab.GetPostGrabActionKey(secondTemplate)]); + } + + [Fact] + public void ShouldPersistLastUsedState_ForForcedSourceAction_ReturnsTrue() + { + ButtonInfo lastUsedAction = new("Remove duplicate lines", "RemoveDuplicateLines_Click", SymbolRegular.Apps24, DefaultCheckState.LastUsed); + + bool shouldPersist = FullscreenGrab.ShouldPersistLastUsedState( + lastUsedAction, + previousChecked: true, + isChecked: true, + forcePersistActionKey: FullscreenGrab.GetPostGrabActionKey(lastUsedAction)); + + Assert.True(shouldPersist); + } + + [Fact] + public void ShouldPersistLastUsedState_DoesNotPersistUnchangedNonSourceAction() + { + ButtonInfo lastUsedAction = new("Remove duplicate lines", "RemoveDuplicateLines_Click", SymbolRegular.Apps24, DefaultCheckState.LastUsed); + + bool shouldPersist = FullscreenGrab.ShouldPersistLastUsedState( + lastUsedAction, + previousChecked: true, + isChecked: true); + + Assert.False(shouldPersist); + } +} diff --git a/Tests/FullscreenGrabSelectionStyleTests.cs b/Tests/FullscreenGrabSelectionStyleTests.cs new file mode 100644 index 00000000..9ab30270 --- /dev/null +++ b/Tests/FullscreenGrabSelectionStyleTests.cs @@ -0,0 +1,74 @@ +using System.Windows; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Views; + +namespace Tests; + +public class FullscreenGrabSelectionStyleTests +{ + [Theory] + [InlineData(FsgSelectionStyle.Window, false, true)] + [InlineData(FsgSelectionStyle.Window, true, true)] + [InlineData(FsgSelectionStyle.Region, true, true)] + [InlineData(FsgSelectionStyle.Region, false, false)] + [InlineData(FsgSelectionStyle.Freeform, false, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, false, false)] + public void ShouldKeepTopToolbarVisible_MatchesSelectionState( + FsgSelectionStyle selectionStyle, + bool isAwaitingAdjustAfterCommit, + bool expected) + { + bool shouldKeepVisible = FullscreenGrab.ShouldKeepTopToolbarVisible( + selectionStyle, + isAwaitingAdjustAfterCommit); + + Assert.Equal(expected, shouldKeepVisible); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void ShouldUseOverlayCutout_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + bool shouldUseCutout = FullscreenGrab.ShouldUseOverlayCutout(selectionStyle); + + Assert.Equal(expected, shouldUseCutout); + } + + [Theory] + [InlineData(FsgSelectionStyle.Region, true)] + [InlineData(FsgSelectionStyle.Window, false)] + [InlineData(FsgSelectionStyle.Freeform, false)] + [InlineData(FsgSelectionStyle.AdjustAfter, true)] + public void ShouldDrawSelectionOutline_MatchesSelectionStyle(FsgSelectionStyle selectionStyle, bool expected) + { + bool shouldDrawOutline = FullscreenGrab.ShouldDrawSelectionOutline(selectionStyle); + + Assert.Equal(expected, shouldDrawOutline); + } + + [Fact] + public void ShouldCommitWindowSelection_RequiresSameWindowHandleOnMouseUp() + { + WindowSelectionCandidate pressedCandidate = new((nint)1, new Rect(0, 0, 40, 40), "Target", 100); + WindowSelectionCandidate releasedSameCandidate = new((nint)1, new Rect(0, 0, 40, 40), "Target", 100); + WindowSelectionCandidate releasedDifferentCandidate = new((nint)2, new Rect(0, 0, 40, 40), "Other", 200); + + Assert.True(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, releasedSameCandidate)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, releasedDifferentCandidate)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(pressedCandidate, null)); + Assert.False(FullscreenGrab.ShouldCommitWindowSelection(null, releasedSameCandidate)); + } + + [Fact] + public void WindowSelectionCandidate_DisplayText_UsesFallbacksWhenMetadataMissing() + { + WindowSelectionCandidate candidate = new((nint)1, new Rect(0, 0, 40, 40), string.Empty, 100); + + Assert.Equal("Application", candidate.DisplayAppName); + Assert.Equal("Untitled window", candidate.DisplayTitle); + } +} diff --git a/Tests/FullscreenGrabZoomCaptureTests.cs b/Tests/FullscreenGrabZoomCaptureTests.cs new file mode 100644 index 00000000..df437ebb --- /dev/null +++ b/Tests/FullscreenGrabZoomCaptureTests.cs @@ -0,0 +1,90 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Text_Grab.Views; + +namespace Tests; + +public class FullscreenGrabZoomCaptureTests +{ + [Fact] + public void TryGetBitmapCropRectForSelection_UsesSelectionRectWithoutZoom() + { + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + new Rect(10, 20, 30, 40), + Matrix.Identity, + null, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(10, cropRect.X); + Assert.Equal(20, cropRect.Y); + Assert.Equal(30, cropRect.Width); + Assert.Equal(40, cropRect.Height); + } + + [Fact] + public void TryGetBitmapCropRectForSelection_MapsZoomedSelectionBackToFrozenBitmap() + { + TransformGroup zoomTransform = new(); + zoomTransform.Children.Add(new ScaleTransform(2, 2, 50, 50)); + zoomTransform.Children.Add(new TranslateTransform(-10, 15)); + + Rect sourceRect = new(40, 50, 20, 10); + Rect displayedSelectionRect = TransformRect(sourceRect, zoomTransform.Value); + + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + displayedSelectionRect, + Matrix.Identity, + zoomTransform, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(40, cropRect.X); + Assert.Equal(50, cropRect.Y); + Assert.Equal(20, cropRect.Width); + Assert.Equal(10, cropRect.Height); + } + + [Fact] + public void TryGetBitmapCropRectForSelection_AppliesDeviceScalingAfterUndoingZoom() + { + ScaleTransform zoomTransform = new(2, 2); + + bool didCreateCrop = FullscreenGrab.TryGetBitmapCropRectForSelection( + new Rect(20, 30, 40, 50), + new Matrix(1.5, 0, 0, 1.5, 0, 0), + zoomTransform, + 200, + 200, + out Int32Rect cropRect); + + Assert.True(didCreateCrop); + Assert.Equal(15, cropRect.X); + Assert.Equal(22, cropRect.Y); + Assert.Equal(30, cropRect.Width); + Assert.Equal(38, cropRect.Height); + } + + private static Rect TransformRect(Rect rect, Matrix matrix) + { + Point[] points = + [ + matrix.Transform(rect.TopLeft), + matrix.Transform(new Point(rect.Right, rect.Top)), + matrix.Transform(new Point(rect.Left, rect.Bottom)), + matrix.Transform(rect.BottomRight) + ]; + + double left = points.Min(static point => point.X); + double top = points.Min(static point => point.Y); + double right = points.Max(static point => point.X); + double bottom = points.Max(static point => point.Y); + + return new Rect(new Point(left, top), new Point(right, bottom)); + } +} diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs new file mode 100644 index 00000000..18f17917 --- /dev/null +++ b/Tests/GrabTemplateExecutorTests.cs @@ -0,0 +1,475 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateExecutorTests +{ + // ── ApplyOutputTemplate – basic substitution ────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_SingleRegion_SubstitutesCorrectly() + { + Dictionary regions = new() { [1] = "Alice" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("Name: {1}", regions); + Assert.Equal("Name: Alice", result); + } + + [Fact] + public void ApplyOutputTemplate_MultipleRegions_SubstitutesAll() + { + Dictionary regions = new() + { + [1] = "Alice", + [2] = "alice@example.com" + }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} <{2}>", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_MissingRegion_ReplacesWithEmpty() + { + Dictionary regions = new() { [1] = "Alice" }; + // Region 2 not present + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} {2}", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_EmptyTemplate_ReturnsEmpty() + { + Dictionary regions = new() { [1] = "value" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate(string.Empty, regions); + Assert.Equal(string.Empty, result); + } + + // ── Modifiers ────────────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_TrimModifier_TrimsWhitespace() + { + Dictionary regions = new() { [1] = " hello " }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:trim}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UpperModifier_ConvertsToUpper() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:upper}", regions); + Assert.Equal("HELLO", result); + } + + [Fact] + public void ApplyOutputTemplate_LowerModifier_ConvertsToLower() + { + Dictionary regions = new() { [1] = "HELLO" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:lower}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UnknownModifier_LeavesTextAsIs() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:unknown}", regions); + Assert.Equal("hello", result); + } + + // ── Escape sequences ────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_NewlineEscape_InsertsNewline() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\n{2}", regions); + Assert.Equal("A\nB", result); + } + + [Fact] + public void ApplyOutputTemplate_TabEscape_InsertsTab() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\t{2}", regions); + Assert.Equal("A\tB", result); + } + + [Fact] + public void ApplyOutputTemplate_LiteralBraceEscape_PreservesBrace() + { + Dictionary regions = new() { [1] = "value" }; + // \{ in template produces literal {, then {1} → value, then literal text } + string result = GrabTemplateExecutor.ApplyOutputTemplate("\\{{1}}", regions); + Assert.Equal("{value}", result); + } + + [Fact] + public void ApplyOutputTemplate_DoubleBackslash_PreservesBackslash() + { + Dictionary regions = new() { [1] = "A" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\\\{1}", regions); + Assert.Equal(@"A\A", result); + } + + // ── ValidateOutputTemplate ──────────────────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidTemplate_ReturnsNoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{1} {2}", [1, 2]); + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_OutOfRangeRegion_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{3}", [1, 2]); + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains('3')); + } + + [Fact] + public void ValidateOutputTemplate_EmptyTemplate_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate(string.Empty, [1]); + Assert.NotEmpty(issues); + } + + [Fact] + public void ValidateOutputTemplate_NoRegionsReferenced_ReturnsIssue() + { + // Template has no {N} references + List issues = GrabTemplateExecutor.ValidateOutputTemplate("static text", [1, 2]); + Assert.NotEmpty(issues); + } + + // ── Pattern placeholder – ApplyPatternPlaceholders ──────────────────────── + + [Fact] + public void ApplyPatternPlaceholders_FirstMatch_ReturnsFirstOccurrence() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: alice@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_LastMatch_ReturnsLastOccurrence() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "last") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:last}", fullText, patterns, regexes); + + Assert.Equal("Email: bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_AllMatches_JoinsWithDefaultSeparator() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "all", ", ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Emails: {p:Email:all}", fullText, patterns, regexes); + + Assert.Equal("Emails: alice@test.com, bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_AllMatchesCustomSeparator_UsesOverride() + { + string fullText = "Contact: alice@test.com and bob@test.com"; + List patterns = + [ + new("id1", "Email", "all", ", ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Emails: {p:Email:all: | }", fullText, patterns, regexes); + + Assert.Equal("Emails: alice@test.com | bob@test.com", result); + } + + [Fact] + public void ApplyPatternPlaceholders_NthMatch_ReturnsSingleIndex() + { + string fullText = "Numbers: 100 200 300"; + List patterns = + [ + new("id1", "Integer", "2") + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Second: {p:Integer:2}", fullText, patterns, regexes); + + Assert.Equal("Second: 200", result); + } + + [Fact] + public void ApplyPatternPlaceholders_MultipleIndices_JoinsSelected() + { + string fullText = "Numbers: 100 200 300 400"; + List patterns = + [ + new("id1", "Integer", "1,3", "; ") + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Selected: {p:Integer:1,3}", fullText, patterns, regexes); + + Assert.Equal("Selected: 100; 300", result); + } + + [Fact] + public void ApplyPatternPlaceholders_NoMatches_ReturnsEmpty() + { + string fullText = "No emails here"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: ", result); + } + + [Fact] + public void ApplyPatternPlaceholders_PatternNotFound_ReturnsEmpty() + { + string fullText = "Some text"; + List patterns = + [ + new("id1", "Email", "first") + ]; + // No regexes provided for this pattern + Dictionary regexes = []; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Email: {p:Email:first}", fullText, patterns, regexes); + + Assert.Equal("Email: ", result); + } + + [Fact] + public void ApplyPatternPlaceholders_UnknownPatternName_LeavesPlaceholder() + { + string fullText = "Some text"; + List patterns = []; // no patterns registered + Dictionary regexes = []; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Data: {p:Unknown:first}", fullText, patterns, regexes); + + Assert.Equal("Data: {p:Unknown:first}", result); + } + + [Fact] + public void ApplyPatternPlaceholders_IndexOutOfRange_ReturnsEmpty() + { + string fullText = "One: 100"; + List patterns = + [ + new("id1", "Integer", "5") // only 1 match, requesting 5th + ]; + Dictionary regexes = new() + { + ["id1"] = @"\d+", + ["Integer"] = @"\d+" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + "Fifth: {p:Integer:5}", fullText, patterns, regexes); + + Assert.Equal("Fifth: ", result); + } + + // ── ExtractMatchesByMode ────────────────────────────────────────────────── + + [Fact] + public void ExtractMatchesByMode_First_ReturnsFirst() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "first", ", "); + Assert.Equal("abc", result); + } + + [Fact] + public void ExtractMatchesByMode_Last_ReturnsLast() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "last", ", "); + Assert.Equal("ghi", result); + } + + [Fact] + public void ExtractMatchesByMode_All_JoinsAll() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches("abc def ghi", @"\w+"); + + string result = GrabTemplateExecutor.ExtractMatchesByMode(matches, "all", " | "); + Assert.Equal("abc | def | ghi", result); + } + + // ── Hybrid template (regions + patterns) ────────────────────────────────── + + [Fact] + public void HybridTemplate_RegionsAndPatterns_BothResolved() + { + // First apply regions + Dictionary regions = new() { [1] = "John Doe" }; + string template = "Name: {1}\\nEmail: {p:Email:first}"; + string afterRegions = GrabTemplateExecutor.ApplyOutputTemplate(template, regions); + + // Then apply patterns + string fullText = "Contact john@example.com for details"; + List patterns = + [ + new("id1", "Email", "first") + ]; + Dictionary regexes = new() + { + ["id1"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + ["Email"] = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + }; + + string result = GrabTemplateExecutor.ApplyPatternPlaceholders( + afterRegions, fullText, patterns, regexes); + + Assert.Equal("Name: John Doe\nEmail: john@example.com", result); + } + + // ── GrabTemplate model ──────────────────────────────────────────────────── + + [Fact] + public void GrabTemplate_IsValid_PatternOnlyTemplate() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "{p:Email:first}", + PatternMatches = [new("id1", "Email", "first")] + }; + + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_RequiresNameAndOutput() + { + GrabTemplate template = new() + { + PatternMatches = [new("id1", "Email", "first")] + }; + + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_GetReferencedPatternNames_ParsesNames() + { + GrabTemplate template = new("Test") + { + OutputTemplate = "Email: {p:Email Address:first}\\nPhone: {p:Phone Number:all:, }" + }; + + List names = [.. template.GetReferencedPatternNames()]; + Assert.Equal(2, names.Count); + Assert.Contains("Email Address", names); + Assert.Contains("Phone Number", names); + } + + // ── ValidateOutputTemplate with patterns ────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidPatternPlaceholder_NoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Email:first}", + [], + ["Email"]); + + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_UnknownPatternName_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Unknown Pattern:first}", + [], + ["Email"]); + + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains("Unknown Pattern")); + } + + [Fact] + public void ValidateOutputTemplate_InvalidMatchMode_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate( + "{p:Email:invalid_mode}", + [], + ["Email"]); + + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains("invalid_mode")); + } +} diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs new file mode 100644 index 00000000..c4d93e36 --- /dev/null +++ b/Tests/GrabTemplateManagerTests.cs @@ -0,0 +1,228 @@ +using System.IO; +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/Tests/Tests.csproj b/Tests/Tests.csproj index f436f3b2..eb6c0bf8 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,13 +13,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all 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/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/App.xaml b/Text-Grab/App.xaml index 9cd6c706..2f2e0bd6 100644 --- a/Text-Grab/App.xaml +++ b/Text-Grab/App.xaml @@ -40,6 +40,7 @@ + diff --git a/Text-Grab/Controls/InlineChipElement.cs b/Text-Grab/Controls/InlineChipElement.cs new file mode 100644 index 00000000..beab9d6b --- /dev/null +++ b/Text-Grab/Controls/InlineChipElement.cs @@ -0,0 +1,58 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace Text_Grab.Controls; + +[TemplatePart(Name = PartRemoveButton, Type = typeof(Button))] +public class InlineChipElement : Control +{ + private const string PartRemoveButton = "PART_RemoveButton"; + + public static readonly DependencyProperty DisplayNameProperty = + DependencyProperty.Register(nameof(DisplayName), typeof(string), typeof(InlineChipElement), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), typeof(string), typeof(InlineChipElement), + new PropertyMetadata(string.Empty)); + + public string DisplayName + { + get => (string)GetValue(DisplayNameProperty); + set => SetValue(DisplayNameProperty, value); + } + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public event EventHandler? RemoveRequested; + + private Button? _removeButton; + + static InlineChipElement() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(InlineChipElement), + new FrameworkPropertyMetadata(typeof(InlineChipElement))); + } + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + if (_removeButton is not null) + _removeButton.Click -= RemoveButton_Click; + + _removeButton = GetTemplateChild(PartRemoveButton) as Button; + + if (_removeButton is not null) + _removeButton.Click += RemoveButton_Click; + } + + private void RemoveButton_Click(object sender, RoutedEventArgs e) + => RemoveRequested?.Invoke(this, EventArgs.Empty); +} diff --git a/Text-Grab/Controls/InlinePickerItem.cs b/Text-Grab/Controls/InlinePickerItem.cs new file mode 100644 index 00000000..da922a71 --- /dev/null +++ b/Text-Grab/Controls/InlinePickerItem.cs @@ -0,0 +1,24 @@ +namespace Text_Grab.Controls; + +public class InlinePickerItem +{ + public string DisplayName { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + + /// + /// Optional group label used to render section headers in the picker popup + /// (e.g. "Regions", "Patterns"). + /// + public string Group { get; set; } = string.Empty; + + public InlinePickerItem() { } + + public InlinePickerItem(string displayName, string value, string group = "") + { + DisplayName = displayName; + Value = value; + Group = group; + } + + public override string ToString() => DisplayName; +} diff --git a/Text-Grab/Controls/InlinePickerRichTextBox.cs b/Text-Grab/Controls/InlinePickerRichTextBox.cs new file mode 100644 index 00000000..0957745d --- /dev/null +++ b/Text-Grab/Controls/InlinePickerRichTextBox.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Effects; +using Text_Grab.Models; + +namespace Text_Grab.Controls; + +/// +/// A RichTextBox that shows a compact inline picker popup when the trigger character +/// (default '{') is typed, allowing users to insert named value chips into the document. +/// Supports grouped items with section headers (e.g. "Regions" and "Patterns"). +/// +public class InlinePickerRichTextBox : RichTextBox +{ + private readonly Popup _popup; + private readonly ListBox _listBox; + + private TextPointer? _triggerStart; + private bool _isModifyingDocument; + + #region Dependency Properties + + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + nameof(ItemsSource), + typeof(IEnumerable), + typeof(InlinePickerRichTextBox), + new PropertyMetadata(null)); + + public static readonly DependencyProperty SerializedTextProperty = + DependencyProperty.Register( + nameof(SerializedText), + typeof(string), + typeof(InlinePickerRichTextBox), + new FrameworkPropertyMetadata( + string.Empty, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + #endregion Dependency Properties + + #region Properties + + public IEnumerable ItemsSource + { + get => (IEnumerable?)GetValue(ItemsSourceProperty) ?? []; + set => SetValue(ItemsSourceProperty, value); + } + + /// + /// The document serialized back to a plain string, where each chip is replaced + /// with its (e.g. "{1}"). + /// Supports two-way binding. + /// + public string SerializedText + { + get => (string)GetValue(SerializedTextProperty); + set => SetValue(SerializedTextProperty, value); + } + + /// Character that opens the picker popup. Default is '{'. + public char TriggerChar { get; set; } = '{'; + + #endregion Properties + + public event EventHandler? ItemInserted; + + /// + /// Called when a pattern-group item is selected. The handler should show the + /// and return the configured + /// , or null to cancel. + /// + public Func? PatternItemSelected { get; set; } + + static InlinePickerRichTextBox() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(InlinePickerRichTextBox), + new FrameworkPropertyMetadata(typeof(InlinePickerRichTextBox))); + } + + public InlinePickerRichTextBox() + { + AcceptsReturn = false; + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + + _listBox = BuildPopupListBox(); + + Border popupBorder = new() + { + Child = _listBox, + CornerRadius = new CornerRadius(8), + BorderThickness = new Thickness(1), + Padding = new Thickness(2), + Effect = new DropShadowEffect + { + BlurRadius = 6, + Direction = 270, + Opacity = 0.2, + ShadowDepth = 2, + Color = Colors.Black + } + }; + popupBorder.SetResourceReference(BackgroundProperty, "SolidBackgroundFillColorBaseBrush"); + popupBorder.SetResourceReference(BorderBrushProperty, "Teal"); + + _popup = new Popup + { + Child = popupBorder, + StaysOpen = true, + AllowsTransparency = true, + Placement = PlacementMode.RelativePoint, + PlacementTarget = this, + }; + + TextChanged += OnTextChanged; + PreviewKeyDown += OnPreviewKeyDown; + LostKeyboardFocus += OnLostKeyboardFocus; + } + + private ListBox BuildPopupListBox() + { + ListBox lb = new() + { + MinWidth = 140, + MaxHeight = 200, + FontSize = 11, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + FocusVisualStyle = null, + Focusable = false, + }; + lb.SetResourceReference(ForegroundProperty, "TextFillColorPrimaryBrush"); + + // Use a template selector to render headers vs selectable items + lb.ItemTemplateSelector = new PickerItemTemplateSelector( + BuildSelectableItemTemplate(), + BuildHeaderItemTemplate()); + + lb.PreviewMouseDown += ListBox_PreviewMouseDown; + lb.ItemContainerStyle = BuildCompactItemStyle(); + return lb; + } + + private static DataTemplate BuildSelectableItemTemplate() + { + DataTemplate dt = new(); + FrameworkElementFactory spFactory = new(typeof(StackPanel)); + spFactory.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal); + + FrameworkElementFactory nameFactory = new(typeof(TextBlock)); + nameFactory.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.DisplayName))); + nameFactory.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); + nameFactory.SetValue(FrameworkElement.MarginProperty, new Thickness(6, 2, 4, 2)); + + FrameworkElementFactory valueFactory = new(typeof(TextBlock)); + valueFactory.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.Value))); + valueFactory.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); + valueFactory.SetValue(TextBlock.FontSizeProperty, 9.0); + valueFactory.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 2, 6, 2)); + valueFactory.SetValue(TextBlock.OpacityProperty, 0.55); + + spFactory.AppendChild(nameFactory); + spFactory.AppendChild(valueFactory); + dt.VisualTree = spFactory; + return dt; + } + + private static DataTemplate BuildHeaderItemTemplate() + { + DataTemplate dt = new(); + FrameworkElementFactory tb = new(typeof(TextBlock)); + tb.SetBinding(TextBlock.TextProperty, + new System.Windows.Data.Binding(nameof(InlinePickerItem.DisplayName))); + tb.SetValue(TextBlock.FontSizeProperty, 9.5); + tb.SetValue(TextBlock.FontWeightProperty, FontWeights.SemiBold); + tb.SetValue(TextBlock.OpacityProperty, 0.6); + tb.SetValue(FrameworkElement.MarginProperty, new Thickness(4, 4, 4, 2)); + tb.SetValue(UIElement.IsHitTestVisibleProperty, false); + dt.VisualTree = tb; + return dt; + } + + private static Style BuildCompactItemStyle() + { + // Provide a minimal ControlTemplate so WPF-UI's touch-sized ListBoxItem + // template (large MinHeight + padding) is completely replaced. + FrameworkElementFactory border = new(typeof(Border)) + { + Name = "Bd" + }; + border.SetValue(Border.BackgroundProperty, Brushes.Transparent); + border.SetValue(Border.CornerRadiusProperty, new CornerRadius(4)); + border.SetValue(FrameworkElement.MarginProperty, new Thickness(1, 1, 1, 0)); + + FrameworkElementFactory cp = new(typeof(ContentPresenter)); + cp.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + border.AppendChild(cp); + + ControlTemplate template = new(typeof(ListBoxItem)) { VisualTree = border }; + + Trigger hoverTrigger = new() { Property = UIElement.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x22, 0x30, 0x8E, 0x98)), "Bd")); + template.Triggers.Add(hoverTrigger); + + Trigger selectedTrigger = new() { Property = ListBoxItem.IsSelectedProperty, Value = true }; + selectedTrigger.Setters.Add(new Setter(Border.BackgroundProperty, + new SolidColorBrush(Color.FromArgb(0x44, 0x30, 0x8E, 0x98)), "Bd")); + template.Triggers.Add(selectedTrigger); + + Style style = new(typeof(ListBoxItem)); + style.Setters.Add(new Setter(Control.TemplateProperty, template)); + style.Setters.Add(new Setter(FrameworkElement.MinHeightProperty, 0.0)); + style.Setters.Add(new Setter(Control.FocusVisualStyleProperty, (Style?)null)); + return style; + } + + #region Keyboard & Focus handling + + private void OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + // Keep popup open if focus moved into it (e.g. scrollbar click) + if (e.NewFocus is DependencyObject target && IsVisualDescendant(_popup.Child, target)) + return; + + HidePopup(); + _triggerStart = null; + } + + protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e) + { + // RichTextBox intercepts mouse-down for cursor placement before child controls receive it. + // Detect clicks on a chip's remove button and route them manually. + if (e.OriginalSource is DependencyObject source) + { + Button? btn = FindVisualAncestor + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs index 40385f79..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,40 @@ 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 (!CanReadBitmapDimensions(bitmap)) + return EmptyBarcodeOutput; + BarcodeReader barcodeReader = new() { AutoRotate = true, - Options = new ZXing.Common.DecodingOptions { TryHarder = true } + Options = new DecodingOptions { TryHarder = true } }; - ZXing.Result result = barcodeReader.Decode(bitmap); + Result? result = null; + + try + { + result = barcodeReader.Decode(bitmap); + } + 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) + { + Debug.WriteLine($"Unable to decode barcode from GDI+ bitmap: {ex.Message}"); + return EmptyBarcodeOutput; + } string resultString = string.Empty; if (result is not null) @@ -34,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() { @@ -81,4 +136,4 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor return svg; } -} \ 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/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs new file mode 100644 index 00000000..52ead768 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Executes a against a captured screen region: +/// OCRs each sub-region, then formats the results using the template's +/// string. +/// +/// Output template syntax — region placeholders: +/// {N} — OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text +/// {N:upper} — uppercased OCR text +/// {N:lower} — lowercased OCR text +/// +/// Output template syntax — pattern placeholders: +/// {p:Name:first} — first regex match +/// {p:Name:last} — last regex match +/// {p:Name:all:, } — all matches joined by separator +/// {p:Name:2} — 2nd match (1-based) +/// {p:Name:1,3} — 1st and 3rd matches joined by separator +/// +/// Escape sequences: +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public static class GrabTemplateExecutor +{ + // Matches {N} or {N:modifier} where N is one or more digits + private static readonly Regex PlaceholderRegex = + new(@"\{(\d+)(?::([a-z]+))?\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // Matches {p:PatternName:mode} or {p:PatternName:mode:separator} + // Group 1 = pattern name, Group 2 = match mode, Group 3 = optional separator + private static readonly Regex PatternPlaceholderRegex = + new(@"\{p:([^:}]+):([^:}]+)(?::([^}]*))?\}", RegexOptions.Compiled); + + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(5); + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Executes the given template using as the + /// coordinate space. Each template region is mapped to a sub-rectangle of + /// , OCR'd, then assembled via the output template. + /// + /// The template to execute. + /// + /// The screen rectangle in physical screen pixels that bounds the user's + /// selection. Template region ratios are applied to this rectangle's + /// width/height to derive each sub-region's capture bounds. + /// + /// The OCR language to use. Pass null to use the app default. + public static async Task ExecuteTemplateAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + + // 1. OCR each region (if any) + Dictionary regionResults = template.Regions.Count > 0 + ? await OcrAllRegionsAsync(template, captureRegion, resolvedLanguage) + : []; + + // 2. OCR full capture area for pattern matching (if any pattern references exist) + 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; + } + + /// + /// Applies the output template string with the provided region text values. + /// Useful for unit testing the string processing independently of OCR. + /// + public static string ApplyOutputTemplate( + string outputTemplate, + IReadOnlyDictionary regionResults) + { + if (string.IsNullOrEmpty(outputTemplate)) + return string.Empty; + + // Replace escape sequences first + string processed = outputTemplate + .Replace(@"\\", "\x00BACKSLASH\x00") // protect real backslashes + .Replace(@"\n", "\n") + .Replace(@"\t", "\t") + .Replace(@"\{", "\x00LBRACE\x00") // protect literal braces + .Replace("\x00BACKSLASH\x00", @"\"); + + // Replace {N} / {N:modifier} placeholders + string result = PlaceholderRegex.Replace(processed, match => + { + if (!int.TryParse(match.Groups[1].Value, out int regionNumber)) + return match.Value; // leave unknown placeholders as-is + + regionResults.TryGetValue(regionNumber, out string? text); + text ??= string.Empty; + + string modifier = match.Groups[2].Success + ? match.Groups[2].Value.ToLowerInvariant() + : string.Empty; + + return modifier switch + { + "trim" => text.Trim(), + "upper" => text.ToUpper(), + "lower" => text.ToLower(), + _ => text + }; + }); + + // Restore protected literal characters + result = result.Replace("\x00LBRACE\x00", "{"); + + return result; + } + + // ── Pattern placeholder processing ────────────────────────────────────── + + /// + /// Replaces {p:PatternName:mode} and {p:PatternName:mode:separator} + /// placeholders in the template with regex match results from the full-area OCR text. + /// + public static string ApplyPatternPlaceholders( + string template, + string fullText, + IReadOnlyList patternMatches, + IReadOnlyDictionary patternRegexes) + { + if (string.IsNullOrEmpty(template) || patternMatches.Count == 0) + return template; + + return PatternPlaceholderRegex.Replace(template, match => + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + string separatorOverride = match.Groups[3].Success ? match.Groups[3].Value : null!; + + // Find the matching pattern config + TemplatePatternMatch? patternMatch = patternMatches + .FirstOrDefault(p => p.PatternName.Equals(patternName, StringComparison.OrdinalIgnoreCase)); + + if (patternMatch == null) + return match.Value; // leave unresolved + + // Resolve the regex string + if (!patternRegexes.TryGetValue(patternMatch.PatternId, out string? regexPattern) + && !patternRegexes.TryGetValue(patternMatch.PatternName, out regexPattern)) + return string.Empty; // pattern not found + + string separator = separatorOverride ?? patternMatch.Separator; + + try + { + MatchCollection regexMatches = Regex.Matches(fullText, regexPattern, RegexOptions.Multiline, RegexTimeout); + + if (regexMatches.Count == 0) + return string.Empty; + + return ExtractMatchesByMode(regexMatches, mode, separator); + } + catch (RegexMatchTimeoutException) + { + return string.Empty; + } + catch (ArgumentException) + { + return string.Empty; // invalid regex + } + }); + } + + /// + /// Extracts match values based on the mode string. + /// + internal static string ExtractMatchesByMode(MatchCollection matches, string mode, string separator) + { + List allValues = matches.Select(m => m.Value).ToList(); + + return mode.ToLowerInvariant() switch + { + "first" => allValues[0], + "last" => allValues[^1], + "all" => string.Join(separator, allValues), + _ => ExtractByIndices(allValues, mode, separator) + }; + } + + private static string ExtractByIndices(List values, string mode, string separator) + { + // mode is either a single index like "2" or comma-separated like "1,3,5" + string[] parts = mode.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + List selected = []; + + foreach (string part in parts) + { + if (int.TryParse(part, out int index) && index >= 1 && index <= values.Count) + selected.Add(values[index - 1]); // convert 1-based to 0-based + } + + return string.Join(separator, selected); + } + + /// + /// Resolves entries to their actual regex strings + /// by loading saved patterns from settings. + /// Returns a dictionary keyed by both PatternId and PatternName for flexible lookup. + /// + internal static Dictionary ResolvePatternRegexes( + IReadOnlyList patternMatches) + { + Dictionary result = []; + + StoredRegex[] savedPatterns = LoadSavedPatterns(); + Dictionary byId = []; + Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + foreach (StoredRegex sr in savedPatterns) + { + byId[sr.Id] = sr; + byName[sr.Name] = sr; + } + + foreach (TemplatePatternMatch pm in patternMatches) + { + StoredRegex? resolved = null; + + // Prefer lookup by ID (survives renames) + if (!string.IsNullOrEmpty(pm.PatternId) && byId.TryGetValue(pm.PatternId, out resolved)) + { + result[pm.PatternId] = resolved.Pattern; + result[pm.PatternName] = resolved.Pattern; + continue; + } + + // Fallback to name + if (byName.TryGetValue(pm.PatternName, out resolved)) + { + result[pm.PatternId] = resolved.Pattern; + result[pm.PatternName] = resolved.Pattern; + } + } + + return result; + } + + 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( + GrabTemplate template, + Rect captureRegion, + ILanguage language) + { + Dictionary results = []; + + foreach (TemplateRegion region in template.Regions) + { + // Compute absolute screen rect from capture region + region ratios + Rect absoluteRegionRect = new( + x: captureRegion.X + region.RatioLeft * captureRegion.Width, + y: captureRegion.Y + region.RatioTop * captureRegion.Height, + width: region.RatioWidth * captureRegion.Width, + height: region.RatioHeight * captureRegion.Height); + + if (absoluteRegionRect.Width <= 0 || absoluteRegionRect.Height <= 0) + { + results[region.RegionNumber] = region.DefaultValue; + continue; + } + + try + { + // GetTextFromAbsoluteRectAsync uses absolute screen coordinates + string regionText = await OcrUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); + // Use default value when OCR returns nothing + results[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + results[region.RegionNumber] = region.DefaultValue; + } + } + + return results; + } + + /// + /// Validates the output template syntax and returns a list of issues. + /// Returns an empty list when valid. + /// + public static List ValidateOutputTemplate( + string outputTemplate, + IEnumerable availableRegionNumbers, + IEnumerable? availablePatternNames = null) + { + List issues = []; + HashSet available = [.. availableRegionNumbers]; + + // Validate region placeholders + MatchCollection regionMatches = PlaceholderRegex.Matches(outputTemplate); + HashSet referenced = []; + + foreach (Match match in regionMatches) + { + if (!int.TryParse(match.Groups[1].Value, out int num)) + { + issues.Add($"Invalid placeholder: {match.Value}"); + continue; + } + + if (!available.Contains(num)) + issues.Add($"Placeholder {{{num}}} references region {num} which does not exist."); + + referenced.Add(num); + } + + foreach (int availableNum in available) + { + if (!referenced.Contains(availableNum)) + issues.Add($"Region {availableNum} is defined but not used in the output template."); + } + + // Validate pattern placeholders + if (availablePatternNames != null) + { + HashSet availableNames = new(availablePatternNames, StringComparer.OrdinalIgnoreCase); + MatchCollection patternMatches = PatternPlaceholderRegex.Matches(outputTemplate); + + foreach (Match match in patternMatches) + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + + if (!availableNames.Contains(patternName)) + issues.Add($"Pattern placeholder references \"{patternName}\" which is not a saved pattern."); + + if (!IsValidMatchMode(mode)) + issues.Add($"Invalid match mode \"{mode}\" for pattern \"{patternName}\". Use first, last, all, or numeric indices."); + } + } + + return issues; + } + + private static bool IsValidMatchMode(string mode) + { + if (string.IsNullOrEmpty(mode)) + return false; + + return mode.ToLowerInvariant() switch + { + "first" or "last" or "all" => true, + _ => mode.Split(',', StringSplitOptions.RemoveEmptyEntries) + .All(p => int.TryParse(p.Trim(), out int v) && v >= 1) + }; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs new file mode 100644 index 00000000..2c45ad8d --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Windows.Media.Imaging; +using Text_Grab.Models; +using Text_Grab.Properties; +using Wpf.Ui.Controls; + +namespace Text_Grab.Utilities; + +/// +/// Provides CRUD operations for objects, 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); + } + + /// + /// Saves as a PNG in the template-images folder, named + /// after and the first 8 characters of . + /// Returns the full file path on success, or null if the source is null or the write fails. + /// + public static string? SaveTemplateReferenceImage(BitmapSource? imageSource, string templateName, string templateId) + { + if (imageSource is null) + return null; + + try + { + string folder = GetTemplateImagesFolder(); + if (!Directory.Exists(folder)) + Directory.CreateDirectory(folder); + + string safeName = templateName.ReplaceReservedCharacters(); + string shortId = templateId.Length >= 8 ? templateId[..8] : templateId; + string filePath = Path.Combine(folder, $"{safeName}_{shortId}.png"); + + // Write to a temp file first so the encoder never contends with WPF's + // read lock on filePath (held when BitmapImage was loaded without OnLoad). + string tempPath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp"); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(imageSource)); + using (FileStream fs = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + encoder.Save(fs); + + // Atomically replace the destination; succeeds even when the target file + // is open for reading by another process (e.g. WPF's BitmapImage). + File.Move(tempPath, filePath, overwrite: true); + return filePath; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save template reference image: {ex.Message}"); + return null; + } + } + + /// Returns the folder where template reference images are stored alongside the templates JSON. + public static string GetTemplateImagesFolder() + { + if (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() + { + 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); + } +} diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index ae43afd5..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; } @@ -117,16 +119,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 { @@ -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) 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/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; diff --git a/Text-Grab/Utilities/WindowSelectionUtilities.cs b/Text-Grab/Utilities/WindowSelectionUtilities.cs new file mode 100644 index 00000000..45706e4e --- /dev/null +++ b/Text-Grab/Utilities/WindowSelectionUtilities.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +public static class WindowSelectionUtilities +{ + private const int DwmwaExtendedFrameBounds = 9; + private const int DwmwaCloaked = 14; + private const int GwlExStyle = -20; + private const int WsExToolWindow = 0x00000080; + private const int WsExNoActivate = 0x08000000; + + public static List GetCapturableWindows(IReadOnlyCollection? excludedHandles = null) + { + HashSet excluded = excludedHandles is null ? [] : [.. excludedHandles]; + IntPtr shellWindow = OSInterop.GetShellWindow(); + List candidates = []; + + _ = OSInterop.EnumWindows((windowHandle, _) => + { + WindowSelectionCandidate? candidate = CreateCandidate(windowHandle, shellWindow, excluded); + if (candidate is not null) + candidates.Add(candidate); + + return true; + }, IntPtr.Zero); + + return candidates; + } + + public static WindowSelectionCandidate? FindWindowAtPoint(IEnumerable candidates, Point screenPoint) + { + return candidates.FirstOrDefault(candidate => candidate.Contains(screenPoint)); + } + + internal static bool IsValidWindowBounds(Rect bounds) + { + return bounds != Rect.Empty && bounds.Width > 20 && bounds.Height > 20; + } + + private static WindowSelectionCandidate? CreateCandidate(IntPtr windowHandle, IntPtr shellWindow, ISet excludedHandles) + { + if (windowHandle == IntPtr.Zero || windowHandle == shellWindow || excludedHandles.Contains(windowHandle)) + return null; + + if (!OSInterop.IsWindowVisible(windowHandle) || OSInterop.IsIconic(windowHandle)) + return null; + + if (IsCloaked(windowHandle)) + return null; + + int extendedStyle = OSInterop.GetWindowLong(windowHandle, GwlExStyle); + if ((extendedStyle & WsExToolWindow) != 0 || (extendedStyle & WsExNoActivate) != 0) + return null; + + Rect bounds = GetWindowBounds(windowHandle); + if (!IsValidWindowBounds(bounds)) + return null; + + _ = OSInterop.GetWindowThreadProcessId(windowHandle, out uint processId); + + return new WindowSelectionCandidate( + windowHandle, + bounds, + GetWindowTitle(windowHandle), + (int)processId, + GetProcessName((int)processId)); + } + + private static Rect GetWindowBounds(IntPtr windowHandle) + { + int rectSize = Marshal.SizeOf(); + + if (OSInterop.DwmGetWindowAttribute(windowHandle, DwmwaExtendedFrameBounds, out OSInterop.RECT frameBounds, rectSize) == 0) + { + Rect extendedBounds = new(frameBounds.left, frameBounds.top, frameBounds.width, frameBounds.height); + if (IsValidWindowBounds(extendedBounds)) + return extendedBounds; + } + + if (OSInterop.GetWindowRect(windowHandle, out OSInterop.RECT windowRect)) + return new Rect(windowRect.left, windowRect.top, windowRect.width, windowRect.height); + + return Rect.Empty; + } + + private static string GetWindowTitle(IntPtr windowHandle) + { + int titleLength = OSInterop.GetWindowTextLength(windowHandle); + if (titleLength <= 0) + return string.Empty; + + StringBuilder titleBuilder = new(titleLength + 1); + _ = OSInterop.GetWindowText(windowHandle, titleBuilder, titleBuilder.Capacity); + return titleBuilder.ToString(); + } + + private static bool IsCloaked(IntPtr windowHandle) + { + return OSInterop.DwmGetWindowAttribute(windowHandle, DwmwaCloaked, out int cloakedState, sizeof(int)) == 0 + && cloakedState != 0; + } + + private static string GetProcessName(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + return process.ProcessName; + } + catch (ArgumentException) + { + return string.Empty; + } + catch (InvalidOperationException) + { + return string.Empty; + } + catch (Win32Exception) + { + return string.Empty; + } + } +} diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index 8f7e9a3a..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; @@ -77,6 +79,11 @@ public static void SetWindowPosition(Window passedWindow) } public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) + { + LaunchFullScreenGrab(destinationTextBox, null); + } + + public static void LaunchFullScreenGrab(TextBox? destinationTextBox, string? preselectedTemplateId) { DisplayInfo[] allScreens = DisplayInfo.AllDisplayInfos; WindowCollection allWindows = Application.Current.Windows; @@ -89,6 +96,9 @@ public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) if (window is FullscreenGrab grab) allFullscreenGrab.Add(grab); + if (allFullscreenGrab.Count == 0) + ClearFullscreenPostGrabActionStates(); + int numberOfFullscreenGrabWindowsToCreate = numberOfScreens - allFullscreenGrab.Count; for (int i = 0; i < numberOfFullscreenGrabWindowsToCreate; i++) @@ -107,6 +117,7 @@ public static void LaunchFullScreenGrab(TextBox? destinationTextBox = null) fullScreenGrab.Width = sideLength; fullScreenGrab.Height = sideLength; fullScreenGrab.DestinationTextBox = destinationTextBox; + fullScreenGrab.PreselectedTemplateId = preselectedTemplateId; fullScreenGrab.WindowState = WindowState.Normal; Point screenCenterPoint = screen.ScaledCenterPoint(); @@ -151,6 +162,7 @@ public static void CenterOverThisWindow(this Window newWindow, Window bottomWind internal static async void CloseAllFullscreenGrabs() { WindowCollection allWindows = Application.Current.Windows; + ClearFullscreenPostGrabActionStates(); bool isFromEditWindow = false; string stringFromOCR = ""; @@ -197,6 +209,31 @@ internal static void FullscreenKeyDown(Key key, bool? isActive = null) fsg.KeyPressed(key, isActive); } + internal static void SyncFullscreenPostGrabActionStates(IReadOnlyDictionary actionStates, FullscreenGrab? sourceWindow = null) + { + fullscreenPostGrabActionStates = new Dictionary(actionStates); + + WindowCollection allWindows = Application.Current.Windows; + foreach (Window window in allWindows) + { + if (window is FullscreenGrab fsg && fsg != sourceWindow) + fsg.ApplyPostGrabActionSnapshot(fullscreenPostGrabActionStates); + } + } + + internal static IReadOnlyDictionary? GetFullscreenPostGrabActionStates() + { + if (fullscreenPostGrabActionStates is null || fullscreenPostGrabActionStates.Count == 0) + return null; + + return new Dictionary(fullscreenPostGrabActionStates); + } + + internal static void ClearFullscreenPostGrabActionStates() + { + fullscreenPostGrabActionStates = null; + } + internal static async Task TryInsertString(string stringToInsert) { await Task.Delay(TimeSpan.FromSeconds(AppUtilities.TextGrabSettings.InsertDelay)); diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index e42947e9..3b25d90d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -24,130 +24,124 @@ 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}" /> - - + + - + - - + InputGestureText="Ctrl + O" /> + + + InputGestureText="Ctrl + S" /> + InputGestureText="Ctrl + Shift + S" /> + Header="_Copy And Close" /> - + Header="Close and I_nsert" /> + + Header="Text Grab Sett_ings..." /> + InputGestureText="Alt + F4" /> - - - - - - + + + + + + - + InputGestureText="Ctrl + Shift + V" /> + + Header="Launch URL" /> + Header="Make _Single Line" /> + Header="_Trim Each Line" /> + Header="Try To Make _Numbers" /> + Header="Try To Make _Letters" /> + Header="Correct Common _GUID/UUID Errors" /> + InputGestureText="Shift + F3" /> + Header="Remove Duplicate Lines" /> + InputGestureText="Ctrl + R" /> + InputGestureText="Ctrl + U" /> + Header="_Unstack Text (Select First Column)" /> + Header="_Add, Remove, Limit..." /> + Header="Find and Replace" /> - - + Header="Default Web Search" /> + + + InputGestureText="Ctrl + W" /> + InputGestureText="Ctrl + L" /> + InputGestureText="Ctrl + A" /> + Header="Select None" /> - + Header="Delete Selected Text" /> + + InputGestureText="Alt + Up" /> - + InputGestureText="Alt + Down" /> + + Header="Split Lines _Before Each Selection" /> + Header="Split Lines _After Each Selection" /> + InputGestureText="Ctrl + I" /> + Header="Delete All Instances of Selection" /> + Header="Delete All Instances of Selection Simple Pattern" /> + Header="Insert Selection On Every Line" /> + Header="_Summarize Paragraph" /> + Header="_Rewrite" /> + Header="_Convert to Table" /> - + Header="Translate to System Language" /> + + Tag="English" /> + Tag="Spanish" /> + Tag="French" /> + Tag="German" /> + Tag="Italian" /> + Tag="Portuguese" /> + Tag="Russian" /> + Tag="Japanese" /> + Tag="Chinese (Simplified)" /> + Tag="Korean" /> + Tag="Arabic" /> + Tag="Hindi" /> + Header="E_xtract RegEx" /> + Header="_Learn About Local AI Features..." /> + IsCheckable="True" /> + Unchecked="MarginsMenuItem_Checked" /> + Unchecked="WrapTextCHBX_Checked" /> + Header="_Font..." /> + InputGestureText="Ctrl + F" /> + Header="Fullscreen with 2 second _delay" /> + Header="Grab previous region" /> - + InputGestureText="Ctrl + G" /> + + + InputGestureText="Ctrl + Q" /> - + IsChecked="False" /> + - + Header="_List Files and Folders From Folder..." /> + - + Header="_Extract Text from Images in Folder..." /> + + StaysOpenOnClick="True" /> + StaysOpenOnClick="True" /> + StaysOpenOnClick="True" /> + StaysOpenOnClick="True" /> + StaysOpenOnClick="True" /> + IsChecked="False" /> + IsChecked="False" /> + IsChecked="False" /> + Unchecked="RestorePositionMenuItem_Checked" /> + Header="_Restore this windows's position" /> - + IsCheckable="True" /> + + Header="_New Window" /> + Header="New Window with Selected _Text" /> + Header="Make _QR Code" /> + Header="Edit _Last Grab" /> + Header="_Contact The Developer..." /> + Header="_Rate and Review..." /> + Header="_Feedback..." /> + Header="_About" /> - - + + + VerticalScrollBarVisibility="Auto" /> - - - - + + + + + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -715,31 +702,31 @@ + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -747,7 +734,7 @@ x:Name="CalcAggregateStatusText" FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text=""/> + Text="" /> + Symbol="Copy24" /> @@ -775,8 +762,7 @@ BorderThickness="0" Click="CalcCopyAllButton_Click" ToolTip="Copy All Results"> - + @@ -810,62 +795,55 @@ Margin="0,0,0,8" FontSize="16" FontWeight="Bold" - Text="Calculation Pane"/> - - + Text="Calculation Pane" /> + + - - + Text="Features:" /> + + - - + + - - + + - - + + - - + + - - + + + Text="Examples:" /> - - - - - + + + + + - - - + + + + TextWrapping="Wrap" /> - + - + @@ -901,7 +879,7 @@ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" MouseDoubleClick="TextBoxSplitter_MouseDoubleClick" ResizeDirection="Auto" - ShowsPreview="True"/> + ShowsPreview="True" /> - + - - + + @@ -937,13 +912,13 @@ Grid.Row="2" Background="Transparent"> - - + + + Orientation="Horizontal" /> + Text="0 matches" /> @@ -1009,17 +984,17 @@ + Text="Regex: " /> + Header="Save Pattern" /> + Header="Explain Pattern" /> @@ -1037,12 +1012,12 @@ x:Name="CharDetailsButtonText" FontFamily="Cascadia Mono" FontSize="12" - Text="U+0000"/> + Text="U+0000" /> + Text="Ln 1, Col 0" /> - + - + @@ -1080,14 +1053,14 @@ x:Name="ProgressRing" Width="60" Height="60" - IsIndeterminate="True"/> + IsIndeterminate="True" /> + Text="Working..." /> diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 854029c6..83015ca0 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -913,6 +913,12 @@ private void GrabFrameMenuItem_Click(object sender, RoutedEventArgs e) CheckForGrabFrameOrLaunch(); } + private void ManageGrabTemplates_Click(object sender, RoutedEventArgs e) + { + PostGrabActionEditor editor = new(); + editor.Show(); + } + private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e) { // Source: StackOverflow, read on Sep. 10, 2021 diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs new file mode 100644 index 00000000..ef17d018 --- /dev/null +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -0,0 +1,1290 @@ +using Dapplo.Windows.User32; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Shapes; +using System.Windows.Threading; +using Text_Grab.Extensions; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Services; +using Text_Grab.Utilities; +using Bitmap = System.Drawing.Bitmap; +using Point = System.Windows.Point; + +namespace Text_Grab.Views; + +public partial class FullscreenGrab +{ + private enum SelectionInteractionMode + { + None = 0, + CreatingRectangle = 1, + CreatingFreeform = 2, + MovingSelection = 3, + ResizeLeft = 4, + ResizeTop = 5, + ResizeRight = 6, + ResizeBottom = 7, + ResizeTopLeft = 8, + ResizeTopRight = 9, + ResizeBottomLeft = 10, + ResizeBottomRight = 11, + } + + private const double MinimumSelectionSize = 6.0; + private const double AdjustHandleSize = 12.0; + private static readonly SolidColorBrush SelectionBorderBrush = new(System.Windows.Media.Color.FromArgb(255, 40, 118, 126)); + private static readonly SolidColorBrush WindowSelectionFillBrush = new(System.Windows.Media.Color.FromArgb(52, 255, 255, 255)); + private static readonly SolidColorBrush WindowSelectionLabelBackgroundBrush = new(System.Windows.Media.Color.FromArgb(224, 20, 27, 46)); + private static readonly SolidColorBrush FreeformFillBrush = new(System.Windows.Media.Color.FromArgb(36, 40, 118, 126)); + private readonly DispatcherTimer windowSelectionTimer = new() { Interval = TimeSpan.FromMilliseconds(100) }; + private readonly Path freeformSelectionPath = new() + { + Stroke = SelectionBorderBrush, + Fill = FreeformFillBrush, + StrokeThickness = 2, + Visibility = Visibility.Collapsed, + IsHitTestVisible = false + }; + + private readonly List freeformSelectionPoints = []; + private readonly List selectionHandleBorders = []; + private readonly Border selectionOutlineBorder = new(); + private readonly Grid windowSelectionHighlightContent = new() { ClipToBounds = false, IsHitTestVisible = false }; + private readonly Border windowSelectionInfoBadge = new(); + private readonly TextBlock windowSelectionAppNameText = new(); + private readonly TextBlock windowSelectionTitleText = new(); + private Point adjustmentStartPoint = new(); + private Rect selectionRectBeforeDrag = Rect.Empty; + private WindowSelectionCandidate? clickedWindowCandidate; + private WindowSelectionCandidate? hoveredWindowCandidate; + private SelectionInteractionMode selectionInteractionMode = SelectionInteractionMode.None; + private FsgSelectionStyle currentSelectionStyle = FsgSelectionStyle.Region; + private bool isAwaitingAdjustAfterCommit = false; + private bool suppressSelectionStyleComboBoxSelectionChanged = false; + + private FsgSelectionStyle CurrentSelectionStyle => currentSelectionStyle; + + private void InitializeSelectionStyles() + { + selectBorder.BorderThickness = new Thickness(2); + selectBorder.BorderBrush = SelectionBorderBrush; + selectBorder.Background = Brushes.Transparent; + selectBorder.CornerRadius = new CornerRadius(6); + selectBorder.IsHitTestVisible = false; + selectBorder.SnapsToDevicePixels = true; + + selectionOutlineBorder.BorderThickness = new Thickness(2); + selectionOutlineBorder.BorderBrush = SelectionBorderBrush; + selectionOutlineBorder.Background = Brushes.Transparent; + selectionOutlineBorder.CornerRadius = new CornerRadius(0); + selectionOutlineBorder.IsHitTestVisible = false; + selectionOutlineBorder.SnapsToDevicePixels = true; + + windowSelectionAppNameText.FontWeight = FontWeights.SemiBold; + windowSelectionAppNameText.Foreground = Brushes.White; + windowSelectionAppNameText.TextTrimming = TextTrimming.CharacterEllipsis; + + windowSelectionTitleText.Margin = new Thickness(0, 2, 0, 0); + windowSelectionTitleText.Foreground = Brushes.White; + windowSelectionTitleText.TextTrimming = TextTrimming.CharacterEllipsis; + windowSelectionTitleText.TextWrapping = TextWrapping.NoWrap; + + StackPanel windowSelectionTextStack = new() + { + MaxWidth = 360, + Orientation = Orientation.Vertical + }; + windowSelectionTextStack.Children.Add(windowSelectionAppNameText); + windowSelectionTextStack.Children.Add(windowSelectionTitleText); + + windowSelectionInfoBadge.Background = WindowSelectionLabelBackgroundBrush; + windowSelectionInfoBadge.CornerRadius = new CornerRadius(4); + windowSelectionInfoBadge.HorizontalAlignment = HorizontalAlignment.Left; + windowSelectionInfoBadge.Margin = new Thickness(8); + windowSelectionInfoBadge.Padding = new Thickness(8, 5, 8, 6); + windowSelectionInfoBadge.VerticalAlignment = VerticalAlignment.Top; + windowSelectionInfoBadge.Child = windowSelectionTextStack; + + windowSelectionHighlightContent.Children.Add(windowSelectionInfoBadge); + windowSelectionTimer.Tick += WindowSelectionTimer_Tick; + } + + private void ApplySelectionStyle(FsgSelectionStyle selectionStyle, bool persistToSettings = true) + { + currentSelectionStyle = selectionStyle; + SyncSelectionStyleComboBox(selectionStyle); + + RegionSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Region; + WindowSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Window; + FreeformSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.Freeform; + AdjustAfterSelectionMenuItem.IsChecked = selectionStyle == FsgSelectionStyle.AdjustAfter; + + if (persistToSettings) + { + DefaultSettings.FsgSelectionStyle = selectionStyle.ToString(); + DefaultSettings.Save(); + } + + ResetSelectionVisualState(); + RegionClickCanvas.Cursor = selectionStyle == FsgSelectionStyle.Window ? Cursors.Hand : Cursors.Cross; + UpdateTopToolbarVisibility(RegionClickCanvas.IsMouseOver || TopButtonsStackPanel.IsMouseOver); + + if (selectionStyle == FsgSelectionStyle.Window) + UpdateWindowSelectionHighlight(); + } + + internal static bool ShouldKeepTopToolbarVisible(FsgSelectionStyle selectionStyle, bool isAwaitingAdjustAfterCommit) + { + return selectionStyle == FsgSelectionStyle.Window || isAwaitingAdjustAfterCommit; + } + + internal static bool ShouldCommitWindowSelection(WindowSelectionCandidate? pressedWindowCandidate, WindowSelectionCandidate? releasedWindowCandidate) + { + return pressedWindowCandidate is not null + && releasedWindowCandidate is not null + && pressedWindowCandidate.Handle == releasedWindowCandidate.Handle; + } + + internal static bool ShouldUseOverlayCutout(FsgSelectionStyle selectionStyle) + { + return selectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter; + } + + internal static bool ShouldDrawSelectionOutline(FsgSelectionStyle selectionStyle) + { + return ShouldUseOverlayCutout(selectionStyle); + } + + private static Key GetSelectionStyleKey(FsgSelectionStyle selectionStyle) + { + return selectionStyle switch + { + FsgSelectionStyle.Region => Key.R, + FsgSelectionStyle.Window => Key.W, + FsgSelectionStyle.Freeform => Key.D, + FsgSelectionStyle.AdjustAfter => Key.A, + _ => Key.R, + }; + } + + private bool TryGetSelectionStyle(object? sender, out FsgSelectionStyle selectionStyle) + { + selectionStyle = FsgSelectionStyle.Region; + if (sender is not FrameworkElement element || element.Tag is not string tag) + return false; + + return Enum.TryParse(tag, true, out selectionStyle); + } + + private void SelectionStyleMenuItem_Click(object sender, RoutedEventArgs e) + { + if (TryGetSelectionStyle(sender, out FsgSelectionStyle selectionStyle)) + WindowUtilities.FullscreenKeyDown(GetSelectionStyleKey(selectionStyle)); + } + + private void SelectionStyleComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (suppressSelectionStyleComboBoxSelectionChanged + || SelectionStyleComboBox.SelectedItem is not ComboBoxItem selectedItem) + return; + + if (TryGetSelectionStyle(selectedItem, out FsgSelectionStyle selectionStyle)) + WindowUtilities.FullscreenKeyDown(GetSelectionStyleKey(selectionStyle)); + } + + private void SyncSelectionStyleComboBox(FsgSelectionStyle selectionStyle) + { + suppressSelectionStyleComboBoxSelectionChanged = true; + + try + { + foreach (ComboBoxItem comboBoxItem in SelectionStyleComboBox.Items.OfType()) + { + if (!TryGetSelectionStyle(comboBoxItem, out FsgSelectionStyle comboBoxItemStyle)) + continue; + + if (comboBoxItemStyle == selectionStyle) + { + SelectionStyleComboBox.SelectedItem = comboBoxItem; + return; + } + } + + SelectionStyleComboBox.SelectedIndex = -1; + } + finally + { + suppressSelectionStyleComboBoxSelectionChanged = false; + } + } + + private void WindowSelectionTimer_Tick(object? sender, EventArgs e) + { + if (CurrentSelectionStyle != FsgSelectionStyle.Window || selectionInteractionMode != SelectionInteractionMode.None) + { + if (hoveredWindowCandidate is not null) + { + hoveredWindowCandidate = null; + clickedWindowCandidate = null; + ClearSelectionBorderVisual(); + } + + return; + } + + UpdateWindowSelectionHighlight(); + } + + private void UpdateWindowSelectionHighlight() + { + ApplyWindowSelectionHighlight(GetWindowSelectionCandidateAtCurrentMousePosition()); + } + + private void UpdateTopToolbarVisibility(bool isPointerOverSelectionSurface) + { + if (ShouldKeepTopToolbarVisible(CurrentSelectionStyle, isAwaitingAdjustAfterCommit)) + { + TopButtonsStackPanel.Visibility = Visibility.Visible; + return; + } + + if (isSelecting) + { + TopButtonsStackPanel.Visibility = Visibility.Collapsed; + return; + } + + TopButtonsStackPanel.Visibility = isPointerOverSelectionSurface + ? Visibility.Visible + : Visibility.Collapsed; + } + + private WindowSelectionCandidate? GetWindowSelectionCandidateAtCurrentMousePosition() + { + if (!WindowUtilities.GetMousePosition(out Point mousePosition)) + return null; + + return WindowSelectionUtilities.FindWindowAtPoint( + WindowSelectionUtilities.GetCapturableWindows(GetExcludedWindowHandles()), + mousePosition); + } + + private void ApplyWindowSelectionHighlight(WindowSelectionCandidate? candidate) + { + hoveredWindowCandidate = candidate; + + if (candidate is null) + { + ClearSelectionBorderVisual(); + return; + } + + Rect windowBounds = GetWindowDeviceBounds(); + Rect intersection = Rect.Intersect(candidate.Bounds, windowBounds); + if (intersection == Rect.Empty) + { + ClearSelectionBorderVisual(); + return; + } + + Rect localRect = ConvertAbsoluteDeviceRectToLocal(intersection); + ApplySelectionRect(localRect, WindowSelectionFillBrush, updateTemplateOverlays: false); + UpdateWindowSelectionInfo(candidate, localRect); + } + + private IReadOnlyCollection GetExcludedWindowHandles() + { + List handles = []; + foreach (Window window in Application.Current.Windows) + { + IntPtr handle = new WindowInteropHelper(window).Handle; + if (handle != IntPtr.Zero) + handles.Add(handle); + } + + return handles; + } + + private double GetCurrentDeviceScale() + { + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + return presentationSource?.CompositionTarget is null + ? 1.0 + : presentationSource.CompositionTarget.TransformToDevice.M11; + } + + private Rect GetWindowDeviceBounds() + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + Point absolutePosition = this.GetAbsolutePosition(); + return new Rect(absolutePosition.X, absolutePosition.Y, ActualWidth * dpi.DpiScaleX, ActualHeight * dpi.DpiScaleY); + } + + private Rect ConvertAbsoluteDeviceRectToLocal(Rect absoluteRect) + { + PresentationSource? presentationSource = PresentationSource.FromVisual(this); + if (presentationSource?.CompositionTarget is null) + return Rect.Empty; + + Point absoluteWindowPosition = this.GetAbsolutePosition(); + Matrix fromDevice = presentationSource.CompositionTarget.TransformFromDevice; + + Point topLeft = fromDevice.Transform(new Point( + absoluteRect.Left - absoluteWindowPosition.X, + absoluteRect.Top - absoluteWindowPosition.Y)); + + Point bottomRight = fromDevice.Transform(new Point( + absoluteRect.Right - absoluteWindowPosition.X, + absoluteRect.Bottom - absoluteWindowPosition.Y)); + + return new Rect(topLeft, bottomRight); + } + + private Rect GetCurrentSelectionRect() + { + double left = Canvas.GetLeft(selectBorder); + double top = Canvas.GetTop(selectBorder); + + if (double.IsNaN(left) || double.IsNaN(top)) + return Rect.Empty; + + return new Rect(left, top, selectBorder.Width, selectBorder.Height); + } + + private void ApplySelectionRect( + Rect rect, + Brush? selectionFillBrush = null, + bool updateTemplateOverlays = true, + bool? useOverlayCutout = null) + { + EnsureSelectionBorderVisible(); + bool shouldUseCutout = useOverlayCutout ?? ShouldUseOverlayCutout(CurrentSelectionStyle); + + selectBorder.Width = Math.Max(0, rect.Width); + selectBorder.Height = Math.Max(0, rect.Height); + selectBorder.Background = selectionFillBrush ?? Brushes.Transparent; + Canvas.SetLeft(selectBorder, rect.Left); + Canvas.SetTop(selectBorder, rect.Top); + clippingGeometry.Rect = shouldUseCutout + ? rect + : Rect.Empty; + UpdateSelectionOutline(rect, shouldUseCutout && ShouldDrawSelectionOutline(CurrentSelectionStyle)); + + if (updateTemplateOverlays) + UpdateTemplateRegionOverlays(rect.Left, rect.Top, rect.Width, rect.Height); + } + + private void UpdateWindowSelectionInfo(WindowSelectionCandidate candidate, Rect localRect) + { + windowSelectionAppNameText.Text = candidate.DisplayAppName; + windowSelectionTitleText.Text = candidate.DisplayTitle; + windowSelectionInfoBadge.MaxWidth = Math.Max(72, localRect.Width - 16); + selectBorder.Child = windowSelectionHighlightContent; + } + + private void EnsureSelectionBorderVisible() + { + if (!RegionClickCanvas.Children.Contains(selectBorder)) + _ = RegionClickCanvas.Children.Add(selectBorder); + } + + private void EnsureSelectionOutlineVisible() + { + if (!SelectionOutlineHost.Children.Contains(selectionOutlineBorder)) + _ = SelectionOutlineHost.Children.Add(selectionOutlineBorder); + } + + private void ClearSelectionBorderVisual() + { + if (RegionClickCanvas.Children.Contains(selectBorder)) + RegionClickCanvas.Children.Remove(selectBorder); + + ClearSelectionOutline(); + selectBorder.Background = Brushes.Transparent; + selectBorder.Child = null; + clippingGeometry.Rect = new Rect(new Point(0, 0), new Size(0, 0)); + TemplateOverlayHost.Children.Clear(); + templateOverlayCanvas.Children.Clear(); + } + + private void UpdateSelectionOutline(Rect rect, bool shouldShowOutline) + { + if (!shouldShowOutline || rect.Width <= 0 || rect.Height <= 0) + { + ClearSelectionOutline(); + return; + } + + EnsureSelectionOutlineVisible(); + 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(); + + if (TryGetBitmapCropRectForSelection( + selectionRect, + transformToDevice, + BackgroundImage.RenderTransform, + backgroundBitmap.PixelWidth, + backgroundBitmap.PixelHeight, + out Int32Rect cropRect)) + { + CroppedBitmap croppedBitmap = new(backgroundBitmap, cropRect); + croppedBitmap.Freeze(); + return croppedBitmap; + } + } + + using Bitmap capturedBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle(), cacheResult: false); + return ImageMethods.BitmapToImageSource(capturedBitmap); + } + + private 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 5011adc1..35902a56 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml +++ b/Text-Grab/Views/FullscreenGrab.xaml @@ -21,22 +21,12 @@ mc:Ignorable="d"> - - - - + + + + + + @@ -85,6 +75,37 @@ Header="Freeze" IsCheckable="True" IsChecked="True" /> + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +