# -----------------------------------------------------------------------------
# Copyright (C) 2026 Iliyas Jorio.
# This file is part of GitFourchette, distributed under the GNU GPL v3.
# For full terms, see the included LICENSE file.
# -----------------------------------------------------------------------------

import logging
from pathlib import Path
from typing import Any

from gitfourchette.exttools.toolcommands import ToolCommands
from gitfourchette.exttools.toolpresets import ToolPresets
from gitfourchette.exttools.usercommandsyntaxhighlighter import UserCommandSyntaxHighlighter
from gitfourchette.localization import *
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.settings import SHORT_DATE_PRESETS, prefs
from gitfourchette.syntax import ColorScheme, PygmentsPresets, syntaxHighlightingAvailable
from gitfourchette.toolbox import *
from gitfourchette.trtables import TrTables

logger = logging.getLogger(__name__)

SAMPLE_SIGNATURE = Signature("Jean-Michel Tartempion", "jm.tarte@example.com", 0, 0)
SAMPLE_FILE_PATH = "spam/.ham/eggs/hello.c"

# This dict initially contains hardcoded language names, e.g. to disambiguate
# both Portugueses, to give U.S. English a shorter name, etc. For all other
# languages, the dict is fleshed out with QLocale.nativeLanguageName().
LANGUAGE_NAMES = {
    "en": "English",
    "es": "Español",
    "pt": "Português PT",
    "pt_BR": "Português BR",
}


def _boxWidget(layoutType, *controls):
    w = QWidget()
    layout: QBoxLayout = layoutType(w)
    layout.setSpacing(0)
    layout.setContentsMargins(0, 0, 0, 0)
    for control in controls:
        layout.addWidget(control)
    return w


def vBoxWidget(*controls):
    return _boxWidget(QVBoxLayout, *controls)


def hBoxWidget(*controls):
    return _boxWidget(QHBoxLayout, *controls)


def makeshiftSpacer(height=1):
    spacer = QWidget()
    spacer.setEnabled(False)
    spacer.setFixedSize(1, height)
    return spacer


def localeCodeToLanguageName(code: str):
    try:
        name = LANGUAGE_NAMES[code]
    except KeyError:
        # Cache native language name
        name = QLocale(code).nativeLanguageName()
        LANGUAGE_NAMES[code] = name

    return name


