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.
This commit is contained in:
Seyit Gokce
2026-08-14 17:21:37 -04:00
parent 77b26e76be
commit f7fa37b4b7
2 changed files with 97 additions and 27 deletions
+71 -27
View File
@@ -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()
+26
View File
@@ -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."""