mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-12 03:16:19 +00:00
Merge pull request #43 from ademtfkc/mac-keeps-the-front
Keep the front where the dictation started on macOS
This commit is contained in:
+104
-2
@@ -18,6 +18,7 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||||
# is drawn through XWayland.
|
# is drawn through XWayland.
|
||||||
@@ -49,6 +50,7 @@ from . import hub # noqa: E402
|
|||||||
from . import i18n # noqa: E402
|
from . import i18n # noqa: E402
|
||||||
from . import integrate # noqa: E402
|
from . import integrate # noqa: E402
|
||||||
from . import ipc # noqa: E402
|
from . import ipc # noqa: E402
|
||||||
|
from . import mac_window # noqa: E402
|
||||||
from . import meeting # noqa: E402
|
from . import meeting # noqa: E402
|
||||||
from . import trayicon # noqa: E402
|
from . import trayicon # noqa: E402
|
||||||
from . import update # noqa: E402
|
from . import update # noqa: E402
|
||||||
@@ -145,6 +147,12 @@ class Dikte:
|
|||||||
# Which recording is the current one, so a timer set for the run that
|
# Which recording is the current one, so a timer set for the run that
|
||||||
# started it cannot stop the one that came after.
|
# started it cannot stop the one that came after.
|
||||||
self._run_id = 0
|
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"])
|
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||||
# The agent's indicator sits on top of the dictation one when both are
|
# The agent's indicator sits on top of the dictation one when both are
|
||||||
@@ -605,9 +613,19 @@ class Dikte:
|
|||||||
self.last_toggle.restart()
|
self.last_toggle.restart()
|
||||||
return False
|
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):
|
def start(self):
|
||||||
if self.state != IDLE or self.recording:
|
if self.state != IDLE or self.recording:
|
||||||
return
|
return
|
||||||
|
self.front_before = self._the_front()
|
||||||
self.overlay.show_recording()
|
self.overlay.show_recording()
|
||||||
self._begin_recording(DICTATION)
|
self._begin_recording(DICTATION)
|
||||||
self._set_state(RECORDING)
|
self._set_state(RECORDING)
|
||||||
@@ -615,6 +633,7 @@ class Dikte:
|
|||||||
def start_ask(self):
|
def start_ask(self):
|
||||||
if self.ask_state != IDLE or self.recording:
|
if self.ask_state != IDLE or self.recording:
|
||||||
return
|
return
|
||||||
|
self.front_before = self._the_front()
|
||||||
self.ask_overlay.show_recording(asking=True)
|
self.ask_overlay.show_recording(asking=True)
|
||||||
self._begin_recording(ASK)
|
self._begin_recording(ASK)
|
||||||
self._set_ask_state(RECORDING)
|
self._set_ask_state(RECORDING)
|
||||||
@@ -627,6 +646,80 @@ class Dikte:
|
|||||||
self.elapsed.restart()
|
self.elapsed.restart()
|
||||||
self.ticker.start()
|
self.ticker.start()
|
||||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
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)
|
||||||
|
# activateWithOptions: answers whether macOS accepted the request, not
|
||||||
|
# whether the other application is already back in front. Keep the
|
||||||
|
# watch alive until that asynchronous handoff is observable; on Intel
|
||||||
|
# Macs it can take hundreds of milliseconds after the call returned.
|
||||||
|
restore_requested = False
|
||||||
|
|
||||||
|
def look():
|
||||||
|
nonlocal restore_requested
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
self._stop_watching_the_front()
|
||||||
|
return
|
||||||
|
if mac_window.is_frontmost():
|
||||||
|
if not restore_requested:
|
||||||
|
restore_requested = mac_window.activate(was_in_front)
|
||||||
|
elif restore_requested:
|
||||||
|
# The request has landed. Stop only now, rather than as soon
|
||||||
|
# as AppKit accepted it, so a delayed or failed handoff stays
|
||||||
|
# under observation until the deadline guard above.
|
||||||
|
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):
|
def stop(self):
|
||||||
if self.state != RECORDING:
|
if self.state != RECORDING:
|
||||||
@@ -742,6 +835,12 @@ class Dikte:
|
|||||||
return
|
return
|
||||||
base = meeting.new_base()
|
base = meeting.new_base()
|
||||||
_, wav_path = cfg.meeting_paths(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(
|
self.meeting_recorder.start(
|
||||||
str(wav_path),
|
str(wav_path),
|
||||||
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
||||||
@@ -750,6 +849,7 @@ class Dikte:
|
|||||||
)
|
)
|
||||||
if not self.meeting_recorder.active:
|
if not self.meeting_recorder.active:
|
||||||
return # start() has already said what went wrong
|
return # start() has already said what went wrong
|
||||||
|
self._give_the_front_back(was_in_front)
|
||||||
self.meeting_base = base
|
self.meeting_base = base
|
||||||
self.meeting_elapsed.restart()
|
self.meeting_elapsed.restart()
|
||||||
self.meeting_ticker.start()
|
self.meeting_ticker.start()
|
||||||
@@ -876,11 +976,13 @@ class Dikte:
|
|||||||
def _on_recorded(self, wav_path, duration, rms_values):
|
def _on_recorded(self, wav_path, duration, rms_values):
|
||||||
owner, self.recorder_owner = self.recorder_owner, None
|
owner, self.recorder_owner = self.recorder_owner, None
|
||||||
wants_paste = self.paste_override.pop(owner, None)
|
wants_paste = self.paste_override.pop(owner, None)
|
||||||
|
focus, self.front_before = self.front_before, None
|
||||||
if owner == ASK:
|
if owner == ASK:
|
||||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||||
paste=wants_paste)
|
paste=wants_paste, focus=focus)
|
||||||
else:
|
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):
|
def _on_finished(self, _raw, text, warning):
|
||||||
if warning:
|
if warning:
|
||||||
|
|||||||
@@ -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,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
|||||||
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||||
from PyQt6.QtWidgets import QWidget, QApplication
|
from PyQt6.QtWidgets import QWidget, QApplication
|
||||||
|
|
||||||
|
from . import mac_window
|
||||||
|
|
||||||
BARS = 22
|
BARS = 22
|
||||||
HEIGHT = 56
|
HEIGHT = 56
|
||||||
MIN_WIDTH = 210
|
MIN_WIDTH = 210
|
||||||
@@ -202,6 +204,11 @@ class Overlay(QWidget):
|
|||||||
self._reposition()
|
self._reposition()
|
||||||
if not self.isVisible():
|
if not self.isVisible():
|
||||||
self.show()
|
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:
|
if self._concealed:
|
||||||
self.raise_()
|
self.raise_()
|
||||||
self._concealed = False
|
self._concealed = False
|
||||||
|
|||||||
+37
-6
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
|
|||||||
def ready():
|
def ready():
|
||||||
return shutil.which(program) is not None
|
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():
|
if not ready():
|
||||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||||
tool=program))
|
tool=program))
|
||||||
@@ -296,11 +298,27 @@ def _ask_for_permission():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _macos_press(shortcut, delay):
|
def _macos_press(shortcut, delay, focus=None):
|
||||||
"""Post the key down and up straight into the window system.
|
"""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
|
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.
|
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)
|
keycode, flags = _macos_keys(shortcut)
|
||||||
services, core = _macos_api()
|
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 "
|
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||||
"under System Settings → Privacy & Security → Accessibility."
|
"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
|
time.sleep(delay) # let the selection settle and focus come back
|
||||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||||
@@ -464,11 +488,14 @@ class _WinInput(ctypes.Structure):
|
|||||||
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
|
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
|
||||||
|
|
||||||
|
|
||||||
def _win_press(shortcut, delay):
|
def _win_press(shortcut, delay, _focus=None):
|
||||||
"""Post the presses and releases straight into the input queue.
|
"""Post the presses and releases straight into the input queue.
|
||||||
|
|
||||||
No permission stands in front of SendInput the way Accessibility does on
|
No permission stands in front of SendInput the way Accessibility does on
|
||||||
macOS: whatever window has focus receives the combination.
|
macOS: whatever window has focus receives the combination.
|
||||||
|
|
||||||
|
Nothing here takes the front from the window being dictated into, so the
|
||||||
|
remembered process id has nothing to hand back to: it is a macOS concern.
|
||||||
"""
|
"""
|
||||||
codes = _win_keys(shortcut)
|
codes = _win_keys(shortcut)
|
||||||
user32, _ = _win_api()
|
user32, _ = _win_api()
|
||||||
@@ -675,7 +702,11 @@ def paste_ready():
|
|||||||
return desktop().ready()
|
return desktop().ready()
|
||||||
|
|
||||||
|
|
||||||
def press(shortcut="", delay=0.12):
|
def press(shortcut="", delay=0.12, focus=None):
|
||||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
"""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 = desktop()
|
||||||
here.press(shortcut or here.shortcuts[0], delay)
|
here.press(shortcut or here.shortcuts[0], delay, focus)
|
||||||
|
|||||||
+10
-5
@@ -50,16 +50,20 @@ class Pipeline(QObject):
|
|||||||
def busy(self):
|
def busy(self):
|
||||||
return self._thread is not None and self._thread.is_alive()
|
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
|
"""`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
|
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:
|
if self.busy:
|
||||||
return
|
return
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._work,
|
target=self._work,
|
||||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
args=(wav_path, duration, list(rms_values), ask, paste, focus),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -73,7 +77,8 @@ class Pipeline(QObject):
|
|||||||
"""
|
"""
|
||||||
self._stop.set()
|
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
|
conf = self.conf
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
raw = ""
|
raw = ""
|
||||||
@@ -144,7 +149,7 @@ class Pipeline(QObject):
|
|||||||
paste.copy(text)
|
paste.copy(text)
|
||||||
if wants_paste:
|
if wants_paste:
|
||||||
self.stage.emit(t("Pasting…"))
|
self.stage.emit(t("Pasting…"))
|
||||||
paste.press(conf["paste_shortcut"])
|
paste.press(conf["paste_shortcut"], focus=focus)
|
||||||
finally:
|
finally:
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
# Let the focused application consume the temporary
|
# Let the focused application consume the temporary
|
||||||
|
|||||||
@@ -444,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
|
|||||||
self.assertFalse(paste.paste_ready())
|
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):
|
class MacClipboardSnapshot(DikteTest):
|
||||||
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
||||||
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
||||||
|
|||||||
@@ -518,6 +518,321 @@ class KdeSettings(Settings):
|
|||||||
self.assertFalse(window.evdev_enabled.isHidden())
|
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.activate)
|
||||||
|
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 activate(self, pid):
|
||||||
|
self.activated.append(pid)
|
||||||
|
return True
|
||||||
|
|
||||||
|
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.assertTrue(watch.running) # accepted is not the same as landed
|
||||||
|
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
|
||||||
|
watch.tick()
|
||||||
|
self.assertFalse(watch.running)
|
||||||
|
|
||||||
|
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
|
||||||
|
watch = self.watching(4242, dikte_in_front=True)
|
||||||
|
watch.tick()
|
||||||
|
watch.tick()
|
||||||
|
self.assertEqual(self.activated, [4242])
|
||||||
|
self.assertTrue(watch.running)
|
||||||
|
|
||||||
|
def test_an_accepted_restore_that_never_lands_still_times_out(self):
|
||||||
|
watch = self.watching(4242, dikte_in_front=True)
|
||||||
|
watch.tick()
|
||||||
|
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, [4242])
|
||||||
|
|
||||||
|
def test_a_restore_the_system_refused_is_retried(self):
|
||||||
|
from dikte import mac_window
|
||||||
|
self.patch_attr(mac_window, "activate",
|
||||||
|
lambda pid: self.activated.append(pid) or False)
|
||||||
|
watch = self.watching(4242, dikte_in_front=True)
|
||||||
|
watch.tick()
|
||||||
|
watch.tick()
|
||||||
|
self.assertEqual(self.activated, [4242, 4242])
|
||||||
|
self.assertTrue(watch.running)
|
||||||
|
|
||||||
|
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):
|
class Overlay(DikteTest):
|
||||||
def overlay(self, **kwargs):
|
def overlay(self, **kwargs):
|
||||||
widget = overlay_module.Overlay(**kwargs)
|
widget = overlay_module.Overlay(**kwargs)
|
||||||
|
|||||||
+13
-3
@@ -32,7 +32,8 @@ class Chain(DikteTest):
|
|||||||
transcript="uh, book it for Thursday",
|
transcript="uh, book it for Thursday",
|
||||||
cleaned="Book it for Thursday.",
|
cleaned="Book it for Thursday.",
|
||||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
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)
|
pipeline = worker.Pipeline(self.conf)
|
||||||
done, failures, stages, cancels = [], [], [], []
|
done, failures, stages, cancels = [], [], [], []
|
||||||
pipeline.finished.connect(lambda *args: done.append(args))
|
pipeline.finished.connect(lambda *args: done.append(args))
|
||||||
@@ -60,7 +61,8 @@ class Chain(DikteTest):
|
|||||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||||
"read_clipboard": read_clipboard}
|
"read_clipboard": read_clipboard}
|
||||||
pipeline._work(self.wav, duration,
|
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,
|
return {"done": done, "failures": failures, "stages": stages,
|
||||||
"cancelled": cancels, **calls}
|
"cancelled": cancels, **calls}
|
||||||
|
|
||||||
@@ -72,7 +74,15 @@ class Chain(DikteTest):
|
|||||||
self.assertEqual(run["done"][0],
|
self.assertEqual(run["done"][0],
|
||||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||||
run["copy"].assert_called_once_with("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):
|
def test_the_stages_are_named_as_they_happen(self):
|
||||||
run = self.run_chain()
|
run = self.run_chain()
|
||||||
|
|||||||
Reference in New Issue
Block a user