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