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・翻訳する
+
マウスポインター位置のテキストのみオーバレイ翻訳を表示する