diff --git a/WindowTranslator.Tests/OverlayVisibilityTests.cs b/WindowTranslator.Tests/OverlayVisibilityTests.cs new file mode 100644 index 00000000..e5b8c6a7 --- /dev/null +++ b/WindowTranslator.Tests/OverlayVisibilityTests.cs @@ -0,0 +1,968 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.Threading; +using R3; +using Windows.Graphics.Imaging; +using WindowTranslator.Controls; +using WindowTranslator.Modules; +using WindowTranslator.Modules.Capture; +using WindowTranslator.Modules.Main; +using WindowTranslator.Modules.Ocr; +using WindowTranslator.Stores; + +namespace WindowTranslator.Tests; + +public sealed class OverlayVisibilityTests +{ + private const string ChildProcessVariable = "WINDOW_TRANSLATOR_OVERLAY_TEST_CHILD"; + + [Fact] + public async Task 非表示中に追跡を止めて再表示後は新しい認識結果だけを表示する() + { + if (Environment.GetEnvironmentVariable(ChildProcessVariable) == "1") + { + await RunInStaAsync(); + return; + } + + // WPF の Application は同一プロセスで再作成できないため、既存テストと分離する。 + var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true, + }; + start.ArgumentList.Add("vstest"); + start.ArgumentList.Add(typeof(OverlayVisibilityTests).Assembly.Location); + start.ArgumentList.Add("--TestCaseFilter:FullyQualifiedName~WindowTranslator.Tests.OverlayVisibilityTests"); + start.Environment[ChildProcessVariable] = "1"; + using var process = Process.Start(start) ?? throw new InvalidOperationException("テストプロセスを起動できません。"); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + Assert.True(process.ExitCode == 0, $"{await output}\n{await error}"); + } + + private static async Task RunInStaAsync() + { + var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + try + { + var application = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; + Exception? failure = null; + _ = application.Dispatcher.InvokeAsync(async () => + { + try + { + await VerifyHiddenAsync(); + foreach (var stage in Enum.GetValues()) + { + await VerifyInFlightAsync(stage); + } + await VerifyInFlightAsync(Stage.Ocr, withoutContext: true); + await VerifyQueuedCapturesAsync(); + await VerifyQueuedOcrWhileHiddenAsync(); + await VerifyOcrStopsAfterCancellationAsync(); + await VerifyOcrCompletesWhileHiddenAsync(); + await VerifyHiddenDuringResetAsync(); + await VerifyHiddenDuringTrackingAsync(); + await VerifyUiResponsiveDuringTrackingAsync(); + await VerifyUiResponsiveDuringTranslationErrorAsync(); + await VerifyHiddenDuringColorAsync(); + await VerifyHiddenDuringCacheAsync(); + await VerifyOldHandoffAfterReopenAsync(); + await VerifyHiddenBeforeTranslationAsync(); + await VerifyOldEmptyResultDoesNotRemoveNewAsync(); + await VerifyOneShotAsync(); + await VerifyAutomaticWindowVisibilityAsync(); + await VerifyOneShotWindowVisibilityAsync(); + await VerifyDelayedCaptureAsync(isOneShotMode: false); + await VerifyDelayedCaptureAsync(isOneShotMode: true); + await VerifyDisposeAsync(); + } + catch (Exception exception) + { + failure = exception; + } + finally + { + application.Shutdown(); + } + }); + application.Run(); + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + finished.TrySetResult(); + } + catch (Exception exception) + { + finished.TrySetException(exception); + } + }) { IsBackground = true }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + await finished.Task.WaitAsync(TimeSpan.FromSeconds(45)); + } + + private static async Task VerifyHiddenAsync() + { + await using var fixture = new Fixture(); + var hiddenTranslations = 0; + using var translations = fixture.Translations.Subscribe(request => + { + if (!fixture.Model.OverlayVisible) Interlocked.Increment(ref hiddenTranslations); + }); + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Translator.Gate.Count > 0); + var trackerCountBeforeHidden = fixture.Tracker.UpdateCount; + await Task.Delay(250); + // 表示中は追跡周期ごとに追跡が続いていることを確認してから非表示にする。 + Assert.True(fixture.Tracker.UpdateCount > trackerCountBeforeHidden); + fixture.Model.OverlayVisible = false; + // 非表示の直前に始まった追跡は終わるまで待つ。 + await Task.Delay(150); + var trackerCount = fixture.Tracker.UpdateCount; + var colorCount = fixture.Color.Gate.Count; + var translationCount = fixture.Translator.Gate.Count; + await Task.Delay(400); + + Assert.Equal(trackerCount, fixture.Tracker.UpdateCount); + Assert.Equal(colorCount, fixture.Color.Gate.Count); + Assert.Equal(translationCount, fixture.Translator.Gate.Count); + Assert.Equal(0, Volatile.Read(ref hiddenTranslations)); + fixture.Model.OverlayVisible = true; + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); + Assert.Empty(fixture.Model.OcrTexts); + fixture.Ocr.Text = "new"; + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "old"); + Assert.True(fixture.Tracker.ResetCount >= 2); + } + + private static async Task VerifyInFlightAsync(Stage stage, bool withoutContext = false) + { + await using var fixture = new Fixture(stage, withoutContext: withoutContext); + var gate = stage switch + { + Stage.Ocr => fixture.Ocr.Gate, + Stage.Color => fixture.Color.Gate, + _ => fixture.Translator.Gate, + }; + var shown = new List(); + fixture.Model.OcrTexts.CollectionChanged += (_, _) => + shown.AddRange(fixture.Model.OcrTexts.Select(text => text.SourceText)); + fixture.SubmitCapture(Bitmap(320)); + await gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + shown.Clear(); + fixture.SubmitCapture(Bitmap(321)); + await Task.Delay(200); + Assert.False(gate.NextStarted.Task.IsCompleted); + gate.Release.TrySetResult(); + await gate.NextStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain("old", shown); + } + + private static async Task VerifyHiddenDuringResetAsync() + { + await using var fixture = new Fixture(delayReset: true); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Tracker.ResetStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Tracker.ResetRelease.Set(); + await Task.Delay(250); + Assert.False(fixture.Tracker.Updated.Task.IsCompleted); + + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + } + + private static async Task VerifyQueuedCapturesAsync() + { + await using (var throttled = new Fixture(captureInterval: 0.3)) + { + var old = Bitmap(320); + throttled.SubmitCapture(old); + throttled.Model.OverlayVisible = false; + throttled.Model.OverlayVisible = true; + await WaitUntilAsync(() => IsDisposed(old)); + Assert.DoesNotContain(throttled.Model.OcrTexts, text => text.SourceText == "old"); + throttled.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => throttled.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(throttled.Model.OcrTexts, text => text.SourceText == "old"); + } + + await using (var queued = new Fixture(Stage.Ocr)) + { + queued.SubmitCapture(Bitmap(320)); + await queued.Ocr.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var old = Bitmap(322); + queued.SubmitCapture(old); + await Task.Delay(150); + queued.Model.OverlayVisible = false; + queued.Model.OverlayVisible = true; + queued.Ocr.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => IsDisposed(old)); + Assert.DoesNotContain(queued.Model.OcrTexts, text => text.SourceText == "old"); + queued.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => queued.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(queued.Model.OcrTexts, text => text.SourceText == "old"); + } + } + + private static async Task VerifyQueuedOcrWhileHiddenAsync() + { + await using var fixture = new Fixture(Stage.Ocr); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Ocr.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var pending = Bitmap(322); + fixture.SubmitCapture(pending); + await Task.Delay(150); + fixture.Model.OverlayVisible = false; + fixture.Ocr.Gate.Release.TrySetResult(); + await fixture.Ocr.Gate.NextStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => IsDisposed(pending)); + Assert.Equal(0, fixture.Tracker.UpdateCount); + Assert.Equal(0, fixture.Color.Gate.Count); + Assert.Equal(0, fixture.Translator.Gate.Count); + + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "old"); + } + + private static async Task VerifyOcrStopsAfterCancellationAsync() + { + await using var fixture = new Fixture(Stage.Ocr); + fixture.Ocr.CancelFirst = true; + fixture.SubmitCapture(Bitmap(320)); + await fixture.Ocr.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var pending = Bitmap(322); + try + { + fixture.SubmitCapture(pending); + await Task.Delay(150); + fixture.Ocr.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => fixture.Capture.StopCount > 0); + await Task.Delay(250); + Assert.False(fixture.Ocr.Gate.NextStarted.Task.IsCompleted); + } + finally + { + fixture.Model.Dispose(); + if (!IsDisposed(pending)) pending.Dispose(); + } + } + + private static async Task VerifyHiddenDuringTrackingAsync() + { + await using var fixture = new Fixture(delayUpdate: true); + var hiddenTranslation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var translations = fixture.Translations.Subscribe(request => + { + if (!fixture.Model.OverlayVisible) hiddenTranslation.TrySetResult(); + }); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Tracker.Updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Tracker.UpdateRelease.Set(); + await Task.Delay(250); + // 非表示前に始まった追跡の残りは許容するが、翻訳要求は送らない。 + Assert.Equal(1, fixture.Tracker.UpdateCount); + Assert.False(hiddenTranslation.Task.IsCompleted); + Assert.Equal(0, fixture.Translator.Gate.Count); + + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "old"); + } + + private static async Task VerifyOcrCompletesWhileHiddenAsync() + { + await using var fixture = new Fixture(Stage.Ocr); + var image = Bitmap(320); + fixture.SubmitCapture(image); + await fixture.Ocr.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + await Task.Delay(150); + fixture.Ocr.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => IsDisposed(image)); + await Task.Delay(200); + Assert.Equal(0, fixture.Tracker.UpdateCount); + Assert.Equal(0, fixture.Color.Gate.Count); + Assert.Equal(0, fixture.Translator.Gate.Count); + } + + private static async Task VerifyUiResponsiveDuringTrackingAsync() + { + await using var fixture = new Fixture(delayUpdate: true); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Tracker.Updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var dispatcher = Application.Current.Dispatcher; + var responsive = Task.Run(async () => + { + try + { + await Task.Delay(250); + var ping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = dispatcher.InvokeAsync(() => ping.TrySetResult()); + return await Task.WhenAny(ping.Task, Task.Delay(1000)) == ping.Task; + } + finally + { + fixture.Tracker.UpdateRelease.Set(); + } + }); + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + Assert.True(await responsive.WaitAsync(TimeSpan.FromSeconds(5))); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + } + + private static async Task VerifyHiddenDuringColorAsync() + { + await using var fixture = new Fixture(Stage.Color); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Color.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Color.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => fixture.Color.Bitmap is { } bitmap && IsDisposed(bitmap)); + Assert.Equal(0, fixture.Translator.Gate.Count); + } + + private static async Task VerifyUiResponsiveDuringTranslationErrorAsync() + { + await using var fixture = new Fixture(Stage.Translation, failFirst: true); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Translator.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Tracker.BlockNextUpdate(); + await fixture.Tracker.Blocked.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var dispatcher = Application.Current.Dispatcher; + var responsive = Task.Run(async () => + { + try + { + await fixture.Translator.Failed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(100); + var ping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = dispatcher.InvokeAsync(() => ping.TrySetResult()); + return await Task.WhenAny(ping.Task, Task.Delay(1000)) == ping.Task; + } + finally + { + fixture.Tracker.UpdateRelease.Set(); + } + }); + fixture.Translator.Gate.Release.TrySetResult(); + Assert.True(await responsive.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(await fixture.Translator.Failed.Task.WaitAsync(TimeSpan.FromSeconds(5))); + await fixture.Presentation.Shown.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static async Task VerifyHiddenDuringCacheAsync() + { + await using var fixture = new Fixture(delayCache: true); + var hiddenTranslation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var translations = fixture.Translations.Subscribe(request => + { + if (!fixture.Model.OverlayVisible) hiddenTranslation.TrySetResult(); + }); + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "old")); + await fixture.Cache.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Cache.Release.Set(); + await Task.Delay(250); + Assert.False(hiddenTranslation.Task.IsCompleted); + + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + } + + private static async Task VerifyOldHandoffAfterReopenAsync() + { + await using var fixture = new Fixture(Stage.Translation); + try + { + fixture.Ocr.Text = "handoff-old"; + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Capture.StopCount > 0 + || fixture.Model.OcrTexts.Any(text => text.SourceText == "handoff-old")); + var bitmap = fixture.Color.Bitmap!; + if (fixture.Capture.StopCount == 0) + { + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + } + await WaitUntilAsync(() => IsDisposed(bitmap)); + fixture.Ocr.Text = "new"; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "handoff-old"); + } + finally + { + fixture.Translator.Gate.Release.TrySetResult(); + } + } + + private static async Task VerifyHiddenBeforeTranslationAsync() + { + await using var fixture = new Fixture(delayFilter: true); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Filter.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var pending = Bitmap(322); + fixture.Translations.OnNext(([new("pending", 0, 0, 100, 30, 20, false)], + new() { SoftwareBitmap = pending, ImageSize = new(322, 240) }, fixture.Visibility)); + fixture.Model.OverlayVisible = false; + fixture.Filter.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => IsDisposed(pending)); + await Task.Delay(250); + // 非表示前に始まった翻訳の残りだけが実行され、待機していた要求は翻訳しない。 + Assert.Equal(1, fixture.Translator.Gate.Count); + Assert.Equal(1, fixture.Filter.Gate.Count); + + var shown = new List(); + fixture.Model.OcrTexts.CollectionChanged += (_, _) => + shown.AddRange(fixture.Model.OcrTexts.Select(text => text.SourceText)); + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Translator.Gate.Count > 1); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain("old", shown); + Assert.DoesNotContain("pending", shown); + } + + private static async Task VerifyOldEmptyResultDoesNotRemoveNewAsync() + { + await using var fixture = new Fixture(delayFilter: true, emptyFirstCapture: true, delayNextFilter: true); + fixture.SubmitCapture(Bitmap(320)); + await fixture.Filter.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + fixture.Filter.Gate.Release.TrySetResult(); + await fixture.Filter.Gate.NextStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + Assert.Contains(fixture.Model.OcrTexts, text => text.SourceText == "new"); + } + finally + { + fixture.Filter.Gate.NextRelease.TrySetResult(); + } + } + + private static async Task VerifyDelayedCaptureAsync(bool isOneShotMode) + { + await using var fixture = new Fixture(isOneShotMode: isOneShotMode); + var handler = fixture.Capture.CurrentHandler!; + var visibility = fixture.Visibility; + fixture.Model.OverlayVisible = false; + Assert.Null(fixture.Capture.CurrentHandler); + Assert.True(visibility.IsDisposed); + fixture.Model.OverlayVisible = true; + Assert.NotNull(fixture.Capture.CurrentHandler); + Assert.NotSame(handler, fixture.Capture.CurrentHandler); + Assert.False(fixture.Visibility.IsDisposed); + + // 非表示前に取得した画像の認識結果は、再表示後も表示しない。 + var shown = new List(); + fixture.Model.OcrTexts.CollectionChanged += (_, _) => + shown.AddRange(fixture.Model.OcrTexts.Select(text => text.SourceText)); + Field)>>(fixture.Model, "captureRequests").OnNext((Bitmap(320), visibility)); + await WaitUntilAsync(() => fixture.Ocr.Gate.Count == 1); + await Task.Delay(250); + Assert.Equal(0, fixture.Translator.Gate.Count); + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain("old", shown); + } + + private static T Field(MainViewModelBase model, string name) where T : class + => (T)typeof(MainViewModelBase).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(model)!; + + private static async Task VerifyOneShotAsync() + { + await using var fixture = new Fixture(Stage.Translation, captureInterval: 10, isOneShotMode: true); + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(321)); + await fixture.Translator.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + + try + { + fixture.Model.OverlayVisible = false; + fixture.Model.OverlayVisible = true; + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "old")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "new"); + Assert.Equal(0, fixture.Tracker.UpdateCount); + } + finally + { + fixture.Translator.Gate.Release.TrySetResult(); + } + } + + private static async Task VerifyAutomaticWindowVisibilityAsync() + { + await using var fixture = new Fixture(); + fixture.Model.OverlayVisible = false; + var target = new Window(); + var handle = new WindowInteropHelper(target).EnsureHandle(); + var desktop = new TestDesktopManager(); + var window = CreateOverlayWindow(null, handle, desktop, isOneShotMode: false); + try + { + Assert.False(fixture.Model.OverlayVisible); + Assert.Null(fixture.Capture.CurrentHandler); + window.Show(); + StopPositionTimer(window); + var overlay = (OverlayTextsControl)window.FindName("overlay"); + Assert.False(fixture.Model.OverlayVisible); + window.DataContext = fixture.Model; + Assert.True(fixture.Model.OverlayVisible); + + fixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "old") + && fixture.Translator.Gate.Count > 0); + + desktop.IsCurrent = false; + typeof(OverlayMainWindow).GetMethod("UpdateWindowPositionAndSize", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(window, null); + Assert.False(window.IsVisible); + Assert.Equal(Visibility.Visible, overlay.Visibility); + Assert.False(fixture.Model.OverlayVisible); + Assert.Null(fixture.Capture.CurrentHandler); + + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Hidden); + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + Assert.False(fixture.Model.OverlayVisible); + Assert.Null(fixture.Capture.CurrentHandler); + var trackerCount = fixture.Tracker.UpdateCount; + var colorCount = fixture.Color.Gate.Count; + var translationCount = fixture.Translator.Gate.Count; + await Task.Delay(350); + Assert.Equal(trackerCount, fixture.Tracker.UpdateCount); + Assert.Equal(colorCount, fixture.Color.Gate.Count); + Assert.Equal(translationCount, fixture.Translator.Gate.Count); + + window.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + Assert.True(fixture.Model.OverlayVisible); + await Dispatcher.Yield(DispatcherPriority.Background); + Assert.Empty(fixture.Model.OcrTexts); + fixture.Ocr.Text = "new"; + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + Assert.DoesNotContain(fixture.Model.OcrTexts, text => text.SourceText == "old"); + + desktop.IsCurrent = true; + typeof(OverlayMainWindow).GetMethod("UpdateWindowPositionAndSize", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(window, null); + Assert.False(window.IsVisible); + Assert.False(fixture.Model.OverlayVisible); + window.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Hidden); + Assert.False(fixture.Model.OverlayVisible); + window.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Hidden); + window.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + Assert.False(fixture.Model.OverlayVisible); + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + Assert.True(fixture.Model.OverlayVisible); + + var stopsAtDispose = -1; + window.Closed += (_, _) => + { + fixture.Model.Dispose(); + stopsAtDispose = fixture.Capture.StopCount; + }; + window.Close(); + Assert.Equal(stopsAtDispose, fixture.Capture.StopCount); + } + finally + { + window.Close(); + target.Close(); + } + } + + private static async Task VerifyOneShotWindowVisibilityAsync() + { + await using var fixture = new Fixture(isOneShotMode: true); + var target = new Window(); + var handle = new WindowInteropHelper(target).EnsureHandle(); + var window = CreateOverlayWindow(fixture.Model, handle, new TestDesktopManager(), isOneShotMode: true); + try + { + Assert.False(fixture.Model.OverlayVisible); + window.Show(); + StopPositionTimer(window); + var overlay = (OverlayTextsControl)window.FindName("overlay"); + Assert.False(fixture.Model.OverlayVisible); + Assert.Null(fixture.Capture.CurrentHandler); + + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Visible); + Assert.True(fixture.Model.OverlayVisible); + Assert.NotNull(fixture.Capture.CurrentHandler); + fixture.SubmitCapture(Bitmap(321)); + await WaitUntilAsync(() => fixture.Model.OcrTexts.Any(text => text.SourceText == "new")); + overlay.SetCurrentValue(UIElement.VisibilityProperty, Visibility.Hidden); + Assert.False(fixture.Model.OverlayVisible); + } + finally + { + window.Close(); + target.Close(); + } + } + + private static OverlayMainWindow CreateOverlayWindow( + OverlayMainViewModel? model, IntPtr target, IVirtualDesktopManager desktop, bool isOneShotMode) + => new(new Snapshot(new()), + new Snapshot(new() { IsOneShotMode = isOneShotMode }), + new ProcessInfo(target), desktop, NullLogger.Instance) + { + ShowActivated = false, + Left = -10000, + Top = -10000, + Width = 1, + Height = 1, + DataContext = model, + }; + + private static void StopPositionTimer(OverlayMainWindow window) + => ((DispatcherTimer)typeof(OverlayMainWindow).GetField("timer", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(window)!).Stop(); + + private static async Task VerifyDisposeAsync() + { + await using (var trackedFixture = new Fixture()) + { + SoftwareBitmap? tracked = null; + using var tracking = trackedFixture.Tracking.Subscribe(request => + { + if (request.Context is { } context) Volatile.Write(ref tracked, context.SoftwareBitmap); + }); + trackedFixture.SubmitCapture(Bitmap(320)); + await WaitUntilAsync(() => Volatile.Read(ref tracked) is not null); + trackedFixture.Model.Dispose(); + await WaitUntilAsync(() => IsDisposed(Volatile.Read(ref tracked)!)); + } + + await using var fixture = new Fixture(Stage.Ocr); + var image = Bitmap(320); + fixture.SubmitCapture(image); + await fixture.Ocr.Gate.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Run(fixture.Model.Dispose).WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Ocr.Gate.Release.TrySetResult(); + await WaitUntilAsync(() => IsDisposed(image)); + } + + private static SoftwareBitmap Bitmap(int width) => new(BitmapPixelFormat.Bgra8, width, 240); + + private static bool IsDisposed(SoftwareBitmap bitmap) + { + try + { + using var view = bitmap.GetReadOnlyView(); + return false; + } + catch (Exception exception) when (exception is ObjectDisposedException || exception.HResult == unchecked((int)0x80000013)) + { + return true; + } + } + + private static async Task WaitUntilAsync(Func condition) + { + var timeout = Stopwatch.StartNew(); + while (!condition() && timeout.Elapsed < TimeSpan.FromSeconds(5)) + { + await Task.Delay(20); + } + Assert.True(condition(), "期待した処理が時間内に完了しませんでした。"); + } + + private enum Stage { Ocr, Color, Translation } + + private sealed class Fixture : System.IAsyncDisposable + { + private readonly OcrTraceRecorder recorder = new(NullLogger.Instance); + + public Fixture(Stage? delayed = null, double captureInterval = 0, bool delayReset = false, bool delayFilter = false, bool withoutContext = false, bool failFirst = false, bool delayUpdate = false, bool isOneShotMode = false, bool emptyFirstCapture = false, bool delayNextFilter = false, bool delayCache = false) + { + this.Ocr = new(delayed == Stage.Ocr, failFirst && delayed == Stage.Ocr, emptyFirstCapture); + this.Color = new(delayed == Stage.Color); + this.Translator = new(delayed == Stage.Translation, failFirst && delayed == Stage.Translation); + this.Tracker = new(delayReset, delayUpdate); + this.Filter = new(delayFilter, delayNextFilter); + this.Cache = new(delayCache); + var presentation = DispatchProxy.Create(); + this.Presentation = (PresentationProxy)presentation; + var context = SynchronizationContext.Current; + try + { + if (withoutContext) SynchronizationContext.SetSynchronizationContext(null); + this.Model = new OverlayMainViewModel( + presentation, new Snapshot(new() { CaptureInterval = captureInterval, IsOneShotMode = isOneShotMode }), new ProcessInfo(), + this.Capture, this.Ocr, new Snapshot(new()), this.Tracker, this.recorder, + this.Translator, this.Cache, this.Color, delayFilter ? [this.Filter] : [], NullLogger.Instance); + } + finally + { + if (withoutContext) SynchronizationContext.SetSynchronizationContext(context); + } + } + + public Capture Capture { get; } = new(); + public Ocr Ocr { get; } + public Tracker Tracker { get; } + public Color Color { get; } + public Translator Translator { get; } + public Cache Cache { get; } + public Filter Filter { get; } + public PresentationProxy Presentation { get; } + public OverlayMainViewModel Model { get; } + public Subject<(IEnumerable Texts, FilterContext Context, Observer Visibility)> Tracking + => Field, FilterContext, Observer)>>("trackingRequests"); + public Subject<(IEnumerable Texts, FilterContext Context, Observer Visibility)> Translations + => Field, FilterContext, Observer)>>("translationRequests"); + + // 表示期間の購読は、登録中のキャプチャハンドラーが保持している。 + public Observer Visibility + { + get + { + var target = this.Capture.CurrentHandler!.Target!; + return (Observer)target.GetType().GetFields().Single(f => f.FieldType == typeof(Observer)).GetValue(target)!; + } + } + + public void SubmitCapture(SoftwareBitmap bitmap) + => Field)>>("captureRequests").OnNext((bitmap, this.Visibility)); + + private T Field(string name) where T : class + => (T)typeof(MainViewModelBase).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(this.Model)!; + + public async ValueTask DisposeAsync() + { + this.Model.Dispose(); + await this.recorder.DisposeAsync(); + this.Tracker.ResetRelease.Dispose(); + this.Tracker.UpdateRelease.Dispose(); + this.Cache.Release.Dispose(); + } + } + + private sealed class Gate(bool delayed, bool delayNext = false) + { + private int count; + public int Count => Volatile.Read(ref this.count); + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource NextStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource NextRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task EnterAsync() + { + var current = Interlocked.Increment(ref this.count); + if (current == 1) this.Started.TrySetResult(); + else this.NextStarted.TrySetResult(); + if (delayed && current == 1) await this.Release.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + if (delayNext && current == 2) await this.NextRelease.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + } + } + + private sealed class Snapshot(T value) : IOptionsSnapshot where T : class + { + public T Value { get; } = value; + public T Get(string? name) => this.Value; + } + + private sealed class ProcessInfo(IntPtr handle = default) : IProcessInfoStore + { + public IntPtr MainWindowHandle => handle; + public string Name => "test"; + } + + private sealed class TestDesktopManager : IVirtualDesktopManager + { + public bool IsCurrent { get; set; } = true; + public bool IsWindowOnCurrentVirtualDesktop(IntPtr topLevelWindow) => this.IsCurrent; + public Guid GetWindowDesktopId(IntPtr hWnd) => Guid.Empty; + public int MoveWindowToDesktop(IntPtr hWnd, ref Guid desktop) => 0; + } + + private sealed class Capture : ICaptureModule + { + private int stops; + public event AsyncEventHandler? Captured; + public AsyncEventHandler? CurrentHandler => this.Captured; + public int StopCount => Volatile.Read(ref this.stops); + public void StartCapture(IntPtr targetWindow) { } + public void StopCapture() => Interlocked.Increment(ref this.stops); + } + + private sealed class Ocr(bool delayed, bool failFirst, bool emptyFirstCapture) : IOcrModule + { + public Gate Gate { get; } = new(delayed); + public bool CancelFirst { get; set; } + public string? Text { get; set; } + public async ValueTask> RecognizeAsync(OcrCaptureInput input) + { + await this.Gate.EnterAsync(); + if (this.CancelFirst && this.Gate.Count == 1) throw new OperationCanceledException(); + if (failFirst && this.Gate.Count == 1) throw new InvalidOperationException("模擬 OCR エラー"); + if (emptyFirstCapture && input.Source.PixelWidth == 320) return []; + var text = this.Text ?? (input.Source.PixelWidth == 321 ? "new" : "old"); + return [new TextRect(text, 0, 0, 100, 30, 20, false)]; + } + } + + private sealed class Tracker(bool delayReset, bool delayUpdate) : IOcrTextTracker + { + private int updates; + private int resets; + private int blockNext; + private TextRect[] previous = []; + public TaskCompletionSource ResetStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ManualResetEventSlim ResetRelease { get; } = new(); + public ManualResetEventSlim UpdateRelease { get; } = new(); + public TaskCompletionSource Updated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Blocked { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int UpdateCount => Volatile.Read(ref this.updates); + public int ResetCount => Volatile.Read(ref this.resets); + public void BlockNextUpdate() => Interlocked.Exchange(ref this.blockNext, 1); + public IReadOnlyList Update(IEnumerable observations, System.Drawing.Size imageSize, TimeSpan? timestamp = null) + { + var count = Interlocked.Increment(ref this.updates); + this.Updated.TrySetResult(); + if ((delayUpdate && count == 1) || Interlocked.Exchange(ref this.blockNext, 0) == 1) + { + this.Blocked.TrySetResult(); + if (!this.UpdateRelease.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException(); + } + var current = observations.ToArray(); + this.previous = [.. this.previous.Where(old => current.All(text => text.SourceText != old.SourceText)), .. current]; + return this.previous; + } + public void Reset() + { + var count = Interlocked.Increment(ref this.resets); + this.previous = []; + this.ResetStarted.TrySetResult(); + if (delayReset && count == 1 && !this.ResetRelease.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException(); + } + } + } + + private sealed class Color(bool delayed) : IColorModule + { + public Gate Gate { get; } = new(delayed); + public SoftwareBitmap? Bitmap { get; private set; } + public async ValueTask> ConvertColorAsync(SoftwareBitmap bitmap, IEnumerable texts) + { + this.Bitmap = bitmap; + var result = texts.ToArray(); + await this.Gate.EnterAsync(); + return result; + } + } + + private sealed class Translator(bool delayed, bool failFirst) : ITranslateModule + { + public Gate Gate { get; } = new(delayed); + public TaskCompletionSource Failed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async ValueTask TranslateAsync(TextInfo[] srcTexts) + { + await this.Gate.EnterAsync(); + if (failFirst && this.Gate.Count == 1) + { + this.Failed.TrySetResult(Application.Current.Dispatcher.CheckAccess()); + throw new InvalidOperationException("模擬翻訳エラー"); + } + return srcTexts.Select(text => text.SourceText).ToArray(); + } + } + + public class PresentationProxy : DispatchProxy + { + public TaskCompletionSource Shown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override object? Invoke(MethodInfo? method, object?[]? args) + { + if (method?.Name == nameof(IPresentationService.OpenErrorReportDialogAsync)) + { + this.Shown.TrySetResult(); + return Task.FromResult(true); + } + throw new NotSupportedException(method?.Name); + } + } + + private sealed class Filter(bool delayed, bool delayNext) : IFilterModule + { + public Gate Gate { get; } = new(delayed, delayNext); + public async IAsyncEnumerable ExecutePreTranslate(IAsyncEnumerable texts, FilterContext context) + { + await this.Gate.EnterAsync(); + await foreach (var text in texts) yield return text; + } + + public IAsyncEnumerable ExecutePostTranslate(IAsyncEnumerable texts, FilterContext context) => texts; + } + + private sealed class Cache(bool delayed) : ICacheModule + { + private int containsCount; + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ManualResetEventSlim Release { get; } = new(); + public bool Contains(string src) + { + var count = Interlocked.Increment(ref this.containsCount); + if (delayed && count == 2) + { + this.Started.TrySetResult(); + if (!this.Release.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException(); + } + return false; + } + public string Get(string src) => throw new InvalidOperationException(); + public void AddRange(IEnumerable<(string src, string dst)> pairs) { } + } +} diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 1b27e004..8ad9ca18 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -8,6 +8,7 @@ using Kamishibai; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.Threading; using R3; using Windows.Graphics.Imaging; using WindowTranslator.ComponentModel; @@ -22,9 +23,9 @@ namespace WindowTranslator.Modules.Main; [ObservableObject] public abstract partial class MainViewModelBase : IDisposable { - private readonly Subject captureRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context)> trackingRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context)> translationRequests = new(); + private readonly Subject<(SoftwareBitmap Bitmap, Observer Visibility)> captureRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context, Observer Visibility)> trackingRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context, Observer Visibility)> translationRequests = new(); private readonly Subject> uiRequests = new(); private readonly IDisposable captureLoop; private readonly IDisposable trackingLoop; @@ -96,7 +97,6 @@ public MainViewModelBase( this.isOneShotMode = options.Value.IsOneShotMode; this.DisplayBusy = options.Value.DisplayBusy; this.capture = capture ?? throw new ArgumentNullException(nameof(capture)); - this.capture.Captured += Capture_CapturedAsync; this.ocr = ocr ?? throw new ArgumentNullException(nameof(ocr)); this.priorityRects = ocrParam.Value.PriorityRects ?? []; this.ocrTextTracker = ocrTextTracker ?? throw new ArgumentNullException(nameof(ocrTextTracker)); @@ -116,20 +116,20 @@ public MainViewModelBase( : this.captureRequests; this.captureLoop = captures .ObserveOnThreadPool() - .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); + .SubscribeAwait((request, ct) => RecognizeAsync(request.Bitmap, request.Visibility, ct), AwaitOperation.ThrottleFirstLast, configureAwait: false); this.trackingLoop = Disposable.Combine( Disposable.Create(() => this.trackingRequests.OnCompleted()), - this.ObservePropertyChanged(x => x.OverlayVisible).Where(v => v).ObserveOnThreadPool().Subscribe(_ => this.ocrTextTracker.Reset()), + this.ObservePropertyChanged(x => x.OverlayVisible).Where(v => v).Subscribe(_ => SubscribeWhileVisible()), Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), TimeProvider.System).Subscribe(_ => this.trackingRequests.OnNext(default)), this.trackingRequests .Materialize() .Synchronize() // タイマーの空通知では直前のOCR結果を再利用する。 - .Scan(default((IEnumerable Texts, FilterContext Context)), (previous, current) => + .Scan(default((IEnumerable Texts, FilterContext Context, Observer Visibility)), (previous, current) => { if (current.Value.Context is { } context) { - if (previous.Context?.ImageSize != context.ImageSize) + if (previous.Context?.ImageSize != context.ImageSize || previous.Visibility != current.Value.Visibility) { this.ocrTextTracker.Reset(); } @@ -142,17 +142,18 @@ public MainViewModelBase( previous.Context?.SoftwareBitmap.Dispose(); return current.Value; }) - .Where(request => request.Context is not null) + .Where(request => request.Context is not null && !request.Visibility.IsDisposed) .SubscribeAwait(async (request, ct) => { using var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); - var texts = await ProcessTrackingAsync(request, bitmap, ct); + var texts = await ProcessTrackingAsync((request.Texts, request.Context), bitmap, ct); + if (request.Visibility.IsDisposed) return; if (this.isFirstCapture) { this.isFirstCapture = false; this.uiRequests.OnNext(texts); } - this.translationRequests.OnNext((texts, request.Context with { SoftwareBitmap = bitmap.GetReadOnlyView(), })); + this.translationRequests.OnNext((texts, request.Context with { SoftwareBitmap = bitmap.GetReadOnlyView(), }, request.Visibility)); }, AwaitOperation.Drop, configureAwait: false, cancelOnCompleted: true)); this.translationLoop = this.translationRequests .SubscribeAwaitThrottleFirstLast(ProcessTranslationAsync, static r => r.Context.SoftwareBitmap.Dispose(), configureAwait: false); @@ -181,7 +182,18 @@ partial void OnOverlayVisibleChanged(bool value) } } - private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) + // 表示中だけキャプチャを受け付け、非表示で購読を破棄して表示前の処理結果を見分ける。 + private void SubscribeWhileVisible() + => Observable.Create(observer => + { + AsyncEventHandler handler = (_, args) => Capture_CapturedAsync(args, observer); + this.capture.Captured += handler; + return Disposable.Create(() => this.capture.Captured -= handler); + }, rawObserver: true) + .TakeUntil(this.ObservePropertyChanged(x => x.OverlayVisible, pushCurrentValueOnSubscribe: false).Where(v => !v)) + .Subscribe(); + + private async Task Capture_CapturedAsync(CapturedEventArgs args, Observer visibility) { if (this.isOneShotMode) { @@ -197,10 +209,10 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) } this.Width = bitmap.PixelWidth; this.Height = bitmap.PixelHeight; - this.captureRequests.OnNext(bitmap); + this.captureRequests.OnNext((bitmap, visibility)); } - private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) + private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Observer visibility, CancellationToken cancellationToken) { using var bitmap = new DisposeAction(capturedBitmap.Dispose); IEnumerable texts; @@ -276,7 +288,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati SoftwareBitmap = capturedBitmap.GetReadOnlyView(), ImageSize = new(capturedBitmap.PixelWidth, capturedBitmap.PixelHeight), }; - this.trackingRequests.OnNext((texts, context)); + this.trackingRequests.OnNext((texts, context, visibility)); } internal static IEnumerable SelectOcrTexts( @@ -298,7 +310,7 @@ private async ValueTask> ProcessTrackingAsync((IEnumerable return texts; } - private async ValueTask ProcessTranslationAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) + private async ValueTask ProcessTranslationAsync((IEnumerable Texts, FilterContext Context, Observer Visibility) request, CancellationToken cancellationToken) { using var bitmap = request.Context.SoftwareBitmap; cancellationToken.ThrowIfCancellationRequested(); @@ -362,6 +374,7 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi texts = await tmp.ToArrayAsync(cancellationToken); } cancellationToken.ThrowIfCancellationRequested(); + if (request.Visibility.IsDisposed) return; this.uiRequests.OnNext(texts); } diff --git a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml index eda07005..278893f2 100644 --- a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml +++ b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml @@ -15,6 +15,7 @@ d:Width="800" AllowsTransparency="True" Background="Transparent" + DataContextChanged="OverlayVisibilityChanged" Loaded="Window_Loaded" ResizeMode="NoResize" ShowInTaskbar="False" @@ -31,13 +32,13 @@ x:Name="overlay" FontFamily="{Binding Font, Mode=OneWay}" IsSwapVisibility="{Binding IsSwapVisibility, ElementName=host, Mode=OneWay}" + IsVisibleChanged="OverlayVisibilityChanged" MousePointerHitTestPadding="{Binding MousePointerHitTestPadding, Mode=OneWay}" MousePos="{Binding MousePos, ElementName=host, Mode=OneWay}" RectHeight="{Binding Height}" RectWidth="{Binding Width}" Scale="{Binding Scale, ElementName=host, Mode=OneWay}" - Texts="{Binding OcrTexts}" - Visibility="{Binding OverlayVisible, Mode=OneWayToSource, Converter={StaticResource v2bConv}}" /> + Texts="{Binding OcrTexts}" />