Compare commits

..

No commits in common. "0a618c56082a15bc8410f3adaf4e1e8c6dec5d64" and "0a2b8c77b887a097113859aba3eeec0decd006b7" have entirely different histories.

2 changed files with 190 additions and 763 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, QGridLayout, QLineEdit,
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit,
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
QLabel, QFileDialog, QMessageBox, QTextEdit,
QLabel, QFileDialog, QMessageBox, QScrollArea, QTextEdit,
QCheckBox, QApplication, QTabWidget, QFrame, QStackedWidget, QToolButton,
QMenu, QDialog, QDialogButtonBox, QListView, QSizePolicy,
QMenu, QDialog, QDialogButtonBox, QSizePolicy,
)
from PyQt5.QtCore import Qt, QPoint, pyqtSignal, QTimer, QThread
from PyQt5.QtGui import QColor, QIcon, QPainter, QPalette, QPolygon
from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread
from PyQt5.QtGui import QIcon
from dotenv import load_dotenv, set_key, dotenv_values
from gui.rpgmaker_tab import RPGMakerTab
@ -120,86 +120,6 @@ 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)."""
@ -497,173 +417,97 @@ class ConfigTab(QWidget):
page.refresh_from_module()
def create_general_settings_tab(self):
"""Create a compact general-settings dashboard without nested scrolling."""
"""Create combined general settings tab with API, Translation, Performance, and UI settings."""
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: 4px; padding: 4px 8px;"
"border: 1px solid #555555; border-radius: 3px; padding: 4px 8px;"
"}"
"QToolButton:hover { background-color: #505050; }"
"QToolButton::menu-indicator { image: none; }"
)
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)
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)
if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
w.setMinimumHeight(32)
return w
def row_box(field: QWidget, *actions: QWidget) -> QWidget:
"""API row with equal field width and no visible filler widget."""
def row_box(*widgets: QWidget, stretch_index: int = 0) -> QWidget:
"""Fixed-width control cluster; stretch one child, never overflow buttons."""
box = QWidget()
box.setStyleSheet("background: transparent;")
row = QHBoxLayout(box)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(8)
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))
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)
box.setMinimumHeight(32)
box.setMinimumWidth(120)
box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
return box
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 make_section() -> QVBoxLayout:
section = QVBoxLayout()
section.setContentsMargins(0, 0, 0, 12)
section.setSpacing(row_gap)
return section
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)
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)
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
columns_layout = QHBoxLayout()
columns_layout.setSpacing(40)
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 ──────────────────────────────────────────────
left_column = QVBoxLayout()
left_column.setSpacing(8)
# ── API ──────────────────────────────────────────────────────
api_card, api_section = create_card("🔑 API Configuration")
left_column.addWidget(create_section_header("🔑 API Configuration"))
api_section = make_section()
self.api_url_edit = QLineEdit()
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
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_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)
api_url_menu = QMenu(self.api_url_preset_btn)
api_url_menu = QMenu(api_url_preset_btn)
for name, url in (
("OpenAI", "https://api.openai.com/v1"),
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
@ -674,16 +518,16 @@ class ConfigTab(QWidget):
):
action = api_url_menu.addAction(name)
action.triggered.connect(lambda checked, u=url: self.api_url_edit.setText(u))
self.api_url_preset_btn.setMenu(api_url_menu)
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, self.api_url_preset_btn),
row_box(self.api_url_edit, api_url_preset_btn),
)
self.api_key_combo = ConfigComboBox()
self.api_key_combo = QComboBox()
self.api_key_combo.setEditable(False)
self.api_key_combo.setToolTip(
"Select a saved API key by name. Optional per-key endpoints are "
@ -693,14 +537,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(96)
self.api_key_new_btn.setFixedWidth(52)
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(96)
self.api_key_delete_btn.setFixedWidth(64)
self.api_key_delete_btn.setStyleSheet(btn_style)
self.api_key_delete_btn.clicked.connect(self._on_api_key_delete)
@ -710,14 +554,14 @@ class ConfigTab(QWidget):
row_box(self.api_key_combo, self.api_key_new_btn, self.api_key_delete_btn),
)
self.model_combo = ConfigComboBox()
self.model_combo = QComboBox()
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(96)
self.model_refresh_btn.setFixedWidth(32)
self.model_refresh_btn.setStyleSheet(btn_style)
self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False))
@ -727,10 +571,13 @@ class ConfigTab(QWidget):
row_box(self.model_combo, self.model_refresh_btn),
)
# ── TRANSLATION + FORMATTING ─────────────────────────────────
translation_card, trans_section = create_card("🌐 Translation & Text")
left_column.addLayout(api_section)
left_column.addWidget(create_horizontal_line())
self.language_combo = ConfigComboBox()
left_column.addWidget(create_section_header("🌐 Translation Settings"))
trans_section = make_section()
self.language_combo = QComboBox()
self.language_combo.addItems([
"English", "Spanish", "French", "German", "Italian",
"Portuguese", "Russian", "Chinese", "Korean", "Japanese",
@ -744,40 +591,11 @@ class ConfigTab(QWidget):
self.timeout_spin.setSuffix(" sec")
add_row(trans_section, "Timeout:", size_field(self.timeout_spin))
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.addLayout(trans_section)
left_column.addWidget(create_horizontal_line())
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")
left_column.addWidget(create_section_header("⚡ Performance Settings"))
perf_section = make_section()
self.file_threads_spin = QSpinBox()
self.file_threads_spin.setButtonSymbols(QSpinBox.NoButtons)
@ -808,6 +626,69 @@ 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)
@ -815,7 +696,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(perf_section, "Input Cost:", size_field(self.input_cost_spin))
add_row(price_section, "Input Cost:", size_field(self.input_cost_spin))
self.output_cost_spin = QDoubleSpinBox()
self.output_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
@ -824,10 +705,13 @@ 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(perf_section, "Output Cost:", size_field(self.output_cost_spin))
add_row(price_section, "Output Cost:", size_field(self.output_cost_spin))
# ── INTERFACE ────────────────────────────────────────────────
interface_card, ui_section = create_card("🖥️ Interface")
right_column.addLayout(price_section)
right_column.addWidget(create_horizontal_line())
right_column.addWidget(create_section_header("🖥️ UI Settings"))
ui_section = make_section()
self.font_scale_spin = QDoubleSpinBox()
self.font_scale_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
@ -843,10 +727,13 @@ class ConfigTab(QWidget):
)
add_row(ui_section, "Font Scale:", size_field(self.font_scale_spin))
# ── GAME UPDATE ──────────────────────────────────────────────
updates_card, gu_section = create_card("📦 Game Update Defaults")
right_column.addLayout(ui_section)
right_column.addWidget(create_horizontal_line())
self.gu_forge_combo = ConfigComboBox()
right_column.addWidget(create_section_header("📦 Game Update Defaults"))
gu_section = make_section()
self.gu_forge_combo = QComboBox()
self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab")
self.gu_forge_combo.addItem("GitHub", "github")
self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo")
@ -875,42 +762,24 @@ 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))
content = QWidget()
content.setObjectName("generalSettingsContent")
content_layout = QVBoxLayout(content)
content_layout.setContentsMargins(12, 10, 12, 10)
content_layout.setSpacing(0)
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)
# 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;"
"}"
gu_hint = QLabel(
"Copied into each game's gameupdate/patch-config.txt when you run "
"Copy gameupdate/ (repo= is still per-game)."
)
button_layout = QHBoxLayout(action_bar)
button_layout.setContentsMargins(20, 9, 20, 9)
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()
columns_layout.addLayout(left_column, 1)
columns_layout.addLayout(right_column, 1)
layout.addLayout(columns_layout)
layout.addSpacing(15)
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
from gui import qt_icons
@ -919,11 +788,6 @@ 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")
@ -933,31 +797,20 @@ class ConfigTab(QWidget):
self.autosave_label = QLabel("")
self.autosave_label.setStyleSheet("color: #4ec9b0; font-weight: bold;")
button_layout.addWidget(self.autosave_label)
button_layout.addStretch()
button_layout.addWidget(reset_button)
button_layout.addWidget(save_button)
page_layout.addWidget(action_bar)
button_layout.addSpacing(10)
button_layout.addWidget(self.autosave_label)
button_layout.addStretch()
QTimer.singleShot(0, self._update_general_label_width)
layout.addLayout(button_layout)
content.setLayout(layout)
widget.setLayout(QVBoxLayout())
widget.layout().setContentsMargins(0, 0, 0, 0)
widget.layout().addWidget(content)
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
# ------------------------------------------------------------------
@ -1371,10 +1224,6 @@ 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)
@ -1393,12 +1242,15 @@ 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."""

View file

@ -1,425 +0,0 @@
"""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()