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:
DazedAnon 2026-07-25 08:18:56 -05:00
parent abcea76fee
commit 0a618c5608
3 changed files with 763 additions and 190 deletions

View file

@ -1 +1 @@
node: 0a2b8c77b887a097113859aba3eeec0decd006b7 node: $Format:%H$

View file

@ -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)
@ -1242,15 +1393,12 @@ class ConfigTab(QWidget):
"""Reset to defaults, save, and show confirmation.""" """Reset to defaults, save, and show confirmation."""
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
View 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()