Merge master: the modules moved into a package

Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:

hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.

install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.

settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.

CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
This commit is contained in:
2026-08-16 14:10:28 +03:00
54 changed files with 1688 additions and 573 deletions
+3 -3
View File
@@ -71,9 +71,9 @@ jobs:
- name: Check the installer parses - name: Check the installer parses
run: | run: |
bash -n install.sh bash -n install.sh
bash -n install-mac.sh bash -n scripts/install-mac.sh
bash -n update.sh bash -n scripts/update.sh
bash -n uninstall.sh bash -n scripts/uninstall.sh
# The same job again for the same reason. The Windows backends are faked at # The same job again for the same reason. The Windows backends are faked at
# the one function that loads user32 and kernel32, so every line of them is # the one function that loads user32 and kernel32, so every line of them is
+5 -5
View File
@@ -58,10 +58,10 @@ entry in a table, and one chooser picks between them, so a fifth adds an entry
and a line rather than a branch inside every function. The tables are and a line rather than a branch inside every function. The tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device `paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device
lists, and whether the far side of a meeting can be recorded at all), lists, and whether the far side of a meeting can be recorded at all),
`paths.directories()` (where the settings and the data live) and the `paths.directories()` (where the settings and the data live) and `hotkey.backend()`,
`_macos()`/`_windows()`/`_gnome()` set in `hotkey.py`. Keep `sys.platform` inside which names the one mechanism a session has for holding a key. Keep `sys.platform`
the chooser and read it there every time: a constant settled at import is one no inside the chooser and read it there every time: a constant settled at import is
test can stand somewhere else. one no test can stand somewhere else.
Where a platform cannot do something, say so in its table entry rather than in Where a platform cannot do something, say so in its table entry rather than in
the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no
@@ -70,7 +70,7 @@ there can tell that apart from an empty device list, which only means the tool
that lists them is not installed. that lists them is not installed.
The tests are split along the same line, and almost none of them are skipped. The tests are split along the same line, and almost none of them are skipped.
1009 of the 1052 run on any machine, including every line of the Wayland, X11, 1067 of the 1110 run on any machine, including every line of the Wayland, X11,
macOS and Windows backends: the programs are faked at `shutil.which`, the macOS and Windows backends: the programs are faked at `shutil.which`, the
frameworks and system libraries at the one function that loads them frameworks and system libraries at the one function that loads them
(`paste._win_api`, `hotkey._win_input`). A test class says which system it is (`paste._win_api`, `hotkey._win_input`). A test class says which system it is
+26 -13
View File
@@ -5,9 +5,10 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
missing punctuation), and the result lands in your clipboard and is pasted into missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in. whatever window you were typing in.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS,
[Windows](README.windows.md) too. No dependencies beyond system packages: just [Windows](README.windows.md) and any other Linux desktop that will let it read
the Python standard library, 3.11 or newer, and PyQt6. the keyboard. No dependencies beyond system packages: just the Python standard
library, 3.11 or newer, and PyQt6.
*[Türkçe README](README.tr.md)* *[Türkçe README](README.tr.md)*
@@ -55,7 +56,7 @@ tools instead:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg sudo apt install pulseaudio-utils xclip xdotool ffmpeg
``` ```
On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which On macOS the same `./install.sh` runs and hands over to `scripts/install-mac.sh`, which
puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a
LaunchAgent: LaunchAgent:
@@ -84,14 +85,15 @@ transcribe in the cloud. A meeting needs BlackHole or Loopback
Windows works the same way, holding the keys through the system's own hotkey Windows works the same way, holding the keys through the system's own hotkey
service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`, service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`,
then `python dikte.py`, with an optional `install.ps1` for the Start Menu entry then `python -m dikte`, with an optional `install.ps1` for the Start Menu entry
and the `dikte` command. Meetings are not supported there yet; the details are and the `dikte` command. Meetings are not supported there yet; the details are
in the [Windows README](README.windows.md). in the [Windows README](README.windows.md).
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and two global shortcuts, whose keys are its two arguments, or the ones already in
puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away your settings when it is given none. `./scripts/update.sh` pulls and puts all of
again and leaves your settings and dictations alone unless you pass `--purge`. that back; `./scripts/uninstall.sh` takes it away again and leaves your settings
and dictations alone unless you pass `--purge`.
Speech to text and cleanup each pick a provider in the settings window, and both Speech to text and cleanup each pick a provider in the settings window, and both
run here by default, on models of your own. The cloud is the other option: run here by default, on models of your own. The cloud is the other option:
@@ -109,6 +111,7 @@ set next to it.
| What | How | | What | How |
| --- | --- | | --- | --- |
| Start / stop recording | `Ctrl+Space`, or click the tray icon | | Start / stop recording | `Ctrl+Space`, or click the tray icon |
| Pause / resume the recording | Tray menu, `dikte pause`, or a key you set |
| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` | | Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
@@ -189,19 +192,29 @@ running.
right-click to delete. right-click to delete.
- **Turkish and English interface**, following the system locale by default. - **Turkish and English interface**, following the system locale by default.
## The global shortcuts need one logout ## The global shortcuts, and the logout KDE needs
KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh` KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh`
writes will not fire until you log out and back in. Until then, Settings → writes will not fire until you log out and back in. Until then, Settings →
Shortcuts → **built-in listener** reads `/dev/input` and catches the combination Shortcuts → **built-in listener** reads `/dev/input` and catches the combination
itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches
the focused application (some editors will pop up autocomplete). The listener the focused application (some editors will pop up autocomplete). The listener
needs your user in the `input` group: `sudo usermod -aG input $USER`. needs your user in the `input` group: `sudo usermod -aG input $USER`. On GNOME
the shortcut works the moment it is installed, and on a desktop that keeps no
registry at all (i3, XFCE, sway and most others) the listener is the whole
mechanism: nothing is installed, nothing waits for a logout, and Settings →
Shortcuts shows the command to bind if you would rather your desktop owned the
keys.
## Layout ## Layout
Everything below is in the `dikte` package, which is what `python3 -m dikte`
runs and what the `__main__.py` in it hands to every launcher and shortcut.
`scripts/` holds install-mac.sh, update.sh and uninstall.sh; install.sh stays at
the top, and `tests/` has a file per module.
``` ```
dikte.py entry point, tray icon, state machine app.py entry point, tray icon, state machine
cli.py the command line: every verb, and what it answers with cli.py the command line: every verb, and what it answers with
ipc.py one request and one reply over the local socket ipc.py one request and one reply over the local socket
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
@@ -216,14 +229,14 @@ vad.py deciding whether a recording holds speech at all
filetranscribe.py file transcription: ffmpeg, chunking, timestamps filetranscribe.py file transcription: ffmpeg, chunking, timestamps
overlay.py the corner indicator overlay.py the corner indicator
settings_ui.py settings window settings_ui.py settings window
hotkey.py KDE shortcut installation, the evdev listener, Carbon on a Mac hotkey.py the desktop's shortcut registry, the evdev listener, Carbon on a Mac
paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
trayicon.py the tray icons, drawn where there is no icon theme trayicon.py the tray icons, drawn where there is no icon theme
i18n.py the string table i18n.py the string table
``` ```
The indicator is drawn through XWayland, because a Wayland client cannot place a The indicator is drawn through XWayland, because a Wayland client cannot place a
window in a screen corner; `dikte.py` sets `QT_QPA_PLATFORM=xcb` for that. window in a screen corner; `app.py` sets `QT_QPA_PLATFORM=xcb` for that.
## License ## License
+25 -14
View File
@@ -4,9 +4,10 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
KDE Plasma 6 / Wayland için yazıldı, GNOME X11, macOS ve KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta,
[Windows](README.windows.md)'ta da çalışır. Sistem paketleri dışında bağımlılığı [Windows](README.windows.md)'ta ve klavyeyi okumasına izin veren diğer Linux
yok: sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. masaüstlerinde de çalışır. Sistem paketleri dışında bağımlılığı yok: sadece
Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)* *[English README](README.md)*
@@ -54,7 +55,7 @@ araçlarıyla çalışır:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg sudo apt install pulseaudio-utils xclip xdotool ffmpeg
``` ```
macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder; macOS'ta da aynı `./install.sh` çalışır, işi `scripts/install-mac.sh`'a devreder;
`~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent `~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent
kurar: kurar:
@@ -83,15 +84,15 @@ gerekmiyor.
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install
PyQt6`, sonra `python dikte.py`; Başlat Menüsü girdisi ve `dikte` komutu için PyQt6`, sonra `python -m dikte`; Başlat Menüsü girdisi ve `dikte` komutu için
isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar
[Windows README](README.windows.md)'sinde. [Windows README](README.windows.md)'sinde.
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
diktelerine dokunmaz. demedikçe ayarlarına ve diktelerine dokunmaz.
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu
@@ -109,6 +110,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
| Ne | Nasıl | | Ne | Nasıl |
| --- | --- | | --- | --- |
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla | | Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
| Kaydı duraklat / sürdür | Tepsi menüsü, `dikte pause`, ya da atadığın bir tuş |
| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` | | Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
@@ -187,19 +189,28 @@ olmasını ister.
silebilirsin. silebilirsin.
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
## Global kısayollar için bir kez oturum kapatmak gerekir ## Global kısayollar ve KDE'nin istediği oturum kapatma
KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın
yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar → yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir
(bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input` (bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input`
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. GNOME'da kısayol
kurulduğu anda çalışır; hiç kayıt defteri tutmayan masaüstlerinde (i3, XFCE,
sway ve çoğu diğeri) dinleyici mekanizmanın kendisidir: hiçbir şey kurulmaz,
oturum kapatmak gerekmez, tuşları masaüstünün sahiplenmesini istersen Ayarlar →
Kısayollar sekmesi bağlanacak komutu gösterir.
## Dosyalar ## Dosyalar
Aşağıdakilerin hepsi `dikte` paketinin içinde: `python3 -m dikte` bunu çalıştırır,
içindeki `__main__.py` de her başlatıcının ve kısayolun adlandırdığı dosyadır.
`scripts/` altında install-mac.sh, update.sh ve uninstall.sh var; install.sh en
üstte kalır, `tests/` içinde de her modülün bir dosyası.
``` ```
dikte.py giriş noktası, tepsi simgesi, durum makinesi app.py giriş noktası, tepsi simgesi, durum makinesi
cli.py komut satırı: bütün fiiller ve verdikleri cevap cli.py komut satırı: bütün fiiller ve verdikleri cevap
ipc.py yerel sokette bir istek, bir cevap ipc.py yerel sokette bir istek, bir cevap
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
@@ -214,14 +225,14 @@ vad.py kayıtta gerçekten konuşma var mı kararı
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
overlay.py köşedeki gösterge overlay.py köşedeki gösterge
settings_ui.py ayarlar penceresi settings_ui.py ayarlar penceresi
hotkey.py KDE kısayol kurulumu, evdev dinleyici, Mac'te Carbon hotkey.py masaüstünün kısayol kaydı, evdev dinleyici, Mac'te Carbon
paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
i18n.py metin tablosu i18n.py metin tablosu
``` ```
Gösterge XWayland üzerinden çizilir; Wayland'da bir pencereyi belirli bir köşeye Gösterge XWayland üzerinden çizilir; Wayland'da bir pencereyi belirli bir köşeye
yerleştirmenin yolu yok, `dikte.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar. yerleştirmenin yolu yok, `app.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar.
## Lisans ## Lisans
+1 -1
View File
@@ -23,7 +23,7 @@ all of it and leaves the repository and your settings alone.
To try it without installing anything: To try it without installing anything:
```sh ```sh
python dikte.py python -m dikte
``` ```
## First run ## First run
+6
View File
@@ -0,0 +1,6 @@
"""Dikte: press a key, talk, press again to transcribe, clean up and paste.
The package is the application. Nothing is imported here on purpose: `dikte
config get` runs through the same package as the tray icon does, and it has no
business loading Qt to answer one question.
"""
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""What `python3 -m dikte` and the installed `dikte` command both run.
The file is also executed by path, because that is what a desktop shortcut and
the launcher in ~/.local/bin do: neither knows a working directory to be in.
Run that way there is no package around it, so the checkout has to be put on
the import path here, before the first line of the application is imported.
"""
import os
import sys
if not __package__:
# realpath, because the launcher is a symlink into the checkout: what has to
# end up on the path is the checkout, not ~/.local/bin. The directory this
# file is in comes off the path in exchange, so that a module beside it is
# only ever reachable as part of the package.
here = os.path.dirname(os.path.realpath(__file__))
sys.path[:] = [p for p in sys.path if os.path.realpath(p or ".") != here]
sys.path.insert(0, os.path.dirname(here))
from dikte.app import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -23,8 +23,8 @@ import threading
import urllib.error import urllib.error
import urllib.request import urllib.request
import ggml from . import ggml
from i18n import t from .i18n import t
APP_URL = "https://github.com/yusufipk/dikte" APP_URL = "https://github.com/yusufipk/dikte"
USER_AGENT = f"dikte/1.0 (+{APP_URL})" USER_AGENT = f"dikte/1.0 (+{APP_URL})"
Executable → Regular
+92 -25
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Dikte: press Ctrl+Space, talk, press again to transcribe, clean up and paste. """Dikte: press Ctrl+Space, talk, press again to transcribe, clean up and paste.
This is the application: the tray icon, the state machine, and the socket the This is the application: the tray icon, the state machine, and the socket the
terminal talks to. Every verb it answers is in cli.py, which is also what runs terminal talks to. Every verb it answers is in cli.py, which is also what runs
`dikte.py --help`; the only argument handled here is --gui, which is how the `dikte --help`; the only argument handled here is --gui, which is how the
command line says "there is no instance to talk to, so be one". command line says "there is no instance to talk to, so be one".
Nothing runs this file directly; __main__.py is what the launchers start.
""" """
import contextlib import contextlib
@@ -37,21 +38,21 @@ from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
import assistant # noqa: E402 from . import assistant # noqa: E402
import audio # noqa: E402 from . import audio # noqa: E402
import cli # noqa: E402 from . import cli # noqa: E402
import config as cfg # noqa: E402 from . import config as cfg # noqa: E402
import ggml # noqa: E402 from . import ggml # noqa: E402
import hotkey # noqa: E402 from . import hotkey # noqa: E402
import i18n # noqa: E402 from . import i18n # noqa: E402
import ipc # noqa: E402 from . import ipc # noqa: E402
import meeting # noqa: E402 from . import meeting # noqa: E402
import trayicon # noqa: E402 from . import trayicon # noqa: E402
from i18n import t # noqa: E402 from .i18n import t # noqa: E402
from meeting import MeetingPipeline # noqa: E402 from .meeting import MeetingPipeline # noqa: E402
from overlay import Overlay # noqa: E402 from .overlay import Overlay # noqa: E402
from settings_ui import SettingsWindow # noqa: E402 from .settings_ui import SettingsWindow # noqa: E402
from worker import Pipeline # noqa: E402 from .worker import Pipeline # noqa: E402
SERVER_NAME = ipc.SERVER_NAME SERVER_NAME = ipc.SERVER_NAME
IDLE, RECORDING, BUSY = "idle", "recording", "busy" IDLE, RECORDING, BUSY = "idle", "recording", "busy"
@@ -84,6 +85,14 @@ class Dikte:
self.ask_state = IDLE self.ask_state = IDLE
# Which of the two the microphone is currently serving, or None. # Which of the two the microphone is currently serving, or None.
self.recorder_owner = None self.recorder_owner = None
# A recording that is running but taking nothing in. Not a state of its
# own: everything that can be done to a recording can be done to a
# paused one, and a fourth state would have to say so four times over.
self.paused = False
# Paused time, which is time the recording does not have: the clock on
# screen and the limit both go by what was actually captured.
self._paused_ms = 0
self._paused_at = 0
self.meeting_state = M_IDLE self.meeting_state = M_IDLE
self.meeting_base = "" self.meeting_base = ""
self.meeting_message = "" self.meeting_message = ""
@@ -163,6 +172,12 @@ class Dikte:
self.toggle_action.triggered.connect(self._toggle) self.toggle_action.triggered.connect(self._toggle)
self.menu.addAction(self.toggle_action) self.menu.addAction(self.toggle_action)
# Named in _refresh_tray as well, since it says one of two things.
self.pause_action = QAction(t("Pause the recording"), self.menu)
self.pause_action.triggered.connect(self._toggle_pause)
self.pause_action.setEnabled(False)
self.menu.addAction(self.pause_action)
# Named in _refresh_tray, which is where the chosen provider is known. # Named in _refresh_tray, which is where the chosen provider is known.
self.ask_action = QAction("", self.menu) self.ask_action = QAction("", self.menu)
self.ask_action.triggered.connect(self._toggle_ask) self.ask_action.triggered.connect(self._toggle_ask)
@@ -287,6 +302,9 @@ class Dikte:
or (self.ask_state == IDLE and not self.recording) or (self.ask_state == IDLE and not self.recording)
) )
self.reset_action.setEnabled(self.ask_state != BUSY) self.reset_action.setEnabled(self.ask_state != BUSY)
self.pause_action.setText(t("Resume the recording") if self.paused
else t("Pause the recording"))
self.pause_action.setEnabled(self.recording)
self.cancel_action.setEnabled(self.recording) self.cancel_action.setEnabled(self.recording)
# A command to the agent is the one job long enough to be worth calling # A command to the agent is the one job long enough to be worth calling
# off once it is already running. # off once it is already running.
@@ -303,6 +321,12 @@ class Dikte:
else: else:
icon, tip = "view-refresh", "Dikte: talking to Claude" icon, tip = "view-refresh", "Dikte: talking to Claude"
# Whichever of the two is holding the microphone, a recording dot that
# keeps burning while nothing goes in is the icon telling the opposite
# of what is happening.
if self.paused and self.recording:
icon, tip = "media-playback-pause", "Dikte: paused"
meeting_labels = { meeting_labels = {
M_IDLE: "Record a meeting", M_IDLE: "Record a meeting",
M_RECORDING: "End the meeting and write it up", M_RECORDING: "End the meeting and write it up",
@@ -338,13 +362,16 @@ class Dikte:
def toggle_meeting(self): def toggle_meeting(self):
self._external("meeting", self._toggle_meeting) self._external("meeting", self._toggle_meeting)
def toggle_pause(self):
self._external("pause", self._toggle_pause)
def cancel(self): def cancel(self):
self._external("cancel", self._cancel) self._external("cancel", self._cancel)
def _external(self, name, handler): def _external(self, name, handler):
# The built-in listener sees the key press the instant it happens, so a # The built-in listener sees the key press the instant it happens, so a
# toggle arriving right behind one is the KDE shortcut catching up on # toggle arriving right behind one is the desktop's own shortcut catching
# that same press. Its lateness is also the proof we were waiting for # up on 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 # that the shortcut is live, which leaves the listener with nothing to
# do but double every press. # do but double every press.
# Where nothing was installed there is no shortcut to catch up, and # Where nothing was installed there is no shortcut to catch up, and
@@ -362,7 +389,7 @@ class Dikte:
timer = self.last_evdev[name] = QElapsedTimer() timer = self.last_evdev[name] = QElapsedTimer()
timer.restart() timer.restart()
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask, handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
"cancel": self._cancel} "cancel": self._cancel, "pause": self._toggle_pause}
handlers.get(name, self._toggle)() handlers.get(name, self._toggle)()
def _retire_listener(self): def _retire_listener(self):
@@ -397,6 +424,7 @@ class Dikte:
else: else:
handler = { handler = {
"cancel": self.cancel, "cancel": self.cancel,
"pause": self.toggle_pause,
"ask-cancel": self.cancel_ask, "ask-cancel": self.cancel_ask,
"ask-reset": self.reset_conversation, "ask-reset": self.reset_conversation,
"meeting-cancel": self.cancel_meeting, "meeting-cancel": self.cancel_meeting,
@@ -479,6 +507,7 @@ class Dikte:
"ok": True, "ok": True,
"running": True, "running": True,
"dictation": self.state, "dictation": self.state,
"paused": self.paused,
"ask": self.ask_state, "ask": self.ask_state,
"meeting": self.meeting_state, "meeting": self.meeting_state,
"meeting_base": self.meetings.running_base, "meeting_base": self.meetings.running_base,
@@ -542,6 +571,7 @@ class Dikte:
"""One microphone, so one of the two holds it at a time.""" """One microphone, so one of the two holds it at a time."""
self.recorder_owner = owner self.recorder_owner = owner
self._run_id += 1 self._run_id += 1
self._clear_pause()
self.elapsed.restart() self.elapsed.restart()
self.ticker.start() self.ticker.start()
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
@@ -550,6 +580,7 @@ class Dikte:
if self.state != RECORDING: if self.state != RECORDING:
return return
self.ticker.stop() self.ticker.stop()
self._clear_pause()
self._set_state(BUSY) self._set_state(BUSY)
self.overlay.show_busy(t("Transcribing…")) self.overlay.show_busy(t("Transcribing…"))
self.recorder.stop() self.recorder.stop()
@@ -558,16 +589,50 @@ class Dikte:
if self.ask_state != RECORDING: if self.ask_state != RECORDING:
return return
self.ticker.stop() self.ticker.stop()
self._clear_pause()
self._set_ask_state(BUSY) self._set_ask_state(BUSY)
self.ask_overlay.show_busy(t("Transcribing…")) self.ask_overlay.show_busy(t("Transcribing…"))
self.recorder.stop() self.recorder.stop()
def _toggle_pause(self):
"""Hold the recording where it is, or take it up again.
A pause is not a stop: the microphone stays ours and what has been said
so far stays in the buffer. What is said while it is held is dropped, so
the phone call in the middle of a dictation never reaches the model and
the sentence around it is still one sentence.
"""
if not self.recording or self._repeated():
return
self.paused = not self.paused
if self.paused:
self._paused_at = self.elapsed.elapsed()
else:
self._paused_ms += self.elapsed.elapsed() - self._paused_at
self.recorder.pause(self.paused)
self._recording_overlay().set_paused(self.paused)
self._refresh_tray()
def _clear_pause(self):
"""Every recording starts and ends taking sound in."""
self.paused = False
self._paused_ms = 0
self._paused_at = 0
def _recorded_seconds(self):
"""Wall clock less whatever was held: the length of what will be
transcribed, which is what the limit has to be measured against too."""
# A held recording is as long now as it was when it was held.
now = self._paused_at if self.paused else self.elapsed.elapsed()
return max(0.0, (now - self._paused_ms) / 1000.0)
def _cancel(self): def _cancel(self):
"""Throw away whichever recording is running.""" """Throw away whichever recording is running."""
if not self.recording: if not self.recording:
return return
asking = self.ask_state == RECORDING asking = self.ask_state == RECORDING
self.ticker.stop() self.ticker.stop()
self._clear_pause()
self.recorder.cancel() self.recorder.cancel()
self.recorder_owner = None self.recorder_owner = None
# What goes over the socket is read by a program as often as by a # What goes over the socket is read by a program as often as by a
@@ -607,7 +672,7 @@ class Dikte:
self._recording_overlay().push_level(level) self._recording_overlay().push_level(level)
def _tick(self): def _tick(self):
seconds = self.elapsed.elapsed() / 1000.0 seconds = self._recorded_seconds()
self._recording_overlay().set_seconds(seconds) self._recording_overlay().set_seconds(seconds)
if seconds >= self.conf["max_seconds"]: if seconds >= self.conf["max_seconds"]:
(self.stop_ask if self.recorder_owner == ASK else self.stop)() (self.stop_ask if self.recorder_owner == ASK else self.stop)()
@@ -1058,6 +1123,12 @@ def run_app(args):
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("Dikte") app.setApplicationName("Dikte")
app.setDesktopFileName("dikte") app.setDesktopFileName("dikte")
# Wayland goes from that name to the .desktop file and takes the icon from
# there, and macOS takes it from the bundle, but an X11 window has only what
# it carries itself, and a settings window with no icon is a blank square in
# every task bar and alt-tab list.
if sys.platform != "darwin":
app.setWindowIcon(trayicon.app_icon())
app.setQuitOnLastWindowClosed(False) app.setQuitOnLastWindowClosed(False)
_stay_out_of_the_dock() _stay_out_of_the_dock()
# Before Dikte is built, because building it is what may start a server, and # Before Dikte is built, because building it is what may start a server, and
@@ -1128,7 +1199,3 @@ def run_app(args):
QTimer.singleShot(0, dikte.toggle_meeting) QTimer.singleShot(0, dikte.toggle_meeting)
return app.exec() return app.exec()
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -29,9 +29,9 @@ import subprocess
import threading import threading
import time import time
import api from . import api
import config as cfg from . import config as cfg
from i18n import t from .i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json" SESSION_FILE = cfg.DATA_DIR / "assistant.json"
PROVIDERS = ("claude", "codex", "openrouter") PROVIDERS = ("claude", "codex", "openrouter")
+26 -1
View File
@@ -31,7 +31,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t from .i18n import t
# Console programs started from a windowless process would otherwise each open # Console programs started from a windowless process would otherwise each open
# a console window of their own on Windows. # a console window of their own on Windows.
@@ -89,12 +89,31 @@ class Recorder(QObject):
self._rms = [] self._rms = []
self._cancelled = False self._cancelled = False
self._stopping = False self._stopping = False
self._paused = False
self._lock = threading.Lock() self._lock = threading.Lock()
@property @property
def active(self): def active(self):
return self._thread is not None and self._thread.is_alive() return self._thread is not None and self._thread.is_alive()
@property
def paused(self):
return self._paused
def pause(self, value=True):
"""Stop taking sound in without letting go of the microphone.
The capture program keeps running and keeps handing blocks over; they
are dropped as they arrive rather than kept. Stopping it instead would
mean asking the sound server for the device again on the way back, and
that is the one moment another application can take it: a recording
would be lost to the phone call it was paused for.
What was said while it was paused is gone, which is the point. The two
halves meet as one splice, with none of the room in between.
"""
self._paused = bool(value)
def start(self, target="", max_seconds=300): def start(self, target="", max_seconds=300):
if self.active: if self.active:
return return
@@ -120,6 +139,7 @@ class Recorder(QObject):
self._rms = [] self._rms = []
self._cancelled = False self._cancelled = False
self._stopping = False self._stopping = False
self._paused = False
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS) self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
self._thread = threading.Thread(target=self._pump, daemon=True) self._thread = threading.Thread(target=self._pump, daemon=True)
self._thread.start() self._thread.start()
@@ -132,6 +152,11 @@ class Recorder(QObject):
chunk = stdout.read(CHUNK_BYTES) chunk = stdout.read(CHUNK_BYTES)
if not chunk: if not chunk:
break break
if self._paused:
# Read and thrown away rather than left in the pipe: a pipe
# nobody empties fills up, and the capture program blocks on
# a full one instead of waiting quietly for the resume.
continue
peak, rms = chunk_levels(chunk) peak, rms = chunk_levels(chunk)
with self._lock: with self._lock:
self._buffer.extend(chunk) self._buffer.extend(chunk)
+4 -4
View File
@@ -18,10 +18,10 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import api from . import api
import assistant from . import assistant
import ggml from . import ggml
from i18n import t from .i18n import t
PROVIDERS = ("openrouter", "local", "claude", "codex") PROVIDERS = ("openrouter", "local", "claude", "codex")
+20 -15
View File
@@ -23,28 +23,28 @@ import time
from PyQt6.QtCore import QCoreApplication, QTimer from PyQt6.QtCore import QCoreApplication, QTimer
import api from . import api
import assistant from . import assistant
import audio from . import audio
import cleanup from . import cleanup
import config as cfg from . import config as cfg
import filetranscribe from . import filetranscribe
import hotkey from . import hotkey
import ipc from . import ipc
import meeting from . import meeting
import paste from . import paste
NOT_RUNNING = 3 NOT_RUNNING = 3
# Verbs that start the application when none is running, which is what the KDE # Verbs that start the application when none is running, which is what a
# shortcut has always relied on: press the key on a fresh login and Dikte comes # shortcut registered with the desktop has always relied on: press the key on a
# up recording. # fresh login and Dikte comes up recording.
GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
# Asking a process that is not there to stop, cancel or quit is not a failure; # Asking a process that is not there to stop, cancel or quit is not a failure;
# it is already in the state that was asked for. # it is already in the state that was asked for.
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel", IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
"ask-reset", "meeting-cancel"} "ask-reset", "meeting-cancel", "pause"}
_app = None _app = None
@@ -782,7 +782,8 @@ def cmd_status(opts):
return fail(opts, "the running instance is from before it could answer " return fail(opts, "the running instance is from before it could answer "
"questions; reload it with: dikte restart", 1, running=True) "questions; reload it with: dikte restart", 1, running=True)
lines = [ lines = [
f"dictation: {reply.get('dictation', '?')}", f"dictation: {reply.get('dictation', '?')}"
+ (" (paused)" if reply.get("paused") else ""),
f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})", f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})",
f"meeting: {reply.get('meeting', '?')}" f"meeting: {reply.get('meeting', '?')}"
+ (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""), + (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""),
@@ -907,6 +908,10 @@ def build_parser():
page.add_argument("--timeout", type=float, default=0) page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_toggle) page.set_defaults(func=cmd_toggle)
# One verb for both halves, the way `toggle` is one verb: a key can only be
# pressed, so a key that resumed nothing would need a second key.
leaf(subs, "pause", "hold the recording, or take it up again"
).set_defaults(func=cmd_plain)
leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel) leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel)
# --- the agent -------------------------------------------------------- # --- the agent --------------------------------------------------------
+10 -6
View File
@@ -6,12 +6,12 @@ import json
import os import os
import sys import sys
import api from . import api
import ggml from . import ggml
import i18n from . import i18n
import paste from . import paste
import paths from . import paths
from i18n import t from .i18n import t
_MACOS = sys.platform == "darwin" _MACOS = sys.platform == "darwin"
@@ -435,6 +435,10 @@ DEFAULTS = {
# trick lands on the toggle, Alt and Option being one key, so discarding # trick lands on the toggle, Alt and Option being one key, so discarding
# gets a letter instead. # gets a letter instead.
"cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space", "cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space",
# Empty -> tray only. Holding a recording is not something a keyboard has a
# habit for, and a combination nobody asked for is one taken away from
# whatever else was using it.
"pause_shortcut": "",
"evdev_hotkey": False, "evdev_hotkey": False,
"overlay_corner": "bottom-left", "overlay_corner": "bottom-left",
"keep_audio": False, "keep_audio": False,
@@ -23,10 +23,10 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
import api from . import api
import cleanup from . import cleanup
import ggml from . import ggml
from i18n import t from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less
+3 -3
View File
@@ -45,9 +45,9 @@ import urllib.error
import urllib.request import urllib.request
import zipfile import zipfile
import hub from . import hub
import paths from . import paths
from i18n import t from .i18n import t
HOST = "127.0.0.1" HOST = "127.0.0.1"
# The path api.py asks for, so its URL and the server's line up. # The path api.py asks for, so its URL and the server's line up.
+113 -41
View File
@@ -1,11 +1,16 @@
"""Global shortcuts: the desktop's own registry, plus a listener of our own. """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 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 to be told about it, and something has to be listening. Two desktops keep a
desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of registry we can write into and read back, and something outside Dikte acts on
/dev/input for the wait until the registry is live. macOS has no registry to it: KDE has a file, GNOME has gsettings. There the /dev/input listener only
write into: the application asks Carbon for the combination while it runs, so covers the wait until the registry is live.
there the listener is not a fallback but the whole mechanism.
Everywhere else there is nothing to write into, so Dikte holds the combination
itself for as long as it runs: macOS asks Carbon for it, and every other Linux
session (i3, XFCE, Cinnamon, sway, whatever the session calls itself) leans on
the /dev/input listener. That is not a fallback there, it is the mechanism, so
nothing about installing or removing a registry entry should be offered.
""" """
import ast import ast
@@ -25,10 +30,11 @@ import threading
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t from .i18n import t
DESKTOP_ID = "dikte-toggle.desktop" DESKTOP_ID = "dikte-toggle.desktop"
CANCEL_DESKTOP_ID = "dikte-cancel.desktop" CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
PAUSE_DESKTOP_ID = "dikte-pause.desktop"
MEETING_DESKTOP_ID = "dikte-meeting.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop"
ASK_DESKTOP_ID = "dikte-ask.desktop" ASK_DESKTOP_ID = "dikte-ask.desktop"
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
@@ -39,7 +45,7 @@ GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keyb
Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback") Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
# Every global shortcut in one place, because there are four of them and the # Every global shortcut in one place, because there are five of them and the
# command line, the settings window and the installer each used to carry their # command line, the settings window and the installer each used to carry their
# own copy of the list. `fallback` is what to register when the setting is # own copy of the list. `fallback` is what to register when the setting is
# empty: only the toggle has one, since it is the key the application is # empty: only the toggle has one, since it is the key the application is
@@ -47,6 +53,8 @@ Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fall
SHORTCUTS = { SHORTCUTS = {
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording", "toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
"shortcut", "Ctrl+Space"), "shortcut", "Ctrl+Space"),
"pause": Shortcut("pause", PAUSE_DESKTOP_ID,
"Dikte: pause/resume the recording", "pause_shortcut", ""),
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording", "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
"cancel_shortcut", ""), "cancel_shortcut", ""),
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code", "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
@@ -102,13 +110,23 @@ def parse_shortcut(text):
return mods, key return mods, key
# What the running listener holds. Where there is no registry this is the whole
# of "installed", and it lasts as long as the process does: there is no file,
# and no other program to read one. Written by the listener that is in use,
# read by the status line, so what Settings shows is what is actually being
# listened for.
_REGISTERED = {}
# --- built-in listener ---------------------------------------------------- # --- built-in listener ----------------------------------------------------
class EvdevHotkey(QObject): class EvdevHotkey(QObject):
"""Catches global shortcuts by reading /dev/input directly. """Catches global shortcuts by reading /dev/input directly.
It does not swallow the key; the focused application sees the combination It does not swallow the key; the focused application sees the combination
too. This is the fallback that works before the KDE shortcut goes live. too. On KDE that is the price of not waiting for the next login, and the
registry takes over once it is live. On a desktop with no registry at all
it is the only way the keys arrive, and the price is permanent.
""" """
triggered = pyqtSignal(str) # the name the binding was registered under triggered = pyqtSignal(str) # the name the binding was registered under
@@ -151,6 +169,10 @@ class EvdevHotkey(QObject):
)) ))
return False return False
self._bindings = parsed self._bindings = parsed
for name, shortcut in bindings.items():
spec = SHORTCUTS.get(name)
if spec and shortcut:
_REGISTERED[spec.desktop_id] = shortcut
self._stop.clear() self._stop.clear()
self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True) self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True)
self._thread.start() self._thread.start()
@@ -161,6 +183,7 @@ class EvdevHotkey(QObject):
if self._thread: if self._thread:
self._thread.join(timeout=1.5) self._thread.join(timeout=1.5)
self._thread = None self._thread = None
_REGISTERED.clear()
def _open_devices(self): def _open_devices(self):
fds = [] fds = []
@@ -243,12 +266,6 @@ HOTKEY_PRESSED = 5 # kEventHotKeyPressed
PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard
HOTKEY_ID_PARAMETER = "hkid" 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): def parse_macos_shortcut(text):
"""'Cmd+Space' -> (256, 49), or (None, None) when unusable.""" """'Cmd+Space' -> (256, 49), or (None, None) when unusable."""
@@ -584,6 +601,17 @@ class WinHotkey(QObject):
# --- the desktop's own shortcut ------------------------------------------- # --- the desktop's own shortcut -------------------------------------------
# The five ways a combination can reach Dikte. Everything below asks backend()
# rather than looking at the session itself, so the name shown, the status read
# back, what Install writes and what the installer promises cannot disagree
# about which one this session got.
KDE = "kde"
GNOME = "gnome"
MACOS = "macos"
WINDOWS = "windows"
LISTENER = "listener"
def _macos(): def _macos():
return sys.platform == "darwin" return sys.platform == "darwin"
@@ -592,9 +620,27 @@ def _windows():
return sys.platform == "win32" return sys.platform == "win32"
def _gnome(): def backend():
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() """Which shortcut mechanism this session has.
return "gnome" in desktop and shutil.which("gsettings") is not None
A desktop only counts when the program that writes its registry is
installed too: a GNOME session without gsettings, or a Plasma one without
kwriteconfig6, has nothing we can register into. Anything unrecognised is
the listener's, which is every other Linux desktop and needs nothing from
the session at all.
"""
if _macos():
return MACOS
if _windows():
return WINDOWS
names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":")
names = [name.strip() for name in names if name.strip()]
if any("gnome" in name for name in names) and shutil.which("gsettings"):
return GNOME
if (any("kde" in name or "plasma" in name for name in names)
and shutil.which("kwriteconfig6")):
return KDE
return LISTENER
def _gnome_path(desktop_id): def _gnome_path(desktop_id):
@@ -748,57 +794,80 @@ def installs_shortcuts():
"""Whether this system keeps a shortcut registry to write into. """Whether this system keeps a shortcut registry to write into.
KDE and GNOME do, and something outside Dikte reads it, so the combination KDE and GNOME do, and something outside Dikte reads it, so the combination
survives Dikte being closed. macOS and Windows do not: there is nothing to survives Dikte being closed. macOS, Windows and the plain listener do not:
install, nothing to remove, and Settings should not offer either. there is nothing to install, nothing to remove, and Settings should not
offer either.
""" """
return not _macos() and not _windows() return backend() in (KDE, GNOME)
def shortcut_needs_restart(): def shortcut_needs_restart():
"""Whether an installed shortcut waits for the next login before it works. """Whether an installed shortcut waits for the next login before it works.
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding
up as it is written, and macOS and Windows never had one to write. up as it is written, and the others never had one to write.
""" """
return not _macos() and not _windows() and not _gnome() return backend() == KDE
def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
desktop_id=DESKTOP_ID): desktop_id=DESKTOP_ID):
if _macos() or _windows(): which = backend()
_REGISTERED[desktop_id] = shortcut if which == GNOME:
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
if which == KDE:
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
_REGISTERED[desktop_id] = shortcut
if which in (MACOS, WINDOWS):
return True, t( return True, t(
"Shortcut saved: {shortcut}\nDikte holds this one itself while it " "Shortcut saved: {shortcut}\nDikte holds this one itself while it "
"is running, so it works as soon as the settings are saved.", "is running, so it works as soon as the settings are saved.",
shortcut=shortcut, shortcut=shortcut,
) )
if _gnome(): return True, t(
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) "Shortcut saved: {shortcut}\n{desktop} has no shortcut registry to "
return install_kde_shortcut(shortcut, exec_command, name, desktop_id) "install into, so Dikte listens for this one itself while it is "
"running. It works as soon as the settings are saved.",
shortcut=shortcut, desktop=desktop_name(),
)
def remove_shortcut(desktop_id=DESKTOP_ID): def remove_shortcut(desktop_id=DESKTOP_ID):
if _macos() or _windows(): which = backend()
_REGISTERED.pop(desktop_id, None) if which == GNOME:
elif _gnome():
remove_gnome_shortcut(desktop_id) remove_gnome_shortcut(desktop_id)
else: elif which == KDE:
remove_kde_shortcut(desktop_id) remove_kde_shortcut(desktop_id)
else:
_REGISTERED.pop(desktop_id, None)
def shortcut_status(desktop_id=DESKTOP_ID): def shortcut_status(desktop_id=DESKTOP_ID):
if _macos() or _windows(): which = backend()
return _REGISTERED.get(desktop_id) if which == GNOME:
return (gnome_shortcut_status(desktop_id) if _gnome() return gnome_shortcut_status(desktop_id)
else kde_shortcut_status(desktop_id)) if which == KDE:
return kde_shortcut_status(desktop_id)
return _REGISTERED.get(desktop_id)
def desktop_name(): def desktop_name():
if _macos(): """What to call this session in the interface.
The listener's desktops get the name the session gave itself, so an i3 user
is told about i3 rather than about a KDE that is not running.
"""
which = backend()
if which == MACOS:
return "macOS" return "macOS"
if _windows(): if which == WINDOWS:
return "Windows" return "Windows"
return "GNOME" if _gnome() else "KDE" if which == GNOME:
return "GNOME"
if which == KDE:
return "KDE"
name = os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0].strip()
return name or "This desktop"
# --- KDE ------------------------------------------------------------------ # --- KDE ------------------------------------------------------------------
@@ -882,9 +951,12 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination.""" """Names of other KDE entries bound to the same combination."""
if _macos() or _windows(): if backend() != KDE:
# There is no list to read: both answer the question by refusing the # Nowhere else has a list to read. macOS and Windows answer the question
# registration, which their listeners report when they ask for the key. # by refusing the registration, which their listeners report when they
# ask for the key; the other two would only be reading a file their
# session never looks at, and a leftover one from a Plasma install the
# user has since left would refuse perfectly good combinations.
return [] return []
try: try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8") text = SHORTCUTS_FILE.read_text(encoding="utf-8")
+1 -1
View File
@@ -25,7 +25,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from i18n import t from .i18n import t
GITHUB_API = "https://api.github.com" GITHUB_API = "https://api.github.com"
HF_API = "https://huggingface.co/api" HF_API = "https://huggingface.co/api"
+35
View File
@@ -56,12 +56,15 @@ TR = {
"Start recording": "Kaydı başlat", "Start recording": "Kaydı başlat",
"Stop and transcribe": "Kaydı bitir ve yaz", "Stop and transcribe": "Kaydı bitir ve yaz",
"Working…": "İşleniyor…", "Working…": "İşleniyor…",
"Pause the recording": "Kaydı duraklat",
"Resume the recording": "Kayda devam et",
"Discard the recording": "Kaydı iptal et", "Discard the recording": "Kaydı iptal et",
"Settings…": "Ayarlar…", "Settings…": "Ayarlar…",
"Restart": "Yeniden başlat", "Restart": "Yeniden başlat",
"Quit": "Çık", "Quit": "Çık",
"Dikte: ready": "Dikte: hazır", "Dikte: ready": "Dikte: hazır",
"Dikte: recording": "Dikte: kaydediyor", "Dikte: recording": "Dikte: kaydediyor",
"Dikte: paused": "Dikte: duraklatıldı",
"Dikte: working": "Dikte: işleniyor", "Dikte: working": "Dikte: işleniyor",
# --- overlay / pipeline ------------------------------------------- # --- overlay / pipeline -------------------------------------------
@@ -309,6 +312,8 @@ TR = {
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
"No KDE shortcut installed.": "KDE kısayolu kurulu değil.", "No KDE shortcut installed.": "KDE kısayolu kurulu değil.",
"Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}", "Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}",
"Held by Dikte while it runs: {shortcut}":
"Dikte çalıştığı sürece tutuyor: {shortcut}",
"No global shortcut installed.": "Global kısayol kurulu değil.", "No global shortcut installed.": "Global kısayol kurulu değil.",
"No global shortcut installed. The tray menu starts a meeting too.": "No global shortcut installed. The tray menu starts a meeting too.":
"Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.", "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
@@ -316,7 +321,14 @@ TR = {
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.", "Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
"No global shortcut installed. The tray menu discards it too.": "No global shortcut installed. The tray menu discards it too.":
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.", "Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
"No global shortcut installed. The tray menu holds it too.":
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de duraklatılabilir.",
"Start and stop": "Başlat ve bitir", "Start and stop": "Başlat ve bitir",
"Pause and resume": "Duraklat ve devam et",
"Holds the recording without ending it. Nothing said while it is paused is "
"kept, and the clock stops with it.":
"Kaydı bitirmeden duraklatır. Duraklatıldığı sürede konuşulanlar "
"kaydedilmez, süre sayacı da onunla birlikte durur.",
"Throws the recording away without transcribing it. Works on a dictation " "Throws the recording away without transcribing it. Works on a dictation "
"and on a command for the agent alike, whichever is running.": "and on a command for the agent alike, whichever is running.":
"Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, " "Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
@@ -345,6 +357,23 @@ TR = {
"meantime.": "meantime.":
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey " "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.", "kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"Dikte asks Windows 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 Windows'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"{desktop} keeps no shortcut registry, so Dikte listens for these "
"combinations itself while it is running. Your user has to be able to read "
"/dev/input for that, and the focused application receives the keys as "
"well. To have the desktop own them instead, bind this command in its own "
"configuration, with the last word swapped for pause, cancel, ask or "
"meeting:":
"{desktop} kısayol kaydı tutmaz, bu yüzden Dikte bu kombinasyonları "
"çalıştığı sürece kendisi dinler. Bunun için kullanıcının /dev/input'u "
"okuyabilmesi gerekir, ayrıca tuşlar odaktaki uygulamaya da iletilir. "
"Tuşları masaüstünün sahiplenmesini istersen, son kelimeyi pause, "
"cancel, ask veya meeting ile değiştirerek şu komutu kendi "
"yapılandırmasında bir tuşa bağla:",
"Shortcut conflict": "Kısayol çakışması", "Shortcut conflict": "Kısayol çakışması",
"{shortcut} is also used by:\n\n{list}\n\nInstall anyway?": "{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?", "{shortcut} şu girdilerde de kullanılıyor:\n\n{list}\n\nYine de kurulsun mu?",
@@ -361,6 +390,12 @@ TR = {
"running, so it works as soon as the settings are saved.": "running, so it works as soon as the settings are saved.":
"Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi " "Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi "
"tutar, yani ayarlar kaydedilir kaydedilmez çalışır.", "tutar, yani ayarlar kaydedilir kaydedilmez çalışır.",
"Shortcut saved: {shortcut}\n{desktop} has no shortcut registry to install "
"into, so Dikte listens for this one itself while it is running. It works "
"as soon as the settings are saved.":
"Kısayol kaydedildi: {shortcut}\n{desktop} kurulacak bir kısayol kaydı "
"tutmadığı için Dikte bunu çalıştığı sürece kendisi dinler. Ayarlar "
"kaydedilir kaydedilmez çalışır.",
"Could not reach the macOS shortcut service: {error}": "Could not reach the macOS shortcut service: {error}":
"macOS kısayol servisine ulaşılamadı: {error}", "macOS kısayol servisine ulaşılamadı: {error}",
"macOS would not give Dikte {shortcut}; another application already holds it.": "macOS would not give Dikte {shortcut}; another application already holds it.":
+11 -2
View File
@@ -24,13 +24,22 @@ CONNECT_MS = 800
def script_path(): def script_path():
"""The package entry point, as a path.
A shortcut and a relaunch both start a second process, and neither has a
working directory to run `-m dikte` from, so the file is named outright.
"""
return os.path.realpath( return os.path.realpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "dikte.py") os.path.join(os.path.dirname(os.path.abspath(__file__)), "__main__.py")
) )
def command_for(verb): def command_for(verb):
"""The command line a KDE shortcut runs for one of the verbs.""" """The command line a desktop's shortcut runs for one of the verbs.
Also what Settings shows an i3 or XFCE user to paste into their own
configuration, since there is no registry there for Dikte to write into.
"""
return f"{sys.executable} {script_path()} {verb}" return f"{sys.executable} {script_path()} {verb}"
+7 -7
View File
@@ -24,13 +24,13 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
import api from . import api
import cleanup from . import cleanup
import config as cfg from . import config as cfg
import filetranscribe from . import filetranscribe
import vad from . import vad
from filetranscribe import Cancelled, format_timestamp from .filetranscribe import Cancelled, format_timestamp
from i18n import t from .i18n import t
# Where the document stops being prose and starts being the transcript. It is a # Where the document stops being prose and starts being the transcript. It is a
# comment, so it never shows up in a rendered document, and it is what a retry # comment, so it never shows up in a rendered document, and it is what a retry
+39 -4
View File
@@ -26,6 +26,9 @@ WARN = QColor(240, 180, 80)
THEM = QColor(110, 190, 255) # the other side of a meeting THEM = QColor(110, 190, 255) # the other side of a meeting
ASK = QColor(150, 140, 255) # recording a command rather than a dictation ASK = QColor(150, 140, 255) # recording a command rather than a dictation
# Recording, but nothing is going in. The same amber a warning gets, and for
# the same reason: it is the colour that stops you walking away from it.
HELD = WARN
STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
"done": OK, "warning": WARN, "error": ERR} "done": OK, "warning": WARN, "error": ERR}
@@ -47,6 +50,10 @@ class Overlay(QWidget):
self.dismissable = dismissable self.dismissable = dismissable
self.muted = False self.muted = False
self._stacked = False self._stacked = False
# A pause is not a state of its own: what is on screen is still the
# recording, held. Keeping it beside the state is what lets the ribbon
# stay where the pause found it instead of being cleared and rebuilt.
self.paused = False
self.state = "idle" self.state = "idle"
self.message = "" self.message = ""
self.levels = [0.0] * BARS self.levels = [0.0] * BARS
@@ -103,6 +110,7 @@ class Overlay(QWidget):
self.message = "" self.message = ""
self.seconds = 0.0 self.seconds = 0.0
self.levels = [0.0] * BARS self.levels = [0.0] * BARS
self.paused = False
self.muted = False # a new run starts visible, whatever the last one did self.muted = False # a new run starts visible, whatever the last one did
self._hide_timer.stop() self._hide_timer.stop()
self._appear() self._appear()
@@ -114,6 +122,7 @@ class Overlay(QWidget):
self.seconds = 0.0 self.seconds = 0.0
self.levels = [0.0] * BARS self.levels = [0.0] * BARS
self.levels2 = [0.0] * BARS self.levels2 = [0.0] * BARS
self.paused = False
self._hide_timer.stop() self._hide_timer.stop()
self._appear() self._appear()
@@ -175,6 +184,17 @@ class Overlay(QWidget):
def set_seconds(self, seconds): def set_seconds(self, seconds):
self.seconds = seconds self.seconds = seconds
def set_paused(self, paused):
"""Held, or taking sound in again.
Everything about the ribbon says a recording is running: a dot that
pulses, bars that move, a clock that counts. A pause that only stopped
the sound would leave all three saying the words are still going in, so
it is the ribbon that has to say otherwise.
"""
self.paused = bool(paused)
self.update()
# ---- internals ----------------------------------------------------- # ---- internals -----------------------------------------------------
def _appear(self): def _appear(self):
@@ -244,11 +264,11 @@ class Overlay(QWidget):
# the corner when it does rather than leaving a gap where it was. # the corner when it does rather than leaving a gap where it was.
if self.below is not None and self.below.showing != self._stacked: if self.below is not None and self.below.showing != self._stacked:
self._reposition() self._reposition()
if self.state in LIVE: if self.state in LIVE and not self.paused:
# keep the ribbon moving even through a pause in speech # keep the ribbon moving even through a pause in speech
self.levels = self.levels[1:] + [self.levels[-1] * 0.72] self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
if self.state == "meeting": if self.state == "meeting":
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72] self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
self.update() self.update()
def _label_font(self): def _label_font(self):
@@ -273,6 +293,8 @@ class Overlay(QWidget):
painter.drawPath(path) painter.drawPath(path)
accent = STATE_COLORS.get(self.state, MUTED) accent = STATE_COLORS.get(self.state, MUTED)
if self._held:
accent = HELD
self._draw_indicator(painter, accent) self._draw_indicator(painter, accent)
if self.state in LIVE: if self.state in LIVE:
@@ -283,10 +305,23 @@ class Overlay(QWidget):
if self._can_dismiss: if self._can_dismiss:
self._draw_dismiss(painter) self._draw_dismiss(painter)
@property
def _held(self):
"""A recording that is paused. Nothing else can be."""
return self.paused and self.state in LIVE
def _draw_indicator(self, painter, accent): def _draw_indicator(self, painter, accent):
cx, cy = 26.0, self.height() / 2 cx, cy = 26.0, self.height() / 2
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
if self.state in LIVE: if self._held:
# The two bars everything that plays sound uses, and no glow: a
# pulse is what says a recording is live.
painter.setBrush(accent)
for offset in (-4.4, 1.4):
painter.drawRoundedRect(
QRectF(cx + offset, cy - 6.5, 3.0, 13.0), 1.2, 1.2
)
elif self.state in LIVE:
pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6)) pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6))
glow = QColor(accent) glow = QColor(accent)
glow.setAlphaF(0.22 * pulse) glow.setAlphaF(0.22 * pulse)
+1 -1
View File
@@ -19,7 +19,7 @@ import sys
import tempfile import tempfile
import time import time
from i18n import t from .i18n import t
# Linux input event codes (linux/input-event-codes.h), which is what ydotool # Linux input event codes (linux/input-event-codes.h), which is what ydotool
# takes. They are also the list of keys a paste shortcut may be built from, so # takes. They are also the list of keys a paste shortcut may be built from, so
View File
+171 -54
View File
@@ -4,28 +4,28 @@ import os
import shutil import shutil
import threading import threading
from PyQt6.QtCore import Qt, QUrl, pyqtSignal from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
) )
import api from . import api
import assistant from . import assistant
import audio from . import audio
import cleanup from . import cleanup
import config as cfg from . import config as cfg
import filetranscribe from . import filetranscribe
import ggml from . import ggml
import hotkey from . import hotkey
import ipc from . import ipc
import meeting from . import meeting
import paste from . import paste
from filetranscribe import FileTranscriber from .filetranscribe import FileTranscriber
from i18n import t from .i18n import t
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [ LANGUAGES = [
@@ -127,6 +127,58 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi") "*.mp4 *.mkv *.webm *.mov *.avi")
class WrappedLabel(QLabel):
"""A label that wraps, and keeps the height the wrapping calls for.
Word wrap on its own only decides where the lines break. The height comes
from the layout, which asks once, before the width is settled, and a label
sharing a row with a button is answered as if one line were enough: a long
program path then has its second line cut off. Claiming the height back as
a minimum, once the width is known, is what keeps the whole text on screen.
"""
def __init__(self, text="", parent=None):
super().__init__(text, parent)
self.setWordWrap(True)
def setText(self, text):
super().setText(text)
self._fit()
def resizeEvent(self, event):
super().resizeEvent(event)
self._fit()
def _fit(self):
# Measured off the font rather than asked of the label, whose own answer
# is floored by the minimum set here a moment ago and so only ever grows.
if self.width() > 0:
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
box = QRect(0, 0, self.width(), 0)
self.setMinimumHeight(
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
class WheelGuard(QObject):
"""Keeps a rolled wheel off the box the pointer only passed over.
A combo box and a spin box both read the wheel as a change of value, and
every tab scrolls now: rolling down the API tab with the pointer over the
model box would pick a different model on the way past, and the setting is
saved without anybody having chosen it. The wheel counts once the box has
been clicked into; before that it is handed back to the page underneath,
which is what the roll was for.
"""
def eventFilter(self, box, event):
if event.type() == QEvent.Type.Wheel and not box.hasFocus():
# Refused rather than swallowed. An unaccepted wheel event carries
# on up the parents to the scroll area, so the page still moves.
event.ignore()
return True
return super().eventFilter(box, event)
class LocalModelBox(QGroupBox): class LocalModelBox(QGroupBox):
"""The program, the model, and the two downloads that put them there. """The program, the model, and the two downloads that put them there.
@@ -160,8 +212,7 @@ class LocalModelBox(QGroupBox):
form = QFormLayout(self) form = QFormLayout(self)
self.program_label = QLabel("") self.program_label = WrappedLabel()
self.program_label.setWordWrap(True)
self.install_button = QPushButton(t("Download")) self.install_button = QPushButton(t("Download"))
self.install_button.clicked.connect(self._install_program) self.install_button.clicked.connect(self._install_program)
form.addRow(t("Program"), self._side_by_side(self.program_label, form.addRow(t("Program"), self._side_by_side(self.program_label,
@@ -484,18 +535,22 @@ class SettingsWindow(QDialog):
self._shown_provider = "" self._shown_provider = ""
self.transcriber = FileTranscriber(conf, self) self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings")) self.setWindowTitle(t("Dikte Settings"))
self.resize(680, 640)
# One for the whole window, parented to it so it outlives the boxes it
# watches and goes when they do.
self._wheel_guard = WheelGuard(self)
tabs = self.tabs = QTabWidget(self) tabs = self.tabs = QTabWidget(self)
tabs.addTab(self._general_tab(), t("General")) tabs.addTab(self._scrolled(self._general_tab()), t("General"))
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models")) self.api_tab_index = tabs.addTab(
tabs.addTab(self._prompt_tab(), t("Cleanup rules")) self._scrolled(self._api_tab()), t("API and models"))
tabs.addTab(self._assistant_tab(), t("Agent")) tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules"))
tabs.addTab(self._meeting_tab(), t("Meeting")) tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent"))
tabs.addTab(self._minutes_tab(), t("Minutes")) tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting"))
tabs.addTab(self._file_tab(), t("Audio file")) tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes"))
tabs.addTab(self._shortcut_tab(), t("Shortcuts")) tabs.addTab(self._scrolled(self._file_tab()), t("Audio file"))
tabs.addTab(self._history_tab(), t("History")) tabs.addTab(self._scrolled(self._shortcut_tab()), t("Shortcuts"))
tabs.addTab(self._scrolled(self._history_tab()), t("History"))
# Save keeps the window open, so the window is closed with the titlebar # Save keeps the window open, so the window is closed with the titlebar
# cross (or Escape) instead. A "Cancel" next to it would be a lie: the # cross (or Escape) instead. A "Cancel" next to it would be a lie: the
@@ -507,6 +562,7 @@ class SettingsWindow(QDialog):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget(tabs) layout.addWidget(tabs)
layout.addWidget(buttons) layout.addWidget(buttons)
self._size_to_screen(680, 640)
self._models_loaded.connect(self._on_models_loaded) self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
@@ -528,6 +584,40 @@ class SettingsWindow(QDialog):
if not conf.transcribe_ready(): if not conf.transcribe_ready():
self.tabs.setCurrentIndex(self.api_tab_index) self.tabs.setCurrentIndex(self.api_tab_index)
def _scrolled(self, page):
"""A tab that scrolls instead of growing the window to fit."""
# Every tab goes through here. A page kept at its full height passes
# that height on as the window's minimum, and a tall one (the API tab
# is the tallest, and taller still under a large interface font) then
# pushes Save off the bottom of the screen with no way to shrink back.
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QScrollArea.Shape.NoFrame)
area.setWidget(page)
for box in page.findChildren((QComboBox, QAbstractSpinBox)):
# Focus by click or by tab, not by wheel. Qt hands the focus over
# before it delivers the wheel, so a box left on the default policy
# would have it by the time the guard below asked.
box.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
box.installEventFilter(self._wheel_guard)
return area
def _size_to_screen(self, width, height):
"""Open at this size, or at whatever the screen has room for."""
screen = self.screen() or QGuiApplication.primaryScreen()
if screen is not None:
room = screen.availableGeometry()
# Room for the titlebar and a little air, so the window is grabbable
# and the buttons along the bottom stay on screen.
width = min(width, room.width() - 40)
height = min(height, room.height() - 80)
# Scrolling tabs ask for no height of their own, which leaves nothing to
# stop the window being dragged down to a tab bar and half a button. The
# floor is a floor and not a demand: it never asks for more room than
# the screen has just been found to have.
self.setMinimumSize(min(520, width), min(380, height))
self.resize(width, height)
# ---- tabs ---------------------------------------------------------- # ---- tabs ----------------------------------------------------------
def _general_tab(self): def _general_tab(self):
@@ -975,12 +1065,7 @@ class SettingsWindow(QDialog):
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt()) lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
) )
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight) layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
return page
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QScrollArea.Shape.NoFrame)
area.setWidget(page)
return area
def _meeting_tab(self): def _meeting_tab(self):
page = QWidget() page = QWidget()
@@ -1119,14 +1204,7 @@ class SettingsWindow(QDialog):
lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt()) lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt())
) )
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight) layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
return page
# Everything above is more than one screenful; let it scroll rather than
# squeezing the prompt box down to nothing.
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QScrollArea.Shape.NoFrame)
area.setWidget(page)
return area
def _minutes_tab(self): def _minutes_tab(self):
page = QWidget() page = QWidget()
@@ -1255,6 +1333,14 @@ class SettingsWindow(QDialog):
form, "toggle", t("Start and stop"), form, "toggle", t("Start and stop"),
t("No global shortcut installed."), placeholder="Ctrl+Space", t("No global shortcut installed."), placeholder="Ctrl+Space",
) )
# The point of holding a recording is that something else came up, and
# something else is exactly when a hand is not free for a menu.
self._shortcut_row(
form, "pause", t("Pause and resume"),
t("No global shortcut installed. The tray menu holds it too."),
tooltip=t("Holds the recording without ending it. Nothing said "
"while it is paused is kept, and the clock stops with it."),
)
# Stopping is what sends the recording off to be transcribed, and that # Stopping is what sends the recording off to be transcribed, and that
# is the step there is no taking back. By the time the tray menu is # is the step there is no taking back. By the time the tray menu is
# open the sentence you did not mean to dictate is already on its way. # open the sentence you did not mean to dictate is already on its way.
@@ -1289,15 +1375,40 @@ class SettingsWindow(QDialog):
) )
elif hotkey.installs_shortcuts(): elif hotkey.installs_shortcuts():
explanation = t("The shortcut starts working as soon as it is installed.") explanation = t("The shortcut starts working as soon as it is installed.")
else: elif hotkey.backend() == hotkey.MACOS:
explanation = t( explanation = t(
"Dikte asks macOS for these combinations itself, while it is " "Dikte asks macOS for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives " "running. Nothing is installed, and no other application receives "
"them in the meantime." "them in the meantime."
) )
elif hotkey.backend() == hotkey.WINDOWS:
explanation = t(
"Dikte asks Windows for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
else:
# The desktops nobody writes a backend for. Saying "installed" here
# would be the old bug in words: there is no registry, the listener
# is the whole mechanism, and both of its costs are permanent
# rather than lasting until the next login.
explanation = t(
"{desktop} keeps no shortcut registry, so Dikte listens for "
"these combinations itself while it is running. Your user has "
"to be able to read /dev/input for that, and the focused "
"application receives the keys as well. To have the desktop own "
"them instead, bind this command in its own configuration, with "
"the last word swapped for pause, cancel, ask or meeting:",
desktop=hotkey.desktop_name(),
)
note = QLabel(explanation) note = QLabel(explanation)
note.setWordWrap(True) note.setWordWrap(True)
layout.addWidget(note) layout.addWidget(note)
if hotkey.backend() == hotkey.LISTENER:
command = QLineEdit(ipc.command_for("toggle"))
command.setReadOnly(True)
command.setCursorPosition(0)
layout.addWidget(command)
layout.addStretch(1) layout.addStretch(1)
return page return page
@@ -1368,7 +1479,7 @@ class SettingsWindow(QDialog):
"""The field a global shortcut is typed or picked in.""" """The field a global shortcut is typed or picked in."""
box = QComboBox() box = QComboBox()
box.setEditable(True) box.setEditable(True)
box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS" box.addItems(MAC_SHORTCUTS if hotkey.backend() == hotkey.MACOS
else SHORTCUTS) else SHORTCUTS)
box.setCurrentText("") box.setCurrentText("")
if placeholder: if placeholder:
@@ -1417,8 +1528,9 @@ class SettingsWindow(QDialog):
def _install_buttons(install_handler, remove_handler): def _install_buttons(install_handler, remove_handler):
"""Install and Remove, where this system has somewhere to install into. """Install and Remove, where this system has somewhere to install into.
macOS has not: Dikte asks for the combination itself while it runs, so macOS has not, and neither has a Linux desktop that keeps no registry:
there is nothing to write down and nothing to take back out. Dikte holds the combination itself while it runs, so there is nothing
to write down and nothing to take back out.
""" """
if not hotkey.installs_shortcuts(): if not hotkey.installs_shortcuts():
return [] return []
@@ -1634,8 +1746,8 @@ class SettingsWindow(QDialog):
conf["file_cleanup"] = self.file_cleanup.isChecked() conf["file_cleanup"] = self.file_cleanup.isChecked()
# Left empty, only the toggle falls back to a default: the application # Left empty, only the toggle falls back to a default: the application
# is unusable without it. The other three stay empty, which is what # is unusable without it. The rest stay empty, which is what turns
# turns them off. # them off.
for which, (box, _status, _missing) in self._shortcut_rows.items(): for which, (box, _status, _missing) in self._shortcut_rows.items():
spec = hotkey.SHORTCUTS[which] spec = hotkey.SHORTCUTS[which]
conf[spec.setting] = (box.currentText().strip() conf[spec.setting] = (box.currentText().strip()
@@ -1916,11 +2028,16 @@ class SettingsWindow(QDialog):
def _refresh_shortcut_status(self, which): def _refresh_shortcut_status(self, which):
_box, status, missing = self._shortcut_rows[which] _box, status, missing = self._shortcut_rows[which]
current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id) current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
status.setText( if not current:
t("Registered in {desktop}: {shortcut}", status.setText(missing)
desktop=hotkey.desktop_name(), shortcut=current) if current elif hotkey.installs_shortcuts():
else missing status.setText(t("Registered in {desktop}: {shortcut}",
) desktop=hotkey.desktop_name(), shortcut=current))
else:
# Nothing was written anywhere: this is the combination the running
# process is holding, which is the only sense in which it exists.
status.setText(t("Held by Dikte while it runs: {shortcut}",
shortcut=current))
def _cleanup_provider_changed(self): def _cleanup_provider_changed(self):
provider = self.cleanup_provider.currentData() or "openrouter" provider = self.cleanup_provider.currentData() or "openrouter"
+323
View File
@@ -0,0 +1,323 @@
"""The four tray icons, drawn here for systems that have no icon theme.
A desktop hands out `audio-input-microphone`, `media-record`, `view-refresh`
and `media-playback-pause` from whatever icon theme is installed, and Qt finds
them through QIcon.fromTheme. Two systems have nothing to hand out. macOS keeps
no such registry at all. And a Linux session that names no desktop, which is
what i3 and a bare X11 login are, leaves Qt with `hicolor` as its only theme,
where none of those four names exist. On both, fromTheme returns a null icon,
and a null icon in a tray is an item you cannot see, which is the whole of
Dikte's interface gone. So the same four shapes are drawn here, and used
whenever the theme has nothing to offer.
On macOS they are template images: one colour, transparent everywhere else,
with isMask set. That is what lets macOS invert them for a dark menu bar and
grey them while the menu is open, and it is why the shapes are outlines rather
than the coloured glyphs a Linux theme would give.
X11 has no such contract. A tray there is given a picture, paints it over
whatever colour the bar happens to be, and never says what that colour is, so
black ink on i3's black bar is an empty slot rather than an icon. The same
shapes are drawn there in white over a dark copy of themselves spread a pixel
outwards, which stands out on a dark bar and stays readable on a light one.
"""
import pathlib
import sys
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap)
# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the
# icon rather than one being scaled, because a scaled stroke goes soft.
SIZES = (22, 44)
# The two inks. macOS is handed the dark one and throws the colour away, keeping
# only the coverage; everywhere else the light one is the glyph and the dark one
# is the outline behind it.
DARK = QColor(0, 0, 0)
LIGHT = QColor(255, 255, 255)
def _canvas(size):
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
return pixmap, painter
def _microphone(painter, size, ink):
"""A capsule on a stand: idle, and the application's own mark.
Every shape below takes its colour rather than reaching for a constant: the
same glyph is drawn dark for the macOS mask, white for the tray on X11, dark
again a pixel out for the outline under it, and white on the blue tile of
the application icon.
"""
unit = size / 22.0
pen = QPen(ink, 1.6 * unit)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(ink)
# The capsule, held away from the edges so the stroke below has room.
painter.drawRoundedRect(
QRectF(8.2 * unit, 3.4 * unit, 5.6 * unit, 10.4 * unit),
2.8 * unit, 2.8 * unit,
)
painter.setBrush(Qt.BrushStyle.NoBrush)
# The arc that cradles it, and the post and foot under that.
painter.drawArc(
QRectF(5.4 * unit, 6.6 * unit, 11.2 * unit, 10.4 * unit),
180 * 16, 180 * 16,
)
painter.drawLine(QPointF(11 * unit, 16.8 * unit), QPointF(11 * unit, 19 * unit))
painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit))
def _record(painter, size, ink):
"""A filled dot: recording, and the same red dot the overlay shows."""
unit = size / 22.0
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ink)
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
def _paused(painter, size, ink):
"""Two bars: the recording is still ours, and nothing is going into it."""
unit = size / 22.0
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ink)
for left in (6.4, 12.4):
painter.drawRoundedRect(
QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit),
1.2 * unit, 1.2 * unit,
)
def _working(painter, size, ink):
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
unit = size / 22.0
pen = QPen(ink, 2.0 * unit)
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
ring = QRectF(4.4 * unit, 4.4 * unit, 13.2 * unit, 13.2 * unit)
# Three quarters of the way round, leaving the gap the head sits in.
painter.drawArc(ring, 90 * 16, -280 * 16)
# The head, as a filled triangle at the open end rather than two more
# strokes: at 22 points a drawn arrowhead closes up into a blob.
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ink)
head = QPainterPath()
head.moveTo(QPointF(11.0 * unit, 1.6 * unit))
head.lineTo(QPointF(11.0 * unit, 7.2 * unit))
head.lineTo(QPointF(15.8 * unit, 4.4 * unit))
head.closeSubpath()
painter.drawPath(head)
# The names Linux themes use, which are what app.py asks for either way.
SHAPES = {
"audio-input-microphone": _microphone,
"media-record": _record,
"media-playback-pause": _paused,
"view-refresh": _working,
}
_cache = {}
def _stencil(shape, size, ink, pad=0):
"""One shape in one colour, held `pad` pixels in from every edge.
The inset is what leaves room for the outline: the shapes are drawn to the
edge of their 22 point square, so a copy shifted outwards would otherwise
lose the foot of the microphone and the tip of the arrow to the crop.
"""
pixmap, painter = _canvas(size)
try:
if pad:
painter.translate(pad, pad)
painter.scale((size - 2 * pad) / size, (size - 2 * pad) / size)
shape(painter, size, ink)
finally:
painter.end()
return pixmap
def _outlined(shape, size):
"""The shape in white, over a dark copy of itself spread a pixel outwards.
Eight shifted copies rather than a blur or a stroked path: the shapes are a
mix of strokes and fills, and this is the one way to put a border round all
of them without drawing each one twice by hand.
"""
pad = max(1, round(size / 22.0))
outline = _stencil(shape, size, DARK, pad)
glyph = _stencil(shape, size, LIGHT, pad)
pixmap, painter = _canvas(size)
try:
for dx in (-pad, 0, pad):
for dy in (-pad, 0, pad):
painter.drawPixmap(dx, dy, outline)
painter.drawPixmap(0, 0, glyph)
finally:
painter.end()
return pixmap
def icon(name):
"""The named icon drawn here, or a null QIcon when it is not one of ours.
Cached because the tray is refreshed on every state change and every one of
those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the
pixmaps inside it are shared, so handing the same object out is safe. The
platform is part of the key rather than settled at import, so that a test
can stand on either one.
"""
shape = SHAPES.get(name)
if shape is None:
return QIcon()
mask = sys.platform == "darwin"
if (name, mask) in _cache:
return _cache[(name, mask)]
result = QIcon()
for size in SIZES:
result.addPixmap(_stencil(shape, size, DARK) if mask
else _outlined(shape, size))
if mask:
# The line that makes it a template image: macOS then owns the colour,
# and the icon follows the menu bar into dark mode instead of staying
# black. Nothing outside macOS reads it, and setting it there would only
# promise a recolouring that never comes.
result.setIsMask(True)
_cache[(name, mask)] = result
return result
# --- the application icon --------------------------------------------------
#
# The menu bar wants a flat stencil; the Finder, the Dock, an application menu
# and a task bar want a picture. Same microphone, on a ground of its own, and
# drawn here as well so that `install-mac.sh` has an .icns and `install.sh` a
# set of PNGs to install without a binary blob living in the repository.
# What iconutil expects to find in an .iconset: each of these at 1x and 2x.
APP_ICON_SIZES = (16, 32, 128, 256, 512)
# What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a
# file dialog 16, and something scaling for a HiDPI panel wants the big ones.
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256)
def app_pixmap(size):
"""The application icon at one size: a white microphone on a blue tile."""
pixmap, painter = _canvas(size)
try:
unit = size / 22.0
# macOS rounds and shadows the tile itself for some icon styles but not
# for a plain .icns, so the shape is drawn: the squircle radius Apple
# uses is close enough to 22% of the side.
ground = QLinearGradient(0, 0, 0, size)
ground.setColorAt(0.0, QColor(0x3B, 0x82, 0xF6))
ground.setColorAt(1.0, QColor(0x1D, 0x4E, 0xD8))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ground)
inset = 1.0 * unit
painter.drawRoundedRect(
QRectF(inset, inset, size - 2 * inset, size - 2 * inset),
4.4 * unit, 4.4 * unit,
)
# The same glyph as the tray, in white and a little smaller so it sits
# inside the tile rather than against its edges.
painter.save()
painter.translate(size / 2.0, size / 2.0)
painter.scale(0.64, 0.64)
painter.translate(-size / 2.0, -size / 2.0)
_microphone(painter, size, LIGHT)
painter.restore()
finally:
painter.end()
return pixmap
_app_icon = None
def app_icon():
"""The application icon as a QIcon, for the windows and whatever lists them.
Wayland reads it off the .desktop file instead, through the desktop file
name the application sets, but X11 has only what the window itself carries.
"""
global _app_icon
if _app_icon is None:
_app_icon = QIcon()
for size in (16, 22, 24, 32, 48, 64, 128):
_app_icon.addPixmap(app_pixmap(size))
return _app_icon
def write_iconset(directory):
"""Write the PNGs `iconutil -c icns` reads. The directory it wrote to."""
directory = pathlib.Path(directory)
directory.mkdir(parents=True, exist_ok=True)
for size in APP_ICON_SIZES:
for scale in (1, 2):
name = f"icon_{size}x{size}{'@2x' if scale == 2 else ''}.png"
app_pixmap(size * scale).save(str(directory / name), "PNG")
return directory
def write_hicolor(directory, name="dikte"):
"""Install the icon into an XDG theme. The paths it wrote.
A .desktop file names its icon rather than carrying a path, and a name is
only found if some installed theme has it. `audio-input-microphone`, which
is what the entries used to name, is in Breeze and in Adwaita but not in
hicolor, and hicolor is all Qt and a panel are left with on a session that
names no desktop. Under a name of our own in hicolor it is found everywhere,
since hicolor is the one theme every desktop is required to fall back to.
"""
directory = pathlib.Path(directory)
written = []
for size in HICOLOR_SIZES:
apps = directory / "hicolor" / f"{size}x{size}" / "apps"
apps.mkdir(parents=True, exist_ok=True)
path = apps / f"{name}.png"
app_pixmap(size).save(str(path), "PNG")
written.append(path)
return written
def _main(argv):
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
install.sh.
A QGuiApplication has to exist before a QPixmap can, and offscreen because
this runs from a shell script with no window to open.
"""
hicolor = len(argv) == 3 and argv[1] == "--hicolor"
if not hicolor and len(argv) != 2:
print("usage: trayicon.py <directory>.iconset\n"
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
return 2
from PyQt6.QtGui import QGuiApplication
QGuiApplication.setAttribute(
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
try:
if hicolor:
for path in write_hicolor(argv[2]):
print(path)
else:
print(write_iconset(argv[1]))
finally:
del app
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv))
View File
+9 -9
View File
@@ -15,15 +15,15 @@ import traceback
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
import api from . import api
import assistant from . import assistant
import audio from . import audio
import cleanup from . import cleanup
import config as cfg from . import config as cfg
import i18n from . import i18n
import paste from . import paste
import vad from . import vad
from i18n import t from .i18n import t
CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE
+6 -3
View File
@@ -11,6 +11,9 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$repo = $PSScriptRoot $repo = $PSScriptRoot
# The one file that starts the application, whoever is asking: the Start Menu
# entry, the autostart entry and the dikte command all name it.
$entry = Join-Path $repo "dikte\__main__.py"
$startMenu = [Environment]::GetFolderPath("Programs") $startMenu = [Environment]::GetFolderPath("Programs")
$startup = [Environment]::GetFolderPath("Startup") $startup = [Environment]::GetFolderPath("Startup")
$shortcut = Join-Path $startMenu "Dikte.lnk" $shortcut = Join-Path $startMenu "Dikte.lnk"
@@ -55,7 +58,7 @@ $shell = New-Object -ComObject WScript.Shell
foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) { foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) {
$link = $shell.CreateShortcut($path) $link = $shell.CreateShortcut($path)
$link.TargetPath = $pythonw $link.TargetPath = $pythonw
$link.Arguments = "`"$repo\dikte.py`" --gui" $link.Arguments = "`"$entry`" --gui"
$link.WorkingDirectory = $repo $link.WorkingDirectory = $repo
$link.Description = "Dikte: dictation" $link.Description = "Dikte: dictation"
$link.Save() $link.Save()
@@ -67,11 +70,11 @@ foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else {
# the one the command line should run, whatever a later PATH change puts first. # the one the command line should run, whatever a later PATH change puts first.
$shimDir = Split-Path $cmdShim $shimDir = Split-Path $cmdShim
if (Test-Path $shimDir) { if (Test-Path $shimDir) {
"@echo off`r`n`"$($python.Source)`" `"$repo\dikte.py`" %*" | "@echo off`r`n`"$($python.Source)`" `"$entry`" %*" |
Out-File $cmdShim -Encoding ascii Out-File $cmdShim -Encoding ascii
Write-Host "command: dikte ($cmdShim)" Write-Host "command: dikte ($cmdShim)"
} else { } else {
Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$repo\dikte.py`"" Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$entry`""
} }
Write-Host "" Write-Host ""
+87 -20
View File
@@ -9,17 +9,35 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# path. That is a second script rather than a branch through this one, and # path. That is a second script rather than a branch through this one, and
# update.sh reaches it through here without having to know which it is on. # update.sh reaches it through here without having to know which it is on.
if [[ "$(uname -s)" == "Darwin" ]]; then if [[ "$(uname -s)" == "Darwin" ]]; then
exec "$DIR/install-mac.sh" "$@" exec "$DIR/scripts/install-mac.sh" "$@"
fi fi
PY="$(command -v python3)" PY="$(command -v python3)"
# The one file that starts the application, whoever is asking: the launcher
# below, both .desktop files, and every shortcut Dikte registers.
ENTRY="$DIR/dikte/__main__.py"
BIN_DIR="$HOME/.local/bin" BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications" APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart" AUTOSTART_DIR="$HOME/.config/autostart"
SHORTCUT="${1:-Ctrl+Space}" ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
# Without the colon, so that a second argument given as "" stays empty. That is # Only the one: the discard key's default is the settings' own, read back below.
# how update.sh says "this one was turned off", as against not saying anything. DEFAULT_SHORTCUT="Ctrl+Space"
CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}" # Given as arguments, or asked of the settings further down. An installer run
# again, which is what every update does, must not undo a key you chose in
# Settings, so silence here means "keep whatever is there".
SHORTCUT="${1:-}"
CANCEL_SHORTCUT="${2-}"
# Passed as "" means the discard key is off, which is not the same answer as
# not being passed at all.
CANCEL_GIVEN=$(( $# >= 2 ))
# One caller says both without meaning either: an updater from before Dikte
# became a package looks for the settings at a path that no longer exists, and
# so passes the default key and an empty discard key rather than yours. This
# can go once nobody is updating across that commit any more.
if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then
SHORTCUT=""
CANCEL_GIVEN=0
fi
say() { printf ' %s\n' "$1"; } say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
@@ -81,22 +99,42 @@ if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then
fi fi
# 3. Launchers ------------------------------------------------------------- # 3. Launchers -------------------------------------------------------------
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR"
ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte" ln -sf "$ENTRY" "$BIN_DIR/dikte"
chmod +x "$DIR/dikte.py" chmod +x "$ENTRY"
ok "Command installed: $BIN_DIR/dikte" ok "Command installed: $BIN_DIR/dikte"
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) ;; *":$BIN_DIR:"*) ;;
*) warn "$BIN_DIR is not on your PATH. For fish: fish_add_path $BIN_DIR" ;; *) warn "$BIN_DIR is not on your PATH. For fish: fish_add_path $BIN_DIR" ;;
esac esac
# The icon, drawn by trayicon.py so that there is no binary in the repository,
# and installed under a name of our own. Naming a theme icon like
# audio-input-microphone instead only works where a theme has it: on i3 or a
# bare X11 login Qt is left with hicolor, which has no such name, and the entry
# comes out blank. hicolor is also where this goes, since it is the theme every
# desktop must fall back to.
if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon --hicolor "$ICON_DIR" >/dev/null 2>&1; then
ICON=dikte
# Only GTK reads a cache, and only if one is already there; a stale cache
# would otherwise hide the file we just wrote.
if command -v gtk-update-icon-cache >/dev/null \
&& [[ -f "$ICON_DIR/hicolor/icon-theme.cache" ]]; then
gtk-update-icon-cache -q -f -t "$ICON_DIR/hicolor" 2>/dev/null || true
fi
ok "Icon installed: $ICON_DIR/hicolor"
else
ICON=audio-input-microphone
warn "Could not draw the icon, so the entries name your theme's microphone"
fi
cat > "$APP_DIR/dikte.desktop" <<EOF cat > "$APP_DIR/dikte.desktop" <<EOF
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Dikte Name=Dikte
Comment=Voice dictation: record, transcribe, clean up, paste Comment=Voice dictation: record, transcribe, clean up, paste
Exec=$PY $DIR/dikte.py Exec=$PY $ENTRY
Icon=audio-input-microphone Icon=$ICON
Categories=Utility;AudioVideo; Categories=Utility;AudioVideo;
StartupNotify=false StartupNotify=false
EOF EOF
@@ -106,8 +144,8 @@ cat > "$AUTOSTART_DIR/dikte.desktop" <<EOF
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Dikte Name=Dikte
Exec=$PY $DIR/dikte.py Exec=$PY $ENTRY
Icon=audio-input-microphone Icon=$ICON
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
StartupNotify=false StartupNotify=false
EOF EOF
@@ -122,14 +160,26 @@ ok "Will start automatically on login"
# knows which desktop it is on, and it stores the combination in the settings # knows which desktop it is on, and it stores the combination in the settings
# as well, which is where the built-in listener reads it from. A key written # as well, which is where the built-in listener reads it from. A key written
# to only one of the two places is a key that half works. # to only one of the two places is a key that half works.
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then # What was not asked for is read back out of the settings, which is where Dikte
warn "Both arguments are $SHORTCUT, so the discard key was left out." # keeps the keys and so what a second run of this script has to leave alone.
stored() { PYTHONPATH="$DIR" "$PY" -m dikte config get "$1" 2>/dev/null || true; }
if [[ -z "$SHORTCUT" ]]; then
SHORTCUT="$(stored shortcut)"
SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}"
fi
if [[ $CANCEL_GIVEN == 0 ]]; then
# An empty answer here is a discard key that was turned off, and it stays off.
CANCEL_SHORTCUT="$(stored cancel_shortcut)"
fi
if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both keys are $SHORTCUT, so the discard key was left out."
say "Pass two different combinations, or set it in Settings → Shortcuts." say "Pass two different combinations, or set it in Settings → Shortcuts."
CANCEL_SHORTCUT="" CANCEL_SHORTCUT=""
fi fi
register() { # which combination label register() { # which combination label
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then
ok "$3: $2" ok "$3: $2"
else else
# One line: the rest of what it has to say about KWin is printed below. # One line: the rest of what it has to say about KWin is printed below.
@@ -142,11 +192,28 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
if [[ -n "$CANCEL_SHORTCUT" ]]; then if [[ -n "$CANCEL_SHORTCUT" ]]; then
register cancel "$CANCEL_SHORTCUT" "Discard the recording" register cancel "$CANCEL_SHORTCUT" "Discard the recording"
fi fi
if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then # Which of the three mechanisms this session got is Dikte's answer to give,
warn "KWin only reads these at startup, so they go live after your next" # not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every
say "login. Until then open Settings → Shortcuts and turn on the" # session that was neither GNOME nor KDE used to be promised a KWin that was
say "built-in listener to use them right away." # never running.
fi case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
kde)
warn "KWin only reads these at startup, so they go live after your next"
say "login. Until then open Settings → Shortcuts and turn on the"
say "built-in listener to use them right away."
;;
gnome) ;;
*)
if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx input; then
say "Your desktop keeps no shortcut registry, so Dikte listens for these"
say "keys itself while it is running."
else
warn "Your desktop keeps no shortcut registry, so Dikte listens for these"
say "keys itself, and it cannot read /dev/input yet:"
say " sudo usermod -aG input $(id -un) (then log out and back in)"
fi
;;
esac
else else
warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:" warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
say "dikte shortcut install toggle --combo '$SHORTCUT'" say "dikte shortcut install toggle --combo '$SHORTCUT'"
+46 -14
View File
@@ -11,19 +11,38 @@
# Homebrew moves that copy. # Homebrew moves that copy.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# The one file that starts the application, whoever is asking: the wrapper in
# ~/.local/bin, the bundle, and every shortcut Dikte registers.
ENTRY="$DIR/dikte/__main__.py"
APP_DIR="$HOME/Applications" APP_DIR="$HOME/Applications"
APP="$APP_DIR/Dikte.app" APP="$APP_DIR/Dikte.app"
BIN_DIR="$HOME/.local/bin" BIN_DIR="$HOME/.local/bin"
AGENT_DIR="$HOME/Library/LaunchAgents" AGENT_DIR="$HOME/Library/LaunchAgents"
AGENT_ID="io.github.yusufipk.dikte" AGENT_ID="io.github.yusufipk.dikte"
AGENT="$AGENT_DIR/$AGENT_ID.plist" AGENT="$AGENT_DIR/$AGENT_ID.plist"
SHORTCUT="${1:-Ctrl+Option+Space}" # Only the one: the discard key's default is the settings' own, read back below.
# Without the colon, so that a second argument given as "" stays empty. That is DEFAULT_SHORTCUT="Ctrl+Option+Space"
# how update.sh says "this one was turned off", as against not saying anything. # Given as arguments, or asked of the settings further down. An installer run
CANCEL_SHORTCUT="${2-Ctrl+Option+D}" # again, which is what every update does, must not undo a key you chose in
# Settings, so silence here means "keep whatever is there".
SHORTCUT="${1:-}"
CANCEL_SHORTCUT="${2-}"
# Passed as "" means the discard key is off, which is not the same answer as
# not being passed at all.
CANCEL_GIVEN=$(( $# >= 2 ))
# One caller says both without meaning either: an updater from before Dikte
# became a package looks for the settings at a path that no longer exists, and
# so passes the default key and an empty discard key rather than yours. This
# can go once nobody is updating across that commit any more.
if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then
SHORTCUT=""
CANCEL_GIVEN=0
fi
# The two places Homebrew installs to, in front, for the same reason dikte.py # The two places Homebrew installs to, in front, for the same reason the app
# puts them there: a shell that has not been logged into since Homebrew was # puts them there: a shell that has not been logged into since Homebrew was
# installed does not have them, and this script would then report ffmpeg as # installed does not have them, and this script would then report ffmpeg as
# missing while the application finds it perfectly well. # missing while the application finds it perfectly well.
@@ -147,7 +166,7 @@ if [ ! -d "\$PYTHONHOME" ]; then
osascript -e 'display alert "Dikte" message "The Python this was installed against is gone, most likely after a brew upgrade. Run ./install.sh again."' >/dev/null 2>&1 osascript -e 'display alert "Dikte" message "The Python this was installed against is gone, most likely after a brew upgrade. Run ./install.sh again."' >/dev/null 2>&1
exit 1 exit 1
fi fi
exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@" exec "\$HERE/python3" "$ENTRY" --gui "\$@"
EOF EOF
chmod +x "$APP/Contents/MacOS/Dikte" chmod +x "$APP/Contents/MacOS/Dikte"
@@ -186,7 +205,7 @@ printf 'APPL????' > "$APP/Contents/PkgInfo"
# and no second place to change what Dikte looks like. Failing to draw it is # and no second place to change what Dikte looks like. Failing to draw it is
# not worth stopping for: a bundle with no icon gets the generic one. # not worth stopping for: a bundle with no icon gets the generic one.
iconset="$(mktemp -d)/Dikte.iconset" iconset="$(mktemp -d)/Dikte.iconset"
if "$PY" "$DIR/trayicon.py" "$iconset" >/dev/null 2>&1 \ if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon "$iconset" >/dev/null 2>&1 \
&& iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then && iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then
ok "Icon drawn" ok "Icon drawn"
else else
@@ -212,7 +231,7 @@ fi
-f "$APP" >/dev/null 2>&1 || true -f "$APP" >/dev/null 2>&1 || true
# 4. The command ------------------------------------------------------------ # 4. The command ------------------------------------------------------------
# A wrapper, where Linux gets a symlink to dikte.py. The shebang there is # A wrapper, where Linux gets a symlink to the entry point. The shebang there is
# `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to # `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to
# the one interpreter that cannot run this. Naming the interpreter here also # the one interpreter that cannot run this. Naming the interpreter here also
# gives update.sh and uninstall.sh somewhere to read it from, so that the three # gives update.sh and uninstall.sh somewhere to read it from, so that the three
@@ -221,9 +240,9 @@ mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/dikte" <<EOF cat > "$BIN_DIR/dikte" <<EOF
#!/bin/sh #!/bin/sh
# Written by install-mac.sh. Edit that, not this. # Written by install-mac.sh. Edit that, not this.
exec "$PY" "$DIR/dikte.py" "\$@" exec "$PY" "$ENTRY" "\$@"
EOF EOF
chmod +x "$BIN_DIR/dikte" "$DIR/dikte.py" chmod +x "$BIN_DIR/dikte" "$ENTRY"
ok "Command installed: $BIN_DIR/dikte" ok "Command installed: $BIN_DIR/dikte"
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) ;; *":$BIN_DIR:"*) ;;
@@ -271,14 +290,27 @@ fi
# shortcut registry at all, so "installed" means the running application is # shortcut registry at all, so "installed" means the running application is
# holding the combination, and the settings file is where it reads it from # holding the combination, and the settings file is where it reads it from
# after a restart. Nothing here needs a logout, unlike KDE. # after a restart. Nothing here needs a logout, unlike KDE.
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then #
warn "Both arguments are $SHORTCUT, so the discard key was left out." # What was not asked for is read back out of the settings, which is what makes
# a second run of this script leave the keys you chose alone.
stored() { PYTHONPATH="$DIR" "$PY" -m dikte config get "$1" 2>/dev/null || true; }
if [[ -z "$SHORTCUT" ]]; then
SHORTCUT="$(stored shortcut)"
SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}"
fi
if [[ $CANCEL_GIVEN == 0 ]]; then
# An empty answer here is a discard key that was turned off, and it stays off.
CANCEL_SHORTCUT="$(stored cancel_shortcut)"
fi
if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both keys are $SHORTCUT, so the discard key was left out."
say "Pass two different combinations, or set it in Settings → Shortcuts." say "Pass two different combinations, or set it in Settings → Shortcuts."
CANCEL_SHORTCUT="" CANCEL_SHORTCUT=""
fi fi
register() { # which combination label register() { # which combination label
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then
ok "$3: $2" ok "$3: $2"
else else
warn "${out%%$'\n'*}" warn "${out%%$'\n'*}"
+41 -15
View File
@@ -4,7 +4,10 @@
# is the word that deletes them. # is the word that deletes them.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENTRY="$DIR/dikte/__main__.py"
USER_NAME="$(id -un)" USER_NAME="$(id -un)"
BIN_DIR="$HOME/.local/bin" BIN_DIR="$HOME/.local/bin"
@@ -29,6 +32,7 @@ else
MACOS=0 MACOS=0
APP_DIR="$HOME/.local/share/applications" APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart" AUTOSTART_DIR="$HOME/.config/autostart"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
PY="$(command -v python3 || true)" PY="$(command -v python3 || true)"
@@ -48,7 +52,7 @@ count() {
usage() { usage() {
cat <<EOF cat <<EOF
Usage: ./uninstall.sh [--purge] [--yes] Usage: ./scripts/uninstall.sh [--purge] [--yes]
--purge also delete the settings ($CONFIG_DIR) --purge also delete the settings ($CONFIG_DIR)
and the dictations, meetings and recordings ($DATA_DIR) and the dictations, meetings and recordings ($DATA_DIR)
@@ -84,17 +88,24 @@ echo "──────────────────"
# 1. Global shortcuts ------------------------------------------------------ # 1. Global shortcuts ------------------------------------------------------
# Handed to Dikte while it can still run, because it is the half that knows # Handed to Dikte while it can still run, because it is the half that knows
# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. macOS # whether they went into KDE's kglobalshortcutsrc, GNOME's gsettings, or
# keeps no registry: the combinations are held by the running process and are # nowhere at all. macOS and the desktops with no registry hold the combinations
# gone the moment it stops, so there is nothing here to take back. # in the running process, where they are gone the moment it stops, so there is
# nothing there to take back.
if ((MACOS)); then if ((MACOS)); then
say "Nothing to unregister: macOS shortcuts live only while Dikte runs." say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
for which in toggle cancel ask meeting; do for which in toggle pause cancel ask meeting; do
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true "$PY" "$ENTRY" shortcut remove "$which" >/dev/null 2>&1 || true
done done
ok "Global shortcuts unregistered" case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
say "KWin reads that file at startup, so the keys are free after your next login." kde)
ok "Global shortcuts unregistered"
say "KWin reads that file at startup, so the keys are free after your next login."
;;
gnome) ok "Global shortcuts unregistered" ;;
*) say "Nothing to unregister: Dikte listened for the keys itself, and they stop with it." ;;
esac
else else
warn "PyQt6 is missing, so the shortcuts were left registered." warn "PyQt6 is missing, so the shortcuts were left registered."
say "Remove them in your desktop's shortcut settings." say "Remove them in your desktop's shortcut settings."
@@ -103,10 +114,12 @@ fi
# 2. The running instance -------------------------------------------------- # 2. The running instance --------------------------------------------------
# It holds a tray icon and a socket; asking it to quit is tidier than pulling # It holds a tray icon and a socket; asking it to quit is tidier than pulling
# its launchers out from under it. # its launchers out from under it.
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then # The pattern matches an instance started before Dikte became a package as well,
[[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true # which is what an uninstall run straight after an update finds running.
if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
[[ -n "$PY" ]] && "$PY" "$ENTRY" quit >/dev/null 2>&1 || true
sleep 0.5 sleep 0.5
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
warn "Dikte is still running; close it from the tray icon" warn "Dikte is still running; close it from the tray icon"
else else
ok "Stopped the running instance" ok "Stopped the running instance"
@@ -148,10 +161,23 @@ fi
if ((!MACOS)); then if ((!MACOS)); then
remove "$APP_DIR/dikte.desktop" remove "$APP_DIR/dikte.desktop"
remove "$AUTOSTART_DIR/dikte.desktop" remove "$AUTOSTART_DIR/dikte.desktop"
# The icon, at each of the sizes install.sh drew it. One line rather than
# eight, and the size directories stay: they are the theme's, not ours.
icons=0
for png in "$ICON_DIR"/hicolor/*/apps/dikte.png; do
[[ -e "$png" ]] || continue
rm -f "$png"
icons=$((icons + 1))
done
if ((icons)); then
ok "Removed the icon from $ICON_DIR/hicolor"
else
gone "Was not there: $ICON_DIR/hicolor/*/apps/dikte.png"
fi
# Removing the shortcut takes its desktop file with it, but an install from # Removing the shortcut takes its desktop file with it, but an install from
# before this script existed may have left one behind on a desktop that never # before this script existed may have left one behind on a desktop that never
# used them. # used them.
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do for id in dikte-toggle dikte-pause dikte-cancel dikte-ask dikte-meeting; do
if [[ -e "$APP_DIR/$id.desktop" ]]; then if [[ -e "$APP_DIR/$id.desktop" ]]; then
remove "$APP_DIR/$id.desktop" remove "$APP_DIR/$id.desktop"
fi fi
@@ -195,11 +221,11 @@ elif [[ "$CONFIG_DIR" == "$DATA_DIR" ]]; then
# macOS keeps both in the one directory a Mac user's backup already knows # macOS keeps both in the one directory a Mac user's backup already knows
# about, so naming it twice would only look like two things were kept. # about, so naming it twice would only look like two things were kept.
say "Settings and dictations kept: $CONFIG_DIR" say "Settings and dictations kept: $CONFIG_DIR"
say "Delete them too with: ./uninstall.sh --purge" say "Delete them too with: ./scripts/uninstall.sh --purge"
else else
say "Settings kept: $CONFIG_DIR" say "Settings kept: $CONFIG_DIR"
say "Dictations kept: $DATA_DIR" say "Dictations kept: $DATA_DIR"
say "Delete them too with: ./uninstall.sh --purge" say "Delete them too with: ./scripts/uninstall.sh --purge"
fi fi
echo echo
+11 -20
View File
@@ -2,7 +2,10 @@
# Dikte updater: pull, put the launchers back, restart what was running. # Dikte updater: pull, put the launchers back, restart what was running.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENTRY="$DIR/dikte/__main__.py"
USER_NAME="$(id -un)" USER_NAME="$(id -un)"
if [[ "$(uname -s)" == "Darwin" ]]; then if [[ "$(uname -s)" == "Darwin" ]]; then
@@ -13,10 +16,8 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
# make a missing file the end of the script rather than a question answered no. # make a missing file the end of the script rather than a question answered no.
PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)" PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)"
[[ -x "$PY" ]] || PY="$(command -v python3 || true)" [[ -x "$PY" ]] || PY="$(command -v python3 || true)"
DEFAULT_SHORTCUT="Ctrl+Option+Space"
else else
PY="$(command -v python3 || true)" PY="$(command -v python3 || true)"
DEFAULT_SHORTCUT="Ctrl+Space"
fi fi
say() { printf ' %s\n' "$1"; } say() { printf ' %s\n' "$1"; }
@@ -24,13 +25,6 @@ ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; } die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
# The combination stored in the settings, which is where Dikte itself reads it
# from and the one place that is the same on KDE and on GNOME.
setting() {
[[ -n "$PY" ]] || return 0
"$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true
}
echo echo
echo "Updating Dikte" echo "Updating Dikte"
echo "──────────────" echo "──────────────"
@@ -90,18 +84,15 @@ echo
# 4. Launchers -------------------------------------------------------------- # 4. Launchers --------------------------------------------------------------
# An update can add a dependency or move a file, so the installer runs again. # An update can add a dependency or move a file, so the installer runs again.
# It would otherwise register its own defaults over the keys you chose, so it # With no keys named it keeps the ones stored in the settings, which are the
# is told what those are. Read before the installer runs, since it is the one # ones you chose.
# writing them. "$DIR/install.sh"
shortcut="$(setting shortcut)"
cancel_shortcut="$(setting cancel_shortcut)"
# Positional, so a chosen discard key cannot be passed without the other one.
"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}"
# 5. The running instance --------------------------------------------------- # 5. The running instance ---------------------------------------------------
# It is still running the code from before the pull. # It is still running the code from before the pull, which on an update across
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then # the move into a package is a process still named after the old entry point.
if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
if [[ -n "$PY" ]] && "$PY" "$ENTRY" restart >/dev/null 2>&1; then
ok "Restarted, so the new version is the one running" ok "Restarted, so the new version is the one running"
else else
warn "Could not restart it; use the tray menu → Restart" warn "Could not restart it; use the tray menu → Restart"
+3 -3
View File
@@ -22,9 +22,9 @@ import urllib.request
import wave import wave
from unittest import mock from unittest import mock
import assistant from dikte import assistant
import config as cfg from dikte import config as cfg
import i18n from dikte import i18n
# What the application is, rather than what it does: PipeWire, wl-clipboard, # What the application is, rather than what it does: PipeWire, wl-clipboard,
# ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces # ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces
+2 -2
View File
@@ -16,8 +16,8 @@ import threading
import time import time
import unittest import unittest
import api from dikte import api
import ggml from dikte import ggml
from tests.support import ( from tests.support import (
DikteTest, DikteTest,
fake_urlopen, fake_urlopen,
+1 -1
View File
@@ -14,7 +14,7 @@ import time
import unittest import unittest
from unittest import mock from unittest import mock
import assistant from dikte import assistant
from tests.support import DikteTest, fake_urlopen, only_these_tools from tests.support import DikteTest, fake_urlopen, only_these_tools
+66 -1
View File
@@ -21,7 +21,7 @@ import unittest
import wave import wave
from unittest import mock from unittest import mock
import audio from dikte import audio
from tests.support import ( from tests.support import (
DikteTest, DikteTest,
FakeCompleted, FakeCompleted,
@@ -280,6 +280,26 @@ class _StalledStream:
self._released.set() self._released.set()
class _HeldStream:
"""A capture that is paused and taken up again partway through, the way a
key press lands in the middle of a recording rather than between two."""
def __init__(self, data, recorder, pause_at, resume_at=None):
self._data = io.BytesIO(data)
self._recorder = recorder
self._pause_at = pause_at
self._resume_at = resume_at
self.reads = 0
def read(self, size):
if self.reads == self._pause_at:
self._recorder.pause()
elif self.reads == self._resume_at:
self._recorder.pause(False)
self.reads += 1
return self._data.read(size)
class RecordingCommand(OnLinux, DikteTest): class RecordingCommand(OnLinux, DikteTest):
"""Which program captures the microphone, and how it is asked to.""" """Which program captures the microphone, and how it is asked to."""
@@ -499,6 +519,51 @@ class RecorderChain(OnLinux, DikteTest):
self.assertEqual(len(failures), 1) self.assertEqual(len(failures), 1)
self.assertIn("0.3", failures[0]) self.assertIn("0.3", failures[0])
def held(self, data, pause_at, resume_at=None):
"""Record `data` with the recorder paused for part of it."""
recorder = audio.Recorder()
results = []
failures = []
recorder.stopped.connect(lambda *args: results.append(args))
recorder.failed.connect(failures.append)
proc = FakeProcess(data)
proc.stdout = _HeldStream(data, recorder, pause_at, resume_at)
with only_these_tools("pw-record"), \
mock.patch.object(subprocess, "Popen", return_value=proc):
recorder.start()
recorder._thread.join(timeout=5)
# Nothing has ended the capture: a pause holds the microphone.
self.assertEqual(proc.signals, [])
recorder.stop()
return results, failures
def test_what_is_said_while_it_is_held_is_not_in_the_recording(self):
"""The phone call in the middle of a dictation is the whole feature: it
must not reach the transcript, and the two halves must meet."""
results, failures = self.held(tone(2.0), pause_at=8, resume_at=16)
self.assertEqual(failures, [])
path, duration, _ = results[0]
self.addCleanup(os.unlink, path)
dropped = 8 * audio.CHUNK_FRAMES
self.assertAlmostEqual(duration, (2 * audio.RATE - dropped) / audio.RATE,
places=3)
def test_a_recording_held_all_the_way_through_captured_nothing(self):
results, failures = self.held(tone(2.0), pause_at=0)
self.assertEqual(results, [])
self.assertIn("0.3", failures[0])
def test_a_pause_does_not_outlive_the_recording_it_was_asked_for(self):
recorder = audio.Recorder()
recorder.pause()
proc = FakeProcess(tone(0.5))
with only_these_tools("pw-record"), \
mock.patch.object(subprocess, "Popen", return_value=proc):
recorder.start()
self.assertFalse(recorder.paused)
recorder._thread.join(timeout=5)
recorder.cancel()
def test_a_recorder_that_could_not_start(self): def test_a_recorder_that_could_not_start(self):
recorder = audio.Recorder() recorder = audio.Recorder()
failures = [] failures = []
+3 -3
View File
@@ -11,9 +11,9 @@ import subprocess
import unittest import unittest
from unittest import mock from unittest import mock
import api from dikte import api
import cleanup from dikte import cleanup
import ggml from dikte import ggml
from tests.support import DikteTest, fake_urlopen, sent_json, url_error from tests.support import DikteTest, fake_urlopen, sent_json, url_error
from tests.test_api import FakeServer, chat_reply from tests.test_api import FakeServer, chat_reply
+16 -8
View File
@@ -12,13 +12,13 @@ import json
import unittest import unittest
from unittest import mock from unittest import mock
import audio from dikte import audio
import cli from dikte import cli
import config as cfg from dikte import config as cfg
import ggml from dikte import ggml
import hotkey from dikte import hotkey
import ipc from dikte import ipc
import paste from dikte import paste
from tests.support import DikteTest, fake_urlopen, only_these_tools from tests.support import DikteTest, fake_urlopen, only_these_tools
@@ -165,7 +165,7 @@ class Parser(unittest.TestCase):
name) name)
def test_every_verb_is_wired_to_something(self): def test_every_verb_is_wired_to_something(self):
for verb in ("record", "toggle", "start", "stop", "cancel", "ask", for verb in ("record", "toggle", "start", "stop", "pause", "cancel", "ask",
"session", "transcribe", "meeting", "meetings", "history", "session", "transcribe", "meeting", "meetings", "history",
"config", "prompt", "devices", "models", "test-key", "config", "prompt", "devices", "models", "test-key",
"doctor", "shortcut", "status", "settings", "restart", "doctor", "shortcut", "status", "settings", "restart",
@@ -660,6 +660,14 @@ class Replies(DikteTest):
captured(): captured():
self.assertEqual(cli.run(["cancel"]), 0) self.assertEqual(cli.run(["cancel"]), 0)
def test_pausing_a_recording_nobody_started_is_not_a_failure_either(self):
"""A key that pauses can be pressed when there is nothing to pause, and
it must not start an application to tell you so."""
with mock.patch.object(ipc, "send", return_value=None), \
mock.patch.object(cli, "launch_gui") as launched, captured():
self.assertEqual(cli.run(["pause"]), 0)
self.assertFalse(launched.called)
class TranscribeRunsHere(DikteTest): class TranscribeRunsHere(DikteTest):
"""`dikte transcribe` runs in this process, not in the instance.""" """`dikte transcribe` runs in this process, not in the instance."""
+6 -6
View File
@@ -12,12 +12,12 @@ import sys
import unittest import unittest
from unittest import mock from unittest import mock
import api from dikte import api
import cleanup from dikte import cleanup
import config as cfg from dikte import config as cfg
import ggml from dikte import ggml
import i18n from dikte import i18n
import paste from dikte import paste
from tests.support import DikteTest from tests.support import DikteTest
+2 -2
View File
@@ -13,8 +13,8 @@ import unittest
import wave import wave
from unittest import mock from unittest import mock
import api from dikte import api
import filetranscribe as ft from dikte import filetranscribe as ft
from tests.support import DikteTest, make_wav, silence, tone from tests.support import DikteTest, make_wav, silence, tone
+14 -4
View File
@@ -18,8 +18,8 @@ import time
import zipfile import zipfile
from unittest import mock from unittest import mock
import ggml from dikte import ggml
import hub from dikte import hub
from tests.support import (DikteTest, fake_urlopen, http_error, json_body, from tests.support import (DikteTest, fake_urlopen, http_error, json_body,
linux_only, url_error) linux_only, url_error)
@@ -449,7 +449,7 @@ class Catalogue(Local):
STAND_IN = textwrap.dedent(""" STAND_IN = textwrap.dedent("""
import http.server, sys, threading, time import http.server, socketserver, sys, threading, time
args = sys.argv[1:] args = sys.argv[1:]
@@ -475,7 +475,17 @@ STAND_IN = textwrap.dedent("""
def log_message(self, *a): def log_message(self, *a):
pass pass
server = http.server.HTTPServer((opt("--host"), int(opt("--port"))), Handler) # The same server, without the reverse lookup of the address it bound.
# http.server asks the resolver for the name behind 127.0.0.1 in between
# binding and listening; on a Mac nothing answers and the call sits in a
# timeout for half a minute, all of it with the port still closed and a
# start waiting on it.
class Bound(http.server.HTTPServer):
def server_bind(self):
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]
server = Bound((opt("--host"), int(opt("--port"))), Handler)
print("listening on " + opt("--port"), flush=True) print("listening on " + opt("--port"), flush=True)
server.serve_forever() server.serve_forever()
""") """)
+75 -13
View File
@@ -10,8 +10,8 @@ from unittest import mock
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
import config as cfg from dikte import config as cfg
import hotkey from dikte import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only from tests.support import DikteTest, FakeCompleted, linux_only
SHORTCUTS_RC = """[services][dikte-toggle.desktop] SHORTCUTS_RC = """[services][dikte-toggle.desktop]
@@ -178,38 +178,69 @@ class Bindings(DikteTest):
class Chooser(DikteTest): class Chooser(DikteTest):
"""Which desktop is asked to register the shortcut.""" """Which mechanism the session gets, and everything keyed off that."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux") self.patch_attr(hotkey.sys, "platform", "linux")
self.addCleanup(hotkey._REGISTERED.clear)
@contextlib.contextmanager @contextlib.contextmanager
def under(self, desktop, has_gsettings=True): def under(self, desktop, tools=True):
"""A session that says it is this desktop, with or without gsettings.""" """A session that says it is this desktop, with or without its tools."""
with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \ with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \
mock.patch.object(hotkey.shutil, "which", mock.patch.object(hotkey.shutil, "which",
return_value="/usr/bin/gsettings" return_value="/usr/bin/tool" if tools else None):
if has_gsettings else None):
yield yield
def test_gnome_when_the_session_says_so_and_gsettings_is_there(self): def test_gnome_when_the_session_says_so_and_gsettings_is_there(self):
with self.under("GNOME"): with self.under("GNOME"):
self.assertEqual(hotkey.backend(), hotkey.GNOME)
self.assertEqual(hotkey.desktop_name(), "GNOME") self.assertEqual(hotkey.desktop_name(), "GNOME")
def test_kde_otherwise(self): def test_kde_when_the_session_says_so_and_kwriteconfig_is_there(self):
with self.under("KDE"): with self.under("KDE"):
self.assertEqual(hotkey.backend(), hotkey.KDE)
self.assertEqual(hotkey.desktop_name(), "KDE") self.assertEqual(hotkey.desktop_name(), "KDE")
def test_a_gnome_session_with_no_gsettings_falls_back(self): def test_a_desktop_with_no_registry_is_the_listeners(self):
"""Nothing to write the binding with, so KDE's file is the only try.""" """The bug this replaced: i3 was told KDE, and KWin was not running."""
with self.under("GNOME", has_gsettings=False): for desktop in ("i3", "XFCE", "X-Cinnamon", "sway", "MATE", ""):
self.assertEqual(hotkey.desktop_name(), "KDE") with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.backend(), hotkey.LISTENER)
def test_the_desktop_that_has_no_registry_is_called_by_its_own_name(self):
with self.under("i3"):
self.assertEqual(hotkey.desktop_name(), "i3")
with self.under("XFCE:GNOME-Flashback", tools=False):
self.assertEqual(hotkey.desktop_name(), "XFCE")
with self.under(""):
self.assertEqual(hotkey.desktop_name(), "This desktop")
def test_a_gnome_session_with_no_gsettings_falls_back_to_the_listener(self):
"""Nothing to write the binding with, and KDE's file is not an answer:
KWin is no more running here than it is on i3."""
with self.under("GNOME", tools=False):
self.assertEqual(hotkey.backend(), hotkey.LISTENER)
def test_the_desktop_is_matched_loosely(self): def test_the_desktop_is_matched_loosely(self):
for desktop in ("GNOME", "ubuntu:GNOME", "gnome"): for desktop in ("GNOME", "ubuntu:GNOME", "gnome"):
with self.subTest(desktop=desktop), self.under(desktop): with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.desktop_name(), "GNOME") self.assertEqual(hotkey.backend(), hotkey.GNOME)
for desktop in ("KDE", "KDE:plasma", "plasma"):
with self.subTest(desktop=desktop), self.under(desktop):
self.assertEqual(hotkey.backend(), hotkey.KDE)
def test_only_a_registry_is_installed_into_and_only_kwin_waits(self):
with self.under("KDE"):
self.assertTrue(hotkey.installs_shortcuts())
self.assertTrue(hotkey.shortcut_needs_restart())
with self.under("GNOME"):
self.assertTrue(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
with self.under("i3"):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
def test_installing_goes_to_whichever_it_is(self): def test_installing_goes_to_whichever_it_is(self):
with self.under("GNOME"), \ with self.under("GNOME"), \
@@ -224,6 +255,20 @@ class Chooser(DikteTest):
hotkey.install_shortcut("Ctrl+Space", "dikte toggle") hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
kde.assert_called_once() kde.assert_called_once()
def test_a_desktop_with_no_registry_installs_nothing_anywhere(self):
with self.under("i3"), \
mock.patch.object(hotkey, "install_kde_shortcut") as kde, \
mock.patch.object(hotkey, "install_gnome_shortcut") as gnome:
ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
kde.assert_not_called()
gnome.assert_not_called()
self.assertTrue(ok)
self.assertIn("i3", message)
self.assertNotIn("log out", message)
def test_removing_and_reading_back_go_to_the_same_one(self): def test_removing_and_reading_back_go_to_the_same_one(self):
with self.under("GNOME"), \ with self.under("GNOME"), \
mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \ mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \
@@ -234,6 +279,17 @@ class Chooser(DikteTest):
remove.assert_called_once() remove.assert_called_once()
status.assert_called_once() status.assert_called_once()
def test_only_kde_has_a_list_of_conflicts_to_read(self):
"""A leftover kglobalshortcutsrc from a Plasma the user has since left
would otherwise refuse combinations nothing is holding."""
rc = self.path("kglobalshortcutsrc")
rc.write_text(SHORTCUTS_RC, encoding="utf-8")
self.patch_attr(hotkey, "SHORTCUTS_FILE", rc)
with self.under("KDE"):
self.assertTrue(hotkey.conflicting_shortcuts("Meta+W"))
with self.under("i3"):
self.assertEqual(hotkey.conflicting_shortcuts("Meta+W"), [])
@linux_only @linux_only
class GnomeAccelerator(DikteTest): class GnomeAccelerator(DikteTest):
@@ -376,6 +432,12 @@ class KdeShortcut(DikteTest):
self.rc = self.path("kglobalshortcutsrc") self.rc = self.path("kglobalshortcutsrc")
self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps) self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps)
self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc) self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc)
# A Plasma session with kwriteconfig6 on it, whatever the machine
# running the suite happens to be logged into.
session = mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"})
session.start()
self.addCleanup(session.stop)
self.patch_attr(hotkey.shutil, "which", lambda _name: "/usr/bin/tool")
def test_installing_writes_a_desktop_file_kwin_will_launch(self): def test_installing_writes_a_desktop_file_kwin_will_launch(self):
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()): with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
+1 -1
View File
@@ -2,7 +2,7 @@
import json import json
import hub from dikte import hub
from tests.support import DikteTest, fake_urlopen, http_error, url_error from tests.support import DikteTest, fake_urlopen, http_error, url_error
RELEASE = { RELEASE = {
+1 -1
View File
@@ -9,7 +9,7 @@ import string
import unittest import unittest
from unittest import mock from unittest import mock
import i18n from dikte import i18n
from tests.support import DikteTest from tests.support import DikteTest
+2 -2
View File
@@ -11,7 +11,7 @@ import sys
import unittest import unittest
from unittest import mock from unittest import mock
import ipc from dikte import ipc
class FakeSocket: class FakeSocket:
@@ -57,7 +57,7 @@ class FakeSocket:
class Paths(unittest.TestCase): class Paths(unittest.TestCase):
def test_script_path_points_at_dikte(self): def test_script_path_points_at_dikte(self):
self.assertTrue(ipc.script_path().endswith("dikte.py")) self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
self.assertTrue(os.path.exists(ipc.script_path())) self.assertTrue(os.path.exists(ipc.script_path()))
def test_the_shortcut_command_runs_it_with_this_interpreter(self): def test_the_shortcut_command_runs_it_with_this_interpreter(self):
+3 -3
View File
@@ -11,9 +11,9 @@ import unittest
import wave import wave
from unittest import mock from unittest import mock
import api from dikte import api
import config as cfg from dikte import config as cfg
import meeting from dikte import meeting
from tests.support import DikteTest, make_wav, silence, speech, stereo, tone from tests.support import DikteTest, make_wav, silence, speech, stereo, tone
+1 -1
View File
@@ -23,7 +23,7 @@ import unittest
from typing import ClassVar from typing import ClassVar
from unittest import mock from unittest import mock
import paste from dikte import paste
from tests.support import DikteTest, FakeCompleted, only_these_tools from tests.support import DikteTest, FakeCompleted, only_these_tools
+3 -3
View File
@@ -9,9 +9,9 @@ import os
import unittest import unittest
from unittest import mock from unittest import mock
import config as cfg from dikte import config as cfg
import ggml from dikte import ggml
import paths from dikte import paths
class Directories(unittest.TestCase): class Directories(unittest.TestCase):
+155
View File
@@ -0,0 +1,155 @@
"""The drawn icons, checked against the thing that made them necessary.
A tray never says what colour it is painting over, so the only question worth
asking of these pixmaps is whether they can be seen at all: a glyph drawn in
black on i3's black bar is an empty slot, which is what issue #27 was. So the
test blends each icon onto a bar of its own and asks whether anything of it
survives, once over black and once over white.
"""
import sys
import tempfile
import unittest
from unittest import mock
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication
from dikte import trayicon
from tests.support import DikteTest
# One application for the whole run; Qt allows no second one.
_app = QApplication.instance() or QApplication([])
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
# Breeze's own panel, as the middle case: not every bar is at one end.
CHARCOAL = (0x31, 0x36, 0x3B)
def _pixels(pixmap):
image = pixmap.toImage().convertToFormat(QImage.Format.Format_ARGB32)
for y in range(image.height()):
for x in range(image.width()):
yield image.pixelColor(x, y)
def _stands_out_from(pixmap, background):
"""True when some pixel of this icon differs from the bar behind it.
The icons are transparent everywhere they are not drawn, so the tray gets
the blend rather than the pixmap, and a pixel that blends back into the bar
is a pixel nobody sees. 60 out of 255 is the gap being asked for, which is
well under black against white and well over the antialiasing at an edge.
"""
for colour in _pixels(pixmap):
weight = colour.alphaF()
ink = (colour.red(), colour.green(), colour.blue())
for channel, behind in zip(ink, background):
if abs(channel * weight + behind * (1 - weight) - behind) > 60:
return True
return False
def _drawn(pixmap):
"""True when anything at all was painted onto this pixmap."""
return any(colour.alpha() > 128 for colour in _pixels(pixmap))
class Tray(DikteTest):
"""The four state icons, on a session whose theme has none of them."""
def setUp(self):
super().setUp()
# Held between calls on purpose, so a test does not read what the one
# before it drew on another platform.
self.patch_attr(trayicon, "_cache", {})
def test_a_name_we_do_not_draw_is_a_null_icon(self):
# app.py asks the theme first and falls through to here, so anything
# answered with a picture would be one the theme should have given.
self.assertTrue(trayicon.icon("emblem-important").isNull())
def test_every_state_has_a_shape(self):
for name in trayicon.SHAPES:
with self.subTest(name=name):
icon = trayicon.icon(name)
self.assertFalse(icon.isNull())
for size in trayicon.SIZES:
self.assertTrue(_drawn(icon.pixmap(size, size)))
def test_visible_on_a_bar_of_any_colour(self):
# The regression: on X11 the icon is composited over a bar whose colour
# nobody declares, and i3's is black.
with mock.patch.object(sys, "platform", "linux"):
for name in trayicon.SHAPES:
for size in trayicon.SIZES:
pixmap = trayicon.icon(name).pixmap(size, size)
for background in (BLACK, WHITE, CHARCOAL):
with self.subTest(name=name, size=size, bar=background):
self.assertTrue(_stands_out_from(pixmap, background))
def test_x11_is_not_handed_a_mask(self):
# Only macOS recolours one. Setting it elsewhere would promise a
# recolouring that never comes, and the outline would be the only thing
# keeping the icon visible either way.
with mock.patch.object(sys, "platform", "linux"):
self.assertFalse(trayicon.icon("media-record").isMask())
def test_macos_gets_a_flat_black_stencil(self):
# There the colour is thrown away and only the coverage is read, so an
# outline would come back as part of the glyph.
with mock.patch.object(sys, "platform", "darwin"):
icon = trayicon.icon("audio-input-microphone")
self.assertTrue(icon.isMask())
for colour in _pixels(icon.pixmap(22, 22)):
if colour.alpha() > 128:
self.assertEqual(
(colour.red(), colour.green(), colour.blue()), BLACK)
def test_the_two_platforms_do_not_share_a_cached_icon(self):
with mock.patch.object(sys, "platform", "darwin"):
self.assertTrue(trayicon.icon("media-record").isMask())
with mock.patch.object(sys, "platform", "linux"):
self.assertFalse(trayicon.icon("media-record").isMask())
class ApplicationIcon(DikteTest):
"""The picture the menu entry, the task bar and the Finder are given."""
def test_written_where_every_desktop_looks(self):
with tempfile.TemporaryDirectory() as root:
written = trayicon.write_hicolor(root)
self.assertEqual(len(written), len(trayicon.HICOLOR_SIZES))
for size, path in zip(trayicon.HICOLOR_SIZES, written):
with self.subTest(size=size):
self.assertEqual(
path.parts[-3:], (f"{size}x{size}", "apps", "dikte.png"))
self.assertTrue(path.is_file())
image = QImage(str(path))
self.assertEqual((image.width(), image.height()),
(size, size))
def test_the_installed_name_is_the_one_the_entries_use(self):
# install.sh writes Icon=dikte into both .desktop files, and a name that
# matches no installed file is the blank slot all over again.
with tempfile.TemporaryDirectory() as root:
self.assertTrue(
all(path.name == "dikte.png"
for path in trayicon.write_hicolor(root)))
def test_a_tile_rather_than_a_stencil(self):
# Coloured on purpose: this one is composited onto backgrounds that are
# nothing like a tray, so it carries its own ground.
pixmap = trayicon.app_pixmap(64)
for background in (BLACK, WHITE):
self.assertTrue(_stands_out_from(pixmap, background))
def test_offered_at_the_sizes_a_window_asks_for(self):
icon = trayicon.app_icon()
self.assertFalse(icon.isNull())
self.assertIn(48, [size.width() for size in icon.availableSizes()])
if __name__ == "__main__":
unittest.main()
+172 -10
View File
@@ -6,21 +6,24 @@ 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. next time anybody presses Save. That is the failure this catches.
""" """
import os
import sys import sys
import unittest import unittest
from typing import ClassVar from typing import ClassVar
from unittest import mock from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtWidgets import QApplication, QMessageBox
import audio from dikte import audio
import cleanup from dikte import cleanup
import config as cfg from dikte import config as cfg
import ggml from dikte import ggml
import hotkey from dikte import hotkey
import overlay as overlay_module from dikte import overlay as overlay_module
import paste from dikte import paste
import settings_ui from dikte import settings_ui
from tests.support import DikteTest, only_these_tools from tests.support import DikteTest, only_these_tools
# One application for the whole run; Qt allows no second one. # One application for the whole run; Qt allows no second one.
@@ -96,6 +99,7 @@ CHANGED = {
"file_cleanup": False, "file_cleanup": False,
"shortcut": "Ctrl+Alt+Space", "shortcut": "Ctrl+Alt+Space",
"cancel_shortcut": "Meta+Shift+Space", "cancel_shortcut": "Meta+Shift+Space",
"pause_shortcut": "Meta+P",
"evdev_hotkey": True, "evdev_hotkey": True,
"history_limit": 50, "history_limit": 50,
} }
@@ -106,18 +110,30 @@ class Settings(DikteTest):
# one. Everything else about the window is the same on both. # one. Everything else about the window is the same on both.
changed = CHANGED changed = CHANGED
platform = "linux" platform = "linux"
# A session with no shortcut registry, which is what most Linux desktops
# are. The subclasses below stand on the other two. Pinned rather than
# inherited from whatever the machine running the suite is logged into,
# since half the shortcut tab is built from the answer.
desktop = "i3"
tools: ClassVar[tuple] = ()
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# No pactl, no model lists over the network, and no modal dialogue # No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK. # waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform)) self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(only_these_tools()) self.enterContext(only_these_tools(*self.tools))
self.enterContext(mock.patch.dict(
os.environ, {"XDG_CURRENT_DESKTOP": self.desktop}))
self.enterContext(mock.patch.object(QMessageBox, "information")) self.enterContext(mock.patch.object(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow, self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_models")) "_load_models"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow, self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_transcribe_models")) "_load_transcribe_models"))
# The local model boxes fetch their own list the moment they are shown,
# from a thread, which is nobody's test failing but a real request.
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
"_fetch_models"))
self.enterContext(mock.patch.object(settings_ui.hotkey, "APPLICATIONS_DIR", self.enterContext(mock.patch.object(settings_ui.hotkey, "APPLICATIONS_DIR",
self.path("applications"))) self.path("applications")))
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE", self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
@@ -129,12 +145,81 @@ class Settings(DikteTest):
self.addCleanup(window.close) self.addCleanup(window.close)
return window return window
@staticmethod
def wheel():
"""One notch of a mouse wheel, rolled downwards."""
return QWheelEvent(QPointF(5, 5), QPointF(5, 5), QPoint(0, 0),
QPoint(0, -120), Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase, False)
def test_the_window_opens_with_every_tab_on_it(self): def test_the_window_opens_with_every_tab_on_it(self):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
tabs = window.findChildren(settings_ui.QTabWidget)[0] tabs = window.findChildren(settings_ui.QTabWidget)[0]
self.assertEqual(tabs.count(), 9) self.assertEqual(tabs.count(), 9)
self.assertEqual(window.windowTitle(), "Dikte Settings") self.assertEqual(window.windowTitle(), "Dikte Settings")
def test_no_tab_can_stretch_the_window_past_a_small_screen(self):
# A tab that keeps its full height hands that height to the window as a
# minimum, and a tall one then carries Save off the bottom of a laptop
# screen with no way to drag it back. Each tab scrolls instead.
window = self.window(cfg.Config())
for index in range(window.tabs.count()):
window.tabs.setCurrentIndex(index)
self.assertLess(window.minimumSizeHint().height(), 500,
window.tabs.tabText(index))
def test_the_window_cannot_be_dragged_down_to_a_stub(self):
# A tab that scrolls asks for no height of its own, which leaves nothing
# to stop the window being pulled down to a tab bar and half a button.
window = self.window(cfg.Config())
window.resize(1, 1)
self.assertGreaterEqual(window.width(), 500)
self.assertGreaterEqual(window.height(), 360)
def test_the_wheel_passes_over_a_box_it_was_not_aimed_at(self):
# Every tab scrolls now, and a combo box reads the wheel as a change of
# value: rolling down the page with the pointer over the language box
# would pick another language on the way past, and Save would write it
# down. The box takes the wheel once it has been clicked into.
window = self.window(cfg.Config())
# Shown and activated, because a box in a window nobody is looking at
# can be given the focus but never has it.
window.show()
window.activateWindow()
QApplication.processEvents()
box = window.ui_language
# Not the wheel focus a combo box has by default: Qt hands the focus
# over before it delivers the wheel, which would make "has the focus"
# true for the very roll being refused.
self.assertEqual(box.focusPolicy(), Qt.FocusPolicy.StrongFocus)
before = box.currentIndex()
rolled = self.wheel()
QApplication.sendEvent(box, rolled)
self.assertEqual(box.currentIndex(), before)
# Refused, not swallowed. An unaccepted wheel event is the one Qt
# carries on up to the scroll area, so the page moves instead.
self.assertFalse(rolled.isAccepted())
box.setFocus()
QApplication.sendEvent(box, self.wheel())
self.assertNotEqual(box.currentIndex(), before)
def test_a_wrapped_label_keeps_the_room_its_lines_need(self):
# The program path shares a row with a button, and a row is measured
# before its width is known: the label has to claim the second line back
# itself, and give it up again when the window is widened.
label = settings_ui.WrappedLabel()
# Shown, because a hidden widget is told about its new size only once
# somebody looks at it, and the height is worked out from that size.
label.show()
self.addCleanup(label.deleteLater)
line = label.fontMetrics().height()
label.resize(120, line)
label.setText("Installed on the system: /opt/homebrew/bin/whisper-server")
self.assertGreater(label.minimumHeight(), line)
label.resize(2000, line)
self.assertLessEqual(label.minimumHeight(), line)
def test_saving_without_touching_anything_changes_nothing(self): def test_saving_without_touching_anything_changes_nothing(self):
"""Every widget has to load what is stored, or Save writes its default """Every widget has to load what is stored, or Save writes its default
over it. This says so for the whole table at once.""" over it. This says so for the whole table at once."""
@@ -180,6 +265,31 @@ class Settings(DikteTest):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS)) self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS))
def shortcut_tab_text(self, window):
"""Everything the shortcut tab says, as one string."""
return "\n".join(
widget.text() for widget in
window.findChildren(settings_ui.QLabel)
+ window.findChildren(settings_ui.QLineEdit)
+ window.findChildren(settings_ui.QPushButton)
)
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
"""A desktop with no registry is told the truth: nothing is installed
anywhere, Dikte is listening, and here is the command to bind if the
desktop should own the keys instead. It used to be promised a KWin that
was not running."""
window = self.window(cfg.Config())
text = self.shortcut_tab_text(window)
self.assertIn("i3 keeps no shortcut registry", text)
self.assertNotIn("KWin", text)
self.assertIn("__main__.py toggle", text)
# Not a choice to offer where it is the only mechanism there is.
self.assertTrue(window.evdev_enabled.isHidden())
self.assertFalse([button for button in
window.findChildren(settings_ui.QPushButton)
if "install" in button.text().lower()])
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self): def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
"""The application is unusable without the toggle, so that one box """The application is unusable without the toggle, so that one box
falls back. The rest stay empty, which is how they are switched off. falls back. The rest stay empty, which is how they are switched off.
@@ -196,6 +306,7 @@ class Settings(DikteTest):
self.assertTrue(conf["shortcut"]) self.assertTrue(conf["shortcut"])
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle")) self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
self.assertEqual(conf["cancel_shortcut"], "") self.assertEqual(conf["cancel_shortcut"], "")
self.assertEqual(conf["pause_shortcut"], "")
self.assertEqual(conf["assistant_shortcut"], "") self.assertEqual(conf["assistant_shortcut"], "")
self.assertEqual(conf["meeting_shortcut"], "") self.assertEqual(conf["meeting_shortcut"], "")
@@ -340,7 +451,16 @@ class MacSettings(Settings):
def test_the_listener_is_not_offered_as_a_choice(self): def test_the_listener_is_not_offered_as_a_choice(self):
"""It is the whole mechanism there; turning it off would leave nothing.""" """It is the whole mechanism there; turning it off would leave nothing."""
window = self.window(cfg.Config()) window = self.window(cfg.Config())
self.assertFalse(window.evdev_enabled.isVisible()) self.assertTrue(window.evdev_enabled.isHidden())
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
"""Carbon holds the keys here, so there is no command to bind and no
/dev/input to be let into."""
window = self.window(cfg.Config())
text = self.shortcut_tab_text(window)
self.assertIn("Dikte asks macOS for these combinations", text)
self.assertNotIn("KWin", text)
self.assertNotIn("__main__.py toggle", text)
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
@@ -349,6 +469,27 @@ class MacSettings(Settings):
self.assertEqual(offered, paste.MACOS.shortcuts) self.assertEqual(offered, paste.MACOS.shortcuts)
class KdeSettings(Settings):
"""The same window on the one desktop that keeps a registry and makes you
wait for it. Nothing here is about KDE: it is the rest of the window,
checked on the platform where Install, Remove and the listener's own
checkbox are all on screen."""
desktop = "KDE"
tools: ClassVar[tuple] = ("kwriteconfig6",)
def test_the_shortcut_tab_talks_about_this_session_and_no_other(self):
window = self.window(cfg.Config())
text = self.shortcut_tab_text(window)
self.assertIn("KWin only reads shortcut settings at startup", text)
self.assertIn("Install as a KDE shortcut", text)
self.assertNotIn("keeps no shortcut registry", text)
self.assertNotIn("__main__.py toggle", text)
# Here it is a choice: the wait for the next login, or the key press
# reaching the focused application as well.
self.assertFalse(window.evdev_enabled.isHidden())
class Overlay(DikteTest): class Overlay(DikteTest):
def overlay(self, **kwargs): def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs) widget = overlay_module.Overlay(**kwargs)
@@ -393,6 +534,27 @@ class Overlay(DikteTest):
widget._conceal() widget._conceal()
self.assertFalse(widget.showing) self.assertFalse(widget.showing)
def test_a_held_recording_says_so_and_stops_moving(self):
"""Everything about the ribbon says a recording is running; a pause the
ribbon did not show would leave all of it saying the opposite."""
widget = self.overlay()
widget.show_recording()
widget.push_level(0.8)
widget.set_paused(True)
levels = list(widget.levels)
widget._tick()
self.assertEqual(widget.levels, levels)
widget.set_paused(False)
widget._tick()
self.assertNotEqual(widget.levels, levels)
def test_a_new_recording_is_never_the_last_one_still_held(self):
widget = self.overlay()
widget.show_recording()
widget.set_paused(True)
widget.show_recording()
self.assertFalse(widget.paused)
def test_a_meeting_shows_both_sides(self): def test_a_meeting_shows_both_sides(self):
widget = self.overlay() widget = self.overlay()
widget.show_meeting() widget.show_meeting()
+1 -1
View File
@@ -2,7 +2,7 @@
import unittest import unittest
import vad from dikte import vad
from tests.support import DikteTest from tests.support import DikteTest
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
+5 -5
View File
@@ -11,11 +11,11 @@ import os
import unittest import unittest
from unittest import mock from unittest import mock
import api from dikte import api
import assistant from dikte import assistant
import config as cfg from dikte import config as cfg
import paste from dikte import paste
import worker from dikte import worker
from tests.support import DikteTest, make_wav, speech from tests.support import DikteTest, make_wav, speech
-213
View File
@@ -1,213 +0,0 @@
"""The three tray icons, drawn here for systems that have no icon theme.
Linux hands out `audio-input-microphone`, `media-record` and `view-refresh`
from whatever icon theme is installed, and Qt finds them through
QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon
there, and a null icon in the menu bar is an item you cannot see, which is the
whole of Dikte's interface gone. So the same three shapes are drawn here, and
used whenever the theme has nothing to offer.
They are drawn as template images: one colour, transparent everywhere else,
with isMask set. That is what lets macOS invert them for a dark menu bar and
grey them while the menu is open, and it is why the shapes are outlines rather
than the coloured glyphs a Linux theme would give.
"""
import pathlib
import sys
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap)
# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the
# icon rather than one being scaled, because a scaled stroke goes soft.
SIZES = (22, 44)
# Drawn in black; the mask throws the colour away and keeps the coverage, and
# on a system that does not do masks black is still the right ink for a light
# panel and readable on a dark one.
INK = QColor(0, 0, 0)
def _canvas(size):
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
return pixmap, painter
def _paint_microphone(painter, size, ink):
"""A capsule on a stand: idle, and the application's own mark.
The colour is a parameter because the same glyph is the tray stencil, where
it is black and then masked, and the white one on the application icon.
"""
unit = size / 22.0
pen = QPen(ink, 1.6 * unit)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(ink)
# The capsule, held away from the edges so the stroke below has room.
painter.drawRoundedRect(
QRectF(8.2 * unit, 3.4 * unit, 5.6 * unit, 10.4 * unit),
2.8 * unit, 2.8 * unit,
)
painter.setBrush(Qt.BrushStyle.NoBrush)
# The arc that cradles it, and the post and foot under that.
painter.drawArc(
QRectF(5.4 * unit, 6.6 * unit, 11.2 * unit, 10.4 * unit),
180 * 16, 180 * 16,
)
painter.drawLine(QPointF(11 * unit, 16.8 * unit), QPointF(11 * unit, 19 * unit))
painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit))
def _microphone(painter, size):
_paint_microphone(painter, size, INK)
def _record(painter, size):
"""A filled dot: recording, and the same red dot the overlay shows."""
unit = size / 22.0
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(INK)
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
def _working(painter, size):
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
unit = size / 22.0
pen = QPen(INK, 2.0 * unit)
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
ring = QRectF(4.4 * unit, 4.4 * unit, 13.2 * unit, 13.2 * unit)
# Three quarters of the way round, leaving the gap the head sits in.
painter.drawArc(ring, 90 * 16, -280 * 16)
# The head, as a filled triangle at the open end rather than two more
# strokes: at 22 points a drawn arrowhead closes up into a blob.
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(INK)
head = QPainterPath()
head.moveTo(QPointF(11.0 * unit, 1.6 * unit))
head.lineTo(QPointF(11.0 * unit, 7.2 * unit))
head.lineTo(QPointF(15.8 * unit, 4.4 * unit))
head.closeSubpath()
painter.drawPath(head)
# The names Linux themes use, which are what dikte.py asks for either way.
SHAPES = {
"audio-input-microphone": _microphone,
"media-record": _record,
"view-refresh": _working,
}
_cache = {}
def icon(name):
"""The named icon drawn here, or a null QIcon when it is not one of ours.
Cached because the tray is refreshed on every state change and every one of
those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the
pixmaps inside it are shared, so handing the same object out is safe.
"""
shape = SHAPES.get(name)
if shape is None:
return QIcon()
if name in _cache:
return _cache[name]
result = QIcon()
for size in SIZES:
pixmap, painter = _canvas(size)
try:
shape(painter, size)
finally:
painter.end()
result.addPixmap(pixmap)
# The line that makes it a template image: macOS then owns the colour, and
# the icon follows the menu bar into dark mode instead of staying black.
result.setIsMask(True)
_cache[name] = result
return result
# --- the application icon --------------------------------------------------
#
# The menu bar wants a flat stencil; the Finder, the Dock and the permission
# dialogs want a picture. Same microphone, on a ground of its own, and drawn
# here as well so that `install-mac.sh` has an .icns to build without a binary
# blob living in the repository.
# What iconutil expects to find in an .iconset: each of these at 1x and 2x.
APP_ICON_SIZES = (16, 32, 128, 256, 512)
def app_pixmap(size):
"""The application icon at one size: a white microphone on a blue tile."""
pixmap, painter = _canvas(size)
try:
unit = size / 22.0
# macOS rounds and shadows the tile itself for some icon styles but not
# for a plain .icns, so the shape is drawn: the squircle radius Apple
# uses is close enough to 22% of the side.
ground = QLinearGradient(0, 0, 0, size)
ground.setColorAt(0.0, QColor(0x3B, 0x82, 0xF6))
ground.setColorAt(1.0, QColor(0x1D, 0x4E, 0xD8))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ground)
inset = 1.0 * unit
painter.drawRoundedRect(
QRectF(inset, inset, size - 2 * inset, size - 2 * inset),
4.4 * unit, 4.4 * unit,
)
# The same glyph as the tray, in white and a little smaller so it sits
# inside the tile rather than against its edges.
painter.save()
painter.translate(size / 2.0, size / 2.0)
painter.scale(0.64, 0.64)
painter.translate(-size / 2.0, -size / 2.0)
_paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF))
painter.restore()
finally:
painter.end()
return pixmap
def write_iconset(directory):
"""Write the PNGs `iconutil -c icns` reads. The directory it wrote to."""
directory = pathlib.Path(directory)
directory.mkdir(parents=True, exist_ok=True)
for size in APP_ICON_SIZES:
for scale in (1, 2):
name = f"icon_{size}x{size}{'@2x' if scale == 2 else ''}.png"
app_pixmap(size * scale).save(str(directory / name), "PNG")
return directory
def _main(argv):
"""`python3 trayicon.py <path>.iconset`, which install-mac.sh calls.
A QGuiApplication has to exist before a QPixmap can, and offscreen because
this runs from a shell script with no window to open.
"""
if len(argv) != 2:
print("usage: trayicon.py <directory>.iconset", file=sys.stderr)
return 2
from PyQt6.QtGui import QGuiApplication
QGuiApplication.setAttribute(
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
try:
print(write_iconset(argv[1]))
finally:
del app
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv))