mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Put the indicator on the screen the session is actually on
The indicator asked QCursor.pos() which screen to appear on, and Wayland tells a client where the pointer is only while it is over one of that client's own windows. The indicator is never under the pointer, so the answer came back stale, or at the origin when the pointer had never been over a window of ours. Measured on Plasma 6: the origin under the Wayland platform, and a point frozen for the whole run through XWayland, which is the platform Dikte actually uses. Every indicator therefore landed in the corner of whichever screen holds 0,0, which on a two monitor desk is the wrong screen most of the time. KWin knows, and answers for it over D-Bus with activeOutputName, naming outputs the way Qt names screens: by connector, natively and through XWayland alike. That answer now comes before the pointer, and the pointer still decides everywhere else, which is right on X11 and no worse than before on other Wayland desktops. It is the active output and not the pointer's, so on Plasma the two are the same screen only where the active screen is set to follow the mouse, and otherwise it is the focused window that decides, which is where the typing is going anyway. Nothing in the settings window promises the pointer any more. Deciding the screen once, when the indicator appears, leaves it behind when the work moves to another monitor mid-recording, so overlay_follows_pointer keeps it up to date while it is up. Off by default: a ribbon that changes desks mid-sentence is one more thing moving while you are trying to talk. The compositor is asked four times a second rather than at the ribbon's 33 ms, because a hand moving a mouse across a desk is slower than that, and the call is given a 200 ms timeout so a wedged compositor cannot freeze the indicator with it. One indicator stacking on another takes that one's screen and never asks for its own. Asked for itself it would answer where the session is now, which is not where the ribbon underneath was put a minute ago, and the pair would end up a monitor apart with the top one raised over nothing. That one is checked every tick, since its answer costs nothing.
This commit is contained in:
+8
-6
@@ -164,12 +164,14 @@ class Dikte:
|
||||
self._front_watch = None
|
||||
|
||||
self.overlay = Overlay(self.conf["overlay_corner"],
|
||||
screen_name=self.conf["overlay_screen"])
|
||||
screen_name=self.conf["overlay_screen"],
|
||||
follow_pointer=self.conf["overlay_follows_pointer"])
|
||||
# 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,
|
||||
screen_name=self.conf["overlay_screen"])
|
||||
screen_name=self.conf["overlay_screen"],
|
||||
follow_pointer=self.conf["overlay_follows_pointer"])
|
||||
self.recorder = audio.Recorder()
|
||||
self.pipeline = Pipeline(self.conf)
|
||||
self.ask_pipeline = Pipeline(self.conf)
|
||||
@@ -1297,10 +1299,10 @@ class Dikte:
|
||||
threading.Thread(target=warm, daemon=True).start()
|
||||
|
||||
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"]
|
||||
for indicator in (self.overlay, self.ask_overlay):
|
||||
indicator.corner = self.conf["overlay_corner"]
|
||||
indicator.screen_name = self.conf["overlay_screen"]
|
||||
indicator.follow_pointer = self.conf["overlay_follows_pointer"]
|
||||
self._apply_local()
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
|
||||
@@ -470,6 +470,9 @@ DEFAULTS = {
|
||||
"evdev_hotkey": False,
|
||||
"overlay_corner": "bottom-left",
|
||||
"overlay_screen": "",
|
||||
# Off, so that an indicator stays where it appeared unless it is asked to
|
||||
# keep up with the pointer. Nothing to say when a screen is named above.
|
||||
"overlay_follows_pointer": False,
|
||||
"keep_audio": False,
|
||||
"history_limit": 200,
|
||||
# A look at the releases page once a day, and nothing more than a look:
|
||||
|
||||
+3
-1
@@ -189,8 +189,10 @@ TR = {
|
||||
"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",
|
||||
"Follow the active screen": "Etkin ekranı takip et",
|
||||
"{name} (not connected)": "{name} (bağlı değil)",
|
||||
"Move it when the active screen changes":
|
||||
"Etkin ekran değiştiğinde göstergeyi de taşı",
|
||||
"Indicator corner": "Gösterge köşesi",
|
||||
"bottom-left": "sol-alt",
|
||||
"bottom-right": "sağ-alt",
|
||||
|
||||
+104
-10
@@ -1,6 +1,7 @@
|
||||
"""The small recording indicator that appears in a screen corner without taking focus."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||
@@ -15,6 +16,7 @@ MIN_WIDTH = 210
|
||||
MAX_WIDTH = 460
|
||||
MARGIN = 28
|
||||
GAP = 10 # between two indicators sharing a corner
|
||||
FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second
|
||||
|
||||
BG = QColor(22, 24, 29, 238)
|
||||
BORDER = QColor(255, 255, 255, 28)
|
||||
@@ -37,16 +39,68 @@ STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
|
||||
LIVE = ("recording", "asking", "meeting")
|
||||
|
||||
|
||||
# KWin's interface, kept once one has been built. See _compositor_screen.
|
||||
_kwin = None
|
||||
|
||||
|
||||
def _compositor_screen():
|
||||
"""The screen KWin says the session is on, or None where nothing says.
|
||||
|
||||
Wayland tells a client where the pointer is only while it is over one of
|
||||
that client's own windows, and the indicator is never under the pointer, so
|
||||
QCursor.pos() answers with a stale point or, when the pointer has never
|
||||
been over a window of ours, with the origin. Either way the indicator lands
|
||||
in the corner of whichever screen holds 0,0 instead of the one being worked
|
||||
on, and on a two-monitor desk that is the wrong screen most of the time.
|
||||
KWin does know, and it names outputs the way Qt names screens, by
|
||||
connector, natively and through XWayland alike. No other Wayland desktop
|
||||
answers this, so the rest are left with the pointer, which is right on X11
|
||||
and wrong on Wayland exactly as before.
|
||||
|
||||
What it answers with is the active output, which is the one under the
|
||||
pointer only where Plasma is set to let the active screen follow the mouse.
|
||||
Under the default, click to focus, it is the focused window's screen, so
|
||||
the indicator lands where the typing is going rather than where the mouse
|
||||
was left. Which is why nothing here, and nothing in the settings window,
|
||||
promises the pointer.
|
||||
"""
|
||||
global _kwin
|
||||
if _kwin is None or not _kwin.isValid():
|
||||
# Which also leaves macOS and Windows out, where nothing sets it and
|
||||
# the pointer can be asked where it is like anywhere else.
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
if "kde" not in desktop and "plasma" not in desktop:
|
||||
return None
|
||||
try:
|
||||
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
|
||||
_kwin = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin",
|
||||
QDBusConnection.sessionBus())
|
||||
except Exception:
|
||||
return None
|
||||
if not _kwin.isValid():
|
||||
return None
|
||||
# A compositor busy enough not to answer in a fifth of a second is one
|
||||
# the indicator should stop waiting for, not one it should freeze with.
|
||||
_kwin.setTimeout(200)
|
||||
answer = _kwin.call("activeOutputName").arguments()
|
||||
name = answer[0] if answer else ""
|
||||
return next((item for item in QApplication.screens() if item.name() == name),
|
||||
None)
|
||||
|
||||
|
||||
class Overlay(QWidget):
|
||||
"""One indicator. Give it `below` and it stacks on top of that one instead
|
||||
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,
|
||||
screen_name=""):
|
||||
screen_name="", follow_pointer=False):
|
||||
super().__init__(None)
|
||||
self.corner = corner
|
||||
self.screen_name = screen_name
|
||||
# Whether it goes on following the pointer once it is up, rather than
|
||||
# settling on the screen it appeared on.
|
||||
self.follow_pointer = follow_pointer
|
||||
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
|
||||
@@ -65,6 +119,8 @@ class Overlay(QWidget):
|
||||
self.seconds = 0.0
|
||||
self._phase = 0.0
|
||||
self._concealed = True
|
||||
self._shown_on = "" # the screen it was last put on, by name
|
||||
self._looks = 0 # ticks since the pointer was last looked for
|
||||
|
||||
flags = (
|
||||
Qt.WindowType.FramelessWindowHint
|
||||
@@ -252,16 +308,52 @@ class Overlay(QWidget):
|
||||
min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra))
|
||||
self.resize(width, HEIGHT)
|
||||
|
||||
def _reposition(self):
|
||||
# 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(
|
||||
def _screen(self):
|
||||
"""The screen this indicator belongs on right now.
|
||||
|
||||
The one 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.
|
||||
|
||||
One stacking on another belongs on that one's screen and nowhere else.
|
||||
Asked for itself it would answer where the user is now, which is not
|
||||
where the ribbon it stacks on was put a minute ago, and the pair would
|
||||
end up a monitor apart with this one raised over nothing.
|
||||
"""
|
||||
if self.below is not None and self.below.showing:
|
||||
under = next((item for item in QApplication.screens()
|
||||
if item.name() == self.below._shown_on), None)
|
||||
if under is not None:
|
||||
return under
|
||||
named = next(
|
||||
(item for item in QApplication.screens() if item.name() == self.screen_name),
|
||||
None,
|
||||
)
|
||||
screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen()
|
||||
return (named or _compositor_screen()
|
||||
or QApplication.screenAt(QCursor.pos())
|
||||
or QApplication.primaryScreen())
|
||||
|
||||
def _wandered_off(self):
|
||||
"""Whether the pointer has left the screen the indicator is on.
|
||||
|
||||
Only asked while it is following, and only every few ticks: the answer
|
||||
costs a word with the compositor, and a hand moving a mouse across a
|
||||
desk is slow next to a 33 ms ribbon. Every tick for one that stacks on
|
||||
another, where the answer is free and waiting a third of a second for
|
||||
it would leave the pair split over two monitors for that long.
|
||||
"""
|
||||
if not self.follow_pointer or self.screen_name:
|
||||
return False
|
||||
if self.below is None or not self.below.showing:
|
||||
self._looks = (self._looks + 1) % FOLLOW_EVERY
|
||||
if self._looks:
|
||||
return False
|
||||
return self._screen().name() != self._shown_on
|
||||
|
||||
def _reposition(self):
|
||||
screen = self._screen()
|
||||
self._shown_on = screen.name()
|
||||
area = screen.availableGeometry()
|
||||
left = "left" in self.corner
|
||||
top = "top" in self.corner
|
||||
@@ -277,8 +369,10 @@ class Overlay(QWidget):
|
||||
def _tick(self):
|
||||
self._phase += 0.12
|
||||
# The one underneath can come and go while this one is up; drop back to
|
||||
# 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:
|
||||
# the corner when it does rather than leaving a gap where it was. And
|
||||
# the screen under the pointer can change while it is up too.
|
||||
moved = self.below is not None and self.below.showing != self._stacked
|
||||
if moved or self._wandered_off():
|
||||
self._reposition()
|
||||
if self.state in LIVE and not self.paused:
|
||||
# keep the ribbon moving even through a pause in speech
|
||||
|
||||
+24
-1
@@ -867,7 +867,11 @@ class SettingsWindow(QDialog):
|
||||
form = QFormLayout(page)
|
||||
|
||||
self.indicator_screen = QComboBox()
|
||||
self.indicator_screen.addItem(t("Follow the mouse pointer"), "")
|
||||
# The active screen rather than the pointer, for the reason in
|
||||
# overlay._compositor_screen: it is what a compositor will answer for,
|
||||
# and on Plasma the two are one screen only where the active screen is
|
||||
# set to follow the mouse.
|
||||
self.indicator_screen.addItem(t("Follow the active screen"), "")
|
||||
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.
|
||||
@@ -881,12 +885,26 @@ class SettingsWindow(QDialog):
|
||||
)
|
||||
form.addRow(t("Indicator screen"), self.indicator_screen)
|
||||
|
||||
# Only the screen it appeared on is decided when it appears; this is
|
||||
# what makes it keep up with a session that moves to another one
|
||||
# mid-recording. The active screen and not the pointer, because that is
|
||||
# what a compositor will answer for: on Plasma the two are the same
|
||||
# screen only where the active screen is set to follow the mouse, and
|
||||
# otherwise it is the focused window that decides. Nothing to offer
|
||||
# when a screen is named above, since that name is the whole answer.
|
||||
self.follow_pointer = QCheckBox(t("Move it when the active screen changes"))
|
||||
self.indicator_screen.currentIndexChanged.connect(self._sync_follow_pointer)
|
||||
form.addRow("", self.follow_pointer)
|
||||
|
||||
self.corner = QComboBox()
|
||||
for value in CORNERS:
|
||||
self.corner.addItem(t(value), value)
|
||||
form.addRow(t("Indicator corner"), self.corner)
|
||||
return page
|
||||
|
||||
def _sync_follow_pointer(self):
|
||||
self.follow_pointer.setEnabled(not self.indicator_screen.currentData())
|
||||
|
||||
def _api_tab(self):
|
||||
page = QWidget()
|
||||
outer = QVBoxLayout(page)
|
||||
@@ -1805,6 +1823,8 @@ class SettingsWindow(QDialog):
|
||||
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.follow_pointer.setChecked(conf["overlay_follows_pointer"])
|
||||
self._sync_follow_pointer()
|
||||
self._select_data(self.corner, conf["overlay_corner"])
|
||||
self.max_seconds.setValue(conf["max_seconds"])
|
||||
self.skip_silent.setChecked(conf["skip_silent"])
|
||||
@@ -1925,6 +1945,9 @@ class SettingsWindow(QDialog):
|
||||
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
||||
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
||||
conf["overlay_screen"] = self.indicator_screen.currentData() or ""
|
||||
# Read even while it is greyed out, so that naming a screen and taking
|
||||
# the name back again does not clear a preference nobody touched.
|
||||
conf["overlay_follows_pointer"] = self.follow_pointer.isChecked()
|
||||
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
|
||||
conf["max_seconds"] = self.max_seconds.value()
|
||||
conf["skip_silent"] = self.skip_silent.isChecked()
|
||||
|
||||
@@ -52,6 +52,7 @@ CHANGED = {
|
||||
"restore_clipboard": True,
|
||||
"overlay_corner": "top-right",
|
||||
"overlay_screen": "DP-1",
|
||||
"overlay_follows_pointer": True,
|
||||
"max_seconds": 120,
|
||||
"skip_silent": False,
|
||||
"silence_db": -42.0,
|
||||
@@ -1069,6 +1070,121 @@ class Overlay(DikteTest):
|
||||
screen_at.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def _screen(self, name, area):
|
||||
screen = mock.Mock()
|
||||
screen.name.return_value = name
|
||||
screen.availableGeometry.return_value = area
|
||||
return screen
|
||||
|
||||
def _kwin(self, *answer):
|
||||
kwin = mock.Mock()
|
||||
kwin.isValid.return_value = True
|
||||
kwin.call.return_value.arguments.return_value = list(answer)
|
||||
return kwin
|
||||
|
||||
def test_the_compositor_says_which_screen_the_pointer_is_on(self):
|
||||
"""Wayland tells a client where the pointer is only while it is over one
|
||||
of that client's own windows, so QCursor.pos() comes back at the origin
|
||||
and every indicator lands on whichever screen holds it. KWin knows."""
|
||||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", self._kwin("DP-2")), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||||
widget._reposition()
|
||||
screen_at.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def test_the_pointer_decides_when_the_compositor_will_not_say(self):
|
||||
"""Every desktop but Plasma, and Plasma while KWin is being replaced."""
|
||||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080))]
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", self._kwin()), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt",
|
||||
return_value=screens[0]) as screen_at:
|
||||
widget._reposition()
|
||||
screen_at.assert_called()
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def _two_screens(self):
|
||||
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||
|
||||
def _ticks_on(self, widget, screens, kwin):
|
||||
"""Run the ribbon long enough for one look at where the pointer is."""
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt", return_value=screens[0]):
|
||||
for _ in range(overlay_module.FOLLOW_EVERY):
|
||||
widget._tick()
|
||||
|
||||
def test_it_can_be_told_to_keep_up_with_the_pointer(self):
|
||||
"""The screen it started on is not always the screen you end up on."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(follow_pointer=True)
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self):
|
||||
"""Left off, because an indicator that jumps desks mid-sentence is one
|
||||
more thing moving while you are trying to talk."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def test_a_named_screen_is_never_left_for_the_pointer(self):
|
||||
"""Naming one is the whole answer; following it would undo the naming."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(screen_name="DP-1", follow_pointer=True)
|
||||
with mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
kwin.call.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def test_the_one_on_top_goes_where_the_one_underneath_is(self):
|
||||
"""Asking for itself would put the pair on two monitors, with this one
|
||||
raised over a ribbon that is not underneath it."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
first = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
first.show_recording()
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
second = self.overlay(below=first)
|
||||
second.show_busy("Asking Claude…")
|
||||
self.assertEqual(first.pos(), QPoint(1948, 995))
|
||||
self.assertEqual(second.pos(), QPoint(1948, 929))
|
||||
|
||||
def test_the_compositor_is_asked_only_now_and_then(self):
|
||||
"""Every tick would be thirty conversations a second about a hand
|
||||
moving a mouse."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(follow_pointer=True)
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
kwin.call.reset_mock()
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(kwin.call.call_count, 1)
|
||||
|
||||
def test_a_warning_and_an_error_both_show(self):
|
||||
widget = self.overlay()
|
||||
widget.show_warning("cleanup failed")
|
||||
|
||||
Reference in New Issue
Block a user