diff --git a/dikte/app.py b/dikte/app.py index 42d68e7..d321256 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -163,11 +163,13 @@ class Dikte: self.front_before = None self._front_watch = None - 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) @@ -1296,7 +1298,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 3b904c1..ddb059b 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -457,6 +457,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 9bea8ad..07ae222 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -151,6 +151,7 @@ TR = { # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", + "Display": "Ekran", "API and models": "API ve modeller", "Cleanup rules": "Temizleme kuralları", "Audio file": "Ses dosyası", @@ -179,6 +180,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 11f8b31..054be9d 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -42,9 +42,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 @@ -251,8 +253,15 @@ class Overlay(QWidget): self.resize(width, HEIGHT) def _reposition(self): - # On a multi-monitor setup, show up where the user actually is. - screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() + # The screen the settings name, or, when none is named or it is not + # plugged in right now, where the user actually is. Names are connector + # names on X11 and model names on macOS, where two identical monitors + # can share one; the first then wins. + screen = next( + (item for item in QApplication.screens() if item.name() == self.screen_name), + None, + ) if self.screen_name else None + screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner @@ -271,7 +280,7 @@ 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() - elif self.state in LIVE: + elif self.state in LIVE and not self.screen_name: current_screen = QApplication.screenAt(QCursor.pos()) if current_screen is not None and current_screen != self.screen(): self._reposition() diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index c7fc52c..1103c3c 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -596,6 +596,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")) @@ -718,11 +719,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")) @@ -773,6 +769,31 @@ 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(): + # The native resolution, so that a scaled 4K screen reads + # 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale. + area = screen.geometry() + ratio = screen.devicePixelRatio() + self.indicator_screen.addItem( + t("{name} ({width} × {height})", name=screen.name(), + width=round(area.width() * ratio), + height=round(area.height() * ratio)), + 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) @@ -1652,6 +1673,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"]) @@ -1759,6 +1784,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 84c2a26..534a404 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, @@ -161,7 +162,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): @@ -992,6 +993,17 @@ class Overlay(DikteTest): self.assertEqual(widget.pos(), QPoint(1000 + overlay_module.MARGIN, 200 + overlay_module.MARGIN)) + 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")