mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Add a Mac as the third system, beside Wayland and X11
Dikte already chose its clipboard programs once instead of in every function; macOS joins that table rather than adding a branch to each one. A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics, records through AVFoundation, and asks Carbon for its global shortcuts. The three tables are paste.Desktop, audio.Sound, and the pair of predicates in hotkey.py. Each reads sys.platform inside the chooser, so a test can stand somewhere else: 697 of the 737 tests now run on any machine, the Wayland and X11 halves included, and the suite passes whole whichever system it is run on. Two things a Mac does not have needed saying rather than pretending: there is no shortcut registry to install into, so Settings offers no Install button and the listener is the mechanism instead of a fallback; and nothing is offered as the sound the speakers are playing, so a meeting needs BlackHole or Loopback and says so. The KDE-only labels around them were already wrong on GNOME, and now name whichever desktop is there. Co-authored-by: firat <[email protected]>
This commit is contained in:
+25
-19
@@ -31,6 +31,7 @@ directories, resets the language, and puts them back afterwards.
|
||||
| A reply that fails | `http_error(429)`, `url_error()`, `raw_body("not json")` |
|
||||
| Reading what was sent | `sent_json(request)`, `multipart_fields(request)` |
|
||||
| A program on the PATH | `only_these_tools("pactl", "wl-copy")` |
|
||||
| Standing on another system | `mock.patch.object(sys, "platform", "darwin")` |
|
||||
| Audio | `silence()`, `tone()`, `speech()`, `stereo()`, `make_wav()` |
|
||||
| A settings object | `self.config(cleanup_enabled=False)` |
|
||||
|
||||
@@ -52,30 +53,35 @@ forgets fails rather than hangs.
|
||||
|
||||
## Another platform
|
||||
|
||||
Most of what Dikte does is not desktop-specific, and the tests are split along
|
||||
that line. 511 of them pass anywhere: transcription, cleanup, the config file,
|
||||
the history, the agent, the command line, the timeline of a meeting. The
|
||||
remaining 59 cover what Dikte *is* on this desktop, and carry `@linux_only`
|
||||
from `tests.support`: PipeWire capture and the pactl device list, wl-clipboard
|
||||
and ydotool, KDE's shortcut file and the `/dev/input` listener.
|
||||
Three systems are supported: Wayland, X11 and macOS. Each one is a named entry
|
||||
in a table, and one chooser picks between them, so a fourth adds an entry and a
|
||||
line rather than a branch inside every function. The three tables are
|
||||
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device
|
||||
lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform`
|
||||
inside the chooser and read it there every time: a constant settled at import is
|
||||
one no test can stand somewhere else.
|
||||
|
||||
Mark a test `@linux_only` when it would fail on a machine that never had those
|
||||
programs. Do not mark one because it happens to be convenient: a test that
|
||||
quietly stops running on the platform you are porting to protects nothing.
|
||||
|
||||
The other half of a port is where the branch goes. Keep `sys.platform` out of
|
||||
the middle of a function; make the public name a chooser and give each platform
|
||||
its own function underneath:
|
||||
The tests are split along the same line, and almost none of them are skipped.
|
||||
697 of the 737 run on any machine, including every line of the Wayland, X11 and
|
||||
macOS backends: the programs are faked at `shutil.which`, the frameworks at the
|
||||
one function that loads them. A test class says which system it is standing on
|
||||
rather than avoiding the question:
|
||||
|
||||
```python
|
||||
def copy(text):
|
||||
return _copy_macos(text) if sys.platform == "darwin" else _copy_wayland(text)
|
||||
class MacOS(ClipboardContract, DikteTest):
|
||||
platform = "darwin"
|
||||
here = paste.MACOS
|
||||
```
|
||||
|
||||
Then each platform's test calls its own function directly and passes everywhere,
|
||||
and adding a third one leaves the first two's tests alone. An `if` buried inside
|
||||
`copy()` forces every existing test to patch `sys.platform` instead, and the
|
||||
next port breaks all of them.
|
||||
so the Linux half is checked on a Mac and the macOS half on Linux, and a change
|
||||
to a chooser cannot quietly break the platform nobody is sitting at. What the
|
||||
systems owe in common is written once as a contract class and subclassed by each
|
||||
of them.
|
||||
|
||||
The 40 that do carry `@linux_only` are the ones that would need the real thing:
|
||||
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test
|
||||
that way only when faking it would leave nothing to test. A test that quietly
|
||||
stops running on the platform you are porting to protects nothing.
|
||||
|
||||
## What a pull request should carry
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"""Raw PCM capture with a live level meter.
|
||||
|
||||
Dictation records one source through pw-record. A meeting records two of them at
|
||||
once, the microphone and what comes out of the speakers, and for that it goes
|
||||
through ffmpeg instead: one process reading both devices and merging them into
|
||||
the two channels of a single stream, which is the only way the two stay aligned
|
||||
with each other over an hour.
|
||||
Dictation records one source. A meeting records two of them at once, the
|
||||
microphone and what comes out of the speakers, and for that it goes through
|
||||
ffmpeg: one process reading both devices and merging them into the two channels
|
||||
of a single stream, which is the only way the two stay aligned with each other
|
||||
over an hour.
|
||||
|
||||
Which programs do the capturing is a property of the machine, not of the code
|
||||
above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS.
|
||||
They are gathered into one group each near the bottom of this file, and a
|
||||
chooser picks between them.
|
||||
"""
|
||||
|
||||
import array
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
@@ -57,9 +65,7 @@ class Recorder(QObject):
|
||||
return
|
||||
cmd = recording_command(target)
|
||||
if not cmd:
|
||||
self.failed.emit(t(
|
||||
"No audio recorder found. Install pulseaudio-utils or pipewire-audio."
|
||||
))
|
||||
self.failed.emit(t(sound().missing))
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -175,36 +181,13 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
|
||||
|
||||
|
||||
def recording_command(target=""):
|
||||
"""Return a raw-s16 capture command for the sound server on this desktop.
|
||||
"""A raw-s16 capture command for the sound system on this machine."""
|
||||
return sound().record(target)
|
||||
|
||||
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
|
||||
service, and its source names are the same ones shown by list_sources().
|
||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||
"""
|
||||
if shutil.which("parec"):
|
||||
cmd = [
|
||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16le",
|
||||
# Left alone, parec holds about two seconds before handing anything
|
||||
# over, and then hands over all of it at once: the level meter sits
|
||||
# still and jumps, and the tail of a recording can be lost on the
|
||||
# way out. A chunk of the meter is the unit the rest of this file
|
||||
# is measured in, so ask for that.
|
||||
f"--latency-msec={CHUNK_LATENCY_MS}",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--device={target}")
|
||||
return cmd
|
||||
if shutil.which("pw-record"):
|
||||
cmd = [
|
||||
"pw-record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--target={target}")
|
||||
cmd.append("-")
|
||||
return cmd
|
||||
return []
|
||||
|
||||
def meeting_command(mic_target, system_target):
|
||||
"""One ffmpeg reading both devices and merging them into two channels."""
|
||||
return sound().meeting(mic_target, system_target)
|
||||
|
||||
|
||||
class MeetingRecorder(QObject):
|
||||
@@ -251,18 +234,7 @@ class MeetingRecorder(QObject):
|
||||
"Pick one in Settings → Meeting."))
|
||||
return
|
||||
|
||||
merge = (
|
||||
"[0:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
||||
"[1:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
||||
"[m][s]amerge=inputs=2[out]"
|
||||
).format(rate=RATE)
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
||||
"-filter_complex", merge, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
cmd = meeting_command(mic_target, system_target)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
@@ -439,7 +411,61 @@ def _peak(samples):
|
||||
return min(1.0, max(abs(min(samples)), abs(max(samples))) / 32768.0)
|
||||
|
||||
|
||||
def _sources():
|
||||
# --- the sound system, one group per machine -------------------------------
|
||||
|
||||
# Both meeting commands merge the same way: each input down to mono at our own
|
||||
# rate, then the two of them into the left and right of one stream.
|
||||
MERGE_FILTER = (
|
||||
f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
||||
f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
||||
"[m][s]amerge=inputs=2[out]"
|
||||
)
|
||||
|
||||
|
||||
def _pulse_record(target):
|
||||
"""parec, or pw-record where PulseAudio's tools were left out.
|
||||
|
||||
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
|
||||
service, and its source names are the same ones shown by list_sources().
|
||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||
"""
|
||||
if shutil.which("parec"):
|
||||
cmd = [
|
||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16le",
|
||||
# Left alone, parec holds about two seconds before handing anything
|
||||
# over, and then hands over all of it at once: the level meter sits
|
||||
# still and jumps, and the tail of a recording can be lost on the
|
||||
# way out. A chunk of the meter is the unit the rest of this file
|
||||
# is measured in, so ask for that.
|
||||
f"--latency-msec={CHUNK_LATENCY_MS}",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--device={target}")
|
||||
return cmd
|
||||
if shutil.which("pw-record"):
|
||||
cmd = [
|
||||
"pw-record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--target={target}")
|
||||
cmd.append("-")
|
||||
return cmd
|
||||
return []
|
||||
|
||||
|
||||
def _pulse_meeting(mic_target, system_target):
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
|
||||
|
||||
def _pactl_sources():
|
||||
if not shutil.which("pactl"):
|
||||
return []
|
||||
try:
|
||||
@@ -452,30 +478,23 @@ def _sources():
|
||||
return []
|
||||
|
||||
|
||||
def list_sources():
|
||||
"""[(name, description)] for every real input source."""
|
||||
def _pulse_inputs():
|
||||
return [
|
||||
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
||||
for src in _sources()
|
||||
for src in _pactl_sources()
|
||||
if not src.get("name", "").endswith(".monitor")
|
||||
]
|
||||
|
||||
|
||||
def list_monitors():
|
||||
"""[(name, description)] for the monitor of every output.
|
||||
|
||||
Recording a monitor is recording whatever is being played, which in a
|
||||
meeting is the other participants and nothing of your own microphone.
|
||||
"""
|
||||
def _pulse_outputs():
|
||||
return [
|
||||
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
||||
for src in _sources()
|
||||
for src in _pactl_sources()
|
||||
if src.get("name", "").endswith(".monitor")
|
||||
]
|
||||
|
||||
|
||||
def default_monitor():
|
||||
"""The monitor of the output sound is currently going to, or ''."""
|
||||
def _pulse_default_output():
|
||||
if not shutil.which("pactl"):
|
||||
return ""
|
||||
try:
|
||||
@@ -488,5 +507,130 @@ def default_monitor():
|
||||
if not sink:
|
||||
return ""
|
||||
monitor = f"{sink}.monitor"
|
||||
names = {name for name, _ in list_monitors()}
|
||||
names = {name for name, _ in _pulse_outputs()}
|
||||
return monitor if not names or monitor in names else ""
|
||||
|
||||
|
||||
# macOS hands out no monitor of its own: what the speakers are playing is not
|
||||
# an input, and the only way to record it is a driver that pretends to be one.
|
||||
# These are the three people install.
|
||||
LOOPBACK_DEVICES = ("blackhole", "loopback", "soundflower")
|
||||
|
||||
|
||||
def _avfoundation_record(target):
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
# AVFoundation names an input "video:audio", so the empty half in front
|
||||
# of the colon is what says this recording has no picture in it.
|
||||
"-f", "avfoundation", "-i", f":{target or 'default'}",
|
||||
"-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-",
|
||||
]
|
||||
|
||||
|
||||
def _avfoundation_meeting(mic_target, system_target):
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-thread_queue_size", "4096",
|
||||
"-f", "avfoundation", "-i", f":{mic_target or 'default'}",
|
||||
"-thread_queue_size", "4096",
|
||||
"-f", "avfoundation", "-i", f":{system_target}",
|
||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
|
||||
|
||||
def _avfoundation_inputs():
|
||||
"""[(index, name)] for every capture device AVFoundation offers.
|
||||
|
||||
The index is what the recorder is given, because that is what ffmpeg takes;
|
||||
it changes when devices are plugged in, which is why the name is shown.
|
||||
"""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
try:
|
||||
# Listing devices is not a thing ffmpeg can do without an input, so it
|
||||
# is asked for one it cannot open: the list comes out on stderr and the
|
||||
# command then fails, which is the documented way of doing this.
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=8, check=False,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
|
||||
devices, listing = [], False
|
||||
for line in result.stderr.splitlines():
|
||||
if "AVFoundation audio devices:" in line:
|
||||
listing = True
|
||||
continue
|
||||
if not listing:
|
||||
continue
|
||||
match = re.search(r"\[(\d+)\]\s+(.+)$", line)
|
||||
if match:
|
||||
devices.append((match.group(1), match.group(2).strip()))
|
||||
return devices
|
||||
|
||||
|
||||
def _avfoundation_default_output():
|
||||
for name, description in _avfoundation_inputs():
|
||||
if any(word in description.lower() for word in LOOPBACK_DEVICES):
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
Sound = collections.namedtuple(
|
||||
"Sound",
|
||||
# How to capture one source and two at once, the two device lists, which
|
||||
# device a meeting records the far side from, and what to say when the
|
||||
# programs for any of it are not installed.
|
||||
"record meeting inputs outputs default_output missing",
|
||||
)
|
||||
|
||||
PULSE = Sound(
|
||||
record=_pulse_record,
|
||||
meeting=_pulse_meeting,
|
||||
inputs=_pulse_inputs,
|
||||
outputs=_pulse_outputs,
|
||||
default_output=_pulse_default_output,
|
||||
missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.",
|
||||
)
|
||||
|
||||
COREAUDIO = Sound(
|
||||
record=_avfoundation_record,
|
||||
meeting=_avfoundation_meeting,
|
||||
inputs=_avfoundation_inputs,
|
||||
# Every macOS capture device is offered as the far side of a meeting, the
|
||||
# loopback driver among them: there is no way to tell them apart, and an
|
||||
# empty list would leave nothing to pick.
|
||||
outputs=_avfoundation_inputs,
|
||||
default_output=_avfoundation_default_output,
|
||||
missing="ffmpeg not found. Install it with: brew install ffmpeg",
|
||||
)
|
||||
|
||||
|
||||
def sound():
|
||||
"""The programs this machine records through."""
|
||||
return COREAUDIO if sys.platform == "darwin" else PULSE
|
||||
|
||||
|
||||
def list_sources():
|
||||
"""[(name, description)] for every real input source."""
|
||||
return sound().inputs()
|
||||
|
||||
|
||||
def list_monitors():
|
||||
"""[(name, description)] for whatever can be recorded as the other side.
|
||||
|
||||
On Linux that is the monitor of an output, and recording it is recording
|
||||
whatever is being played: in a meeting the other participants, and nothing
|
||||
of your own microphone.
|
||||
"""
|
||||
return sound().outputs()
|
||||
|
||||
|
||||
def default_monitor():
|
||||
"""The device the far side of a meeting comes from, or ''."""
|
||||
return sound().default_output()
|
||||
|
||||
@@ -720,7 +720,7 @@ def cmd_shortcut(opts):
|
||||
combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip()
|
||||
if not combo:
|
||||
return fail(opts, "no combination given and none stored; pass --combo", 2)
|
||||
if hotkey.parse_shortcut(combo) == (None, None):
|
||||
if not hotkey.valid_shortcut(combo):
|
||||
return fail(opts, f"cannot parse that combination: {combo}", 2)
|
||||
clashes = hotkey.conflicting_shortcuts(combo, desktop_id)
|
||||
if clashes and not opts.force:
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
"""Settings storage in ~/.config/dikte/config.json"""
|
||||
"""Settings storage, in the place this system keeps a program's settings."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import api
|
||||
import i18n
|
||||
import paste
|
||||
|
||||
|
||||
def _xdg(var, default):
|
||||
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
||||
|
||||
|
||||
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", "~/.config") / "dikte"
|
||||
def _directories(platform=None):
|
||||
"""(settings, data), in the two places this system keeps them.
|
||||
|
||||
macOS keeps both in the one directory a Mac user's backup already knows
|
||||
about. Everywhere else they are separate and follow the XDG variables.
|
||||
"""
|
||||
if (platform or sys.platform) == "darwin":
|
||||
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
||||
return support, support
|
||||
return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
||||
_xdg("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
||||
|
||||
|
||||
CONFIG_DIR, DATA_DIR = _directories()
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DATA_DIR = _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte"
|
||||
HISTORY_FILE = DATA_DIR / "history.jsonl"
|
||||
RECORDINGS_DIR = DATA_DIR / "recordings"
|
||||
MEETINGS_DIR = DATA_DIR / "meetings"
|
||||
@@ -375,7 +389,7 @@ DEFAULTS = {
|
||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||
"cleanup_prompt": "", # empty -> language-specific default
|
||||
"auto_paste": True,
|
||||
"paste_shortcut": "ctrl+v",
|
||||
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
||||
"restore_clipboard": False,
|
||||
"mic_target": "",
|
||||
"max_seconds": 300,
|
||||
|
||||
@@ -16,6 +16,15 @@ import sys
|
||||
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
|
||||
|
||||
# An application started from the Finder is given none of the shell's PATH, so
|
||||
# Homebrew's ffmpeg is invisible to it. Put the two places brew installs to in
|
||||
# front, before anything goes looking for a program.
|
||||
if sys.platform == "darwin":
|
||||
os.environ["PATH"] = os.pathsep.join(
|
||||
part for part in ("/opt/homebrew/bin", "/usr/local/bin",
|
||||
os.environ.get("PATH", "")) if part
|
||||
)
|
||||
|
||||
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
|
||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
@@ -91,7 +100,7 @@ class Dikte:
|
||||
self.ask_pipeline = Pipeline(self.conf)
|
||||
self.meeting_recorder = audio.MeetingRecorder()
|
||||
self.meetings = MeetingPipeline(self.conf)
|
||||
self.evdev = hotkey.EvdevHotkey()
|
||||
self.evdev = hotkey.listener()
|
||||
|
||||
self.recorder.level.connect(self._on_level)
|
||||
self.recorder.stopped.connect(self._on_recorded)
|
||||
@@ -312,8 +321,11 @@ class Dikte:
|
||||
# that same press. Its lateness is also the proof we were waiting for
|
||||
# that the shortcut is live, which leaves the listener with nothing to
|
||||
# do but double every press.
|
||||
# Where nothing was installed there is no shortcut to catch up, and
|
||||
# retiring the listener would leave the keys with nowhere to arrive.
|
||||
timer = self.last_evdev.get(name)
|
||||
if self.evdev.running and timer is not None and timer.elapsed() < ECHO_MS:
|
||||
if (hotkey.installs_shortcuts() and self.evdev.running
|
||||
and timer is not None and timer.elapsed() < ECHO_MS):
|
||||
self._retire_listener()
|
||||
return
|
||||
handler()
|
||||
@@ -332,8 +344,9 @@ class Dikte:
|
||||
self.conf.save()
|
||||
self.tray.showMessage(
|
||||
"Dikte",
|
||||
t("The KDE shortcut is live now, so the built-in listener has been "
|
||||
"turned off. It was doubling every key press."),
|
||||
t("The {desktop} shortcut is live now, so the built-in listener has "
|
||||
"been turned off. It was doubling every key press.",
|
||||
desktop=hotkey.desktop_name()),
|
||||
QSystemTrayIcon.MessageIcon.Information, 8000,
|
||||
)
|
||||
|
||||
@@ -807,7 +820,10 @@ class Dikte:
|
||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
if self.conf["evdev_hotkey"]:
|
||||
# Where the desktop has no shortcut registry of its own, the listener is
|
||||
# not the fallback the setting offers to turn on: it is the only way the
|
||||
# keys arrive at all, so it runs whatever the setting says.
|
||||
if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts():
|
||||
self.evdev.start({"toggle": self.conf["shortcut"],
|
||||
"ask": self.conf["assistant_shortcut"],
|
||||
"meeting": self.conf["meeting_shortcut"]})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
|
||||
"""Global shortcuts: the desktop's own registry, plus a listener of our own.
|
||||
|
||||
Two things have to happen for a key combination to reach Dikte. Somewhere has
|
||||
to be told about it, and something has to be listening. On Linux that is the
|
||||
desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of
|
||||
/dev/input for the wait until the registry is live. macOS has no registry to
|
||||
write into: the application asks Carbon for the combination while it runs, so
|
||||
there the listener is not a fallback but the whole mechanism.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
@@ -9,6 +19,7 @@ import select
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
@@ -18,6 +29,9 @@ from i18n import t
|
||||
DESKTOP_ID = "dikte-toggle.desktop"
|
||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
# The name each binding is started under, and the entry it is installed as.
|
||||
DESKTOP_IDS = {"toggle": DESKTOP_ID, "meeting": MEETING_DESKTOP_ID,
|
||||
"ask": ASK_DESKTOP_ID}
|
||||
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
||||
DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID
|
||||
SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
|
||||
@@ -176,8 +190,222 @@ class EvdevHotkey(QObject):
|
||||
return True
|
||||
|
||||
|
||||
# --- macOS: Carbon's hotkey service ---------------------------------------
|
||||
|
||||
# Apple virtual key codes: where a key sits, not what is printed on it.
|
||||
MAC_KEYS = {
|
||||
"space": 49, "tab": 48, "enter": 36, "return": 36, "esc": 53, "escape": 53,
|
||||
"backspace": 51, "delete": 117, "home": 115, "end": 119,
|
||||
"pgup": 116, "pgdown": 121, "up": 126, "down": 125, "left": 123, "right": 124,
|
||||
"1": 18, "2": 19, "3": 20, "4": 21, "5": 23, "6": 22, "7": 26,
|
||||
"8": 28, "9": 25, "0": 29,
|
||||
"a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4,
|
||||
"i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31,
|
||||
"p": 35, "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9,
|
||||
"w": 13, "x": 7, "y": 16, "z": 6,
|
||||
"f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97,
|
||||
"f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
|
||||
}
|
||||
# Carbon's own modifier bits, which are not the ones CoreGraphics uses in
|
||||
# paste.py: the same four modifiers, numbered differently by two APIs.
|
||||
MAC_MODS = {
|
||||
"cmd": 1 << 8, "command": 1 << 8, "meta": 1 << 8, "super": 1 << 8,
|
||||
"shift": 1 << 9,
|
||||
"alt": 1 << 11, "option": 1 << 11,
|
||||
"ctrl": 1 << 12, "control": 1 << 12,
|
||||
}
|
||||
HOTKEY_SIGNATURE = "Dikt" # what our registrations are labelled with
|
||||
KEYBOARD_EVENT_CLASS = "keyb"
|
||||
HOTKEY_PRESSED = 5 # kEventHotKeyPressed
|
||||
PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard
|
||||
HOTKEY_ID_PARAMETER = "hkid"
|
||||
|
||||
# What the running listener holds. This is the whole of "installed" on macOS,
|
||||
# and it lasts as long as the process does: there is no file, and no other
|
||||
# program to read one. Written by CarbonHotkey.start(), read by the status
|
||||
# line, so what Settings shows is what the Mac actually gave us.
|
||||
_REGISTERED = {}
|
||||
|
||||
|
||||
def parse_macos_shortcut(text):
|
||||
"""'Cmd+Space' -> (256, 49), or (None, None) when unusable."""
|
||||
parts = [part.strip().lower() for part in str(text).split("+") if part.strip()]
|
||||
modifiers, key = 0, None
|
||||
for part in parts:
|
||||
if part in MAC_MODS:
|
||||
modifiers |= MAC_MODS[part]
|
||||
elif key is None and part in MAC_KEYS:
|
||||
key = MAC_KEYS[part]
|
||||
else:
|
||||
return None, None
|
||||
if key is None:
|
||||
return None, None
|
||||
return modifiers, key
|
||||
|
||||
|
||||
def _fourcc(text):
|
||||
"""A Carbon four-character code, which is those four bytes as a number."""
|
||||
return int.from_bytes(text.encode("ascii"), "big")
|
||||
|
||||
|
||||
class _EventTypeSpec(ctypes.Structure):
|
||||
_fields_ = [("eventClass", ctypes.c_uint32), ("eventKind", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class _EventHotKeyID(ctypes.Structure):
|
||||
_fields_ = [("signature", ctypes.c_uint32), ("id", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class CarbonHotkey(QObject):
|
||||
"""Catches global shortcuts through macOS's own hotkey service.
|
||||
|
||||
RegisterEventHotKey asks for one combination rather than reading the
|
||||
keyboard, so it needs no permission at all. Accessibility is a separate
|
||||
matter, and only for the Cmd+V that puts the text back (see paste.py).
|
||||
|
||||
Unlike the evdev listener this one does swallow the key: while Dikte holds
|
||||
a combination, nothing else on the Mac receives it.
|
||||
"""
|
||||
|
||||
triggered = pyqtSignal(str) # the name the binding was registered under
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._carbon = None
|
||||
self._callback = None
|
||||
self._handler = ctypes.c_void_p()
|
||||
self._registrations = []
|
||||
self._names = {}
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return bool(self._registrations)
|
||||
|
||||
def start(self, bindings):
|
||||
"""`bindings` is {name: 'Cmd+Space'}; an empty combination is skipped."""
|
||||
self.stop()
|
||||
try:
|
||||
self._carbon = _carbon()
|
||||
except OSError as exc:
|
||||
self.failed.emit(t("Could not reach the macOS shortcut service: "
|
||||
"{error}", error=exc))
|
||||
return False
|
||||
if not self._install_handler():
|
||||
return False
|
||||
|
||||
for identifier, (name, shortcut) in enumerate(bindings.items(), 1):
|
||||
if not shortcut:
|
||||
continue
|
||||
modifiers, key = parse_macos_shortcut(shortcut)
|
||||
if key is None:
|
||||
self.failed.emit(
|
||||
t("Could not parse the shortcut: {shortcut}", shortcut=shortcut)
|
||||
)
|
||||
continue
|
||||
reference = ctypes.c_void_p()
|
||||
code = self._carbon.RegisterEventHotKey(
|
||||
key, modifiers,
|
||||
_EventHotKeyID(_fourcc(HOTKEY_SIGNATURE), identifier),
|
||||
self._carbon.GetApplicationEventTarget(), 0, ctypes.byref(reference),
|
||||
)
|
||||
if code != 0:
|
||||
# This is the conflict warning on macOS: there is no list to
|
||||
# read beforehand, the answer comes from asking for the key.
|
||||
self.failed.emit(t(
|
||||
"macOS would not give Dikte {shortcut}; another application "
|
||||
"already holds it.", shortcut=shortcut))
|
||||
continue
|
||||
self._registrations.append(reference)
|
||||
self._names[identifier] = name
|
||||
desktop_id = DESKTOP_IDS.get(name)
|
||||
if desktop_id:
|
||||
_REGISTERED[desktop_id] = shortcut
|
||||
return bool(self._registrations)
|
||||
|
||||
def stop(self):
|
||||
if self._carbon:
|
||||
for reference in self._registrations:
|
||||
self._carbon.UnregisterEventHotKey(reference)
|
||||
if self._handler:
|
||||
self._carbon.RemoveEventHandler(self._handler)
|
||||
self._registrations = []
|
||||
self._names = {}
|
||||
self._handler = ctypes.c_void_p()
|
||||
self._callback = None
|
||||
_REGISTERED.clear()
|
||||
|
||||
def _install_handler(self):
|
||||
carbon = self._carbon
|
||||
callback_type = ctypes.CFUNCTYPE(
|
||||
ctypes.c_int32, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p
|
||||
)
|
||||
|
||||
def pressed(_next_handler, event, _user_data):
|
||||
wanted = _EventHotKeyID()
|
||||
size = ctypes.c_uint32()
|
||||
code = carbon.GetEventParameter(
|
||||
event, _fourcc(PARAMETER_ANY), _fourcc(HOTKEY_ID_PARAMETER), None,
|
||||
ctypes.sizeof(wanted), ctypes.byref(size), ctypes.byref(wanted),
|
||||
)
|
||||
if code == 0:
|
||||
name = self._names.get(wanted.id)
|
||||
if name:
|
||||
self.triggered.emit(name)
|
||||
return 0
|
||||
|
||||
# Kept on self: Carbon holds the address of this function, and nothing
|
||||
# on the Python side would otherwise stop it being collected.
|
||||
self._callback = callback_type(pressed)
|
||||
event_type = _EventTypeSpec(_fourcc(KEYBOARD_EVENT_CLASS), HOTKEY_PRESSED)
|
||||
code = carbon.InstallEventHandler(
|
||||
carbon.GetApplicationEventTarget(), self._callback, 1,
|
||||
ctypes.byref(event_type), None, ctypes.byref(self._handler),
|
||||
)
|
||||
if code != 0:
|
||||
self.failed.emit(t("Could not reach the macOS shortcut service: "
|
||||
"{error}", error=code))
|
||||
self._callback = None
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _carbon():
|
||||
"""Carbon, with its calls typed the way they are used above."""
|
||||
path = (ctypes.util.find_library("Carbon")
|
||||
or "/System/Library/Frameworks/Carbon.framework/Carbon")
|
||||
carbon = ctypes.CDLL(path)
|
||||
carbon.GetApplicationEventTarget.restype = ctypes.c_void_p
|
||||
carbon.InstallEventHandler.restype = ctypes.c_int32
|
||||
carbon.InstallEventHandler.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(_EventTypeSpec), ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
]
|
||||
carbon.RegisterEventHotKey.restype = ctypes.c_int32
|
||||
carbon.RegisterEventHotKey.argtypes = [
|
||||
ctypes.c_uint32, ctypes.c_uint32, _EventHotKeyID, ctypes.c_void_p,
|
||||
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
|
||||
]
|
||||
carbon.UnregisterEventHotKey.restype = ctypes.c_int32
|
||||
carbon.UnregisterEventHotKey.argtypes = [ctypes.c_void_p]
|
||||
carbon.RemoveEventHandler.restype = ctypes.c_int32
|
||||
carbon.RemoveEventHandler.argtypes = [ctypes.c_void_p]
|
||||
carbon.GetEventParameter.restype = ctypes.c_int32
|
||||
carbon.GetEventParameter.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32,
|
||||
ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32,
|
||||
ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p,
|
||||
]
|
||||
return carbon
|
||||
|
||||
|
||||
# --- the desktop's own shortcut -------------------------------------------
|
||||
|
||||
def _macos():
|
||||
return sys.platform == "darwin"
|
||||
|
||||
|
||||
def _gnome():
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
return "gnome" in desktop and shutil.which("gsettings") is not None
|
||||
@@ -300,26 +528,69 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID):
|
||||
return None
|
||||
|
||||
|
||||
def listener(parent=None):
|
||||
"""The thing that hears the key, for whichever system this is."""
|
||||
return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent)
|
||||
|
||||
|
||||
def valid_shortcut(text):
|
||||
"""Whether this machine can bind the combination as it was typed."""
|
||||
parse = parse_macos_shortcut if _macos() else parse_shortcut
|
||||
return parse(text)[1] is not None
|
||||
|
||||
|
||||
def installs_shortcuts():
|
||||
"""Whether this system keeps a shortcut registry to write into.
|
||||
|
||||
KDE and GNOME do, and something outside Dikte reads it, so the combination
|
||||
survives Dikte being closed. macOS does not: there is nothing to install,
|
||||
nothing to remove, and Settings should not offer either.
|
||||
"""
|
||||
return not _macos()
|
||||
|
||||
|
||||
def shortcut_needs_restart():
|
||||
"""Whether an installed shortcut waits for the next login before it works.
|
||||
|
||||
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding
|
||||
up as it is written, and macOS never had one to write.
|
||||
"""
|
||||
return not _macos() and not _gnome()
|
||||
|
||||
|
||||
def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
|
||||
desktop_id=DESKTOP_ID):
|
||||
if _macos():
|
||||
_REGISTERED[desktop_id] = shortcut
|
||||
return True, t(
|
||||
"Shortcut saved: {shortcut}\nDikte holds this one itself while it "
|
||||
"is running, so it works as soon as the settings are saved.",
|
||||
shortcut=shortcut,
|
||||
)
|
||||
if _gnome():
|
||||
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
|
||||
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
|
||||
|
||||
|
||||
def remove_shortcut(desktop_id=DESKTOP_ID):
|
||||
if _gnome():
|
||||
if _macos():
|
||||
_REGISTERED.pop(desktop_id, None)
|
||||
elif _gnome():
|
||||
remove_gnome_shortcut(desktop_id)
|
||||
else:
|
||||
remove_kde_shortcut(desktop_id)
|
||||
|
||||
|
||||
def shortcut_status(desktop_id=DESKTOP_ID):
|
||||
if _macos():
|
||||
return _REGISTERED.get(desktop_id)
|
||||
return (gnome_shortcut_status(desktop_id) if _gnome()
|
||||
else kde_shortcut_status(desktop_id))
|
||||
|
||||
|
||||
def desktop_name():
|
||||
if _macos():
|
||||
return "macOS"
|
||||
return "GNOME" if _gnome() else "KDE"
|
||||
|
||||
|
||||
@@ -400,6 +671,10 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
|
||||
|
||||
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
|
||||
"""Names of other KDE entries bound to the same combination."""
|
||||
if _macos():
|
||||
# There is no list to read: macOS answers the question by refusing the
|
||||
# registration, which CarbonHotkey reports when it asks for the key.
|
||||
return []
|
||||
try:
|
||||
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
|
||||
@@ -80,9 +80,9 @@ TR = {
|
||||
"{service} hesapta kredi kalmadığını söylüyor (HTTP 402).",
|
||||
"{service} is rate limiting you (HTTP 429). Try again in a moment.":
|
||||
"{service} hız sınırı uyguluyor (HTTP 429). Birazdan tekrar dene.",
|
||||
"The KDE shortcut is live now, so the built-in listener has been "
|
||||
"turned off. It was doubling every key press.":
|
||||
"KDE kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. "
|
||||
"The {desktop} shortcut is live now, so the built-in listener has "
|
||||
"been turned off. It was doubling every key press.":
|
||||
"{desktop} kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. "
|
||||
"Her tuşa basışı ikiye katlıyordu.",
|
||||
"No speech detected": "Ses algılanmadı",
|
||||
"No speech detected ({level} dB)": "Ses algılanmadı ({level} dB)",
|
||||
@@ -104,11 +104,14 @@ TR = {
|
||||
"Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
|
||||
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
|
||||
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
|
||||
"ffmpeg not found. Install it with: brew install ffmpeg":
|
||||
"ffmpeg bulunamadı. Şununla kur: brew install ffmpeg",
|
||||
"Audio recorder stopped before receiving sound: {error}":
|
||||
"Ses kayıt aracı veri alamadan kapandı: {error}",
|
||||
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
|
||||
"{tool} not found. Install {packages}.":
|
||||
"{tool} bulunamadı. {packages} paketlerini kur.",
|
||||
"{tool} not found.": "{tool} bulunamadı.",
|
||||
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
|
||||
"{tool} not found, cannot paste automatically.":
|
||||
"{tool} bulunamadı, otomatik yapıştırma yapılamıyor.",
|
||||
@@ -117,6 +120,10 @@ TR = {
|
||||
"{tool} failed: {error}": "{tool} hatası: {error}",
|
||||
"Is ydotoold running? (systemctl --user status ydotool)":
|
||||
"ydotoold çalışıyor mu? (systemctl --user status ydotool)",
|
||||
"macOS has not been told to let Dikte press keys. Turn Dikte on under "
|
||||
"System Settings → Privacy & Security → Accessibility.":
|
||||
"macOS, Dikte'nin tuşlara basmasına henüz izin vermiyor. Sistem Ayarları "
|
||||
"→ Gizlilik ve Güvenlik → Erişilebilirlik altında Dikte'yi aç.",
|
||||
|
||||
# --- api errors ----------------------------------------------------
|
||||
"{service} API key is empty. Add it in Settings.":
|
||||
@@ -154,6 +161,8 @@ TR = {
|
||||
"Paste key": "Yapıştırma tuşu",
|
||||
"Terminals usually want ctrl+shift+v. Change this if pasting does nothing.":
|
||||
"Terminaller genelde ctrl+shift+v ister. Yapıştırma çalışmıyorsa bunu değiştir.",
|
||||
"macOS asks for Accessibility permission the first time this is sent.":
|
||||
"macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.",
|
||||
"Restore the previous clipboard after pasting":
|
||||
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
||||
"Indicator corner": "Gösterge köşesi",
|
||||
@@ -166,8 +175,7 @@ TR = {
|
||||
"Skip silent recordings (don't call the API)":
|
||||
"Sessiz kayıtları atla (API'ye gönderme)",
|
||||
"Silence threshold": "Sessizlik eşiği",
|
||||
"Keep audio files (~/.local/share/dikte/recordings)":
|
||||
"Ses kayıtlarını sakla (~/.local/share/dikte/recordings)",
|
||||
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
|
||||
|
||||
# --- settings: api --------------------------------------------------
|
||||
"Keys": "Anahtarlar",
|
||||
@@ -270,7 +278,7 @@ TR = {
|
||||
"Saved: {path}": "Kaydedildi: {path}",
|
||||
|
||||
# --- settings: shortcut ------------------------------------------------
|
||||
"Install as a KDE shortcut": "KDE kısayolu olarak kur",
|
||||
"Install as a {desktop} shortcut": "{desktop} kısayolu olarak kur",
|
||||
"Install as a global shortcut": "Global kısayol olarak kur",
|
||||
"Remove": "Kaldır",
|
||||
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
|
||||
@@ -284,8 +292,10 @@ TR = {
|
||||
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
|
||||
"Could not register the GNOME shortcut: {error}":
|
||||
"GNOME kısayolu kaydedilemedi: {error}",
|
||||
"Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet":
|
||||
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken",
|
||||
"Use the built-in listener (/dev/input), for when the {desktop} shortcut "
|
||||
"is not active yet":
|
||||
"Yerleşik dinleyici kullan (/dev/input), {desktop} kısayolu henüz etkin "
|
||||
"değilken",
|
||||
"Works immediately, no session restart. The only difference: the key "
|
||||
"combination also reaches the focused application.":
|
||||
"Anında çalışır, oturum yenilemek gerekmez. Tek farkı: tuş kombinasyonu "
|
||||
@@ -296,6 +306,13 @@ TR = {
|
||||
"KWin, kısayol ayarlarını yalnızca açılışta okur. 'Kur' dedikten sonra kısayol "
|
||||
"Sistem Ayarları → Kısayollar altında görünür ama oturumu yeniden açana kadar "
|
||||
"tetiklenmez. O zamana kadar yerleşik dinleyiciyi kullanabilirsin.",
|
||||
"The shortcut starts working as soon as it is installed.":
|
||||
"Kısayol kurulur kurulmaz çalışmaya başlar.",
|
||||
"Dikte asks macOS for these combinations itself, while it is running. "
|
||||
"Nothing is installed, and no other application receives them in the "
|
||||
"meantime.":
|
||||
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey "
|
||||
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
|
||||
"Shortcut conflict": "Kısayol çakışması",
|
||||
"{shortcut} is also used by:\n\n{list}\n\nInstall anyway?":
|
||||
"{shortcut} şu girdilerde de kullanılıyor:\n\n{list}\n\nYine de kurulsun mu?",
|
||||
@@ -308,6 +325,15 @@ TR = {
|
||||
"Could not write the desktop file: {error}": "Desktop dosyası yazılamadı: {error}",
|
||||
"Could not write kglobalshortcutsrc: {error}": "kglobalshortcutsrc yazılamadı: {error}",
|
||||
"Could not parse the shortcut: {shortcut}": "Kısayol çözümlenemedi: {shortcut}",
|
||||
"Shortcut saved: {shortcut}\nDikte holds this one itself while it is "
|
||||
"running, so it works as soon as the settings are saved.":
|
||||
"Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi "
|
||||
"tutar, yani ayarlar kaydedilir kaydedilmez çalışır.",
|
||||
"Could not reach the macOS shortcut service: {error}":
|
||||
"macOS kısayol servisine ulaşılamadı: {error}",
|
||||
"macOS would not give Dikte {shortcut}; another application already holds it.":
|
||||
"macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
|
||||
"onu şimdiden tutuyor.",
|
||||
"Cannot read /dev/input. Your user needs to be in the 'input' group:\n"
|
||||
" sudo usermod -aG input $USER (then log out and back in)":
|
||||
"/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n"
|
||||
@@ -527,6 +553,12 @@ TR = {
|
||||
"Same as dictation": "Diktedekiyle aynı",
|
||||
"Current output": "Geçerli çıkış",
|
||||
"The other participants": "Karşı tarafın sesi",
|
||||
"macOS does not offer what the speakers are playing as something to "
|
||||
"record. Install BlackHole or Loopback, send the meeting's sound through "
|
||||
"it, and pick it above.":
|
||||
"macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. "
|
||||
"BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve "
|
||||
"yukarıdan onu seç.",
|
||||
"Wear headphones if you can. Through speakers your microphone hears the "
|
||||
"other side as well, and although a line that lands on both channels at "
|
||||
"once is dropped again, the repair is never as clean as not needing it.":
|
||||
|
||||
+7
-2
@@ -1,6 +1,7 @@
|
||||
"""The small recording indicator that appears in a screen corner without taking focus."""
|
||||
|
||||
import math
|
||||
import sys
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||
@@ -54,13 +55,17 @@ class Overlay(QWidget):
|
||||
self._phase = 0.0
|
||||
self._concealed = True
|
||||
|
||||
self.setWindowFlags(
|
||||
flags = (
|
||||
Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint
|
||||
| Qt.WindowType.Tool
|
||||
| Qt.WindowType.WindowDoesNotAcceptFocus
|
||||
| Qt.WindowType.X11BypassWindowManagerHint
|
||||
)
|
||||
if sys.platform != "darwin":
|
||||
# It is the window manager that would otherwise move this out of
|
||||
# the corner. macOS has no such hint, and Qt warns about it.
|
||||
flags |= Qt.WindowType.X11BypassWindowManagerHint
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
# One that can be clicked away has to receive the click, which means it
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Clipboard and key injection, through whichever pair of programs is here.
|
||||
"""Clipboard and key injection, through whatever this machine gives us.
|
||||
|
||||
A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and
|
||||
xdotool, and a session is one or the other. Which it is gets decided in one
|
||||
place, and each desktop is a small group of functions below it: another desktop,
|
||||
or another operating system, adds a group and a line to the chooser rather than
|
||||
a branch inside every function here.
|
||||
xdotool, and macOS has pbcopy with the key press going straight to
|
||||
CoreGraphics. Which of them is here gets decided in one place, and each is a
|
||||
small group of functions below it: another desktop, or another operating
|
||||
system, adds a group and a line to the chooser rather than a branch inside
|
||||
every function here.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import ctypes
|
||||
import functools
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from i18n import t
|
||||
@@ -27,11 +31,30 @@ KEYCODES = {
|
||||
KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert",
|
||||
"enter": "Return", "return": "Return"}
|
||||
|
||||
# Apple virtual key codes, which say where a key sits rather than what is
|
||||
# printed on it: the same numbers on a Turkish and a US keyboard.
|
||||
MAC_KEYCODES = {
|
||||
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
|
||||
"c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
|
||||
"y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
|
||||
"5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
|
||||
"]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37,
|
||||
"j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44,
|
||||
"n": 45, "m": 46, ".": 47, "`": 50, "enter": 36, "return": 36,
|
||||
}
|
||||
MAC_FLAGS = {"shift": 1 << 17, "ctrl": 1 << 18, "alt": 1 << 19, "command": 1 << 20}
|
||||
# What the same modifier is called on a Mac keyboard.
|
||||
MAC_ALIASES = {"cmd": "command", "meta": "command", "super": "command",
|
||||
"control": "ctrl", "option": "alt"}
|
||||
HID_EVENT_TAP = 0 # kCGHIDEventTap: the event goes in where the keyboard does
|
||||
|
||||
|
||||
class PasteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- the key press, one group per system ----------------------------------
|
||||
|
||||
def _keys(shortcut):
|
||||
"""'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know."""
|
||||
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
|
||||
@@ -54,41 +77,200 @@ def _xdotool_command(shortcut):
|
||||
return ["xdotool", "key", "--clearmodifiers", "+".join(keys)]
|
||||
|
||||
|
||||
def _program_keyboard(program, command, hint=""):
|
||||
"""A desktop that presses keys by running another program.
|
||||
|
||||
Returns the three fields an entry below is built from: the program's name,
|
||||
whether it is here at all, and the press itself.
|
||||
"""
|
||||
|
||||
def ready():
|
||||
return shutil.which(program) is not None
|
||||
|
||||
def press(shortcut, delay):
|
||||
if not ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=program))
|
||||
argv = command(shortcut)
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
try:
|
||||
res = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool=program, error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
message = t("{tool} failed: {error}", tool=program,
|
||||
error=res.stderr.strip() or "unknown error")
|
||||
raise PasteError(f"{message}\n{t(hint)}" if hint else message)
|
||||
|
||||
return {"keyboard": program, "ready": ready, "press": press}
|
||||
|
||||
|
||||
def _macos_keys(shortcut):
|
||||
"""'Cmd+V' -> (9, 0x100000): where the key sits, and the modifiers on it."""
|
||||
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
|
||||
parts = [MAC_ALIASES.get(part, part) for part in parts]
|
||||
if not parts or parts[-1] not in MAC_KEYCODES:
|
||||
raise PasteError(t("Unknown key: {key}", key=parts[-1] if parts else shortcut))
|
||||
flags = 0
|
||||
for part in parts[:-1]:
|
||||
if part not in MAC_FLAGS:
|
||||
raise PasteError(t("Unknown key: {key}", key=part))
|
||||
flags |= MAC_FLAGS[part]
|
||||
return MAC_KEYCODES[parts[-1]], flags
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _macos_api():
|
||||
"""The bit of CoreGraphics and Accessibility a paste goes through.
|
||||
|
||||
Loaded on the first paste rather than at import: this module is read on
|
||||
every system, and these two frameworks exist on one of them.
|
||||
"""
|
||||
try:
|
||||
services = ctypes.CDLL(
|
||||
"/System/Library/Frameworks/ApplicationServices.framework"
|
||||
"/ApplicationServices"
|
||||
)
|
||||
core = ctypes.CDLL(
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool="CoreGraphics", error=exc)) from exc
|
||||
services.AXIsProcessTrusted.argtypes = []
|
||||
services.AXIsProcessTrusted.restype = ctypes.c_bool
|
||||
services.CGEventCreateKeyboardEvent.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool,
|
||||
]
|
||||
services.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p
|
||||
services.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
|
||||
services.CGEventSetFlags.restype = None
|
||||
services.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
|
||||
services.CGEventPost.restype = None
|
||||
core.CFRelease.argtypes = [ctypes.c_void_p]
|
||||
core.CFRelease.restype = None
|
||||
return services, core
|
||||
|
||||
|
||||
def _macos_trusted():
|
||||
"""Whether macOS lets this process type into another application."""
|
||||
try:
|
||||
return bool(_macos_api()[0].AXIsProcessTrusted())
|
||||
except PasteError:
|
||||
return False
|
||||
|
||||
|
||||
_asked_for_permission = False
|
||||
|
||||
|
||||
def _ask_for_permission():
|
||||
"""Open the one settings pane that grants it, and only the first time.
|
||||
|
||||
Every dictation would otherwise reopen it until the box is ticked, which is
|
||||
a window in the user's face on top of the paste that did not happen.
|
||||
"""
|
||||
global _asked_for_permission
|
||||
if _asked_for_permission:
|
||||
return
|
||||
_asked_for_permission = True
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["open", ("x-apple.systempreferences:com.apple.preference.security"
|
||||
"?Privacy_Accessibility")],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _macos_press(shortcut, delay):
|
||||
"""Post the key down and up straight into the window system.
|
||||
|
||||
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
|
||||
only asks once, when the paste it was granted for is first tried.
|
||||
"""
|
||||
keycode, flags = _macos_keys(shortcut)
|
||||
services, core = _macos_api()
|
||||
if not _macos_trusted():
|
||||
_ask_for_permission()
|
||||
raise PasteError(t(
|
||||
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||
"under System Settings → Privacy & Security → Accessibility."
|
||||
))
|
||||
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||
up = services.CGEventCreateKeyboardEvent(None, keycode, False)
|
||||
if not down or not up:
|
||||
for event in (down, up):
|
||||
if event:
|
||||
core.CFRelease(event)
|
||||
raise PasteError(t("Could not run {tool}: {error}", tool="CoreGraphics",
|
||||
error="it would not make a keyboard event"))
|
||||
try:
|
||||
for event in (down, up):
|
||||
services.CGEventSetFlags(event, flags)
|
||||
services.CGEventPost(HID_EVENT_TAP, event)
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
core.CFRelease(down)
|
||||
core.CFRelease(up)
|
||||
|
||||
|
||||
# --- which of them is here -------------------------------------------------
|
||||
|
||||
Desktop = collections.namedtuple(
|
||||
"Desktop",
|
||||
# The two programs, the packages to install them from, how to build the key
|
||||
# press, and what else to say when the key press fails.
|
||||
"clipboard keyboard packages read_command copy_command key_command key_hint",
|
||||
# The clipboard program and the two commands it is run with, what to
|
||||
# install when it is missing, the paste combinations Settings offers, and
|
||||
# the key press: the program that does it, whether it can happen at all,
|
||||
# and the pressing itself.
|
||||
"clipboard packages read_command copy_command shortcuts keyboard ready press",
|
||||
)
|
||||
|
||||
WAYLAND = Desktop(
|
||||
clipboard="wl-copy",
|
||||
keyboard="ydotool",
|
||||
packages="wl-clipboard and ydotool",
|
||||
read_command=["wl-paste", "--no-newline"],
|
||||
copy_command=["wl-copy"],
|
||||
key_command=_ydotool_command,
|
||||
key_hint="Is ydotoold running? (systemctl --user status ydotool)",
|
||||
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
|
||||
**_program_keyboard(
|
||||
"ydotool", _ydotool_command,
|
||||
hint="Is ydotoold running? (systemctl --user status ydotool)",
|
||||
),
|
||||
)
|
||||
|
||||
X11 = Desktop(
|
||||
clipboard="xclip",
|
||||
keyboard="xdotool",
|
||||
packages="xclip and xdotool",
|
||||
read_command=["xclip", "-selection", "clipboard", "-out"],
|
||||
copy_command=["xclip", "-selection", "clipboard", "-in"],
|
||||
key_command=_xdotool_command,
|
||||
key_hint="",
|
||||
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
|
||||
**_program_keyboard("xdotool", _xdotool_command),
|
||||
)
|
||||
|
||||
MACOS = Desktop(
|
||||
clipboard="pbcopy",
|
||||
packages="", # both are part of macOS; there is nothing to install
|
||||
read_command=["pbpaste"],
|
||||
copy_command=["pbcopy"],
|
||||
shortcuts=["cmd+v", "cmd+shift+v", "cmd+alt+shift+v"],
|
||||
keyboard="", # no program: the key press is a call into the system
|
||||
ready=_macos_trusted,
|
||||
press=_macos_press,
|
||||
)
|
||||
|
||||
|
||||
def desktop():
|
||||
"""The pair of programs this session's clipboard and keyboard go through.
|
||||
"""The programs this session's clipboard and key press go through.
|
||||
|
||||
Read every time rather than settled at import: a session started before the
|
||||
display server was up would otherwise be stuck with the wrong answer, and a
|
||||
test would have nowhere to say which one it means.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return MACOS
|
||||
if os.environ.get("XDG_SESSION_TYPE") == "x11":
|
||||
return X11
|
||||
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
||||
@@ -124,8 +306,11 @@ def _run_copy(payload):
|
||||
def copy(text):
|
||||
here = desktop()
|
||||
if not shutil.which(here.clipboard):
|
||||
raise PasteError(t("{tool} not found. Install {packages}.",
|
||||
tool=here.clipboard, packages=here.packages))
|
||||
raise PasteError(
|
||||
t("{tool} not found. Install {packages}.",
|
||||
tool=here.clipboard, packages=here.packages) if here.packages
|
||||
else t("{tool} not found.", tool=here.clipboard)
|
||||
)
|
||||
try:
|
||||
res = _run_copy(text.encode("utf-8"))
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
@@ -147,25 +332,11 @@ def copy_bytes(data):
|
||||
# --- the key press ---------------------------------------------------------
|
||||
|
||||
def paste_ready():
|
||||
return shutil.which(desktop().keyboard) is not None
|
||||
"""Whether a paste can be sent: the program is here, or macOS trusts us."""
|
||||
return desktop().ready()
|
||||
|
||||
|
||||
def press(shortcut="ctrl+v", delay=0.12):
|
||||
"""Press a key combination, e.g. 'ctrl+v'."""
|
||||
def press(shortcut="", delay=0.12):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
||||
here = desktop()
|
||||
if not paste_ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=here.keyboard))
|
||||
|
||||
command = here.key_command(shortcut)
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
try:
|
||||
res = subprocess.run(command, capture_output=True, text=True, timeout=10)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool=here.keyboard, error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
message = t("{tool} failed: {error}", tool=here.keyboard,
|
||||
error=res.stderr.strip() or "unknown error")
|
||||
raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint
|
||||
else message)
|
||||
here.press(shortcut or here.shortcuts[0], delay)
|
||||
|
||||
+81
-31
@@ -20,6 +20,7 @@ import config as cfg
|
||||
import filetranscribe
|
||||
import hotkey
|
||||
import meeting
|
||||
import paste
|
||||
from filetranscribe import FileTranscriber
|
||||
from i18n import t
|
||||
|
||||
@@ -89,7 +90,6 @@ REASONING_LEVELS = [
|
||||
("Low", "low"), ("Medium", "medium"), ("High", "high"),
|
||||
("Very high", "xhigh"), ("Maximum", "max"),
|
||||
]
|
||||
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
|
||||
# Offered for all three global shortcuts, which keeps them one kind of field
|
||||
# rather than three. The boxes stay editable: this is a shortlist of
|
||||
# combinations that are usually free, not the set of ones that work.
|
||||
@@ -99,6 +99,13 @@ SHORTCUTS = [
|
||||
"Meta+A", "Meta+D", "Meta+M",
|
||||
"Ctrl+Alt+F1", "Ctrl+Alt+F2", "Ctrl+Alt+F3",
|
||||
]
|
||||
# Cmd+Space is Spotlight and Ctrl+Space switches input sources, so a Mac gets
|
||||
# its own shortlist. Option is what Alt is called on that keyboard.
|
||||
MAC_SHORTCUTS = [
|
||||
"Ctrl+Option+Space", "Cmd+Shift+Space", "Ctrl+Shift+Space",
|
||||
"Ctrl+Option+A", "Ctrl+Option+D", "Ctrl+Option+M",
|
||||
"Cmd+Option+A", "Cmd+Option+D", "Cmd+Option+M",
|
||||
]
|
||||
AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
|
||||
"*.mp4 *.mkv *.webm *.mov *.avi")
|
||||
|
||||
@@ -191,10 +198,16 @@ class SettingsWindow(QDialog):
|
||||
form.addRow("", self.auto_paste)
|
||||
|
||||
self.paste_shortcut = QComboBox()
|
||||
self.paste_shortcut.addItems(PASTE_SHORTCUTS)
|
||||
self.paste_shortcut.setToolTip(
|
||||
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.")
|
||||
)
|
||||
# A shortlist of the combinations that usually paste, not the set of
|
||||
# them: a stored one this desktop does not offer is kept as it is
|
||||
# rather than quietly replaced by the first item on the list.
|
||||
self.paste_shortcut.setEditable(True)
|
||||
self.paste_shortcut.addItems(paste.desktop().shortcuts)
|
||||
self.paste_shortcut.setToolTip(t(
|
||||
"macOS asks for Accessibility permission the first time this is sent."
|
||||
if paste.desktop() is paste.MACOS else
|
||||
"Terminals usually want ctrl+shift+v. Change this if pasting does nothing."
|
||||
))
|
||||
form.addRow(t("Paste key"), self.paste_shortcut)
|
||||
|
||||
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
|
||||
@@ -232,7 +245,9 @@ class SettingsWindow(QDialog):
|
||||
)
|
||||
form.addRow("", self.filter_hallucinations)
|
||||
|
||||
self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)"))
|
||||
self.keep_audio = QCheckBox(
|
||||
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
|
||||
)
|
||||
form.addRow("", self.keep_audio)
|
||||
return page
|
||||
|
||||
@@ -368,12 +383,11 @@ class SettingsWindow(QDialog):
|
||||
how = QGroupBox(t("How it runs"))
|
||||
how_form = QFormLayout(how)
|
||||
self.assistant_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_ask_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_ask_shortcut)
|
||||
how_form.addRow(t("Shortcut"),
|
||||
self._row(self.assistant_shortcut, install, remove))
|
||||
how_form.addRow(t("Shortcut"), self._row(
|
||||
self.assistant_shortcut,
|
||||
*self._install_buttons(self._install_ask_shortcut,
|
||||
self._remove_ask_shortcut),
|
||||
))
|
||||
self.assistant_shortcut_status = QLabel("")
|
||||
self.assistant_shortcut_status.setWordWrap(True)
|
||||
how_form.addRow(self.assistant_shortcut_status)
|
||||
@@ -555,6 +569,15 @@ class SettingsWindow(QDialog):
|
||||
self.meeting_system.addItem(desc, name)
|
||||
sources_form.addRow(t("The other participants"), self.meeting_system)
|
||||
|
||||
if audio.sound() is audio.COREAUDIO:
|
||||
mac_note = QLabel(t(
|
||||
"macOS does not offer what the speakers are playing as something "
|
||||
"to record. Install BlackHole or Loopback, send the meeting's "
|
||||
"sound through it, and pick it above."
|
||||
))
|
||||
mac_note.setWordWrap(True)
|
||||
sources_form.addRow(mac_note)
|
||||
|
||||
note = QLabel(t(
|
||||
"Wear headphones if you can. Through speakers your microphone hears "
|
||||
"the other side as well, and although a line that lands on both "
|
||||
@@ -630,12 +653,11 @@ class SettingsWindow(QDialog):
|
||||
recording_form.addRow("", self.meeting_keep_audio)
|
||||
|
||||
self.meeting_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_meeting_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_meeting_shortcut)
|
||||
recording_form.addRow(t("Shortcut"),
|
||||
self._row(self.meeting_shortcut, install, remove))
|
||||
recording_form.addRow(t("Shortcut"), self._row(
|
||||
self.meeting_shortcut,
|
||||
*self._install_buttons(self._install_meeting_shortcut,
|
||||
self._remove_meeting_shortcut),
|
||||
))
|
||||
self.meeting_shortcut_status = QLabel("")
|
||||
self.meeting_shortcut_status.setWordWrap(True)
|
||||
recording_form.addRow(self.meeting_shortcut_status)
|
||||
@@ -784,13 +806,10 @@ class SettingsWindow(QDialog):
|
||||
form.addRow(t("Shortcut"), self.shortcut)
|
||||
layout.addLayout(form)
|
||||
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_shortcut)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(self._remove_shortcut)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(install)
|
||||
row.addWidget(remove)
|
||||
for button in self._install_buttons(self._install_shortcut,
|
||||
self._remove_shortcut):
|
||||
row.addWidget(button)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
|
||||
@@ -799,20 +818,34 @@ class SettingsWindow(QDialog):
|
||||
layout.addWidget(self.shortcut_status)
|
||||
|
||||
self.evdev_enabled = QCheckBox(t(
|
||||
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
|
||||
"not active yet"
|
||||
"Use the built-in listener (/dev/input), for when the {desktop} "
|
||||
"shortcut is not active yet", desktop=hotkey.desktop_name()
|
||||
))
|
||||
self.evdev_enabled.setToolTip(t(
|
||||
"Works immediately, no session restart. The only difference: the key "
|
||||
"combination also reaches the focused application."
|
||||
))
|
||||
layout.addWidget(self.evdev_enabled)
|
||||
# Nothing to wait for where nothing is installed: there the listener is
|
||||
# the mechanism, always on, and not a choice to offer.
|
||||
self.evdev_enabled.setVisible(hotkey.installs_shortcuts())
|
||||
|
||||
note = QLabel(t(
|
||||
if hotkey.shortcut_needs_restart():
|
||||
explanation = t(
|
||||
"KWin only reads shortcut settings at startup. After 'Install' the "
|
||||
"shortcut shows up under System Settings → Shortcuts, but it will not "
|
||||
"fire until you log out and back in. Until then, use the built-in listener."
|
||||
))
|
||||
"shortcut shows up under System Settings → Shortcuts, but it will "
|
||||
"not fire until you log out and back in. Until then, use the "
|
||||
"built-in listener."
|
||||
)
|
||||
elif hotkey.installs_shortcuts():
|
||||
explanation = t("The shortcut starts working as soon as it is installed.")
|
||||
else:
|
||||
explanation = t(
|
||||
"Dikte asks macOS for these combinations itself, while it is "
|
||||
"running. Nothing is installed, and no other application receives "
|
||||
"them in the meantime."
|
||||
)
|
||||
note = QLabel(explanation)
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
layout.addStretch(1)
|
||||
@@ -885,12 +918,29 @@ class SettingsWindow(QDialog):
|
||||
"""The field a global shortcut is typed or picked in."""
|
||||
box = QComboBox()
|
||||
box.setEditable(True)
|
||||
box.addItems(SHORTCUTS)
|
||||
box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS"
|
||||
else SHORTCUTS)
|
||||
box.setCurrentText("")
|
||||
if placeholder:
|
||||
box.lineEdit().setPlaceholderText(placeholder)
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def _install_buttons(install_handler, remove_handler):
|
||||
"""Install and Remove, where this system has somewhere to install into.
|
||||
|
||||
macOS has not: Dikte asks for the combination itself while it runs, so
|
||||
there is nothing to write down and nothing to take back out.
|
||||
"""
|
||||
if not hotkey.installs_shortcuts():
|
||||
return []
|
||||
install = QPushButton(t("Install as a {desktop} shortcut",
|
||||
desktop=hotkey.desktop_name()))
|
||||
install.clicked.connect(install_handler)
|
||||
remove = QPushButton(t("Remove"))
|
||||
remove.clicked.connect(remove_handler)
|
||||
return [install, remove]
|
||||
|
||||
@staticmethod
|
||||
def _row(*widgets):
|
||||
"""Widgets side by side in one form row; the first one takes the space."""
|
||||
|
||||
@@ -17,6 +17,11 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
||||
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
||||
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
||||
# Home goes with them: the shortcut file, the applications directory and every
|
||||
# macOS path start from it rather than from an XDG variable, and a test run is
|
||||
# not allowed to touch the real one.
|
||||
os.environ["HOME"] = os.path.join(_SANDBOX, "home")
|
||||
os.makedirs(os.environ["HOME"], exist_ok=True)
|
||||
atexit.register(shutil.rmtree, _SANDBOX, True)
|
||||
|
||||
# A key sitting in the environment would otherwise reach the code that falls
|
||||
|
||||
+168
-10
@@ -1,8 +1,12 @@
|
||||
"""Level metering, the WAV writer, and what pactl is asked for.
|
||||
"""Level metering, the WAV writer, and what the sound system is asked for.
|
||||
|
||||
The device list is where a platform port lands first, so the parsing is pinned
|
||||
here: a source that is not a monitor is an input, one that is belongs to the
|
||||
speakers, and neither list may go missing when pactl is absent.
|
||||
here: on Linux a source that is not a monitor is an input, one that is belongs
|
||||
to the speakers, and neither list may go missing when pactl is absent. macOS
|
||||
answers the same three questions out of one ffmpeg listing.
|
||||
|
||||
Each class says which machine it is standing on, so both halves run on either
|
||||
one: nothing here reaches a real sound server.
|
||||
"""
|
||||
|
||||
import array
|
||||
@@ -11,6 +15,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import wave
|
||||
from unittest import mock
|
||||
@@ -19,7 +24,6 @@ import audio
|
||||
from tests.support import (
|
||||
DikteTest,
|
||||
FakeCompleted,
|
||||
linux_only,
|
||||
only_these_tools,
|
||||
pcm,
|
||||
silence,
|
||||
@@ -28,6 +32,22 @@ from tests.support import (
|
||||
)
|
||||
|
||||
|
||||
class OnLinux:
|
||||
"""A test that runs as if the machine ran PulseAudio or PipeWire."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(sys, "platform", "linux"))
|
||||
|
||||
|
||||
class OnMacOS:
|
||||
"""A test that runs as if the machine were a Mac."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(sys, "platform", "darwin"))
|
||||
|
||||
|
||||
class ChunkLevels(unittest.TestCase):
|
||||
def test_silence(self):
|
||||
self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0))
|
||||
@@ -115,8 +135,7 @@ SOURCES = [
|
||||
]
|
||||
|
||||
|
||||
@linux_only
|
||||
class Devices(DikteTest):
|
||||
class Devices(OnLinux, DikteTest):
|
||||
@contextlib.contextmanager
|
||||
def pactl(self, sources=None, sink=None, tools=("pactl",)):
|
||||
payloads = {
|
||||
@@ -216,8 +235,7 @@ class FakeProcess:
|
||||
self._alive = False
|
||||
|
||||
|
||||
@linux_only
|
||||
class RecordingCommand(DikteTest):
|
||||
class RecordingCommand(OnLinux, DikteTest):
|
||||
"""Which program captures the microphone, and how it is asked to."""
|
||||
|
||||
def test_parec_is_preferred(self):
|
||||
@@ -270,8 +288,7 @@ class RecordingCommand(DikteTest):
|
||||
if arg.startswith(flag)])
|
||||
|
||||
|
||||
@linux_only
|
||||
class RecorderChain(DikteTest):
|
||||
class RecorderChain(OnLinux, DikteTest):
|
||||
"""Start to WAV, with pw-record faked out."""
|
||||
|
||||
def record(self, data, target="", max_seconds=300):
|
||||
@@ -404,5 +421,146 @@ class RecorderChain(DikteTest):
|
||||
self.assertFalse(recorder.active)
|
||||
|
||||
|
||||
class MeetingCommand(unittest.TestCase):
|
||||
"""One process reading both devices, because two would drift apart."""
|
||||
|
||||
def command(self, platform, mic="", system="them"):
|
||||
with mock.patch.object(sys, "platform", platform):
|
||||
return audio.meeting_command(mic, system)
|
||||
|
||||
def test_linux_reads_both_through_pulse(self):
|
||||
cmd = self.command("linux", mic="mine")
|
||||
self.assertEqual(cmd.count("pulse"), 2)
|
||||
self.assertEqual(cmd[cmd.index("mine") - 1], "-i")
|
||||
self.assertEqual(cmd[cmd.index("them") - 1], "-i")
|
||||
|
||||
def test_a_mac_reads_both_through_avfoundation(self):
|
||||
cmd = self.command("darwin", mic="1")
|
||||
self.assertEqual(cmd.count("avfoundation"), 2)
|
||||
self.assertIn(":1", cmd)
|
||||
self.assertIn(":them", cmd)
|
||||
|
||||
def test_no_microphone_named_means_the_default_one(self):
|
||||
self.assertIn("default", self.command("linux"))
|
||||
self.assertIn(":default", self.command("darwin"))
|
||||
|
||||
def test_both_merge_the_two_into_one_stereo_stream(self):
|
||||
for platform in ("linux", "darwin"):
|
||||
with self.subTest(platform=platform):
|
||||
cmd = self.command(platform)
|
||||
self.assertIn(audio.MERGE_FILTER, cmd)
|
||||
self.assertEqual(cmd[cmd.index("-map") + 1], "[out]")
|
||||
self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le")
|
||||
|
||||
def test_neither_lets_ffmpeg_read_the_terminal(self):
|
||||
"""It shares stdin with Dikte, and would eat a keypress meant for it."""
|
||||
for platform in ("linux", "darwin"):
|
||||
with self.subTest(platform=platform):
|
||||
self.assertIn("-nostdin", self.command(platform))
|
||||
|
||||
|
||||
class MacDevices(OnMacOS, DikteTest):
|
||||
"""The one ffmpeg listing all three device questions are answered from."""
|
||||
|
||||
LISTING = (
|
||||
"[AVFoundation indev @ 0x7fb] AVFoundation video devices:\n"
|
||||
"[AVFoundation indev @ 0x7fb] [0] FaceTime HD Camera\n"
|
||||
"[AVFoundation indev @ 0x7fb] [1] Capture screen 0\n"
|
||||
"[AVFoundation indev @ 0x7fb] AVFoundation audio devices:\n"
|
||||
"[AVFoundation indev @ 0x7fb] [0] MacBook Pro Microphone\n"
|
||||
"[AVFoundation indev @ 0x7fb] [1] BlackHole 2ch\n"
|
||||
": Input/output error\n"
|
||||
)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||
completed = FakeCompleted(
|
||||
returncode=1, stderr=self.LISTING if stderr is None else stderr)
|
||||
with only_these_tools(*tools), \
|
||||
mock.patch.object(subprocess, "run", return_value=completed):
|
||||
yield
|
||||
|
||||
def test_the_audio_half_of_the_listing_is_the_only_half_read(self):
|
||||
with self.listing():
|
||||
self.assertEqual(audio.list_sources(),
|
||||
[("0", "MacBook Pro Microphone"), ("1", "BlackHole 2ch")])
|
||||
|
||||
def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self):
|
||||
with self.listing():
|
||||
name, description = audio.list_sources()[1]
|
||||
self.assertEqual(name, "1")
|
||||
self.assertIn("BlackHole", description)
|
||||
|
||||
def test_no_ffmpeg_installed(self):
|
||||
with only_these_tools():
|
||||
self.assertEqual(audio.list_sources(), [])
|
||||
self.assertEqual(audio.list_monitors(), [])
|
||||
self.assertEqual(audio.default_monitor(), "")
|
||||
|
||||
def test_an_ffmpeg_that_will_not_run(self):
|
||||
with only_these_tools("ffmpeg"), \
|
||||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||
self.assertEqual(audio.list_sources(), [])
|
||||
|
||||
def test_a_listing_with_no_audio_section(self):
|
||||
with self.listing(stderr="[AVFoundation indev @ 0x7fb] [0] FaceTime\n"):
|
||||
self.assertEqual(audio.list_sources(), [])
|
||||
|
||||
def test_the_far_side_of_a_meeting_is_offered_the_same_devices(self):
|
||||
"""macOS calls none of them an output, so the loopback one is in here."""
|
||||
with self.listing():
|
||||
self.assertEqual(audio.list_monitors(), audio.list_sources())
|
||||
|
||||
def test_the_loopback_driver_is_picked_out_by_name(self):
|
||||
with self.listing():
|
||||
self.assertEqual(audio.default_monitor(), "1")
|
||||
|
||||
def test_the_other_two_drivers_people_install(self):
|
||||
for name in ("Loopback Audio", "Soundflower (2ch)"):
|
||||
with self.subTest(name=name):
|
||||
listing = ("AVFoundation audio devices:\n"
|
||||
f"[0] Built-in Microphone\n[1] {name}\n")
|
||||
with self.listing(stderr=listing):
|
||||
self.assertEqual(audio.default_monitor(), "1")
|
||||
|
||||
def test_a_mac_with_nothing_to_record_the_far_side_from(self):
|
||||
listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n"
|
||||
with self.listing(stderr=listing):
|
||||
self.assertEqual(audio.default_monitor(), "")
|
||||
|
||||
|
||||
class MacRecordingCommand(OnMacOS, DikteTest):
|
||||
def test_the_microphone_is_read_through_avfoundation(self):
|
||||
with only_these_tools("ffmpeg"):
|
||||
cmd = audio.recording_command()
|
||||
self.assertEqual(cmd[0], "ffmpeg")
|
||||
self.assertEqual(cmd[cmd.index("-f") + 1], "avfoundation")
|
||||
|
||||
def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self):
|
||||
with only_these_tools("ffmpeg"):
|
||||
self.assertIn(":default", audio.recording_command())
|
||||
self.assertIn(":2", audio.recording_command("2"))
|
||||
|
||||
def test_it_captures_the_format_the_rest_of_the_code_expects(self):
|
||||
with only_these_tools("ffmpeg"):
|
||||
cmd = audio.recording_command()
|
||||
self.assertEqual(cmd[cmd.index("-ar") + 1], str(audio.RATE))
|
||||
self.assertEqual(cmd[cmd.index("-ac") + 1], str(audio.CHANNELS))
|
||||
self.assertEqual(cmd[-2:], ["s16le", "-"])
|
||||
|
||||
def test_no_ffmpeg_installed(self):
|
||||
with only_these_tools():
|
||||
self.assertEqual(audio.recording_command(), [])
|
||||
|
||||
def test_what_a_mac_is_told_to_install(self):
|
||||
recorder = audio.Recorder()
|
||||
failures = []
|
||||
recorder.failed.connect(failures.append)
|
||||
with only_these_tools():
|
||||
recorder.start()
|
||||
self.assertIn("brew install ffmpeg", failures[0])
|
||||
self.assertFalse(recorder.active)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest import mock
|
||||
import api
|
||||
import config as cfg
|
||||
import i18n
|
||||
import paste
|
||||
from tests.support import DikteTest
|
||||
|
||||
|
||||
@@ -422,6 +423,39 @@ class Defaults(unittest.TestCase):
|
||||
with self.subTest(prompt=f"{name}_{suffix}"):
|
||||
self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip())
|
||||
|
||||
def test_the_paste_key_is_the_one_this_desktop_pastes_with(self):
|
||||
"""cmd+v on a Mac, and it must be one paste.py can actually press."""
|
||||
self.assertEqual(cfg.DEFAULTS["paste_shortcut"],
|
||||
paste.desktop().shortcuts[0])
|
||||
|
||||
|
||||
class Directories(unittest.TestCase):
|
||||
"""Where the settings and the recordings are kept, per system."""
|
||||
|
||||
def test_linux_keeps_them_apart_and_follows_xdg(self):
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c",
|
||||
"XDG_DATA_HOME": "/d"}):
|
||||
config_dir, data_dir = cfg._directories("linux")
|
||||
self.assertEqual(str(config_dir), "/c/dikte")
|
||||
self.assertEqual(str(data_dir), "/d/dikte")
|
||||
|
||||
def test_linux_without_the_variables_set(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
config_dir, data_dir = cfg._directories("linux")
|
||||
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
|
||||
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
|
||||
|
||||
def test_a_mac_keeps_both_in_application_support(self):
|
||||
config_dir, data_dir = cfg._directories("darwin")
|
||||
self.assertEqual(config_dir, data_dir)
|
||||
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
|
||||
|
||||
def test_a_mac_does_not_read_the_xdg_variables(self):
|
||||
"""A Mac with them set from some other tool still stores in one place."""
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
|
||||
config_dir, _ = cfg._directories("darwin")
|
||||
self.assertNotIn("/c", str(config_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+235
-1
@@ -123,10 +123,13 @@ class Bindings(DikteTest):
|
||||
self.assertEqual(len(listener._bindings[57]), 2)
|
||||
|
||||
|
||||
@linux_only
|
||||
class Chooser(DikteTest):
|
||||
"""Which desktop is asked to register the shortcut."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hotkey.sys, "platform", "linux")
|
||||
|
||||
@contextlib.contextmanager
|
||||
def under(self, desktop, has_gsettings=True):
|
||||
"""A session that says it is this desktop, with or without gsettings."""
|
||||
@@ -407,5 +410,236 @@ class KdeShortcut(DikteTest):
|
||||
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
|
||||
|
||||
|
||||
# --- macOS ----------------------------------------------------------------
|
||||
|
||||
class ParseMacShortcut(unittest.TestCase):
|
||||
def test_a_combination_a_mac_would_use(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Space"),
|
||||
(hotkey.MAC_MODS["cmd"], 49))
|
||||
|
||||
def test_case_and_spacing_do_not_matter(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut(" ctrl + option + a "),
|
||||
hotkey.parse_macos_shortcut("Ctrl+Option+A"))
|
||||
|
||||
def test_several_modifiers_are_one_number(self):
|
||||
modifiers, key = hotkey.parse_macos_shortcut("Cmd+Shift+M")
|
||||
self.assertEqual(modifiers,
|
||||
hotkey.MAC_MODS["cmd"] | hotkey.MAC_MODS["shift"])
|
||||
self.assertEqual(key, hotkey.MAC_KEYS["m"])
|
||||
|
||||
def test_the_names_a_mac_keyboard_uses(self):
|
||||
for name in ("cmd", "command", "meta", "super"):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut(f"{name}+space"),
|
||||
hotkey.parse_macos_shortcut("cmd+space"))
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("option+a"),
|
||||
hotkey.parse_macos_shortcut("alt+a"))
|
||||
|
||||
def test_a_key_on_its_own(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("F5"), (0, 96))
|
||||
|
||||
def test_modifiers_with_no_key(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Shift"), (None, None))
|
||||
|
||||
def test_a_key_nobody_mapped(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+F13"), (None, None))
|
||||
|
||||
def test_two_keys_are_not_a_shortcut(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut("A+B"), (None, None))
|
||||
|
||||
def test_nothing(self):
|
||||
self.assertEqual(hotkey.parse_macos_shortcut(""), (None, None))
|
||||
self.assertEqual(hotkey.parse_macos_shortcut(None), (None, None))
|
||||
|
||||
|
||||
class FakeCarbon:
|
||||
"""Enough of Carbon to watch what the listener asks it for.
|
||||
|
||||
The references are numbers standing in for pointers, which is all the code
|
||||
does with them: it collects them and hands them back to be unregistered.
|
||||
"""
|
||||
|
||||
def __init__(self, install=0, register=0):
|
||||
self.install, self.register = install, register # what they return
|
||||
self.registered = [] # (key, modifiers, identifier)
|
||||
self.unregistered = []
|
||||
self.handlers_removed = 0
|
||||
self.pressed_id = 0
|
||||
self.parameter_result = 0
|
||||
|
||||
def GetApplicationEventTarget(self):
|
||||
return 7000
|
||||
|
||||
def InstallEventHandler(self, target, callback, count, spec, data, out):
|
||||
if self.install == 0:
|
||||
out._obj.value = 8000
|
||||
return self.install
|
||||
|
||||
def RegisterEventHotKey(self, key, modifiers, identifier, target, options, out):
|
||||
if self.register != 0:
|
||||
return self.register
|
||||
self.registered.append((key, modifiers, identifier.id))
|
||||
out._obj.value = 9000 + len(self.registered)
|
||||
return 0
|
||||
|
||||
def UnregisterEventHotKey(self, reference):
|
||||
self.unregistered.append(reference.value)
|
||||
return 0
|
||||
|
||||
def RemoveEventHandler(self, handler):
|
||||
self.handlers_removed += 1
|
||||
return 0
|
||||
|
||||
def GetEventParameter(self, event, kind, name, wanted_type, size, out_size, out):
|
||||
out._obj.id = self.pressed_id
|
||||
return self.parameter_result
|
||||
|
||||
|
||||
class CarbonListener(DikteTest):
|
||||
"""What the listener asks macOS for, without a Mac to ask."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.carbon = FakeCarbon()
|
||||
self.patch_attr(hotkey, "_carbon", lambda: self.carbon)
|
||||
self.addCleanup(hotkey._REGISTERED.clear)
|
||||
self.listener = hotkey.CarbonHotkey()
|
||||
self.addCleanup(self.listener.stop)
|
||||
self.failures = []
|
||||
self.listener.failed.connect(self.failures.append)
|
||||
|
||||
def test_every_binding_is_asked_for_by_position_and_modifier(self):
|
||||
self.assertTrue(self.listener.start({"toggle": "Ctrl+Option+Space"}))
|
||||
self.assertEqual(self.carbon.registered,
|
||||
[(49, hotkey.MAC_MODS["ctrl"] | hotkey.MAC_MODS["option"], 1)])
|
||||
self.assertEqual(self.failures, [])
|
||||
self.assertTrue(self.listener.running)
|
||||
|
||||
def test_a_binding_with_no_shortcut_is_skipped(self):
|
||||
self.assertFalse(self.listener.start({"toggle": "", "ask": ""}))
|
||||
self.assertEqual(self.carbon.registered, [])
|
||||
self.assertFalse(self.listener.running)
|
||||
|
||||
def test_an_unparsable_shortcut_is_reported_and_the_rest_go_on(self):
|
||||
self.assertTrue(self.listener.start({"toggle": "Cmd+F13",
|
||||
"ask": "Cmd+Shift+Space"}))
|
||||
self.assertEqual(len(self.failures), 1)
|
||||
self.assertIn("Cmd+F13", self.failures[0])
|
||||
self.assertEqual(len(self.carbon.registered), 1)
|
||||
|
||||
def test_a_combination_another_application_already_holds(self):
|
||||
"""The conflict warning macOS has: it is the answer to asking."""
|
||||
self.carbon.register = -9878 # eventHotKeyExistsErr
|
||||
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
|
||||
self.assertIn("Cmd+Shift+Space", self.failures[0])
|
||||
self.assertFalse(self.listener.running)
|
||||
|
||||
def test_a_handler_that_will_not_install(self):
|
||||
self.carbon.install = -50
|
||||
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
|
||||
self.assertEqual(self.carbon.registered, [])
|
||||
self.assertEqual(len(self.failures), 1)
|
||||
|
||||
def test_no_carbon_to_talk_to(self):
|
||||
self.patch_attr(hotkey, "_carbon",
|
||||
mock.Mock(side_effect=OSError("no such library")))
|
||||
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
|
||||
self.assertIn("no such library", self.failures[0])
|
||||
|
||||
def test_the_key_press_arrives_under_the_name_it_was_registered_with(self):
|
||||
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
|
||||
heard = []
|
||||
self.listener.triggered.connect(heard.append)
|
||||
self.carbon.pressed_id = 2 # the second binding, which is "ask"
|
||||
self.listener._callback(None, 0, None)
|
||||
self.assertEqual(heard, ["ask"])
|
||||
|
||||
def test_a_press_carbon_could_not_identify_is_dropped(self):
|
||||
self.listener.start({"toggle": "Cmd+Shift+Space"})
|
||||
heard = []
|
||||
self.listener.triggered.connect(heard.append)
|
||||
self.carbon.parameter_result = -50
|
||||
self.listener._callback(None, 0, None)
|
||||
self.assertEqual(heard, [])
|
||||
|
||||
def test_stopping_hands_every_registration_back(self):
|
||||
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
|
||||
self.listener.stop()
|
||||
self.assertEqual(self.carbon.unregistered, [9001, 9002])
|
||||
self.assertEqual(self.carbon.handlers_removed, 1)
|
||||
self.assertFalse(self.listener.running)
|
||||
|
||||
def test_starting_twice_does_not_leave_the_first_set_behind(self):
|
||||
self.listener.start({"toggle": "Cmd+Shift+Space"})
|
||||
self.listener.start({"toggle": "Cmd+Shift+A"})
|
||||
self.assertEqual(self.carbon.unregistered, [9001])
|
||||
self.assertEqual(len(self.listener._registrations), 1)
|
||||
|
||||
def test_what_it_registered_is_what_the_status_line_reads_back(self):
|
||||
with mock.patch.object(hotkey.sys, "platform", "darwin"):
|
||||
self.listener.start({"toggle": "Ctrl+Option+Space",
|
||||
"meeting": "Ctrl+Option+M"})
|
||||
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Option+Space")
|
||||
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
|
||||
"Ctrl+Option+M")
|
||||
self.listener.stop()
|
||||
self.assertIsNone(hotkey.shortcut_status())
|
||||
|
||||
|
||||
class MacChooser(DikteTest):
|
||||
"""What the shortcut verbs mean where there is nothing to write them into."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hotkey.sys, "platform", "darwin")
|
||||
self.addCleanup(hotkey._REGISTERED.clear)
|
||||
|
||||
def test_the_listener_is_the_one_macos_has(self):
|
||||
self.assertIsInstance(hotkey.listener(), hotkey.CarbonHotkey)
|
||||
|
||||
def test_everywhere_else_reads_the_keyboard_itself(self):
|
||||
with mock.patch.object(hotkey.sys, "platform", "linux"):
|
||||
self.assertIsInstance(hotkey.listener(), hotkey.EvdevHotkey)
|
||||
|
||||
def test_there_is_nothing_to_install_into(self):
|
||||
self.assertFalse(hotkey.installs_shortcuts())
|
||||
self.assertFalse(hotkey.shortcut_needs_restart())
|
||||
self.assertEqual(hotkey.desktop_name(), "macOS")
|
||||
|
||||
def test_installing_records_it_rather_than_writing_anything(self):
|
||||
with mock.patch.object(hotkey.subprocess, "run") as run:
|
||||
ok, message = hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
|
||||
run.assert_not_called()
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("Cmd+Shift+Space", message)
|
||||
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
|
||||
|
||||
def test_removing_takes_it_back_out(self):
|
||||
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
|
||||
hotkey.remove_shortcut()
|
||||
self.assertIsNone(hotkey.shortcut_status())
|
||||
|
||||
def test_each_verb_is_kept_apart(self):
|
||||
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
|
||||
hotkey.install_shortcut("Cmd+Shift+M", "dikte meeting",
|
||||
desktop_id=hotkey.MEETING_DESKTOP_ID)
|
||||
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
|
||||
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
|
||||
"Cmd+Shift+M")
|
||||
|
||||
def test_no_list_of_conflicts_to_read(self):
|
||||
"""Not even KDE's file, which a Mac could well have a copy of."""
|
||||
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
|
||||
|
||||
def test_a_combination_is_checked_against_the_mac_table(self):
|
||||
self.assertTrue(hotkey.valid_shortcut("Cmd+Shift+Space"))
|
||||
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
|
||||
|
||||
def test_the_other_table_is_the_one_used_elsewhere(self):
|
||||
with mock.patch.object(hotkey.sys, "platform", "linux"):
|
||||
self.assertTrue(hotkey.valid_shortcut("Ctrl+F1"))
|
||||
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+192
-22
@@ -1,30 +1,35 @@
|
||||
"""The clipboard and the key press, which is where a dictation actually lands.
|
||||
|
||||
Everything here shells out, so the tools are faked. What the tests hold onto is
|
||||
the command line: a paste that presses the wrong keys, or in the wrong order,
|
||||
types nothing and looks like a hang.
|
||||
Everything here is faked: the programs the two Linux desktops shell out to, and
|
||||
the frameworks macOS goes through. What the tests hold onto is what was asked
|
||||
for. A paste that presses the wrong keys, or in the wrong order, types nothing
|
||||
and looks like a hang.
|
||||
|
||||
Both desktops owe the same promises, so those are written once and run against
|
||||
each of them. A third one added to paste.py inherits the same list rather than
|
||||
needing its own copy of it.
|
||||
Every system owes the same promises about the clipboard, so those are written
|
||||
once and run against each of them. A fourth one added to paste.py inherits the
|
||||
same list rather than needing its own copy of it. Each class says which system
|
||||
it is standing on, which is why none of this is skipped anywhere: the Linux half
|
||||
is checked on a Mac and the macOS half on Linux, and a change to the chooser
|
||||
cannot quietly break the platform nobody is sitting at.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
import paste
|
||||
from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
|
||||
from tests.support import DikteTest, FakeCompleted, only_these_tools
|
||||
|
||||
|
||||
@linux_only
|
||||
class Chooser(DikteTest):
|
||||
"""Which pair of programs this session's clipboard goes through."""
|
||||
|
||||
def under(self, **env):
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
def under(self, platform="linux", **env):
|
||||
with mock.patch.object(sys, "platform", platform), \
|
||||
mock.patch.dict(os.environ, env, clear=True):
|
||||
return paste.desktop()
|
||||
|
||||
def test_a_wayland_session(self):
|
||||
@@ -46,18 +51,29 @@ class Chooser(DikteTest):
|
||||
def test_nothing_set_at_all(self):
|
||||
self.assertIs(self.under(), paste.WAYLAND)
|
||||
|
||||
def test_a_mac(self):
|
||||
self.assertIs(self.under("darwin"), paste.MACOS)
|
||||
|
||||
class DesktopContract:
|
||||
"""What both desktops owe. Each of them subclasses this once, below."""
|
||||
def test_a_mac_running_an_x_server_is_still_a_mac(self):
|
||||
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
|
||||
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
|
||||
|
||||
|
||||
class Standing:
|
||||
"""A test that runs as if it were sitting at one particular system."""
|
||||
|
||||
env: ClassVar[dict] = {}
|
||||
platform = "linux"
|
||||
here = None
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.enterContext(mock.patch.object(sys, "platform", self.platform))
|
||||
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True))
|
||||
|
||||
# ---- reading the clipboard -------------------------------------------
|
||||
|
||||
class ClipboardContract(Standing):
|
||||
"""What every system owes the text on its way to the clipboard."""
|
||||
|
||||
def test_no_reader_installed(self):
|
||||
with only_these_tools():
|
||||
@@ -81,13 +97,10 @@ class DesktopContract:
|
||||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||
self.assertIsNone(paste.read_clipboard())
|
||||
|
||||
# ---- copying ----------------------------------------------------------
|
||||
|
||||
def test_no_clipboard_tool_installed_says_what_to_install(self):
|
||||
def test_no_clipboard_tool_installed_names_it(self):
|
||||
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
|
||||
paste.copy("hello")
|
||||
self.assertIn(self.here.clipboard, str(caught.exception))
|
||||
self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception))
|
||||
|
||||
def test_the_text_goes_in_as_utf8(self):
|
||||
with only_these_tools(self.here.clipboard), \
|
||||
@@ -138,7 +151,15 @@ class DesktopContract:
|
||||
paste.copy_bytes(b"\x89PNG\r\n")
|
||||
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
|
||||
|
||||
# ---- pressing the key -------------------------------------------------
|
||||
def test_the_paste_key_it_offers_is_one_it_can_press(self):
|
||||
"""Whatever Settings lists, pressing it must not come back unknown."""
|
||||
for shortcut in self.here.shortcuts:
|
||||
with self.subTest(shortcut=shortcut):
|
||||
self.assertTrue(self.pressing(shortcut))
|
||||
|
||||
|
||||
class KeyProgramContract(Standing):
|
||||
"""The half of it that is another program: ydotool, xdotool."""
|
||||
|
||||
def press(self, shortcut, result=None):
|
||||
with only_these_tools(self.here.keyboard), \
|
||||
@@ -148,6 +169,10 @@ class DesktopContract:
|
||||
paste.press(shortcut)
|
||||
return run.call_args.args[0]
|
||||
|
||||
def pressing(self, shortcut):
|
||||
"""The command a shortcut would run, for the contract above."""
|
||||
return self.press(shortcut)
|
||||
|
||||
def test_no_keyboard_tool_installed(self):
|
||||
with only_these_tools():
|
||||
self.assertFalse(paste.paste_ready())
|
||||
@@ -180,9 +205,11 @@ class DesktopContract:
|
||||
self.assertIn(self.here.keyboard, str(caught.exception))
|
||||
self.assertIn("no socket", str(caught.exception))
|
||||
|
||||
def test_nothing_named_presses_the_one_this_desktop_pastes_with(self):
|
||||
self.assertEqual(self.press(""), self.press(self.here.shortcuts[0]))
|
||||
|
||||
@linux_only
|
||||
class Wayland(DesktopContract, DikteTest):
|
||||
|
||||
class Wayland(ClipboardContract, KeyProgramContract, DikteTest):
|
||||
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
|
||||
"WAYLAND_DISPLAY": "wayland-0"}
|
||||
here = paste.WAYLAND
|
||||
@@ -208,8 +235,7 @@ class Wayland(DesktopContract, DikteTest):
|
||||
self.assertIn("ydotoold", str(caught.exception))
|
||||
|
||||
|
||||
@linux_only
|
||||
class X11(DesktopContract, DikteTest):
|
||||
class X11(ClipboardContract, KeyProgramContract, DikteTest):
|
||||
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
|
||||
here = paste.X11
|
||||
|
||||
@@ -232,5 +258,149 @@ class X11(DesktopContract, DikteTest):
|
||||
self.assertNotIn("ydotoold", str(caught.exception))
|
||||
|
||||
|
||||
class FakeCoreGraphics:
|
||||
"""Enough of the two frameworks to watch what a paste does to them.
|
||||
|
||||
The events are numbers standing in for pointers, which is all the code
|
||||
treats them as: it makes them, sets flags on them, posts them, and hands
|
||||
them back.
|
||||
"""
|
||||
|
||||
def __init__(self, trusted=True, makes=None):
|
||||
self.trusted = trusted
|
||||
self.makes = makes # how many it will hand out; None for as many as asked
|
||||
self.made = [] # (keycode, is_down)
|
||||
self.flags = [] # (event, flags)
|
||||
self.posted = [] # (tap, event)
|
||||
self.released = []
|
||||
|
||||
# --- ApplicationServices
|
||||
def AXIsProcessTrusted(self):
|
||||
return self.trusted
|
||||
|
||||
def CGEventCreateKeyboardEvent(self, source, keycode, down):
|
||||
self.made.append((keycode, down))
|
||||
if self.makes is not None and len(self.made) > self.makes:
|
||||
return None
|
||||
return 1000 + len(self.made)
|
||||
|
||||
def CGEventSetFlags(self, event, flags):
|
||||
self.flags.append((event, flags))
|
||||
|
||||
def CGEventPost(self, tap, event):
|
||||
self.posted.append((tap, event))
|
||||
|
||||
# --- CoreFoundation
|
||||
def CFRelease(self, event):
|
||||
self.released.append(event)
|
||||
|
||||
|
||||
class MacOS(ClipboardContract, DikteTest):
|
||||
platform = "darwin"
|
||||
here = paste.MACOS
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = FakeCoreGraphics()
|
||||
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
|
||||
self.patch_attr(paste.time, "sleep", lambda seconds: None)
|
||||
# It opens the settings pane once per run; each test gets its own run.
|
||||
self.patch_attr(paste, "_asked_for_permission", False)
|
||||
self.opened = self.patch_attr(paste.subprocess, "Popen", mock.Mock())
|
||||
|
||||
def pressing(self, shortcut):
|
||||
paste.press(shortcut)
|
||||
return self.api.posted
|
||||
|
||||
def test_the_key_goes_in_by_position_with_the_modifiers_on_it(self):
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(self.api.made, [(9, True), (9, False)])
|
||||
self.assertEqual([flags for _, flags in self.api.flags],
|
||||
[paste.MAC_FLAGS["command"]] * 2)
|
||||
self.assertEqual([tap for tap, _ in self.api.posted],
|
||||
[paste.HID_EVENT_TAP] * 2)
|
||||
|
||||
def test_the_down_is_posted_before_the_up(self):
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
|
||||
|
||||
def test_both_events_are_handed_back(self):
|
||||
"""CoreGraphics gives out memory that nothing else will free."""
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(sorted(self.api.released), [1001, 1002])
|
||||
|
||||
def test_several_modifiers_are_one_number(self):
|
||||
paste.press("cmd+shift+v")
|
||||
self.assertEqual(self.api.flags[0][1],
|
||||
paste.MAC_FLAGS["command"] | paste.MAC_FLAGS["shift"])
|
||||
|
||||
def test_the_names_a_mac_keyboard_uses(self):
|
||||
for name in ("cmd", "command", "meta", "super"):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(paste._macos_keys(f"{name}+v"),
|
||||
(9, paste.MAC_FLAGS["command"]))
|
||||
self.assertEqual(paste._macos_keys("option+v"), paste._macos_keys("alt+v"))
|
||||
self.assertEqual(paste._macos_keys("control+v"), paste._macos_keys("ctrl+v"))
|
||||
|
||||
def test_case_and_spacing_do_not_matter(self):
|
||||
self.assertEqual(paste._macos_keys(" Cmd + V "), paste._macos_keys("cmd+v"))
|
||||
|
||||
def test_a_key_nobody_mapped_is_refused_before_anything_is_posted(self):
|
||||
with self.assertRaises(paste.PasteError) as caught:
|
||||
paste.press("cmd+f13")
|
||||
self.assertIn("f13", str(caught.exception))
|
||||
self.assertEqual(self.api.posted, [])
|
||||
|
||||
def test_a_modifier_nobody_mapped(self):
|
||||
with self.assertRaises(paste.PasteError) as caught:
|
||||
paste.press("hyper+v")
|
||||
self.assertIn("hyper", str(caught.exception))
|
||||
|
||||
def test_nothing_at_all(self):
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("+")
|
||||
|
||||
def test_nothing_is_typed_until_macos_says_so(self):
|
||||
self.api.trusted = False
|
||||
with self.assertRaises(paste.PasteError) as caught:
|
||||
paste.press("cmd+v")
|
||||
self.assertIn("Accessibility", str(caught.exception))
|
||||
self.assertEqual(self.api.posted, [])
|
||||
|
||||
def test_the_permission_pane_is_opened_once_and_not_again(self):
|
||||
"""It is a window in the user's face, and one paste is every dictation."""
|
||||
self.api.trusted = False
|
||||
for _ in range(3):
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("cmd+v")
|
||||
self.opened.assert_called_once()
|
||||
self.assertIn("Privacy_Accessibility", self.opened.call_args.args[0][1])
|
||||
|
||||
def test_a_system_that_will_not_open_the_pane_still_says_what_is_wrong(self):
|
||||
self.api.trusted = False
|
||||
self.opened.side_effect = OSError("no open(1) here")
|
||||
with self.assertRaises(paste.PasteError) as caught:
|
||||
paste.press("cmd+v")
|
||||
self.assertIn("Accessibility", str(caught.exception))
|
||||
|
||||
def test_readiness_is_the_permission_rather_than_a_program(self):
|
||||
self.assertTrue(paste.paste_ready())
|
||||
self.api.trusted = False
|
||||
self.assertFalse(paste.paste_ready())
|
||||
|
||||
def test_an_event_that_could_not_be_made_takes_its_pair_with_it(self):
|
||||
self.api.makes = 1 # the second one comes back null
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(self.api.released, [1001])
|
||||
self.assertEqual(self.api.posted, [])
|
||||
|
||||
def test_the_frameworks_not_being_there_is_not_a_crash(self):
|
||||
"""Every other system imports this module too, and must survive it."""
|
||||
self.patch_attr(paste, "_macos_api",
|
||||
mock.Mock(side_effect=paste.PasteError("no such library")))
|
||||
self.assertFalse(paste.paste_ready())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+39
-2
@@ -6,13 +6,16 @@ save, so a setting added to one half and not the other is silently reset the
|
||||
next time anybody presses Save. That is the failure this catches.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
import config as cfg
|
||||
import overlay as overlay_module
|
||||
import paste
|
||||
import settings_ui
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
@@ -80,10 +83,16 @@ CHANGED = {
|
||||
|
||||
|
||||
class Settings(DikteTest):
|
||||
# What a Mac shows instead, where the combination on offer is a different
|
||||
# one. Everything else about the window is the same on both.
|
||||
changed = CHANGED
|
||||
platform = "linux"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# No pactl, no model lists over the network, and no modal dialogue
|
||||
# waiting for somebody to press OK.
|
||||
self.enterContext(mock.patch.object(sys, "platform", self.platform))
|
||||
self.enterContext(only_these_tools())
|
||||
self.enterContext(mock.patch.object(QMessageBox, "information"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
@@ -116,11 +125,11 @@ class Settings(DikteTest):
|
||||
self.assertEqual(conf.data, before)
|
||||
|
||||
def test_a_setting_of_your_own_survives_the_round_trip(self):
|
||||
self.write_config(CHANGED)
|
||||
self.write_config(self.changed)
|
||||
conf = cfg.Config()
|
||||
self.window(conf)._save()
|
||||
stored = self.read_config_file()
|
||||
for key, value in CHANGED.items():
|
||||
for key, value in self.changed.items():
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(stored[key], value)
|
||||
|
||||
@@ -175,6 +184,34 @@ class Settings(DikteTest):
|
||||
self.assertEqual(window.windowTitle(), "Dikte Ayarları")
|
||||
|
||||
|
||||
class MacSettings(Settings):
|
||||
"""The same window and the same round trip, standing on a Mac.
|
||||
|
||||
Nothing here is about macOS: it is the rest of the window, checked on the
|
||||
platform where three of its widgets are gone and one offers other keys.
|
||||
"""
|
||||
|
||||
platform = "darwin"
|
||||
changed: ClassVar[dict] = {**CHANGED, "paste_shortcut": "cmd+shift+v"}
|
||||
|
||||
def test_there_is_no_install_button_where_nothing_is_installed(self):
|
||||
window = self.window(cfg.Config())
|
||||
labels = [button.text() for button in
|
||||
window.findChildren(settings_ui.QPushButton)]
|
||||
self.assertFalse([text for text in labels if "shortcut" in text.lower()])
|
||||
|
||||
def test_the_listener_is_not_offered_as_a_choice(self):
|
||||
"""It is the whole mechanism there; turning it off would leave nothing."""
|
||||
window = self.window(cfg.Config())
|
||||
self.assertFalse(window.evdev_enabled.isVisible())
|
||||
|
||||
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
|
||||
window = self.window(cfg.Config())
|
||||
offered = [window.paste_shortcut.itemText(index)
|
||||
for index in range(window.paste_shortcut.count())]
|
||||
self.assertEqual(offered, paste.MACOS.shortcuts)
|
||||
|
||||
|
||||
class Overlay(DikteTest):
|
||||
def overlay(self, **kwargs):
|
||||
widget = overlay_module.Overlay(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user