Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions WindowTranslator.Abstractions/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ public class TargetSettings
/// </summary>
public bool DisplayBusy { get; set; } = true;

/// <summary>
/// ホットキーが押されたときだけOCRと翻訳を行うか
/// </summary>
public bool IsOneShotMode { get; set; }

/// <summary>
/// マウスポインター判定の余白(WPF上のピクセル値)
/// </summary>
Expand Down
5 changes: 3 additions & 2 deletions WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1705,15 +1705,16 @@ public void DormantChildrenDoNotConsumeAnActiveTracksObservation()
}

[Fact]
public void RemovedFeaturesAreNotExposed()
public void RemovedBufferFeaturesAreNotExposedAndOneShotIsAvailable()
{
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.NotNull(appResources.GetProperty("IsOneShotMode", flags));
Assert.NotNull(typeof(TargetSettings).GetProperty(nameof(TargetSettings.IsOneShotMode)));
Assert.Null(abstractionResources.GetProperty("Buffer", flags));
Assert.Null(abstractionResources.GetProperty("BufferSize", flags));
Assert.Null(abstractionResources.GetProperty("IsSuppressVibe", flags));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ private static string GetInfo(Exception ex, UserSettings? settings, string targe
sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayShortcut: {value.OverlayShortcut ?? "N/A"}");
sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayOpacity: {value.OverlayOpacity}");
sb.AppendLine(CultureInfo.CurrentCulture, $"DisplayBusy: {value.DisplayBusy}");
sb.AppendLine(CultureInfo.CurrentCulture, $"IsOneShotMode: {value.IsOneShotMode}");

if (value.SelectedPlugins?.Count > 0)
{
Expand Down
35 changes: 34 additions & 1 deletion WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs
Comment thread
Freeesia marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,18 +20,30 @@ public partial class CaptureMainWindow
{
private readonly IProcessInfoStore processInfo;
private readonly DispatcherTimer timer = new();
private readonly bool isOneShotMode;
private readonly HOT_KEY_MODIFIERS shortcutModifiers;
private readonly int shortcutKey;
private IntPtr windowHandle;

public CaptureMainWindow(IProcessInfoStore processInfo)
public CaptureMainWindow(IProcessInfoStore processInfo, IOptionsSnapshot<TargetSettings> targetSettings)
{
InitializeComponent();
this.processInfo = processInfo;
this.isOneShotMode = targetSettings.Value.IsOneShotMode;
(this.shortcutModifiers, this.shortcutKey) = targetSettings.Value.OverlayShortcut.ToHotKey();
this.timer.Interval = TimeSpan.FromMilliseconds(10);
this.timer.Tick += (s, e) => CheckTargetWindow();
}

private void Window_Loaded(object sender, RoutedEventArgs e)
{
this.timer.Start();
if (this.isOneShotMode)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ホットキーの登録はOneShotかどうかに関わらず必要では?本当にオーバーレイと実装揃ってる?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘のとおり、Capture表示のホットキー登録はOneShot固有の変更として入れるべきではありませんでした。今回の変更範囲とは関係ないため、CaptureMainWindowの差分をすべて戻し、OneShotはOverlay表示の既存ホットキー経路だけで起動する形に修正しました。

{
this.windowHandle = new WindowInteropHelper(this).Handle;
RegisterHotKey(new(this.windowHandle), 0, this.shortcutModifiers, (uint)this.shortcutKey);
HwndSource.FromHwnd(this.windowHandle).AddHook(WndProc);
}
StrongReferenceMessenger.Default.Register<CaptureMainWindow, CloseMessage>(this, CloseIfViewModel);
}

Expand All @@ -45,9 +61,26 @@ protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
this.timer.Stop();
if (this.isOneShotMode)
{
UnregisterHotKey(new(this.windowHandle), 0);
}
StrongReferenceMessenger.Default.Unregister<CloseMessage>(this);
}

private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
{
if (msg != WM_HOTKEY)
{
return 0;
}
if (this.DataContext is CaptureMainViewModel viewModel)
{
viewModel.RequestOneShot();
}
return 0;
}

private static void CloseIfViewModel(CaptureMainWindow w, CloseMessage m)
{
if (w.DataContext == m.ViewModel)
Expand Down
124 changes: 90 additions & 34 deletions WindowTranslator/Modules/Main/MainViewModelBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 IOcrTextTracker ocrTextTracker;
private readonly ITranslateModule translator;
Expand All @@ -39,6 +39,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]
Expand All @@ -59,6 +60,7 @@ public abstract partial class MainViewModelBase : IDisposable

private SoftwareBitmap? capturedBmp;
private SoftwareBitmap? analyzingBmp;
private bool isFirstCapture;
private bool disposedValue;

public ObservableCollection<TextRect> OcrTexts { get; } = [];
Expand All @@ -85,6 +87,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;
Expand All @@ -95,14 +98,23 @@ 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})";
}

partial void OnOverlayVisibleChanged(bool value)
{
// OneShotのキャプチャーはRequestOneShotで開始し、翻訳完了後に停止する。
if (this.isOneShotMode)
Comment thread
Freeesia marked this conversation as resolved.
{
return;
}

if (value)
{
this.OcrTexts.Clear();
Expand Down Expand Up @@ -132,6 +144,20 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args)
sbmp?.Dispose();
}

public void RequestOneShot()
{
if (!this.isOneShotMode)
{
return;
}

this.logger.LogDebug("OneShot OCR requested");
this.OcrTexts.Clear();
this.isFirstCapture = true;
this.capture.StopCapture();
this.capture.StartCapture(this.processInfoStore.MainWindowHandle);
}

