mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
Executable
+25
@@ -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())
|
||||||
@@ -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
@@ -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())
|
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 --------------------------------------------------------
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
+111
-39
@@ -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()
|
||||||
|
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
|
_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()
|
||||||
|
if which == GNOME:
|
||||||
|
return gnome_shortcut_status(desktop_id)
|
||||||
|
if which == KDE:
|
||||||
|
return kde_shortcut_status(desktop_id)
|
||||||
return _REGISTERED.get(desktop_id)
|
return _REGISTERED.get(desktop_id)
|
||||||
return (gnome_shortcut_status(desktop_id) if _gnome()
|
|
||||||
else kde_shortcut_status(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")
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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}"
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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,7 +264,7 @@ 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":
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
+83
-16
@@ -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,
|
||||||
|
# not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every
|
||||||
|
# session that was neither GNOME nor KDE used to be promised a KWin that was
|
||||||
|
# never running.
|
||||||
|
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"
|
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 "login. Until then open Settings → Shortcuts and turn on the"
|
||||||
say "built-in listener to use them right away."
|
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
|
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'"
|
||||||
|
|||||||
@@ -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'*}"
|
||||||
@@ -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
|
||||||
|
case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
|
||||||
|
kde)
|
||||||
ok "Global shortcuts unregistered"
|
ok "Global shortcuts unregistered"
|
||||||
say "KWin reads that file at startup, so the keys are free after your next login."
|
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
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 = []
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
|
||||||
Reference in New Issue
Block a user