From e6208418f928eb9a557efa2bf47970d8c1d7740a Mon Sep 17 00:00:00 2001 From: Aydogan Date: Thu, 27 Aug 2026 07:46:34 +0300 Subject: [PATCH] Add display selection for recording indicator --- README.md | 16 +++++++++++----- README.tr.md | 11 +++++++++-- dikte/app.py | 8 ++++++-- dikte/config.py | 1 + dikte/i18n.py | 4 ++++ dikte/overlay.py | 10 ++++++++-- dikte/settings_ui.py | 32 +++++++++++++++++++++++++++----- tests/test_ui.py | 14 +++++++++++++- 8 files changed, 79 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fe36166..85be8fc 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,17 @@ run here by default, on models of your own. The cloud is the other option: speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), cleanup on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, -`GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in -`~/.config/dikte/config.json`, mode 600, or in -`~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in -which case the raw transcript is pasted, and a thinking model's effort can be -set next to it. +`GROQ_API_KEY` and `OPENROUTER_API_KEY`. + +All settings, including the indicator screen and corner, are stored outside the +application: in `$XDG_CONFIG_HOME/dikte/config.json` (normally +`~/.config/dikte/config.json`) on Linux, `%APPDATA%\Dikte\config.json` on +Windows, and `~/Library/Application Support/Dikte/config.json` on macOS. The +file is mode 600 where Unix permissions apply. Rebuilding, replacing or +updating Dikte therefore keeps the settings. Only removing that file or +uninstalling with `--purge` resets them. Cleanup can be switched off, in which +case the raw transcript is pasted, and a thinking model's effort can be set +next to it. ## Using it diff --git a/README.tr.md b/README.tr.md index 5e88c69..0457c52 100644 --- a/README.tr.md +++ b/README.tr.md @@ -114,8 +114,15 @@ seçersen sesi yazıya çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da (varsayılan `gpt-4o-transcribe`), temizleme OpenRouter'da (`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve -`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` -içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. +`OPENROUTER_API_KEY` kullanılır. + +Gösterge ekranı ve köşesi dahil bütün ayarlar uygulamanın dışında saklanır: +Linux'ta `$XDG_CONFIG_HOME/dikte/config.json` (normalde +`~/.config/dikte/config.json`), Windows'ta `%APPDATA%\Dikte\config.json`, +macOS'ta `~/Library/Application Support/Dikte/config.json`. Unix izinlerinin +geçerli olduğu sistemlerde dosyanın izni 600'dür. Bu nedenle Dikte'yi yeniden +derlemek, değiştirmek veya güncellemek ayarları silmez. Yalnızca bu dosyayı +silmek ya da kaldırıcıyı `--purge` ile çalıştırmak ayarları sıfırlar. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. diff --git a/dikte/app.py b/dikte/app.py index 1da104e..5fb2837 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -146,11 +146,13 @@ class Dikte: # started it cannot stop the one that came after. self._run_id = 0 - self.overlay = Overlay(self.conf["overlay_corner"]) + self.overlay = Overlay(self.conf["overlay_corner"], + screen_name=self.conf["overlay_screen"]) # The agent's indicator sits on top of the dictation one when both are # up, and drops into the corner when it is alone there. self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, - dismissable=True) + dismissable=True, + screen_name=self.conf["overlay_screen"]) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf) @@ -1049,7 +1051,9 @@ class Dikte: def _apply_settings(self): self.overlay.corner = self.conf["overlay_corner"] + self.overlay.screen_name = self.conf["overlay_screen"] self.ask_overlay.corner = self.conf["overlay_corner"] + self.ask_overlay.screen_name = self.conf["overlay_screen"] self._apply_local() self._build_tray() self._refresh_tray() diff --git a/dikte/config.py b/dikte/config.py index fb7683a..eeaa704 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -455,6 +455,7 @@ DEFAULTS = { "pause_shortcut": "", "evdev_hotkey": False, "overlay_corner": "bottom-left", + "overlay_screen": "", "keep_audio": False, "history_limit": 200, # A look at the releases page once a day, and nothing more than a look: diff --git a/dikte/i18n.py b/dikte/i18n.py index f7f4910..56ec263 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -150,6 +150,7 @@ TR = { # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", + "Display": "Görüntü", "API and models": "API ve modeller", "Cleanup rules": "Temizleme kuralları", "Audio file": "Ses dosyası", @@ -180,6 +181,9 @@ TR = { "macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.", "Restore the previous clipboard after pasting": "Yapıştırdıktan sonra eski pano içeriğini geri koy", + "Indicator screen": "Gösterge ekranı", + "Follow the mouse pointer": "Fare imlecini takip et", + "{name} (not connected)": "{name} (bağlı değil)", "Indicator corner": "Gösterge köşesi", "bottom-left": "sol-alt", "bottom-right": "sağ-alt", diff --git a/dikte/overlay.py b/dikte/overlay.py index 4f4d3f1..a4acbe3 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -40,9 +40,11 @@ class Overlay(QWidget): of covering it, which is what lets a dictation and a command to the agent be under way at the same time and still both be visible.""" - def __init__(self, corner="bottom-left", below=None, dismissable=False): + def __init__(self, corner="bottom-left", below=None, dismissable=False, + screen_name=""): super().__init__(None) self.corner = corner + self.screen_name = screen_name self.below = below # A job that can run for ten minutes should not have to be watched for # ten minutes. Clicking such an indicator puts the progress away; the @@ -245,7 +247,11 @@ class Overlay(QWidget): def _reposition(self): # On a multi-monitor setup, show up where the user actually is. - screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() + screen = next( + (item for item in QApplication.screens() if item.name() == self.screen_name), + None, + ) + screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index d3a97b7..a032ffc 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -553,6 +553,7 @@ class SettingsWindow(QDialog): tabs = self.tabs = QTabWidget(self) tabs.addTab(self._scrolled(self._general_tab()), t("General")) + tabs.addTab(self._scrolled(self._display_tab()), t("Display")) 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")) @@ -674,11 +675,6 @@ class SettingsWindow(QDialog): self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting")) form.addRow("", self.restore_clipboard) - self.corner = QComboBox() - for value in CORNERS: - self.corner.addItem(t(value), value) - form.addRow(t("Indicator corner"), self.corner) - self.max_seconds = QSpinBox() self.max_seconds.setRange(10, 3600) self.max_seconds.setSuffix(t(" s")) @@ -729,6 +725,27 @@ class SettingsWindow(QDialog): self.update_now)) return page + def _display_tab(self): + page = QWidget() + form = QFormLayout(page) + + self.indicator_screen = QComboBox() + self.indicator_screen.addItem(t("Follow the mouse pointer"), "") + for screen in QGuiApplication.screens(): + area = screen.geometry() + self.indicator_screen.addItem( + t("{name} ({width} × {height})", name=screen.name(), + width=area.width(), height=area.height()), + screen.name(), + ) + form.addRow(t("Indicator screen"), self.indicator_screen) + + self.corner = QComboBox() + for value in CORNERS: + self.corner.addItem(t(value), value) + form.addRow(t("Indicator corner"), self.corner) + return page + def _api_tab(self): page = QWidget() outer = QVBoxLayout(page) @@ -1591,6 +1608,10 @@ class SettingsWindow(QDialog): self.auto_paste.setChecked(conf["auto_paste"]) self.paste_shortcut.setCurrentText(conf["paste_shortcut"]) self.restore_clipboard.setChecked(conf["restore_clipboard"]) + screen_name = conf["overlay_screen"] + if screen_name and self.indicator_screen.findData(screen_name) < 0: + self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name) + self._select_data(self.indicator_screen, screen_name) self._select_data(self.corner, conf["overlay_corner"]) self.max_seconds.setValue(conf["max_seconds"]) self.skip_silent.setChecked(conf["skip_silent"]) @@ -1686,6 +1707,7 @@ class SettingsWindow(QDialog): conf["auto_paste"] = self.auto_paste.isChecked() conf["paste_shortcut"] = self.paste_shortcut.currentText().strip() conf["restore_clipboard"] = self.restore_clipboard.isChecked() + conf["overlay_screen"] = self.indicator_screen.currentData() or "" conf["overlay_corner"] = self.corner.currentData() or "bottom-left" conf["max_seconds"] = self.max_seconds.value() conf["skip_silent"] = self.skip_silent.isChecked() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0b04be9..104be0d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -42,6 +42,7 @@ CHANGED = { "paste_shortcut": "ctrl+shift+v", "restore_clipboard": True, "overlay_corner": "top-right", + "overlay_screen": "DP-1", "max_seconds": 120, "skip_silent": False, "silence_db": -42.0, @@ -159,7 +160,7 @@ class Settings(DikteTest): def test_the_window_opens_with_every_tab_on_it(self): window = self.window(cfg.Config()) tabs = window.findChildren(settings_ui.QTabWidget)[0] - self.assertEqual(tabs.count(), 9) + self.assertEqual(tabs.count(), 10) self.assertEqual(window.windowTitle(), "Dikte Settings") def test_no_tab_can_stretch_the_window_past_a_small_screen(self): @@ -596,6 +597,17 @@ class Overlay(DikteTest): widget.show_recording() widget._reposition() + def test_a_named_screen_is_used_instead_of_the_pointer_screen(self): + screen = mock.Mock() + screen.name.return_value = "DP-1" + screen.availableGeometry.return_value = settings_ui.QRect(1920, 0, 1920, 1080) + widget = self.overlay(screen_name="DP-1") + with mock.patch.object(QApplication, "screens", return_value=[screen]), \ + mock.patch.object(QApplication, "screenAt") as screen_at: + widget._reposition() + screen_at.assert_not_called() + self.assertEqual(widget.pos(), QPoint(1948, 995)) + def test_a_warning_and_an_error_both_show(self): widget = self.overlay() widget.show_warning("cleanup failed")