private async Task CreateTextOverlayAsync()
{
if (!await this.analyzing.WaitAsync(0))
Expand All @@ -158,40 +184,51 @@ private async Task CreateTextOverlayAsync()
return;
}

var shouldRecognize = !this.isOneShotMode || this.isFirstCapture;
IEnumerable<TextRect> texts;
using (this.Recognizing.EnterBusy())
if (shouldRecognize)
{
try
{
texts = await this.ocr.RecognizeAsync(sbmp);
texts = this.ocrTextTracker.Update(texts, new(sbmp.PixelWidth, sbmp.PixelHeight));
}
catch (ObjectDisposedException)
{
// すでに破棄されている場合は何もしない
this.timer.DisposeAsync().Forget();
this.capture.StopCapture();
return;
}
catch (OperationCanceledException)
using (this.Recognizing.EnterBusy())
{
// キャンセルされた場合は何もしない
this.timer.DisposeAsync().Forget();
this.capture.StopCapture();
return;
}
catch (Exception e)
{
this.timer.DisposeAsync().Forget();
this.capture.StopCapture();
var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png");
await sbmp.TrySaveImage(path);
await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path);
StrongReferenceMessenger.Default.Send<CloseMessage>(new(this));
return;
try
{
var observations = await this.ocr.RecognizeAsync(sbmp);
texts = this.isOneShotMode
? observations.ToArray()
: this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight));
this.isFirstCapture = false;
}
catch (ObjectDisposedException)
{
// すでに破棄されている場合は何もしない
await DisposeTimerAsync();
this.capture.StopCapture();
return;
}
catch (OperationCanceledException)
{
// キャンセルされた場合は何もしない
await DisposeTimerAsync();
this.capture.StopCapture();
return;
}
catch (Exception e)
{
await DisposeTimerAsync();
this.capture.StopCapture();
var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png");
await sbmp.TrySaveImage(path);
await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path);
StrongReferenceMessenger.Default.Send<CloseMessage>(new(this));
return;
}
}
texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale });
}
else
{
texts = this.OcrTexts.ToArray();
}
texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale });

// フィルター&翻訳処理は必ず通す
using (this.Filtering.EnterBusy())
Expand All @@ -212,7 +249,10 @@ private async Task CreateTextOverlayAsync()
using var t = this.logger.LogDebugTime("PreTranslate");
texts = await tmp.ToArrayAsync();
}
TranslateAsync(texts).Forget();
if (shouldRecognize)
{
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) },
Expand Down Expand Up @@ -242,6 +282,10 @@ private async Task CreateTextOverlayAsync()
{
this.OcrTexts.Add(text);
}
if (this.isOneShotMode && this.OcrTexts.All(t => t.TranslatedText is not null))
{
this.capture.StopCapture();
}
}

private async Task TranslateAsync(IEnumerable<TextRect> texts)
Expand Down Expand Up @@ -276,11 +320,15 @@ private async Task TranslateAsync(IEnumerable<TextRect> texts)
this.logger.LogDebug("Translate");
var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false);
this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated));
if (this.isOneShotMode)
{
_ = Application.Current.Dispatcher.BeginInvoke(() => CreateTextOverlayAsync().Forget());
}
}
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);
Expand All @@ -293,6 +341,14 @@ private async Task TranslateAsync(IEnumerable<TextRect> texts)
}
}

private async ValueTask DisposeTimerAsync()
{
if (this.timer is { } timer)
{
await timer.DisposeAsync();
}
}

protected virtual void Dispose(bool disposing)
{
if (disposedValue)
Expand Down
4 changes: 4 additions & 0 deletions WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand
{
return 0;
}
if (this.DataContext is OverlayMainViewModel viewModel)
{
viewModel.RequestOneShot();
}
if (this.overlaySwitch == OverlaySwitch.Hold)
{
HoldHideOverlay();
Expand Down
8 changes: 7 additions & 1 deletion WindowTranslator/Modules/Settings/AllSettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,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,
}),
Expand Down Expand Up @@ -467,10 +468,15 @@ public partial class TargetSettingsViewModel(
[ObservableProperty]
private bool displayBusy = settings.DisplayBusy;

[property: Category("SettingsViewModel|Misc")]
[property: SortIndex(9)]
[ObservableProperty]
private bool isOneShotMode = settings.IsOneShotMode;

[property: Category("SettingsViewModel|Misc")]
[property: LocalizedDescription(typeof(Resources), $"{nameof(MousePointerHitTestPadding)}_Desc")]
[property: Slidable(0, 100, 1, 10, true, 1)]
[property: SortIndex(9)]
[property: SortIndex(10)]
[ObservableProperty]
private double mousePointerHitTestPadding = settings.MousePointerHitTestPadding;

Expand Down
5 changes: 5 additions & 0 deletions WindowTranslator/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions WindowTranslator/Properties/Resources.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@
<data name="DisplayBusy" xml:space="preserve">
<value>Show busy icon</value>
</data>
<data name="IsOneShotMode" xml:space="preserve">
<value>Run OCR and translation only when the hotkey is pressed</value>
</data>
<data name="IsOverlayPointSwap" xml:space="preserve">
<value>Display overlay translation only for text at mouse pointer position</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions WindowTranslator/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@
<data name="DisplayBusy" xml:space="preserve">
<value>処理中アイコンを表示する</value>
</data>
<data name="IsOneShotMode" xml:space="preserve">
<value>ホットキーを押したときだけOCR・翻訳する</value>
</data>
<data name="IsOverlayPointSwap" xml:space="preserve">
<value>マウスポインター位置のテキストのみオーバレイ翻訳を表示する</value>
</data>
Expand Down
Loading