Keep the front where the dictation started on macOS

Recording goes through ffmpeg's avfoundation input, and opening a capture
session there brings the process that did it to the front. ffmpeg is a
child of Dikte with no bundle of its own, so macOS credits the move to
Dikte: the window the user was dictating into goes inactive, its caret
stops, its title bar greys out, and the Cmd+V at the end of the run lands
somewhere other than the document it was meant for. Measured with a
TextEdit document in front:

    press the shortcut          front = TextEdit
    recorder.start returns      front = TextEdit
    89 ms later                 front = Dikte

Nothing about the capture session can be asked not to do this. Starting
ffmpeg in its own session, and clearing __CFBundleIdentifier from its
environment, were both tried and both measured to make no difference, so
it is undone instead: the application in front is noted before the
indicator goes up, and a short watch puts it back the moment Dikte takes
the front. Measured at 99 ms from the moment it is taken, against the
title bar staying grey for the whole dictation before. All three ways in
do it, dictation, agent and meeting, since all three open the same
capture.

The indicator had a share of the same problem and needed AppKit for it
too, so mac_window.py carries both. An NSPanel is hidden by the system
the moment its application stops being the active one, which for a
dictation indicator is immediately, and ordering one to the front brings
its application with it unless it carries the nonactivating style bit.
Neither is reachable through Qt.

