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/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/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/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..869a5cf3 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -127,7 +127,6 @@ Clicking the notification opens the copied text into a new Edit Text Window to display and edit text. - + diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 987e789f..b419c50c 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -139,7 +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); - // Context menu integration - only available for unpackaged apps if (!AppUtilities.IsPackaged()) { 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/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a19092cc..8277b0ac 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 @@ -798,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; @@ -3474,6 +3479,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; + SpeakAndShowStopButton(FrameText); + } + if (IsFromEditWindow && destinationTextBox is not null && AlwaysUpdateEtwCheckBox.IsChecked is true @@ -3645,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) @@ -3676,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)) diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml index 37a52c3b..575b9bb7 100644 --- a/Text-Grab/Views/SettingsWindow.xaml +++ b/Text-Grab/Views/SettingsWindow.xaml @@ -155,6 +155,19 @@ + + + + Voice + + Output + + + + + + +