From eaa31286ee5f217f0a65a9b9900b4505a8768da0 Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Thu, 21 May 2026 08:18:28 +0100 Subject: [PATCH 01/97] feat: add Text-to-Speech post-grab action Adds a "Speak text" PostGrabAction that reads OCR output aloud using Windows.Media.SpeechSynthesis. Speaks the final transformed text after all other actions run in FullscreenGrab; in GrabFrame, speaks only when the captured text changes to avoid repeating on every OCR tick. - ITtsEngine interface for future engine swappability - WindowsSpeechEngine wraps WinRT SpeechSynthesizer + MediaPlayer - TtsService queues utterances via Channel so new text waits rather than interrupting in-progress speech - TtsSpeakWordLimit setting (default 100) truncates long captures; configurable in General Settings - PostGrabActionManager: new SpeakText_Click action at order 6.6 - GrabFrame: speaks on text change when action is checked - Tests: count updated to 6, "Speak text" assertion, fire-and-forget test Co-Authored-By: Claude Sonnet 4.6 --- Tests/PostGrabActionManagerTests.cs | 18 +++++- Text-Grab/Interfaces/ITtsEngine.cs | 9 +++ Text-Grab/Pages/GeneralSettings.xaml | 32 ++++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 23 +++++++ Text-Grab/Properties/Settings.Designer.cs | 12 ++++ Text-Grab/Properties/Settings.settings | 3 + Text-Grab/Services/TtsService.cs | 65 ++++++++++++++++++++ Text-Grab/Services/WindowsSpeechEngine.cs | 37 +++++++++++ Text-Grab/Utilities/PostGrabActionManager.cs | 16 ++++- Text-Grab/Views/GrabFrame.xaml.cs | 13 ++++ 10 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 Text-Grab/Interfaces/ITtsEngine.cs create mode 100644 Text-Grab/Services/TtsService.cs create mode 100644 Text-Grab/Services/WindowsSpeechEngine.cs diff --git a/Tests/PostGrabActionManagerTests.cs b/Tests/PostGrabActionManagerTests.cs index 8e47ca19..7a0a3b92 100644 --- a/Tests/PostGrabActionManagerTests.cs +++ b/Tests/PostGrabActionManagerTests.cs @@ -13,7 +13,7 @@ public void GetDefaultPostGrabActions_ReturnsExpectedCount() // Assert Assert.NotNull(actions); - Assert.Equal(5, actions.Count); + Assert.Equal(6, actions.Count); } [Fact] @@ -28,6 +28,7 @@ public void GetDefaultPostGrabActions_ContainsExpectedActions() 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 == "Speak text"); //Assert.Contains(actions, a => a.ButtonText == "Translate to system language"); } @@ -103,6 +104,21 @@ public async System.Threading.Tasks.Task ExecutePostGrabAction_RemoveDuplicateLi Assert.Single(lines, l => l == "Line 1"); } + [Fact] + public async Task ExecutePostGrabAction_SpeakText_ReturnsTextUnchanged() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "SpeakText_Click"); + string input = "Hello world"; + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert — TTS is fire-and-forget; text must pass through unchanged + Assert.Equal(input, result); + } + [Fact] public void GetCheckState_DefaultOff_ReturnsFalse() { diff --git a/Text-Grab/Interfaces/ITtsEngine.cs b/Text-Grab/Interfaces/ITtsEngine.cs new file mode 100644 index 00000000..2ab4f43f --- /dev/null +++ b/Text-Grab/Interfaces/ITtsEngine.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Text_Grab.Interfaces; + +public interface ITtsEngine +{ + Task SpeakAsync(string text, CancellationToken ct); +} diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 75b2bdab..d1d07341 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -387,5 +387,37 @@ Keep recent history of Grabs and Edit Text Windows + + + + + + + + diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 987e789f..168b0401 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -139,6 +139,7 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) TryInsertCheckbox.IsChecked = DefaultSettings.TryInsert; InsertDelaySeconds = DefaultSettings.InsertDelay; SecondsTextBox.Text = InsertDelaySeconds.ToString("##.#", System.Globalization.CultureInfo.InvariantCulture); + TtsSpeakWordLimitTextBox.Text = DefaultSettings.TtsSpeakWordLimit.ToString(); // Context menu integration - only available for unpackaged apps if (!AppUtilities.IsPackaged()) @@ -181,6 +182,28 @@ private void ValidateTextIsNumber(object sender, TextChangedEventArgs e) } } + private void TtsSpeakWordLimitTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (!settingsSet) + return; + + if (sender is System.Windows.Controls.TextBox textBox) + { + bool wasAbleToConvert = int.TryParse(textBox.Text, out int parsedValue); + if (wasAbleToConvert && parsedValue > 0) + { + DefaultSettings.TtsSpeakWordLimit = parsedValue; + TtsWordLimitError.Visibility = Visibility.Collapsed; + textBox.BorderBrush = GoodBrush; + } + else + { + TtsWordLimitError.Visibility = Visibility.Visible; + textBox.BorderBrush = BadBrush; + } + } + } + private void FullScreenRDBTN_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 073f399f..01b42633 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -958,5 +958,17 @@ public bool RegisterOpenWith { this["RegisterOpenWith"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("100")] + public int TtsSpeakWordLimit { + get { + return ((int)(this["TtsSpeakWordLimit"])); + } + set { + this["TtsSpeakWordLimit"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index be4eb667..86766019 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -236,5 +236,8 @@ False + + 100 + diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab/Services/TtsService.cs new file mode 100644 index 00000000..aa642854 --- /dev/null +++ b/Text-Grab/Services/TtsService.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Text_Grab.Properties; + +namespace Text_Grab.Services; + +public class TtsService +{ + private ITtsEngine _engine = new WindowsSpeechEngine(); + private readonly Channel _queue = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _cts = new(); + + public ITtsEngine Engine + { + set => _engine = value; + } + + public TtsService() + { + _ = Task.Run(DrainLoopAsync); + } + + public void Speak(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + int wordLimit = Settings.Default.TtsSpeakWordLimit; + if (wordLimit > 0) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length > wordLimit) + text = string.Join(' ', words[..wordLimit]); + } + + _queue.Writer.TryWrite(text); + } + + private async Task DrainLoopAsync() + { + CancellationToken ct = _cts.Token; + try + { + await foreach (string text in _queue.Reader.ReadAllAsync(ct)) + { + try + { + await _engine.SpeakAsync(text, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception) + { + // swallow per-item errors so the queue keeps draining + } + } + } + catch (OperationCanceledException) { } + } +} diff --git a/Text-Grab/Services/WindowsSpeechEngine.cs b/Text-Grab/Services/WindowsSpeechEngine.cs new file mode 100644 index 00000000..fa92d954 --- /dev/null +++ b/Text-Grab/Services/WindowsSpeechEngine.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Windows.Media.Core; +using Windows.Media.Playback; +using Windows.Media.SpeechSynthesis; + +namespace Text_Grab.Services; + +public class WindowsSpeechEngine : ITtsEngine +{ + public async Task SpeakAsync(string text, CancellationToken ct) + { + using SpeechSynthesizer synthesizer = new(); + SpeechSynthesisStream stream = await synthesizer.SynthesizeTextToStreamAsync(text).AsTask(); + + ct.ThrowIfCancellationRequested(); + + TaskCompletionSource tcs = new(); + + using MediaPlayer player = new(); + player.Source = MediaSource.CreateFromStream(stream, stream.ContentType); + + player.MediaEnded += (s, e) => tcs.TrySetResult(true); + player.MediaFailed += (s, e) => tcs.TrySetException(new System.Exception(e.ErrorMessage)); + + using CancellationTokenRegistration registration = ct.Register(() => + { + player.Pause(); + tcs.TrySetCanceled(); + }); + + player.Play(); + await tcs.Task; + } +} diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index 3b3c91c1..723dc416 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -6,6 +6,7 @@ using System.Windows; using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Services; using Wpf.Ui.Controls; namespace Text_Grab.Utilities; @@ -90,6 +91,15 @@ public static List GetDefaultPostGrabActions() ) { OrderNumber = 6.5 + }, + new ButtonInfo( + buttonText: "Speak text", + clickEvent: "SpeakText_Click", + symbolIcon: SymbolRegular.Speaker224, + defaultCheckState: DefaultCheckState.Off + ) + { + OrderNumber = 6.6 } //, //new ButtonInfo( @@ -99,7 +109,7 @@ public static List GetDefaultPostGrabActions() // defaultCheckState: DefaultCheckState.Off //) //{ - // OrderNumber = 6.6 + // OrderNumber = 6.7 //} ]; } @@ -205,6 +215,10 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, PostGr // Don't modify the text break; + case "SpeakText_Click": + Singleton.Instance.Speak(text); + break; + case "Translate_Click": if (WindowsAiUtilities.CanDeviceUseWinAI()) { diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a19092cc..db90971e 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -93,6 +93,7 @@ public partial class GrabFrame : Window private int translatedWordsCount = 0; private CancellationTokenSource? translationCancellationTokenSource; private const string TargetLanguageMenuHeader = "Target Language"; + private string _lastSpokenFrameText = string.Empty; #endregion Fields @@ -3474,6 +3475,18 @@ private void UpdateFrameText() FrameText = stringBuilder.ToString(); + // Speak if TTS action is enabled, checked, and text has changed + ButtonInfo? speakAction = PostGrabActionManager.GetEnabledPostGrabActions() + .FirstOrDefault(a => a.ClickEvent == "SpeakText_Click"); + if (speakAction is not null + && PostGrabActionManager.GetCheckState(speakAction) + && FrameText != _lastSpokenFrameText + && !string.IsNullOrWhiteSpace(FrameText)) + { + _lastSpokenFrameText = FrameText; + Singleton.Instance.Speak(FrameText); + } + if (IsFromEditWindow && destinationTextBox is not null && AlwaysUpdateEtwCheckBox.IsChecked is true From f1a8a697176cd559a0d3092e03cc8e90011c972a Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Fri, 22 May 2026 11:36:54 +0100 Subject: [PATCH 02/97] feat: speak captured text instead of showing notification Adds SpeakInsteadOfToast setting: when enabled, Text Grab speaks the captured text aloud rather than chiming a notification toast. Defaults to enabled. Includes: - Stop-speaking button in GrabFrame to cancel playback mid-sentence - TTS drain-on-shutdown fix so the queue empties cleanly on exit - Stop() method on TtsService to flush the queue and cancel speech --- Text-Grab/Controls/WordBorder.xaml.cs | 6 ++++- Text-Grab/Pages/GeneralSettings.xaml | 11 ++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 17 +++++++++++++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++- Text-Grab/Properties/Settings.settings | 3 +++ Text-Grab/Services/TtsService.cs | 28 +++++++++++++++++--- Text-Grab/Utilities/OutputUtilities.cs | 5 +++- Text-Grab/Utilities/WindowUtilities.cs | 16 +++++++++++- Text-Grab/Views/GrabFrame.xaml | 14 ++++++++++ Text-Grab/Views/GrabFrame.xaml.cs | 31 ++++++++++++++++++++--- 10 files changed, 134 insertions(+), 11 deletions(-) diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 44a80425..a7856005 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Media; using System.Windows.Threading; using Text_Grab.Models; +using Text_Grab.Services; using Text_Grab.Utilities; using Text_Grab.Views; @@ -449,7 +450,10 @@ private void WordBorderControl_MouseDoubleClick(object sender, MouseButtonEventA try { Clipboard.SetDataObject(Word, true); } catch { } - if (AppUtilities.TextGrabSettings.ShowToast + if (AppUtilities.TextGrabSettings.SpeakInsteadOfToast + && !IsFromEditWindow) + Singleton.Instance.Speak(Word); + else if (AppUtilities.TextGrabSettings.ShowToast && !IsFromEditWindow) NotificationUtilities.ShowToast(Word); diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index d1d07341..41a85ce9 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -127,6 +127,17 @@ Clicking the notification opens the copied text into a new Edit Text Window to display and edit text. + + + Speak text instead of showing notification. + + + + Speaks the grabbed text aloud rather than chiming a notification. + .Instance.DefaultSearcher; ShowToastCheckBox.IsChecked = DefaultSettings.ShowToast; + SpeakInsteadOfToastCheckBox.IsChecked = DefaultSettings.SpeakInsteadOfToast; RunInBackgroundChkBx.IsChecked = DefaultSettings.RunInTheBackground; ReadBarcodesBarcode.IsChecked = DefaultSettings.TryToReadBarcodes; @@ -406,6 +407,22 @@ private void ShowToastCheckBox_Unchecked(object sender, RoutedEventArgs e) DefaultSettings.ShowToast = false; } + private void SpeakInsteadOfToastCheckBox_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.SpeakInsteadOfToast = true; + } + + private void SpeakInsteadOfToastCheckBox_Unchecked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.SpeakInsteadOfToast = false; + } + private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (!settingsSet diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 01b42633..8a2b8a9e 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -46,7 +46,19 @@ public bool ShowToast { this["ShowToast"] = value; } } - + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool SpeakInsteadOfToast { + get { + return ((bool)(this["SpeakInsteadOfToast"])); + } + set { + this["SpeakInsteadOfToast"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("Fullscreen")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 86766019..1e27355c 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -8,6 +8,9 @@ True + + True + Fullscreen diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab/Services/TtsService.cs index aa642854..5501125e 100644 --- a/Text-Grab/Services/TtsService.cs +++ b/Text-Grab/Services/TtsService.cs @@ -12,6 +12,11 @@ public class TtsService private ITtsEngine _engine = new WindowsSpeechEngine(); private readonly Channel _queue = Channel.CreateUnbounded(); private readonly CancellationTokenSource _cts = new(); + private CancellationTokenSource _speechCts = new(); + private int _pendingCount = 0; + + public event Action? Drained; + public bool IsBusy => Volatile.Read(ref _pendingCount) > 0; public ITtsEngine Engine { @@ -36,28 +41,43 @@ public void Speak(string text) text = string.Join(' ', words[..wordLimit]); } + Interlocked.Increment(ref _pendingCount); _queue.Writer.TryWrite(text); } + public void Stop() + { + _speechCts.Cancel(); + _speechCts = new CancellationTokenSource(); + + while (_queue.Reader.TryRead(out _)) + Interlocked.Decrement(ref _pendingCount); + } + private async Task DrainLoopAsync() { - CancellationToken ct = _cts.Token; + CancellationToken lifecycleCt = _cts.Token; try { - await foreach (string text in _queue.Reader.ReadAllAsync(ct)) + await foreach (string text in _queue.Reader.ReadAllAsync(lifecycleCt)) { try { - await _engine.SpeakAsync(text, ct); + await _engine.SpeakAsync(text, _speechCts.Token); } catch (OperationCanceledException) { - break; + // speech was stopped; continue so the loop can drain remaining items } catch (Exception) { // swallow per-item errors so the queue keeps draining } + finally + { + if (Interlocked.Decrement(ref _pendingCount) == 0) + Drained?.Invoke(); + } } } catch (OperationCanceledException) { } diff --git a/Text-Grab/Utilities/OutputUtilities.cs b/Text-Grab/Utilities/OutputUtilities.cs index 20475866..d80c3ccd 100644 --- a/Text-Grab/Utilities/OutputUtilities.cs +++ b/Text-Grab/Utilities/OutputUtilities.cs @@ -1,5 +1,6 @@ using System.Windows; using System.Windows.Controls; +using Text_Grab.Services; namespace Text_Grab.Utilities; @@ -24,7 +25,9 @@ public static void HandleTextFromOcr(string grabbedText, bool isSingleLine, bool if (!AppUtilities.TextGrabSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(grabbedText, true); } catch { } - if (AppUtilities.TextGrabSettings.ShowToast) + if (AppUtilities.TextGrabSettings.SpeakInsteadOfToast) + Singleton.Instance.Speak(grabbedText); + else if (AppUtilities.TextGrabSettings.ShowToast) NotificationUtilities.ShowToast(grabbedText); WindowUtilities.ShouldShutDown(); diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index bcf95aed..f9108e85 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -9,6 +9,7 @@ using System.Windows.Input; using System.Windows.Media; using Text_Grab.Extensions; +using Text_Grab.Services; using Text_Grab.Views; using static OSInterop; @@ -352,7 +353,20 @@ public static void ShouldShutDown() shouldShutDown = true; if (shouldShutDown) - Application.Current.Shutdown(); + { + TtsService tts = Singleton.Instance; + if (tts.IsBusy) + { + void onDrained() + { + tts.Drained -= onDrained; + Application.Current.Dispatcher.Invoke(Application.Current.Shutdown); + } + tts.Drained += onDrained; + } + else + Application.Current.Shutdown(); + } } public static bool GetMousePosition(out Point mousePosition) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index e6275d84..fee39c55 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -751,6 +751,7 @@ + + + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index db90971e..8277b0ac 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -799,12 +799,16 @@ public async void GrabFrame_Loaded(object sender, RoutedEventArgs e) CheckBottomRowButtonsVis(); + Singleton.Instance.Drained += OnTtsDrained; + if (historyItem is not null) await LoadContentFromHistory(historyItem); } public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) { + Singleton.Instance.Drained -= OnTtsDrained; + Activated -= GrabFrameWindow_Activated; Closed -= Window_Closed; Deactivated -= GrabFrameWindow_Deactivated; @@ -3484,7 +3488,7 @@ private void UpdateFrameText() && !string.IsNullOrWhiteSpace(FrameText)) { _lastSpokenFrameText = FrameText; - Singleton.Instance.Speak(FrameText); + SpeakAndShowStopButton(FrameText); } if (IsFromEditWindow @@ -3658,7 +3662,9 @@ private async void GrabExecuted(object sender, ExecutedRoutedEventArgs e) if (!DefaultSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(outputText, true); } catch { } - if (DefaultSettings.ShowToast) + if (DefaultSettings.SpeakInsteadOfToast) + SpeakAndShowStopButton(outputText); + else if (DefaultSettings.ShowToast) NotificationUtilities.ShowToast(outputText); if (CloseOnGrabMenuItem.IsChecked) @@ -3689,13 +3695,32 @@ private void GrabTrimExecuted(object sender, ExecutedRoutedEventArgs e) if (!DefaultSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(trimmedSingleLineFrameText, true); } catch { } - if (DefaultSettings.ShowToast) + if (DefaultSettings.SpeakInsteadOfToast) + SpeakAndShowStopButton(trimmedSingleLineFrameText); + else if (DefaultSettings.ShowToast) NotificationUtilities.ShowToast(trimmedSingleLineFrameText); if (CloseOnGrabMenuItem.IsChecked) Close(); } + private void SpeakAndShowStopButton(string text) + { + Singleton.Instance.Speak(text); + StopSpeakingBTN.Visibility = Visibility.Visible; + } + + private void OnTtsDrained() + { + Dispatcher.Invoke(() => StopSpeakingBTN.Visibility = Visibility.Collapsed); + } + + private void StopSpeakingBTN_Click(object sender, RoutedEventArgs e) + { + Singleton.Instance.Stop(); + StopSpeakingBTN.Visibility = Visibility.Collapsed; + } + private void ScrollBehaviorMenuItem_Click(object sender, RoutedEventArgs e) { if (sender is not MenuItem menuItem || !Enum.TryParse(menuItem.Tag.ToString(), out scrollBehavior)) From 4ee634bda1aab3892902b88d32d6c3529d33f873 Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Fri, 22 May 2026 11:37:35 +0100 Subject: [PATCH 03/97] feat: add Voice Output settings page with voice selection Adds a dedicated Voice Output page in Settings containing: - Voice picker populated from SpeechSynthesizer.AllVoices - Word limit setting - Speak-instead-of-notification toggle (moved from General Settings) - Preview button to hear the selected voice WindowsSpeechEngine now applies the saved TtsVoiceName before synthesising. TtsVoiceName setting added (empty = system default). --- Text-Grab/App.config | 3 + Text-Grab/Pages/GeneralSettings.xaml | 43 --------- Text-Grab/Pages/GeneralSettings.xaml.cs | 41 -------- Text-Grab/Pages/VoiceOutputSettings.xaml | 100 ++++++++++++++++++++ Text-Grab/Pages/VoiceOutputSettings.xaml.cs | 82 ++++++++++++++++ Text-Grab/Properties/Settings.Designer.cs | 12 +++ Text-Grab/Properties/Settings.settings | 5 +- Text-Grab/Services/WindowsSpeechEngine.cs | 12 +++ Text-Grab/Views/SettingsWindow.xaml | 13 +++ 9 files changed, 226 insertions(+), 85 deletions(-) create mode 100644 Text-Grab/Pages/VoiceOutputSettings.xaml create mode 100644 Text-Grab/Pages/VoiceOutputSettings.xaml.cs diff --git a/Text-Grab/App.config b/Text-Grab/App.config index a1c719d6..d6f8aa89 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -52,6 +52,9 @@ False + + + False diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 41a85ce9..869a5cf3 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -127,18 +127,6 @@ Clicking the notification opens the copied text into a new Edit Text Window to display and edit text. - - - Speak text instead of showing notification. - - - - Speaks the grabbed text aloud rather than chiming a notification. - - - - - - - - - diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index a92755a8..b419c50c 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -129,7 +129,6 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) WebSearchersComboBox.SelectedItem = Singleton.Instance.DefaultSearcher; ShowToastCheckBox.IsChecked = DefaultSettings.ShowToast; - SpeakInsteadOfToastCheckBox.IsChecked = DefaultSettings.SpeakInsteadOfToast; RunInBackgroundChkBx.IsChecked = DefaultSettings.RunInTheBackground; ReadBarcodesBarcode.IsChecked = DefaultSettings.TryToReadBarcodes; @@ -140,8 +139,6 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) TryInsertCheckbox.IsChecked = DefaultSettings.TryInsert; InsertDelaySeconds = DefaultSettings.InsertDelay; SecondsTextBox.Text = InsertDelaySeconds.ToString("##.#", System.Globalization.CultureInfo.InvariantCulture); - TtsSpeakWordLimitTextBox.Text = DefaultSettings.TtsSpeakWordLimit.ToString(); - // Context menu integration - only available for unpackaged apps if (!AppUtilities.IsPackaged()) { @@ -183,28 +180,6 @@ private void ValidateTextIsNumber(object sender, TextChangedEventArgs e) } } - private void TtsSpeakWordLimitTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - if (!settingsSet) - return; - - if (sender is System.Windows.Controls.TextBox textBox) - { - bool wasAbleToConvert = int.TryParse(textBox.Text, out int parsedValue); - if (wasAbleToConvert && parsedValue > 0) - { - DefaultSettings.TtsSpeakWordLimit = parsedValue; - TtsWordLimitError.Visibility = Visibility.Collapsed; - textBox.BorderBrush = GoodBrush; - } - else - { - TtsWordLimitError.Visibility = Visibility.Visible; - textBox.BorderBrush = BadBrush; - } - } - } - private void FullScreenRDBTN_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) @@ -407,22 +382,6 @@ private void ShowToastCheckBox_Unchecked(object sender, RoutedEventArgs e) DefaultSettings.ShowToast = false; } - private void SpeakInsteadOfToastCheckBox_Checked(object sender, RoutedEventArgs e) - { - if (!settingsSet) - return; - - DefaultSettings.SpeakInsteadOfToast = true; - } - - private void SpeakInsteadOfToastCheckBox_Unchecked(object sender, RoutedEventArgs e) - { - if (!settingsSet) - return; - - DefaultSettings.SpeakInsteadOfToast = false; - } - private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (!settingsSet diff --git a/Text-Grab/Pages/VoiceOutputSettings.xaml b/Text-Grab/Pages/VoiceOutputSettings.xaml new file mode 100644 index 00000000..613a6279 --- /dev/null +++ b/Text-Grab/Pages/VoiceOutputSettings.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + Speak text instead of showing notification + + + + Speaks the grabbed text aloud rather than showing a notification. + + + + + + Choose from the voices installed on this device. + + + + + + + Stop speaking after this many words. Set to 0 for no limit. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/SearchBar.xaml.cs b/Text-Grab/Controls/SearchBar.xaml.cs new file mode 100644 index 00000000..020d4b9a --- /dev/null +++ b/Text-Grab/Controls/SearchBar.xaml.cs @@ -0,0 +1,290 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using Text_Grab.Models; + +namespace Text_Grab.Controls; + +/// +/// A shared search input used by Quick Simple Lookup, Find & Replace, and Grab Frame. It bundles +/// the free-text box, a regex icon toggle (and optional exact-match toggle), a removable +/// smart-pattern "chip", and the unified picker so all three search +/// surfaces look and behave the same. Each host keeps its own search/filter engine and debounce — +/// this control only owns the inputs and raises when any of them change. +/// +public partial class SearchBar : UserControl +{ + private const string RegexToolTip = "Search using Regular Expression syntax"; + + /// Suppresses while several inputs are updated as one action. + private bool suppressSearchChanged; + + public SearchBar() + { + InitializeComponent(); + UpdateAdornments(); + } + + /// Raised whenever the search text, regex/exact toggles, or selected pattern change. + public event EventHandler? SearchChanged; + + /// Raised only when the exact-match toggle changes (hosts that adjust case handling subscribe to this). + public event EventHandler? ExactMatchChanged; + + #region Dependency properties + + public string SearchText + { + get => (string)GetValue(SearchTextProperty); + set => SetValue(SearchTextProperty, value); + } + + public static readonly DependencyProperty SearchTextProperty = + DependencyProperty.Register(nameof(SearchText), typeof(string), typeof(SearchBar), + new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSearchTextChanged)); + + public bool UseRegex + { + get => (bool)GetValue(UseRegexProperty); + set => SetValue(UseRegexProperty, value); + } + + public static readonly DependencyProperty UseRegexProperty = + DependencyProperty.Register(nameof(UseRegex), typeof(bool), typeof(SearchBar), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnUseRegexChanged)); + + public bool ExactMatch + { + get => (bool)GetValue(ExactMatchProperty); + set => SetValue(ExactMatchProperty, value); + } + + public static readonly DependencyProperty ExactMatchProperty = + DependencyProperty.Register(nameof(ExactMatch), typeof(bool), typeof(SearchBar), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnExactMatchChanged)); + + /// When true the exact-match toggle is visible (used by Grab Frame). Hidden by default. + public bool ShowExactMatchToggle + { + get => (bool)GetValue(ShowExactMatchToggleProperty); + set => SetValue(ShowExactMatchToggleProperty, value); + } + + public static readonly DependencyProperty ShowExactMatchToggleProperty = + DependencyProperty.Register(nameof(ShowExactMatchToggle), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + /// The active recognizer shown as a chip, or null. Saved regexes do not set this (they load into the text box). + public PatternItem? SelectedPattern + { + get => (PatternItem?)GetValue(SelectedPatternProperty); + set => SetValue(SelectedPatternProperty, value); + } + + public static readonly DependencyProperty SelectedPatternProperty = + DependencyProperty.Register(nameof(SelectedPattern), typeof(PatternItem), typeof(SearchBar), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedPatternChanged)); + + public string PlaceholderText + { + get => (string)GetValue(PlaceholderTextProperty); + set => SetValue(PlaceholderTextProperty, value); + } + + public static readonly DependencyProperty PlaceholderTextProperty = + DependencyProperty.Register(nameof(PlaceholderText), typeof(string), typeof(SearchBar), + new PropertyMetadata("Type to search...")); + + public bool AcceptsReturn + { + get => (bool)GetValue(AcceptsReturnProperty); + set => SetValue(AcceptsReturnProperty, value); + } + + public static readonly DependencyProperty AcceptsReturnProperty = + DependencyProperty.Register(nameof(AcceptsReturn), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + public bool AcceptsTab + { + get => (bool)GetValue(AcceptsTabProperty); + set => SetValue(AcceptsTabProperty, value); + } + + public static readonly DependencyProperty AcceptsTabProperty = + DependencyProperty.Register(nameof(AcceptsTab), typeof(bool), typeof(SearchBar), + new PropertyMetadata(false)); + + #endregion Dependency properties + + #region Public API + + /// Colors the split-button border red on an invalid pattern and sets a matching tooltip. + public void SetRegexValidity(bool isValid, string? toolTip = null) + { + if (isValid) + RegexSplitContainer.ClearValue(Border.BorderBrushProperty); // let the style/checked trigger drive the border + else + RegexSplitContainer.BorderBrush = Brushes.Red; + + RegExToggleButton.ToolTip = toolTip ?? (isValid ? RegexToolTip : "Invalid Regular Expression"); + } + + /// The underlying text box, for hosts that need the control directly (focus, OCR target, caret, etc.). + public TextBox TextBox => InnerTextBox; + + /// Focuses the text box and places the caret at the end. + public void FocusInput() + { + InnerTextBox.Focus(); + InnerTextBox.CaretIndex = InnerTextBox.Text.Length; + } + + #endregion Public API + + #region Change handlers + + private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.UpdateAdornments(); + bar.RaiseSearchChanged(); + } + + private static void OnUseRegexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.SetRegexValidity(true); + bar.RaiseSearchChanged(); + } + + private static void OnExactMatchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.ExactMatchChanged?.Invoke(bar, EventArgs.Empty); + bar.RaiseSearchChanged(); + } + + private static void OnSelectedPatternChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SearchBar bar = (SearchBar)d; + bar.UpdateChip(); + bar.UpdateAdornments(); + bar.RaiseSearchChanged(); + } + + private void RaiseSearchChanged() + { + if (!suppressSearchChanged) + SearchChanged?.Invoke(this, EventArgs.Empty); + } + + private void UpdateChip() + { + if (PatternChip is null) + return; + + if (SelectedPattern is not null) + { + PatternChipText.Text = SelectedPattern.Name; + PatternChip.Visibility = Visibility.Visible; + } + else + { + PatternChip.Visibility = Visibility.Collapsed; + } + } + + private void UpdateAdornments() + { + if (ClearButton is null) + return; + + ClearButton.Visibility = string.IsNullOrEmpty(SearchText) ? Visibility.Collapsed : Visibility.Visible; + PlaceholderTextBlock.Visibility = string.IsNullOrEmpty(SearchText) && SelectedPattern is null + ? Visibility.Visible + : Visibility.Collapsed; + } + + private void PatternDropDownButton_Click(object sender, RoutedEventArgs e) + { + PatternMenu.PlacementTarget = PatternDropDownButton; + PatternMenu.Placement = PlacementMode.Bottom; + PatternMenu.IsOpen = true; + } + + // Rebuild on each open so newly saved regexes appear. Headers are non-selectable. + private void PatternMenu_Opened(object sender, RoutedEventArgs e) + { + PatternMenu.Items.Clear(); + + string? currentGroup = null; + foreach (PatternItem pattern in PatternItem.GetAll()) + { + if (pattern.GroupLabel != currentGroup) + { + currentGroup = pattern.GroupLabel; + if (PatternMenu.Items.Count > 0) + PatternMenu.Items.Add(new Separator()); + PatternMenu.Items.Add(new MenuItem { Header = currentGroup, IsEnabled = false }); + } + + MenuItem item = new() + { + Header = pattern.Name, + ToolTip = string.IsNullOrWhiteSpace(pattern.Description) ? null : pattern.Description, + Tag = pattern, + }; + item.Click += PatternMenuItem_Click; + PatternMenu.Items.Add(item); + } + } + + private void PatternMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem { Tag: PatternItem pattern }) + ApplyPickedPattern(pattern); + } + + /// + /// Applies a pattern chosen from the dropdown: a saved regex loads into the text box and turns + /// on the regex toggle; a smart pattern (recognizer) becomes a chip with typing allowed to narrow. + /// + private void ApplyPickedPattern(PatternItem pattern) + { + suppressSearchChanged = true; + + if (pattern is { Kind: PatternKind.SavedRegex, SavedRegex: { } savedRegex }) + { + SelectedPattern = null; + SearchText = savedRegex.Pattern; + UseRegex = true; + } + else + { + SearchText = string.Empty; + SelectedPattern = pattern; + } + + suppressSearchChanged = false; + + RaiseSearchChanged(); + FocusInput(); + } + + private void ChipClearButton_Click(object sender, RoutedEventArgs e) + { + SelectedPattern = null; + FocusInput(); + } + + private void ClearButton_Click(object sender, RoutedEventArgs e) + { + SearchText = string.Empty; + FocusInput(); + } + + #endregion Change handlers +} diff --git a/Text-Grab/Styles/ButtonStyles.xaml b/Text-Grab/Styles/ButtonStyles.xaml index 10b109ce..66984434 100644 --- a/Text-Grab/Styles/ButtonStyles.xaml +++ b/Text-Grab/Styles/ButtonStyles.xaml @@ -93,6 +93,102 @@ + + + + + + + + - - - - - Use Regex - - + /// Loads text into the shared search bar (optionally enabling regex) and places the caret at + /// the end. Used by other windows that open Find & Replace pre-filled with a pattern. + /// + public void SetFindText(string text, bool useRegex = false) + { + SearchBar.SearchText = text; + if (useRegex) + SearchBar.UseRegex = true; + SearchBar.FocusInput(); + } + public void SearchForText() { if (IsSpreadsheetSearch) { SearchSpreadsheetCells(); return; } @@ -95,30 +106,31 @@ public void SearchForText() // Recognizers are find-only (no regex replace). A saved regex, by contrast, has already // been loaded into the find box, so it flows through the normal regex search below. - if (GetSelectedPattern() is { Kind: PatternKind.Recognizer, Recognizer: { } selectedRecognizer }) + // When a recognizer chip is active, any typed text narrows its matches. + if (SearchBar.SelectedPattern is { Kind: PatternKind.Recognizer, Recognizer: { } selectedRecognizer }) { - SearchByRecognizer(selectedRecognizer); + SearchByRecognizer(selectedRecognizer, SearchBar.SearchText); return; } - if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (!TextSearchUtilities.HasSearchText(SearchBar.SearchText)) { Matches = null; MatchesText.Text = "0 Matches"; return; } - Pattern = FindTextBox.Text; + Pattern = SearchBar.SearchText; // Auto-detect regex pattern: if starts with ^ and ends with $, enable regex mode and strip anchors if (Pattern.StartsWith('^') && Pattern.EndsWith('$') && Pattern.Length > 2) { - UsePatternCheckBox.IsChecked = true; + SearchBar.UseRegex = true; Pattern = Pattern[1..^1]; // Strip ^ from start and $ from end } - if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) - Pattern = Pattern.EscapeSpecialRegexChars(matchExactly); + if (!SearchBar.UseRegex) + Pattern = Pattern.EscapeSpecialRegexChars(SearchBar.ExactMatch); if (string.IsNullOrEmpty(StringFromWindow) && TextEditWindow is not null) StringFromWindow = TextEditWindow.GetSelectedTextOrAllText(); @@ -127,8 +139,8 @@ public void SearchForText() { // When using pattern mode with inline flags, rely on the inline flags for case sensitivity // Otherwise, use RegexOptions for backward compatibility - bool usingPatternMode = UsePatternCheckBox.IsChecked is true; - bool exactMatch = ExactMatchCheckBox.IsChecked is true; + bool usingPatternMode = SearchBar.UseRegex; + bool exactMatch = SearchBar.ExactMatch; Regex regex = TextSearchUtilities.CreateFindAndReplaceSearchRegex(Pattern, usingPatternMode, exactMatch); Matches = regex.Matches(StringFromWindow); } @@ -193,41 +205,13 @@ public void SearchForText() } } - /// Sentinel item shown when no pattern is selected. - private const string NoPatternLabel = "Pattern…"; - - private void PopulatePatternComboBox() - { - PatternComboBox.ItemsSource = PatternItem.BuildComboChoices(NoPatternLabel); - PatternComboBox.SelectedIndex = 0; - } - - /// Returns the pattern chosen in the combo box, or null when none is selected. - private PatternItem? GetSelectedPattern() - => (PatternComboBox.SelectedItem as PatternChoice)?.Pattern; - - private void PatternComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!IsLoaded) - return; - - // A saved regex loads into the find box and runs as a normal regex search, so replace - // and match navigation keep working. Recognizers stay find-only (handled in SearchForText). - if (GetSelectedPattern() is { Kind: PatternKind.SavedRegex, SavedRegex: { } savedRegex }) - { - FindTextBox.Text = savedRegex.Pattern; - UsePatternCheckBox.IsChecked = true; - } - - SearchForText(); - } - /// /// Finds every entity the recognizer detects in the source text and lists them as - /// s. Leaves null (like spreadsheet search), - /// so regex-based replace/navigation is disabled in recognizer mode. + /// s. When is supplied, only matches + /// whose text contains it are kept (the chip + free-text case). Leaves + /// null (like spreadsheet search), so regex-based replace/navigation is disabled in recognizer mode. /// - private void SearchByRecognizer(BuiltInRecognizer recognizer) + private void SearchByRecognizer(BuiltInRecognizer recognizer, string narrowText = "") { if (string.IsNullOrEmpty(StringFromWindow) && TextEditWindow is not null) StringFromWindow = TextEditWindow.GetSelectedTextOrAllText(); @@ -236,6 +220,9 @@ private void SearchByRecognizer(BuiltInRecognizer recognizer) IReadOnlyList recognizerMatches = RecognizerExecutor.GetMatches(recognizer, StringFromWindow); + if (!string.IsNullOrEmpty(narrowText)) + recognizerMatches = [.. recognizerMatches.Where(m => m.Text.Contains(narrowText, StringComparison.CurrentCultureIgnoreCase))]; + if (recognizerMatches.Count == 0) { MatchesText.Text = "0 Matches"; @@ -274,16 +261,16 @@ private void SearchByRecognizer(BuiltInRecognizer recognizer) private Regex? BuildCurrentRegex() { - string rawPattern = FindTextBox.Text; + string rawPattern = SearchBar.SearchText; if (!TextSearchUtilities.HasSearchText(rawPattern)) return null; if (rawPattern.StartsWith('^') && rawPattern.EndsWith('$') && rawPattern.Length > 2) rawPattern = rawPattern[1..^1]; - if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) - rawPattern = rawPattern.EscapeSpecialRegexChars(matchExactly); + if (!SearchBar.UseRegex) + rawPattern = rawPattern.EscapeSpecialRegexChars(SearchBar.ExactMatch); - try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, ExactMatchCheckBox.IsChecked is true); } + try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, SearchBar.ExactMatch); } catch { return null; } } @@ -293,7 +280,7 @@ private void SearchSpreadsheetCells() ResultsListView.ItemsSource = null; Matches = null; - if (textEditWindow is null || !TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (textEditWindow is null || !TextSearchUtilities.HasSearchText(SearchBar.SearchText)) { MatchesText.Text = "0 Matches"; return; @@ -345,11 +332,11 @@ private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs { if (IsSpreadsheetSearch) { - e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); + e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(SearchBar.SearchText); return; } - if (Matches is null || Matches.Count < 1 || string.IsNullOrEmpty(FindTextBox.Text)) + if (Matches is null || Matches.Count < 1 || string.IsNullOrEmpty(SearchBar.SearchText)) e.CanExecute = false; else e.CanExecute = true; @@ -381,11 +368,11 @@ private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e) { if (IsSpreadsheetSearch) { - e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); + e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(SearchBar.SearchText); return; } - if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(FindTextBox.Text)) + if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(SearchBar.SearchText)) e.CanExecute = true; else e.CanExecute = false; @@ -470,15 +457,15 @@ private void ExtractPattern_Executed(object sender, ExecutedRoutedEventArgs e) string? selection = textEditWindow.PassedTextControl.SelectedText; // Generate all precision levels from the selected text - // Use inverse of ExactMatchCheckBox: when exact match is OFF, ignore case - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + // Use inverse of the exact-match toggle: when exact match is OFF, ignore case + bool ignoreCase = !SearchBar.ExactMatch; extractedPattern = new ExtractedPattern(selection, ignoreCase); int precisionLevel = (int)PrecisionSlider.Value; string simplePattern = extractedPattern.GetPattern(precisionLevel); - UsePatternCheckBox.IsChecked = true; - FindTextBox.Text = simplePattern; + SearchBar.UseRegex = true; + SearchBar.SearchText = simplePattern; // Show the slider now that we have an extracted pattern PrecisionSliderPanel.Visibility = Visibility.Visible; @@ -488,13 +475,13 @@ private void ExtractPattern_Executed(object sender, ExecutedRoutedEventArgs e) private void FindAndReplacedLoaded(object sender, RoutedEventArgs e) { - if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (TextSearchUtilities.HasSearchText(SearchBar.SearchText)) SearchForText(); // Update save button visibility on load UpdateSaveButtonVisibility(); - FindTextBox.Focus(); + SearchBar.FocusInput(); } private void FindTextBox_KeyUp(object sender, KeyEventArgs e) @@ -534,9 +521,9 @@ private void MoreOptionsToggleButton_Click(object sender, RoutedEventArgs e) SetExtraOptionsVisibility(optionsVisibility); } - private void OptionsChangedRefresh(object sender, RoutedEventArgs e) + private void OptionsChangedRefresh(object? sender, EventArgs e) { - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + bool ignoreCase = !SearchBar.ExactMatch; // If we have an extracted pattern and the case sensitivity changed, update it if (extractedPattern is not null) @@ -547,13 +534,13 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) // Update the FindTextBox with the regenerated pattern int precisionLevel = (int)PrecisionSlider.Value; - FindTextBox.Text = extractedPattern.GetPattern(precisionLevel); + SearchBar.SearchText = extractedPattern.GetPattern(precisionLevel); } } - else if (UsePatternCheckBox.IsChecked is true && TextSearchUtilities.HasSearchText(FindTextBox.Text)) + else if (SearchBar.UseRegex && TextSearchUtilities.HasSearchText(SearchBar.SearchText)) { // No extracted pattern, but we're in pattern mode - manually toggle (?i) flag - string currentPattern = FindTextBox.Text; + string currentPattern = SearchBar.SearchText; bool hasIgnoreCaseFlag = currentPattern.StartsWith("(?i)"); bool hasCaseSensitiveFlag = currentPattern.StartsWith("(?-i)"); @@ -563,18 +550,18 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) if (hasCaseSensitiveFlag) { // Replace (?-i) with (?i) - FindTextBox.Text = "(?i)" + currentPattern[5..]; + SearchBar.SearchText = "(?i)" + currentPattern[5..]; } else { // Add (?i) at the beginning - FindTextBox.Text = $"(?i){currentPattern}"; + SearchBar.SearchText = $"(?i){currentPattern}"; } } else if (!ignoreCase && hasIgnoreCaseFlag) { // Need case-sensitive: remove (?i) flag - FindTextBox.Text = currentPattern[4..]; + SearchBar.SearchText = currentPattern[4..]; } } @@ -829,7 +816,7 @@ private void SetExtraOptionsVisibility(Visibility optionsVisibility) private void TextSearch_CanExecute(object sender, CanExecuteRoutedEventArgs e) { - if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) + if (!TextSearchUtilities.HasSearchText(SearchBar.SearchText)) e.CanExecute = false; else e.CanExecute = true; @@ -850,8 +837,8 @@ private void Window_KeyUp(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { - if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) - FindTextBox.Clear(); + if (TextSearchUtilities.HasSearchText(SearchBar.SearchText)) + SearchBar.SearchText = string.Empty; else this.Close(); } @@ -868,7 +855,7 @@ private void PrecisionSlider_ValueChanged(object sender, RoutedPropertyChangedEv return; // Only update if regex mode is enabled - if (UsePatternCheckBox?.IsChecked is not true) + if (!SearchBar.UseRegex) return; int precisionLevel = (int)e.NewValue; @@ -876,7 +863,7 @@ private void PrecisionSlider_ValueChanged(object sender, RoutedPropertyChangedEv // Get the pre-generated pattern at this precision level (instant, no recalculation!) string pattern = extractedPattern.GetPattern(precisionLevel); - FindTextBox.Text = pattern; + SearchBar.SearchText = pattern; // Use debounced search instead of immediate search PrecisionSliderTimer.Stop(); @@ -893,7 +880,7 @@ private void ManageRegexButton_Click(object sender, RoutedEventArgs e) private void SavePatternButton_Click(object sender, RoutedEventArgs e) { // Get the current pattern from the FindTextBox - string pattern = FindTextBox.Text; + string pattern = SearchBar.SearchText; if (string.IsNullOrWhiteSpace(pattern)) return; @@ -915,10 +902,19 @@ private void SavePatternButton_Click(object sender, RoutedEventArgs e) regexManager.AddPatternFromText(pattern, sourceText, textEditWindow); } - private void UsePatternCheckBox_CheckedChanged(object sender, RoutedEventArgs e) + /// + /// Re-runs the search (debounced) whenever the shared search bar's text, regex/exact toggles, + /// or selected pattern change. Keyboard specifics (Enter, clearing an extracted pattern) are + /// handled in . + /// + private void SearchBar_SearchChanged(object? sender, EventArgs e) { - // Update save button visibility when regex mode is toggled + if (!IsLoaded) + return; + UpdateSaveButtonVisibility(); + ChangeFindTextTimer.Stop(); + ChangeFindTextTimer.Start(); } private void UpdateSaveButtonVisibility() @@ -928,9 +924,9 @@ private void UpdateSaveButtonVisibility() // 2. Find text is not empty // 3. Pattern doesn't already exist in saved patterns SavePatternButton.Visibility = - (UsePatternCheckBox.IsChecked is true && - !string.IsNullOrWhiteSpace(FindTextBox.Text) && - !IsPatternAlreadySaved(FindTextBox.Text)) + (SearchBar.UseRegex && + !string.IsNullOrWhiteSpace(SearchBar.SearchText) && + !IsPatternAlreadySaved(SearchBar.SearchText)) ? Visibility.Visible : Visibility.Collapsed; } @@ -954,7 +950,7 @@ internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null extractedPattern = pattern; // Ensure the pattern's case sensitivity matches the current checkbox state - bool ignoreCase = ExactMatchCheckBox.IsChecked is not true; + bool ignoreCase = !SearchBar.ExactMatch; extractedPattern.IgnoreCase = ignoreCase; // If a precision level was provided, use it; otherwise use the current slider value @@ -963,9 +959,9 @@ internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null // Update the slider to reflect the precision level being used PrecisionSlider.Value = levelToUse; - FindTextBox.Text = pattern.GetPattern(levelToUse); + SearchBar.SearchText = pattern.GetPattern(levelToUse); - UsePatternCheckBox.IsChecked = true; + SearchBar.UseRegex = true; // Show the slider now that we have an extracted pattern PrecisionSliderPanel.Visibility = Visibility.Visible; diff --git a/Text-Grab/Controls/RegexManager.xaml.cs b/Text-Grab/Controls/RegexManager.xaml.cs index 5cf6b564..c2852aba 100644 --- a/Text-Grab/Controls/RegexManager.xaml.cs +++ b/Text-Grab/Controls/RegexManager.xaml.cs @@ -130,8 +130,7 @@ private void UseButton_Click(object sender, RoutedEventArgs e) // Open Find and Replace window with this pattern FindAndReplaceWindow findWindow = WindowUtilities.OpenOrActivateWindow(); findWindow.TextEditWindow ??= SourceEditTextWindow; - findWindow.FindTextBox.Text = selectedRegex.Pattern; - findWindow.UsePatternCheckBox.IsChecked = true; + findWindow.SetFindText(selectedRegex.Pattern, useRegex: true); findWindow.Show(); findWindow.Activate(); findWindow.SearchForText(); diff --git a/Text-Grab/Models/PatternItem.cs b/Text-Grab/Models/PatternItem.cs index 1657981b..a9323a61 100644 --- a/Text-Grab/Models/PatternItem.cs +++ b/Text-Grab/Models/PatternItem.cs @@ -97,56 +97,4 @@ public static IReadOnlyList GetAll() /// public static PatternItem? GetByName(string name) => GetAll().FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - - /// - /// Builds the entries for a pattern combo box: a leading "none" sentinel, then the - /// combined catalog with a non-selectable subsection header before each group. Items - /// carry the directly (names can collide across the two - /// catalogs, e.g. both have "URL"), so selection resolves by identity, not by name. - /// - public static List BuildComboChoices(string noneLabel) - { - List choices = [new PatternChoice { Display = noneLabel }]; - - string? currentGroup = null; - foreach (PatternItem pattern in GetAll()) - { - if (pattern.GroupLabel != currentGroup) - { - currentGroup = pattern.GroupLabel; - choices.Add(new PatternChoice { Display = currentGroup, IsHeader = true }); - } - - choices.Add(new PatternChoice - { - Display = pattern.Name, - Description = pattern.Description, - Pattern = pattern, - }); - } - - return choices; - } -} - -/// -/// One row in a pattern combo box — a selectable pattern, a non-selectable subsection -/// header, or the leading "none" sentinel (header = false, = null). -/// -public sealed class PatternChoice -{ - /// Text shown for the row. - public string Display { get; init; } = string.Empty; - - /// Tooltip for selectable rows; empty for headers and the sentinel. - public string Description { get; init; } = string.Empty; - - /// True for a non-selectable subsection header row. - public bool IsHeader { get; init; } - - /// The backing pattern for selectable rows; null for headers and the sentinel. - public PatternItem? Pattern { get; init; } - - /// False for header rows — bound to the combo item's IsEnabled to block selection. - public bool IsSelectable => !IsHeader; } diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 9370d716..6042ba49 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -3404,8 +3404,7 @@ private void LaunchFindAndReplace() if (PassedTextControl.SelectedText.Length > 0) { - findAndReplaceWindow.FindTextBox.Text = PassedTextControl.SelectedText.Trim(); - findAndReplaceWindow.FindTextBox.Select(findAndReplaceWindow.FindTextBox.Text.Length, 0); + findAndReplaceWindow.SetFindText(PassedTextControl.SelectedText.Trim()); findAndReplaceWindow.SearchForText(); } } @@ -6563,8 +6562,7 @@ private void LoadStoredPatternToSelection(StoredRegex storedPattern) // Open Find and Replace window with this pattern and execute search FindAndReplaceWindow findWindow = WindowUtilities.OpenOrActivateWindow(); - findWindow.FindTextBox.Text = storedPattern.Pattern; - findWindow.UsePatternCheckBox.IsChecked = true; + findWindow.SetFindText(storedPattern.Pattern, useRegex: true); findWindow.TextEditWindow = this; findWindow.StringFromWindow = PassedTextControl.Text; findWindow.SearchForText(); diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index e5dacf40..5b114971 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -849,7 +849,7 @@ @@ -862,63 +862,14 @@ - - - - - - - - - - - - - - -