From da044ecba523cb5c84fda4a98832c1df245494e4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:24:49 -0600 Subject: [PATCH 01/35] Add template-based capture system with region support Introduce GrabTemplate, TemplateRegion, and PostGrabContext classes to enable reusable OCR templates for fixed-layout documents. Templates define named, numbered regions and an output format for assembling OCR results. PostGrabContext encapsulates all data from a grab action, supporting both simple and template-based post-processing. This lays the foundation for structured data extraction and advanced OCR workflows. --- Text-Grab/Models/GrabTemplate.cs | 96 +++++++++++++++++++++++++++++ Text-Grab/Models/PostGrabContext.cs | 36 +++++++++++ Text-Grab/Models/TemplateRegion.cs | 66 ++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 Text-Grab/Models/GrabTemplate.cs create mode 100644 Text-Grab/Models/PostGrabContext.cs create mode 100644 Text-Grab/Models/TemplateRegion.cs diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs new file mode 100644 index 00000000..e21ed33a --- /dev/null +++ b/Text-Grab/Models/GrabTemplate.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Models; + +/// +/// Defines a reusable capture template: a set of numbered named regions on a fixed-layout +/// document (e.g. a business card or invoice) and an output format string that assembles +/// the OCR results from those regions into final text. +/// +/// Output template syntax: +/// {N} — replaced by the OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text from region N +/// {N:upper} — uppercased OCR text from region N +/// {N:lower} — lowercased OCR text from region N +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public class GrabTemplate +{ + /// Unique persistent identifier. + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// Human-readable name shown in menus and list boxes. + public string Name { get; set; } = string.Empty; + + /// Optional description shown as a tooltip. + public string Description { get; set; } = string.Empty; + + /// Date this template was created. + public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now; + + /// Date this template was last used for capture. + public DateTimeOffset? LastUsedDate { get; set; } + + /// + /// Optional path to a reference image the designer shows as the canvas background. + /// May be empty if no reference image was loaded. + /// + public string SourceImagePath { get; set; } = string.Empty; + + /// + /// Width of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageWidth { get; set; } = 800; + + /// + /// Height of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageHeight { get; set; } = 600; + + /// + /// The capture regions, each with a 1-based . + /// + public List Regions { get; set; } = []; + + /// + /// Output format string. Use {N}, {N:trim}, {N:upper}, {N:lower}, \n, \t. + /// Example: "Name: {1}\nEmail: {2}\nPhone: {3}" + /// + public string OutputTemplate { get; set; } = string.Empty; + + public GrabTemplate() { } + + public GrabTemplate(string name) + { + Name = name; + } + + /// + /// Returns whether this template has the minimum required data to be executed. + /// + public bool IsValid => + !string.IsNullOrWhiteSpace(Name) + && Regions.Count > 0 + && !string.IsNullOrWhiteSpace(OutputTemplate); + + /// + /// Returns all region numbers referenced in the output template. + /// + public IEnumerable GetReferencedRegionNumbers() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches( + OutputTemplate, + @"\{(\d+)(?::[a-z]+)?\}"); + + foreach (System.Text.RegularExpressions.Match match in matches) + { + if (int.TryParse(match.Groups[1].Value, out int number)) + yield return number; + } + } +} diff --git a/Text-Grab/Models/PostGrabContext.cs b/Text-Grab/Models/PostGrabContext.cs new file mode 100644 index 00000000..63707a09 --- /dev/null +++ b/Text-Grab/Models/PostGrabContext.cs @@ -0,0 +1,36 @@ +using System.Windows; +using System.Windows.Media.Imaging; +using Text_Grab.Interfaces; + +namespace Text_Grab.Models; + +/// +/// Carries all context data produced by a grab action and passed through +/// the post-grab action pipeline. This allows actions that need only the +/// OCR text to ignore the extra fields, while template actions can use +/// the capture region and DPI to re-run sub-region OCR. +/// +public record PostGrabContext( + /// The OCR text extracted from the full capture region. + string Text, + + /// + /// The screen rectangle (in physical pixels) that was captured. + /// Used by template execution to derive sub-region rectangles. + /// + Rect CaptureRegion, + + /// The DPI scale factor at capture time. + double DpiScale, + + /// Optional in-memory copy of the captured image. + BitmapSource? CapturedImage, + + /// The OCR language used for the capture. Null means use the app default. + ILanguage? Language = null +) +{ + /// Convenience factory for non-template actions that only need text. + public static PostGrabContext TextOnly(string text) => + new(text, Rect.Empty, 1.0, null, null); +} diff --git a/Text-Grab/Models/TemplateRegion.cs b/Text-Grab/Models/TemplateRegion.cs new file mode 100644 index 00000000..f2a16117 --- /dev/null +++ b/Text-Grab/Models/TemplateRegion.cs @@ -0,0 +1,66 @@ +using System.Windows; + +namespace Text_Grab.Models; + +/// +/// Defines a named, numbered capture region within a GrabTemplate. +/// Positions are stored as ratios (0.0–1.0) of the reference image dimensions +/// so the template scales to any screen size or DPI. +/// +public class TemplateRegion +{ + /// + /// 1-based number shown on the region border and used in the output template as {RegionNumber}. + /// + public int RegionNumber { get; set; } = 1; + + /// + /// Optional friendly label for this region (e.g. "Name", "Email"). + /// Displayed on the border in the designer. + /// + public string Label { get; set; } = string.Empty; + + /// + /// Position and size as ratios of the reference image dimensions (each value 0.0–1.0). + /// X, Y, Width, Height correspond to left, top, width, height proportions. + /// + public double RatioLeft { get; set; } = 0; + public double RatioTop { get; set; } = 0; + public double RatioWidth { get; set; } = 0; + public double RatioHeight { get; set; } = 0; + + /// + /// Optional default/fallback value used when OCR returns empty for this region. + /// + public string DefaultValue { get; set; } = string.Empty; + + public TemplateRegion() { } + + /// + /// Returns the absolute pixel Rect for this region given the canvas/image dimensions. + /// + public Rect ToAbsoluteRect(double imageWidth, double imageHeight) + { + return new Rect( + x: RatioLeft * imageWidth, + y: RatioTop * imageHeight, + width: RatioWidth * imageWidth, + height: RatioHeight * imageHeight); + } + + /// + /// Sets ratio values from an absolute Rect and canvas dimensions. + /// + public static TemplateRegion FromAbsoluteRect(Rect rect, double imageWidth, double imageHeight, int regionNumber, string label = "") + { + return new TemplateRegion + { + RegionNumber = regionNumber, + Label = label, + RatioLeft = imageWidth > 0 ? rect.X / imageWidth : 0, + RatioTop = imageHeight > 0 ? rect.Y / imageHeight : 0, + RatioWidth = imageWidth > 0 ? rect.Width / imageWidth : 0, + RatioHeight = imageHeight > 0 ? rect.Height / imageHeight : 0, + }; + } +} From 2dc268c5ea6db7e26528e955867beea2f03701b1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:25:54 -0600 Subject: [PATCH 02/35] Add file-based GrabTemplate storage and executor Replaces settings-based template storage with a robust file-based system (GrabTemplateManager) supporting CRUD, migration, and error handling. Implements GrabTemplateExecutor for OCR region extraction and flexible output template formatting with modifiers and escapes. Includes comprehensive unit tests for both manager and executor. --- Tests/GrabTemplateExecutorTests.cs | 146 +++++++++++++ Tests/GrabTemplateManagerTests.cs | 230 ++++++++++++++++++++ Text-Grab/Utilities/GrabTemplateExecutor.cs | 186 ++++++++++++++++ Text-Grab/Utilities/GrabTemplateManager.cs | 227 +++++++++++++++++++ 4 files changed, 789 insertions(+) create mode 100644 Tests/GrabTemplateExecutorTests.cs create mode 100644 Tests/GrabTemplateManagerTests.cs create mode 100644 Text-Grab/Utilities/GrabTemplateExecutor.cs create mode 100644 Text-Grab/Utilities/GrabTemplateManager.cs diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs new file mode 100644 index 00000000..94f00754 --- /dev/null +++ b/Tests/GrabTemplateExecutorTests.cs @@ -0,0 +1,146 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateExecutorTests +{ + // ── ApplyOutputTemplate – basic substitution ────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_SingleRegion_SubstitutesCorrectly() + { + Dictionary regions = new() { [1] = "Alice" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("Name: {1}", regions); + Assert.Equal("Name: Alice", result); + } + + [Fact] + public void ApplyOutputTemplate_MultipleRegions_SubstitutesAll() + { + Dictionary regions = new() + { + [1] = "Alice", + [2] = "alice@example.com" + }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} <{2}>", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_MissingRegion_ReplacesWithEmpty() + { + Dictionary regions = new() { [1] = "Alice" }; + // Region 2 not present + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} {2}", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_EmptyTemplate_ReturnsEmpty() + { + Dictionary regions = new() { [1] = "value" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate(string.Empty, regions); + Assert.Equal(string.Empty, result); + } + + // ── Modifiers ────────────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_TrimModifier_TrimsWhitespace() + { + Dictionary regions = new() { [1] = " hello " }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:trim}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UpperModifier_ConvertsToUpper() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:upper}", regions); + Assert.Equal("HELLO", result); + } + + [Fact] + public void ApplyOutputTemplate_LowerModifier_ConvertsToLower() + { + Dictionary regions = new() { [1] = "HELLO" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:lower}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UnknownModifier_LeavesTextAsIs() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:unknown}", regions); + Assert.Equal("hello", result); + } + + // ── Escape sequences ────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_NewlineEscape_InsertsNewline() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\n{2}", regions); + Assert.Equal("A\nB", result); + } + + [Fact] + public void ApplyOutputTemplate_TabEscape_InsertsTab() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\t{2}", regions); + Assert.Equal("A\tB", result); + } + + [Fact] + public void ApplyOutputTemplate_LiteralBraceEscape_PreservesBrace() + { + Dictionary regions = new() { [1] = "value" }; + // \{ in template produces literal {, then {1} → value, then literal text } + string result = GrabTemplateExecutor.ApplyOutputTemplate("\\{{1}}", regions); + Assert.Equal("{value}", result); + } + + [Fact] + public void ApplyOutputTemplate_DoubleBackslash_PreservesBackslash() + { + Dictionary regions = new() { [1] = "A" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\\\{1}", regions); + Assert.Equal(@"A\A", result); + } + + // ── ValidateOutputTemplate ──────────────────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidTemplate_ReturnsNoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{1} {2}", [1, 2]); + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_OutOfRangeRegion_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{3}", [1, 2]); + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains('3')); + } + + [Fact] + public void ValidateOutputTemplate_EmptyTemplate_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate(string.Empty, [1]); + Assert.NotEmpty(issues); + } + + [Fact] + public void ValidateOutputTemplate_NoRegionsReferenced_ReturnsIssue() + { + // Template has no {N} references + List issues = GrabTemplateExecutor.ValidateOutputTemplate("static text", [1, 2]); + Assert.NotEmpty(issues); + } +} diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs new file mode 100644 index 00000000..e99d92fd --- /dev/null +++ b/Tests/GrabTemplateManagerTests.cs @@ -0,0 +1,230 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateManagerTests : IDisposable +{ + // Use a temp file so tests don't pollute each other or real user data + private readonly string _tempFilePath; + + public GrabTemplateManagerTests() + { + _tempFilePath = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Test_{Guid.NewGuid()}.json"); + GrabTemplateManager.TestFilePath = _tempFilePath; + } + + public void Dispose() + { + GrabTemplateManager.TestFilePath = null; + if (File.Exists(_tempFilePath)) + File.Delete(_tempFilePath); + } + + // ── GetAllTemplates ─────────────────────────────────────────────────────── + + [Fact] + public void GetAllTemplates_WhenEmpty_ReturnsEmptyList() + { + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void GetAllTemplates_AfterAddingTemplate_ReturnsSavedTemplate() + { + GrabTemplate template = CreateSampleTemplate("Invoice"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Invoice", templates[0].Name); + } + + // ── GetTemplateById ─────────────────────────────────────────────────────── + + [Fact] + public void GetTemplateById_ExistingId_ReturnsTemplate() + { + GrabTemplate template = CreateSampleTemplate("Business Card"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplate? found = GrabTemplateManager.GetTemplateById(template.Id); + + Assert.NotNull(found); + Assert.Equal(template.Id, found.Id); + Assert.Equal("Business Card", found.Name); + } + + [Fact] + public void GetTemplateById_NonExistentId_ReturnsNull() + { + GrabTemplate? found = GrabTemplateManager.GetTemplateById("non-existent-id"); + Assert.Null(found); + } + + // ── AddOrUpdateTemplate ─────────────────────────────────────────────────── + + [Fact] + public void AddOrUpdateTemplate_AddNew_IncrementsCount() + { + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T1")); + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T2")); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Equal(2, templates.Count); + } + + [Fact] + public void AddOrUpdateTemplate_UpdateExisting_ReplacesByIdNotDuplicate() + { + GrabTemplate original = CreateSampleTemplate("Original Name"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + original.Name = "Updated Name"; + GrabTemplateManager.AddOrUpdateTemplate(original); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Updated Name", templates[0].Name); + } + + // ── DeleteTemplate ──────────────────────────────────────────────────────── + + [Fact] + public void DeleteTemplate_ExistingId_RemovesTemplate() + { + GrabTemplate template = CreateSampleTemplate("ToDelete"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplateManager.DeleteTemplate(template.Id); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void DeleteTemplate_NonExistentId_DoesNotThrow() + { + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("Keeper")); + GrabTemplateManager.DeleteTemplate("does-not-exist"); + + // Should still have the original template + Assert.Single(GrabTemplateManager.GetAllTemplates()); + } + + // ── DuplicateTemplate ───────────────────────────────────────────────────── + + [Fact] + public void DuplicateTemplate_ValidId_CreatesNewTemplateWithCopyPrefix() + { + GrabTemplate original = CreateSampleTemplate("My Template"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate(original.Id); + + Assert.NotNull(copy); + Assert.NotEqual(original.Id, copy.Id); + Assert.Contains("(copy)", copy.Name); + Assert.Equal(2, GrabTemplateManager.GetAllTemplates().Count); + } + + [Fact] + public void DuplicateTemplate_NonExistentId_ReturnsNull() + { + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate("not-there"); + Assert.Null(copy); + } + + // ── CreateButtonInfoForTemplate ─────────────────────────────────────────── + + [Fact] + public void CreateButtonInfoForTemplate_SetsTemplateId() + { + GrabTemplate template = CreateSampleTemplate("Card"); + + Text_Grab.Models.ButtonInfo button = GrabTemplateManager.CreateButtonInfoForTemplate(template); + + Assert.Equal(template.Id, button.TemplateId); + Assert.Equal("ApplyTemplate_Click", button.ClickEvent); + Assert.Equal(template.Name, button.ButtonText); + } + + // ── Corrupt JSON robustness ─────────────────────────────────────────────── + + [Fact] + public void GetAllTemplates_CorruptJson_ReturnsEmptyList() + { + File.WriteAllText(_tempFilePath, "{ this is not valid json }}}"); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + // ── GrabTemplate model ──────────────────────────────────────────────────── + + [Fact] + public void GrabTemplate_IsValid_TrueWhenNameRegionsAndOutputTemplateSet() + { + GrabTemplate template = CreateSampleTemplate("Valid"); + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenNameEmpty() + { + GrabTemplate template = CreateSampleTemplate(string.Empty); + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenNoRegions() + { + GrabTemplate template = CreateSampleTemplate("No Regions"); + template.Regions.Clear(); + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_GetReferencedRegionNumbers_ParsesPlaceholders() + { + GrabTemplate template = CreateSampleTemplate("Multi"); + template.OutputTemplate = "{1} {2} {1:upper}"; + + HashSet referenced = template.GetReferencedRegionNumbers().ToHashSet(); + + Assert.Contains(1, referenced); + Assert.Contains(2, referenced); + Assert.Equal(2, referenced.Count); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + private static GrabTemplate CreateSampleTemplate(string name) + { + return new GrabTemplate + { + Id = Guid.NewGuid().ToString(), + Name = name, + Description = "Test template", + OutputTemplate = "{1}", + ReferenceImageWidth = 800, + ReferenceImageHeight = 600, + Regions = + [ + new Text_Grab.Models.TemplateRegion + { + RegionNumber = 1, + Label = "Field 1", + RatioLeft = 0.1, + RatioTop = 0.1, + RatioWidth = 0.5, + RatioHeight = 0.1, + } + ] + }; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs new file mode 100644 index 00000000..e3d3add4 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Executes a against a captured screen region: +/// OCRs each sub-region, then formats the results using the template's +/// string. +/// +/// Output template syntax: +/// {N} — OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text +/// {N:upper} — uppercased OCR text +/// {N:lower} — lowercased OCR text +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public static class GrabTemplateExecutor +{ + // Matches {N} or {N:modifier} where N is one or more digits + private static readonly Regex PlaceholderRegex = + new(@"\{(\d+)(?::([a-z]+))?\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Executes the given template using as the + /// coordinate space. Each template region is mapped to a sub-rectangle of + /// , OCR'd, then assembled via the output template. + /// + /// The template to execute. + /// + /// The screen rectangle (in WPF units, pre-DPI-scaling applied by caller) + /// that bounds the user's selection. Template region ratios are applied to + /// this rectangle's width/height. + /// + /// The OCR language to use. Pass null to use the app default. + public static async Task ExecuteTemplateAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + // 1. OCR each region + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + Dictionary regionResults = await OcrAllRegionsAsync( + template, captureRegion, resolvedLanguage); + + // 2. Apply output template + return ApplyOutputTemplate(template.OutputTemplate, regionResults); + } + + /// + /// Applies the output template string with the provided region text values. + /// Useful for unit testing the string processing independently of OCR. + /// + public static string ApplyOutputTemplate( + string outputTemplate, + IReadOnlyDictionary regionResults) + { + if (string.IsNullOrEmpty(outputTemplate)) + return string.Empty; + + // Replace escape sequences first + string processed = outputTemplate + .Replace(@"\\", "\x00BACKSLASH\x00") // protect real backslashes + .Replace(@"\n", "\n") + .Replace(@"\t", "\t") + .Replace(@"\{", "\x00LBRACE\x00") // protect literal braces + .Replace("\x00BACKSLASH\x00", @"\"); + + // Replace {N} / {N:modifier} placeholders + string result = PlaceholderRegex.Replace(processed, match => + { + if (!int.TryParse(match.Groups[1].Value, out int regionNumber)) + return match.Value; // leave unknown placeholders as-is + + regionResults.TryGetValue(regionNumber, out string? text); + text ??= string.Empty; + + string modifier = match.Groups[2].Success + ? match.Groups[2].Value.ToLowerInvariant() + : string.Empty; + + return modifier switch + { + "trim" => text.Trim(), + "upper" => text.ToUpper(), + "lower" => text.ToLower(), + _ => text + }; + }); + + // Restore protected literal characters + result = result.Replace("\x00LBRACE\x00", "{"); + + return result; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static async Task> OcrAllRegionsAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage language) + { + Dictionary results = []; + + foreach (TemplateRegion region in template.Regions) + { + // Compute absolute screen rect from capture region + region ratios + Rect absoluteRegionRect = new( + x: captureRegion.X + region.RatioLeft * captureRegion.Width, + y: captureRegion.Y + region.RatioTop * captureRegion.Height, + width: region.RatioWidth * captureRegion.Width, + height: region.RatioHeight * captureRegion.Height); + + if (absoluteRegionRect.Width <= 0 || absoluteRegionRect.Height <= 0) + { + results[region.RegionNumber] = region.DefaultValue; + continue; + } + + try + { + // GetTextFromAbsoluteRectAsync uses absolute screen coordinates + string regionText = await OcrUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); + // Use default value when OCR returns nothing + results[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + results[region.RegionNumber] = region.DefaultValue; + } + } + + return results; + } + + /// + /// Validates the output template syntax and returns a list of issues. + /// Returns an empty list when valid. + /// + public static List ValidateOutputTemplate(string outputTemplate, IEnumerable availableRegionNumbers) + { + List issues = []; + HashSet available = [.. availableRegionNumbers]; + + MatchCollection matches = PlaceholderRegex.Matches(outputTemplate); + HashSet referenced = []; + + foreach (Match match in matches) + { + if (!int.TryParse(match.Groups[1].Value, out int num)) + { + issues.Add($"Invalid placeholder: {match.Value}"); + continue; + } + + if (!available.Contains(num)) + issues.Add($"Placeholder {{{{num}}}} references region {num} which does not exist."); + + referenced.Add(num); + } + + foreach (int availableNum in available) + { + if (!referenced.Contains(availableNum)) + issues.Add($"Region {availableNum} is defined but not used in the output template."); + } + + return issues; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs new file mode 100644 index 00000000..6f01f9f2 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Wpf.Ui.Controls; + +namespace Text_Grab.Utilities; + +/// +/// Provides CRUD operations for objects, persisted as +/// a JSON file on disk. Previously stored in application settings, but moved to +/// file-based storage because ApplicationDataContainer has an 8 KB per-value limit. +/// Pattern follows . +/// +public static class GrabTemplateManager +{ + private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private const string TemplatesFileName = "GrabTemplates.json"; + private static bool _migrated; + + // Allow tests to override the file path + internal static string? TestFilePath { get; set; } + + // ── File path ───────────────────────────────────────────────────────────── + + private static string GetTemplatesFilePath() + { + if (TestFilePath is not null) + return TestFilePath; + + if (AppUtilities.IsPackaged()) + { + string localFolder = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + return Path.Combine(localFolder, TemplatesFileName); + } + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", TemplatesFileName); + } + + // ── Migration from settings ─────────────────────────────────────────────── + + private static void MigrateFromSettingsIfNeeded() + { + if (_migrated) + return; + + _migrated = true; + + string filePath = GetTemplatesFilePath(); + if (File.Exists(filePath)) + return; + + try + { + string settingsJson = DefaultSettings.GrabTemplatesJSON; + if (string.IsNullOrWhiteSpace(settingsJson)) + return; + + // Validate the JSON before migrating + List? templates = JsonSerializer.Deserialize>(settingsJson, JsonOptions); + if (templates is null || templates.Count == 0) + return; + + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, settingsJson); + + // Clear the setting so it no longer overflows the container + DefaultSettings.GrabTemplatesJSON = string.Empty; + DefaultSettings.Save(); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to migrate GrabTemplates from settings to file: {ex.Message}"); + } + } + + // ── Read ────────────────────────────────────────────────────────────────── + + /// Returns all saved templates, or an empty list if none exist. + public static List GetAllTemplates() + { + MigrateFromSettingsIfNeeded(); + + string filePath = GetTemplatesFilePath(); + + if (!File.Exists(filePath)) + return []; + + try + { + string json = File.ReadAllText(filePath); + + if (string.IsNullOrWhiteSpace(json)) + return []; + + List? templates = JsonSerializer.Deserialize>(json, JsonOptions); + if (templates is not null) + return templates; + } + catch (JsonException) + { + // Return empty list if deserialization fails — never crash + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read GrabTemplates file: {ex.Message}"); + } + + return []; + } + + /// Returns the template with the given ID, or null. + public static GrabTemplate? GetTemplateById(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return GetAllTemplates().FirstOrDefault(t => t.Id == id); + } + + // ── Write ───────────────────────────────────────────────────────────────── + + /// Replaces the entire saved template list. + public static void SaveTemplates(List templates) + { + string json = JsonSerializer.Serialize(templates, JsonOptions); + string filePath = GetTemplatesFilePath(); + + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, json); + } + + /// Adds a new template (or updates an existing one with the same ID). + public static void AddOrUpdateTemplate(GrabTemplate template) + { + List templates = GetAllTemplates(); + int existing = templates.FindIndex(t => t.Id == template.Id); + if (existing >= 0) + templates[existing] = template; + else + templates.Add(template); + + SaveTemplates(templates); + } + + /// Removes the template with the given ID. No-op if not found. + public static void DeleteTemplate(string id) + { + List templates = GetAllTemplates(); + int removed = templates.RemoveAll(t => t.Id == id); + if (removed > 0) + SaveTemplates(templates); + } + + /// Creates and saves a shallow copy of an existing template with a new ID and name. + public static GrabTemplate? DuplicateTemplate(string id) + { + GrabTemplate? original = GetTemplateById(id); + if (original is null) + return null; + + string json = JsonSerializer.Serialize(original, JsonOptions); + GrabTemplate? copy = JsonSerializer.Deserialize(json, JsonOptions); + if (copy is null) + return null; + + copy.Id = Guid.NewGuid().ToString(); + copy.Name = $"{original.Name} (copy)"; + copy.CreatedDate = DateTimeOffset.Now; + copy.LastUsedDate = null; + + AddOrUpdateTemplate(copy); + return copy; + } + + // ── ButtonInfo bridge ───────────────────────────────────────────────────── + + /// + /// Generates a post-grab action that executes the given template. + /// + public static ButtonInfo CreateButtonInfoForTemplate(GrabTemplate template) + { + return new ButtonInfo( + buttonText: template.Name, + clickEvent: "ApplyTemplate_Click", + symbolIcon: SymbolRegular.DocumentTableSearch24, + defaultCheckState: DefaultCheckState.Off) + { + TemplateId = template.Id, + IsRelevantForFullscreenGrab = true, + IsRelevantForEditWindow = false, + OrderNumber = 7.0, + }; + } + + /// + /// Updates a 's LastUsedDate and persists it. + /// + public static void RecordUsage(string templateId) + { + List templates = GetAllTemplates(); + GrabTemplate? template = templates.FirstOrDefault(t => t.Id == templateId); + if (template is null) + return; + + template.LastUsedDate = DateTimeOffset.Now; + SaveTemplates(templates); + } +} From ae3aa3da7a618a6ce0bf8765e282a96228aad3f9 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:28:57 -0600 Subject: [PATCH 03/35] Add Grab Template support as post-grab actions - Add TemplateId property to ButtonInfo for template actions - Include Grab Templates in available post-grab actions - Add ApplyTemplate_Click action handling in PostGrabActionManager - Overload ExecutePostGrabAction to accept PostGrabContext - Improve BarcodeUtilities with null checks and error handling - Update comments and summaries for clarity and maintainability --- Text-Grab/Models/ButtonInfo.cs | 6 +++ Text-Grab/Utilities/BarcodeUtilities.cs | 17 +++++++- Text-Grab/Utilities/PostGrabActionManager.cs | 44 ++++++++++++++++++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index a21b39e9..ce3706c8 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -30,6 +30,12 @@ public class ButtonInfo public bool IsRelevantForEditWindow { get; set; } = true; // Default to true for backward compatibility public DefaultCheckState DefaultCheckState { get; set; } = DefaultCheckState.Off; + /// + /// When this ButtonInfo represents a Grab Template action, this holds the template's + /// unique ID so the executor can look it up. Empty for non-template actions. + /// + public string TemplateId { get; set; } = string.Empty; + public ButtonInfo() { diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs index 40385f79..45cd0928 100644 --- a/Text-Grab/Utilities/BarcodeUtilities.cs +++ b/Text-Grab/Utilities/BarcodeUtilities.cs @@ -14,13 +14,26 @@ public static class BarcodeUtilities public static OcrOutput TryToReadBarcodes(Bitmap bitmap) { + if (bitmap is null || bitmap.Width <= 0 || bitmap.Height <= 0) + return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty }; + BarcodeReader barcodeReader = new() { AutoRotate = true, Options = new ZXing.Common.DecodingOptions { TryHarder = true } }; - ZXing.Result result = barcodeReader.Decode(bitmap); + Result? result = null; + + try + { + result = barcodeReader.Decode(bitmap); + + } + catch (System.Exception) + { + return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty }; + } string resultString = string.Empty; if (result is not null) @@ -81,4 +94,4 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor return svg; } -} \ No newline at end of file +} diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index c5242595..00693031 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -4,6 +4,8 @@ using System.Net; using System.Text.Json; using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; using Text_Grab.Models; using Text_Grab.Properties; using Wpf.Ui.Controls; @@ -15,7 +17,8 @@ public class PostGrabActionManager private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; /// - /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance + /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance. + /// Also includes a ButtonInfo for each saved Grab Template. /// public static List GetAvailablePostGrabActions() { @@ -24,9 +27,19 @@ public static List GetAvailablePostGrabActions() // Add other relevant actions from AllButtons that are marked as relevant for FullscreenGrab IEnumerable relevantActions = ButtonInfo.AllButtons .Where(button => button.IsRelevantForFullscreenGrab && !allPostGrabActions.Any(b => b.ButtonText == button.ButtonText)); - + allPostGrabActions.AddRange(relevantActions); + // Add a ButtonInfo for each saved Grab Template + List templates = GrabTemplateManager.GetAllTemplates(); + foreach (GrabTemplate template in templates) + { + ButtonInfo templateAction = GrabTemplateManager.CreateButtonInfoForTemplate(template); + // Avoid duplicates if it's somehow already in the list + if (!allPostGrabActions.Any(b => b.TemplateId == template.Id)) + allPostGrabActions.Add(templateAction); + } + return [.. allPostGrabActions.OrderBy(b => b.OrderNumber)]; } @@ -142,7 +155,7 @@ public static bool GetCheckState(ButtonInfo action) try { Dictionary? checkStates = JsonSerializer.Deserialize>(statesJson); - if (checkStates is not null + if (checkStates is not null && checkStates.TryGetValue(action.ButtonText, out bool storedState) && action.DefaultCheckState == DefaultCheckState.LastUsed) { @@ -191,6 +204,16 @@ public static void SaveCheckState(ButtonInfo action, bool isChecked) /// public static async Task ExecutePostGrabAction(ButtonInfo action, string text) { + return await ExecutePostGrabAction(action, PostGrabContext.TextOnly(text)); + } + + /// + /// Executes a post-grab action using the full . + /// Template actions use the context's CaptureRegion and DpiScale to re-OCR sub-regions. + /// + public static async Task ExecutePostGrabAction(ButtonInfo action, PostGrabContext context) + { + string text = context.Text; string result = text; switch (action.ClickEvent) @@ -236,6 +259,21 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, string } break; + case "ApplyTemplate_Click": + if (!string.IsNullOrWhiteSpace(action.TemplateId) + && context.CaptureRegion != Rect.Empty) + { + GrabTemplate? template = GrabTemplateManager.GetTemplateById(action.TemplateId); + if (template is not null) + { + result = await GrabTemplateExecutor.ExecuteTemplateAsync( + template, context.CaptureRegion, context.Language); + GrabTemplateManager.RecordUsage(action.TemplateId); + } + } + // If no capture region (e.g. called from EditTextWindow), skip template + break; + default: // Unknown action - return text unchanged break; From 376b0faefa24f41a4d85981e956c848f8ec960e6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:30:03 -0600 Subject: [PATCH 04/35] Add new user settings for templates and context menu options Added three user-scoped settings: GrabTemplatesJSON (string), AddToContextMenu (bool), and RegisterOpenWith (bool). Updated App.config, Settings.settings, and Settings.Designer.cs to support these options. Also updated the code generation version in Settings.Designer.cs. --- Text-Grab/App.config | 9 +++++++++ Text-Grab/Properties/Settings.Designer.cs | 16 ++++++++++++++-- Text-Grab/Properties/Settings.settings | 3 +++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index ab9b17f7..4675d28e 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -211,6 +211,15 @@ False + + + + + False + + + False + \ No newline at end of file diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index d223017a..6c4d60c4 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace Text_Grab.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.14.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.4.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -839,6 +839,18 @@ public bool PostGrabStayOpen { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string GrabTemplatesJSON { + get { + return ((string)(this["GrabTemplatesJSON"])); + } + set { + this["GrabTemplatesJSON"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] @@ -850,7 +862,7 @@ public bool AddToContextMenu { this["AddToContextMenu"] = value; } } - + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 63fb72c1..ac20c5b4 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -206,6 +206,9 @@ False + + + False From 6fb2a4b82cbc94abb49ebd04d7ddacbafab08eba Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:30:21 -0600 Subject: [PATCH 05/35] Handle ApplicationDataContainer size limit exception Add specific handling for COMException when saving settings exceeds the ApplicationDataContainer 8 KB size limit. Log a clear debug message suggesting large data be stored in a file. Also, clarify the generic exception debug message. --- Text-Grab/Services/SettingsService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index fd1cddd8..73bc7d24 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -98,9 +98,15 @@ public void SaveSettingInContainer(string name, T value) { _localSettings.Values[name] = value; } + catch (System.Runtime.InteropServices.COMException ex) when (ex.HResult == unchecked((int)0x80073DC8)) + { + // The value exceeds the ApplicationDataContainer size limit (8 KB). + // Large data should be stored in a file instead. + Debug.WriteLine($"Setting '{name}' exceeds ApplicationDataContainer size limit: {ex.Message}"); + } catch (Exception ex) { - Debug.WriteLine($"Failed to Save setting from ApplicationDataContainer {ex.Message}"); + Debug.WriteLine($"Failed to Save setting in ApplicationDataContainer: {ex.Message}"); #if DEBUG throw; #endif From 58b7ba37b1c2b31c7f8688444e24f7c5cf38af0c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:32:04 -0600 Subject: [PATCH 06/35] Add Grab Templates management to Post-Grab Actions editor Introduce a new "Grab Templates" section in the Post-Grab Actions editor, allowing users to create, view, and delete OCR region templates. Add UI for managing templates, including a list view and actions to open the Grab Frame or delete templates. Update Fullscreen Grab settings to link to the template management UI, making template-based OCR more accessible and discoverable. Includes supporting code for loading, refreshing, and deleting templates, as well as minor UI consistency improvements. --- Text-Grab/Controls/PostGrabActionEditor.xaml | 220 ++++++++++++++---- .../Controls/PostGrabActionEditor.xaml.cs | 69 ++++++ Text-Grab/Pages/FullscreenGrabSettings.xaml | 58 +++-- .../Pages/FullscreenGrabSettings.xaml.cs | 12 + 4 files changed, 297 insertions(+), 62 deletions(-) diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml index 3126b4c2..9f6416f8 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -1,4 +1,4 @@ - - + - + - - - + + + - - - + + + + + Icon="{StaticResource TextGrabIcon}"/> + Text="Available Actions"/> + TextWrapping="Wrap"/> - + - + @@ -82,7 +86,7 @@ + Header="Action Name"/> @@ -94,7 +98,7 @@ FontSize="14" Opacity="0.6" Text="All actions are currently enabled" - Visibility="Collapsed" /> + Visibility="Collapsed"/> @@ -113,8 +117,10 @@ Click="AddButton_Click" ToolTip="Add selected action to enabled list"> - - + + - - + + - + - - + + - - + + @@ -164,12 +176,12 @@ Margin="0,0,0,8" FontSize="18" FontWeight="SemiBold" - Text="Enabled Actions" /> + Text="Enabled Actions"/> + TextWrapping="Wrap"/> - + - + @@ -195,14 +209,16 @@ - + Header="Action Name"/> + - - - - + + + + @@ -217,12 +233,130 @@ x:Name="StayOpenToggle" Margin="0,16,0,0" Content="Keep menu open after clicking an action" - ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected" /> + ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="Reset to default post-grab actions"/> + ToolTip="Save changes and close"/> + ToolTip="Close without saving"/> diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs index a050266a..c82d375d 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Data; using Text_Grab.Models; using Text_Grab.Utilities; +using Text_Grab.Views; using Wpf.Ui.Controls; namespace Text_Grab.Controls; @@ -73,6 +74,9 @@ public PostGrabActionEditor() // Update empty state visibility UpdateEmptyStateVisibility(); + + // Load templates + LoadTemplates(); } #endregion Constructors @@ -199,5 +203,70 @@ private void UpdateEmptyStateVisibility() } } + private void LoadTemplates() + { + List templates = GrabTemplateManager.GetAllTemplates(); + TemplatesListBox.ItemsSource = templates; + UpdateTemplateEmptyState(templates.Count); + } + + private void UpdateTemplateEmptyState(int count) + { + bool hasTemplates = count > 0; + TemplatesListBox.Visibility = hasTemplates ? Visibility.Visible : Visibility.Collapsed; + NoTemplatesText.Visibility = hasTemplates ? Visibility.Collapsed : Visibility.Visible; + } + + private void OpenGrabFrameButton_Click(object sender, RoutedEventArgs e) + { + GrabFrame grabFrame = new(); + grabFrame.Closed += (_, _) => RefreshTemplatesAndActions(); + grabFrame.Show(); + grabFrame.Activate(); + } + + private void DeleteTemplateButton_Click(object sender, RoutedEventArgs e) + { + if (TemplatesListBox.SelectedItem is not GrabTemplate selected) + return; + + System.Windows.MessageBoxResult result = System.Windows.MessageBox.Show( + $"Delete template '{selected.Name}'?", + "Delete Template", + System.Windows.MessageBoxButton.YesNo, + System.Windows.MessageBoxImage.Question); + + if (result != System.Windows.MessageBoxResult.Yes) + return; + + GrabTemplateManager.DeleteTemplate(selected.Id); + + // Also remove any enabled action tied to this template + ButtonInfo? toRemove = EnabledActions.FirstOrDefault(a => a.TemplateId == selected.Id); + if (toRemove is not null) + EnabledActions.Remove(toRemove); + + RefreshTemplatesAndActions(); + } + + private void RefreshTemplatesAndActions() + { + LoadTemplates(); + + // Rebuild available actions list to include/exclude updated templates + List allActions = PostGrabActionManager.GetAvailablePostGrabActions(); + List enabledIds = [.. EnabledActions]; + + AvailableActions.Clear(); + foreach (ButtonInfo action in allActions + .Where(a => !enabledIds.Any(e => e.ButtonText == a.ButtonText && e.TemplateId == a.TemplateId)) + .OrderBy(a => a.OrderNumber)) + { + AvailableActions.Add(action); + } + + UpdateEmptyStateVisibility(); + } + #endregion Methods } diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 54a6c2a2..57d04d05 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -10,18 +10,21 @@ Loaded="Page_Loaded" mc:Ignorable="d"> - - + + + Text="Defaults"/> - + + Text="Single Line outputs captures as a single line (same as pressing S). Table mode requires Windows OCR/AI languages."/> + Content="Send output to Edit Text Window by default"/> + Text="Automatically route capture text into the Edit Text Window (same as pressing E)."/> + Content="Dim screen with translucent overlay while selecting"/> + Text="Controls the transparent overlay shown during Fullscreen Grab selection. Turn off to disable screen dimming."/> + Text="Post-capture insert"/> + Content="Insert captured text into the focused app after closing Fullscreen Grab"/> + Text="Insert delay (seconds):"/> + ValueChanged="InsertDelaySlider_ValueChanged"/> + Style="{StaticResource TextBodyNormal}"/> + Text="How long to wait before simulating paste (Ctrl+V)."/> + Text="Post-capture actions"/> + Text="Configure which actions are performed automatically after text is captured (Fix GUIDs, Trim lines, Remove duplicates, etc.)."/> @@ -470,7 +487,8 @@ Grid.Row="1" ClipToBounds="True"> - + @@ -482,7 +500,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" - Stretch="Uniform" /> + Stretch="Uniform"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0a4df295d8393d22ac958a62b21e079ae4fb8816 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 20:36:27 -0600 Subject: [PATCH 10/35] Add chip picker for template output editing in GrabFrame Introduce InlinePickerRichTextBox for editing Grab Template output, enabling users to insert region chips via a picker UI. Hide the old plain TextBox and update all related logic to use the new control, including serialization and deserialization of template output. Ensure region chips are kept in sync with word borders, and improve file handling for template images. Also includes XAML cleanup and minor refactoring for maintainability. --- Text-Grab/Utilities/GrabTemplateManager.cs | 13 +- Text-Grab/Views/GrabFrame.xaml | 343 ++++++++++----------- Text-Grab/Views/GrabFrame.xaml.cs | 40 ++- 3 files changed, 207 insertions(+), 189 deletions(-) diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs index 03d9126b..70e99ef2 100644 --- a/Text-Grab/Utilities/GrabTemplateManager.cs +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -67,15 +67,22 @@ private static string GetTemplatesFilePath() if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); - string safeName = Regex.Replace(templateName.ToLowerInvariant(), @"[^\w]+", "-").Trim('-'); + string safeName = templateName.ReplaceReservedCharacters(); string shortId = templateId.Length >= 8 ? templateId[..8] : templateId; string filePath = Path.Combine(folder, $"{safeName}_{shortId}.png"); + // Write to a temp file first so the encoder never contends with WPF's + // read lock on filePath (held when BitmapImage was loaded without OnLoad). + string tempPath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp"); + PngBitmapEncoder encoder = new(); encoder.Frames.Add(BitmapFrame.Create(imageSource)); - using FileStream fs = new(filePath, FileMode.Create, FileAccess.Write); - encoder.Save(fs); + using (FileStream fs = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + encoder.Save(fs); + // Atomically replace the destination; succeeds even when the target file + // is open for reading by another process (e.g. WPF's BitmapImage). + File.Move(tempPath, filePath, overwrite: true); return filePath; } catch (Exception ex) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 3eb7a778..6f64338b 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -37,21 +37,14 @@ WindowStyle="None" mc:Ignorable="d"> - + - @@ -59,31 +52,31 @@ + Executed="PasteExecuted" /> + Executed="UndoExecuted" /> + Executed="RedoExecuted" /> + Executed="DeleteWordBordersExecuted" /> + Executed="MergeWordBordersExecuted" /> + Executed="GrabExecuted" /> + Executed="GrabTrimExecuted" /> @@ -91,7 +84,7 @@ CaptionHeight="32" CornerRadius="18,18,2,18" GlassFrameThickness="0" - ResizeBorderThickness="5"/> + ResizeBorderThickness="5" /> - - - - + + + + - - - - - + + + + + - + + IsChecked="True" /> + IsChecked="True" /> + Unchecked="ReadBarcodesMenuItem_Checked" /> - - + IsChecked="{Binding Topmost, ElementName=GrabFrameWindow, Mode=TwoWay}" /> + + - + IsCheckable="True" /> + + 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" /> - + + InputGestureText="Ctrl + Shift + V" /> - - + InputGestureText="Ctrl + O" /> + + + InputGestureText="Ctrl + G" /> - + InputGestureText="Ctrl + Shift + G" /> + + Header="Text Grab Settings" /> + InputGestureText="Alt + F4" /> + InputGestureText="Ctrl + Y" /> + InputGestureText="Ctrl + Z" /> + InputGestureText="Ctrl + A" /> - + IsCheckable="True" /> + + Header="Invert Colors" /> + Header="Increase Contrast (Sigmoid)" /> + Header="Brighten" /> + Header="Darken" /> + Header="Grayscale" /> + InputGestureText="(Ctrl + R)" /> + Unchecked="AspectRationMI_Checked" /> + IsChecked="{Binding IsChecked, ElementName=FreezeToggleButton, Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, ElementName=TableToggleButton, Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, ElementName=EditToggleButton, Mode=TwoWay}" /> - + IsChecked="{Binding IsChecked, ElementName=EditTextToggleButton, Mode=TwoWay}" /> + + InputGestureText="Ctrl + I" /> + InputGestureText="Ctrl + M" /> - + InputGestureText="Del" /> + + Tag="None" /> + Tag="Resize" /> - + Tag="Zoom" /> + + IsCheckable="True" /> + Header="_Contact The Developer..." /> + Header="_Rate and Review..." /> + Header="_Feedback..." /> + Header="_About" /> @@ -424,10 +412,8 @@ @@ -442,7 +428,7 @@ Padding="8,2" HorizontalAlignment="Right" Background="{ui:ThemeResource ApplicationBackgroundBrush}" - WindowChrome.IsHitTestVisibleInChrome="True"/> + WindowChrome.IsHitTestVisibleInChrome="True" /> - + @@ -487,8 +471,7 @@ Grid.Row="1" ClipToBounds="True"> - + @@ -500,7 +483,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" - Stretch="Uniform"/> + Stretch="Uniform" /> + InputGestureText="Ctrl + Shift + V" /> - + InputGestureText="Ctrl + O" /> + - + Header="Copy Text" /> + + Unchecked="AspectRationMI_Checked" /> - + InputGestureText="F" /> + + Header="Try To Make _Numbers" /> + Header="Try To Make _Letters" /> + InputGestureText="Ctrl + M" /> + InputGestureText="Del" /> @@ -577,21 +560,21 @@ Direction="270" Opacity="0.3" ShadowDepth="2" - Color="Black"/> + Color="Black" /> - - - - + + + + + Symbol="LocalLanguage24" /> + Text="Translating..." /> + Value="0" /> + Text="0/0" /> @@ -648,64 +631,69 @@ Visibility="Collapsed"> - - - - - - + + + + + + + Text="Template Name:" /> + VerticalContentAlignment="Center" /> + Text="Output (type { to pick):" + ToolTip="Type { to insert a region chip. Plain text is also supported." /> + ToolTip="Use {1}, {2}, etc. for region values." + Visibility="Collapsed" /> + - - + + + VerticalScrollBarVisibility="Auto" /> - - - - + + + + + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -715,31 +698,31 @@ + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -747,7 +730,7 @@ x:Name="CalcAggregateStatusText" FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text=""/> + Text="" /> + Symbol="Copy24" /> @@ -775,8 +758,7 @@ BorderThickness="0" Click="CalcCopyAllButton_Click" ToolTip="Copy All Results"> - + @@ -810,62 +791,55 @@ Margin="0,0,0,8" FontSize="16" FontWeight="Bold" - Text="Calculation Pane"/> - - + Text="Calculation Pane" /> + + - - + Text="Features:" /> + + - - + + - - + + - - + + - - + + - - + + + Text="Examples:" /> - - - - - + + + + + - - - + + + + TextWrapping="Wrap" /> - + - + @@ -901,7 +875,7 @@ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" MouseDoubleClick="TextBoxSplitter_MouseDoubleClick" ResizeDirection="Auto" - ShowsPreview="True"/> + ShowsPreview="True" /> - + - - + + @@ -937,13 +908,13 @@ Grid.Row="2" Background="Transparent"> - - + + + Orientation="Horizontal" /> + Text="0 matches" /> @@ -1009,17 +980,17 @@ + Text="Regex: " /> + Header="Save Pattern" /> + Header="Explain Pattern" /> @@ -1037,12 +1008,12 @@ x:Name="CharDetailsButtonText" FontFamily="Cascadia Mono" FontSize="12" - Text="U+0000"/> + Text="U+0000" /> + Text="Ln 1, Col 0" /> - + - + @@ -1080,14 +1049,14 @@ x:Name="ProgressRing" Width="60" Height="60" - IsIndeterminate="True"/> + IsIndeterminate="True" /> + Text="Working..." /> diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index c63269e8..f2ab0375 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -2372,13 +2372,12 @@ private void UpdateTemplateBadges() private void UpdateTemplatePickerItems() { List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)]; - TemplateOutputBox.ItemsSource = sorted + TemplateOutputBox.ItemsSource = [.. sorted .Select((wb, i) => { string label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word; return new InlinePickerItem(label, $"{{{i + 1}}}"); - }) - .ToList(); + })]; } private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null) @@ -2395,10 +2394,10 @@ private async Task TryLoadImageFromPath(string path) ResetGrabFrame(); await Task.Delay(300); BitmapImage droppedImage = new(); - droppedImage.BeginInit(); - droppedImage.UriSource = fileURI; - droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle - System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path); + droppedImage.BeginInit(); + droppedImage.UriSource = fileURI; + droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle + System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path); ImageMethods.RotateImage(droppedImage, rotateFlipType); droppedImage.EndInit(); frameContentImageSource = droppedImage; From 44116597d2ec18329839a818a6ad918a4b95a360 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 21:23:00 -0600 Subject: [PATCH 14/35] Add Microsoft.WindowsAppSDK.WinUI package reference Added Microsoft.WindowsAppSDK.WinUI version 1.8.260204000 to the project file to enable or support WinUI features. No other changes were made. --- Text-Grab/Text-Grab.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 4c1bf1c7..23ed5387 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -61,6 +61,7 @@ + From 7c8c8918edc1b9c6e3b5e316bd238a56d92d35fa Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 23:55:56 -0600 Subject: [PATCH 15/35] Improve GrabFrame content area calculation and DPI accuracy Refactor content area and image rect calculations to use WPF's layout engine and PointToScreen, eliminating hardcoded offsets and improving accuracy across DPI settings. Update border and resize settings for better appearance and usability. These changes ensure robust, layout-independent screen coordinate handling. --- Text-Grab/Utilities/ImageMethods.cs | 16 +++++----- Text-Grab/Views/GrabFrame.xaml | 6 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 46 ++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index ae43afd5..e4a908c0 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -117,16 +117,16 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) { Rect imageRect = grabFrame.GetImageContentRect(); - int borderThickness = 2; - int titleBarHeight = 32; - int bottomBarHeight = 42; - if (imageRect == Rect.Empty) { - thisCorrectedLeft = (int)((absPosPoint.X + borderThickness) * dpi.DpiScaleX); - thisCorrectedTop = (int)((absPosPoint.Y + (titleBarHeight + borderThickness)) * dpi.DpiScaleY); - windowWidth -= (int)((2 * borderThickness) * dpi.DpiScaleX); - windowHeight -= (int)((titleBarHeight + bottomBarHeight + (2 * borderThickness)) * dpi.DpiScaleY); + // Ask WPF's layout engine for the exact physical-pixel bounds of the + // transparent content area. This is always correct regardless of DPI, + // border thickness, or title/bottom bar heights. + Rectangle contentRect = grabFrame.GetContentAreaScreenRect(); + thisCorrectedLeft = contentRect.X; + thisCorrectedTop = contentRect.Y; + windowWidth = contentRect.Width; + windowHeight = contentRect.Height; } else { diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index ec49cd0e..19f3a283 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -17,8 +17,8 @@ AllowDrop="True" AllowsTransparency="True" Background="Transparent" - BorderBrush="Gray" - BorderThickness="0.2" + BorderBrush="{ui:ThemeResource ApplicationBackgroundBrush}" + BorderThickness="1" Closed="Window_Closed" Closing="GrabFrameWindow_Closing" Deactivated="GrabFrameWindow_Deactivated" @@ -84,7 +84,7 @@ CaptionHeight="32" CornerRadius="18,18,2,18" GlassFrameThickness="0" - ResizeBorderThickness="5" /> + ResizeBorderThickness="8" /> + /// Returns the physical-pixel screen rectangle that exactly covers the + /// transparent content area (RectanglesBorder, Row 1 of the grid). + /// Uses PointToScreen so it is always accurate regardless of border + /// thickness, DPI, or future layout changes. + /// + internal System.Drawing.Rectangle GetContentAreaScreenRect() + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + Point topLeft = RectanglesBorder.PointToScreen(new Point(0, 0)); + return new System.Drawing.Rectangle( + (int)topLeft.X, + (int)topLeft.Y, + (int)(RectanglesBorder.ActualWidth * dpi.DpiScaleX), + (int)(RectanglesBorder.ActualHeight * dpi.DpiScaleY)); + } + public Rect GetImageContentRect() { // This is a WIP to try to remove the gray letterboxes on either @@ -1070,31 +1087,32 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") RectanglesCanvas.Children.Clear(); wordBorders.Clear(); - Point windowPosition = this.GetAbsolutePosition(); DpiScale dpi = VisualTreeHelper.GetDpi(this); double canvasScale = CanvasViewBox.GetHorizontalScaleFactor(); - Point rectanglesPosition = RectanglesCanvas.TransformToAncestor(this) - .Transform(new Point(0, 0)); - if (double.IsNaN(canvasScale)) canvasScale = 1; - double ContentWidth = RectanglesCanvas.RenderSize.Width; - double ContentHeight = RectanglesCanvas.RenderSize.Height; + double contentWidth = RectanglesCanvas.RenderSize.Width; + double contentHeight = RectanglesCanvas.RenderSize.Height; - if (ContentWidth == 4 || ContentHeight == 2) + // When the canvas hasn't been measured yet (Viewbox content is still + // at its minimum size), fall back to the containing border's dimensions. + if (contentWidth <= 4 || contentHeight <= 2) { - ContentWidth = RectanglesBorder.RenderSize.Width; - ContentHeight = RectanglesBorder.RenderSize.Height; - rectanglesPosition = new(-2, 32); + contentWidth = RectanglesBorder.ActualWidth; + contentHeight = RectanglesBorder.ActualHeight; + canvasScale = 1; } + // PointToScreen gives the exact physical-pixel origin of the canvas + // regardless of border thickness, DPI, or layout changes. + Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0)); System.Drawing.Rectangle rectCanvasSize = new() { - Width = (int)(ContentWidth * dpi.DpiScaleX * canvasScale), - Height = (int)(ContentHeight * dpi.DpiScaleY * canvasScale), - X = (int)((windowPosition.X + rectanglesPosition.X) * dpi.DpiScaleX), - Y = (int)((windowPosition.Y + rectanglesPosition.Y) * dpi.DpiScaleY) + X = (int)scanTopLeft.X, + Y = (int)scanTopLeft.Y, + Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale), + Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale), }; if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0) From 3cd300126aaa988b343499266cf1e27a040e6f7e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 2 Mar 2026 20:02:43 -0600 Subject: [PATCH 16/35] Improve GrabFrame overlay scaling and image cropping accuracy - Fix Viewbox scaling to use both width and height (Uniform) - Crop frozen image to selection for GrabFrame continuity - Add GrabFrame constructor for pre-loaded cropped images - Explicitly size overlay canvas to match image pixels - Rescale word borders from history for accurate overlays - Refine content/non-content area calculations for sizing - Improve background color sampling for word borders - Add robustness checks for invalid/unmeasured sizes - Slightly increase GrabFrame title bar height for UI polish These changes resolve overlay misalignment and scaling issues, ensuring accurate OCR word border placement and a consistent user experience when grabbing, freezing, and editing regions. --- Text-Grab/Extensions/ControlExtensions.cs | 27 +- Text-Grab/Views/FullscreenGrab.xaml | 4 +- Text-Grab/Views/FullscreenGrab.xaml.cs | 34 ++- Text-Grab/Views/GrabFrame.xaml | 2 +- Text-Grab/Views/GrabFrame.xaml.cs | 316 ++++++++++++++++++---- 5 files changed, 315 insertions(+), 68 deletions(-) diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs index 7ccfe990..4e3a32ab 100644 --- a/Text-Grab/Extensions/ControlExtensions.cs +++ b/Text-Grab/Extensions/ControlExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Windows; using System.Windows.Controls; @@ -14,9 +15,33 @@ public static double GetHorizontalScaleFactor(this Viewbox viewbox) return 1.0; double outsideWidth = viewbox.ActualWidth; + double outsideHeight = viewbox.ActualHeight; double insideWidth = childElement.ActualWidth; + double insideHeight = childElement.ActualHeight; - return outsideWidth / insideWidth; + if (!double.IsFinite(outsideWidth) || !double.IsFinite(insideWidth) + || outsideWidth <= 0 || insideWidth <= 4) + { + return 1.0; + } + + // A Viewbox with Stretch="Uniform" applies min(width_ratio, height_ratio) so that + // the content fits in both dimensions. Using only the width ratio produces the wrong + // scale when the image is height-limited (taller relative to the window than it is + // wide), which causes OCR word borders to be placed at incorrect canvas positions. + double scale = outsideWidth / insideWidth; + + if (double.IsFinite(outsideHeight) && double.IsFinite(insideHeight) + && outsideHeight > 0 && insideHeight > 4) + { + double scaleY = outsideHeight / insideHeight; + scale = Math.Min(scale, scaleY); + } + + if (!double.IsFinite(scale) || scale <= 0) + return 1.0; + + return scale; } public static Rect GetAbsolutePlacement(this FrameworkElement element, bool relativeToScreen = false) diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml index e6fedeb3..231ddc76 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml +++ b/Text-Grab/Views/FullscreenGrab.xaml @@ -112,9 +112,7 @@ - + 0 && cropH > 0) + { + CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH)); + croppedBitmap.Freeze(); + grabFrame = new GrabFrame(croppedBitmap); + } + else + { + grabFrame = new GrabFrame(); + } + } + else { - Left = posLeft, - Top = posTop, - }; + grabFrame = new GrabFrame(); + } + + grabFrame.Left = posLeft; + grabFrame.Top = posTop; grabFrame.Left -= (2 / dpi.PixelsPerDip); grabFrame.Top -= (48 / dpi.PixelsPerDip); diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 19f3a283..2ec53219 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -92,7 +92,7 @@ ClipToBounds="True"> - + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 4b9b8a4c..6f6bcad6 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -138,6 +138,27 @@ public GrabFrame(string imagePath) Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath); } + /// + /// Creates a GrabFrame pre-loaded with a frozen image cropped from a Fullscreen Grab selection. + /// The frame opens in freeze mode showing the provided bitmap and immediately runs OCR. + /// + /// The cropped bitmap to display as the initial frozen background. + public GrabFrame(BitmapSource frozenImage) + { + StandardInitialize(); + + ShouldSaveOnClose = true; + frameContentImageSource = frozenImage; + hasLoadedImageSource = true; + + Loaded += (s, e) => + { + FreezeToggleButton.IsChecked = true; + FreezeGrabFrame(); + reDrawTimer.Start(); + }; + } + /// /// Opens GrabFrame in template editing mode with existing regions pre-loaded. /// @@ -231,11 +252,30 @@ private async Task LoadContentFromHistory(HistoryInfo history) return; } + history.ImageContent = bgBitmap; frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap); hasLoadedImageSource = true; GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); + if (history.PositionRect != Rect.Empty) + { + Left = history.PositionRect.Left; + Top = history.PositionRect.Top; + + if (history.SourceMode == TextGrabMode.Fullscreen) + { + Size nonContentSize = GetGrabFrameNonContentSize(); + Width = history.PositionRect.Width + nonContentSize.Width; + Height = history.PositionRect.Height + nonContentSize.Height; + } + else + { + Width = history.PositionRect.Width; + Height = history.PositionRect.Height; + } + } + List? wbInfoList = null; if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) @@ -243,6 +283,8 @@ private async Task LoadContentFromHistory(HistoryInfo history) if (wbInfoList is not null && wbInfoList.Count > 0) { + ScaleHistoryWordBordersToCanvas(history, wbInfoList); + foreach (WordBorderInfo info in wbInfoList) { WordBorder wb = new(info) @@ -263,26 +305,95 @@ private async Task LoadContentFromHistory(HistoryInfo history) ShouldSaveOnClose = true; } - if (history.PositionRect != Rect.Empty) + TableToggleButton.IsChecked = history.IsTable; + + UpdateFrameText(); + } + + private Size GetGrabFrameNonContentSize() + { + const double defaultNonContentWidth = 4; + const double defaultNonContentHeight = 74; + + UpdateLayout(); + + if (ActualWidth <= 1 || ActualHeight <= 1 + || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1) { - Left = history.PositionRect.Left; - Top = history.PositionRect.Top; - Height = history.PositionRect.Height; - Width = history.PositionRect.Width; + return new Size(defaultNonContentWidth, defaultNonContentHeight); + } - if (history.SourceMode == TextGrabMode.Fullscreen) - { - int borderThickness = 2; - int titleBarHeight = 32; - int bottomBarHeight = 42; - Height += (titleBarHeight + bottomBarHeight); - Width += (2 * borderThickness); - } + double nonContentWidth = ActualWidth - RectanglesBorder.ActualWidth; + double nonContentHeight = ActualHeight - RectanglesBorder.ActualHeight; + + if (!double.IsFinite(nonContentWidth) || nonContentWidth < 0 || nonContentWidth > 100) + nonContentWidth = defaultNonContentWidth; + + if (!double.IsFinite(nonContentHeight) || nonContentHeight < 0 || nonContentHeight > 200) + nonContentHeight = defaultNonContentHeight; + + return new Size(nonContentWidth, nonContentHeight); + } + + private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List wbInfoList) + { + if (wbInfoList.Count == 0 || RectanglesCanvas.Width <= 0 || RectanglesCanvas.Height <= 0) + return; + + Size savedContentSize = GetSavedHistoryContentSize(history); + if (savedContentSize.Width <= 0 || savedContentSize.Height <= 0) + return; + + double scaleX = RectanglesCanvas.Width / savedContentSize.Width; + double scaleY = RectanglesCanvas.Height / savedContentSize.Height; + if (!double.IsFinite(scaleX) || !double.IsFinite(scaleY) || (scaleX <= 1.05 && scaleY <= 1.05)) + return; + + double maxRight = wbInfoList.Max(info => info.BorderRect.Right); + double maxBottom = wbInfoList.Max(info => info.BorderRect.Bottom); + + // Scale only when saved word borders look like they were captured in + // the old window-content coordinate space rather than image-space. + if (maxRight > savedContentSize.Width * 1.1 || maxBottom > savedContentSize.Height * 1.1) + return; + + foreach (WordBorderInfo info in wbInfoList) + { + Rect borderRect = info.BorderRect; + info.BorderRect = new Rect( + borderRect.Left * scaleX, + borderRect.Top * scaleY, + borderRect.Width * scaleX, + borderRect.Height * scaleY); } + } - TableToggleButton.IsChecked = history.IsTable; + private Size GetSavedHistoryContentSize(HistoryInfo history) + { + if (history.ImageContent is System.Drawing.Bitmap imageContentBitmap + && imageContentBitmap.Width > 0 && imageContentBitmap.Height > 0) + { + return new Size(imageContentBitmap.Width, imageContentBitmap.Height); + } - UpdateFrameText(); + Rect positionRect = history.PositionRect; + if (positionRect == Rect.Empty || positionRect.Width <= 0 || positionRect.Height <= 0) + return new Size(0, 0); + + if (history.SourceMode == TextGrabMode.Fullscreen) + return new Size(positionRect.Width, positionRect.Height); + + Size nonContentSize = GetGrabFrameNonContentSize(); + double contentWidth = positionRect.Width - nonContentSize.Width; + double contentHeight = positionRect.Height - nonContentSize.Height; + + if (!double.IsFinite(contentWidth) || contentWidth <= 0) + contentWidth = positionRect.Width; + + if (!double.IsFinite(contentHeight) || contentHeight <= 0) + contentHeight = positionRect.Height; + + return new Size(contentWidth, contentHeight); } /// @@ -307,17 +418,21 @@ public Rect GetImageContentRect() // This is a WIP to try to remove the gray letterboxes on either // side of the image when zooming it. - Rect imageRect = Rect.Empty; + if (frameContentImageSource is null || !IsLoaded || !RectanglesCanvas.IsLoaded) + return Rect.Empty; - if (frameContentImageSource is null) - return imageRect; + Rect canvasPlacement = RectanglesCanvas.GetAbsolutePlacement(true); + if (canvasPlacement == Rect.Empty) + return Rect.Empty; - imageRect = RectanglesCanvas.GetAbsolutePlacement(true); Size rectCanvasSize = RectanglesCanvas.RenderSize; - imageRect.Width = rectCanvasSize.Width; - imageRect.Height = rectCanvasSize.Height; + if (!double.IsFinite(rectCanvasSize.Width) || !double.IsFinite(rectCanvasSize.Height) + || rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0) + { + return canvasPlacement; + } - return imageRect; + return new Rect(canvasPlacement.X, canvasPlacement.Y, rectCanvasSize.Width, rectCanvasSize.Height); } private void StandardInitialize() @@ -1088,33 +1203,14 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") wordBorders.Clear(); DpiScale dpi = VisualTreeHelper.GetDpi(this); - double canvasScale = CanvasViewBox.GetHorizontalScaleFactor(); - if (double.IsNaN(canvasScale)) - canvasScale = 1; - - double contentWidth = RectanglesCanvas.RenderSize.Width; - double contentHeight = RectanglesCanvas.RenderSize.Height; - - // When the canvas hasn't been measured yet (Viewbox content is still - // at its minimum size), fall back to the containing border's dimensions. - if (contentWidth <= 4 || contentHeight <= 2) + System.Drawing.Rectangle rectCanvasSize = GetContentAreaScreenRect(); + if (rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0) { - contentWidth = RectanglesBorder.ActualWidth; - contentHeight = RectanglesBorder.ActualHeight; - canvasScale = 1; + isDrawing = false; + reDrawTimer.Start(); + return; } - // PointToScreen gives the exact physical-pixel origin of the canvas - // regardless of border thickness, DPI, or layout changes. - Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0)); - System.Drawing.Rectangle rectCanvasSize = new() - { - X = (int)scanTopLeft.X, - Y = (int)scanTopLeft.Y, - Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale), - Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale), - }; - if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0) { ILanguage lang = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); @@ -1126,13 +1222,22 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") isSpaceJoining = CurrentLanguage!.IsSpaceJoining(); - System.Drawing.Bitmap? bmp = null; + System.Drawing.Bitmap? bmp = Singleton.Instance.CachedBitmap; + bool shouldDisposeBmp = false; - if (frameContentImageSource is BitmapSource bmpImg) + if (bmp is null && frameContentImageSource is BitmapSource bmpImg) + { bmp = ImageMethods.BitmapSourceToBitmap(bmpImg); + shouldDisposeBmp = true; + } int lineNumber = 0; double viewBoxZoomFactor = CanvasViewBox.GetHorizontalScaleFactor(); + if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4) + viewBoxZoomFactor = 1; + Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); + double borderToCanvasX = -canvasOriginInBorder.X; + double borderToCanvasY = -canvasOriginInBorder.Y; foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines) { @@ -1145,7 +1250,7 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") SolidColorBrush backgroundBrush = new(Colors.Black); if (bmp is not null) - backgroundBrush = GetBackgroundBrushFromBitmap(ref dpi, windowFrameImageScale, bmp, ref lineRect); + backgroundBrush = GetBackgroundBrushFromOcrBitmap(windowFrameImageScale, bmp, ref lineRect); string ocrText = lineText.ToString(); @@ -1159,8 +1264,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") { Width = ((lineRect.Width / (dpi.DpiScaleX * windowFrameImageScale)) + 2) / viewBoxZoomFactor, Height = ((lineRect.Height / (dpi.DpiScaleY * windowFrameImageScale)) + 2) / viewBoxZoomFactor, - Top = (lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) / viewBoxZoomFactor, - Left = (lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) / viewBoxZoomFactor, + Top = ((lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) + borderToCanvasY) / viewBoxZoomFactor, + Left = ((lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) + borderToCanvasX) / viewBoxZoomFactor, Word = ocrText, OwnerGrabFrame = this, LineNumber = lineNumber, @@ -1203,7 +1308,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") isDrawing = false; - bmp?.Dispose(); + if (shouldDisposeBmp) + bmp?.Dispose(); reSearchTimer.Start(); // Trigger translation if enabled @@ -1335,6 +1441,8 @@ private void FreezeGrabFrame() GrabFrameImage.Source = frameContentImageSource; } + SyncRectanglesCanvasSizeToImage(); + FreezeToggleButton.IsChecked = true; Topmost = false; Background = new SolidColorBrush(Colors.DimGray); @@ -1342,6 +1450,28 @@ private void FreezeGrabFrame() IsFreezeMode = true; } + private void SyncRectanglesCanvasSizeToImage() + { + if (GrabFrameImage.Source is not BitmapSource source) + return; + + // Keep image and overlay in the same coordinate space (raw image pixels). + double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth : source.Width; + double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight : source.Height; + + if (double.IsFinite(sourceWidth) && sourceWidth > 0) + { + GrabFrameImage.Width = sourceWidth; + RectanglesCanvas.Width = sourceWidth; + } + + if (double.IsFinite(sourceHeight) && sourceHeight > 0) + { + GrabFrameImage.Height = sourceHeight; + RectanglesCanvas.Height = sourceHeight; + } + } + private async void FreezeMI_Click(object sender, RoutedEventArgs e) { if (IsFreezeMode) @@ -1371,6 +1501,54 @@ private void FreezeToggleButton_Click(object? sender = null, RoutedEventArgs? e UnfreezeGrabFrame(); } + private static SolidColorBrush GetBackgroundBrushFromOcrBitmap(double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect) + { + if (!double.IsFinite(scale) || scale <= 0) + scale = 1; + + double boxLeft = lineRect.Left / scale; + double boxTop = lineRect.Top / scale; + double boxRight = lineRect.Right / scale; + double boxBottom = lineRect.Bottom / scale; + double boxWidth = Math.Max(0, boxRight - boxLeft); + double boxHeight = Math.Max(0, boxBottom - boxTop); + double insetX = Math.Min(boxWidth / 2, Math.Max(1, boxWidth * 0.12)); + double insetY = Math.Min(boxHeight / 2, Math.Max(1, boxHeight * 0.12)); + + int pxLeft = Math.Clamp((int)(boxLeft + insetX), 0, bmp.Width - 1); + int pxTop = Math.Clamp((int)(boxTop + insetY), 0, bmp.Height - 1); + int pxRight = Math.Clamp((int)(boxRight - insetX), 0, bmp.Width - 1); + int pxBottom = Math.Clamp((int)(boxBottom - insetY), 0, bmp.Height - 1); + + if (pxRight < pxLeft) + pxRight = pxLeft; + + if (pxBottom < pxTop) + pxBottom = pxTop; + + System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop); + System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop); + System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom); + System.Drawing.Color pxColorLeftBottom = bmp.GetPixel(pxLeft, pxBottom); + + List mediaColorList = + [ + ColorHelper.MediaColorFromDrawingColor(pxColorLeftTop), + ColorHelper.MediaColorFromDrawingColor(pxColorRightTop), + ColorHelper.MediaColorFromDrawingColor(pxColorRightBottom), + ColorHelper.MediaColorFromDrawingColor(pxColorLeftBottom), + ]; + + Color? mostCommonColor = mediaColorList.GroupBy(c => c) + .OrderBy(g => g.Count()) + .LastOrDefault()?.Key; + + if (mostCommonColor is not null) + return new SolidColorBrush(mostCommonColor.Value); + + return ColorHelper.SolidColorBrushFromDrawingColor(pxColorLeftTop); + } + private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect) { SolidColorBrush backgroundBrush = new(Colors.Black); @@ -1385,10 +1563,26 @@ private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double sc double rightFraction = boxRight / RectanglesCanvas.ActualWidth; double bottomFraction = boxBottom / RectanglesCanvas.ActualHeight; - int pxLeft = Math.Clamp((int)(leftFraction * bmp.Width) - 1, 0, bmp.Width - 1); - int pxTop = Math.Clamp((int)(topFraction * bmp.Height) - 2, 0, bmp.Height - 1); - int pxRight = Math.Clamp((int)(rightFraction * bmp.Width) + 1, 0, bmp.Width - 1); - int pxBottom = Math.Clamp((int)(bottomFraction * bmp.Height) + 1, 0, bmp.Height - 1); + int rawLeft = Math.Clamp((int)(leftFraction * bmp.Width), 0, bmp.Width - 1); + int rawTop = Math.Clamp((int)(topFraction * bmp.Height), 0, bmp.Height - 1); + int rawRight = Math.Clamp((int)(rightFraction * bmp.Width), 0, bmp.Width - 1); + int rawBottom = Math.Clamp((int)(bottomFraction * bmp.Height), 0, bmp.Height - 1); + + int spanX = Math.Max(0, rawRight - rawLeft); + int spanY = Math.Max(0, rawBottom - rawTop); + int insetX = Math.Min(spanX / 2, Math.Max(1, spanX / 8)); + int insetY = Math.Min(spanY / 2, Math.Max(1, spanY / 8)); + int pxLeft = Math.Clamp(rawLeft + insetX, 0, bmp.Width - 1); + int pxTop = Math.Clamp(rawTop + insetY, 0, bmp.Height - 1); + int pxRight = Math.Clamp(rawRight - insetX, 0, bmp.Width - 1); + int pxBottom = Math.Clamp(rawBottom - insetY, 0, bmp.Height - 1); + + if (pxRight < pxLeft) + pxRight = pxLeft; + + if (pxBottom < pxTop) + pxBottom = pxTop; + System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop); System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop); System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom); @@ -2018,6 +2212,12 @@ private async void ReDrawTimer_Tick(object? sender, EventArgs? e) reDrawTimer.Stop(); SetRefreshOrOcrFrameBtnVis(); + if (!IsLoaded || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1) + { + reDrawTimer.Start(); + return; + } + if (CheckKey(VirtualKeyCodes.LeftButton) || CheckKey(VirtualKeyCodes.MiddleButton)) { reDrawTimer.Start(); @@ -2177,6 +2377,10 @@ private void ResetGrabFrame() MainZoomBorder.Reset(); RectanglesCanvas.RenderTransform = Transform.Identity; + RectanglesCanvas.ClearValue(WidthProperty); + RectanglesCanvas.ClearValue(HeightProperty); + GrabFrameImage.ClearValue(WidthProperty); + GrabFrameImage.ClearValue(HeightProperty); IsOcrValid = false; ocrResultOfWindow = null; From bfe084bc1af0fe803ae003011fc5f9a9c3ec5541 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 3 Mar 2026 19:53:58 -0600 Subject: [PATCH 17/35] Highlight unused template regions in GrabFrame/preview Add visual dimming for template regions not referenced in the output template, both in the GrabFrame editor and FullscreenGrab preview. WordBorder controls now support a dimmed border state, and region highlighting updates dynamically as the template is edited. Also includes minor XAML cleanup for menu item formatting. This improves clarity when editing templates by making unused regions visually distinct. --- Text-Grab/Controls/NotifyIconWindow.xaml | 4 ++-- Text-Grab/Controls/WordBorder.xaml.cs | 23 ++++++++++++++++++- Text-Grab/Extensions/ControlExtensions.cs | 3 --- Text-Grab/Views/FullscreenGrab.xaml.cs | 6 ++++- Text-Grab/Views/GrabFrame.xaml | 1 + Text-Grab/Views/GrabFrame.xaml.cs | 28 +++++++++++++++++++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index 468c4ebd..bd6e13f7 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -40,8 +40,8 @@ + Header="Paste in Grab Frame" + IsEnabled="False"> diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index b4dbda65..ec86d291 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -174,7 +174,28 @@ public string Word public void Deselect() { IsSelected = false; - WordBorderBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(255, 48, 142, 152)); + ApplyTemplateDimBorderBrush(); + } + + private bool _isDimmedForTemplate = false; + + /// + /// Dims the border brush to indicate this region is not referenced in the output template. + /// Call with false to restore the normal border color. + /// + public void SetDimmedForTemplate(bool isDimmed) + { + _isDimmedForTemplate = isDimmed; + if (!IsSelected) + ApplyTemplateDimBorderBrush(); + } + + private void ApplyTemplateDimBorderBrush() + { + byte alpha = _isDimmedForTemplate ? (byte)80 : (byte)255; + SolidColorBrush brush = new(Color.FromArgb(alpha, 48, 142, 152)); + WordBorderBorder.BorderBrush = brush; + MoveResizeBorder.BorderBrush = brush; } public void EnterEdit() diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs index 4e3a32ab..7174705d 100644 --- a/Text-Grab/Extensions/ControlExtensions.cs +++ b/Text-Grab/Extensions/ControlExtensions.cs @@ -6,9 +6,6 @@ namespace Text_Grab; public static class ControlExtensions { - - - public static double GetHorizontalScaleFactor(this Viewbox viewbox) { if (viewbox.Child is not FrameworkElement childElement) diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs index 2223713a..48ea0907 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml.cs +++ b/Text-Grab/Views/FullscreenGrab.xaml.cs @@ -426,6 +426,9 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double Canvas.SetTop(templateOverlayCanvas, selTop); System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(220, 255, 180, 0); + System.Windows.Media.Color dimBorderColor = System.Windows.Media.Color.FromArgb(80, 255, 180, 0); + + HashSet referencedRegions = [.. template.GetReferencedRegionNumbers()]; foreach (TemplateRegion region in template.Regions) { @@ -437,11 +440,12 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double if (regionWidth < 1 || regionHeight < 1) continue; + bool isReferenced = referencedRegions.Count == 0 || referencedRegions.Contains(region.RegionNumber); Border regionBorder = new() { Width = regionWidth, Height = regionHeight, - BorderBrush = new SolidColorBrush(borderColor), + BorderBrush = new SolidColorBrush(isReferenced ? borderColor : dimBorderColor), BorderThickness = new Thickness(1.5), }; diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 2ec53219..a61972c6 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -660,6 +660,7 @@ VerticalAlignment="Center" AcceptsReturn="True" AcceptsTab="True" + TextChanged="TemplateOutputBox_TextChanged" ToolTip="Type { to open the region picker. Plain text also supported." />