From f7fa37b4b7c242101a3f9461fd8fa03e6eb45f98 Mon Sep 17 00:00:00 2001 From: Seyit Gokce <47825211+seyitgkc@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:21:37 -0400 Subject: [PATCH 1/2] Keep the settings window inside the screen Only two tabs scrolled, so the rest handed their full content height to the window as a minimum. The API tab alone asked for 668px, which put the window's minimum at 756px: taller than a laptop screen has room for once the menu bar and dock have taken theirs, and unshrinkable, so Save sat off the bottom edge. Every tab scrolls now, and the opening size is clamped to what the screen actually offers. The program path in the local model box was clipped to one line for a related reason: it shares a form row with a button, and the row is measured before its width is known. WrappedLabel re-measures against the width it ends up with and claims that height back. --- settings_ui.py | 98 +++++++++++++++++++++++++++++++++++------------- tests/test_ui.py | 26 +++++++++++++ 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/settings_ui.py b/settings_ui.py index 87d2611..4a87fa2 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -4,7 +4,7 @@ import os import shutil import threading -from PyQt6.QtCore import Qt, QUrl, pyqtSignal +from PyQt6.QtCore import QRect, Qt, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -127,6 +127,38 @@ 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 LocalModelBox(QGroupBox): """The program, the model, and the two downloads that put them there. @@ -160,8 +192,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 +515,18 @@ class SettingsWindow(QDialog): self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) - self.resize(680, 640) 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 +538,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 +560,30 @@ class SettingsWindow(QDialog): if not conf.transcribe_ready(): self.tabs.setCurrentIndex(self.api_tab_index) + @staticmethod + def _scrolled(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) + 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) + self.resize(width, height) + # ---- tabs ---------------------------------------------------------- def _general_tab(self): @@ -967,12 +1023,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() @@ -1099,14 +1150,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 518166f..2efa676 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -133,6 +133,32 @@ class Settings(DikteTest): 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_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.""" From 5547f858488db258f79ce2994705eccf1208914d Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 12:09:21 +0300 Subject: [PATCH 2/2] Keep the wheel off the boxes and a floor under the window Two things the scrolling tabs brought with them. A combo box and a spin box read the wheel as a change of value, and Qt hands them the focus before delivering it. Now that every tab scrolls, rolling down the API tab with the pointer over the model box picks a different model on the way past, and Save writes it down. The boxes take the focus by click or by tab only, and a wheel that arrives at one without the focus is refused rather than swallowed, so it carries on up to the scroll area and the page moves instead. A tab that scrolls also asks for no height of its own, which left nothing to stop the window being dragged down to a tab bar and half a button. It has a floor now, and the floor never asks for more room than the screen was just found to have. --- settings_ui.py | 46 +++++++++++++++++++++++++++++++++++++++------ tests/test_ui.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) 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