mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d2a40341 | ||
|
|
44db26c459 | ||
|
|
06e578d901 | ||
|
|
84c79b2d68 | ||
|
|
f36348d536 |
+8
-6
@@ -164,12 +164,14 @@ class Dikte:
|
|||||||
self._front_watch = 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"])
|
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
|
# 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.
|
# up, and drops into the corner when it is alone there.
|
||||||
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
|
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
|
||||||
dismissable=True,
|
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.recorder = audio.Recorder()
|
||||||
self.pipeline = Pipeline(self.conf)
|
self.pipeline = Pipeline(self.conf)
|
||||||
self.ask_pipeline = Pipeline(self.conf)
|
self.ask_pipeline = Pipeline(self.conf)
|
||||||
@@ -1297,10 +1299,10 @@ class Dikte:
|
|||||||
threading.Thread(target=warm, daemon=True).start()
|
threading.Thread(target=warm, daemon=True).start()
|
||||||
|
|
||||||
def _apply_settings(self):
|
def _apply_settings(self):
|
||||||
self.overlay.corner = self.conf["overlay_corner"]
|
for indicator in (self.overlay, self.ask_overlay):
|
||||||
self.overlay.screen_name = self.conf["overlay_screen"]
|
indicator.corner = self.conf["overlay_corner"]
|
||||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
indicator.screen_name = self.conf["overlay_screen"]
|
||||||
self.ask_overlay.screen_name = self.conf["overlay_screen"]
|
indicator.follow_pointer = self.conf["overlay_follows_pointer"]
|
||||||
self._apply_local()
|
self._apply_local()
|
||||||
self._build_tray()
|
self._build_tray()
|
||||||
self._refresh_tray()
|
self._refresh_tray()
|
||||||
|
|||||||
@@ -470,6 +470,9 @@ DEFAULTS = {
|
|||||||
"evdev_hotkey": False,
|
"evdev_hotkey": False,
|
||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"overlay_screen": "",
|
"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,
|
"keep_audio": False,
|
||||||
"history_limit": 200,
|
"history_limit": 200,
|
||||||
# A look at the releases page once a day, and nothing more than a look:
|
# 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":
|
"Restore the previous clipboard after pasting":
|
||||||
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
||||||
"Indicator screen": "Gösterge ekranı",
|
"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)",
|
"{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",
|
"Indicator corner": "Gösterge köşesi",
|
||||||
"bottom-left": "sol-alt",
|
"bottom-left": "sol-alt",
|
||||||
"bottom-right": "sağ-alt",
|
"bottom-right": "sağ-alt",
|
||||||
|
|||||||
+104
-10
@@ -1,6 +1,7 @@
|
|||||||
"""The small recording indicator that appears in a screen corner without taking focus."""
|
"""The small recording indicator that appears in a screen corner without taking focus."""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||||
@@ -15,6 +16,7 @@ MIN_WIDTH = 210
|
|||||||
MAX_WIDTH = 460
|
MAX_WIDTH = 460
|
||||||
MARGIN = 28
|
MARGIN = 28
|
||||||
GAP = 10 # between two indicators sharing a corner
|
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)
|
BG = QColor(22, 24, 29, 238)
|
||||||
BORDER = QColor(255, 255, 255, 28)
|
BORDER = QColor(255, 255, 255, 28)
|
||||||
@@ -37,16 +39,68 @@ STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
|
|||||||
LIVE = ("recording", "asking", "meeting")
|
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):
|
class Overlay(QWidget):
|
||||||
"""One indicator. Give it `below` and it stacks on top of that one instead
|
"""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
|
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."""
|
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=""):
|
screen_name="", follow_pointer=False):
|
||||||
super().__init__(None)
|
super().__init__(None)
|
||||||
self.corner = corner
|
self.corner = corner
|
||||||
self.screen_name = screen_name
|
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
|
self.below = below
|
||||||
# A job that can run for ten minutes should not have to be watched for
|
# 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
|
# ten minutes. Clicking such an indicator puts the progress away; the
|
||||||
@@ -65,6 +119,8 @@ class Overlay(QWidget):
|
|||||||
self.seconds = 0.0
|
self.seconds = 0.0
|
||||||
self._phase = 0.0
|
self._phase = 0.0
|
||||||
self._concealed = True
|
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 = (
|
flags = (
|
||||||
Qt.WindowType.FramelessWindowHint
|
Qt.WindowType.FramelessWindowHint
|
||||||
@@ -252,16 +308,52 @@ class Overlay(QWidget):
|
|||||||
min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra))
|
min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra))
|
||||||
self.resize(width, HEIGHT)
|
self.resize(width, HEIGHT)
|
||||||
|
|
||||||
def _reposition(self):
|
def _screen(self):
|
||||||
# The screen the settings name, or, when none is named or it is not
|
"""The screen this indicator belongs on right now.
|
||||||
# plugged in right now, where the user actually is. Names are connector
|
|
||||||
# names on X11 and model names on macOS, where two identical monitors
|
The one the settings name, or, when none is named or it is not plugged
|
||||||
# can share one; the first then wins.
|
in right now, where the user actually is. Names are connector names on
|
||||||
screen = next(
|
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),
|
(item for item in QApplication.screens() if item.name() == self.screen_name),
|
||||||
None,
|
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()
|
area = screen.availableGeometry()
|
||||||
left = "left" in self.corner
|
left = "left" in self.corner
|
||||||
top = "top" in self.corner
|
top = "top" in self.corner
|
||||||
@@ -277,8 +369,10 @@ class Overlay(QWidget):
|
|||||||
def _tick(self):
|
def _tick(self):
|
||||||
self._phase += 0.12
|
self._phase += 0.12
|
||||||
# The one underneath can come and go while this one is up; drop back to
|
# 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.
|
# the corner when it does rather than leaving a gap where it was. And
|
||||||
if self.below is not None and self.below.showing != self._stacked:
|
# 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()
|
self._reposition()
|
||||||
if self.state in LIVE and not self.paused:
|
if self.state in LIVE and not self.paused:
|
||||||
# keep the ribbon moving even through a pause in speech
|
# keep the ribbon moving even through a pause in speech
|
||||||
|
|||||||
+45
-6
@@ -181,18 +181,34 @@ class WrappedLabel(QLabel):
|
|||||||
super().setText(text)
|
super().setText(text)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
# Text set while the window was still being built was measured against
|
||||||
|
# nothing; this is the first moment the width means anything.
|
||||||
|
super().showEvent(event)
|
||||||
|
self._fit()
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
def _fit(self):
|
def _fit(self):
|
||||||
|
# A label the layout has not placed yet is a handful of pixels wide,
|
||||||
|
# and wrapping a sentence against that width invents a hundred lines.
|
||||||
|
# The minimum set from it does not stay a minimum either: QLabel folds
|
||||||
|
# it into its own cached size hints and clears that cache only when the
|
||||||
|
# text changes, so the row stands thousands of pixels tall and carries
|
||||||
|
# the model box and everything under it off the bottom of the window
|
||||||
|
# until another publisher is picked. Nothing to measure against yet
|
||||||
|
# means nothing to claim yet, and the show and resize above come back
|
||||||
|
# for it.
|
||||||
|
if not self.isVisible() or self.width() <= 0:
|
||||||
|
return
|
||||||
# Measured off the font rather than asked of the label, whose own answer
|
# 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.
|
# 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
|
||||||
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
box = QRect(0, 0, self.width(), 0)
|
||||||
box = QRect(0, 0, self.width(), 0)
|
self.setMinimumHeight(
|
||||||
self.setMinimumHeight(
|
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
||||||
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
|
||||||
|
|
||||||
|
|
||||||
class WheelGuard(QObject):
|
class WheelGuard(QObject):
|
||||||
@@ -1068,7 +1084,11 @@ class SettingsWindow(QDialog):
|
|||||||
form = QFormLayout(page)
|
form = QFormLayout(page)
|
||||||
|
|
||||||
self.indicator_screen = QComboBox()
|
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():
|
for screen in QGuiApplication.screens():
|
||||||
# The native resolution, so that a scaled 4K screen reads
|
# The native resolution, so that a scaled 4K screen reads
|
||||||
# 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale.
|
# 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale.
|
||||||
@@ -1082,12 +1102,26 @@ class SettingsWindow(QDialog):
|
|||||||
)
|
)
|
||||||
form.addRow(t("Indicator screen"), self.indicator_screen)
|
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()
|
self.corner = QComboBox()
|
||||||
for value in CORNERS:
|
for value in CORNERS:
|
||||||
self.corner.addItem(t(value), value)
|
self.corner.addItem(t(value), value)
|
||||||
form.addRow(t("Indicator corner"), self.corner)
|
form.addRow(t("Indicator corner"), self.corner)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _sync_follow_pointer(self):
|
||||||
|
self.follow_pointer.setEnabled(not self.indicator_screen.currentData())
|
||||||
|
|
||||||
def _api_tab(self):
|
def _api_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
outer = QVBoxLayout(page)
|
outer = QVBoxLayout(page)
|
||||||
@@ -2006,6 +2040,8 @@ class SettingsWindow(QDialog):
|
|||||||
if screen_name and self.indicator_screen.findData(screen_name) < 0:
|
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.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name)
|
||||||
self._select_data(self.indicator_screen, 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._select_data(self.corner, conf["overlay_corner"])
|
||||||
self.max_seconds.setValue(conf["max_seconds"])
|
self.max_seconds.setValue(conf["max_seconds"])
|
||||||
self.skip_silent.setChecked(conf["skip_silent"])
|
self.skip_silent.setChecked(conf["skip_silent"])
|
||||||
@@ -2126,6 +2162,9 @@ class SettingsWindow(QDialog):
|
|||||||
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
||||||
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
||||||
conf["overlay_screen"] = self.indicator_screen.currentData() or ""
|
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["overlay_corner"] = self.corner.currentData() or "bottom-left"
|
||||||
conf["max_seconds"] = self.max_seconds.value()
|
conf["max_seconds"] = self.max_seconds.value()
|
||||||
conf["skip_silent"] = self.skip_silent.isChecked()
|
conf["skip_silent"] = self.skip_silent.isChecked()
|
||||||
|
|||||||
+150
-1
@@ -14,7 +14,7 @@ import unittest
|
|||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from PyQt6.QtCore import QPoint, QPointF, Qt
|
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
|
||||||
from PyQt6.QtGui import QWheelEvent
|
from PyQt6.QtGui import QWheelEvent
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ CHANGED = {
|
|||||||
"restore_clipboard": True,
|
"restore_clipboard": True,
|
||||||
"overlay_corner": "top-right",
|
"overlay_corner": "top-right",
|
||||||
"overlay_screen": "DP-1",
|
"overlay_screen": "DP-1",
|
||||||
|
"overlay_follows_pointer": True,
|
||||||
"max_seconds": 120,
|
"max_seconds": 120,
|
||||||
"skip_silent": False,
|
"skip_silent": False,
|
||||||
"silence_db": -42.0,
|
"silence_db": -42.0,
|
||||||
@@ -243,6 +244,39 @@ class Settings(DikteTest):
|
|||||||
label.resize(2000, line)
|
label.resize(2000, line)
|
||||||
self.assertLessEqual(label.minimumHeight(), line)
|
self.assertLessEqual(label.minimumHeight(), line)
|
||||||
|
|
||||||
|
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
|
||||||
|
# The publisher note is written while the settings window is still
|
||||||
|
# being built, when the label is a handful of pixels wide. Wrapped
|
||||||
|
# against that width the sentence became a hundred lines, and the
|
||||||
|
# minimum taken from it did not stay a minimum: QLabel folds it into
|
||||||
|
# its own cached size hints and clears that cache only when the text
|
||||||
|
# changes. The group box stood thousands of pixels tall, with the
|
||||||
|
# model box and everything under it off the bottom of the window,
|
||||||
|
# until another publisher was picked.
|
||||||
|
label = settings_ui.WrappedLabel()
|
||||||
|
self.addCleanup(label.deleteLater)
|
||||||
|
line = label.fontMetrics().height()
|
||||||
|
label.resize(8, line)
|
||||||
|
label.setText("Google Gemma 4, the small one. The default: nothing "
|
||||||
|
"else this size follows an instruction as closely, and "
|
||||||
|
"cleanup is all instruction.")
|
||||||
|
self.assertEqual(label.minimumHeight(), 0)
|
||||||
|
# Placed and shown, which is the first width worth measuring against.
|
||||||
|
# The room the wrapping needs is claimed then, and it is the lines the
|
||||||
|
# sentence takes at this width rather than at the last one. Counted
|
||||||
|
# off the font rather than written down here, because how many lines
|
||||||
|
# 400 pixels hold is a different answer on every machine.
|
||||||
|
label.resize(400, line)
|
||||||
|
label.show()
|
||||||
|
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||||
|
needed = label.fontMetrics().boundingRect(
|
||||||
|
QRect(0, 0, 400, 0), wrap, label.text()).height()
|
||||||
|
self.assertGreater(needed, line) # or the sentence never wrapped
|
||||||
|
self.assertEqual(label.minimumHeight(), needed)
|
||||||
|
# And the label's own hints are the wrapping at this width too, not
|
||||||
|
# the hundred lines the eight pixel one asked for.
|
||||||
|
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
|
||||||
|
|
||||||
def test_saving_without_touching_anything_changes_nothing(self):
|
def test_saving_without_touching_anything_changes_nothing(self):
|
||||||
"""Every widget has to load what is stored, or Save writes its default
|
"""Every widget has to load what is stored, or Save writes its default
|
||||||
over it. This says so for the whole table at once."""
|
over it. This says so for the whole table at once."""
|
||||||
@@ -1069,6 +1103,121 @@ class Overlay(DikteTest):
|
|||||||
screen_at.assert_not_called()
|
screen_at.assert_not_called()
|
||||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
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):
|
def test_a_warning_and_an_error_both_show(self):
|
||||||
widget = self.overlay()
|
widget = self.overlay()
|
||||||
widget.show_warning("cleanup failed")
|
widget.show_warning("cleanup failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user