mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +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
|
||||
run: |
|
||||
bash -n install.sh
|
||||
bash -n install-mac.sh
|
||||
bash -n update.sh
|
||||
bash -n uninstall.sh
|
||||
bash -n scripts/install-mac.sh
|
||||
bash -n scripts/update.sh
|
||||
bash -n scripts/uninstall.sh
|
||||
|
||||
# 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
|
||||
|
||||
+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
|
||||
`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),
|
||||
`paths.directories()` (where the settings and the data live) and the
|
||||
`_macos()`/`_windows()`/`_gnome()` set in `hotkey.py`. Keep `sys.platform` inside
|
||||
the chooser and read it there every time: a constant settled at import is one no
|
||||
test can stand somewhere else.
|
||||
`paths.directories()` (where the settings and the data live) and `hotkey.backend()`,
|
||||
which names the one mechanism a session has for holding a key. Keep `sys.platform`
|
||||
inside the chooser and read it there every time: a constant settled at import is
|
||||
one no test can stand somewhere else.
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
whatever window you were typing in.
|
||||
|
||||
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and
|
||||
[Windows](README.windows.md) too. No dependencies beyond system packages: just
|
||||
the Python standard library, 3.11 or newer, and PyQt6.
|
||||
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS,
|
||||
[Windows](README.windows.md) and any other Linux desktop that will let it read
|
||||
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)*
|
||||
|
||||
@@ -55,7 +56,7 @@ tools instead:
|
||||
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
|
||||
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
|
||||
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
|
||||
in the [Windows README](README.windows.md).
|
||||
|
||||
`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
|
||||
puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away
|
||||
again and leaves your settings and dictations alone unless you pass `--purge`.
|
||||
two global shortcuts, whose keys are its two arguments, or the ones already in
|
||||
your settings when it is given none. `./scripts/update.sh` pulls and puts all of
|
||||
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
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -189,19 +192,29 @@ running.
|
||||
right-click to delete.
|
||||
- **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`
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
ipc.py one request and one reply over the local socket
|
||||
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
|
||||
overlay.py the corner indicator
|
||||
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
|
||||
trayicon.py the tray icons, drawn where there is no icon theme
|
||||
i18n.py the string table
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
+25
-14
@@ -4,9 +4,10 @@
|
||||
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
|
||||
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
|
||||
[Windows](README.windows.md)'ta da çalışır. Sistem paketleri dışında bağımlılığı
|
||||
yok: sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
|
||||
KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta,
|
||||
[Windows](README.windows.md)'ta ve klavyeyi okumasına izin veren diğer Linux
|
||||
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)*
|
||||
|
||||
@@ -54,7 +55,7 @@ araçlarıyla çalışır:
|
||||
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
|
||||
kurar:
|
||||
|
||||
@@ -83,15 +84,15 @@ gerekmiyor.
|
||||
|
||||
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
|
||||
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
|
||||
[Windows README](README.windows.md)'sinde.
|
||||
|
||||
`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`
|
||||
son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar;
|
||||
`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve
|
||||
diktelerine dokunmaz.
|
||||
başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
|
||||
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
|
||||
bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
|
||||
demedikçe ayarlarına ve diktelerine dokunmaz.
|
||||
|
||||
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
|
||||
@@ -109,6 +110,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
| Ne | Nasıl |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -187,19 +189,28 @@ olmasını ister.
|
||||
silebilirsin.
|
||||
- **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
|
||||
yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
|
||||
Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
|
||||
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`
|
||||
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
|
||||
|
||||
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
|
||||
ipc.py yerel sokette bir istek, bir cevap
|
||||
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ı
|
||||
overlay.py köşedeki gösterge
|
||||
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
|
||||
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
|
||||
i18n.py metin tablosu
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ all of it and leaves the repository and your settings alone.
|
||||
To try it without installing anything:
|
||||
|
||||
```sh
|
||||
python dikte.py
|
||||
python -m dikte
|
||||
```
|
||||
|
||||
## 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.request
|
||||
|
||||
import ggml
|
||||
from i18n import t
|
||||
from . import ggml
|
||||
from .i18n import t
|
||||
|
||||
APP_URL = "https://github.com/yusufipk/dikte"
|
||||
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.
|
||||
|
||||
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
|
||||
`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".
|
||||
|
||||
Nothing runs this file directly; __main__.py is what the launchers start.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
@@ -37,21 +38,21 @@ from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
|
||||
|
||||
import assistant # noqa: E402
|
||||
import audio # noqa: E402
|
||||
import cli # noqa: E402
|
||||
import config as cfg # noqa: E402
|
||||
import ggml # noqa: E402
|
||||
import hotkey # noqa: E402
|
||||
import i18n # noqa: E402
|
||||
import ipc # noqa: E402
|
||||
import meeting # noqa: E402
|
||||
import trayicon # noqa: E402
|
||||
from i18n import t # noqa: E402
|
||||
from meeting import MeetingPipeline # noqa: E402
|
||||
from overlay import Overlay # noqa: E402
|
||||
from settings_ui import SettingsWindow # noqa: E402
|
||||
from worker import Pipeline # noqa: E402
|
||||
from . import assistant # noqa: E402
|
||||
from . import audio # noqa: E402
|
||||
from . import cli # noqa: E402
|
||||
from . import config as cfg # noqa: E402
|
||||
from . import ggml # noqa: E402
|
||||
from . import hotkey # noqa: E402
|
||||
from . import i18n # noqa: E402
|
||||
from . import ipc # noqa: E402
|
||||
from . import meeting # noqa: E402
|
||||
from . import trayicon # noqa: E402
|
||||
from .i18n import t # noqa: E402
|
||||
from .meeting import MeetingPipeline # noqa: E402
|
||||
from .overlay import Overlay # noqa: E402
|
||||
from .settings_ui import SettingsWindow # noqa: E402
|
||||
from .worker import Pipeline # noqa: E402
|
||||
|
||||
SERVER_NAME = ipc.SERVER_NAME
|
||||
IDLE, RECORDING, BUSY = "idle", "recording", "busy"
|
||||
@@ -84,6 +85,14 @@ class Dikte:
|
||||
self.ask_state = IDLE
|
||||
# Which of the two the microphone is currently serving, or 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_base = ""
|
||||
self.meeting_message = ""
|
||||
@@ -163,6 +172,12 @@ class Dikte:
|
||||
self.toggle_action.triggered.connect(self._toggle)
|
||||
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.
|
||||
self.ask_action = QAction("", self.menu)
|
||||
self.ask_action.triggered.connect(self._toggle_ask)
|
||||
@@ -287,6 +302,9 @@ class Dikte:
|
||||
or (self.ask_state == IDLE and not self.recording)
|
||||
)
|
||||
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)
|
||||
# A command to the agent is the one job long enough to be worth calling
|
||||
# off once it is already running.
|
||||
@@ -303,6 +321,12 @@ class Dikte:
|
||||
else:
|
||||
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 = {
|
||||
M_IDLE: "Record a meeting",
|
||||
M_RECORDING: "End the meeting and write it up",
|
||||
@@ -338,13 +362,16 @@ class Dikte:
|
||||
def toggle_meeting(self):
|
||||
self._external("meeting", self._toggle_meeting)
|
||||
|
||||
def toggle_pause(self):
|
||||
self._external("pause", self._toggle_pause)
|
||||
|
||||
def cancel(self):
|
||||
self._external("cancel", self._cancel)
|
||||
|
||||
def _external(self, name, handler):
|
||||
# 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
|
||||
# that same press. Its lateness is also the proof we were waiting for
|
||||
# toggle arriving right behind one is the desktop's own shortcut catching
|
||||
# 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
|
||||
# do but double every press.
|
||||
# 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.restart()
|
||||
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
|
||||
"cancel": self._cancel}
|
||||
"cancel": self._cancel, "pause": self._toggle_pause}
|
||||
handlers.get(name, self._toggle)()
|
||||
|
||||
def _retire_listener(self):
|
||||
@@ -397,6 +424,7 @@ class Dikte:
|
||||
else:
|
||||
handler = {
|
||||
"cancel": self.cancel,
|
||||
"pause": self.toggle_pause,
|
||||
"ask-cancel": self.cancel_ask,
|
||||
"ask-reset": self.reset_conversation,
|
||||
"meeting-cancel": self.cancel_meeting,
|
||||
@@ -479,6 +507,7 @@ class Dikte:
|
||||
"ok": True,
|
||||
"running": True,
|
||||
"dictation": self.state,
|
||||
"paused": self.paused,
|
||||
"ask": self.ask_state,
|
||||
"meeting": self.meeting_state,
|
||||
"meeting_base": self.meetings.running_base,
|
||||
@@ -542,6 +571,7 @@ class Dikte:
|
||||
"""One microphone, so one of the two holds it at a time."""
|
||||
self.recorder_owner = owner
|
||||
self._run_id += 1
|
||||
self._clear_pause()
|
||||
self.elapsed.restart()
|
||||
self.ticker.start()
|
||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
||||
@@ -550,6 +580,7 @@ class Dikte:
|
||||
if self.state != RECORDING:
|
||||
return
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self._set_state(BUSY)
|
||||
self.overlay.show_busy(t("Transcribing…"))
|
||||
self.recorder.stop()
|
||||
@@ -558,16 +589,50 @@ class Dikte:
|
||||
if self.ask_state != RECORDING:
|
||||
return
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self._set_ask_state(BUSY)
|
||||
self.ask_overlay.show_busy(t("Transcribing…"))
|
||||
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):
|
||||
"""Throw away whichever recording is running."""
|
||||
if not self.recording:
|
||||
return
|
||||
asking = self.ask_state == RECORDING
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self.recorder.cancel()
|
||||
self.recorder_owner = None
|
||||
# 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)
|
||||
|
||||
def _tick(self):
|
||||
seconds = self.elapsed.elapsed() / 1000.0
|
||||
seconds = self._recorded_seconds()
|
||||
self._recording_overlay().set_seconds(seconds)
|
||||
if seconds >= self.conf["max_seconds"]:
|
||||
(self.stop_ask if self.recorder_owner == ASK else self.stop)()
|
||||
@@ -1058,6 +1123,12 @@ def run_app(args):
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("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)
|
||||
_stay_out_of_the_dock()
|
||||
# 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)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -29,9 +29,9 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import api
|
||||
import config as cfg
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import config as cfg
|
||||
from .i18n import t
|
||||
|
||||
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
||||
PROVIDERS = ("claude", "codex", "openrouter")
|
||||
@@ -31,7 +31,7 @@ import wave
|
||||
|
||||
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
|
||||
# a console window of their own on Windows.
|
||||
@@ -89,12 +89,31 @@ class Recorder(QObject):
|
||||
self._rms = []
|
||||
self._cancelled = False
|
||||
self._stopping = False
|
||||
self._paused = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
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):
|
||||
if self.active:
|
||||
return
|
||||
@@ -120,6 +139,7 @@ class Recorder(QObject):
|
||||
self._rms = []
|
||||
self._cancelled = False
|
||||
self._stopping = False
|
||||
self._paused = False
|
||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||
self._thread.start()
|
||||
@@ -132,6 +152,11 @@ class Recorder(QObject):
|
||||
chunk = stdout.read(CHUNK_BYTES)
|
||||
if not chunk:
|
||||
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)
|
||||
with self._lock:
|
||||
self._buffer.extend(chunk)
|
||||
@@ -18,10 +18,10 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import ggml
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import assistant
|
||||
from . import ggml
|
||||
from .i18n import t
|
||||
|
||||
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
||||
|
||||
+20
-15
@@ -23,28 +23,28 @@ import time
|
||||
|
||||
from PyQt6.QtCore import QCoreApplication, QTimer
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import hotkey
|
||||
import ipc
|
||||
import meeting
|
||||
import paste
|
||||
from . import api
|
||||
from . import assistant
|
||||
from . import audio
|
||||
from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import hotkey
|
||||
from . import ipc
|
||||
from . import meeting
|
||||
from . import paste
|
||||
|
||||
NOT_RUNNING = 3
|
||||
|
||||
# Verbs that start the application when none is running, which is what the KDE
|
||||
# shortcut has always relied on: press the key on a fresh login and Dikte comes
|
||||
# up recording.
|
||||
# Verbs that start the application when none is running, which is what a
|
||||
# shortcut registered with the desktop has always relied on: press the key on a
|
||||
# fresh login and Dikte comes up recording.
|
||||
GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
|
||||
|
||||
# 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.
|
||||
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
|
||||
"ask-reset", "meeting-cancel"}
|
||||
"ask-reset", "meeting-cancel", "pause"}
|
||||
|
||||
_app = None
|
||||
|
||||
@@ -782,7 +782,8 @@ def cmd_status(opts):
|
||||
return fail(opts, "the running instance is from before it could answer "
|
||||
"questions; reload it with: dikte restart", 1, running=True)
|
||||
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"meeting: {reply.get('meeting', '?')}"
|
||||
+ (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.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)
|
||||
|
||||
# --- the agent --------------------------------------------------------
|
||||
@@ -6,12 +6,12 @@ import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import api
|
||||
import ggml
|
||||
import i18n
|
||||
import paste
|
||||
import paths
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import ggml
|
||||
from . import i18n
|
||||
from . import paste
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
|
||||
_MACOS = sys.platform == "darwin"
|
||||
@@ -435,6 +435,10 @@ DEFAULTS = {
|
||||
# trick lands on the toggle, Alt and Option being one key, so discarding
|
||||
# gets a letter instead.
|
||||
"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,
|
||||
"overlay_corner": "bottom-left",
|
||||
"keep_audio": False,
|
||||
@@ -23,10 +23,10 @@ import wave
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import ggml
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import cleanup
|
||||
from . import ggml
|
||||
from .i18n import t
|
||||
|
||||
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
|
||||
@@ -45,9 +45,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
import hub
|
||||
import paths
|
||||
from i18n import t
|
||||
from . import hub
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
# The path api.py asks for, so its URL and the server's line up.
|
||||
+113
-41
@@ -1,11 +1,16 @@
|
||||
"""Global shortcuts: the desktop's own registry, plus a listener of our own.
|
||||
|
||||
Two things have to happen for a key combination to reach Dikte. Somewhere has
|
||||
to be told about it, and something has to be listening. On Linux that is the
|
||||
desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of
|
||||
/dev/input for the wait until the registry is live. macOS has no registry to
|
||||
write into: the application asks Carbon for the combination while it runs, so
|
||||
there the listener is not a fallback but the whole mechanism.
|
||||
to be told about it, and something has to be listening. Two desktops keep a
|
||||
registry we can write into and read back, and something outside Dikte acts on
|
||||
it: KDE has a file, GNOME has gsettings. There the /dev/input listener only
|
||||
covers the wait until the registry is live.
|
||||
|
||||
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
|
||||
@@ -25,10 +30,11 @@ import threading
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from i18n import t
|
||||
from .i18n import t
|
||||
|
||||
DESKTOP_ID = "dikte-toggle.desktop"
|
||||
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
||||
PAUSE_DESKTOP_ID = "dikte-pause.desktop"
|
||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
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")
|
||||
|
||||
# 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
|
||||
# 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
|
||||
@@ -47,6 +53,8 @@ Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fall
|
||||
SHORTCUTS = {
|
||||
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
|
||||
"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", ""),
|
||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
||||
@@ -102,13 +110,23 @@ def parse_shortcut(text):
|
||||
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 ----------------------------------------------------
|
||||
|
||||
class EvdevHotkey(QObject):
|
||||
"""Catches global shortcuts by reading /dev/input directly.
|
||||
|
||||
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
|
||||
@@ -151,6 +169,10 @@ class EvdevHotkey(QObject):
|
||||
))
|
||||
return False
|
||||
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._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True)
|
||||
self._thread.start()
|
||||
@@ -161,6 +183,7 @@ class EvdevHotkey(QObject):
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1.5)
|
||||
self._thread = None
|
||||
_REGISTERED.clear()
|
||||
|
||||
def _open_devices(self):
|
||||
fds = []
|
||||
@@ -243,12 +266,6 @@ HOTKEY_PRESSED = 5 # kEventHotKeyPressed
|
||||
PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard
|
||||
HOTKEY_ID_PARAMETER = "hkid"
|
||||
|
||||
# What the running listener holds. This is the whole of "installed" on macOS,
|
||||
# and it lasts as long as the process does: there is no file, and no other
|
||||
# program to read one. Written by CarbonHotkey.start(), read by the status
|
||||
# line, so what Settings shows is what the Mac actually gave us.
|
||||
_REGISTERED = {}
|
||||
|
||||
|
||||
def parse_macos_shortcut(text):
|
||||
"""'Cmd+Space' -> (256, 49), or (None, None) when unusable."""
|
||||
@@ -584,6 +601,17 @@ class WinHotkey(QObject):
|
||||
|
||||
# --- 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():
|
||||
return sys.platform == "darwin"
|
||||
|
||||
@@ -592,9 +620,27 @@ def _windows():
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def _gnome():
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
return "gnome" in desktop and shutil.which("gsettings") is not None
|
||||
def backend():
|
||||
"""Which shortcut mechanism this session has.
|
||||
|
||||
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):
|
||||
@@ -748,57 +794,80 @@ def installs_shortcuts():
|
||||
"""Whether this system keeps a shortcut registry to write into.
|
||||
|
||||
KDE and GNOME do, and something outside Dikte reads it, so the combination
|
||||
survives Dikte being closed. macOS and Windows do not: there is nothing to
|
||||
install, nothing to remove, and Settings should not offer either.
|
||||
survives Dikte being closed. macOS, Windows and the plain listener do not:
|
||||
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():
|
||||
"""Whether an installed shortcut waits for the next login before it works.
|
||||
|
||||
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding
|
||||
up as it is written, and macOS 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",
|
||||
desktop_id=DESKTOP_ID):
|
||||
if _macos() or _windows():
|
||||
_REGISTERED[desktop_id] = shortcut
|
||||
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
|
||||
if which in (MACOS, WINDOWS):
|
||||
return True, t(
|
||||
"Shortcut saved: {shortcut}\nDikte holds this one itself while it "
|
||||
"is running, so it works as soon as the settings are saved.",
|
||||
shortcut=shortcut,
|
||||
)
|
||||
if _gnome():
|
||||
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
|
||||
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
|
||||
return True, t(
|
||||
"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.",
|
||||
shortcut=shortcut, desktop=desktop_name(),
|
||||
)
|
||||
|
||||
|
||||
def remove_shortcut(desktop_id=DESKTOP_ID):
|
||||
if _macos() or _windows():
|
||||
_REGISTERED.pop(desktop_id, None)
|
||||
elif _gnome():
|
||||
which = backend()
|
||||
if which == GNOME:
|
||||
remove_gnome_shortcut(desktop_id)
|
||||
else:
|
||||
elif which == KDE:
|
||||
remove_kde_shortcut(desktop_id)
|
||||
else:
|
||||
_REGISTERED.pop(desktop_id, None)
|
||||
|
||||
|
||||
def shortcut_status(desktop_id=DESKTOP_ID):
|
||||
if _macos() or _windows():
|
||||
return _REGISTERED.get(desktop_id)
|
||||
return (gnome_shortcut_status(desktop_id) if _gnome()
|
||||
else kde_shortcut_status(desktop_id))
|
||||
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)
|
||||
|
||||
|
||||
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"
|
||||
if _windows():
|
||||
if which == 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 ------------------------------------------------------------------
|
||||
@@ -882,9 +951,12 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
|
||||
|
||||
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
|
||||
"""Names of other KDE entries bound to the same combination."""
|
||||
if _macos() or _windows():
|
||||
# There is no list to read: both answer the question by refusing the
|
||||
# registration, which their listeners report when they ask for the key.
|
||||
if backend() != KDE:
|
||||
# Nowhere else has a list to read. macOS and Windows answer the question
|
||||
# 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 []
|
||||
try:
|
||||
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
|
||||
@@ -25,7 +25,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from i18n import t
|
||||
from .i18n import t
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
HF_API = "https://huggingface.co/api"
|
||||
@@ -56,12 +56,15 @@ TR = {
|
||||
"Start recording": "Kaydı başlat",
|
||||
"Stop and transcribe": "Kaydı bitir ve yaz",
|
||||
"Working…": "İşleniyor…",
|
||||
"Pause the recording": "Kaydı duraklat",
|
||||
"Resume the recording": "Kayda devam et",
|
||||
"Discard the recording": "Kaydı iptal et",
|
||||
"Settings…": "Ayarlar…",
|
||||
"Restart": "Yeniden başlat",
|
||||
"Quit": "Çık",
|
||||
"Dikte: ready": "Dikte: hazır",
|
||||
"Dikte: recording": "Dikte: kaydediyor",
|
||||
"Dikte: paused": "Dikte: duraklatıldı",
|
||||
"Dikte: working": "Dikte: işleniyor",
|
||||
|
||||
# --- overlay / pipeline -------------------------------------------
|
||||
@@ -309,6 +312,8 @@ TR = {
|
||||
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
|
||||
"No KDE shortcut installed.": "KDE kısayolu kurulu değil.",
|
||||
"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. The tray menu starts a meeting too.":
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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 "
|
||||
"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, "
|
||||
@@ -345,6 +357,23 @@ TR = {
|
||||
"meantime.":
|
||||
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey "
|
||||
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
|
||||
"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} 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?",
|
||||
@@ -361,6 +390,12 @@ TR = {
|
||||
"running, so it works as soon as the settings are saved.":
|
||||
"Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi "
|
||||
"tutar, yani ayarlar kaydedilir kaydedilmez çalışır.",
|
||||
"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}":
|
||||
"macOS kısayol servisine ulaşılamadı: {error}",
|
||||
"macOS would not give Dikte {shortcut}; another application already holds it.":
|
||||
+11
-2
@@ -24,13 +24,22 @@ CONNECT_MS = 800
|
||||
|
||||
|
||||
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(
|
||||
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):
|
||||
"""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}"
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ import wave
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import vad
|
||||
from filetranscribe import Cancelled, format_timestamp
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import vad
|
||||
from .filetranscribe import Cancelled, format_timestamp
|
||||
from .i18n import t
|
||||
|
||||
# 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
|
||||
@@ -26,6 +26,9 @@ WARN = QColor(240, 180, 80)
|
||||
THEM = QColor(110, 190, 255) # the other side of a meeting
|
||||
|
||||
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,
|
||||
"done": OK, "warning": WARN, "error": ERR}
|
||||
@@ -47,6 +50,10 @@ class Overlay(QWidget):
|
||||
self.dismissable = dismissable
|
||||
self.muted = 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.message = ""
|
||||
self.levels = [0.0] * BARS
|
||||
@@ -103,6 +110,7 @@ class Overlay(QWidget):
|
||||
self.message = ""
|
||||
self.seconds = 0.0
|
||||
self.levels = [0.0] * BARS
|
||||
self.paused = False
|
||||
self.muted = False # a new run starts visible, whatever the last one did
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
@@ -114,6 +122,7 @@ class Overlay(QWidget):
|
||||
self.seconds = 0.0
|
||||
self.levels = [0.0] * BARS
|
||||
self.levels2 = [0.0] * BARS
|
||||
self.paused = False
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
|
||||
@@ -175,6 +184,17 @@ class Overlay(QWidget):
|
||||
def set_seconds(self, 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 -----------------------------------------------------
|
||||
|
||||
def _appear(self):
|
||||
@@ -244,11 +264,11 @@ class Overlay(QWidget):
|
||||
# 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:
|
||||
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
|
||||
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
||||
if self.state == "meeting":
|
||||
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
|
||||
if self.state == "meeting":
|
||||
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
|
||||
self.update()
|
||||
|
||||
def _label_font(self):
|
||||
@@ -273,6 +293,8 @@ class Overlay(QWidget):
|
||||
painter.drawPath(path)
|
||||
|
||||
accent = STATE_COLORS.get(self.state, MUTED)
|
||||
if self._held:
|
||||
accent = HELD
|
||||
self._draw_indicator(painter, accent)
|
||||
|
||||
if self.state in LIVE:
|
||||
@@ -283,10 +305,23 @@ class Overlay(QWidget):
|
||||
if self._can_dismiss:
|
||||
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):
|
||||
cx, cy = 26.0, self.height() / 2
|
||||
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))
|
||||
glow = QColor(accent)
|
||||
glow.setAlphaF(0.22 * pulse)
|
||||
@@ -19,7 +19,7 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from i18n import t
|
||||
from .i18n import t
|
||||
|
||||
# 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
|
||||
@@ -4,28 +4,28 @@ import os
|
||||
import shutil
|
||||
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.QtWidgets import (
|
||||
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||
QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
||||
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
||||
QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
|
||||
QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import ggml
|
||||
import hotkey
|
||||
import ipc
|
||||
import meeting
|
||||
import paste
|
||||
from filetranscribe import FileTranscriber
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import assistant
|
||||
from . import audio
|
||||
from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import ggml
|
||||
from . import hotkey
|
||||
from . import ipc
|
||||
from . import meeting
|
||||
from . import paste
|
||||
from .filetranscribe import FileTranscriber
|
||||
from .i18n import t
|
||||
|
||||
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
|
||||
LANGUAGES = [
|
||||
@@ -127,6 +127,58 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
|
||||
"*.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):
|
||||
"""The program, the model, and the two downloads that put them there.
|
||||
|
||||
@@ -160,8 +212,7 @@ class LocalModelBox(QGroupBox):
|
||||
|
||||
form = QFormLayout(self)
|
||||
|
||||
self.program_label = QLabel("")
|
||||
self.program_label.setWordWrap(True)
|
||||
self.program_label = WrappedLabel()
|
||||
self.install_button = QPushButton(t("Download"))
|
||||
self.install_button.clicked.connect(self._install_program)
|
||||
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
||||
@@ -484,18 +535,22 @@ class SettingsWindow(QDialog):
|
||||
self._shown_provider = ""
|
||||
self.transcriber = FileTranscriber(conf, self)
|
||||
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.addTab(self._general_tab(), t("General"))
|
||||
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
|
||||
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
||||
tabs.addTab(self._assistant_tab(), t("Agent"))
|
||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
||||
tabs.addTab(self._minutes_tab(), t("Minutes"))
|
||||
tabs.addTab(self._file_tab(), t("Audio file"))
|
||||
tabs.addTab(self._shortcut_tab(), t("Shortcuts"))
|
||||
tabs.addTab(self._history_tab(), t("History"))
|
||||
tabs.addTab(self._scrolled(self._general_tab()), t("General"))
|
||||
self.api_tab_index = tabs.addTab(
|
||||
self._scrolled(self._api_tab()), t("API and models"))
|
||||
tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules"))
|
||||
tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent"))
|
||||
tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting"))
|
||||
tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes"))
|
||||
tabs.addTab(self._scrolled(self._file_tab()), t("Audio file"))
|
||||
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
|
||||
# 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.addWidget(tabs)
|
||||
layout.addWidget(buttons)
|
||||
self._size_to_screen(680, 640)
|
||||
|
||||
self._models_loaded.connect(self._on_models_loaded)
|
||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||
@@ -528,6 +584,40 @@ class SettingsWindow(QDialog):
|
||||
if not conf.transcribe_ready():
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
def _general_tab(self):
|
||||
@@ -975,12 +1065,7 @@ class SettingsWindow(QDialog):
|
||||
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
|
||||
)
|
||||
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
area.setWidget(page)
|
||||
return area
|
||||
return page
|
||||
|
||||
def _meeting_tab(self):
|
||||
page = QWidget()
|
||||
@@ -1119,14 +1204,7 @@ class SettingsWindow(QDialog):
|
||||
lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt())
|
||||
)
|
||||
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# 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
|
||||
return page
|
||||
|
||||
def _minutes_tab(self):
|
||||
page = QWidget()
|
||||
@@ -1255,6 +1333,14 @@ class SettingsWindow(QDialog):
|
||||
form, "toggle", t("Start and stop"),
|
||||
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
|
||||
# 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.
|
||||
@@ -1289,15 +1375,40 @@ class SettingsWindow(QDialog):
|
||||
)
|
||||
elif hotkey.installs_shortcuts():
|
||||
explanation = t("The shortcut starts working as soon as it is installed.")
|
||||
else:
|
||||
elif hotkey.backend() == hotkey.MACOS:
|
||||
explanation = t(
|
||||
"Dikte asks macOS for these combinations itself, while it is "
|
||||
"running. Nothing is installed, and no other application receives "
|
||||
"them in the meantime."
|
||||
)
|
||||
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.setWordWrap(True)
|
||||
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)
|
||||
return page
|
||||
|
||||
@@ -1368,7 +1479,7 @@ class SettingsWindow(QDialog):
|
||||
"""The field a global shortcut is typed or picked in."""
|
||||
box = QComboBox()
|
||||
box.setEditable(True)
|
||||
box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS"
|
||||
box.addItems(MAC_SHORTCUTS if hotkey.backend() == hotkey.MACOS
|
||||
else SHORTCUTS)
|
||||
box.setCurrentText("")
|
||||
if placeholder:
|
||||
@@ -1417,8 +1528,9 @@ class SettingsWindow(QDialog):
|
||||
def _install_buttons(install_handler, remove_handler):
|
||||
"""Install and Remove, where this system has somewhere to install into.
|
||||
|
||||
macOS has not: Dikte asks for the combination itself while it runs, so
|
||||
there is nothing to write down and nothing to take back out.
|
||||
macOS has not, and neither has a Linux desktop that keeps no registry:
|
||||
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():
|
||||
return []
|
||||
@@ -1634,8 +1746,8 @@ class SettingsWindow(QDialog):
|
||||
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||
|
||||
# Left empty, only the toggle falls back to a default: the application
|
||||
# is unusable without it. The other three stay empty, which is what
|
||||
# turns them off.
|
||||
# is unusable without it. The rest stay empty, which is what turns
|
||||
# them off.
|
||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||
spec = hotkey.SHORTCUTS[which]
|
||||
conf[spec.setting] = (box.currentText().strip()
|
||||
@@ -1916,11 +2028,16 @@ class SettingsWindow(QDialog):
|
||||
def _refresh_shortcut_status(self, which):
|
||||
_box, status, missing = self._shortcut_rows[which]
|
||||
current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
|
||||
status.setText(
|
||||
t("Registered in {desktop}: {shortcut}",
|
||||
desktop=hotkey.desktop_name(), shortcut=current) if current
|
||||
else missing
|
||||
)
|
||||
if not current:
|
||||
status.setText(missing)
|
||||
elif hotkey.installs_shortcuts():
|
||||
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):
|
||||
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
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import i18n
|
||||
import paste
|
||||
import vad
|
||||
from i18n import t
|
||||
from . import api
|
||||
from . import assistant
|
||||
from . import audio
|
||||
from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import i18n
|
||||
from . import paste
|
||||
from . import vad
|
||||
from .i18n import t
|
||||
|
||||
CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE
|
||||
|
||||
+6
-3
@@ -11,6 +11,9 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$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")
|
||||
$startup = [Environment]::GetFolderPath("Startup")
|
||||
$shortcut = Join-Path $startMenu "Dikte.lnk"
|
||||
@@ -55,7 +58,7 @@ $shell = New-Object -ComObject WScript.Shell
|
||||
foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) {
|
||||
$link = $shell.CreateShortcut($path)
|
||||
$link.TargetPath = $pythonw
|
||||
$link.Arguments = "`"$repo\dikte.py`" --gui"
|
||||
$link.Arguments = "`"$entry`" --gui"
|
||||
$link.WorkingDirectory = $repo
|
||||
$link.Description = "Dikte: dictation"
|
||||
$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.
|
||||
$shimDir = Split-Path $cmdShim
|
||||
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
|
||||
Write-Host "command: dikte ($cmdShim)"
|
||||
} 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 ""
|
||||
|
||||
+87
-20
@@ -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
|
||||
# update.sh reaches it through here without having to know which it is on.
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
exec "$DIR/install-mac.sh" "$@"
|
||||
exec "$DIR/scripts/install-mac.sh" "$@"
|
||||
fi
|
||||
|
||||
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"
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
SHORTCUT="${1:-Ctrl+Space}"
|
||||
# Without the colon, so that a second argument given as "" stays empty. That is
|
||||
# how update.sh says "this one was turned off", as against not saying anything.
|
||||
CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
|
||||
# Only the one: the discard key's default is the settings' own, read back below.
|
||||
DEFAULT_SHORTCUT="Ctrl+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"; }
|
||||
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
|
||||
|
||||
# 3. Launchers -------------------------------------------------------------
|
||||
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR"
|
||||
ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte"
|
||||
chmod +x "$DIR/dikte.py"
|
||||
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR"
|
||||
ln -sf "$ENTRY" "$BIN_DIR/dikte"
|
||||
chmod +x "$ENTRY"
|
||||
ok "Command installed: $BIN_DIR/dikte"
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*) warn "$BIN_DIR is not on your PATH. For fish: fish_add_path $BIN_DIR" ;;
|
||||
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
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Dikte
|
||||
Comment=Voice dictation: record, transcribe, clean up, paste
|
||||
Exec=$PY $DIR/dikte.py
|
||||
Icon=audio-input-microphone
|
||||
Exec=$PY $ENTRY
|
||||
Icon=$ICON
|
||||
Categories=Utility;AudioVideo;
|
||||
StartupNotify=false
|
||||
EOF
|
||||
@@ -106,8 +144,8 @@ cat > "$AUTOSTART_DIR/dikte.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Dikte
|
||||
Exec=$PY $DIR/dikte.py
|
||||
Icon=audio-input-microphone
|
||||
Exec=$PY $ENTRY
|
||||
Icon=$ICON
|
||||
X-GNOME-Autostart-enabled=true
|
||||
StartupNotify=false
|
||||
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
|
||||
# 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.
|
||||
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 where Dikte
|
||||
# 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."
|
||||
CANCEL_SHORTCUT=""
|
||||
fi
|
||||
|
||||
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"
|
||||
else
|
||||
# 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
|
||||
register cancel "$CANCEL_SHORTCUT" "Discard the recording"
|
||||
fi
|
||||
if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then
|
||||
warn "KWin only reads these at startup, so they go live after your next"
|
||||
say "login. Until then open Settings → Shortcuts and turn on the"
|
||||
say "built-in listener to use them right away."
|
||||
fi
|
||||
# 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"
|
||||
say "login. Until then open Settings → Shortcuts and turn on the"
|
||||
say "built-in listener to use them right away."
|
||||
;;
|
||||
gnome) ;;
|
||||
*)
|
||||
if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx input; then
|
||||
say "Your desktop keeps no shortcut registry, so Dikte listens for these"
|
||||
say "keys itself while it is running."
|
||||
else
|
||||
warn "Your desktop keeps no shortcut registry, so Dikte listens for these"
|
||||
say "keys itself, and it cannot read /dev/input yet:"
|
||||
say " sudo usermod -aG input $(id -un) (then log out and back in)"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
|
||||
say "dikte shortcut install toggle --combo '$SHORTCUT'"
|
||||
|
||||
@@ -11,19 +11,38 @@
|
||||
# Homebrew moves that copy.
|
||||
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="$APP_DIR/Dikte.app"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
AGENT_DIR="$HOME/Library/LaunchAgents"
|
||||
AGENT_ID="io.github.yusufipk.dikte"
|
||||
AGENT="$AGENT_DIR/$AGENT_ID.plist"
|
||||
SHORTCUT="${1:-Ctrl+Option+Space}"
|
||||
# Without the colon, so that a second argument given as "" stays empty. That is
|
||||
# how update.sh says "this one was turned off", as against not saying anything.
|
||||
CANCEL_SHORTCUT="${2-Ctrl+Option+D}"
|
||||
# Only the one: the discard key's default is the settings' own, read back below.
|
||||
DEFAULT_SHORTCUT="Ctrl+Option+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
|
||||
|
||||
# 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
|
||||
# installed does not have them, and this script would then report ffmpeg as
|
||||
# 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
|
||||
exit 1
|
||||
fi
|
||||
exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@"
|
||||
exec "\$HERE/python3" "$ENTRY" --gui "\$@"
|
||||
EOF
|
||||
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
|
||||
# not worth stopping for: a bundle with no icon gets the generic one.
|
||||
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
|
||||
ok "Icon drawn"
|
||||
else
|
||||
@@ -212,7 +231,7 @@ fi
|
||||
-f "$APP" >/dev/null 2>&1 || true
|
||||
|
||||
# 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
|
||||
# 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
|
||||
@@ -221,9 +240,9 @@ mkdir -p "$BIN_DIR"
|
||||
cat > "$BIN_DIR/dikte" <<EOF
|
||||
#!/bin/sh
|
||||
# Written by install-mac.sh. Edit that, not this.
|
||||
exec "$PY" "$DIR/dikte.py" "\$@"
|
||||
exec "$PY" "$ENTRY" "\$@"
|
||||
EOF
|
||||
chmod +x "$BIN_DIR/dikte" "$DIR/dikte.py"
|
||||
chmod +x "$BIN_DIR/dikte" "$ENTRY"
|
||||
ok "Command installed: $BIN_DIR/dikte"
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
@@ -271,14 +290,27 @@ fi
|
||||
# shortcut registry at all, so "installed" means the running application is
|
||||
# holding the combination, and the settings file is where it reads it from
|
||||
# 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."
|
||||
CANCEL_SHORTCUT=""
|
||||
fi
|
||||
|
||||
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"
|
||||
else
|
||||
warn "${out%%$'\n'*}"
|
||||
@@ -4,7 +4,10 @@
|
||||
# is the word that deletes them.
|
||||
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)"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
|
||||
@@ -29,6 +32,7 @@ else
|
||||
MACOS=0
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
|
||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
||||
PY="$(command -v python3 || true)"
|
||||
@@ -48,7 +52,7 @@ count() {
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ./uninstall.sh [--purge] [--yes]
|
||||
Usage: ./scripts/uninstall.sh [--purge] [--yes]
|
||||
|
||||
--purge also delete the settings ($CONFIG_DIR)
|
||||
and the dictations, meetings and recordings ($DATA_DIR)
|
||||
@@ -84,17 +88,24 @@ echo "──────────────────"
|
||||
|
||||
# 1. Global shortcuts ------------------------------------------------------
|
||||
# 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
|
||||
# keeps no registry: the combinations are held by the running process and are
|
||||
# gone the moment it stops, so there is nothing here to take back.
|
||||
# whether they went into KDE's kglobalshortcutsrc, GNOME's gsettings, or
|
||||
# nowhere at all. macOS and the desktops with no registry hold the combinations
|
||||
# in the running process, where they are gone the moment it stops, so there is
|
||||
# nothing there to take back.
|
||||
if ((MACOS)); then
|
||||
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
|
||||
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||
for which in toggle cancel ask meeting; do
|
||||
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
||||
for which in toggle pause cancel ask meeting; do
|
||||
"$PY" "$ENTRY" shortcut remove "$which" >/dev/null 2>&1 || true
|
||||
done
|
||||
ok "Global shortcuts unregistered"
|
||||
say "KWin reads that file at startup, so the keys are free after your next login."
|
||||
case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
|
||||
kde)
|
||||
ok "Global shortcuts unregistered"
|
||||
say "KWin reads that file at startup, so the keys are free after your next login."
|
||||
;;
|
||||
gnome) ok "Global shortcuts unregistered" ;;
|
||||
*) say "Nothing to unregister: Dikte listened for the keys itself, and they stop with it." ;;
|
||||
esac
|
||||
else
|
||||
warn "PyQt6 is missing, so the shortcuts were left registered."
|
||||
say "Remove them in your desktop's shortcut settings."
|
||||
@@ -103,10 +114,12 @@ fi
|
||||
# 2. The running instance --------------------------------------------------
|
||||
# It holds a tray icon and a socket; asking it to quit is tidier than pulling
|
||||
# its launchers out from under it.
|
||||
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
||||
[[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true
|
||||
# The pattern matches an instance started before Dikte became a package as well,
|
||||
# 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
|
||||
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"
|
||||
else
|
||||
ok "Stopped the running instance"
|
||||
@@ -148,10 +161,23 @@ fi
|
||||
if ((!MACOS)); then
|
||||
remove "$APP_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
|
||||
# before this script existed may have left one behind on a desktop that never
|
||||
# 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
|
||||
remove "$APP_DIR/$id.desktop"
|
||||
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
|
||||
# about, so naming it twice would only look like two things were kept.
|
||||
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
|
||||
say "Settings kept: $CONFIG_DIR"
|
||||
say "Dictations kept: $DATA_DIR"
|
||||
say "Delete them too with: ./uninstall.sh --purge"
|
||||
say "Delete them too with: ./scripts/uninstall.sh --purge"
|
||||
fi
|
||||
|
||||
echo
|
||||
@@ -2,7 +2,10 @@
|
||||
# Dikte updater: pull, put the launchers back, restart what was running.
|
||||
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)"
|
||||
|
||||
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.
|
||||
PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)"
|
||||
[[ -x "$PY" ]] || PY="$(command -v python3 || true)"
|
||||
DEFAULT_SHORTCUT="Ctrl+Option+Space"
|
||||
else
|
||||
PY="$(command -v python3 || true)"
|
||||
DEFAULT_SHORTCUT="Ctrl+Space"
|
||||
fi
|
||||
|
||||
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"; }
|
||||
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 "Updating Dikte"
|
||||
echo "──────────────"
|
||||
@@ -90,18 +84,15 @@ echo
|
||||
|
||||
# 4. Launchers --------------------------------------------------------------
|
||||
# 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
|
||||
# is told what those are. Read before the installer runs, since it is the one
|
||||
# writing them.
|
||||
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:-}"
|
||||
# With no keys named it keeps the ones stored in the settings, which are the
|
||||
# ones you chose.
|
||||
"$DIR/install.sh"
|
||||
|
||||
# 5. The running instance ---------------------------------------------------
|
||||
# It is still running the code from before the pull.
|
||||
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
||||
if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then
|
||||
# It is still running the code from before the pull, which on an update across
|
||||
# the move into a package is a process still named after the old entry point.
|
||||
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"
|
||||
else
|
||||
warn "Could not restart it; use the tray menu → Restart"
|
||||
+3
-3
@@ -22,9 +22,9 @@ import urllib.request
|
||||
import wave
|
||||
from unittest import mock
|
||||
|
||||
import assistant
|
||||
import config as cfg
|
||||
import i18n
|
||||
from dikte import assistant
|
||||
from dikte import config as cfg
|
||||
from dikte import i18n
|
||||
|
||||
# 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
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import api
|
||||
import ggml
|
||||
from dikte import api
|
||||
from dikte import ggml
|
||||
from tests.support import (
|
||||
DikteTest,
|
||||
fake_urlopen,
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import assistant
|
||||
from dikte import assistant
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
|
||||
|
||||
|
||||
+66
-1
@@ -21,7 +21,7 @@ import unittest
|
||||
import wave
|
||||
from unittest import mock
|
||||
|
||||
import audio
|
||||
from dikte import audio
|
||||
from tests.support import (
|
||||
DikteTest,
|
||||
FakeCompleted,
|
||||
@@ -280,6 +280,26 @@ class _StalledStream:
|
||||
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):
|
||||
"""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.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):
|
||||
recorder = audio.Recorder()
|
||||
failures = []
|
||||
|
||||
@@ -11,9 +11,9 @@ import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import ggml
|
||||
from dikte import api
|
||||
from dikte import cleanup
|
||||
from dikte import ggml
|
||||
from tests.support import DikteTest, fake_urlopen, sent_json, url_error
|
||||
from tests.test_api import FakeServer, chat_reply
|
||||
|
||||
|
||||
+16
-8
@@ -12,13 +12,13 @@ import json
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import audio
|
||||
import cli
|
||||
import config as cfg
|
||||
import ggml
|
||||
import hotkey
|
||||
import ipc
|
||||
import paste
|
||||
from dikte import audio
|
||||
from dikte import cli
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
from dikte import ipc
|
||||
from dikte import paste
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ class Parser(unittest.TestCase):
|
||||
name)
|
||||
|
||||
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",
|
||||
"config", "prompt", "devices", "models", "test-key",
|
||||
"doctor", "shortcut", "status", "settings", "restart",
|
||||
@@ -660,6 +660,14 @@ class Replies(DikteTest):
|
||||
captured():
|
||||
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):
|
||||
"""`dikte transcribe` runs in this process, not in the instance."""
|
||||
|
||||
@@ -12,12 +12,12 @@ import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import ggml
|
||||
import i18n
|
||||
import paste
|
||||
from dikte import api
|
||||
from dikte import cleanup
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import i18n
|
||||
from dikte import paste
|
||||
from tests.support import DikteTest
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import unittest
|
||||
import wave
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import filetranscribe as ft
|
||||
from dikte import api
|
||||
from dikte import filetranscribe as ft
|
||||
from tests.support import DikteTest, make_wav, silence, tone
|
||||
|
||||
|
||||
|
||||
+14
-4
@@ -18,8 +18,8 @@ import time
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
import ggml
|
||||
import hub
|
||||
from dikte import ggml
|
||||
from dikte import hub
|
||||
from tests.support import (DikteTest, fake_urlopen, http_error, json_body,
|
||||
linux_only, url_error)
|
||||
|
||||
@@ -449,7 +449,7 @@ class Catalogue(Local):
|
||||
|
||||
|
||||
STAND_IN = textwrap.dedent("""
|
||||
import http.server, sys, threading, time
|
||||
import http.server, socketserver, sys, threading, time
|
||||
|
||||
args = sys.argv[1:]
|
||||
|
||||
@@ -475,7 +475,17 @@ STAND_IN = textwrap.dedent("""
|
||||
def log_message(self, *a):
|
||||
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)
|
||||
server.serve_forever()
|
||||
""")
|
||||
|
||||
+75
-13
@@ -10,8 +10,8 @@ from unittest import mock
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
import config as cfg
|
||||
import hotkey
|
||||
from dikte import config as cfg
|
||||
from dikte import hotkey
|
||||
from tests.support import DikteTest, FakeCompleted, linux_only
|
||||
|
||||
SHORTCUTS_RC = """[services][dikte-toggle.desktop]
|
||||
@@ -178,38 +178,69 @@ class Bindings(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):
|
||||
super().setUp()
|
||||
self.patch_attr(hotkey.sys, "platform", "linux")
|
||||
self.addCleanup(hotkey._REGISTERED.clear)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def under(self, desktop, has_gsettings=True):
|
||||
"""A session that says it is this desktop, with or without gsettings."""
|
||||
def under(self, desktop, tools=True):
|
||||
"""A session that says it is this desktop, with or without its tools."""
|
||||
with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \
|
||||
mock.patch.object(hotkey.shutil, "which",
|
||||
return_value="/usr/bin/gsettings"
|
||||
if has_gsettings else None):
|
||||
return_value="/usr/bin/tool" if tools else None):
|
||||
yield
|
||||
|
||||
def test_gnome_when_the_session_says_so_and_gsettings_is_there(self):
|
||||
with self.under("GNOME"):
|
||||
self.assertEqual(hotkey.backend(), hotkey.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"):
|
||||
self.assertEqual(hotkey.backend(), hotkey.KDE)
|
||||
self.assertEqual(hotkey.desktop_name(), "KDE")
|
||||
|
||||
def test_a_gnome_session_with_no_gsettings_falls_back(self):
|
||||
"""Nothing to write the binding with, so KDE's file is the only try."""
|
||||
with self.under("GNOME", has_gsettings=False):
|
||||
self.assertEqual(hotkey.desktop_name(), "KDE")
|
||||
def test_a_desktop_with_no_registry_is_the_listeners(self):
|
||||
"""The bug this replaced: i3 was told KDE, and KWin was not running."""
|
||||
for desktop in ("i3", "XFCE", "X-Cinnamon", "sway", "MATE", ""):
|
||||
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):
|
||||
for desktop in ("GNOME", "ubuntu:GNOME", "gnome"):
|
||||
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):
|
||||
with self.under("GNOME"), \
|
||||
@@ -224,6 +255,20 @@ class Chooser(DikteTest):
|
||||
hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
|
||||
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):
|
||||
with self.under("GNOME"), \
|
||||
mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \
|
||||
@@ -234,6 +279,17 @@ class Chooser(DikteTest):
|
||||
remove.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
|
||||
class GnomeAccelerator(DikteTest):
|
||||
@@ -376,6 +432,12 @@ class KdeShortcut(DikteTest):
|
||||
self.rc = self.path("kglobalshortcutsrc")
|
||||
self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps)
|
||||
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):
|
||||
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
|
||||
import hub
|
||||
from dikte import hub
|
||||
from tests.support import DikteTest, fake_urlopen, http_error, url_error
|
||||
|
||||
RELEASE = {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import string
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import i18n
|
||||
from dikte import i18n
|
||||
from tests.support import DikteTest
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import ipc
|
||||
from dikte import ipc
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
@@ -57,7 +57,7 @@ class FakeSocket:
|
||||
|
||||
class Paths(unittest.TestCase):
|
||||
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()))
|
||||
|
||||
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
|
||||
|
||||
@@ -11,9 +11,9 @@ import unittest
|
||||
import wave
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import config as cfg
|
||||
import meeting
|
||||
from dikte import api
|
||||
from dikte import config as cfg
|
||||
from dikte import meeting
|
||||
from tests.support import DikteTest, make_wav, silence, speech, stereo, tone
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import unittest
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
import paste
|
||||
from dikte import paste
|
||||
from tests.support import DikteTest, FakeCompleted, only_these_tools
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -9,9 +9,9 @@ import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import config as cfg
|
||||
import ggml
|
||||
import paths
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import paths
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from PyQt6.QtCore import QPoint, QPointF, Qt
|
||||
from PyQt6.QtGui import QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
import audio
|
||||
import cleanup
|
||||
import config as cfg
|
||||
import ggml
|
||||
import hotkey
|
||||
import overlay as overlay_module
|
||||
import paste
|
||||
import settings_ui
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
from dikte import overlay as overlay_module
|
||||
from dikte import paste
|
||||
from dikte import settings_ui
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
# One application for the whole run; Qt allows no second one.
|
||||
@@ -96,6 +99,7 @@ CHANGED = {
|
||||
"file_cleanup": False,
|
||||
"shortcut": "Ctrl+Alt+Space",
|
||||
"cancel_shortcut": "Meta+Shift+Space",
|
||||
"pause_shortcut": "Meta+P",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
}
|
||||
@@ -106,18 +110,30 @@ class Settings(DikteTest):
|
||||
# one. Everything else about the window is the same on both.
|
||||
changed = CHANGED
|
||||
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):
|
||||
super().setUp()
|
||||
# No pactl, no model lists over the network, and no modal dialogue
|
||||
# waiting for somebody to press OK.
|
||||
self.enterContext(mock.patch.object(sys, "platform", self.platform))
|
||||
self.enterContext(only_these_tools())
|
||||
self.enterContext(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(settings_ui.SettingsWindow,
|
||||
"_load_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_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.path("applications")))
|
||||
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
|
||||
@@ -129,12 +145,81 @@ class Settings(DikteTest):
|
||||
self.addCleanup(window.close)
|
||||
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):
|
||||
window = self.window(cfg.Config())
|
||||
tabs = window.findChildren(settings_ui.QTabWidget)[0]
|
||||
self.assertEqual(tabs.count(), 9)
|
||||
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):
|
||||
"""Every widget has to load what is stored, or Save writes its default
|
||||
over it. This says so for the whole table at once."""
|
||||
@@ -180,6 +265,31 @@ class Settings(DikteTest):
|
||||
window = self.window(cfg.Config())
|
||||
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):
|
||||
"""The application is unusable without the toggle, so that one box
|
||||
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.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
|
||||
self.assertEqual(conf["cancel_shortcut"], "")
|
||||
self.assertEqual(conf["pause_shortcut"], "")
|
||||
self.assertEqual(conf["assistant_shortcut"], "")
|
||||
self.assertEqual(conf["meeting_shortcut"], "")
|
||||
|
||||
@@ -340,7 +451,16 @@ class MacSettings(Settings):
|
||||
def test_the_listener_is_not_offered_as_a_choice(self):
|
||||
"""It is the whole mechanism there; turning it off would leave nothing."""
|
||||
window = self.window(cfg.Config())
|
||||
self.assertFalse(window.evdev_enabled.isVisible())
|
||||
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):
|
||||
window = self.window(cfg.Config())
|
||||
@@ -349,6 +469,27 @@ class MacSettings(Settings):
|
||||
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):
|
||||
def overlay(self, **kwargs):
|
||||
widget = overlay_module.Overlay(**kwargs)
|
||||
@@ -393,6 +534,27 @@ class Overlay(DikteTest):
|
||||
widget._conceal()
|
||||
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):
|
||||
widget = self.overlay()
|
||||
widget.show_meeting()
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import unittest
|
||||
|
||||
import vad
|
||||
from dikte import vad
|
||||
from tests.support import DikteTest
|
||||
|
||||
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
|
||||
|
||||
@@ -11,11 +11,11 @@ import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import api
|
||||
import assistant
|
||||
import config as cfg
|
||||
import paste
|
||||
import worker
|
||||
from dikte import api
|
||||
from dikte import assistant
|
||||
from dikte import config as cfg
|
||||
from dikte import paste
|
||||
from dikte import worker
|
||||
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