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."""