class PrefsDialog(QDialog):
    lastCategory = 0

    prefDiff: dict[str, Any]
    "Delta to on-disk preferences."

    CategoryPrefix = "_category_"
    SpacerPrefix = "_spacer"
    LabelPrefix = "_label_"
    ControlQObjectNamePrefix = "prefctl_"
    LocCategoryHeaderSuffix = "_HEADER"
    LocSettingHelpSuffix = "_help"

    @benchmark
    def __init__(self, parent: QWidget, focusOn: str = ""):
        super().__init__(parent)

        self.setObjectName("PrefsDialog")
        self.setWindowTitle(_("{app} Settings", app=qAppName()))

        self.prefDiff = {}
        self.categoryKeys = []

        self.categoryList = QListWidget()
        self.categoryList.setWordWrap(True)
        self.categoryList.setUniformItemSizes(True)
        self.categoryList.setMinimumWidth(200)
        self.categoryList.setMaximumWidth(200)
        self.categoryList.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.categoryList.setTextElideMode(Qt.TextElideMode.ElideRight)
        self.categoryList.currentRowChanged.connect(self.onCategoryChanged)
        self.categoryList.setIconSize(QSize(24, 24))

        self.categoryLabel = QLabel("CATEGORY")
        tweakWidgetFont(self.categoryLabel, 130)

        self.stackedWidget = QStackedWidget()

        buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Help)
        buttonBox.accepted.connect(self.accept)
        buttonBox.rejected.connect(self.reject)
        self.guideButton = buttonBox.button(QDialogButtonBox.StandardButton.Help)
        self.guideButton.setCheckable(True)
        self.guideButton.clicked.connect(self.toggleGuideBrowser)

        self.guideBrowser = QTextBrowser(self)
        self.guideBrowser.setMinimumWidth(400)
        self.guideBrowser.setOpenExternalLinks(True)
        self.guideBrowser.setVisible(False)
        tweakWidgetFont(self.guideBrowser, 90)

        layout = QGridLayout(self)
        layout.addWidget(self.categoryList,     0, 0, 4, 1)
        layout.addWidget(self.categoryLabel,    0, 1)
        layout.addWidget(QFaintSeparator(),     1, 1)
        layout.addWidget(self.stackedWidget,    2, 1)
        layout.addWidget(self.guideBrowser,     0, 2, 4, 1)
        self._fillControls(focusOn)
        layout.addWidget(buttonBox, 3, 1)  # Add buttonBox last so it comes last in tab order

        layout.setColumnStretch(0, 0)
        layout.setColumnStretch(1, 2)

        if not focusOn:
            # Restore last category
            self.setCategory(PrefsDialog.lastCategory)
            buttonBox.button(QDialogButtonBox.StandardButton.Ok).setFocus()
        else:
            # Save this category if we close the dialog without changing tabs
            PrefsDialog.lastCategory = self.stackedWidget.currentIndex()

        self.setModal(True)

    def _fillControls(self, focusOn):
        skipKeys = self.getHiddenSettingKeys()
        form: QFormLayout | None = None

        for key in prefs.__dict__:
            # New category tab
            if key.startswith(self.CategoryPrefix):
                category = key.removeprefix(self.CategoryPrefix)
                # Stop past the hidden category
                if category == "hidden":
                    break
                form = self._newCategoryForm(category)
                continue

            # Spacer
            if key.startswith(self.SpacerPrefix):
                form.addRow(makeshiftSpacer())
                continue

            # Label
            if key.startswith(self.LabelPrefix):
                labelKey = key.removeprefix(self.LabelPrefix)
                labelText = TrTables.prefKey(labelKey)
                label = QLabel(labelText)
                label.setEnabled(False)
                tweakWidgetFont(label, bold=True)
                if form.count():  # add a spacer before the label
                    form.addRow(makeshiftSpacer())
                form.addRow(label)
                continue

            # Skip hidden settings
            if key in skipKeys or key.startswith("_"):
                continue

            # Add the control to the form layout, with a leading caption if any
            control, formRowItems = self._newRow(key)
            form.addRow(*formRowItems)

            # If the current key matches the setting we want to focus on,
            # bring this tab to the foreground
            if focusOn == key:
                categoryIndex = self.stackedWidget.indexOf(form.parentWidget())
                self.setCategory(categoryIndex)
                control.setFocus()

    def _newCategoryForm(self, category: str) -> QFormLayout:
        formContainer = QWidget(self)

        form = QFormLayout(formContainer)
        form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)

        categoryName = TrTables.prefKey(category)
        categoryIcon = stockIcon(f"prefs-{category.lower()}")

        self.categoryKeys.append(category)
        self.stackedWidget.addWidget(formContainer)
        self.categoryList.addItem(QListWidgetItem(categoryIcon, categoryName))

        headerText = TrTables.prefKeyNoDefault(category + self.LocCategoryHeaderSuffix)
        if headerText:
            headerText = headerText.format(app=qAppName())
            explainer = QLabel(headerText)
            explainer.setWordWrap(True)
            explainer.setTextFormat(Qt.TextFormat.RichText)
            tweakWidgetFont(explainer, 88)
            form.addRow(explainer)

        return form

    def _newRow(self, key: str) -> tuple[QWidget, list]:
        # Get caption and suffix
        suffix = ""
        caption = TrTables.prefKey(key)
        if "#" in caption:
            caption, suffix = caption.split("#")
            caption = caption.rstrip()
            suffix = suffix.lstrip()

        # Get the value of this setting
        prefValue = prefs.__dict__[key]

        # Make the actual control widget
        control = self.makeControlWidget(key, prefValue, caption)
        rowWidgets = [control]

        # Name the control so that unit tests can find it
        control.setObjectName(self.ControlQObjectNamePrefix + key)

        # Tack an extra QLabel to the end if there's a suffix
        if suffix:
            rowWidgets.append(QLabel(suffix))

        if key == "autoFetchMinutes":
            self.prependCheckBox(rowWidgets, "autoFetch", caption)

        # Any help text? Then make a help button for it & set tooltip text on the main control
        tip = TrTables.prefKeyNoDefault(key + self.LocSettingHelpSuffix)
        if tip:
            tip = tip.format(app=qAppName())
            control.setToolTip(tip)
            hintButton = QHintButton(self, tip)
            hintButton.setMaximumHeight(2 + hintButton.fontMetrics().height())
            rowWidgets.append(hintButton)

        # Gather what to add to the form as a single item.
        # If we have more than a single widget to add to the form, lay them out in a row.
        if len(rowWidgets) == 1:
            formField = control
        else:
            rowLayout = QHBoxLayout()
            for w in rowWidgets:
                rowLayout.addWidget(w)
            if control.sizePolicy().horizontalPolicy() == QSizePolicy.Policy.Minimum:
                # Stick help button to right edge of non-expanding widget
                rowLayout.addStretch()
            formField = rowLayout

        # No caption, make field span entire row
        if not caption or isinstance(rowWidgets[0], QCheckBox):
            return control, [formField]

        # There's a leading caption, so add it as the label in the row
        caption += _(":")
        label = QLabel(caption)
        label.setBuddy(rowWidgets[0])
        if tip:
            label.setToolTip(tip)
        return control, [label, formField]

    def setCategory(self, row: int):
        self.categoryList.setCurrentRow(row)

    def onCategoryChanged(self, row: int):
        categoryKey = self.categoryKeys[row]
        categoryName = TrTables.prefKey(categoryKey)
        categoryGuide = TrTables.prefKeyNoDefault(f"{categoryKey}_GUIDE")

        self.stackedWidget.setCurrentIndex(row)
        self.categoryLabel.setText(categoryName)

        self.toggleGuideBrowser(False)
        if categoryGuide:
            self.guideButton.setText(_("{0} Handy Reference").format(categoryName))
            self.guideButton.setVisible(True)
            self.guideBrowser.setHtml(categoryGuide)
        else:
            self.guideButton.setVisible(False)

        # Remember which tab we've last clicked on for next time we open the dialog
        PrefsDialog.lastCategory = row

    def toggleGuideBrowser(self, show: bool):
        if show == self.guideBrowser.isVisible():
            pass
        elif show:
            self._widthBeforeGuide = self.width()
            self.guideBrowser.show()
        else:
            self.guideBrowser.hide()
            QTimer.singleShot(0, lambda: self.resize(self._widthBeforeGuide, self.height()))
        self.guideButton.setChecked(show)

    def assign(self, k, v):
        if prefs.__dict__[k] == v:
            if k in self.prefDiff:
                del self.prefDiff[k]
        else:
            self.prefDiff[k] = v
        logger.debug(f"Assign {k} {v} ({type(v)})")

    def getMostRecentValue(self, k):
        if k in self.prefDiff:
            return self.prefDiff[k]
        elif k in prefs.__dict__:
            return prefs.__dict__[k]
        else:
            return None

    def getHiddenSettingKeys(self) -> set[str]:
        skipKeys = {
            "fontSize",  # bundled with "font"
        }

        # Prevent hiding menubar on macOS
        if MACOS:
            skipKeys.add("showMenuBar")

        # In frozen distributions, hide settings that depend on system Python
        # packages outside our sandbox.
        if APP_FREEZE_QT:
            skipKeys.add("forceQtApi")
            skipKeys.add("pygmentsPlugins")

        return skipKeys

    def makeControlWidget(self, key: str, value, caption: str) -> QWidget:
        valueType = type(value)

        if key == "language":
            return self.languageControl(key, value)
        elif key == "qtStyle":
            return self.qtStyleControl(key, value)
        elif key == "font":
            return self.fontControl(key)
        elif key == "shortTimeFormat":
            return self.dateFormatControl(key, value, SHORT_DATE_PRESETS)
        elif key == "pathDisplayStyle":
            return self.enumControl(key, value, valueType, previewCallback=lambda v: abbreviatePath(SAMPLE_FILE_PATH, v))
        elif key == "authorDisplayStyle":
            return self.enumControl(key, value, valueType, previewCallback=lambda v: abbreviatePerson(SAMPLE_SIGNATURE, v))
        elif key == "shortHashChars":
            return self.boundedIntControl(key, value, 4, 40)
        elif key == "maxRecentRepos":
            return self.boundedIntControl(key, value, 0, 50)
        elif key == "contextLines":  # staging/discarding individual lines is flaky with 0 context lines
            return self.boundedIntControl(key, value, 1, 32)
        elif key == "tabSpaces":
            return self.boundedIntControl(key, value, 1, 16)
        elif key == "autoFetchMinutes":
            return self.boundedIntControl(key, value, 1, 9999)
        elif key == "syntaxHighlighting":
            return self.syntaxHighlightingControl(key, value)
        elif key == "colorblind":
            return self.colorblindControl(key, value)
        elif key == "maxCommits":
            control = self.boundedIntControl(key, value, 0, 999_999_999, 1000)
            control.setSpecialValueText("\u221E")  # infinity
            return control
        elif key == "externalEditor":
            return self.strControlWithPresets(key, value, ToolPresets.Editors, leaveBlankHint=True)
        elif key == "externalDiff":
            return self.strControlWithPresets(
                key, value, ToolPresets.DiffTools,
                validate=lambda cmd: ToolCommands.checkCommand(cmd, "$L", "$R"))
        elif key == "externalMerge":
            return self.strControlWithPresets(
                key, value, ToolPresets.MergeTools,
                validate=lambda cmd: ToolCommands.checkCommand(cmd, "$L", "$R", "$B", "$M"))
        elif key == "terminal":
            return self.strControlWithPresets(
                key, value, ToolPresets.Terminals,
                validate=lambda cmd: ToolCommands.checkCommand(cmd, "$COMMAND"))
        elif key == "commands":
            return self.userCommandTextEditControl(key, value)
        elif key in ["largeFileThresholdKB", "imageFileThresholdKB", "maxTrashFileKB"]:
            control = self.boundedIntControl(key, value, 0, 999_999)
            control.setSpecialValueText("\u221E")  # infinity
            return control
        elif key == "gitPath":
            presets = {}
            builtInGit = ToolPresets.flatpakBuiltInGit()
            if builtInGit:
                presets[_("Built-in git (sandboxed)")] = builtInGit
            presets[_("Auto-detected system git")] = ToolPresets.defaultGit(hostOnly=True)
            return self.strControlWithPresets(key, value, presets)
        elif issubclass(valueType, enum.Enum):
            return self.enumControl(key, value, type(value))
        elif valueType is int:
            return self.intControl(key, value)
        elif valueType is bool:
            trueText = TrTables.prefKeyNoDefault(key + "_true")
            falseText = TrTables.prefKeyNoDefault(key + "_false")
            if trueText or falseText:
                return self.boolComboBoxControl(key, value, trueName=trueText, falseName=falseText)
            else:
                return self.boolCheckBoxControl(key, value, caption)
        else:
            raise NotImplementedError(f"Write pref widget for {key}")

    @benchmark
    def languageControl(self, prefKey: str, prefValue: str):
        defaultCaption = _p("system default language setting", "System default")
        control = QComboBox(self)
        control.addItem(defaultCaption, userData="")
        control.insertSeparator(1)

        localeRatios = {}
        for wipLine in Path(QFile("assets:lang/wip.txt").fileName()).read_text().strip().splitlines():
            code, ratio = wipLine.split(" ")
            localeRatios[code] = ratio
        localeRatios["en"] = "100"

        langDir = QDir("assets:lang", "*.mo")
        localeCodes = [f.removesuffix(".mo") for f in langDir.entryList()]
        assert "en" not in localeCodes, "English shouldn't have an .mo file"
        localeCodes.append("en")

        localeNames = {code: localeCodeToLanguageName(code) for code in localeCodes}
        localeCodes.sort(key=lambda code: "0" if code == "en" else localeNames[code].casefold())

        for code in localeCodes:
            name = localeNames[code]
            name = name[0].upper() + name[1:]  # Many languages don't capitalize their name
            name = f"{name} ({localeRatios.get(code, '--')}%)"
            control.addItem(name, code)

        control.setCurrentIndex(control.findData(prefValue))
        control.activated.connect(lambda index: self.assign(prefKey, control.currentData(Qt.ItemDataRole.UserRole)))

        control.setStyleSheet("QListView::item { max-height: 18px; }")  # Breeze-themed combobox gets unwieldy otherwise
        control.setMaxVisibleItems(20)

        return control

    def fontControl(self, prefKey: str):
        fontControl = FontPicker(self)

        familyKey = prefKey
        sizeKey = "fontSize"

        def assignFont(family: str, size: int):
            self.assign(familyKey, family)
            self.assign(sizeKey, size)

        fontControl.setCurrentFont(self.getMostRecentValue(familyKey), self.getMostRecentValue(sizeKey))
        fontControl.assign.connect(assignFont)
        return fontControl

    def strControlWithPresets(self, prefKey, prefValue, presets, leaveBlankHint=False, validate=None):
        control = QComboBoxWithPreview(self)
        control.setEditable(True)

        for k in presets:
            preview = presets[k]
            if not preview and leaveBlankHint:
                preview = "- " + _p("hint user to leave the field blank", "leave blank") + " -"
            control.addItemWithPreview(k, presets[k], preview)
            if prefValue == presets[k]:
                control.setCurrentIndex(control.count()-1)

        if leaveBlankHint:
            control.lineEdit().setPlaceholderText(_("Leave blank for system default."))

        control.setEditText(prefValue)
        control.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred))

        control.editTextChanged.connect(lambda text: self.assign(prefKey, text))

        if validate:
            validator = ValidatorMultiplexer(self)
            validator.connectInput(control.lineEdit(), validate, mustBeValid=False)
            validator.run()

        return control

    def userCommandTextEditControl(self, prefKey, prefValue):
        monoFont = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
        fontMetrics = QFontMetricsF(monoFont)

        control = QPlainTextEdit(self)
        control.setFont(monoFont)
        control.setMinimumWidth(round(fontMetrics.horizontalAdvance("x" * 72)))
        control.setTabStopDistance(fontMetrics.horizontalAdvance(" " * 4))

        if syntaxHighlightingAvailable:
            highlighter = UserCommandSyntaxHighlighter(control)
            highlighter.setDocument(control.document())

        control.setPlaceholderText(_(
            "# Enter custom terminal commands here.\n"
            "# You can then launch them from the {menu} menu.\n"
            "# Click {button} below for more information.",
            menu=tquo(stripAccelerators(_("&Commands"))),
            button=tquo(_("Handy Reference"))))

        control.setPlainText(prefValue)
        control.textChanged.connect(lambda: self.assign(prefKey, control.toPlainText()))
        return control

    def intControl(self, prefKey, prefValue):
        control = QLineEdit(str(prefValue), self)
        control.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
        control.setValidator(QIntValidator())
        control.textEdited.connect(lambda v, k=prefKey: self.assign(k, int(v) if v else 0))
        return control

    def boundedIntControl(self, prefKey, prefValue, minValue, maxValue, step=1):
        control = QSpinBox(self)
        control.setMinimum(minValue)
        control.setMaximum(maxValue)
        control.setValue(prefValue)
        control.setSingleStep(step)
        control.setGroupSeparatorShown(True)
        control.setAlignment(Qt.AlignmentFlag.AlignRight)
        control.setStepType(QSpinBox.StepType.AdaptiveDecimalStepType)
        control.valueChanged.connect(lambda v, k=prefKey: self.assign(k, v))

        # Qt 6.8.2 inexplicably makes QSpinBoxes super tall with Breeze/Oxygen styles
        control.setMaximumHeight(32)

        return control

    def boolComboBoxControl(self, prefKey: str, prefValue: bool, falseName: str, trueName: str) -> QComboBox:
        control = QComboBox(self)
        control.addItem(trueName)  # index 0 --> True
        control.addItem(falseName)  # index 1 --> False
        control.setCurrentIndex(int(not prefValue))
        control.activated.connect(lambda index: self.assign(prefKey, index == 0))
        return control

    def boolCheckBoxControl(self, prefKey: str, prefValue: bool, caption: str) -> QCheckBox:
        control = QCheckBox(caption, self)
        control.setChecked(prefValue)
        control.checkStateChanged.connect(lambda state, k=prefKey: self.assign(k, state == Qt.CheckState.Checked))
        return control

    def enumControl(self, prefKey, prefValue, enumType, previewCallback=None):
        if previewCallback:
            control = QComboBoxWithPreview(self)
        else:
            control = QComboBox(self)

        for enumMember in enumType:
            # PySide6 demotes StrEnum to str when stored with QComboBox.setItemData().
            # Wrap the value in a tuple to preserve the type. (PyQt5 & PyQt6 do the right thing here)
            data = (enumMember,)
            name = TrTables.enum(enumMember)

            if name == "":
                continue

            if previewCallback:
                control.addItemWithPreview(name, data, previewCallback(enumMember))
            else:
                control.addItem(name, data)
            if prefValue == enumMember:
                control.setCurrentIndex(control.count() - 1)

            if data == ("",):
                control.insertSeparator(control.count())

        control.activated.connect(lambda i: self.assign(prefKey, control.itemData(i)[0]))  # unpack the tuple!

        return control

    def qtStyleControl(self, prefKey, prefValue):
        defaultCaption = _p("system default theme setting", "System default")
        control = QComboBox(self)
        control.addItem(defaultCaption, userData="")
        if not prefValue:
            control.setCurrentIndex(0)
        control.insertSeparator(1)
        for availableStyle in QStyleFactory.keys():
            control.addItem(availableStyle, userData=availableStyle)
            if prefValue == availableStyle:
                control.setCurrentIndex(control.count() - 1)

        def onPickStyle(index):
            styleName = control.itemData(index, Qt.ItemDataRole.UserRole)
            self.assign(prefKey, styleName)

        control.activated.connect(onPickStyle)
        return control

    def dateFormatControl(self, prefKey, prefValue, presets):
        currentDate = QDateTime.currentDateTime()
        sampleDate = QDateTime(QDate(currentDate.date().year(), 1, 30), QTime(9, 45))
        bogusTime = "Wednesday, December 99, 9999 99:99:99 AM"

        def genPreview(f):
            return QLocale().toString(sampleDate, f)

        def onEditTextChanged(text):
            preview.setText(genPreview(text))
            self.assign(prefKey, text)

        preview = QLabel(bogusTime)
        preview.setEnabled(False)
        preview.setMaximumWidth(preview.fontMetrics().horizontalAdvance(bogusTime))
        preview.setText(genPreview(prefValue))

        control = QComboBoxWithPreview(self)
        control.setEditable(True)
        for presetName, presetFormat in presets.items():
            control.addItemWithPreview(presetName, presetFormat, genPreview(presetFormat))
            if prefValue == presetFormat:
                control.setCurrentIndex(control.count()-1)
        control.setMinimumWidth(200)
        control.setEditText(prefValue)
        control.editTextChanged.connect(onEditTextChanged)

        return vBoxWidget(control, preview)

    @benchmark
    def syntaxHighlightingControl(self, prefKey, prefValue):
        if not syntaxHighlightingAvailable:  # pragma: no cover
            sorry = QLabel(_("This feature requires {0}.", "Pygments"))
            sorry.setEnabled(False)
            return sorry

        autoCaption = _p("syntax highlighting", "Automatic ({name})", name=PygmentsPresets.Dark if isDarkTheme() else PygmentsPresets.Light)
        offCaption = _p("syntax highlighting", "Off")

        control = QComboBox(self)
        control.setStyleSheet("QListView::item { max-height: 18px; }")  # Breeze-themed combobox gets unwieldy otherwise
        control.setIconSize(QSize(16, 16))  # Required if enforceComboBoxMaxVisibleItems kicks in
        control.addItem(stockIcon("light-dark-toggle"), autoCaption, userData=PygmentsPresets.Automatic)
        control.addItem(stockIcon("SP_BrowserStop"), offCaption, userData=PygmentsPresets.Off)
        control.insertSeparator(control.count())

        previousStyleName = ""
        for styleName, chipColors in ColorScheme.stylePreviews(prefs.pygmentsPlugins).items():
            # Insert a separator between light and dark themes, i.e. when sorting resets
            if styleName < previousStyleName:
                control.insertSeparator(control.count())
            previousStyleName = styleName
            # Little icon to preview the colors in this style
            chip = stockIcon("colorscheme-chip", chipColors)
            control.addItem(chip, styleName, userData=styleName)

        index = control.findData(prefValue)
        control.setCurrentIndex(index)

        def onPickStyle(index):
            pickedStyleName = control.itemData(index, Qt.ItemDataRole.UserRole)
            self.assign(prefKey, pickedStyleName)

        control.activated.connect(onPickStyle)

        control.setMaxVisibleItems(30)
        enforceComboBoxMaxVisibleItems(control)  # Prevent Fusion from creating a giant popup

        return control

    def colorblindControl(self, prefKey, prefValue):
        control = QComboBox(self)
        control.addItem(stockIcon("linebg-chip-redgreen"), _("Red and green"), userData=False)
        control.addItem(stockIcon("linebg-chip-colorblind"), _("Colorblind-friendly"), userData=True)

        index = control.findData(prefValue)
        control.setCurrentIndex(index)

        def onPickStyle(index):
            pickedStyleName = control.itemData(index, Qt.ItemDataRole.UserRole)
            self.assign(prefKey, pickedStyleName)

        control.activated.connect(onPickStyle)
        return control

    def prependCheckBox(self, rowWidgets: list[QWidget], booleanKey: str, caption: str) -> QCheckBox:
        """
        Add a QCheckBox on the same row as an existing widget,
        controlling whether another pref key is enabled or disabled.
        """

        def enableRowWidgets(enable: bool):
            for w in rowWidgets:
                if not isinstance(w, QCheckBox):
                    w.setEnabled(enable)

        booleanValue = prefs.__dict__[booleanKey]

        checkBox = self.boolCheckBoxControl(booleanKey, booleanValue, caption)
        checkBox.setObjectName(f"prefctl_{booleanKey}")  # Name the control so that unit tests can find it
        checkBox.checkStateChanged.connect(lambda state: enableRowWidgets(state == Qt.CheckState.Checked))

        rowWidgets.insert(0, checkBox)
        QWidget.setTabOrder(rowWidgets[0], rowWidgets[1])

        enableRowWidgets(booleanValue)  # Prime enabled/disabled state
        return checkBox
