diff --git a/settings_ui.py b/settings_ui.py index b671931..8571a1a 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -4,12 +4,12 @@ import os import shutil import threading -from PyQt6.QtCore import 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, ) @@ -127,6 +127,58 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma " "*.mp4 *.mkv *.webm *.mov *.avi") +class WrappedLabel(QLabel): + """A label that wraps, and keeps the height the wrapping calls for. + + Word wrap on its own only decides where the lines break. The height comes + from the layout, which asks once, before the width is settled, and a label + sharing a row with a button is answered as if one line were enough: a long + program path then has its second line cut off. Claiming the height back as + a minimum, once the width is known, is what keeps the whole text on screen. + """ + + def __init__(self, text="", parent=None): + super().__init__(text, parent) + self.setWordWrap(True) + + def setText(self, text): + super().setText(text) + self._fit() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._fit() + + def _fit(self): + # 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. + if self.width() > 0: + wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere + box = QRect(0, 0, self.width(), 0) + self.setMinimumHeight( + 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. @@ -160,8 +212,7 @@ class LocalModelBox(QGroupBox): form = QFormLayout(self) - self.program_label = QLabel("") - self.program_label.setWordWrap(True) + self.program_label = WrappedLabel() self.install_button = QPushButton(t("Download")) self.install_button.clicked.connect(self._install_program) form.addRow(t("Program"), self._side_by_side(self.program_label, @@ -484,18 +535,22 @@ class SettingsWindow(QDialog): self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) - self.resize(680, 640) + + # 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._general_tab(), t("General")) - self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models")) - tabs.addTab(self._prompt_tab(), t("Cleanup rules")) - tabs.addTab(self._assistant_tab(), t("Agent")) - tabs.addTab(self._meeting_tab(), t("Meeting")) - tabs.addTab(self._minutes_tab(), t("Minutes")) - tabs.addTab(self._file_tab(), t("Audio file")) - tabs.addTab(self._shortcut_tab(), t("Shortcuts")) - tabs.addTab(self._history_tab(), t("History")) + tabs.addTab(self._scrolled(self._general_tab()), t("General")) + 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")) + tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent")) + tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting")) + tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes")) + tabs.addTab(self._scrolled(self._file_tab()), t("Audio file")) + tabs.addTab(self._scrolled(self._shortcut_tab()), t("Shortcuts")) + tabs.addTab(self._scrolled(self._history_tab()), t("History")) # Save keeps the window open, so the window is closed with the titlebar # cross (or Escape) instead. A "Cancel" next to it would be a lie: the @@ -507,6 +562,7 @@ class SettingsWindow(QDialog): layout = QVBoxLayout(self) layout.addWidget(tabs) layout.addWidget(buttons) + self._size_to_screen(680, 640) self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) @@ -528,6 +584,40 @@ class SettingsWindow(QDialog): if not conf.transcribe_ready(): self.tabs.setCurrentIndex(self.api_tab_index) + 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 + # is the tallest, and taller still under a large interface font) then + # pushes Save off the bottom of the screen with no way to shrink back. + area = QScrollArea() + 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): + """Open at this size, or at whatever the screen has room for.""" + screen = self.screen() or QGuiApplication.primaryScreen() + if screen is not None: + room = screen.availableGeometry() + # Room for the titlebar and a little air, so the window is grabbable + # 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 ---------------------------------------------------------- def _general_tab(self): @@ -975,12 +1065,7 @@ class SettingsWindow(QDialog): lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt()) ) layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight) - - area = QScrollArea() - area.setWidgetResizable(True) - area.setFrameShape(QScrollArea.Shape.NoFrame) - area.setWidget(page) - return area + return page def _meeting_tab(self): page = QWidget() @@ -1107,14 +1192,7 @@ class SettingsWindow(QDialog): lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt()) ) layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight) - - # Everything above is more than one screenful; let it scroll rather than - # squeezing the prompt box down to nothing. - area = QScrollArea() - area.setWidgetResizable(True) - area.setFrameShape(QScrollArea.Shape.NoFrame) - area.setWidget(page) - return area + return page def _minutes_tab(self): page = QWidget() diff --git a/tests/test_ui.py b/tests/test_ui.py index e069ad0..b06cc63 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,12 +133,81 @@ 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] self.assertEqual(tabs.count(), 9) self.assertEqual(window.windowTitle(), "Dikte Settings") + def test_no_tab_can_stretch_the_window_past_a_small_screen(self): + # A tab that keeps its full height hands that height to the window as a + # minimum, and a tall one then carries Save off the bottom of a laptop + # screen with no way to drag it back. Each tab scrolls instead. + window = self.window(cfg.Config()) + for index in range(window.tabs.count()): + window.tabs.setCurrentIndex(index) + 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 + # itself, and give it up again when the window is widened. + label = settings_ui.WrappedLabel() + # Shown, because a hidden widget is told about its new size only once + # somebody looks at it, and the height is worked out from that size. + label.show() + self.addCleanup(label.deleteLater) + line = label.fontMetrics().height() + label.resize(120, line) + label.setText("Installed on the system: /opt/homebrew/bin/whisper-server") + self.assertGreater(label.minimumHeight(), line) + label.resize(2000, line) + self.assertLessEqual(label.minimumHeight(), line) + def test_saving_without_touching_anything_changes_nothing(self): """Every widget has to load what is stored, or Save writes its default over it. This says so for the whole table at once."""