From f7fa37b4b7c242101a3f9461fd8fa03e6eb45f98 Mon Sep 17 00:00:00 2001 From: Seyit Gokce <47825211+seyitgkc@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:21:37 -0400 Subject: [PATCH 1/7] Keep the settings window inside the screen Only two tabs scrolled, so the rest handed their full content height to the window as a minimum. The API tab alone asked for 668px, which put the window's minimum at 756px: taller than a laptop screen has room for once the menu bar and dock have taken theirs, and unshrinkable, so Save sat off the bottom edge. Every tab scrolls now, and the opening size is clamped to what the screen actually offers. The program path in the local model box was clipped to one line for a related reason: it shares a form row with a button, and the row is measured before its width is known. WrappedLabel re-measures against the width it ends up with and claims that height back. --- settings_ui.py | 98 +++++++++++++++++++++++++++++++++++------------- tests/test_ui.py | 26 +++++++++++++ 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/settings_ui.py b/settings_ui.py index 87d2611..4a87fa2 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -4,7 +4,7 @@ import os import shutil import threading -from PyQt6.QtCore import Qt, QUrl, pyqtSignal +from PyQt6.QtCore import QRect, Qt, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -127,6 +127,38 @@ 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 LocalModelBox(QGroupBox): """The program, the model, and the two downloads that put them there. @@ -160,8 +192,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 +515,18 @@ class SettingsWindow(QDialog): self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) - self.resize(680, 640) 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 +538,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 +560,30 @@ class SettingsWindow(QDialog): if not conf.transcribe_ready(): self.tabs.setCurrentIndex(self.api_tab_index) + @staticmethod + def _scrolled(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) + 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) + self.resize(width, height) + # ---- tabs ---------------------------------------------------------- def _general_tab(self): @@ -967,12 +1023,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() @@ -1099,14 +1150,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() diff --git a/tests/test_ui.py b/tests/test_ui.py index 518166f..2efa676 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -133,6 +133,32 @@ class Settings(DikteTest): 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_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.""" From 1135362afafe21a7b52aa7fc56d8f85ea1354b45 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 11:57:55 +0300 Subject: [PATCH 2/7] Let the test server open its port without asking who 127.0.0.1 is The stand-in whisper server in the ggml tests is an http.server, and http.server looks up the reverse name of the address it bound in between the bind and the listen. On Linux that answers at once. On a Mac nothing answers and the lookup sits in a resolver timeout for thirty-five seconds, with the port closed the whole time and _wait_ready watching it. Seven tests start a server and one of them starts two, so the macOS job spent 330 of its 334 seconds inside that lookup while the same suite took fifteen seconds on Linux. Binding through socketserver and naming the server after the address it already has skips the question. --- tests/test_ggml.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 983041e..9c11a8b 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -439,7 +439,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:] @@ -465,7 +465,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() """) From 5547f858488db258f79ce2994705eccf1208914d Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 12:09:21 +0300 Subject: [PATCH 3/7] Keep the wheel off the boxes and a floor under the window Two things the scrolling tabs brought with them. A combo box and a spin box read the wheel as a change of value, and Qt hands them the focus before delivering it. Now that every tab scrolls, rolling down the API tab with the pointer over the model box picks a different model on the way past, and Save writes it down. The boxes take the focus by click or by tab only, and a wheel that arrives at one without the focus is refused rather than swallowed, so it carries on up to the scroll area and the page moves instead. A tab that scrolls also asks for no height of its own, which left nothing to stop the window being dragged down to a tab bar and half a button. It has a floor now, and the floor never asks for more room than the screen was just found to have. --- settings_ui.py | 46 +++++++++++++++++++++++++++++++++++++++------ tests/test_ui.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/settings_ui.py b/settings_ui.py index 4a87fa2..96c5989 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -4,12 +4,12 @@ import os import shutil import threading -from PyQt6.QtCore import QRect, 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, ) @@ -159,6 +159,26 @@ class WrappedLabel(QLabel): 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. @@ -516,6 +536,10 @@ class SettingsWindow(QDialog): self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) + # 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._scrolled(self._general_tab()), t("General")) self.api_tab_index = tabs.addTab( @@ -560,8 +584,7 @@ class SettingsWindow(QDialog): if not conf.transcribe_ready(): self.tabs.setCurrentIndex(self.api_tab_index) - @staticmethod - def _scrolled(page): + 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 @@ -571,6 +594,12 @@ class SettingsWindow(QDialog): 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): @@ -582,6 +611,11 @@ class SettingsWindow(QDialog): # 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 ---------------------------------------------------------- diff --git a/tests/test_ui.py b/tests/test_ui.py index 2efa676..4412efb 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -11,6 +11,8 @@ 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 cleanup @@ -116,6 +118,10 @@ class Settings(DikteTest): "_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", @@ -127,6 +133,14 @@ 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] @@ -143,6 +157,41 @@ class Settings(DikteTest): 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 From 0e767454f3c366f217221b0d6a9b06b54bac8b4f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 13:06:36 +0300 Subject: [PATCH 4/7] Let a recording be held while something else happens A phone call in the middle of a dictation left two choices: send what was said so far off to be transcribed, or throw it away. Both end the sentence you were in the middle of. Now the recording can be held: the microphone stays ours, what was said before the pause stays in the buffer, and what is said during it is dropped. The capture program keeps running and keeps handing blocks over, which are read and thrown away rather than left in the pipe. 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. The clock stops with it. Paused time is time the recording does not have, so the indicator and the length limit both go by what was actually captured, and a five minute limit is not spent waiting. The indicator says so as well, since a pulsing dot, moving bars and a counting clock otherwise all say the words are still going in: the ribbon freezes where the pause found it and turns amber behind two bars. The tray menu holds and resumes it, `dikte pause` does, and so does a global shortcut, which starts empty because holding a recording is not something a keyboard has a habit for. Dictation and a command to the agent both, whichever is recording. A meeting is left out: it writes to a file as it goes and keeps two streams aligned itself, and neither of those wants a hole in it. --- README.md | 1 + README.tr.md | 1 + audio.py | 25 +++++++++++++++++ cli.py | 9 ++++-- config.py | 4 +++ dikte.py | 68 +++++++++++++++++++++++++++++++++++++++++++-- hotkey.py | 5 +++- i18n.py | 10 +++++++ overlay.py | 43 +++++++++++++++++++++++++--- settings_ui.py | 12 ++++++-- tests/test_audio.py | 65 +++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 10 ++++++- tests/test_ui.py | 23 +++++++++++++++ trayicon.py | 27 +++++++++++++----- uninstall.sh | 4 +-- 15 files changed, 286 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e408c94..6ef12b6 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,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` | diff --git a/README.tr.md b/README.tr.md index db1f23c..0ac4d6b 100644 --- a/README.tr.md +++ b/README.tr.md @@ -103,6 +103,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` | diff --git a/audio.py b/audio.py index 511bdf1..bcd43b0 100644 --- a/audio.py +++ b/audio.py @@ -72,12 +72,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 @@ -102,6 +121,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() @@ -114,6 +134,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) diff --git a/cli.py b/cli.py index e122905..ba643c2 100644 --- a/cli.py +++ b/cli.py @@ -43,7 +43,7 @@ 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 @@ -765,7 +765,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 ""), @@ -879,6 +880,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 -------------------------------------------------------- diff --git a/config.py b/config.py index 4dd4088..4861a75 100644 --- a/config.py +++ b/config.py @@ -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, diff --git a/dikte.py b/dikte.py index ee64b40..4718c46 100755 --- a/dikte.py +++ b/dikte.py @@ -83,6 +83,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 = "" @@ -159,6 +167,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) @@ -283,6 +297,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. @@ -299,6 +316,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", @@ -334,6 +357,9 @@ 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) @@ -358,7 +384,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): @@ -393,6 +419,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, @@ -475,6 +502,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, @@ -538,6 +566,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"]) @@ -546,6 +575,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() @@ -554,16 +584,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 @@ -603,7 +667,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)() diff --git a/hotkey.py b/hotkey.py index 0cbcfc4..7026dee 100644 --- a/hotkey.py +++ b/hotkey.py @@ -29,6 +29,7 @@ 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 +40,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 +48,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", diff --git a/i18n.py b/i18n.py index 7ce2b3a..8f6aa89 100644 --- a/i18n.py +++ b/i18n.py @@ -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 ------------------------------------------- @@ -312,7 +315,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, " diff --git a/overlay.py b/overlay.py index f843306..a46cb6d 100644 --- a/overlay.py +++ b/overlay.py @@ -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 @@ -102,6 +109,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() @@ -113,6 +121,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() @@ -174,6 +183,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): @@ -243,11 +263,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): @@ -272,6 +292,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: @@ -282,10 +304,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) diff --git a/settings_ui.py b/settings_ui.py index 8571a1a..b8610ce 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1321,6 +1321,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. @@ -1700,8 +1708,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() diff --git a/tests/test_audio.py b/tests/test_audio.py index 5bda0ab..ea2454a 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -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 = [] diff --git a/tests/test_cli.py b/tests/test_cli.py index ebfce3b..15e62b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -162,7 +162,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", @@ -614,6 +614,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) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index b06cc63..9159f6b 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -96,6 +96,7 @@ CHANGED = { "file_cleanup": False, "shortcut": "Ctrl+Alt+Space", "cancel_shortcut": "Meta+Shift+Space", + "pause_shortcut": "Meta+P", "evdev_hotkey": True, "history_limit": 50, } @@ -269,6 +270,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"], "") @@ -466,6 +468,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() diff --git a/trayicon.py b/trayicon.py index b5aafc2..d016b6a 100644 --- a/trayicon.py +++ b/trayicon.py @@ -1,11 +1,11 @@ -"""The three tray icons, drawn here for systems that have no icon theme. +"""The four 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. +Linux 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. 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 four 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 @@ -75,6 +75,18 @@ def _record(painter, size): painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit) +def _paused(painter, size): + """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): """An arrow chasing its own circle: transcribing, cleaning up, thinking.""" unit = size / 22.0 @@ -102,6 +114,7 @@ def _working(painter, size): SHAPES = { "audio-input-microphone": _microphone, "media-record": _record, + "media-playback-pause": _paused, "view-refresh": _working, } diff --git a/uninstall.sh b/uninstall.sh index b7e740b..a9c2ad9 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -90,7 +90,7 @@ echo "──────────────────" 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 + for which in toggle pause cancel ask meeting; do "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true done ok "Global shortcuts unregistered" @@ -151,7 +151,7 @@ if ((!MACOS)); then # 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 From e40191e3a7c287feae352335d9a51574b9e3db51 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 13:28:57 +0300 Subject: [PATCH 5/7] Give the tray an icon a black bar cannot swallow A session that names no desktop, which is what i3 and a bare X11 login are, leaves Qt with hicolor as its only icon theme, and hicolor has none of the four names the tray asks for. So fromTheme returns nothing and the shapes drawn in trayicon.py are used, as they are on macOS. They were drawn in black, which macOS recolours through the mask and X11 does not, and i3's bar is black: the icon was there all along, painted onto a bar of its own colour. Outside macOS they are now white over a dark copy of themselves spread a pixel out, which stands out on a dark bar and stays readable on a light one, and the mask is set only where something reads it. The .desktop files had the same hole from the other side. They named audio-input-microphone, which is in Breeze and in Adwaita but not in hicolor, so the menu entry and the autostart entry were blank on the same systems. install.sh now draws the application icon into ~/.local/share/icons/hicolor and both entries name it; uninstall.sh takes it back. The windows carry it too on X11, where a window has no .desktop file to be looked up in. Closes #27 --- dikte.py | 6 ++ install.sh | 27 +++++- tests/test_trayicon.py | 155 +++++++++++++++++++++++++++++++++ trayicon.py | 191 +++++++++++++++++++++++++++++++---------- uninstall.sh | 14 +++ 5 files changed, 343 insertions(+), 50 deletions(-) create mode 100644 tests/test_trayicon.py diff --git a/dikte.py b/dikte.py index 4718c46..15faef4 100755 --- a/dikte.py +++ b/dikte.py @@ -1093,6 +1093,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 diff --git a/install.sh b/install.sh index 7d4f94f..983752c 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,7 @@ PY="$(command -v python3)" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" AUTOSTART_DIR="$HOME/.config/autostart" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons" 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. @@ -81,7 +82,7 @@ if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then fi # 3. Launchers ------------------------------------------------------------- -mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" +mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR" ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte" chmod +x "$DIR/dikte.py" ok "Command installed: $BIN_DIR/dikte" @@ -90,13 +91,33 @@ case ":$PATH:" in *) 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 "$PY" "$DIR/trayicon.py" --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" < "$AUTOSTART_DIR/dikte.desktop" < 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): + # dikte.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() diff --git a/trayicon.py b/trayicon.py index d016b6a..089cb20 100644 --- a/trayicon.py +++ b/trayicon.py @@ -1,16 +1,25 @@ """The four tray icons, drawn here for systems that have no icon theme. -Linux 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. 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 four shapes are drawn here, -and used whenever the theme has nothing to offer. +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. -They are drawn as template images: one colour, transparent everywhere else, +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 @@ -23,10 +32,11 @@ from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath, # 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) +# 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): @@ -37,11 +47,13 @@ def _canvas(size): return pixmap, painter -def _paint_microphone(painter, size, ink): +def _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. + 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) @@ -63,23 +75,19 @@ def _paint_microphone(painter, size, ink): 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): +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.setBrush(ink) painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit) -def _paused(painter, size): +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) + painter.setBrush(ink) for left in (6.4, 12.4): painter.drawRoundedRect( QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit), @@ -87,10 +95,10 @@ def _paused(painter, size): ) -def _working(painter, size): +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 = QPen(ink, 2.0 * unit) pen.setCapStyle(Qt.PenCapStyle.FlatCap) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) @@ -101,7 +109,7 @@ def _working(painter, size): # 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) + painter.setBrush(ink) head = QPainterPath() head.moveTo(QPointF(11.0 * unit, 1.6 * unit)) head.lineTo(QPointF(11.0 * unit, 7.2 * unit)) @@ -121,43 +129,87 @@ SHAPES = { _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. + 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() - if name in _cache: - return _cache[name] + mask = sys.platform == "darwin" + if (name, mask) in _cache: + return _cache[(name, mask)] 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 + 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 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. +# 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): @@ -184,13 +236,30 @@ def app_pixmap(size): 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)) + _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) @@ -202,21 +271,49 @@ def write_iconset(directory): 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): - """`python3 trayicon.py .iconset`, which install-mac.sh calls. + """`trayicon.py .iconset` for install-mac.sh, `--hicolor ` 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. """ - if len(argv) != 2: - print("usage: trayicon.py .iconset", file=sys.stderr) + hicolor = len(argv) == 3 and argv[1] == "--hicolor" + if not hicolor and len(argv) != 2: + print("usage: trayicon.py .iconset\n" + " trayicon.py --hicolor ", 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])) + if hicolor: + for path in write_hicolor(argv[2]): + print(path) + else: + print(write_iconset(argv[1])) finally: del app return 0 diff --git a/uninstall.sh b/uninstall.sh index a9c2ad9..4bfca85 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -29,6 +29,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)" @@ -148,6 +149,19 @@ 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. From 1f5ea70fa6b877599590a5aa3df65b565d04686a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 13:41:27 +0300 Subject: [PATCH 6/7] Stop telling every desktop that it is KDE Only GNOME was recognised, and everything else was handed to KWin. On i3, XFCE, Cinnamon, MATE, sway and the rest, Dikte wrote an entry into kglobalshortcutsrc that nothing reads, called the session KDE, and promised that the keys would work after the next login. They never did. There is no backend to write for any of them. The /dev/input listener is already desktop-agnostic, so those sessions are the case macOS has always been: no registry, nothing to install, nothing to remove, and the combination held by the running process. One backend() function decides which of the four this session has, and the name shown, the status read back, what Install writes, what Settings explains and what the installer promises are all taken from it, so they cannot disagree. A desktop now only counts when the program that writes its registry is there too. A GNOME session without gsettings and a Plasma one without kwriteconfig6 fall to the listener rather than to a file, which is also how Plasma 5 stops erroring on a kwriteconfig6 it never had. What Settings shows on those desktops is the truth: no Install button, no KWin, no listener checkbox (it is the mechanism, not a choice), what reading /dev/input costs, that the focused application sees the keys too, and the command to bind if you would rather your desktop owned them. The evdev listener records what it is listening for the way the Carbon one does, so the status line has something to say there at all. Closes #28 --- README.md | 14 +++-- README.tr.md | 13 ++-- cli.py | 6 +- dikte.py | 4 +- hotkey.py | 141 +++++++++++++++++++++++++++++++------------ i18n.py | 20 ++++++ install.sh | 27 +++++++-- ipc.py | 6 +- settings_ui.py | 43 ++++++++++--- tests/test_hotkey.py | 84 ++++++++++++++++++++++---- tests/test_ui.py | 68 ++++++++++++++++++++- uninstall.sh | 17 ++++-- update.sh | 2 +- 13 files changed, 360 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 6ef12b6..c047b5a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ 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 and macOS too. No +Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS 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. @@ -184,14 +185,19 @@ 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 @@ -211,7 +217,7 @@ 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 diff --git a/README.tr.md b/README.tr.md index 0ac4d6b..38a7537 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,7 +4,8 @@ ç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 ve macOS'ta da çalışır. Sistem +KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'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. @@ -182,14 +183,18 @@ 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 @@ -209,7 +214,7 @@ 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 diff --git a/cli.py b/cli.py index ba643c2..7ab4d99 100644 --- a/cli.py +++ b/cli.py @@ -35,9 +35,9 @@ 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; diff --git a/dikte.py b/dikte.py index 4718c46..3b7986e 100755 --- a/dikte.py +++ b/dikte.py @@ -365,8 +365,8 @@ class Dikte: 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 diff --git a/hotkey.py b/hotkey.py index 7026dee..1060c08 100644 --- a/hotkey.py +++ b/hotkey.py @@ -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 @@ -105,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 @@ -154,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() @@ -164,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 = [] @@ -246,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.""" @@ -428,13 +442,39 @@ def _carbon(): # --- the desktop's own shortcut ------------------------------------------- +# The four 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" +LISTENER = "listener" + + def _macos(): return sys.platform == "darwin" -def _gnome(): - desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - return "gnome" in desktop and shutil.which("gsettings") is not None +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 + 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): @@ -581,55 +621,77 @@ def installs_shortcuts(): """Whether this system keeps a shortcut registry to write into. KDE and GNOME do, and something outside Dikte reads it, so the combination - survives Dikte being closed. macOS does not: there is nothing to install, - nothing to remove, and Settings should not offer either. + survives Dikte being closed. macOS and the plain listener do not: there is + nothing to install, nothing to remove, and Settings should not offer either. """ - return not _macos() + 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 never had one to write. + up as it is written, and the other two never had one to write. """ - return not _macos() and not _gnome() + return backend() == KDE def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): - if _macos(): - _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 == MACOS: 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(): - _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(): - 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" - 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 ------------------------------------------------------------------ @@ -713,9 +775,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(): - # There is no list to read: macOS answers the question by refusing the - # registration, which CarbonHotkey reports when it asks for the key. + if backend() != KDE: + # Nowhere else has a list to read. macOS answers the question by + # refusing the registration, which CarbonHotkey reports when it asks + # 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") diff --git a/i18n.py b/i18n.py index 8f6aa89..6a29fec 100644 --- a/i18n.py +++ b/i18n.py @@ -308,6 +308,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.", @@ -351,6 +353,18 @@ 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.", + "{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?", @@ -367,6 +381,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.": diff --git a/install.sh b/install.sh index 7d4f94f..4ff5c22 100755 --- a/install.sh +++ b/install.sh @@ -142,11 +142,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 "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 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'" diff --git a/ipc.py b/ipc.py index 541e067..fa436e2 100644 --- a/ipc.py +++ b/ipc.py @@ -28,7 +28,11 @@ def script_path(): 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}" diff --git a/settings_ui.py b/settings_ui.py index b8610ce..e7a7f6d 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1363,15 +1363,34 @@ 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." ) + 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 @@ -1442,7 +1461,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: @@ -1491,8 +1510,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 [] @@ -1990,11 +2010,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" diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 388c41b..fa15c34 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -174,38 +174,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"), \ @@ -220,6 +251,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, \ @@ -230,6 +275,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): @@ -372,6 +428,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()): diff --git a/tests/test_ui.py b/tests/test_ui.py index 9159f6b..444d402 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,6 +6,7 @@ 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 @@ -107,13 +108,21 @@ 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")) @@ -254,6 +263,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("dikte.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. @@ -415,7 +449,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("dikte.py toggle", text) def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): window = self.window(cfg.Config()) @@ -424,6 +467,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("dikte.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) diff --git a/uninstall.sh b/uninstall.sh index a9c2ad9..980772a 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -84,17 +84,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 pause cancel ask meeting; do "$PY" "$DIR/dikte.py" 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 "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 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." diff --git a/update.sh b/update.sh index 18cc40d..e88db46 100755 --- a/update.sh +++ b/update.sh @@ -25,7 +25,7 @@ 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. +# from and the one place that is the same whichever mechanism the session has. setting() { [[ -n "$PY" ]] || return 0 "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true From 2f59029261639be5854623a2e0f4e6e2afdfb502 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 14:01:01 +0300 Subject: [PATCH 7/7] Put the modules in a package and the scripts in a folder Twenty-two files at the top of the tree was the first thing anybody saw of this repository. They are one package now, imported relatively, and the three scripts that are not the front door moved under scripts/. install.sh stays where the README has always said it is. What starts the application is dikte/__main__.py: python3 -m dikte runs it, and so does naming the file, which is what the launcher symlink, both .desktop files, the macOS bundle and every registered shortcut do. Run by path there is no package around it, so it puts the checkout on sys.path itself. The installers now keep the keys you chose when they are given none, which is what an update is: update.sh no longer has to read them out and pass them back. An updater from before this commit cannot read them at all, so the one thing it can say, the default key with an empty discard key, is read as "nothing was asked for" rather than obeyed. That guard can go once nobody is updating across this commit. --- .github/workflows/tests.yml | 6 +- README.md | 18 ++++-- README.tr.md | 19 ++++--- dikte/__init__.py | 6 ++ dikte/__main__.py | 25 ++++++++ api.py => dikte/api.py | 4 +- dikte.py => dikte/app.py | 39 ++++++------- assistant.py => dikte/assistant.py | 6 +- audio.py => dikte/audio.py | 2 +- cleanup.py => dikte/cleanup.py | 8 +-- cli.py => dikte/cli.py | 20 +++---- config.py => dikte/config.py | 12 ++-- filetranscribe.py => dikte/filetranscribe.py | 8 +-- ggml.py => dikte/ggml.py | 6 +- hotkey.py => dikte/hotkey.py | 2 +- hub.py => dikte/hub.py | 2 +- i18n.py => dikte/i18n.py | 0 ipc.py => dikte/ipc.py | 7 ++- meeting.py => dikte/meeting.py | 14 ++--- overlay.py => dikte/overlay.py | 0 paste.py => dikte/paste.py | 2 +- paths.py => dikte/paths.py | 0 settings_ui.py => dikte/settings_ui.py | 26 ++++----- trayicon.py => dikte/trayicon.py | 2 +- vad.py => dikte/vad.py | 0 worker.py => dikte/worker.py | 18 +++--- install.sh | 57 ++++++++++++++----- install-mac.sh => scripts/install-mac.sh | 60 +++++++++++++++----- uninstall.sh => scripts/uninstall.sh | 23 +++++--- update.sh => scripts/update.sh | 31 ++++------ tests/support.py | 6 +- tests/test_api.py | 4 +- tests/test_assistant.py | 2 +- tests/test_audio.py | 2 +- tests/test_cleanup.py | 6 +- tests/test_cli.py | 8 +-- tests/test_config.py | 12 ++-- tests/test_filetranscribe.py | 4 +- tests/test_ggml.py | 4 +- tests/test_hotkey.py | 4 +- tests/test_hub.py | 2 +- tests/test_i18n.py | 2 +- tests/test_ipc.py | 4 +- tests/test_meeting.py | 6 +- tests/test_paste.py | 2 +- tests/test_paths.py | 6 +- tests/test_trayicon.py | 4 +- tests/test_ui.py | 18 +++--- tests/test_vad.py | 2 +- tests/test_worker.py | 10 ++-- 50 files changed, 316 insertions(+), 215 deletions(-) create mode 100644 dikte/__init__.py create mode 100755 dikte/__main__.py rename api.py => dikte/api.py (99%) rename dikte.py => dikte/app.py (98%) mode change 100755 => 100644 rename assistant.py => dikte/assistant.py (99%) rename audio.py => dikte/audio.py (99%) rename cleanup.py => dikte/cleanup.py (99%) rename cli.py => dikte/cli.py (99%) rename config.py => dikte/config.py (99%) rename filetranscribe.py => dikte/filetranscribe.py (99%) rename ggml.py => dikte/ggml.py (99%) rename hotkey.py => dikte/hotkey.py (99%) rename hub.py => dikte/hub.py (99%) rename i18n.py => dikte/i18n.py (100%) rename ipc.py => dikte/ipc.py (93%) rename meeting.py => dikte/meeting.py (98%) rename overlay.py => dikte/overlay.py (100%) rename paste.py => dikte/paste.py (99%) rename paths.py => dikte/paths.py (100%) rename settings_ui.py => dikte/settings_ui.py (99%) rename trayicon.py => dikte/trayicon.py (99%) rename vad.py => dikte/vad.py (100%) rename worker.py => dikte/worker.py (97%) rename install-mac.sh => scripts/install-mac.sh (85%) rename uninstall.sh => scripts/uninstall.sh (89%) rename update.sh => scripts/update.sh (79%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86eaa88..b176668 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,6 @@ 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 diff --git a/README.md b/README.md index c047b5a..52ab781 100644 --- a/README.md +++ b/README.md @@ -56,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,9 +84,10 @@ transcribe in the cloud. A meeting needs BlackHole or Loopback (`brew install blackhole-2ch`); dictation does not. `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: @@ -201,8 +202,13 @@ 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 @@ -224,7 +230,7 @@ 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 diff --git a/README.tr.md b/README.tr.md index 38a7537..6db60dd 100644 --- a/README.tr.md +++ b/README.tr.md @@ -55,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,10 +83,10 @@ BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için gerekmiyor. `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 @@ -198,8 +198,13 @@ 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 @@ -221,7 +226,7 @@ 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 diff --git a/dikte/__init__.py b/dikte/__init__.py new file mode 100644 index 0000000..dbb496d --- /dev/null +++ b/dikte/__init__.py @@ -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. +""" diff --git a/dikte/__main__.py b/dikte/__main__.py new file mode 100755 index 0000000..073b0b0 --- /dev/null +++ b/dikte/__main__.py @@ -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()) diff --git a/api.py b/dikte/api.py similarity index 99% rename from api.py rename to dikte/api.py index 775fe31..fa89af1 100644 --- a/api.py +++ b/dikte/api.py @@ -22,8 +22,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})" diff --git a/dikte.py b/dikte/app.py old mode 100755 new mode 100644 similarity index 98% rename from dikte.py rename to dikte/app.py index 12b57c4..a4ce2b1 --- a/dikte.py +++ b/dikte/app.py @@ -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 @@ -36,21 +37,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" @@ -1168,7 +1169,3 @@ def run_app(args): QTimer.singleShot(0, dikte.toggle_meeting) return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/assistant.py b/dikte/assistant.py similarity index 99% rename from assistant.py rename to dikte/assistant.py index f708ab3..89c2ec1 100644 --- a/assistant.py +++ b/dikte/assistant.py @@ -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") diff --git a/audio.py b/dikte/audio.py similarity index 99% rename from audio.py rename to dikte/audio.py index bcd43b0..7221625 100644 --- a/audio.py +++ b/dikte/audio.py @@ -31,7 +31,7 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal -from i18n import t +from .i18n import t RATE = 16000 CHANNELS = 1 diff --git a/cleanup.py b/dikte/cleanup.py similarity index 99% rename from cleanup.py rename to dikte/cleanup.py index b6485b3..22d31ec 100644 --- a/cleanup.py +++ b/dikte/cleanup.py @@ -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") diff --git a/cli.py b/dikte/cli.py similarity index 99% rename from cli.py rename to dikte/cli.py index 7ab4d99..72f3f5d 100644 --- a/cli.py +++ b/dikte/cli.py @@ -22,16 +22,16 @@ 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 diff --git a/config.py b/dikte/config.py similarity index 99% rename from config.py rename to dikte/config.py index 4861a75..6885fdd 100644 --- a/config.py +++ b/dikte/config.py @@ -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" diff --git a/filetranscribe.py b/dikte/filetranscribe.py similarity index 99% rename from filetranscribe.py rename to dikte/filetranscribe.py index 4c8b4b7..5ac97e3 100644 --- a/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -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 diff --git a/ggml.py b/dikte/ggml.py similarity index 99% rename from ggml.py rename to dikte/ggml.py index 3278f46..6805337 100644 --- a/ggml.py +++ b/dikte/ggml.py @@ -44,9 +44,9 @@ import time import urllib.error import urllib.request -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. diff --git a/hotkey.py b/dikte/hotkey.py similarity index 99% rename from hotkey.py rename to dikte/hotkey.py index 1060c08..16c9a1e 100644 --- a/hotkey.py +++ b/dikte/hotkey.py @@ -30,7 +30,7 @@ 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" diff --git a/hub.py b/dikte/hub.py similarity index 99% rename from hub.py rename to dikte/hub.py index f793d1c..afd8133 100644 --- a/hub.py +++ b/dikte/hub.py @@ -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" diff --git a/i18n.py b/dikte/i18n.py similarity index 100% rename from i18n.py rename to dikte/i18n.py diff --git a/ipc.py b/dikte/ipc.py similarity index 93% rename from ipc.py rename to dikte/ipc.py index fa436e2..4632b31 100644 --- a/ipc.py +++ b/dikte/ipc.py @@ -22,8 +22,13 @@ 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") ) diff --git a/meeting.py b/dikte/meeting.py similarity index 98% rename from meeting.py rename to dikte/meeting.py index 5da05d3..0d582b4 100644 --- a/meeting.py +++ b/dikte/meeting.py @@ -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 diff --git a/overlay.py b/dikte/overlay.py similarity index 100% rename from overlay.py rename to dikte/overlay.py diff --git a/paste.py b/dikte/paste.py similarity index 99% rename from paste.py rename to dikte/paste.py index d39198b..fb1a9e4 100644 --- a/paste.py +++ b/dikte/paste.py @@ -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 diff --git a/paths.py b/dikte/paths.py similarity index 100% rename from paths.py rename to dikte/paths.py diff --git a/settings_ui.py b/dikte/settings_ui.py similarity index 99% rename from settings_ui.py rename to dikte/settings_ui.py index e7a7f6d..1a73968 100644 --- a/settings_ui.py +++ b/dikte/settings_ui.py @@ -13,19 +13,19 @@ from PyQt6.QtWidgets import ( 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 = [ diff --git a/trayicon.py b/dikte/trayicon.py similarity index 99% rename from trayicon.py rename to dikte/trayicon.py index 089cb20..cce525b 100644 --- a/trayicon.py +++ b/dikte/trayicon.py @@ -118,7 +118,7 @@ def _working(painter, size, ink): painter.drawPath(head) -# The names Linux themes use, which are what dikte.py asks for either way. +# The names Linux themes use, which are what app.py asks for either way. SHAPES = { "audio-input-microphone": _microphone, "media-record": _record, diff --git a/vad.py b/dikte/vad.py similarity index 100% rename from vad.py rename to dikte/vad.py diff --git a/worker.py b/dikte/worker.py similarity index 97% rename from worker.py rename to dikte/worker.py index 0f0e004..16cdbad 100644 --- a/worker.py +++ b/dikte/worker.py @@ -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 diff --git a/install.sh b/install.sh index c597882..4b8c609 100755 --- a/install.sh +++ b/install.sh @@ -9,18 +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" ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons" -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}" +# 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"; } @@ -83,8 +100,8 @@ fi # 3. Launchers ------------------------------------------------------------- mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR" -ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte" -chmod +x "$DIR/dikte.py" +ln -sf "$ENTRY" "$BIN_DIR/dikte" +chmod +x "$ENTRY" ok "Command installed: $BIN_DIR/dikte" case ":$PATH:" in *":$BIN_DIR:"*) ;; @@ -97,7 +114,7 @@ esac # 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 "$PY" "$DIR/trayicon.py" --hicolor "$ICON_DIR" >/dev/null 2>&1; then +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. @@ -116,7 +133,7 @@ cat > "$APP_DIR/dikte.desktop" < "$AUTOSTART_DIR/dikte.desktop" </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. @@ -167,7 +196,7 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then # 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 "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + 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" diff --git a/install-mac.sh b/scripts/install-mac.sh similarity index 85% rename from install-mac.sh rename to scripts/install-mac.sh index 34c3f7a..f79b1e8 100755 --- a/install-mac.sh +++ b/scripts/install-mac.sh @@ -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" </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'*}" diff --git a/uninstall.sh b/scripts/uninstall.sh similarity index 89% rename from uninstall.sh rename to scripts/uninstall.sh index a89b03b..8031b54 100755 --- a/uninstall.sh +++ b/scripts/uninstall.sh @@ -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" @@ -49,7 +52,7 @@ count() { usage() { cat </dev/null; then for which in toggle pause cancel ask meeting; do - "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true + "$PY" "$ENTRY" shortcut remove "$which" >/dev/null 2>&1 || true done - case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + 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." @@ -111,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" @@ -216,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 diff --git a/update.sh b/scripts/update.sh similarity index 79% rename from update.sh rename to scripts/update.sh index e88db46..88792b5 100755 --- a/update.sh +++ b/scripts/update.sh @@ -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 whichever mechanism the session has. -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" diff --git a/tests/support.py b/tests/support.py index 26d069b..18c0a06 100644 --- a/tests/support.py +++ b/tests/support.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index f45ca21..58ab960 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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, diff --git a/tests/test_assistant.py b/tests/test_assistant.py index c57cfbe..1c682c6 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -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 diff --git a/tests/test_audio.py b/tests/test_audio.py index ea2454a..3f7777d 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -21,7 +21,7 @@ import unittest import wave from unittest import mock -import audio +from dikte import audio from tests.support import ( DikteTest, FakeCompleted, diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index efb8417..7753029 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 15e62b5..ef8f6f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,10 +12,10 @@ import json import unittest from unittest import mock -import cli -import config as cfg -import hotkey -import ipc +from dikte import cli +from dikte import config as cfg +from dikte import hotkey +from dikte import ipc from tests.support import DikteTest, fake_urlopen diff --git a/tests/test_config.py b/tests/test_config.py index 57482be..aa15c91 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,12 +11,12 @@ import os 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 diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index ff917aa..2adc5ee 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -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 diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 9c11a8b..951654b 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -17,8 +17,8 @@ import threading import time 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) diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index fa15c34..3ac7b52 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -6,8 +6,8 @@ import subprocess import unittest from unittest import mock -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] diff --git a/tests/test_hub.py b/tests/test_hub.py index f3741c2..84cd8d0 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,7 +2,7 @@ import json -import hub +from dikte import hub from tests.support import DikteTest, fake_urlopen, http_error, url_error RELEASE = { diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 0b1f9d5..21af9ab 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -9,7 +9,7 @@ import string import unittest from unittest import mock -import i18n +from dikte import i18n from tests.support import DikteTest diff --git a/tests/test_ipc.py b/tests/test_ipc.py index 0409cad..a95cb8b 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -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): diff --git a/tests/test_meeting.py b/tests/test_meeting.py index 471b77c..26a18e3 100644 --- a/tests/test_meeting.py +++ b/tests/test_meeting.py @@ -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 diff --git a/tests/test_paste.py b/tests/test_paste.py index 85c13cd..8da2c81 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -22,7 +22,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 diff --git a/tests/test_paths.py b/tests/test_paths.py index d932e0f..81517d1 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -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): diff --git a/tests/test_trayicon.py b/tests/test_trayicon.py index 5f53d7d..a257781 100644 --- a/tests/test_trayicon.py +++ b/tests/test_trayicon.py @@ -15,7 +15,7 @@ from unittest import mock from PyQt6.QtGui import QImage from PyQt6.QtWidgets import QApplication -import trayicon +from dikte import trayicon from tests.support import DikteTest # One application for the whole run; Qt allows no second one. @@ -66,7 +66,7 @@ class Tray(DikteTest): self.patch_attr(trayicon, "_cache", {}) def test_a_name_we_do_not_draw_is_a_null_icon(self): - # dikte.py asks the theme first and falls through to here, so anything + # 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()) diff --git a/tests/test_ui.py b/tests/test_ui.py index 444d402..192aae7 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -16,12 +16,12 @@ from PyQt6.QtCore import QPoint, QPointF, Qt from PyQt6.QtGui import QWheelEvent from PyQt6.QtWidgets import QApplication, QMessageBox -import cleanup -import config as cfg -import hotkey -import overlay as overlay_module -import paste -import settings_ui +from dikte import cleanup +from dikte import config as cfg +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. @@ -281,7 +281,7 @@ class Settings(DikteTest): text = self.shortcut_tab_text(window) self.assertIn("i3 keeps no shortcut registry", text) self.assertNotIn("KWin", text) - self.assertIn("dikte.py toggle", 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 @@ -458,7 +458,7 @@ class MacSettings(Settings): text = self.shortcut_tab_text(window) self.assertIn("Dikte asks macOS for these combinations", text) self.assertNotIn("KWin", text) - self.assertNotIn("dikte.py toggle", 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()) @@ -482,7 +482,7 @@ class KdeSettings(Settings): 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("dikte.py toggle", 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()) diff --git a/tests/test_vad.py b/tests/test_vad.py index 4674ebe..71c6cfd 100644 --- a/tests/test_vad.py +++ b/tests/test_vad.py @@ -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 diff --git a/tests/test_worker.py b/tests/test_worker.py index 7634a51..1ea30c3 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -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