mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
+92
-2
@@ -17,6 +17,7 @@ import signal
|
|||||||
import socket
|
import socket
|
||||||
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.
|
||||||
@@ -46,6 +47,7 @@ from . import hotkey # 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 .i18n import t # 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
|
# 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
|
||||||
@@ -550,9 +558,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)
|
||||||
@@ -560,6 +578,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)
|
||||||
@@ -572,6 +591,68 @@ 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)
|
||||||
|
|
||||||
|
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):
|
def stop(self):
|
||||||
if self.state != RECORDING:
|
if self.state != RECORDING:
|
||||||
@@ -687,6 +768,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"],
|
||||||
@@ -695,6 +782,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()
|
||||||
@@ -821,11 +909,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
|
||||||
@@ -201,6 +203,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
|
||||||
|
|||||||
+33
-5
@@ -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)
|
||||||
@@ -488,7 +512,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
|
||||||
|
|||||||
@@ -440,6 +440,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-")
|
||||||
|
|||||||
@@ -488,6 +488,288 @@ 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.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):
|
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