diff --git a/WindowTranslator.Abstractions/Properties/Resources.Designer.cs b/WindowTranslator.Abstractions/Properties/Resources.Designer.cs index e4bf6012..fd40280c 100644 --- a/WindowTranslator.Abstractions/Properties/Resources.Designer.cs +++ b/WindowTranslator.Abstractions/Properties/Resources.Designer.cs @@ -122,6 +122,16 @@ internal Resources() { /// public static string Overlay => ResourceManager.GetString("Overlay", resourceCulture) ?? string.Empty; + /// + /// "OCR範囲" に類似しているローカライズされた文字列を検索します。 + /// + public static string PriorityRect => ResourceManager.GetString("PriorityRect", resourceCulture) ?? string.Empty; + + /// + /// "OCR対象範囲" に類似しているローカライズされた文字列を検索します。 + /// + public static string PriorityRects => ResourceManager.GetString("PriorityRects", resourceCulture) ?? string.Empty; + /// /// "認識設定" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index ee018f1a..8bdfdfaa 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -82,6 +82,11 @@ public class TargetSettings /// public bool DisplayBusy { get; set; } = true; + /// + /// ホットキーが押されたときだけOCRと翻訳を行うか + /// + public bool IsOneShotMode { get; set; } + /// /// マウスポインター判定の余白(WPF上のピクセル値) /// diff --git a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs index d5855894..8bd494e9 100644 --- a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs +++ b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs @@ -1827,10 +1827,8 @@ public void RemovedFeaturesAreNotExposed() const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; - Type appResources = typeof(OcrTextTracker).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; Type abstractionResources = typeof(TextRect).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; - Assert.Null(appResources.GetProperty("IsOneShotMode", flags)); Assert.Null(abstractionResources.GetProperty("Buffer", flags)); Assert.Null(abstractionResources.GetProperty("BufferSize", flags)); Assert.Null(abstractionResources.GetProperty("IsSuppressVibe", flags)); diff --git a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml index 006227a3..7a48dfcf 100644 --- a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml +++ b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml @@ -49,10 +49,12 @@ + Texts="{Binding OcrTexts}" + Visibility="{Binding OverlayVisible, Mode=OneWayToSource, Converter={StaticResource b2vConv}}" /> diff --git a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs index 56f30811..cd8385ee 100644 --- a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs @@ -1,9 +1,13 @@ using System.Runtime.InteropServices; using System.Windows; +using System.Windows.Interop; using System.Windows.Threading; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Options; using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; +using WindowTranslator.Extensions; using WindowTranslator.Stores; using static Windows.Win32.PInvoke; @@ -14,20 +18,39 @@ namespace WindowTranslator.Modules.Main; /// public partial class CaptureMainWindow { + private readonly OverlaySwitch overlaySwitch; + private readonly bool isOneShotMode; private readonly IProcessInfoStore processInfo; private readonly DispatcherTimer timer = new(); + private readonly HOT_KEY_MODIFIERS shortcutModifiers; + private readonly int shortcutKey; + private IntPtr windowHandle; + private int overlayHiddenCount; - public CaptureMainWindow(IProcessInfoStore processInfo) + public CaptureMainWindow( + IOptionsSnapshot settings, + IOptionsSnapshot targetSettings, + IProcessInfoStore processInfo) { InitializeComponent(); + this.overlaySwitch = settings.Value.OverlaySwitch; + this.isOneShotMode = targetSettings.Value.IsOneShotMode; + if (this.isOneShotMode) + { + this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden); + } this.processInfo = processInfo; this.timer.Interval = TimeSpan.FromMilliseconds(10); this.timer.Tick += (s, e) => CheckTargetWindow(); + (this.shortcutModifiers, this.shortcutKey) = targetSettings.Value.OverlayShortcut.ToHotKey(); } private void Window_Loaded(object sender, RoutedEventArgs e) { + this.windowHandle = new WindowInteropHelper(this).Handle; this.timer.Start(); + RegisterHotKey(new(this.windowHandle), 0, this.shortcutModifiers, (uint)this.shortcutKey); + HwndSource.FromHwnd(this.windowHandle).AddHook(WndProc); StrongReferenceMessenger.Default.Register(this, CloseIfViewModel); } @@ -45,9 +68,38 @@ protected override void OnClosed(EventArgs e) { base.OnClosed(e); this.timer.Stop(); + UnregisterHotKey(new(this.windowHandle), 0); StrongReferenceMessenger.Default.Unregister(this); } + private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) + { + if (msg != WM_HOTKEY) + { + return 0; + } + if (this.overlaySwitch == OverlaySwitch.Hold) + { + HoldHideOverlay(); + } + else + { + this.overlay.SetCurrentValue(VisibilityProperty, this.overlay.Visibility == Visibility.Visible ? Visibility.Hidden : Visibility.Visible); + } + return 0; + } + + private async void HoldHideOverlay() + { + var current = Interlocked.Increment(ref this.overlayHiddenCount); + this.overlay.SetCurrentValue(VisibilityProperty, this.isOneShotMode ? Visibility.Visible : Visibility.Hidden); + await Task.Delay(500); + if (Interlocked.CompareExchange(ref this.overlayHiddenCount, 0, current) == current) + { + this.overlay.SetCurrentValue(VisibilityProperty, this.isOneShotMode ? Visibility.Hidden : Visibility.Visible); + } + } + private static void CloseIfViewModel(CaptureMainWindow w, CloseMessage m) { if (w.DataContext == m.ViewModel) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index f104450d..36c5623e 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -22,7 +22,7 @@ namespace WindowTranslator.Modules.Main; [ObservableObject] public abstract partial class MainViewModelBase : IDisposable { - private readonly Timer timer; + private readonly Timer? timer; private readonly IOcrModule ocr; private readonly List priorityRects; private readonly IOcrTextTracker ocrTextTracker; @@ -40,6 +40,7 @@ public abstract partial class MainViewModelBase : IDisposable private readonly double fontScale; private readonly double overlayOpacity; private readonly double mousePointerHitTestPadding; + private readonly bool isOneShotMode; private TextRect[]? lastRequested; [ObservableProperty] @@ -60,6 +61,7 @@ public abstract partial class MainViewModelBase : IDisposable private SoftwareBitmap? capturedBmp; private SoftwareBitmap? analyzingBmp; + private bool isFirstCapture; private bool disposedValue; public ObservableCollection OcrTexts { get; } = []; @@ -87,6 +89,7 @@ public MainViewModelBase( this.fontScale = options.Value.FontScale; this.overlayOpacity = options.Value.OverlayOpacity; this.mousePointerHitTestPadding = options.Value.MousePointerHitTestPadding; + this.isOneShotMode = options.Value.IsOneShotMode; this.DisplayBusy = options.Value.DisplayBusy; this.capture = capture ?? throw new ArgumentNullException(nameof(capture)); this.capture.Captured += Capture_CapturedAsync; @@ -98,8 +101,11 @@ public MainViewModelBase( this.color = color ?? throw new ArgumentNullException(nameof(color)); this.filters = filters.ToArray(); this.logger = logger; - this.capture.StartCapture(processInfoStore.MainWindowHandle); - this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500); + if (!this.isOneShotMode) + { + this.capture.StartCapture(processInfoStore.MainWindowHandle); + this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500); + } var transAsm = this.translator.GetType().Assembly; this.title = $"{this.name} - {this.translator.Name} ({transAsm.GetName().Version})"; } @@ -109,7 +115,14 @@ partial void OnOverlayVisibleChanged(bool value) if (value) { this.OcrTexts.Clear(); - this.ocrTextTracker.Reset(); + if (this.isOneShotMode) + { + this.isFirstCapture = true; + } + else + { + this.ocrTextTracker.Reset(); + } // Start capture when overlay becomes visible this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } @@ -123,6 +136,16 @@ partial void OnOverlayVisibleChanged(bool value) private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) { + if (this.isOneShotMode) + { + if (!this.isFirstCapture) + { + return; + } + this.isFirstCapture = false; + this.capture.StopCapture(); + } + if (this.analyzing.CurrentCount == 0) { return; @@ -155,7 +178,8 @@ private async Task CreateTextOverlayAsync() { if (this.analyzingBmp is { } previousBmp) { - if (previousBmp.PixelWidth != sbmp.PixelWidth || previousBmp.PixelHeight != sbmp.PixelHeight) + if (!this.isOneShotMode + && (previousBmp.PixelWidth != sbmp.PixelWidth || previousBmp.PixelHeight != sbmp.PixelHeight)) { this.ocrTextTracker.Reset(); } @@ -202,26 +226,28 @@ private async Task CreateTextOverlayAsync() } } - texts = await this.ocr.RecognizeAsync(new(sbmp, regions)); - texts = this.ocrTextTracker.Update(texts, new(sbmp.PixelWidth, sbmp.PixelHeight)); + var observations = await this.ocr.RecognizeAsync(new(sbmp, regions)); + texts = this.isOneShotMode + ? observations + : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); } catch (ObjectDisposedException) { // すでに破棄されている場合は何もしない - this.timer.DisposeAsync().Forget(); + await DisposeTimerAsync(); this.capture.StopCapture(); return; } catch (OperationCanceledException) { // キャンセルされた場合は何もしない - this.timer.DisposeAsync().Forget(); + await DisposeTimerAsync(); this.capture.StopCapture(); return; } catch (Exception e) { - this.timer.DisposeAsync().Forget(); + await DisposeTimerAsync(); this.capture.StopCapture(); var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); await sbmp.TrySaveImage(path); @@ -233,11 +259,12 @@ private async Task CreateTextOverlayAsync() texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); // フィルター&翻訳処理は必ず通す + FilterContext context; + TextRect[] displayedTexts; using (this.Filtering.EnterBusy()) { texts = await this.color.ConvertColorAsync(sbmp, texts); - - var context = new FilterContext() + context = new() { SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), @@ -251,26 +278,47 @@ private async Task CreateTextOverlayAsync() using var t = this.logger.LogDebugTime("PreTranslate"); texts = await tmp.ToArrayAsync(); } - TranslateAsync(texts).Forget(); - texts = texts.Select(t => t switch - { - { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, - _ => t, - }).ToArray(); + if (!this.isOneShotMode) { - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderBy(f => f.Priority)) - { - tmp = filter.ExecutePostTranslate(tmp, context); - } - using var t = this.logger.LogDebugTime("PostTranslate"); - texts = await tmp.ToArrayAsync(); + TranslateAsync(texts).Forget(); } + displayedTexts = await CreateDisplayedTextsAsync(texts, context); + } - // 背景色に不透明度を設定 - texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); + UpdateOcrTexts(displayedTexts); + if (!this.isOneShotMode) + { + return; + } + + await TranslateAsync(texts); + + using (this.Filtering.EnterBusy()) + { + displayedTexts = await CreateDisplayedTextsAsync(texts, context); + } + UpdateOcrTexts(displayedTexts); + } + + private async Task CreateDisplayedTextsAsync(IEnumerable texts, FilterContext context) + { + texts = texts.Select(t => t switch + { + { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, + _ => t, + }).ToArray(); + var tmp = texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderBy(f => f.Priority)) + { + tmp = filter.ExecutePostTranslate(tmp, context); } + using var t = this.logger.LogDebugTime("PostTranslate"); + texts = await tmp.ToArrayAsync(); + return texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); + } + private void UpdateOcrTexts(IEnumerable texts) + { var hash = texts.ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { @@ -319,7 +367,7 @@ private async Task TranslateAsync(IEnumerable texts) catch (Exception e) when (e is not OperationCanceledException) { this.logger.LogError(e, "翻訳中にエラーが発生"); - this.timer.DisposeAsync().Forget(); + await DisposeTimerAsync(); this.capture.StopCapture(); // 翻訳失敗してエラーで閉じる場合はキューをクリア Interlocked.Exchange(ref this.lastRequested, null); @@ -332,6 +380,14 @@ private async Task TranslateAsync(IEnumerable texts) } } + private async ValueTask DisposeTimerAsync() + { + if (this.timer is { } timer) + { + await timer.DisposeAsync(); + } + } + protected virtual void Dispose(bool disposing) { if (disposedValue) diff --git a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs index 4f3cc39c..b65b921c 100644 --- a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs @@ -23,6 +23,7 @@ namespace WindowTranslator.Modules.Main; public partial class OverlayMainWindow : Window { private readonly OverlaySwitch overlaySwitch; + private readonly bool isOneShotMode; private readonly bool isEnableCapture; private readonly IProcessInfoStore processInfo; private readonly IVirtualDesktopManager desktopManager; @@ -72,6 +73,11 @@ public OverlayMainWindow( { InitializeComponent(); this.overlaySwitch = settings.Value.OverlaySwitch; + this.isOneShotMode = targetSettings.Value.IsOneShotMode; + if (this.isOneShotMode) + { + this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden); + } this.isEnableCapture = settings.Value.IsEnableCaptureOverlay; this.IsSwapVisibility = settings.Value.IsOverlayPointSwap; this.processInfo = processInfo; @@ -233,11 +239,11 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand private async void HoldHideOverlay() { var current = Interlocked.Increment(ref this.overlayHiddenCount); - this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden); + this.overlay.SetCurrentValue(VisibilityProperty, this.isOneShotMode ? Visibility.Visible : Visibility.Hidden); await Task.Delay(500); if (Interlocked.CompareExchange(ref this.overlayHiddenCount, 0, current) == current) { - this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Visible); + this.overlay.SetCurrentValue(VisibilityProperty, this.isOneShotMode ? Visibility.Hidden : Visibility.Visible); } } } diff --git a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs index c9e43488..0265fd28 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs +++ b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs @@ -246,6 +246,7 @@ public async Task SaveAsync(object window) }, PluginParams = t.Params.ToDictionary(p => p.GetType().Name), DisplayBusy = t.DisplayBusy, + IsOneShotMode = t.IsOneShotMode, OverlayOpacity = t.OverlayOpacity, MousePointerHitTestPadding = t.MousePointerHitTestPadding, }), @@ -428,44 +429,38 @@ public nint TargetWindowHandle [Category("SettingsViewModel|Font")] [FontFamilySelector] [FontPreview(18)] - [SortIndex(5)] public string Font { get; set; } = settings.Font; [property: Category("SettingsViewModel|Font")] [property: Slidable(0.1, 5, 0.1, 1.0, true, 0.1)] [property: FormatString("F2")] - [property: SortIndex(6)] [ObservableProperty] private double fontScale = settings.FontScale; - [property: Category("SettingsViewModel|Shortcut")] - [ObservableProperty] - private string overlayShortcut = settings.OverlayShortcut; - - [property: Category("SettingsViewModel|Misc")] - [property: SortIndex(7)] - [ObservableProperty] - private bool isEnableAutoTarget = settings.IsEnableAutoTarget; + [Category("SettingsViewModel|Overlay")] + public string OverlayShortcut { get; set; } = settings.OverlayShortcut; - [property: Category("SettingsViewModel|Misc")] - [property: SortIndex(8)] + [property: Category("SettingsViewModel|Overlay")] [property: Slidable(0, 1, 0.005, 0.05, true, 0.01)] [property: FormatString("P1")] [ObservableProperty] private double overlayOpacity = settings.OverlayOpacity; - [property: Category("SettingsViewModel|Misc")] - [property: SortIndex(9)] - [ObservableProperty] - private bool displayBusy = settings.DisplayBusy; + [Category("SettingsViewModel|Overlay")] + public bool IsOneShotMode { get; set; } = settings.IsOneShotMode; - [property: Category("SettingsViewModel|Misc")] + [property: Category("SettingsViewModel|Overlay")] [property: LocalizedDescription(typeof(Resources), $"{nameof(MousePointerHitTestPadding)}_Desc")] [property: Slidable(0, 100, 1, 10, true, 1)] - [property: SortIndex(10)] [ObservableProperty] private double mousePointerHitTestPadding = settings.MousePointerHitTestPadding; + [Category("SettingsViewModel|Misc")] + public bool IsEnableAutoTarget { get; set; } = settings.IsEnableAutoTarget; + + [Category("SettingsViewModel|Misc")] + public bool DisplayBusy { get; set; } = settings.DisplayBusy; + public IReadOnlyList Params { get; } = sp.GetServices().Select(p => { var configureType = typeof(IConfigureNamedOptions<>).MakeGenericType(p.GetType()); diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index a66df7da..c89292f7 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -307,6 +307,11 @@ internal Resources() { /// public static string IsLatest => ResourceManager.GetString("IsLatest", resourceCulture) ?? string.Empty; + /// + /// "ホットキーを押したときだけOCR・翻訳する" に類似しているローカライズされた文字列を検索します。 + /// + public static string IsOneShotMode => ResourceManager.GetString("IsOneShotMode", resourceCulture) ?? string.Empty; + /// /// "マウスポインター位置のテキストのみオーバレイ翻訳を表示する" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 74b34056..e0c85bdf 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -303,6 +303,9 @@ Show busy icon + + Run OCR and translation only when the hotkey is pressed + Display overlay translation only for text at mouse pointer position diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 05b1d716..9c4f709c 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -328,6 +328,9 @@ 処理中アイコンを表示する + + ホットキーを押したときだけOCR・翻訳する + マウスポインター位置のテキストのみオーバレイ翻訳を表示する