Compare commits

..

3 commits

Author SHA1 Message Date
0a618c5608 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
2026-07-25 08:18:56 -05:00
abcea76fee refactor(gui): streamline SHA display logic in UpdateDialog
- Remove the _available_sha_display method and directly utilize _short_sha for clarity.
- Update new version label to reflect the installed SHA when no pending SHA is available.
- Clean up related unit tests to align with the updated display logic.
2026-07-24 12:51:43 -05:00
fc0170725e feat(gui): enhance SHA display logic in UpdateDialog
- Introduce a new class method to display the available SHA or indicate "Current" when up to date.
- Update the new version label to use the new display method for better clarity.
- Adjust the label to show "Current" directly when no pending SHA is available.
- Add unit tests to verify the new display logic for available versions.
2026-07-24 12:43:06 -05:00
2 changed files with 762 additions and 189 deletions

View file

@ -5,14 +5,14 @@ Configuration Tab - Handles environment variables, global settings, and engine c
import os
from pathlib import Path
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit,
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, QLineEdit,
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
QLabel, QFileDialog, QMessageBox, QScrollArea, QTextEdit,
QLabel, QFileDialog, QMessageBox, QTextEdit,
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.QtGui import QIcon
from PyQt5.QtCore import Qt, QPoint, pyqtSignal, QTimer, QThread
from PyQt5.QtGui import QColor, QIcon, QPainter, QPalette, QPolygon
from dotenv import load_dotenv, set_key, dotenv_values
from gui.rpgmaker_tab import RPGMakerTab
@ -120,6 +120,86 @@ def create_horizontal_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):
"""Dialog to create or overwrite a named API key (secret entered once)."""
@ -417,97 +497,173 @@ class ConfigTab(QWidget):
page.refresh_from_module()
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.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 = (
"QToolButton {"
"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::menu-indicator { image: none; }"
)
def form_label(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setFixedWidth(label_w)
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
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)
def size_field(w: QWidget, minimum_width: int = 120) -> QWidget:
"""Give controls room to breathe while allowing them to resize."""
w.setMinimumWidth(minimum_width)
w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
w.setMinimumHeight(32)
return w
def row_box(*widgets: QWidget, stretch_index: int = 0) -> QWidget:
"""Fixed-width control cluster; stretch one child, never overflow buttons."""
def row_box(field: QWidget, *actions: QWidget) -> QWidget:
"""API row with equal field width and no visible filler widget."""
box = QWidget()
box.setStyleSheet("background: transparent;")
row = QHBoxLayout(box)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(8)
for i, child in enumerate(widgets):
if i == stretch_index:
child.setMinimumWidth(0)
child.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(child, 1)
else:
child.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
row.addWidget(child, 0)
box.setFixedWidth(field_w)
field.setMinimumWidth(0)
field.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(field, 1)
used_action_width = 0
for action in actions:
action.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
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.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
box.setMinimumWidth(120)
box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
return box
def make_section() -> QVBoxLayout:
section = QVBoxLayout()
section.setContentsMargins(0, 0, 0, 12)
section.setSpacing(row_gap)
return section
def create_card(title: str) -> tuple[QFrame, QFormLayout]:
card = QFrame()
card.setObjectName("settingsCard")
self._general_cards_by_title[title] = card
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:
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(20)
row.addWidget(form_label(label), 0, Qt.AlignVCenter)
row.addWidget(field, 0, Qt.AlignVCenter)
row.addStretch(1)
section.addLayout(row)
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(14, 10, 14, 12)
card_layout.setSpacing(6)
header = create_section_header(title)
if not isinstance(header, QLabel):
header.setStyleSheet("background: transparent;")
card_layout.addWidget(header)
columns_layout = QHBoxLayout()
columns_layout.setSpacing(40)
form = QFormLayout()
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 ──────────────────────────────────────────────
left_column = QVBoxLayout()
left_column.setSpacing(8)
def add_row(form: QFormLayout, label: str, field: QWidget) -> None:
label_widget = QLabel(label)
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_section = make_section()
# ── API ──────────────────────────────────────────────────────
api_card, api_section = create_card("🔑 API Configuration")
self.api_url_edit = QLineEdit()
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
api_url_preset_btn = QToolButton()
api_url_preset_btn.setText("Presets ▾")
api_url_preset_btn.setFixedWidth(84)
api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
api_url_preset_btn.setStyleSheet(btn_style)
self.api_url_preset_btn = QToolButton()
self.api_url_preset_btn.setText("Presets ▾")
self.api_url_preset_btn.setFixedWidth(96)
self.api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
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 (
("OpenAI", "https://api.openai.com/v1"),
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
@ -518,16 +674,16 @@ class ConfigTab(QWidget):
):
action = api_url_menu.addAction(name)
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)
add_row(
api_section,
"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.setToolTip(
"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.setText("New")
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.clicked.connect(self._on_api_key_new)
self.api_key_delete_btn = QToolButton()
self.api_key_delete_btn.setText("Delete")
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.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),
)
self.model_combo = QComboBox()
self.model_combo = ConfigComboBox()
self.model_combo.setEditable(True)
self.model_combo.addItems(ModelFetchThread.DEFAULTS)
self.model_refresh_btn = QToolButton()
self.model_refresh_btn.setText("")
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.clicked.connect(lambda: self.fetch_models(silent=False))
@ -571,13 +727,10 @@ class ConfigTab(QWidget):
row_box(self.model_combo, self.model_refresh_btn),
)
left_column.addLayout(api_section)
left_column.addWidget(create_horizontal_line())
# ── TRANSLATION + FORMATTING ─────────────────────────────────
translation_card, trans_section = create_card("🌐 Translation & Text")
left_column.addWidget(create_section_header("🌐 Translation Settings"))
trans_section = make_section()
self.language_combo = QComboBox()
self.language_combo = ConfigComboBox()
self.language_combo.addItems([
"English", "Spanish", "French", "German", "Italian",
"Portuguese", "Russian", "Chinese", "Korean", "Japanese",
@ -591,11 +744,40 @@ class ConfigTab(QWidget):
self.timeout_spin.setSuffix(" sec")
add_row(trans_section, "Timeout:", size_field(self.timeout_spin))
left_column.addLayout(trans_section)
left_column.addWidget(create_horizontal_line())
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(trans_section, "Dialogue Width:", size_field(self.width_spin))
left_column.addWidget(create_section_header("⚡ Performance Settings"))
perf_section = make_section()
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(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.setButtonSymbols(QSpinBox.NoButtons)
@ -626,69 +808,6 @@ class ConfigTab(QWidget):
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.setButtonSymbols(QDoubleSpinBox.NoButtons)
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.setValue(2.0)
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.setButtonSymbols(QDoubleSpinBox.NoButtons)
@ -705,13 +824,10 @@ class ConfigTab(QWidget):
self.output_cost_spin.setSingleStep(0.1)
self.output_cost_spin.setValue(8.0)
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)
right_column.addWidget(create_horizontal_line())
right_column.addWidget(create_section_header("🖥️ UI Settings"))
ui_section = make_section()
# ── INTERFACE ────────────────────────────────────────────────
interface_card, ui_section = create_card("🖥️ Interface")
self.font_scale_spin = QDoubleSpinBox()
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))
right_column.addLayout(ui_section)
right_column.addWidget(create_horizontal_line())
# ── GAME UPDATE ──────────────────────────────────────────────
updates_card, gu_section = create_card("📦 Game Update Defaults")
right_column.addWidget(create_section_header("📦 Game Update Defaults"))
gu_section = make_section()
self.gu_forge_combo = QComboBox()
self.gu_forge_combo = ConfigComboBox()
self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab")
self.gu_forge_combo.addItem("GitHub", "github")
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).")
add_row(gu_section, "Branch:", size_field(self.gu_branch_edit))
gu_hint = QLabel(
"Copied into each game's gameupdate/patch-config.txt when you run "
"Copy gameupdate/ (repo= is still per-game)."
)
gu_hint.setWordWrap(True)
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()
content = QWidget()
content.setObjectName("generalSettingsContent")
content_layout = QVBoxLayout(content)
content_layout.setContentsMargins(12, 10, 12, 10)
content_layout.setSpacing(0)
columns_layout.addLayout(left_column, 1)
columns_layout.addLayout(right_column, 1)
layout.addLayout(columns_layout)
dashboard = QGridLayout()
dashboard.setContentsMargins(0, 4, 0, 0)
dashboard.setHorizontalSpacing(12)
dashboard.setVerticalSpacing(12)
dashboard.setColumnStretch(0, 1)
dashboard.setColumnStretch(1, 1)
dashboard.setColumnStretch(2, 1)
layout.addSpacing(15)
button_layout = QHBoxLayout()
# The wider API card keeps its button clusters readable. The three
# 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)
from gui import qt_icons
@ -788,6 +919,11 @@ class ConfigTab(QWidget):
qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc")
reset_button.clicked.connect(self.reset_to_defaults_with_save)
reset_button.setMinimumHeight(32)
reset_button.setStyleSheet(
"QPushButton { background: #3e3e42; color: #dddddd; "
"border: 1px solid #555555; }"
"QPushButton:hover { background: #505050; }"
)
save_button = QPushButton()
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.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.addStretch()
button_layout.addWidget(reset_button)
button_layout.addWidget(save_button)
page_layout.addWidget(action_bar)
layout.addLayout(button_layout)
content.setLayout(layout)
widget.setLayout(QVBoxLayout())
widget.layout().setContentsMargins(0, 0, 0, 0)
widget.layout().addWidget(content)
QTimer.singleShot(0, self._update_general_label_width)
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
# ------------------------------------------------------------------
@ -1224,6 +1371,10 @@ class ConfigTab(QWidget):
self.note_width_spin.setValue(75)
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
self.font_scale_spin.setValue(1.0)
@ -1242,15 +1393,12 @@ class ConfigTab(QWidget):
"""Reset to defaults, save, and show confirmation."""
self.reset_to_defaults()
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
self.mvmz_tab.reset_to_defaults()
self.wolf_tab.reset_to_defaults()
self.csv_tab.reset_to_defaults()
self.srpg_tab.reset_to_defaults()
def get_config(self):
"""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()