The runtime is loaded in _appkit() rather than at import, the way paste.py
loads its frameworks in _macos_api(), so the module imports on a machine
with no AppKit and the tests below run there as well: 52 new tests, none
of them skipped anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Gökhan
2026-08-16 17:37:31 +03:00
co-authored by Claude Opus 5
parent 569fe7cea8
commit 45b18722be
8 changed files with 679 additions and 15 deletions
+92 -2
View File
@@ -17,6 +17,7 @@ import signal
import socket
import sys
import threading
import time
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
@@ -46,6 +47,7 @@ from . import hotkey # noqa: E402
from . import i18n # noqa: E402
from . import integrate # noqa: E402
from . import ipc # noqa: E402
from . import mac_window # noqa: E402
from . import meeting # noqa: E402
from . import trayicon # noqa: E402
from .i18n import t # noqa: E402
@@ -107,6 +109,12 @@ class Dikte:
# Which recording is the current one, so a timer set for the run that
# started it cannot stop the one that came after.
self._run_id = 0
# The application that was in front when the recording started, which
# is where the transcript is meant to go, and the timer watching for
# the moment it has to be put back there. macOS only; see
# _give_the_front_back.
self.front_before = None
self._front_watch = None
self.overlay = Overlay(self.conf["overlay_corner"])
# The agent's indicator sits on top of the dictation one when both are
@@ -550,9 +558,19 @@ class Dikte:
self.last_toggle.restart()
return False
def _the_front(self):
"""The application a recording is about to start from, or None.
Asked before the indicator goes up rather than alongside the
microphone: putting a window on screen can take the front as well, and
once it has, the only answer left to the question is Dikte.
"""
return mac_window.frontmost_pid() if sys.platform == "darwin" else None
def start(self):
if self.state != IDLE or self.recording:
return
self.front_before = self._the_front()
self.overlay.show_recording()
self._begin_recording(DICTATION)
self._set_state(RECORDING)
@@ -560,6 +578,7 @@ class Dikte:
def start_ask(self):
if self.ask_state != IDLE or self.recording:
return
self.front_before = self._the_front()
self.ask_overlay.show_recording(asking=True)
self._begin_recording(ASK)
self._set_ask_state(RECORDING)
@@ -572,6 +591,68 @@ class Dikte:
self.elapsed.restart()
self.ticker.start()
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
self._give_the_front_back(self.front_before)
def _give_the_front_back(self, was_in_front):
"""Hand the front back to whoever had it when the recording started.
On macOS a recording goes through ffmpeg's avfoundation input, and
opening a capture session there brings the process that did it to the
front. ffmpeg is a child of Dikte with no bundle of its own, so the
system credits the move to Dikte: the window the user was typing in
loses the front, its caret stops, its title bar greys out, and the
Cmd+V at the end of the dictation has nowhere to land. Measured with a
TextEdit document in front:
press the shortcut front = TextEdit
recorder.start returns front = TextEdit
89 ms later front = Dikte
Nothing about the capture session can be asked not to do this. It is
not a window of ours and no flag reaches it. Starting ffmpeg in its own
session, and clearing __CFBundleIdentifier from its environment, were
both tried and both measured to make no difference. So it is undone
instead. The move lands a moment after the process starts rather than
during the call, hence the short watch rather than one attempt: it
gives up as soon as it has put the front back, and in any case after a
second and a half, which is longer than the microphone has ever taken
to open.
Silent off macOS, and silent when the recording started from Dikte
itself: there is nothing to give back.
"""
# One watch at a time. A second recording started before the first
# watch had finished would otherwise leave two of them running, and the
# older one would put the front back where the older recording
# started, which by then is the wrong window.
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
if not was_in_front or was_in_front == os.getpid():
return
deadline = time.monotonic() + 1.5
watch = QTimer(self.app)
# Ten milliseconds because the front is already gone by the time this
# notices, and every tick it waits is a tick of the user's window drawn
# inactive: at forty the title bar visibly blinks, at ten it does not.
# Two messages to AppKit per tick, for at most a second and a half.
watch.setInterval(10)
def look():
if mac_window.is_frontmost():
mac_window.activate(was_in_front)
self._stop_watching_the_front()
elif time.monotonic() > deadline:
self._stop_watching_the_front()
watch.timeout.connect(look)
self._front_watch = watch
watch.start()
def _stop_watching_the_front(self):
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
def stop(self):
if self.state != RECORDING:
@@ -687,6 +768,12 @@ class Dikte:
return
base = meeting.new_base()
_, wav_path = cfg.meeting_paths(base)
# A meeting opens the same capture as a dictation does, and takes the
# front the same way: whoever is being recorded is in a call, and
# having their window go inactive mid-sentence is worse here than
# anywhere else. Kept as a local rather than on self: a dictation may
# already be waiting on its own note for where to paste.
was_in_front = self._the_front()
self.meeting_recorder.start(
str(wav_path),
self.conf["meeting_mic_target"] or self.conf["mic_target"],
@@ -695,6 +782,7 @@ class Dikte:
)
if not self.meeting_recorder.active:
return # start() has already said what went wrong
self._give_the_front_back(was_in_front)
self.meeting_base = base
self.meeting_elapsed.restart()
self.meeting_ticker.start()
@@ -821,11 +909,13 @@ class Dikte:
def _on_recorded(self, wav_path, duration, rms_values):
owner, self.recorder_owner = self.recorder_owner, None
wants_paste = self.paste_override.pop(owner, None)
focus, self.front_before = self.front_before, None
if owner == ASK:
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
paste=wants_paste)
paste=wants_paste, focus=focus)
else:
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
self.pipeline.run(wav_path, duration, rms_values,
paste=wants_paste, focus=focus)
def _on_finished(self, _raw, text, warning):
if warning:
+203
View File
@@ -0,0 +1,203 @@
"""The parts of AppKit a dictation needs on macOS and Qt does not reach.
Two jobs, both about staying out of the user's way.
The first is keeping the indicator on screen. Qt draws it as a tool window,
which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment
its application stops being the active one. For a dictation indicator that is
exactly backwards: you press the shortcut inside some other program, so Dikte is
never the active application, and the one window that has something to say
disappears as soon as you look away from it. Three settings AppKit has and Qt
does not expose:
hidesOnDeactivate = NO stay put when another application comes forward
collectionBehavior show on whichever desktop is in front, including
over a full screen window, and stay out of Cmd+Tab
nonactivating panel come to the front without bringing Dikte with it
The second is putting the front back. Opening the microphone activates Dikte
whatever the indicator does, so app.py watches for that and calls activate()
here. See _give_the_front_back() there for the measurement.
Done through the Objective-C runtime rather than a binding, because Dikte has no
third party Python packages and this is a handful of messages to three objects.
The runtime is loaded in _appkit() rather than at import, the way paste.py loads
its frameworks in _macos_api(): that is the one function a test fakes, and it is
what lets the tests below run on a machine that has no AppKit at all.
"""
import ctypes
import ctypes.util
import os
from PyQt6.QtGui import QGuiApplication
# NSWindowCollectionBehavior, as of the macOS these names come from:
CAN_JOIN_ALL_SPACES = 1 << 0
IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on
FULL_SCREEN_AUXILIARY = 1 << 8
BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY
# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to
# the front brings Dikte to the front with it, and the application the user was
# typing in loses focus the moment they start dictating: the Cmd+V at the end
# then lands on the indicator instead of their document. Qt has no flag for
# this; WA_ShowWithoutActivating governs the show, not what the panel does to
# the application afterwards.
NONACTIVATING_PANEL = 1 << 7
_appkit_runtime = None
class _AppKit:
"""objc_msgSend under the signatures this file sends it through.
It has no fixed signature of its own, and calling it through the wrong
argument or return types is how a Mac crashes rather than raises, so each
one is spelled out once here and used by name below.
"""
def __init__(self, objc):
self.objc = objc
objc.sel_registerName.restype = ctypes.c_void_p
objc.sel_registerName.argtypes = [ctypes.c_char_p]
objc.objc_getClass.restype = ctypes.c_void_p
objc.objc_getClass.argtypes = [ctypes.c_char_p]
self.ask = self._as(ctypes.c_void_p)
self.ask_bool = self._as(ctypes.c_bool)
self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32
self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger
self.tell_bool = self._as(None, ctypes.c_bool)
self.tell_unsigned = self._as(None, ctypes.c_ulong)
self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p)
self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int)
self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong)
def _as(self, returns, *arguments):
return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE(
returns, ctypes.c_void_p, ctypes.c_void_p, *arguments))
def selector(self, name):
return self.objc.sel_registerName(name)
def shared(self, class_name, selector):
"""A class's singleton, e.g. +[NSWorkspace sharedWorkspace]."""
return ctypes.c_void_p(self.ask(
ctypes.c_void_p(self.objc.objc_getClass(class_name)),
self.selector(selector)))
def _appkit():
"""The Objective-C runtime, loaded the first time something needs it.
Loaded here rather than at import so that this module can be imported on a
machine that has no AppKit: the tests stand on macOS from a Linux machine
and back, and this is the one function they replace to do it.
"""
global _appkit_runtime
if _appkit_runtime is None:
_appkit_runtime = _AppKit(
ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")))
return _appkit_runtime
def frontmost_pid():
"""Which application is in front, by process id, or None when unasked.
A process id rather than the object itself: the object would have to be
retained to survive the trip, and a number needs nothing looking after it.
"""
try:
api = _appkit()
workspace = api.shared(b"NSWorkspace", b"sharedWorkspace")
running = ctypes.c_void_p(api.ask(
workspace, api.selector(b"frontmostApplication")))
if not running:
return None
return int(api.ask_pid(running, api.selector(b"processIdentifier")))
except Exception:
return None
def activate(pid):
"""Put the application with that process id back in front.
False when it has gone away in the meantime, or when the message could not
be sent at all: a dictation is not worth failing over the window behind it.
"""
if not pid:
return False
try:
api = _appkit()
running = ctypes.c_void_p(api.ask_of_pid(
ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")),
api.selector(b"runningApplicationWithProcessIdentifier:"),
int(pid)))
if not running:
return False
# activateWithOptions: rather than the deprecated activate, and with no
# options: bringing every one of its windows forward is not asked for,
# only the application it was before Dikte took the front from it.
return bool(api.ask_with_options(
running, api.selector(b"activateWithOptions:"), 0))
except Exception:
return False
def is_frontmost():
"""Whether Dikte itself is the application in front.
By process id rather than -[NSRunningApplication isActive] on our own
process, which stays true once the application has ever been activated:
measured True with another application plainly in front.
"""
pid = frontmost_pid()
return pid is not None and pid == os.getpid()
def _is_panel(api, window):
"""Whether this window is an NSPanel, which is the only kind the
nonactivating bit is legal on: setting it on a plain NSWindow raises an
Objective-C exception, and an exception through ctypes takes the process
down with it."""
panel = api.objc.objc_getClass(b"NSPanel")
if not panel:
return False
return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"),
ctypes.c_void_p(panel)))
def keep_on_screen(widget):
"""Ask the window behind `widget` to stay while other programs are used.
Silent when anything is not as expected: an indicator that cannot be made
to linger is still an indicator, and a dictation should not fail over the
window it is drawn in.
"""
# Only the Cocoa backend hands out a real NSView. Under the offscreen
# platform the tests run on, winId() is a number that means something else
# entirely, and sending an Objective-C message to it is how a test run
# turns into a crash.
if QGuiApplication.platformName() != "cocoa":
return False
try:
api = _appkit()
view = ctypes.c_void_p(int(widget.winId()))
window = api.ask(view, api.selector(b"window"))
if not window:
return False
window = ctypes.c_void_p(window)
api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False)
api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"),
BEHAVIOUR)
# Only a panel may carry the nonactivating bit, and only a panel is
# asked to: on anything else the message raises, and an Objective-C
# exception through ctypes takes the process with it.
if _is_panel(api, window):
mask = api.ask_unsigned(window, api.selector(b"styleMask"))
if not mask & NONACTIVATING_PANEL:
api.tell_unsigned(window, api.selector(b"setStyleMask:"),
mask | NONACTIVATING_PANEL)
return True
except (AttributeError, OSError, RuntimeError, ValueError):
return False
+7
View File
@@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
from PyQt6.QtWidgets import QWidget, QApplication
from . import mac_window
BARS = 22
HEIGHT = 56
MIN_WIDTH = 210
@@ -201,6 +203,11 @@ class Overlay(QWidget):
self._reposition()
if not self.isVisible():
self.show()
if sys.platform == "darwin":
# After show(), because the window it works on does not exist until
# then, and every time, because a window Qt rebuilt has the setting
# again at its default.
mac_window.keep_on_screen(self)
if self._concealed:
self.raise_()
self._concealed = False
+33 -5
View File
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
def ready():
return shutil.which(program) is not None
def press(shortcut, delay):
def press(shortcut, delay, _focus=None):
# Nothing here takes the front from the window being dictated into, so
# there is nothing to hand back: the process id is a macOS concern.
if not ready():
raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=program))
@@ -296,11 +298,27 @@ def _ask_for_permission():
pass
def _macos_press(shortcut, delay):
def _macos_press(shortcut, delay, focus=None):
"""Post the key down and up straight into the window system.
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
only asks once, when the paste it was granted for is first tried.
`focus` is the application that was in front when the recording began. The
keys land wherever the window system is pointing, so a Dikte that has ended
up in front would swallow its own transcript; when that has happened the
front is handed back before pressing. Nothing is taken from anyone else: an
application the user went to while the transcription ran is where they want
the text now.
This runs on the transcription's own thread rather than the main one, and
the two calls it makes are the kind AppKit documents as answering
atomically wherever they are asked from: NSRunningApplication is thread
safe by its own header, and the workspace lookup behind it returns a
reference rather than anything that has to be held. Stressed with four
threads and 32000 lookups against a running main loop without a fault; if
one ever does happen, mac_window answers None and the press goes ahead
where it would have gone anyway.
"""
keycode, flags = _macos_keys(shortcut)
services, core = _macos_api()
@@ -310,6 +328,12 @@ def _macos_press(shortcut, delay):
"macOS has not been told to let Dikte press keys. Turn Dikte on "
"under System Settings → Privacy & Security → Accessibility."
))
if focus:
# Imported here rather than at the top: it reaches for QtGui, and a
# terminal that only wants the clipboard should not pay for that.
from . import mac_window
if mac_window.is_frontmost():
mac_window.activate(focus)
time.sleep(delay) # let the selection settle and focus come back
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
@@ -488,7 +512,11 @@ def paste_ready():
return desktop().ready()
def press(shortcut="", delay=0.12):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
def press(shortcut="", delay=0.12, focus=None):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own.
`focus` is the process the keys are meant for, remembered when the
recording started: see the macOS press for what is done with it.
"""
here = desktop()
here.press(shortcut or here.shortcuts[0], delay)
here.press(shortcut or here.shortcuts[0], delay, focus)
+10 -5
View File
@@ -50,16 +50,20 @@ class Pipeline(QObject):
def busy(self):
return self._thread is not None and self._thread.is_alive()
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None,
focus=None):
"""`paste` overrides the setting for this one run, which is what a
dictation asked for from a terminal wants: the text comes back down the
socket, and pasting it into whatever had focus is nobody's intention."""
socket, and pasting it into whatever had focus is nobody's intention.
`focus` is the application that was in front when the recording began,
as a process id, and is where the paste is meant to land."""
if self.busy:
return
self._stop.clear()
self._thread = threading.Thread(
target=self._work,
args=(wav_path, duration, list(rms_values), ask, paste),
args=(wav_path, duration, list(rms_values), ask, paste, focus),
daemon=True,
)
self._thread.start()
@@ -73,7 +77,8 @@ class Pipeline(QObject):
"""
self._stop.set()
def _work(self, wav_path, duration, rms_values, ask, paste_override=None):
def _work(self, wav_path, duration, rms_values, ask, paste_override=None,
focus=None):
conf = self.conf
started = time.monotonic()
raw = ""
@@ -144,7 +149,7 @@ class Pipeline(QObject):
paste.copy(text)
if wants_paste:
self.stage.emit(t("Pasting…"))
paste.press(conf["paste_shortcut"])
paste.press(conf["paste_shortcut"], focus=focus)
finally:
if previous is not None:
# Let the focused application consume the temporary
+39
View File
@@ -440,6 +440,45 @@ class MacOS(ClipboardContract, DikteTest):
self.assertFalse(paste.paste_ready())
class MacPasteGoesWhereTheDictationStarted(MacOS):
"""The keys land in the frontmost window, so the front is what decides
where a transcript ends up."""
def setUp(self):
super().setUp()
from dikte import mac_window
self.mac_window = mac_window
self.activated = []
self.patch_attr(mac_window, "activate", self.activated.append)
def frontmost(self, dikte_is):
self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is)
def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self):
self.frontmost(True)
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [4242])
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
def test_another_application_in_front_is_where_the_user_went_and_is_left(self):
self.frontmost(False)
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [])
def test_a_run_that_remembered_nobody_asks_nothing(self):
self.frontmost(True)
paste.press("cmd+v")
self.assertEqual(self.activated, [])
def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self):
"""Pulling the user out of their window and then failing to type would
be the worst of both."""
self.frontmost(True)
self.api.trusted = False
with self.assertRaises(paste.PasteError):
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [])
class MacClipboardSnapshot(DikteTest):
def test_every_native_type_is_restored_and_the_files_are_removed(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
+282
View File
@@ -488,6 +488,288 @@ class KdeSettings(Settings):
self.assertFalse(window.evdev_enabled.isHidden())
class FakeAppKit:
"""The Objective-C runtime, answering rather than being asked.
Stands in for what mac_window._appkit() loads, so what the indicator's
window would have been sent can be read off `told` on any machine. The
selectors come back as the names they were registered under, which is what
lets the tests below name the message they mean.
"""
def __init__(self, window=4242, panel=True, mask=0xe):
self.objc = self
self.window, self.panel, self.mask = window, panel, mask
self.told = {}
def objc_getClass(self, name):
return 1 if name == b"NSPanel" else 0
def selector(self, name):
return name.decode()
def shared(self, _class_name, _selector):
return 1
def ask(self, _to, selector):
return self.window if selector == "window" else 0
def ask_unsigned(self, _to, _selector):
return self.mask
def ask_of_class(self, _to, _selector, _klass):
return self.panel
def tell_bool(self, _to, selector, value):
self.told[selector] = value
tell_unsigned = tell_bool
class MacIndicatorWindow(DikteTest):
"""Which of the three AppKit settings the indicator's window is sent.
The nonactivating bit is the one worth a test of its own: it is legal on an
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
Objective-C exception, which through ctypes is not something Python can
catch. It takes the whole process down. `_is_panel` is the only thing
standing between the two, so what it answers has to decide what is sent.
"""
def setUp(self):
super().setUp()
from dikte import mac_window
self.mac_window = mac_window
self.patch_attr(mac_window.QGuiApplication, "platformName",
staticmethod(lambda: "cocoa"))
def told(self, **kwargs):
"""What keep_on_screen sends an indicator, against this AppKit."""
appkit = FakeAppKit(**kwargs)
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
widget = overlay_module.Overlay()
self.addCleanup(widget.deleteLater)
self.addCleanup(widget.close)
self.answered = self.mac_window.keep_on_screen(widget)
return appkit.told
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
"""The message that would kill the process. The other two still go."""
told = self.told(panel=False)
self.assertTrue(self.answered)
self.assertNotIn("setStyleMask:", told)
self.assertIs(told["setHidesOnDeactivate:"], False)
self.assertEqual(told["setCollectionBehavior:"],
self.mac_window.BEHAVIOUR)
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
told = self.told(panel=True, mask=0xe)
self.assertEqual(told["setStyleMask:"],
0xe | self.mac_window.NONACTIVATING_PANEL)
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
"""Every dictation runs this again, and a mask Cocoa did not need is a
window it rebuilds underneath the indicator."""
told = self.told(panel=True,
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
self.assertNotIn("setStyleMask:", told)
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
self.assertEqual(self.told(window=0), {})
self.assertFalse(self.answered)
def test_nothing_is_sent_anywhere_but_cocoa(self):
"""Every other platform hands out a winId that means something else
entirely, and messaging it crashes the run."""
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
staticmethod(lambda: "offscreen"))
self.assertEqual(self.told(), {})
self.assertFalse(self.answered)
class GivingTheFrontBack(DikteTest):
"""Opening the microphone brings Dikte to the front, and the window the
user was dictating into goes inactive with the caret in it. Nothing can be
asked of the capture session, so the front is put back afterwards.
The watch is driven by hand here: what matters is what it decides, not how
long Qt takes to tick.
"""
def setUp(self):
super().setUp()
from dikte import app as dikte_module
from dikte import mac_window
self.dikte = dikte_module
self.activated = []
self.patch_attr(mac_window, "activate", self.activated.append)
self.ticks = []
outer = self
class FakeTimer:
"""Records what it was asked to do and hands over the tick."""
def __init__(self, _parent):
self.interval = None
self.running = False
outer.ticks.append(self)
def setInterval(self, milliseconds):
self.interval = milliseconds
def start(self):
self.running = True
def stop(self):
self.running = False
@property
def timeout(self):
return self
def connect(self, slot):
self.tick = slot
self.patch_attr(dikte_module, "QTimer", FakeTimer)
class BareDikte:
"""As much of the application as this one method touches."""
app = None
_front_watch = None
_the_front = dikte_module.Dikte._the_front
_give_the_front_back = dikte_module.Dikte._give_the_front_back
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
self.bare = BareDikte
def watching(self, was_in_front, dikte_in_front, on=None):
from dikte import mac_window
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
dikte = on if on is not None else self.bare()
dikte._give_the_front_back(was_in_front)
return self.ticks[-1] if self.ticks else None
def test_the_front_goes_back_to_whoever_had_it(self):
watch = self.watching(4242, dikte_in_front=True)
watch.tick()
self.assertEqual(self.activated, [4242])
self.assertFalse(watch.running) # one is enough; it stops watching
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
"""The microphone does not always take it, and pulling an application
forward that is already there is one flicker for nothing."""
watch = self.watching(4242, dikte_in_front=False)
watch.tick()
self.assertEqual(self.activated, [])
self.assertTrue(watch.running) # still waiting for the moment
def test_it_gives_up_rather_than_watching_for_ever(self):
watch = self.watching(4242, dikte_in_front=False)
with mock.patch.object(self.dikte.time, "monotonic",
return_value=self.dikte.time.monotonic() + 60):
watch.tick()
self.assertFalse(watch.running)
self.assertEqual(self.activated, [])
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
"""Settings is a window of ours, and the front is already where it
belongs."""
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
"""The older watch remembers where the older recording started, and by
now that is the wrong window to be pulling forward."""
dikte = self.bare()
first = self.watching(4242, dikte_in_front=False, on=dikte)
second = self.watching(1111, dikte_in_front=False, on=dikte)
self.assertFalse(first.running)
self.assertTrue(second.running)
second.tick() # and the survivor is the new one
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
second.tick()
self.assertEqual(self.activated, [1111])
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
"""Starting the next one from Dikte's own window is not a reason to
leave the last one's watch running."""
dikte = self.bare()
first = self.watching(4242, dikte_in_front=False, on=dikte)
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
self.assertFalse(first.running)
self.assertIsNone(dikte._front_watch)
def test_nobody_in_front_is_nobody_to_go_back_to(self):
self.assertIsNone(self.watching(None, dikte_in_front=True))
def mac_window_module(self):
from dikte import mac_window
return mac_window
class EveryRecordingProtectsTheFront(DikteTest):
"""Three ways in, dictation, agent and meeting, and all three open the same
avfoundation capture, so all three take the front the same way. What is
checked here is the order: the front has to be noted before the microphone
is opened, and the watch armed after, or there is nothing to go back to.
"""
def setUp(self):
super().setUp()
from dikte import app as dikte_module
self.dikte = dikte_module
self.order = []
def app(self, **attributes):
"""A Dikte that records the order it does things in, and nothing else.
The methods under test are called unbound against it, so the stand-ins
go on the object rather than on the class.
"""
dikte = mock.Mock(**attributes)
dikte._run_id = 0
dikte.conf = {"mic_target": "", "max_seconds": 60,
"meeting_mic_target": "", "meeting_system_target": "",
"meeting_max_seconds": 60}
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
dikte._give_the_front_back.side_effect = (
lambda pid: self.order.append(f"watching {pid}"))
dikte._begin_recording = (
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
dikte.recorder.start.side_effect = (
lambda *_a: self.order.append("microphone"))
dikte.meeting_recorder.start.side_effect = (
lambda *_a: self.order.append("microphone"))
return dikte
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
dikte = self.app(state=self.dikte.IDLE, recording=False)
dikte.overlay.show_recording.side_effect = (
lambda *_a: self.order.append("indicator"))
self.dikte.Dikte.start(dikte)
self.assertEqual(self.order,
["noted", "indicator", "microphone", "watching 4242"])
def test_the_agent_does_the_same(self):
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
self.dikte.Dikte.start_ask(dikte)
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
def test_a_meeting_does_the_same(self):
"""The one most worth protecting: the user is in a call."""
dikte = self.app(meeting_state=self.dikte.M_IDLE)
dikte.meeting_recorder.active = True
self.dikte.Dikte.start_meeting(dikte)
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
dikte = self.app(meeting_state=self.dikte.M_IDLE)
dikte.meeting_recorder.active = False
self.dikte.Dikte.start_meeting(dikte)
self.assertEqual(self.order, ["noted", "microphone"])
class Overlay(DikteTest):
def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs)
+13 -3
View File
@@ -32,7 +32,8 @@ class Chain(DikteTest):
transcript="uh, book it for Thursday",
cleaned="Book it for Thursday.",
cleanup_error=None, answer=("Booked.", ""), rms=None,
clipboard=b"what was there before", paste_error=None):
clipboard=b"what was there before", paste_error=None,
focus=None):
pipeline = worker.Pipeline(self.conf)
done, failures, stages, cancels = [], [], [], []
pipeline.finished.connect(lambda *args: done.append(args))
@@ -60,7 +61,8 @@ class Chain(DikteTest):
"copy": copy, "copy_bytes": copy_bytes, "press": press,
"read_clipboard": read_clipboard}
pipeline._work(self.wav, duration,
self.rms if rms is None else rms, ask, paste_override)
self.rms if rms is None else rms, ask, paste_override,
focus)
return {"done": done, "failures": failures, "stages": stages,
"cancelled": cancels, **calls}
@@ -72,7 +74,15 @@ class Chain(DikteTest):
self.assertEqual(run["done"][0],
("uh, book it for Thursday", "Book it for Thursday.", ""))
run["copy"].assert_called_once_with("Book it for Thursday.")
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
focus=None)
def test_the_paste_is_told_where_the_dictation_started(self):
"""Whoever was in front when the recording began is where the keys are
meant to go, and the press is the only part that can act on it."""
run = self.run_chain(focus=4242)
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
focus=4242)
def test_the_stages_are_named_as_they_happen(self):
run = self.run_chain()