diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 8561e100..b9602ac1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,5 @@ # These are supported funding model platforms -custom: ['paypal.me/JosephFinney'] \ No newline at end of file +github: [TheJoeFin] +buy_me_a_coffee: thejoefin +custom: ["paypal.me/JosephFinney"] diff --git a/Tests/HdrTests.cs b/Tests/HdrTests.cs new file mode 100644 index 00000000..7d1ca06a --- /dev/null +++ b/Tests/HdrTests.cs @@ -0,0 +1,160 @@ +using System.Drawing; +using System.Drawing.Imaging; +using Text_Grab.Utilities; +using Xunit; + +namespace Tests; + +public class HdrTests +{ + [Fact] + public void ConvertHdrToSdr_WithNullBitmap_ReturnsNull() + { + // Arrange + Bitmap? nullBitmap = null; + + // Act + Bitmap? result = HdrUtilities.ConvertHdrToSdr(nullBitmap); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ConvertHdrToSdr_WithValidBitmap_ReturnsNewBitmap() + { + // Arrange + Bitmap testBitmap = new(100, 100, PixelFormat.Format32bppArgb); + using Graphics g = Graphics.FromImage(testBitmap); + // Fill with white color (simulating HDR bright pixels) + g.Clear(Color.White); + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Assert.NotNull(result); + Assert.NotSame(testBitmap, result); + Assert.Equal(testBitmap.Width, result.Width); + Assert.Equal(testBitmap.Height, result.Height); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_WithBrightPixels_ReducesBrightness() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + // Fill with very bright color + using (Graphics g = Graphics.FromImage(testBitmap)) + { + g.Clear(Color.FromArgb(255, 255, 255, 255)); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + // The conversion should tone map bright values + // In this case, pure white (255,255,255) should remain relatively close + // but with tone mapping applied + Color centerPixel = result.GetPixel(5, 5); + + // After tone mapping, pixels should still be bright but potentially adjusted + Assert.True(centerPixel.R >= 200, "Red channel should remain bright"); + Assert.True(centerPixel.G >= 200, "Green channel should remain bright"); + Assert.True(centerPixel.B >= 200, "Blue channel should remain bright"); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_WithMixedPixels_ProcessesCorrectly() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + using (Graphics g = Graphics.FromImage(testBitmap)) + { + // Fill with different colors to test tone mapping + using Brush darkBrush = new SolidBrush(Color.FromArgb(255, 50, 50, 50)); + using Brush brightBrush = new SolidBrush(Color.FromArgb(255, 250, 250, 250)); + + g.FillRectangle(darkBrush, 0, 0, 5, 10); + g.FillRectangle(brightBrush, 5, 0, 5, 10); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Assert.NotNull(result); + Color darkPixel = result.GetPixel(2, 5); + Color brightPixel = result.GetPixel(7, 5); + + // Dark pixels should remain relatively dark + Assert.True(darkPixel.R < 100, "Dark pixel should remain dark"); + + // Bright pixels should be tone mapped + Assert.True(brightPixel.R > darkPixel.R, "Bright pixel should be brighter than dark pixel"); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_PreservesAlphaChannel() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + using (Graphics g = Graphics.FromImage(testBitmap)) + { + using Brush semiTransparentBrush = new SolidBrush(Color.FromArgb(128, 255, 255, 255)); + g.FillRectangle(semiTransparentBrush, 0, 0, 10, 10); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Color pixel = result.GetPixel(5, 5); + Assert.Equal(128, pixel.A); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void IsHdrEnabledAtPoint_WithValidCoordinates_DoesNotThrow() + { + // Arrange + int x = 100; + int y = 100; + + // Act & Assert + // Should not throw exception even if HDR is not available + var exception = Record.Exception(() => HdrUtilities.IsHdrEnabledAtPoint(x, y)); + Assert.Null(exception); + } + + [Fact] + public void IsHdrEnabledAtPoint_WithNegativeCoordinates_ReturnsFalse() + { + // Arrange + int x = -1; + int y = -1; + + // Act + bool result = HdrUtilities.IsHdrEnabledAtPoint(x, y); + + // Assert + // Should return false for invalid coordinates + Assert.False(result); + } +} diff --git a/Tests/PostGrabActionManagerTests.cs b/Tests/PostGrabActionManagerTests.cs new file mode 100644 index 00000000..8e47ca19 --- /dev/null +++ b/Tests/PostGrabActionManagerTests.cs @@ -0,0 +1,131 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class PostGrabActionManagerTests +{ + [Fact] + public void GetDefaultPostGrabActions_ReturnsExpectedCount() + { + // Arrange & Act + List actions = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Assert + Assert.NotNull(actions); + Assert.Equal(5, actions.Count); + } + + [Fact] + public void GetDefaultPostGrabActions_ContainsExpectedActions() + { + // Arrange & Act + List actions = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Assert + Assert.Contains(actions, a => a.ButtonText == "Fix GUIDs"); + Assert.Contains(actions, a => a.ButtonText == "Trim each line"); + Assert.Contains(actions, a => a.ButtonText == "Remove duplicate lines"); + Assert.Contains(actions, a => a.ButtonText == "Web Search"); + Assert.Contains(actions, a => a.ButtonText == "Try to insert text"); + //Assert.Contains(actions, a => a.ButtonText == "Translate to system language"); + } + + [Fact] + public void GetDefaultPostGrabActions_AllHaveClickEvents() + { + // Arrange & Act + List actions = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Assert + Assert.All(actions, action => + { + Assert.False(string.IsNullOrEmpty(action.ClickEvent)); + }); + } + + [Fact] + public void GetDefaultPostGrabActions_AllHaveSymbols() + { + // Arrange & Act + List actions = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Assert + Assert.All(actions, action => + { + Assert.True(action.IsSymbol); + }); + } + + [Fact] + public void GetDefaultPostGrabActions_AllMarkedForFullscreenGrab() + { + // Arrange & Act + List actions = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Assert + Assert.All(actions, action => + { + Assert.True(action.IsRelevantForFullscreenGrab); + Assert.False(action.IsRelevantForEditWindow); + }); + } + + [Fact] + public async Task ExecutePostGrabAction_CorrectGuid_TransformsText() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "CorrectGuid_Click"); + string input = "123e4567-e89b-12d3-a456-426614174OOO"; // Has O's instead of 0's + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert + Assert.Contains("000", result); // Should have corrected O's to 0's + } + + [Fact] + public async System.Threading.Tasks.Task ExecutePostGrabAction_RemoveDuplicateLines_RemovesDuplicates() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "RemoveDuplicateLines_Click"); + string input = $"Line 1{Environment.NewLine}Line 2{Environment.NewLine}Line 1{Environment.NewLine}Line 3"; + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert + string[] lines = result.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + Assert.Single(lines, l => l == "Line 1"); + } + + [Fact] + public void GetCheckState_DefaultOff_ReturnsFalse() + { + // Arrange + ButtonInfo action = new("Test", "Test_Click", Wpf.Ui.Controls.SymbolRegular.Apps24, DefaultCheckState.Off); + + // Act + bool result = PostGrabActionManager.GetCheckState(action); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetCheckState_DefaultOn_ReturnsTrue() + { + // Arrange + ButtonInfo action = new("Test", "Test_Click", Wpf.Ui.Controls.SymbolRegular.Apps24, DefaultCheckState.On); + + // Act + bool result = PostGrabActionManager.GetCheckState(action); + + // Assert + Assert.True(result); + } +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index e8cf7fe9..67989b59 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -18,15 +18,15 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 62590537..ab9b17f7 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -196,6 +196,21 @@ False + + False + + + English + + + + + + + + + False + \ No newline at end of file diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml new file mode 100644 index 00000000..3126b4c2 --- /dev/null +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs new file mode 100644 index 00000000..a050266a --- /dev/null +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Data; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Controls; + +/// +/// Converts enum values to int for ComboBox SelectedIndex binding +/// +public class EnumToIntConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is Enum enumValue) + return (int)(object)enumValue; + return 0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is int intValue && targetType.IsEnum) + return Enum.ToObject(targetType, intValue); + return DefaultCheckState.Off; + } +} + +public partial class PostGrabActionEditor : FluentWindow +{ + #region Properties + + private ObservableCollection AvailableActions { get; set; } + private ObservableCollection EnabledActions { get; set; } + + #endregion Properties + + #region Constructors + + public PostGrabActionEditor() + { + InitializeComponent(); + + // Get all available actions + List allActions = PostGrabActionManager.GetAvailablePostGrabActions(); + + // Get currently enabled actions + List enabledActions = PostGrabActionManager.GetEnabledPostGrabActions(); + + // Populate enabled list + EnabledActions = new ObservableCollection(enabledActions); + EnabledActionsListBox.ItemsSource = EnabledActions; + + // Populate available list (actions not currently enabled) - sorted by OrderNumber + AvailableActions = []; + List availableActionsList = [.. allActions + .Where(a => !enabledActions.Any(e => e.ButtonText == a.ButtonText)) + .OrderBy(a => a.OrderNumber)]; + + foreach (ButtonInfo? action in availableActionsList) + { + AvailableActions.Add(action); + } + AvailableActionsListBox.ItemsSource = AvailableActions; + + // Load PostGrabStayOpen setting + StayOpenToggle.IsChecked = AppUtilities.TextGrabSettings.PostGrabStayOpen; + + // Update empty state visibility + UpdateEmptyStateVisibility(); + } + + #endregion Constructors + + #region Methods + + private void AddButton_Click(object sender, RoutedEventArgs e) + { + if (AvailableActionsListBox.SelectedItem is not ButtonInfo selectedAction) + return; + + EnabledActions.Add(selectedAction); + AvailableActions.Remove(selectedAction); + UpdateEmptyStateVisibility(); + } + + private void RemoveButton_Click(object sender, RoutedEventArgs e) + { + if (EnabledActionsListBox.SelectedItem is not ButtonInfo selectedAction) + return; + + AvailableActions.Add(selectedAction); + EnabledActions.Remove(selectedAction); + + // Re-sort available actions by order number + List sortedAvailable = [.. AvailableActions.OrderBy(a => a.OrderNumber)]; + AvailableActions.Clear(); + foreach (ButtonInfo? action in sortedAvailable) + { + AvailableActions.Add(action); + } + + UpdateEmptyStateVisibility(); + } + + private void MoveUpButton_Click(object sender, RoutedEventArgs e) + { + int index = EnabledActionsListBox.SelectedIndex; + if (index <= 0 || index >= EnabledActions.Count) + return; + + ButtonInfo item = EnabledActions[index]; + EnabledActions.RemoveAt(index); + EnabledActions.Insert(index - 1, item); + EnabledActionsListBox.SelectedIndex = index - 1; + } + + private void MoveDownButton_Click(object sender, RoutedEventArgs e) + { + int index = EnabledActionsListBox.SelectedIndex; + if (index < 0 || index >= EnabledActions.Count - 1) + return; + + ButtonInfo item = EnabledActions[index]; + EnabledActions.RemoveAt(index); + EnabledActions.Insert(index + 1, item); + EnabledActionsListBox.SelectedIndex = index + 1; + } + + private void ResetButton_Click(object sender, RoutedEventArgs e) + { + System.Windows.MessageBoxResult result = System.Windows.MessageBox.Show( + "This will reset to the default post-grab actions. Continue?", + "Reset to Defaults", + System.Windows.MessageBoxButton.YesNo, + System.Windows.MessageBoxImage.Question); + + if (result != System.Windows.MessageBoxResult.Yes) + return; + + // Get defaults + List defaults = PostGrabActionManager.GetDefaultPostGrabActions(); + + // Clear and repopulate enabled list + EnabledActions.Clear(); + foreach (ButtonInfo action in defaults) + { + EnabledActions.Add(action); + } + + // Repopulate available list + List allActions = PostGrabActionManager.GetAvailablePostGrabActions(); + AvailableActions.Clear(); + List availableActionsList = [.. allActions + .Where(a => !defaults.Any(d => d.ButtonText == a.ButtonText)) + .OrderBy(a => a.OrderNumber)]; + + foreach (ButtonInfo? action in availableActionsList) + { + AvailableActions.Add(action); + } + + UpdateEmptyStateVisibility(); + } + + private void SaveButton_Click(object sender, RoutedEventArgs e) + { + // Save the enabled actions + PostGrabActionManager.SavePostGrabActions([.. EnabledActions]); + + // Save the PostGrabStayOpen setting + AppUtilities.TextGrabSettings.PostGrabStayOpen = StayOpenToggle.IsChecked ?? false; + AppUtilities.TextGrabSettings.Save(); + + Close(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Close(); + } + + private void UpdateEmptyStateVisibility() + { + if (AvailableActions.Count == 0) + { + NoAvailableActionsText.Visibility = Visibility.Visible; + AvailableActionsListBox.Visibility = Visibility.Collapsed; + } + else + { + NoAvailableActionsText.Visibility = Visibility.Collapsed; + AvailableActionsListBox.Visibility = Visibility.Visible; + } + } + + #endregion Methods +} diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml index 339fcf7c..fdd869cd 100644 --- a/Text-Grab/Controls/WordBorder.xaml +++ b/Text-Grab/Controls/WordBorder.xaml @@ -89,6 +89,13 @@ Click="MakeSingleLineMenuItem_Click" Header="Make Text _Single Line" /> + + + /// Gets the system's display language name (e.g., "English", "Spanish", "French") + /// Falls back to "English" if the system language is not recognized. + /// + private static string GetSystemLanguageName() + { + // Use the shared utility method from LanguageUtilities + return LanguageUtilities.GetSystemLanguageForTranslation(); + } + + #endregion Methods } - #endregion Methods -} diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 4d286a1a..1a6a2f0a 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -4,6 +4,13 @@ namespace Text_Grab.Models; +public enum DefaultCheckState +{ + Off = 0, + LastUsed = 1, + On = 2 +} + public class ButtonInfo { public double OrderNumber { get; set; } = 0.1; @@ -16,6 +23,11 @@ public class ButtonInfo public SymbolRegular SymbolIcon { get; set; } = SymbolRegular.Diamond24; + // Post-grab action properties + public bool IsRelevantForFullscreenGrab { get; set; } = false; + public bool IsRelevantForEditWindow { get; set; } = true; // Default to true for backward compatibility + public DefaultCheckState DefaultCheckState { get; set; } = DefaultCheckState.Off; + public ButtonInfo() { @@ -31,7 +43,15 @@ public override bool Equals(object? obj) public override int GetHashCode() { - return System.HashCode.Combine(ButtonText, SymbolText, Background, Command, ClickEvent); + return System.HashCode.Combine( + ButtonText, + SymbolText, + Background, + Command, + ClickEvent, + IsRelevantForFullscreenGrab, + IsRelevantForEditWindow, + DefaultCheckState); } // a constructor which takes a collapsible button @@ -45,6 +65,9 @@ public ButtonInfo(CollapsibleButton button) Command = button.CustomButton.Command; ClickEvent = button.CustomButton.ClickEvent; IsSymbol = button.CustomButton.IsSymbol; + IsRelevantForFullscreenGrab = button.CustomButton.IsRelevantForFullscreenGrab; + IsRelevantForEditWindow = button.CustomButton.IsRelevantForEditWindow; + DefaultCheckState = button.CustomButton.DefaultCheckState; } else { @@ -65,6 +88,18 @@ public ButtonInfo(string buttonText, string symbolText, string background, strin IsSymbol = isSymbol; } + // Constructor for post-grab actions + public ButtonInfo(string buttonText, string clickEvent, SymbolRegular symbolIcon, DefaultCheckState defaultCheckState) + { + ButtonText = buttonText; + ClickEvent = clickEvent; + SymbolIcon = symbolIcon; + IsSymbol = true; + IsRelevantForFullscreenGrab = true; + IsRelevantForEditWindow = false; + DefaultCheckState = defaultCheckState; + } + public static List DefaultButtonList { get; set; } = [ new() diff --git a/Text-Grab/Models/WinAiOcrLinesWords.cs b/Text-Grab/Models/WinAiOcrLinesWords.cs index 23f80337..1cf6488e 100644 --- a/Text-Grab/Models/WinAiOcrLinesWords.cs +++ b/Text-Grab/Models/WinAiOcrLinesWords.cs @@ -10,11 +10,19 @@ public WinAiOcrLinesWords(RecognizedText recognizedText) { OriginalRecognizedText = recognizedText; Angle = recognizedText.TextAngle; - Lines = Array.ConvertAll(recognizedText.Lines, line => new WinAiOcrLine(line)); - StringBuilder sb = new(); - foreach (RecognizedLine recognizedLine in recognizedText.Lines) - sb.AppendLine(recognizedLine.Text); + + if (recognizedText.Lines is not null) + { + Lines = Array.ConvertAll(recognizedText.Lines, line => new WinAiOcrLine(line)); + + foreach (RecognizedLine recognizedLine in recognizedText.Lines) + sb.AppendLine(recognizedLine.Text); + } + else + { + Lines = []; + } Text = sb.ToString().Trim(); } diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index cb15c71c..018ef01a 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -22,4 +22,59 @@ internal static partial class NativeMethods [LibraryImport("shcore.dll")] public static partial void GetScaleFactorForMonitor(IntPtr hMon, out uint pScale); + + // HDR detection APIs + [DllImport("user32.dll")] + internal static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] + internal static extern IntPtr CreateDC(string? lpszDriver, string lpszDevice, string? lpszOutput, IntPtr lpInitData); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeleteDC(IntPtr hdc); + + [DllImport("gdi32.dll")] + internal static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + /// + /// Device capability index for GetDeviceCaps to query color management capabilities. + /// + public const int COLORMGMTCAPS = 121; + /// + /// Flag indicating that the device supports HDR (High Dynamic Range). + /// + public const int CM_HDR_SUPPORT = 0x00000001; + + [StructLayout(LayoutKind.Sequential)] + internal struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct MONITORINFOEX + { + public uint cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } } \ No newline at end of file diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index bed7611d..54a6c2a2 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -10,98 +10,139 @@ Loaded="Page_Loaded" mc:Ignorable="d"> - - - + + - + - - - - Default (Standard) - Single Line - Table - - + + + + + Default (Standard) + + + Single Line + + + Table + - - + Text="Single Line outputs captures as a single line (same as pressing S). Table mode requires Windows OCR/AI languages." /> + - - - + + - + + + - + - - - - - + + + Text="Insert delay (seconds):" /> + + - + + + + + + + + + + + + + Visibility="{Binding Visibility, ElementName=SearchBox, Mode=OneWay}" /> + + + + wordBorders = []; private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; private ScrollBehavior scrollBehavior = ScrollBehavior.Resize; + private bool isTranslationEnabled = false; + private string translationTargetLanguage = "English"; + private readonly DispatcherTimer translationTimer = new(); + private readonly Dictionary originalTexts = []; + private readonly SemaphoreSlim translationSemaphore = new(3); // Limit to 3 concurrent translations + private int totalWordsToTranslate = 0; + private int translatedWordsCount = 0; + private CancellationTokenSource? translationCancellationTokenSource; + private const string TargetLanguageMenuHeader = "Target Language"; #endregion Fields @@ -199,6 +209,9 @@ private void StandardInitialize() reSearchTimer.Interval = new(0, 0, 0, 0, 300); reSearchTimer.Tick += ReSearchTimer_Tick; + translationTimer.Interval = new(0, 0, 0, 0, 1000); + translationTimer.Tick += TranslationTimer_Tick; + _ = UndoRedo.HasUndoOperations(); _ = UndoRedo.HasRedoOperations(); @@ -439,6 +452,15 @@ public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) reDrawTimer.Stop(); reDrawTimer.Tick -= ReDrawTimer_Tick; + translationTimer.Stop(); + translationTimer.Tick -= TranslationTimer_Tick; + translationSemaphore.Dispose(); + translationCancellationTokenSource?.Cancel(); + translationCancellationTokenSource?.Dispose(); + + // Dispose the shared translation model during cleanup to prevent resource leaks + WindowsAiUtilities.DisposeTranslationModel(); + MinimizeButton.Click -= OnMinimizeButtonClick; RestoreButton.Click -= OnRestoreButtonClick; CloseButton.Click -= OnCloseButtonClick; @@ -1055,6 +1077,13 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") bmp?.Dispose(); reSearchTimer.Start(); + + // Trigger translation if enabled + if (isTranslationEnabled && WindowsAiUtilities.CanDeviceUseWinAI()) + { + translationTimer.Stop(); + translationTimer.Start(); + } } private void EditMatchesMenuItem_Click(object sender, RoutedEventArgs e) @@ -1263,6 +1292,7 @@ private void GetGrabFrameUserSettings() AlwaysUpdateEtwCheckBox.IsChecked = DefaultSettings.GrabFrameUpdateEtw; CloseOnGrabMenuItem.IsChecked = DefaultSettings.CloseFrameOnGrab; ReadBarcodesMenuItem.IsChecked = DefaultSettings.GrabFrameReadBarcodes; + GetGrabFrameTranslationSettings(); _ = Enum.TryParse(DefaultSettings.GrabFrameScrollBehavior, out scrollBehavior); SetScrollBehaviorMenuItems(); } @@ -1963,7 +1993,7 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) { foreach (WordBorder wb in wordBorders) { - int numberOfMatchesInWord = regex.Matches(wb.Word).Count; + int numberOfMatchesInWord = regex.Count(wb.Word); numberOfMatches += numberOfMatchesInWord; if (numberOfMatchesInWord > 0) @@ -2718,5 +2748,306 @@ private void ReadBarcodesMenuItem_Checked(object sender, RoutedEventArgs e) DefaultSettings.Save(); } + private void TranslateToggleButton_Click(object sender, RoutedEventArgs e) + { + if (TranslateToggleButton.IsChecked is bool isChecked) + { + isTranslationEnabled = isChecked; + EnableTranslationMenuItem.IsChecked = isChecked; + DefaultSettings.GrabFrameTranslationEnabled = isChecked; + DefaultSettings.Save(); + + if (isChecked) + { + if (!WindowsAiUtilities.CanDeviceUseWinAI()) + { + MessageBox.Show("Windows AI is not available on this device. Translation requires Windows AI support.", + "Translation Not Available", MessageBoxButton.OK, MessageBoxImage.Information); + TranslateToggleButton.IsChecked = false; + isTranslationEnabled = false; + return; + } + + // ALWAYS freeze the frame before translation to ensure static content + if (!IsFreezeMode) + { + FreezeToggleButton.IsChecked = true; + FreezeGrabFrame(); + } + + // Store original texts before translation + foreach (WordBorder wb in wordBorders.Where(wb => !originalTexts.ContainsKey(wb))) + { + originalTexts[wb] = wb.Word; + } + + // Create new cancellation token source + translationCancellationTokenSource?.Cancel(); + translationCancellationTokenSource?.Dispose(); + translationCancellationTokenSource = new CancellationTokenSource(); + + translationTimer.Start(); + } + else + { + translationTimer.Stop(); + + // Cancel any ongoing translation + translationCancellationTokenSource?.Cancel(); + + // Restore original texts + foreach (WordBorder wb in wordBorders.Where(wb => originalTexts.ContainsKey(wb))) + { + if (originalTexts.TryGetValue(wb, out string? originalText)) + wb.Word = originalText; + } + originalTexts.Clear(); + + // Dispose the translation model to free resources when not in use + WindowsAiUtilities.DisposeTranslationModel(); + } + } + } + + private void EnableTranslationMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem) + { + TranslateToggleButton.IsChecked = menuItem.IsChecked; + TranslateToggleButton_Click(TranslateToggleButton, e); + } + } + + private void TranslationLanguageMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem || menuItem.Tag is not string language) + return; + + translationTargetLanguage = language; + DefaultSettings.GrabFrameTranslationLanguage = language; + DefaultSettings.Save(); + + // Update the tooltip to show the current target language + TranslateToggleButton.ToolTip = $"Enable real-time translation to {language}"; + + // Uncheck all language menu items and check only the selected one + if (menuItem.Parent is MenuItem parentMenu) + { + foreach (object? item in parentMenu.Items) + { + if (item is MenuItem langMenuItem && langMenuItem.Tag is string) + langMenuItem.IsChecked = langMenuItem.Tag.ToString() == language; + } + } + + // Re-translate if translation is currently enabled + if (isTranslationEnabled) + { + translationTimer.Stop(); + translationTimer.Start(); + } + } + + private async void TranslationTimer_Tick(object? sender, EventArgs e) + { + translationTimer.Stop(); + + if (!isTranslationEnabled || !WindowsAiUtilities.CanDeviceUseWinAI()) + return; + + await PerformTranslationAsync(); + } + + private async Task PerformTranslationAsync() + { + if (translationCancellationTokenSource == null || translationCancellationTokenSource.IsCancellationRequested) + return; + + ShowTranslationProgress(); + + totalWordsToTranslate = wordBorders.Count; + translatedWordsCount = 0; + + CancellationToken cancellationToken = translationCancellationTokenSource.Token; + + // Translate all word borders with controlled concurrency (max 3 at a time) + List translationTasks = []; + + try + { + foreach (WordBorder wb in wordBorders) + { + if (cancellationToken.IsCancellationRequested) + break; + + // Store original text if not already stored + if (!originalTexts.ContainsKey(wb)) + originalTexts[wb] = wb.Word; + + string originalText = originalTexts[wb]; + if (!string.IsNullOrWhiteSpace(originalText)) + { + translationTasks.Add(TranslateWordBorderAsync(wb, originalText, cancellationToken)); + } + else + { + translatedWordsCount++; + UpdateTranslationProgress(); + } + } + + // Wait for all translations to complete or cancellation + // Use WhenAll with exception handling to gracefully handle cancellations + try + { + await Task.WhenAll(translationTasks); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + Debug.WriteLine("Translation tasks cancelled during WhenAll"); + } + + if (!cancellationToken.IsCancellationRequested) + { + UpdateFrameText(); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("Translation was cancelled"); + } + catch (Exception ex) + { + Debug.WriteLine($"Translation error: {ex.Message}"); + } + finally + { + HideTranslationProgress(); + } + } + + private void ShowTranslationProgress() + { + TranslationProgressBorder.Visibility = Visibility.Visible; + TranslationProgressBar.Value = 0; + TranslationProgressText.Text = "Translating..."; + TranslationCountText.Text = "0/0"; + } + + private void HideTranslationProgress() + { + TranslationProgressBorder.Visibility = Visibility.Collapsed; + } + + private void UpdateTranslationProgress() + { + if (totalWordsToTranslate == 0) + return; + + double progress = (double)translatedWordsCount / totalWordsToTranslate * 100; + TranslationProgressBar.Value = progress; + TranslationCountText.Text = $"{translatedWordsCount}/{totalWordsToTranslate}"; + } + + private async Task TranslateWordBorderAsync(WordBorder wordBorder, string originalText, CancellationToken cancellationToken) + { + try + { + await translationSemaphore.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Semaphore wait was cancelled - exit gracefully + return; + } + + try + { + // Ensure cancellation is honored immediately before starting translation + cancellationToken.ThrowIfCancellationRequested(); + + string translatedText = await WindowsAiUtilities.TranslateText(originalText, translationTargetLanguage); + + // If cancellation was requested during translation, abort before updating UI state + cancellationToken.ThrowIfCancellationRequested(); + + wordBorder.Word = translatedText; + + translatedWordsCount++; + await Dispatcher.InvokeAsync(() => UpdateTranslationProgress()); + } + catch (OperationCanceledException) + { + // Expected during cancellation - don't propagate + Debug.WriteLine($"Translation cancelled for word: {originalText}"); + } + catch (Exception ex) + { + Debug.WriteLine($"Translation failed for '{originalText}': {ex.Message}"); + // On error, keep original text (don't update word border) + } + finally + { + translationSemaphore.Release(); + } + } + + private void GetGrabFrameTranslationSettings() + { + isTranslationEnabled = DefaultSettings.GrabFrameTranslationEnabled; + translationTargetLanguage = DefaultSettings.GrabFrameTranslationLanguage; + + // Hide translation button if Windows AI is not available + bool canUseWinAI = WindowsAiUtilities.CanDeviceUseWinAI(); + TranslateToggleButton.Visibility = canUseWinAI ? Visibility.Visible : Visibility.Collapsed; + TranslationMenuItem.Visibility = canUseWinAI ? Visibility.Visible : Visibility.Collapsed; + + if (canUseWinAI) + { + TranslateToggleButton.IsChecked = isTranslationEnabled; + EnableTranslationMenuItem.IsChecked = isTranslationEnabled; + TranslateToggleButton.ToolTip = $"Enable real-time translation to {translationTargetLanguage}"; + } + else + { + // Disable translation if Windows AI is not available + isTranslationEnabled = false; + } + + // Set the checked state for the translation language menu item + // Find the "Target Language" submenu by searching through items + if (canUseWinAI && TranslationMenuItem != null) + { + foreach (object? item in TranslationMenuItem.Items) + { + if (item is MenuItem menuItem && menuItem.Header.ToString() == TargetLanguageMenuHeader) + { + foreach (object? langItem in menuItem.Items) + { + if (langItem is MenuItem langMenuItem && langMenuItem.Tag is string tag) + langMenuItem.IsChecked = tag == translationTargetLanguage; + } + break; + } + } + } + } + + private void CancelTranslationButton_Click(object sender, RoutedEventArgs e) + { + translationCancellationTokenSource?.Cancel(); + HideTranslationProgress(); + + // Restore original texts + foreach (WordBorder wb in wordBorders.Where(wb => originalTexts.ContainsKey(wb))) + { + if (originalTexts.TryGetValue(wb, out string? originalText)) + wb.Word = originalText; + } + + UpdateFrameText(); + } + #endregion Methods } diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml index 4f8a80f6..130c70de 100644 --- a/Text-Grab/Views/SettingsWindow.xaml +++ b/Text-Grab/Views/SettingsWindow.xaml @@ -58,7 +58,10 @@ - + @@ -66,7 +69,11 @@ - + + Fullscreen + + Grab + @@ -74,7 +81,10 @@ - + @@ -82,7 +92,11 @@ - + + Keyboard + + Shortcuts + @@ -90,7 +104,10 @@ - + @@ -98,7 +115,10 @@ - +