fix(config): align and style general settings controls
- Reorganize settings into an even, scroll-free card grid - Use uniform controls, API buttons, and opaque dropdown popups - Add regression coverage for layout, persistence, and defaults - Restore Git archive SHA substitution metadata
This commit is contained in:
parent
abcea76fee
commit
0a618c5608
3 changed files with 763 additions and 190 deletions
|
|
@ -1 +1 @@
|
||||||
node: 0a2b8c77b887a097113859aba3eeec0decd006b7
|
node: $Format:%H$
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ Configuration Tab - Handles environment variables, global settings, and engine c
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit,
|
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, QLineEdit,
|
||||||
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
|
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
|
||||||
QLabel, QFileDialog, QMessageBox, QScrollArea, QTextEdit,
|
QLabel, QFileDialog, QMessageBox, QTextEdit,
|
||||||
QCheckBox, QApplication, QTabWidget, QFrame, QStackedWidget, QToolButton,
|
QCheckBox, QApplication, QTabWidget, QFrame, QStackedWidget, QToolButton,
|
||||||
QMenu, QDialog, QDialogButtonBox, QSizePolicy,
|
QMenu, QDialog, QDialogButtonBox, QListView, QSizePolicy,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread
|
from PyQt5.QtCore import Qt, QPoint, pyqtSignal, QTimer, QThread
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QColor, QIcon, QPainter, QPalette, QPolygon
|
||||||
from dotenv import load_dotenv, set_key, dotenv_values
|
from dotenv import load_dotenv, set_key, dotenv_values
|
||||||
|
|
||||||
from gui.rpgmaker_tab import RPGMakerTab
|
from gui.rpgmaker_tab import RPGMakerTab
|
||||||
|
|
@ -120,6 +120,86 @@ def create_horizontal_line():
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigComboBox(QComboBox):
|
||||||
|
"""Combo box with a platform-independent visible dropdown indicator."""
|
||||||
|
|
||||||
|
_POPUP_COLOR = QColor("#353539")
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
view = QListView()
|
||||||
|
view.setObjectName("configComboPopup")
|
||||||
|
view.setAutoFillBackground(True)
|
||||||
|
view.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
view.viewport().setAutoFillBackground(True)
|
||||||
|
view.viewport().setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
view.setStyleSheet("""
|
||||||
|
QListView#configComboPopup {
|
||||||
|
background-color: #353539;
|
||||||
|
color: #f2f2f2;
|
||||||
|
border: 1px solid #56565b;
|
||||||
|
outline: none;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
QListView#configComboPopup::item {
|
||||||
|
background-color: #353539;
|
||||||
|
color: #f2f2f2;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
QListView#configComboPopup::item:selected {
|
||||||
|
background-color: #007acc;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
QListView#configComboPopup::item:hover:!selected {
|
||||||
|
background-color: #45454a;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.setView(view)
|
||||||
|
self._apply_popup_palette()
|
||||||
|
|
||||||
|
def _apply_popup_palette(self):
|
||||||
|
for widget in (self.view(), self.view().viewport()):
|
||||||
|
widget.setAutoFillBackground(True)
|
||||||
|
widget.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
widget.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
||||||
|
palette = widget.palette()
|
||||||
|
palette.setColor(QPalette.Base, self._POPUP_COLOR)
|
||||||
|
palette.setColor(QPalette.Window, self._POPUP_COLOR)
|
||||||
|
palette.setColor(QPalette.AlternateBase, self._POPUP_COLOR)
|
||||||
|
palette.setColor(QPalette.Text, QColor("#f2f2f2"))
|
||||||
|
palette.setColor(QPalette.Highlight, QColor("#007acc"))
|
||||||
|
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||||
|
widget.setPalette(palette)
|
||||||
|
|
||||||
|
def showPopup(self):
|
||||||
|
self._apply_popup_palette()
|
||||||
|
super().showPopup()
|
||||||
|
self._apply_popup_palette()
|
||||||
|
popup = self.view().window()
|
||||||
|
popup.setWindowOpacity(1.0)
|
||||||
|
popup.setAttribute(Qt.WA_TranslucentBackground, False)
|
||||||
|
popup.setAutoFillBackground(True)
|
||||||
|
palette = popup.palette()
|
||||||
|
palette.setColor(QPalette.Window, self._POPUP_COLOR)
|
||||||
|
palette.setColor(QPalette.Base, self._POPUP_COLOR)
|
||||||
|
popup.setPalette(palette)
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
super().paintEvent(event)
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
painter.setPen(Qt.NoPen)
|
||||||
|
painter.setBrush(QColor("#d8d8d8" if self.isEnabled() else "#858585"))
|
||||||
|
center_x = self.width() - 13
|
||||||
|
center_y = self.height() // 2 + 1
|
||||||
|
painter.drawPolygon(QPolygon([
|
||||||
|
QPoint(center_x - 4, center_y - 2),
|
||||||
|
QPoint(center_x + 4, center_y - 2),
|
||||||
|
QPoint(center_x, center_y + 3),
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyEditDialog(QDialog):
|
class ApiKeyEditDialog(QDialog):
|
||||||
"""Dialog to create or overwrite a named API key (secret entered once)."""
|
"""Dialog to create or overwrite a named API key (secret entered once)."""
|
||||||
|
|
||||||
|
|
@ -417,97 +497,173 @@ class ConfigTab(QWidget):
|
||||||
page.refresh_from_module()
|
page.refresh_from_module()
|
||||||
|
|
||||||
def create_general_settings_tab(self):
|
def create_general_settings_tab(self):
|
||||||
"""Create combined general settings tab with API, Translation, Performance, and UI settings."""
|
"""Create a compact general-settings dashboard without nested scrolling."""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
|
widget.setObjectName("generalSettingsPage")
|
||||||
|
widget.setStyleSheet("""
|
||||||
|
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
|
||||||
|
background-color: #3e3e42;
|
||||||
|
color: #f2f2f2;
|
||||||
|
border: 1px solid #56565b;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 9px;
|
||||||
|
min-height: 30px;
|
||||||
|
selection-background-color: #007acc;
|
||||||
|
}
|
||||||
|
QLineEdit:hover, QComboBox:hover,
|
||||||
|
QSpinBox:hover, QDoubleSpinBox:hover {
|
||||||
|
border-color: #68686e;
|
||||||
|
}
|
||||||
|
QLineEdit:focus, QComboBox:focus,
|
||||||
|
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||||
|
border-color: #007acc;
|
||||||
|
background-color: #424247;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
subcontrol-origin: border;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 26px;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid #56565b;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
background-color: #4a4a50;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down:hover {
|
||||||
|
background-color: #007acc;
|
||||||
|
}
|
||||||
|
QComboBox QLineEdit {
|
||||||
|
background-color: #3e3e42;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView, QMenu {
|
||||||
|
background-color: #353539;
|
||||||
|
color: #f2f2f2;
|
||||||
|
border: 1px solid #56565b;
|
||||||
|
selection-background-color: #007acc;
|
||||||
|
selection-color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView::item, QMenu::item {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #007acc;
|
||||||
|
}
|
||||||
|
QLineEdit:disabled, QComboBox:disabled,
|
||||||
|
QSpinBox:disabled, QDoubleSpinBox:disabled {
|
||||||
|
background-color: #343438;
|
||||||
|
color: #858585;
|
||||||
|
border-color: #47474b;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
page_layout = QVBoxLayout(widget)
|
||||||
|
page_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
page_layout.setSpacing(0)
|
||||||
|
self._general_form_labels = []
|
||||||
|
self._general_cards_by_title = {}
|
||||||
|
self._general_label_width = None
|
||||||
|
|
||||||
content = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
layout.setSpacing(8)
|
|
||||||
layout.setContentsMargins(15, 15, 15, 15)
|
|
||||||
|
|
||||||
# Shared geometry: one label column + one field edge across both columns.
|
|
||||||
label_w = 160
|
|
||||||
field_w = 360
|
|
||||||
row_gap = 12
|
|
||||||
btn_style = (
|
btn_style = (
|
||||||
"QToolButton {"
|
"QToolButton {"
|
||||||
"background-color: #3e3e42; color: #cccccc;"
|
"background-color: #3e3e42; color: #cccccc;"
|
||||||
"border: 1px solid #555555; border-radius: 3px; padding: 4px 8px;"
|
"border: 1px solid #555555; border-radius: 4px; padding: 4px 8px;"
|
||||||
"}"
|
"}"
|
||||||
"QToolButton:hover { background-color: #505050; }"
|
"QToolButton:hover { background-color: #505050; }"
|
||||||
"QToolButton::menu-indicator { image: none; }"
|
"QToolButton::menu-indicator { image: none; }"
|
||||||
)
|
)
|
||||||
|
|
||||||
def form_label(text: str) -> QLabel:
|
def size_field(w: QWidget, minimum_width: int = 120) -> QWidget:
|
||||||
lbl = QLabel(text)
|
"""Give controls room to breathe while allowing them to resize."""
|
||||||
lbl.setFixedWidth(label_w)
|
w.setMinimumWidth(minimum_width)
|
||||||
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
lbl.setStyleSheet("padding: 0px; background: transparent;")
|
|
||||||
return lbl
|
|
||||||
|
|
||||||
def size_field(w: QWidget) -> QWidget:
|
|
||||||
w.setFixedWidth(field_w)
|
|
||||||
w.setMinimumWidth(field_w)
|
|
||||||
w.setMaximumWidth(field_w)
|
|
||||||
w.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
|
||||||
if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
|
if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
|
||||||
w.setMinimumHeight(32)
|
w.setMinimumHeight(32)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def row_box(*widgets: QWidget, stretch_index: int = 0) -> QWidget:
|
def row_box(field: QWidget, *actions: QWidget) -> QWidget:
|
||||||
"""Fixed-width control cluster; stretch one child, never overflow buttons."""
|
"""API row with equal field width and no visible filler widget."""
|
||||||
box = QWidget()
|
box = QWidget()
|
||||||
|
box.setStyleSheet("background: transparent;")
|
||||||
row = QHBoxLayout(box)
|
row = QHBoxLayout(box)
|
||||||
row.setContentsMargins(0, 0, 0, 0)
|
row.setContentsMargins(0, 0, 0, 0)
|
||||||
row.setSpacing(8)
|
row.setSpacing(8)
|
||||||
for i, child in enumerate(widgets):
|
|
||||||
if i == stretch_index:
|
field.setMinimumWidth(0)
|
||||||
child.setMinimumWidth(0)
|
field.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
child.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
row.addWidget(field, 1)
|
||||||
row.addWidget(child, 1)
|
|
||||||
else:
|
used_action_width = 0
|
||||||
child.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
for action in actions:
|
||||||
row.addWidget(child, 0)
|
action.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
||||||
box.setFixedWidth(field_w)
|
row.addWidget(action)
|
||||||
|
used_action_width += action.width()
|
||||||
|
used_action_width += max(0, len(actions) - 1) * row.spacing()
|
||||||
|
row.addSpacing(max(0, 200 - used_action_width))
|
||||||
|
|
||||||
box.setMinimumHeight(32)
|
box.setMinimumHeight(32)
|
||||||
box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
box.setMinimumWidth(120)
|
||||||
|
box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def make_section() -> QVBoxLayout:
|
def create_card(title: str) -> tuple[QFrame, QFormLayout]:
|
||||||
section = QVBoxLayout()
|
card = QFrame()
|
||||||
section.setContentsMargins(0, 0, 0, 12)
|
card.setObjectName("settingsCard")
|
||||||
section.setSpacing(row_gap)
|
self._general_cards_by_title[title] = card
|
||||||
return section
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
card.setStyleSheet(
|
||||||
|
"QFrame#settingsCard {"
|
||||||
|
"background-color: #303033;"
|
||||||
|
"border: 1px solid #444448;"
|
||||||
|
"border-radius: 7px;"
|
||||||
|
"}"
|
||||||
|
"QFrame#settingsCard:hover { border-color: #505055; }"
|
||||||
|
)
|
||||||
|
|
||||||
def add_row(section: QVBoxLayout, label: str, field: QWidget) -> None:
|
card_layout = QVBoxLayout(card)
|
||||||
row = QHBoxLayout()
|
card_layout.setContentsMargins(14, 10, 14, 12)
|
||||||
row.setContentsMargins(0, 0, 0, 0)
|
card_layout.setSpacing(6)
|
||||||
row.setSpacing(20)
|
header = create_section_header(title)
|
||||||
row.addWidget(form_label(label), 0, Qt.AlignVCenter)
|
if not isinstance(header, QLabel):
|
||||||
row.addWidget(field, 0, Qt.AlignVCenter)
|
header.setStyleSheet("background: transparent;")
|
||||||
row.addStretch(1)
|
card_layout.addWidget(header)
|
||||||
section.addLayout(row)
|
|
||||||
|
|
||||||
columns_layout = QHBoxLayout()
|
form = QFormLayout()
|
||||||
columns_layout.setSpacing(40)
|
form.setContentsMargins(0, 0, 0, 0)
|
||||||
|
form.setHorizontalSpacing(12)
|
||||||
|
form.setVerticalSpacing(7)
|
||||||
|
form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
form.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
||||||
|
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
|
||||||
|
card_layout.addLayout(form)
|
||||||
|
card_layout.addStretch()
|
||||||
|
return card, form
|
||||||
|
|
||||||
# ── LEFT COLUMN ──────────────────────────────────────────────
|
def add_row(form: QFormLayout, label: str, field: QWidget) -> None:
|
||||||
left_column = QVBoxLayout()
|
label_widget = QLabel(label)
|
||||||
left_column.setSpacing(8)
|
label_widget.setFixedWidth(180)
|
||||||
|
label_widget.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
label_widget.setStyleSheet("background: transparent;")
|
||||||
|
self._general_form_labels.append(label_widget)
|
||||||
|
form.addRow(label_widget, field)
|
||||||
|
|
||||||
left_column.addWidget(create_section_header("🔑 API Configuration"))
|
# ── API ──────────────────────────────────────────────────────
|
||||||
api_section = make_section()
|
api_card, api_section = create_card("🔑 API Configuration")
|
||||||
|
|
||||||
self.api_url_edit = QLineEdit()
|
self.api_url_edit = QLineEdit()
|
||||||
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
|
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
|
||||||
|
|
||||||
api_url_preset_btn = QToolButton()
|
self.api_url_preset_btn = QToolButton()
|
||||||
api_url_preset_btn.setText("Presets ▾")
|
self.api_url_preset_btn.setText("Presets ▾")
|
||||||
api_url_preset_btn.setFixedWidth(84)
|
self.api_url_preset_btn.setFixedWidth(96)
|
||||||
api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
|
self.api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
|
||||||
api_url_preset_btn.setStyleSheet(btn_style)
|
self.api_url_preset_btn.setStyleSheet(btn_style)
|
||||||
|
|
||||||
api_url_menu = QMenu(api_url_preset_btn)
|
api_url_menu = QMenu(self.api_url_preset_btn)
|
||||||
for name, url in (
|
for name, url in (
|
||||||
("OpenAI", "https://api.openai.com/v1"),
|
("OpenAI", "https://api.openai.com/v1"),
|
||||||
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
|
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
|
||||||
|
|
@ -518,16 +674,16 @@ class ConfigTab(QWidget):
|
||||||
):
|
):
|
||||||
action = api_url_menu.addAction(name)
|
action = api_url_menu.addAction(name)
|
||||||
action.triggered.connect(lambda checked, u=url: self.api_url_edit.setText(u))
|
action.triggered.connect(lambda checked, u=url: self.api_url_edit.setText(u))
|
||||||
api_url_preset_btn.setMenu(api_url_menu)
|
self.api_url_preset_btn.setMenu(api_url_menu)
|
||||||
self.api_url_edit.textChanged.connect(self._update_model_placeholder)
|
self.api_url_edit.textChanged.connect(self._update_model_placeholder)
|
||||||
|
|
||||||
add_row(
|
add_row(
|
||||||
api_section,
|
api_section,
|
||||||
"API URL:",
|
"API URL:",
|
||||||
row_box(self.api_url_edit, api_url_preset_btn),
|
row_box(self.api_url_edit, self.api_url_preset_btn),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.api_key_combo = QComboBox()
|
self.api_key_combo = ConfigComboBox()
|
||||||
self.api_key_combo.setEditable(False)
|
self.api_key_combo.setEditable(False)
|
||||||
self.api_key_combo.setToolTip(
|
self.api_key_combo.setToolTip(
|
||||||
"Select a saved API key by name. Optional per-key endpoints are "
|
"Select a saved API key by name. Optional per-key endpoints are "
|
||||||
|
|
@ -537,14 +693,14 @@ class ConfigTab(QWidget):
|
||||||
self.api_key_new_btn = QToolButton()
|
self.api_key_new_btn = QToolButton()
|
||||||
self.api_key_new_btn.setText("New")
|
self.api_key_new_btn.setText("New")
|
||||||
self.api_key_new_btn.setToolTip("Add or update a named API key")
|
self.api_key_new_btn.setToolTip("Add or update a named API key")
|
||||||
self.api_key_new_btn.setFixedWidth(52)
|
self.api_key_new_btn.setFixedWidth(96)
|
||||||
self.api_key_new_btn.setStyleSheet(btn_style)
|
self.api_key_new_btn.setStyleSheet(btn_style)
|
||||||
self.api_key_new_btn.clicked.connect(self._on_api_key_new)
|
self.api_key_new_btn.clicked.connect(self._on_api_key_new)
|
||||||
|
|
||||||
self.api_key_delete_btn = QToolButton()
|
self.api_key_delete_btn = QToolButton()
|
||||||
self.api_key_delete_btn.setText("Delete")
|
self.api_key_delete_btn.setText("Delete")
|
||||||
self.api_key_delete_btn.setToolTip("Remove the selected API key")
|
self.api_key_delete_btn.setToolTip("Remove the selected API key")
|
||||||
self.api_key_delete_btn.setFixedWidth(64)
|
self.api_key_delete_btn.setFixedWidth(96)
|
||||||
self.api_key_delete_btn.setStyleSheet(btn_style)
|
self.api_key_delete_btn.setStyleSheet(btn_style)
|
||||||
self.api_key_delete_btn.clicked.connect(self._on_api_key_delete)
|
self.api_key_delete_btn.clicked.connect(self._on_api_key_delete)
|
||||||
|
|
||||||
|
|
@ -554,14 +710,14 @@ class ConfigTab(QWidget):
|
||||||
row_box(self.api_key_combo, self.api_key_new_btn, self.api_key_delete_btn),
|
row_box(self.api_key_combo, self.api_key_new_btn, self.api_key_delete_btn),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.model_combo = QComboBox()
|
self.model_combo = ConfigComboBox()
|
||||||
self.model_combo.setEditable(True)
|
self.model_combo.setEditable(True)
|
||||||
self.model_combo.addItems(ModelFetchThread.DEFAULTS)
|
self.model_combo.addItems(ModelFetchThread.DEFAULTS)
|
||||||
|
|
||||||
self.model_refresh_btn = QToolButton()
|
self.model_refresh_btn = QToolButton()
|
||||||
self.model_refresh_btn.setText("⟳")
|
self.model_refresh_btn.setText("⟳")
|
||||||
self.model_refresh_btn.setToolTip("Fetch latest models from the configured API")
|
self.model_refresh_btn.setToolTip("Fetch latest models from the configured API")
|
||||||
self.model_refresh_btn.setFixedWidth(32)
|
self.model_refresh_btn.setFixedWidth(96)
|
||||||
self.model_refresh_btn.setStyleSheet(btn_style)
|
self.model_refresh_btn.setStyleSheet(btn_style)
|
||||||
self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False))
|
self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False))
|
||||||
|
|
||||||
|
|
@ -571,13 +727,10 @@ class ConfigTab(QWidget):
|
||||||
row_box(self.model_combo, self.model_refresh_btn),
|
row_box(self.model_combo, self.model_refresh_btn),
|
||||||
)
|
)
|
||||||
|
|
||||||
left_column.addLayout(api_section)
|
# ── TRANSLATION + FORMATTING ─────────────────────────────────
|
||||||
left_column.addWidget(create_horizontal_line())
|
translation_card, trans_section = create_card("🌐 Translation & Text")
|
||||||
|
|
||||||
left_column.addWidget(create_section_header("🌐 Translation Settings"))
|
self.language_combo = ConfigComboBox()
|
||||||
trans_section = make_section()
|
|
||||||
|
|
||||||
self.language_combo = QComboBox()
|
|
||||||
self.language_combo.addItems([
|
self.language_combo.addItems([
|
||||||
"English", "Spanish", "French", "German", "Italian",
|
"English", "Spanish", "French", "German", "Italian",
|
||||||
"Portuguese", "Russian", "Chinese", "Korean", "Japanese",
|
"Portuguese", "Russian", "Chinese", "Korean", "Japanese",
|
||||||
|
|
@ -591,11 +744,40 @@ class ConfigTab(QWidget):
|
||||||
self.timeout_spin.setSuffix(" sec")
|
self.timeout_spin.setSuffix(" sec")
|
||||||
add_row(trans_section, "Timeout:", size_field(self.timeout_spin))
|
add_row(trans_section, "Timeout:", size_field(self.timeout_spin))
|
||||||
|
|
||||||
left_column.addLayout(trans_section)
|
self.width_spin = QSpinBox()
|
||||||
left_column.addWidget(create_horizontal_line())
|
self.width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
||||||
|
self.width_spin.setRange(20, 200)
|
||||||
|
self.width_spin.setValue(60)
|
||||||
|
self.width_spin.setSuffix(" chars")
|
||||||
|
add_row(trans_section, "Dialogue Width:", size_field(self.width_spin))
|
||||||
|
|
||||||
left_column.addWidget(create_section_header("⚡ Performance Settings"))
|
self.list_width_spin = QSpinBox()
|
||||||
perf_section = make_section()
|
self.list_width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
||||||
|
self.list_width_spin.setRange(20, 200)
|
||||||
|
self.list_width_spin.setValue(100)
|
||||||
|
self.list_width_spin.setSuffix(" chars")
|
||||||
|
add_row(trans_section, "List Width:", size_field(self.list_width_spin))
|
||||||
|
|
||||||
|
self.note_width_spin = QSpinBox()
|
||||||
|
self.note_width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
||||||
|
self.note_width_spin.setRange(20, 200)
|
||||||
|
self.note_width_spin.setValue(75)
|
||||||
|
self.note_width_spin.setSuffix(" chars")
|
||||||
|
add_row(trans_section, "Note Width:", size_field(self.note_width_spin))
|
||||||
|
|
||||||
|
self.convert_quotes_cb = QCheckBox('Convert 「」 / 『』 to ""')
|
||||||
|
self.convert_quotes_cb.setChecked(True)
|
||||||
|
self.convert_quotes_cb.setToolTip(
|
||||||
|
"When enabled, Japanese corner brackets 「」 and 『』 are replaced "
|
||||||
|
'with ASCII double quotes "" in translated output (and on RPG Maker '
|
||||||
|
"source text before translation).\n\n"
|
||||||
|
"Leaving this on is recommended - the AI often fails to keep 「」 "
|
||||||
|
"consistent across lines."
|
||||||
|
)
|
||||||
|
add_row(trans_section, "Convert Quotes:", self.convert_quotes_cb)
|
||||||
|
|
||||||
|
# ── PERFORMANCE + PRICING ────────────────────────────────────
|
||||||
|
performance_card, perf_section = create_card("⚡ Performance & Pricing")
|
||||||
|
|
||||||
self.file_threads_spin = QSpinBox()
|
self.file_threads_spin = QSpinBox()
|
||||||
self.file_threads_spin.setButtonSymbols(QSpinBox.NoButtons)
|
self.file_threads_spin.setButtonSymbols(QSpinBox.NoButtons)
|
||||||
|
|
@ -626,69 +808,6 @@ class ConfigTab(QWidget):
|
||||||
size_field(self.frequency_penalty_spin),
|
size_field(self.frequency_penalty_spin),
|
||||||
)
|
)
|
||||||
|
|
||||||
left_column.addLayout(perf_section)
|
|
||||||
left_column.addStretch()
|
|
||||||
|
|
||||||
# ── RIGHT COLUMN ─────────────────────────────────────────────
|
|
||||||
right_column = QVBoxLayout()
|
|
||||||
right_column.setSpacing(8)
|
|
||||||
|
|
||||||
right_column.addWidget(create_section_header("📝 Text Formatting"))
|
|
||||||
format_section = make_section()
|
|
||||||
|
|
||||||
self.width_spin = QSpinBox()
|
|
||||||
self.width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
|
||||||
self.width_spin.setRange(20, 200)
|
|
||||||
self.width_spin.setValue(60)
|
|
||||||
self.width_spin.setSuffix(" chars")
|
|
||||||
add_row(format_section, "Dialogue Width:", size_field(self.width_spin))
|
|
||||||
|
|
||||||
self.list_width_spin = QSpinBox()
|
|
||||||
self.list_width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
|
||||||
self.list_width_spin.setRange(20, 200)
|
|
||||||
self.list_width_spin.setValue(100)
|
|
||||||
self.list_width_spin.setSuffix(" chars")
|
|
||||||
add_row(format_section, "List Width:", size_field(self.list_width_spin))
|
|
||||||
|
|
||||||
self.note_width_spin = QSpinBox()
|
|
||||||
self.note_width_spin.setButtonSymbols(QSpinBox.NoButtons)
|
|
||||||
self.note_width_spin.setRange(20, 200)
|
|
||||||
self.note_width_spin.setValue(75)
|
|
||||||
self.note_width_spin.setSuffix(" chars")
|
|
||||||
add_row(format_section, "Note Width:", size_field(self.note_width_spin))
|
|
||||||
|
|
||||||
self.convert_quotes_cb = QCheckBox('Convert 「」 / 『』 to ""')
|
|
||||||
self.convert_quotes_cb.setChecked(True)
|
|
||||||
self.convert_quotes_cb.setToolTip(
|
|
||||||
"When enabled, Japanese corner brackets 「」 and 『』 are replaced "
|
|
||||||
'with ASCII double quotes "" in translated output (and on RPG Maker '
|
|
||||||
"source text before translation).\n\n"
|
|
||||||
"Leaving this on is recommended - the AI often fails to keep 「」 "
|
|
||||||
"consistent across lines."
|
|
||||||
)
|
|
||||||
quotes_wrap = QWidget()
|
|
||||||
quotes_wrap.setFixedWidth(field_w)
|
|
||||||
quotes_row = QHBoxLayout(quotes_wrap)
|
|
||||||
quotes_row.setContentsMargins(0, 0, 0, 0)
|
|
||||||
quotes_row.setSpacing(0)
|
|
||||||
quotes_row.addWidget(self.convert_quotes_cb)
|
|
||||||
quotes_row.addStretch()
|
|
||||||
add_row(format_section, "Convert Quotes:", quotes_wrap)
|
|
||||||
|
|
||||||
right_column.addLayout(format_section)
|
|
||||||
right_column.addWidget(create_horizontal_line())
|
|
||||||
|
|
||||||
right_column.addWidget(create_section_header("💰 Custom API Pricing"))
|
|
||||||
pricing_note = QLabel("Only used if model isn't in built-in pricing list")
|
|
||||||
pricing_note.setStyleSheet(
|
|
||||||
f"color: #888888; font-style: italic; font-size: 11px; "
|
|
||||||
f"padding-left: {label_w + 20}px;"
|
|
||||||
)
|
|
||||||
pricing_note.setWordWrap(True)
|
|
||||||
right_column.addWidget(pricing_note)
|
|
||||||
|
|
||||||
price_section = make_section()
|
|
||||||
|
|
||||||
self.input_cost_spin = QDoubleSpinBox()
|
self.input_cost_spin = QDoubleSpinBox()
|
||||||
self.input_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
self.input_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
||||||
self.input_cost_spin.setRange(0.0, 100.0)
|
self.input_cost_spin.setRange(0.0, 100.0)
|
||||||
|
|
@ -696,7 +815,7 @@ class ConfigTab(QWidget):
|
||||||
self.input_cost_spin.setSingleStep(0.1)
|
self.input_cost_spin.setSingleStep(0.1)
|
||||||
self.input_cost_spin.setValue(2.0)
|
self.input_cost_spin.setValue(2.0)
|
||||||
self.input_cost_spin.setSuffix(" / 1M tokens")
|
self.input_cost_spin.setSuffix(" / 1M tokens")
|
||||||
add_row(price_section, "Input Cost:", size_field(self.input_cost_spin))
|
add_row(perf_section, "Input Cost:", size_field(self.input_cost_spin))
|
||||||
|
|
||||||
self.output_cost_spin = QDoubleSpinBox()
|
self.output_cost_spin = QDoubleSpinBox()
|
||||||
self.output_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
self.output_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
||||||
|
|
@ -705,13 +824,10 @@ class ConfigTab(QWidget):
|
||||||
self.output_cost_spin.setSingleStep(0.1)
|
self.output_cost_spin.setSingleStep(0.1)
|
||||||
self.output_cost_spin.setValue(8.0)
|
self.output_cost_spin.setValue(8.0)
|
||||||
self.output_cost_spin.setSuffix(" / 1M tokens")
|
self.output_cost_spin.setSuffix(" / 1M tokens")
|
||||||
add_row(price_section, "Output Cost:", size_field(self.output_cost_spin))
|
add_row(perf_section, "Output Cost:", size_field(self.output_cost_spin))
|
||||||
|
|
||||||
right_column.addLayout(price_section)
|
# ── INTERFACE ────────────────────────────────────────────────
|
||||||
right_column.addWidget(create_horizontal_line())
|
interface_card, ui_section = create_card("🖥️ Interface")
|
||||||
|
|
||||||
right_column.addWidget(create_section_header("🖥️ UI Settings"))
|
|
||||||
ui_section = make_section()
|
|
||||||
|
|
||||||
self.font_scale_spin = QDoubleSpinBox()
|
self.font_scale_spin = QDoubleSpinBox()
|
||||||
self.font_scale_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
self.font_scale_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
|
||||||
|
|
@ -727,13 +843,10 @@ class ConfigTab(QWidget):
|
||||||
)
|
)
|
||||||
add_row(ui_section, "Font Scale:", size_field(self.font_scale_spin))
|
add_row(ui_section, "Font Scale:", size_field(self.font_scale_spin))
|
||||||
|
|
||||||
right_column.addLayout(ui_section)
|
# ── GAME UPDATE ──────────────────────────────────────────────
|
||||||
right_column.addWidget(create_horizontal_line())
|
updates_card, gu_section = create_card("📦 Game Update Defaults")
|
||||||
|
|
||||||
right_column.addWidget(create_section_header("📦 Game Update Defaults"))
|
self.gu_forge_combo = ConfigComboBox()
|
||||||
gu_section = make_section()
|
|
||||||
|
|
||||||
self.gu_forge_combo = QComboBox()
|
|
||||||
self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab")
|
self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab")
|
||||||
self.gu_forge_combo.addItem("GitHub", "github")
|
self.gu_forge_combo.addItem("GitHub", "github")
|
||||||
self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo")
|
self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo")
|
||||||
|
|
@ -762,24 +875,42 @@ class ConfigTab(QWidget):
|
||||||
self.gu_branch_edit.setToolTip("Default branch to track (usually main or master).")
|
self.gu_branch_edit.setToolTip("Default branch to track (usually main or master).")
|
||||||
add_row(gu_section, "Branch:", size_field(self.gu_branch_edit))
|
add_row(gu_section, "Branch:", size_field(self.gu_branch_edit))
|
||||||
|
|
||||||
gu_hint = QLabel(
|
content = QWidget()
|
||||||
"Copied into each game's gameupdate/patch-config.txt when you run "
|
content.setObjectName("generalSettingsContent")
|
||||||
"Copy gameupdate/ (repo= is still per-game)."
|
content_layout = QVBoxLayout(content)
|
||||||
)
|
content_layout.setContentsMargins(12, 10, 12, 10)
|
||||||
gu_hint.setWordWrap(True)
|
content_layout.setSpacing(0)
|
||||||
gu_hint.setStyleSheet(
|
|
||||||
f"color:#7a7a7a;font-size:11px;padding-left: {label_w + 20}px;"
|
|
||||||
)
|
|
||||||
right_column.addLayout(gu_section)
|
|
||||||
right_column.addWidget(gu_hint)
|
|
||||||
right_column.addStretch()
|
|
||||||
|
|
||||||
columns_layout.addLayout(left_column, 1)
|
dashboard = QGridLayout()
|
||||||
columns_layout.addLayout(right_column, 1)
|
dashboard.setContentsMargins(0, 4, 0, 0)
|
||||||
layout.addLayout(columns_layout)
|
dashboard.setHorizontalSpacing(12)
|
||||||
|
dashboard.setVerticalSpacing(12)
|
||||||
|
dashboard.setColumnStretch(0, 1)
|
||||||
|
dashboard.setColumnStretch(1, 1)
|
||||||
|
dashboard.setColumnStretch(2, 1)
|
||||||
|
|
||||||
layout.addSpacing(15)
|
# The wider API card keeps its button clusters readable. The three
|
||||||
button_layout = QHBoxLayout()
|
# denser sections share the second row so the whole page fits without
|
||||||
|
# its own scroll bar.
|
||||||
|
dashboard.addWidget(api_card, 0, 0, 1, 2)
|
||||||
|
dashboard.addWidget(interface_card, 0, 2, 1, 1)
|
||||||
|
dashboard.addWidget(translation_card, 1, 0, 1, 1)
|
||||||
|
dashboard.addWidget(performance_card, 1, 1, 1, 1)
|
||||||
|
dashboard.addWidget(updates_card, 1, 2, 1, 1)
|
||||||
|
content_layout.addLayout(dashboard)
|
||||||
|
content_layout.addStretch()
|
||||||
|
page_layout.addWidget(content, 1)
|
||||||
|
|
||||||
|
# Persistent action bar keeps save/reset in the same predictable place.
|
||||||
|
action_bar = QFrame()
|
||||||
|
action_bar.setObjectName("settingsActionBar")
|
||||||
|
action_bar.setStyleSheet(
|
||||||
|
"QFrame#settingsActionBar {"
|
||||||
|
"background-color: #252526; border-top: 1px solid #414141;"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
button_layout = QHBoxLayout(action_bar)
|
||||||
|
button_layout.setContentsMargins(20, 9, 20, 9)
|
||||||
button_layout.setSpacing(10)
|
button_layout.setSpacing(10)
|
||||||
|
|
||||||
from gui import qt_icons
|
from gui import qt_icons
|
||||||
|
|
@ -788,6 +919,11 @@ class ConfigTab(QWidget):
|
||||||
qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc")
|
qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc")
|
||||||
reset_button.clicked.connect(self.reset_to_defaults_with_save)
|
reset_button.clicked.connect(self.reset_to_defaults_with_save)
|
||||||
reset_button.setMinimumHeight(32)
|
reset_button.setMinimumHeight(32)
|
||||||
|
reset_button.setStyleSheet(
|
||||||
|
"QPushButton { background: #3e3e42; color: #dddddd; "
|
||||||
|
"border: 1px solid #555555; }"
|
||||||
|
"QPushButton:hover { background: #505050; }"
|
||||||
|
)
|
||||||
|
|
||||||
save_button = QPushButton()
|
save_button = QPushButton()
|
||||||
qt_icons.apply_button_icon(save_button, "💾 Save Changes", color="#cccccc")
|
qt_icons.apply_button_icon(save_button, "💾 Save Changes", color="#cccccc")
|
||||||
|
|
@ -797,20 +933,31 @@ class ConfigTab(QWidget):
|
||||||
self.autosave_label = QLabel("")
|
self.autosave_label = QLabel("")
|
||||||
self.autosave_label.setStyleSheet("color: #4ec9b0; font-weight: bold;")
|
self.autosave_label.setStyleSheet("color: #4ec9b0; font-weight: bold;")
|
||||||
|
|
||||||
button_layout.addWidget(reset_button)
|
|
||||||
button_layout.addWidget(save_button)
|
|
||||||
button_layout.addSpacing(10)
|
|
||||||
button_layout.addWidget(self.autosave_label)
|
button_layout.addWidget(self.autosave_label)
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(reset_button)
|
||||||
|
button_layout.addWidget(save_button)
|
||||||
|
page_layout.addWidget(action_bar)
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
QTimer.singleShot(0, self._update_general_label_width)
|
||||||
|
|
||||||
content.setLayout(layout)
|
|
||||||
widget.setLayout(QVBoxLayout())
|
|
||||||
widget.layout().setContentsMargins(0, 0, 0, 0)
|
|
||||||
widget.layout().addWidget(content)
|
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
def _update_general_label_width(self):
|
||||||
|
"""Keep every form aligned without crowding the minimum-width layout."""
|
||||||
|
if not hasattr(self, "_general_form_labels"):
|
||||||
|
return
|
||||||
|
label_width = 150 if self.width() < 1150 else 180
|
||||||
|
if label_width == self._general_label_width:
|
||||||
|
return
|
||||||
|
for label in self._general_form_labels:
|
||||||
|
label.setFixedWidth(label_width)
|
||||||
|
self._general_label_width = label_width
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if hasattr(self, "_general_form_labels"):
|
||||||
|
QTimer.singleShot(0, self._update_general_label_width)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Model fetching
|
# Model fetching
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -1224,6 +1371,10 @@ class ConfigTab(QWidget):
|
||||||
self.note_width_spin.setValue(75)
|
self.note_width_spin.setValue(75)
|
||||||
self.convert_quotes_cb.setChecked(True)
|
self.convert_quotes_cb.setChecked(True)
|
||||||
|
|
||||||
|
# Custom API pricing
|
||||||
|
self.input_cost_spin.setValue(2.0)
|
||||||
|
self.output_cost_spin.setValue(8.0)
|
||||||
|
|
||||||
# UI settings
|
# UI settings
|
||||||
self.font_scale_spin.setValue(1.0)
|
self.font_scale_spin.setValue(1.0)
|
||||||
|
|
||||||
|
|
@ -1243,14 +1394,11 @@ class ConfigTab(QWidget):
|
||||||
self.reset_to_defaults()
|
self.reset_to_defaults()
|
||||||
self.save_to_env(show_message=False)
|
self.save_to_env(show_message=False)
|
||||||
|
|
||||||
# Custom API settings
|
|
||||||
self.input_cost_spin.setValue(2.0)
|
|
||||||
self.output_cost_spin.setValue(8.0)
|
|
||||||
|
|
||||||
# Reset engine tabs
|
# Reset engine tabs
|
||||||
self.mvmz_tab.reset_to_defaults()
|
self.mvmz_tab.reset_to_defaults()
|
||||||
self.wolf_tab.reset_to_defaults()
|
self.wolf_tab.reset_to_defaults()
|
||||||
self.csv_tab.reset_to_defaults()
|
self.csv_tab.reset_to_defaults()
|
||||||
|
self.srpg_tab.reset_to_defaults()
|
||||||
|
|
||||||
def get_config(self):
|
def get_config(self):
|
||||||
"""Get current configuration as dictionary."""
|
"""Get current configuration as dictionary."""
|
||||||
|
|
|
||||||
425
tests/test_config_tab.py
Normal file
425
tests/test_config_tab.py
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
"""Regression coverage for every general option on the Config tab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QPalette
|
||||||
|
from PyQt5.QtWidgets import QApplication, QMainWindow
|
||||||
|
|
||||||
|
from gui.config_tab import ConfigTab
|
||||||
|
from util import api_keys
|
||||||
|
|
||||||
|
_HAS_QT = True
|
||||||
|
except Exception: # pragma: no cover - PyQt5 is optional for non-GUI installs
|
||||||
|
_HAS_QT = False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_QT, "PyQt5 not available")
|
||||||
|
class ConfigTabRegressionTests(unittest.TestCase):
|
||||||
|
CONFIG_KEYS = {
|
||||||
|
"api",
|
||||||
|
"key",
|
||||||
|
"API_KEY_OPTIONAL",
|
||||||
|
"model",
|
||||||
|
"language",
|
||||||
|
"timeout",
|
||||||
|
"fileThreads",
|
||||||
|
"threads",
|
||||||
|
"batchsize",
|
||||||
|
"frequency_penalty",
|
||||||
|
"width",
|
||||||
|
"listWidth",
|
||||||
|
"noteWidth",
|
||||||
|
"convertQuotes",
|
||||||
|
"input_cost",
|
||||||
|
"output_cost",
|
||||||
|
"font_scale",
|
||||||
|
"gameUpdateForge",
|
||||||
|
"gameUpdateHost",
|
||||||
|
"gameUpdateUsername",
|
||||||
|
"gameUpdateBranch",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls._app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.base = Path(self._tmp.name)
|
||||||
|
self.env_path = self.base / ".env"
|
||||||
|
self.vault_path = self.base / "api_keys.json"
|
||||||
|
self._old_cwd = Path.cwd()
|
||||||
|
self._tabs: list[ConfigTab] = []
|
||||||
|
self._windows: list[QMainWindow] = []
|
||||||
|
|
||||||
|
self._environment = patch.dict(os.environ, {}, clear=False)
|
||||||
|
self._environment.start()
|
||||||
|
self._vault = patch.object(api_keys, "API_KEYS_PATH", self.vault_path)
|
||||||
|
self._vault.start()
|
||||||
|
self._fetch = patch.object(
|
||||||
|
ConfigTab,
|
||||||
|
"fetch_models",
|
||||||
|
autospec=True,
|
||||||
|
return_value=None,
|
||||||
|
)
|
||||||
|
self._fetch.start()
|
||||||
|
|
||||||
|
os.chdir(self.base)
|
||||||
|
self.env_path.write_text(
|
||||||
|
"\n".join([
|
||||||
|
"api=https://api.initial.test/v1",
|
||||||
|
"key=sk-regression",
|
||||||
|
"API_KEY_OPTIONAL=false",
|
||||||
|
"model=initial-model",
|
||||||
|
"language=Japanese",
|
||||||
|
"timeout=123",
|
||||||
|
"fileThreads=4",
|
||||||
|
"threads=7",
|
||||||
|
"batchsize=42",
|
||||||
|
"frequency_penalty=0.75",
|
||||||
|
"width=88",
|
||||||
|
"listWidth=99",
|
||||||
|
"noteWidth=111",
|
||||||
|
"convertQuotes=false",
|
||||||
|
"input_cost=3.25",
|
||||||
|
"output_cost=14.75",
|
||||||
|
"font_scale=1.4",
|
||||||
|
"gameUpdateForge=github",
|
||||||
|
"gameUpdateHost=github.example.test",
|
||||||
|
"gameUpdateUsername=translation-team",
|
||||||
|
"gameUpdateBranch=develop",
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# Keep this key's endpoint empty so the API URL field exercises the
|
||||||
|
# global endpoint round-trip rather than per-key endpoint precedence.
|
||||||
|
api_keys.upsert_key(
|
||||||
|
"Regression",
|
||||||
|
"sk-regression",
|
||||||
|
endpoint="",
|
||||||
|
path=self.vault_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for window in self._windows:
|
||||||
|
window.takeCentralWidget()
|
||||||
|
window.close()
|
||||||
|
for tab in self._tabs:
|
||||||
|
tab.close()
|
||||||
|
tab.deleteLater()
|
||||||
|
self._app.processEvents()
|
||||||
|
os.chdir(self._old_cwd)
|
||||||
|
self._fetch.stop()
|
||||||
|
self._vault.stop()
|
||||||
|
self._environment.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def make_tab(self) -> ConfigTab:
|
||||||
|
tab = ConfigTab()
|
||||||
|
self._tabs.append(tab)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def assert_initial_values(self, tab: ConfigTab) -> None:
|
||||||
|
self.assertEqual(tab.api_url_edit.text(), "https://api.initial.test/v1")
|
||||||
|
self.assertEqual(tab.api_key_combo.currentText(), "Regression")
|
||||||
|
self.assertEqual(tab.model_combo.currentText(), "initial-model")
|
||||||
|
self.assertEqual(tab.language_combo.currentText(), "Japanese")
|
||||||
|
self.assertEqual(tab.timeout_spin.value(), 123)
|
||||||
|
self.assertEqual(tab.file_threads_spin.value(), 4)
|
||||||
|
self.assertEqual(tab.threads_spin.value(), 7)
|
||||||
|
self.assertEqual(tab.batch_size_spin.value(), 42)
|
||||||
|
self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.75)
|
||||||
|
self.assertEqual(tab.width_spin.value(), 88)
|
||||||
|
self.assertEqual(tab.list_width_spin.value(), 99)
|
||||||
|
self.assertEqual(tab.note_width_spin.value(), 111)
|
||||||
|
self.assertFalse(tab.convert_quotes_cb.isChecked())
|
||||||
|
self.assertAlmostEqual(tab.input_cost_spin.value(), 3.25)
|
||||||
|
self.assertAlmostEqual(tab.output_cost_spin.value(), 14.75)
|
||||||
|
self.assertAlmostEqual(tab.font_scale_spin.value(), 1.4)
|
||||||
|
self.assertEqual(tab.gu_forge_combo.currentData(), "github")
|
||||||
|
self.assertEqual(tab.gu_host_edit.text(), "github.example.test")
|
||||||
|
self.assertEqual(tab.gu_username_edit.text(), "translation-team")
|
||||||
|
self.assertEqual(tab.gu_branch_edit.text(), "develop")
|
||||||
|
|
||||||
|
def test_loads_every_option_and_left_aligns_form_labels(self) -> None:
|
||||||
|
tab = self.make_tab()
|
||||||
|
|
||||||
|
self.assert_initial_values(tab)
|
||||||
|
self.assertEqual(set(tab.get_config()), self.CONFIG_KEYS - {"API_KEY_OPTIONAL"})
|
||||||
|
self.assertTrue(tab.validate())
|
||||||
|
self.assertTrue(tab._general_form_labels)
|
||||||
|
for label in tab._general_form_labels:
|
||||||
|
self.assertTrue(label.alignment() & Qt.AlignLeft, label.text())
|
||||||
|
|
||||||
|
def test_text_fields_and_dropdowns_share_alignment_and_size(self) -> None:
|
||||||
|
tab = self.make_tab()
|
||||||
|
window = QMainWindow()
|
||||||
|
self._windows.append(window)
|
||||||
|
window.setCentralWidget(tab)
|
||||||
|
window.resize(2048, 884)
|
||||||
|
window.show()
|
||||||
|
for _ in range(4):
|
||||||
|
self._app.processEvents()
|
||||||
|
|
||||||
|
def x_position(widget) -> int:
|
||||||
|
return widget.mapTo(tab, widget.rect().topLeft()).x()
|
||||||
|
|
||||||
|
def x_within_card(widget, title: str) -> int:
|
||||||
|
card = tab._general_cards_by_title[title]
|
||||||
|
return widget.mapTo(card, widget.rect().topLeft()).x()
|
||||||
|
|
||||||
|
api_controls = (
|
||||||
|
tab.api_url_edit,
|
||||||
|
tab.api_key_combo,
|
||||||
|
tab.model_combo,
|
||||||
|
)
|
||||||
|
self.assertEqual(len({x_position(widget) for widget in api_controls}), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{widget.width() for widget in api_controls},
|
||||||
|
{api_controls[0].width()},
|
||||||
|
)
|
||||||
|
api_buttons = (
|
||||||
|
tab.api_url_preset_btn,
|
||||||
|
tab.api_key_new_btn,
|
||||||
|
tab.api_key_delete_btn,
|
||||||
|
tab.model_refresh_btn,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{button.width() for button in api_buttons},
|
||||||
|
{api_buttons[0].width()},
|
||||||
|
)
|
||||||
|
|
||||||
|
aligned_groups = (
|
||||||
|
(
|
||||||
|
tab.language_combo,
|
||||||
|
tab.timeout_spin,
|
||||||
|
tab.width_spin,
|
||||||
|
tab.list_width_spin,
|
||||||
|
tab.note_width_spin,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
tab.file_threads_spin,
|
||||||
|
tab.threads_spin,
|
||||||
|
tab.batch_size_spin,
|
||||||
|
tab.frequency_penalty_spin,
|
||||||
|
tab.input_cost_spin,
|
||||||
|
tab.output_cost_spin,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
tab.gu_forge_combo,
|
||||||
|
tab.gu_host_edit,
|
||||||
|
tab.gu_username_edit,
|
||||||
|
tab.gu_branch_edit,
|
||||||
|
tab.font_scale_spin,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for group in aligned_groups:
|
||||||
|
self.assertEqual(len({x_position(widget) for widget in group}), 1)
|
||||||
|
self.assertEqual(len({widget.width() for widget in group}), 1)
|
||||||
|
self.assertEqual(len({widget.height() for widget in group}), 1)
|
||||||
|
|
||||||
|
standard_widths = [
|
||||||
|
widget.width()
|
||||||
|
for group in aligned_groups
|
||||||
|
for widget in group
|
||||||
|
]
|
||||||
|
self.assertLessEqual(max(standard_widths) - min(standard_widths), 1)
|
||||||
|
|
||||||
|
representative_controls = (
|
||||||
|
(tab.api_url_edit, "🔑 API Configuration"),
|
||||||
|
(tab.font_scale_spin, "🖥️ Interface"),
|
||||||
|
(tab.language_combo, "🌐 Translation & Text"),
|
||||||
|
(tab.file_threads_spin, "⚡ Performance & Pricing"),
|
||||||
|
(tab.gu_forge_combo, "📦 Game Update Defaults"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
len({
|
||||||
|
x_within_card(widget, title)
|
||||||
|
for widget, title in representative_controls
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dropdown_popup_is_opaque(self) -> None:
|
||||||
|
tab = self.make_tab()
|
||||||
|
window = QMainWindow()
|
||||||
|
self._windows.append(window)
|
||||||
|
window.setCentralWidget(tab)
|
||||||
|
window.resize(1280, 760)
|
||||||
|
window.show()
|
||||||
|
for _ in range(3):
|
||||||
|
self._app.processEvents()
|
||||||
|
|
||||||
|
tab.model_combo.showPopup()
|
||||||
|
for _ in range(3):
|
||||||
|
self._app.processEvents()
|
||||||
|
|
||||||
|
view = tab.model_combo.view()
|
||||||
|
popup = view.window()
|
||||||
|
self.assertTrue(view.autoFillBackground())
|
||||||
|
self.assertTrue(view.viewport().autoFillBackground())
|
||||||
|
self.assertEqual(view.palette().color(QPalette.Base).name(), "#353539")
|
||||||
|
self.assertEqual(
|
||||||
|
view.viewport().palette().color(QPalette.Base).name(),
|
||||||
|
"#353539",
|
||||||
|
)
|
||||||
|
self.assertEqual(popup.windowOpacity(), 1.0)
|
||||||
|
self.assertFalse(popup.testAttribute(Qt.WA_TranslucentBackground))
|
||||||
|
tab.model_combo.hidePopup()
|
||||||
|
|
||||||
|
def test_save_and_reload_round_trip_for_every_option(self) -> None:
|
||||||
|
tab = self.make_tab()
|
||||||
|
tab.disconnect_auto_save()
|
||||||
|
|
||||||
|
tab.api_url_edit.setText("https://api.saved.test/v2")
|
||||||
|
tab.model_combo.setCurrentText("saved-model")
|
||||||
|
tab.language_combo.setCurrentText("Korean")
|
||||||
|
tab.timeout_spin.setValue(234)
|
||||||
|
tab.file_threads_spin.setValue(6)
|
||||||
|
tab.threads_spin.setValue(9)
|
||||||
|
tab.batch_size_spin.setValue(64)
|
||||||
|
tab.frequency_penalty_spin.setValue(1.25)
|
||||||
|
tab.width_spin.setValue(72)
|
||||||
|
tab.list_width_spin.setValue(84)
|
||||||
|
tab.note_width_spin.setValue(96)
|
||||||
|
tab.convert_quotes_cb.setChecked(True)
|
||||||
|
tab.input_cost_spin.setValue(4.5)
|
||||||
|
tab.output_cost_spin.setValue(18.25)
|
||||||
|
tab.font_scale_spin.setValue(1.8)
|
||||||
|
tab.gu_forge_combo.setCurrentIndex(
|
||||||
|
tab.gu_forge_combo.findData("forgejo")
|
||||||
|
)
|
||||||
|
tab.gu_host_edit.setText("forge.example.test")
|
||||||
|
tab.gu_username_edit.setText("saved-team")
|
||||||
|
tab.gu_branch_edit.setText("release")
|
||||||
|
|
||||||
|
changes: list[bool] = []
|
||||||
|
tab.config_changed.connect(lambda: changes.append(True))
|
||||||
|
tab.save_to_env(show_message=False)
|
||||||
|
self.assertEqual(len(changes), 1)
|
||||||
|
|
||||||
|
saved = dotenv_values(self.env_path)
|
||||||
|
expected = {
|
||||||
|
"api": "https://api.saved.test/v2",
|
||||||
|
"key": "sk-regression",
|
||||||
|
"API_KEY_OPTIONAL": "false",
|
||||||
|
"model": "saved-model",
|
||||||
|
"language": "Korean",
|
||||||
|
"timeout": "234",
|
||||||
|
"fileThreads": "6",
|
||||||
|
"threads": "9",
|
||||||
|
"batchsize": "64",
|
||||||
|
"frequency_penalty": "1.25",
|
||||||
|
"width": "72",
|
||||||
|
"listWidth": "84",
|
||||||
|
"noteWidth": "96",
|
||||||
|
"convertQuotes": "true",
|
||||||
|
"input_cost": "4.5",
|
||||||
|
"output_cost": "18.25",
|
||||||
|
"font_scale": "1.8",
|
||||||
|
"gameUpdateForge": "forgejo",
|
||||||
|
"gameUpdateHost": "forge.example.test",
|
||||||
|
"gameUpdateUsername": "saved-team",
|
||||||
|
"gameUpdateBranch": "release",
|
||||||
|
}
|
||||||
|
self.assertEqual({key: saved.get(key) for key in expected}, expected)
|
||||||
|
|
||||||
|
reloaded = self.make_tab()
|
||||||
|
self.assertEqual(reloaded.api_url_edit.text(), expected["api"])
|
||||||
|
self.assertEqual(reloaded.api_key_combo.currentText(), "Regression")
|
||||||
|
self.assertEqual(reloaded.model_combo.currentText(), expected["model"])
|
||||||
|
self.assertEqual(reloaded.language_combo.currentText(), expected["language"])
|
||||||
|
self.assertEqual(reloaded.timeout_spin.value(), 234)
|
||||||
|
self.assertEqual(reloaded.file_threads_spin.value(), 6)
|
||||||
|
self.assertEqual(reloaded.threads_spin.value(), 9)
|
||||||
|
self.assertEqual(reloaded.batch_size_spin.value(), 64)
|
||||||
|
self.assertAlmostEqual(reloaded.frequency_penalty_spin.value(), 1.25)
|
||||||
|
self.assertEqual(reloaded.width_spin.value(), 72)
|
||||||
|
self.assertEqual(reloaded.list_width_spin.value(), 84)
|
||||||
|
self.assertEqual(reloaded.note_width_spin.value(), 96)
|
||||||
|
self.assertTrue(reloaded.convert_quotes_cb.isChecked())
|
||||||
|
self.assertAlmostEqual(reloaded.input_cost_spin.value(), 4.5)
|
||||||
|
self.assertAlmostEqual(reloaded.output_cost_spin.value(), 18.25)
|
||||||
|
self.assertAlmostEqual(reloaded.font_scale_spin.value(), 1.8)
|
||||||
|
self.assertEqual(reloaded.gu_forge_combo.currentData(), "forgejo")
|
||||||
|
self.assertEqual(reloaded.gu_host_edit.text(), "forge.example.test")
|
||||||
|
self.assertEqual(reloaded.gu_username_edit.text(), "saved-team")
|
||||||
|
self.assertEqual(reloaded.gu_branch_edit.text(), "release")
|
||||||
|
|
||||||
|
def test_reset_restores_and_persists_every_default(self) -> None:
|
||||||
|
tab = self.make_tab()
|
||||||
|
with (
|
||||||
|
patch.object(tab.mvmz_tab, "reset_to_defaults") as reset_mvmz,
|
||||||
|
patch.object(tab.wolf_tab, "reset_to_defaults") as reset_wolf,
|
||||||
|
patch.object(tab.csv_tab, "reset_to_defaults") as reset_csv,
|
||||||
|
patch.object(tab.srpg_tab, "reset_to_defaults") as reset_srpg,
|
||||||
|
):
|
||||||
|
tab.reset_to_defaults_with_save()
|
||||||
|
|
||||||
|
reset_mvmz.assert_called_once_with()
|
||||||
|
reset_wolf.assert_called_once_with()
|
||||||
|
reset_csv.assert_called_once_with()
|
||||||
|
reset_srpg.assert_called_once_with()
|
||||||
|
|
||||||
|
self.assertEqual(tab.api_url_edit.text(), "")
|
||||||
|
self.assertEqual(tab.model_combo.currentText(), "gpt-4.1")
|
||||||
|
self.assertEqual(tab.language_combo.currentText(), "English")
|
||||||
|
self.assertEqual(tab.timeout_spin.value(), 90)
|
||||||
|
self.assertEqual(tab.file_threads_spin.value(), 1)
|
||||||
|
self.assertEqual(tab.threads_spin.value(), 1)
|
||||||
|
self.assertEqual(tab.batch_size_spin.value(), 30)
|
||||||
|
self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.05)
|
||||||
|
self.assertEqual(tab.width_spin.value(), 60)
|
||||||
|
self.assertEqual(tab.list_width_spin.value(), 100)
|
||||||
|
self.assertEqual(tab.note_width_spin.value(), 75)
|
||||||
|
self.assertTrue(tab.convert_quotes_cb.isChecked())
|
||||||
|
self.assertAlmostEqual(tab.input_cost_spin.value(), 2.0)
|
||||||
|
self.assertAlmostEqual(tab.output_cost_spin.value(), 8.0)
|
||||||
|
self.assertAlmostEqual(tab.font_scale_spin.value(), 1.0)
|
||||||
|
self.assertEqual(tab.gu_forge_combo.currentData(), "gitlab")
|
||||||
|
self.assertEqual(tab.gu_host_edit.text(), "gitgud.io")
|
||||||
|
self.assertEqual(tab.gu_username_edit.text(), "")
|
||||||
|
self.assertEqual(tab.gu_branch_edit.text(), "main")
|
||||||
|
|
||||||
|
saved = dotenv_values(self.env_path)
|
||||||
|
expected_defaults = {
|
||||||
|
"api": "",
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"language": "English",
|
||||||
|
"timeout": "90",
|
||||||
|
"fileThreads": "1",
|
||||||
|
"threads": "1",
|
||||||
|
"batchsize": "30",
|
||||||
|
"frequency_penalty": "0.05",
|
||||||
|
"width": "60",
|
||||||
|
"listWidth": "100",
|
||||||
|
"noteWidth": "75",
|
||||||
|
"convertQuotes": "true",
|
||||||
|
"input_cost": "2.0",
|
||||||
|
"output_cost": "8.0",
|
||||||
|
"font_scale": "1.0",
|
||||||
|
"gameUpdateForge": "gitlab",
|
||||||
|
"gameUpdateHost": "gitgud.io",
|
||||||
|
"gameUpdateUsername": "",
|
||||||
|
"gameUpdateBranch": "main",
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
{key: saved.get(key) for key in expected_defaults},
|
||||||
|
expected_defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in a new issue