mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge pull request #31 from seyitgkc/fix/settings-window-height
Keep the settings window inside the screen Co-authored-by: Seyit Gokce <[email protected]>
This commit is contained in:
+108
-30
@@ -4,12 +4,12 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import threading
|
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.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
||||||
QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
|
||||||
QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
|
||||||
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
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")
|
"*.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):
|
class LocalModelBox(QGroupBox):
|
||||||
"""The program, the model, and the two downloads that put them there.
|
"""The program, the model, and the two downloads that put them there.
|
||||||
|
|
||||||
@@ -160,8 +212,7 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
form = QFormLayout(self)
|
form = QFormLayout(self)
|
||||||
|
|
||||||
self.program_label = QLabel("")
|
self.program_label = WrappedLabel()
|
||||||
self.program_label.setWordWrap(True)
|
|
||||||
self.install_button = QPushButton(t("Download"))
|
self.install_button = QPushButton(t("Download"))
|
||||||
self.install_button.clicked.connect(self._install_program)
|
self.install_button.clicked.connect(self._install_program)
|
||||||
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
||||||
@@ -484,18 +535,22 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self.transcriber = FileTranscriber(conf, self)
|
self.transcriber = FileTranscriber(conf, self)
|
||||||
self.setWindowTitle(t("Dikte Settings"))
|
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 = self.tabs = QTabWidget(self)
|
||||||
tabs.addTab(self._general_tab(), t("General"))
|
tabs.addTab(self._scrolled(self._general_tab()), t("General"))
|
||||||
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
|
self.api_tab_index = tabs.addTab(
|
||||||
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
self._scrolled(self._api_tab()), t("API and models"))
|
||||||
tabs.addTab(self._assistant_tab(), t("Agent"))
|
tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules"))
|
||||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent"))
|
||||||
tabs.addTab(self._minutes_tab(), t("Minutes"))
|
tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting"))
|
||||||
tabs.addTab(self._file_tab(), t("Audio file"))
|
tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes"))
|
||||||
tabs.addTab(self._shortcut_tab(), t("Shortcuts"))
|
tabs.addTab(self._scrolled(self._file_tab()), t("Audio file"))
|
||||||
tabs.addTab(self._history_tab(), t("History"))
|
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
|
# 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
|
# 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 = QVBoxLayout(self)
|
||||||
layout.addWidget(tabs)
|
layout.addWidget(tabs)
|
||||||
layout.addWidget(buttons)
|
layout.addWidget(buttons)
|
||||||
|
self._size_to_screen(680, 640)
|
||||||
|
|
||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
@@ -528,6 +584,40 @@ class SettingsWindow(QDialog):
|
|||||||
if not conf.transcribe_ready():
|
if not conf.transcribe_ready():
|
||||||
self.tabs.setCurrentIndex(self.api_tab_index)
|
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 ----------------------------------------------------------
|
# ---- tabs ----------------------------------------------------------
|
||||||
|
|
||||||
def _general_tab(self):
|
def _general_tab(self):
|
||||||
@@ -975,12 +1065,7 @@ class SettingsWindow(QDialog):
|
|||||||
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
|
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
|
||||||
)
|
)
|
||||||
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
return page
|
||||||
area = QScrollArea()
|
|
||||||
area.setWidgetResizable(True)
|
|
||||||
area.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
||||||
area.setWidget(page)
|
|
||||||
return area
|
|
||||||
|
|
||||||
def _meeting_tab(self):
|
def _meeting_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
@@ -1107,14 +1192,7 @@ class SettingsWindow(QDialog):
|
|||||||
lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt())
|
lambda: self.meeting_prompt.setPlainText(cfg.default_meeting_prompt())
|
||||||
)
|
)
|
||||||
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
return page
|
||||||
# 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
|
|
||||||
|
|
||||||
def _minutes_tab(self):
|
def _minutes_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import unittest
|
|||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QPoint, QPointF, Qt
|
||||||
|
from PyQt6.QtGui import QWheelEvent
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
import cleanup
|
import cleanup
|
||||||
@@ -116,6 +118,10 @@ class Settings(DikteTest):
|
|||||||
"_load_models"))
|
"_load_models"))
|
||||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"))
|
"_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.enterContext(mock.patch.object(settings_ui.hotkey, "APPLICATIONS_DIR",
|
||||||
self.path("applications")))
|
self.path("applications")))
|
||||||
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
|
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
|
||||||
@@ -127,12 +133,81 @@ class Settings(DikteTest):
|
|||||||
self.addCleanup(window.close)
|
self.addCleanup(window.close)
|
||||||
return window
|
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):
|
def test_the_window_opens_with_every_tab_on_it(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
tabs = window.findChildren(settings_ui.QTabWidget)[0]
|
tabs = window.findChildren(settings_ui.QTabWidget)[0]
|
||||||
self.assertEqual(tabs.count(), 9)
|
self.assertEqual(tabs.count(), 9)
|
||||||
self.assertEqual(window.windowTitle(), "Dikte Settings")
|
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):
|
def test_saving_without_touching_anything_changes_nothing(self):
|
||||||
"""Every widget has to load what is stored, or Save writes its default
|
"""Every widget has to load what is stored, or Save writes its default
|
||||||
over it. This says so for the whole table at once."""
|
over it. This says so for the whole table at once."""
|
||||||
|
|||||||
Reference in New Issue
Block a user