Compare commits

...

3 commits

Author SHA1 Message Date
Lukas Brübach
ecc3ecd4ea [Fonts] Make the editor's fallback and Reset preview-independent
- Add ThemeManager::defaultAppFont(), which resolves an unset role against the
  startup font instead of currentFonts, and use it in FontGridWidget::loadFonts
  so the grid never shows the live preview as the theme's default.
- Reload the live theme in PaletteEditorDialog::onReset before re-reading the
  grid, so Reset restores the saved fonts before loadFonts resolves fallbacks.
2026-11-01 13:59:16 +01:00
Lukas Brübach
c0aeaf72e8 [Fonts] Add per-purpose font section to palette editor 2026-11-01 13:53:56 +01:00
Lukas Brübach
c63a359c3d [Fonts] Restore the startup font when a config has no InterfaceDefault
- Capture the untouched application font next to the default palette and use
  it in previewFonts' else branch, so a config without InterfaceDefault
  restores the startup font instead of leaving the last preview applied.
- Drop the unpolish/polish loop from previewFonts: QApplication::setFont
  already notifies every widget, and the loop ran on every editor auto-apply.
2026-11-01 13:53:21 +01:00
7 changed files with 329 additions and 20 deletions

View file

@ -172,6 +172,7 @@ set(cockatrice_SOURCES
src/interface/palette_editor/palette_generator.cpp
src/interface/palette_editor/quick_setup_panel.cpp
src/interface/palette_editor/palette_grid_widget.cpp
src/interface/palette_editor/font_grid_widget.cpp
src/interface/palette_editor/palette_editor_dialog.cpp
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
src/interface/widgets/cards/additional_info/deck_color_identity.cpp

View file

@ -0,0 +1,126 @@
#include "font_grid_widget.h"
#include "../theme_manager.h"
#include <QApplication>
#include <QFont>
#include <QFontComboBox>
#include <QGridLayout>
#include <QLabel>
#include <QMetaEnum>
#include <QSpinBox>
#include <QtGlobal>
static constexpr int MIN_FONT_SIZE = 6;
static constexpr int MAX_FONT_SIZE = 72;
static constexpr int FALLBACK_FONT_SIZE = 10;
static const QMap<AppFont::Role, const char *> APP_FONT_DESCRIPTIONS = {
{AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")},
{AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")},
{AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")},
{AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")},
};
static QString roleDisplayName(AppFont::Role role)
{
switch (role) {
case AppFont::InterfaceDefault:
return FontGridWidget::tr("Interface default");
case AppFont::ChatLog:
return FontGridWidget::tr("Chat log");
case AppFont::CardText:
return FontGridWidget::tr("Card text");
case AppFont::DeckList:
return FontGridWidget::tr("Deck list");
default:
return QString();
}
}
FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent)
{
auto *grid = new QGridLayout(this);
grid->setSpacing(3);
grid->setContentsMargins(12, 8, 12, 8);
grid->setColumnStretch(0, 0);
grid->setColumnStretch(1, 1);
grid->setColumnStretch(2, 0);
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
auto *label = new QLabel(this);
grid->addWidget(label, i, 0);
auto *familyCombo = new QFontComboBox(this);
grid->addWidget(familyCombo, i, 1);
connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); });
auto *sizeSpin = new QSpinBox(this);
sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE);
grid->addWidget(sizeSpin, i, 2);
connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); });
fontRows[role] = {label, familyCombo, sizeSpin};
}
retranslateUi();
}
void FontGridWidget::loadFonts(const FontConfig &cfg)
{
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows[role];
// Roles absent from the file resolve to the startup font, not to the
// live preview: appFont() would return the previewed config, so Reset
// and a first load would both show the edited font instead of the
// theme's real default.
const QFont font = cfg.fonts.value(role, themeManager->defaultAppFont(role));
row.familyCombo->setCurrentFont(font);
int size = font.pointSize();
if (size <= 0) {
size = qApp->font().pointSize();
}
if (size <= 0) {
size = FALLBACK_FONT_SIZE;
}
row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE));
}
}
FontConfig FontGridWidget::currentFontConfig() const
{
FontConfig cfg;
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows.value(role);
// Canonical family + point size only: a fresh QFont carries default
// (non-bold, non-italic) weight/style, so nothing combo-side leaks in.
QFont font(row.familyCombo->currentFont().family());
font.setPointSize(row.sizeSpin->value());
cfg.fonts[role] = font;
}
return cfg;
}
void FontGridWidget::retranslateUi()
{
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows.value(role);
row.label->setText(roleDisplayName(role));
row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
row.sizeSpin->setToolTip(tr("Point size"));
}
}

View file

@ -0,0 +1,43 @@
#ifndef COCKATRICE_FONT_GRID_WIDGET_H
#define COCKATRICE_FONT_GRID_WIDGET_H
#include "../theme_config.h"
#include <QMap>
#include <QWidget>
#include <qtmetamacros.h>
class QFontComboBox;
class QGridLayout;
class QLabel;
class QSpinBox;
// Per-role font editors — one (family, point size) row per AppFont::Role.
// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring
// PaletteGridWidget. Only family and point size are edited; weight and italic
// stay caller-side concerns and are stripped when reading back.
class FontGridWidget : public QWidget
{
Q_OBJECT
public:
explicit FontGridWidget(QWidget *parent = nullptr);
void loadFonts(const FontConfig &cfg);
FontConfig currentFontConfig() const;
void retranslateUi();
signals:
void fontsChanged();
private:
struct FontRow
{
QLabel *label = nullptr;
QFontComboBox *familyCombo = nullptr;
QSpinBox *sizeSpin = nullptr;
};
QMap<AppFont::Role, FontRow> fontRows;
};
#endif // COCKATRICE_FONT_GRID_WIDGET_H

View file

@ -2,6 +2,7 @@
#include "../theme_config.h"
#include "../theme_manager.h"
#include "font_grid_widget.h"
#include "palette_generator.h"
#include "palette_grid_widget.h"
#include "quick_setup_panel.h"
@ -40,6 +41,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
// Load both scheme configs upfront so switching is instant
loadSchemes();
loadFonts();
loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light";
@ -126,6 +128,32 @@ void PaletteEditorDialog::setupUi()
paletteGrid->setVisible(false);
root->addWidget(paletteGrid, 1);
// Font section — sibling to the palette grid, sharing its collapsible
// advanced area pattern. Fonts are shared per theme, not split by scheme.
fontToggleButton = new QPushButton(this);
fontToggleButton->setCheckable(true);
fontToggleButton->setChecked(false);
fontToggleButton->setFlat(true);
fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }"
"QPushButton:checked { }");
root->addWidget(fontToggleButton);
fontSeparator = makeSeparator();
fontSeparator->setVisible(false);
root->addWidget(fontSeparator);
fontSection = new QWidget;
auto *fontSectionLayout = new QVBoxLayout(fontSection);
fontSectionLayout->setContentsMargins(0, 0, 0, 0);
fontSectionLayout->setSpacing(0);
fontNoteLabel = new QLabel(fontSection);
fontNoteLabel->setContentsMargins(12, 4, 12, 0);
fontSectionLayout->addWidget(fontNoteLabel);
fontGrid = new FontGridWidget(fontSection);
fontSectionLayout->addWidget(fontGrid);
fontSection->setVisible(false);
root->addWidget(fontSection);
// Footer
root->addWidget(makeSeparator());
footer = new QWidget;
@ -158,6 +186,7 @@ void PaletteEditorDialog::setupUi()
connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply);
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent);
connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); });
connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { autoApplyTimer->start(); });
connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault);
connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset);
connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave);
@ -167,15 +196,32 @@ void PaletteEditorDialog::setupUi()
paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette"));
paletteGridSeparator->setVisible(open);
paletteGrid->setVisible(open);
if (open) {
setMinimumHeight(680);
resize(width(), 680);
} else {
setMinimumHeight(220);
adjustSize(); // shrinks to fit just the visible content
}
updateAdvancedHeight();
});
connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) {
fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
fontSeparator->setVisible(open);
fontSection->setVisible(open);
updateAdvancedHeight();
});
}
void PaletteEditorDialog::updateAdvancedHeight()
{
int height = 220;
if (paletteGridToggleButton->isChecked()) {
height += 460;
}
if (fontToggleButton->isChecked()) {
height += 200;
}
setMinimumHeight(height);
if (paletteGrid->isVisible() || fontSection->isVisible()) {
resize(width(), height);
} else {
adjustSize(); // shrinks to fit just the visible content
}
}
void PaletteEditorDialog::retranslateUi()
@ -190,12 +236,16 @@ void PaletteEditorDialog::retranslateUi()
editingLabel->setText(tr("Editing:"));
paletteGridToggleButton->setText(tr("▶ Edit Palette"));
paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks"));
fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme"));
fontNoteLabel->setText(tr("Fonts belong to theme %1").arg(themeName));
fontGrid->retranslateUi();
revertButton->setText(tr("↺ Revert to theme default"));
resetBtn->setText(tr("Reset"));
saveBtn->setText(tr("Save"));
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts"));
saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower()));
if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false);
@ -229,6 +279,19 @@ void PaletteEditorDialog::loadSchemes()
}
}
void PaletteEditorDialog::loadFonts()
{
// Same user-dir > system-dir fallback ThemeManager uses: the shipped
// fonts.toml may live in the system theme directory rather than the
// resolved (user) theme directory.
FontConfig cfg = ThemeManager::loadFontConfig(themeDirPath);
if (!cfg.hasFonts()) {
cfg = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
}
savedFontConfig = cfg;
fontGrid->loadFonts(cfg);
}
void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme)
{
QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight);
@ -259,6 +322,7 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi
void PaletteEditorDialog::onApply()
{
themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme);
themeManager->previewFonts(fontGrid->currentFontConfig());
}
void PaletteEditorDialog::onSave()
@ -309,6 +373,17 @@ void PaletteEditorDialog::onSave()
savedConfig[it.key()] = it.value();
}
// Persist the shared per-theme fonts when they changed.
const FontConfig fontCfg = fontGrid->currentFontConfig();
if (fontCfg != savedFontConfig) {
if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir));
return;
}
savedFontConfig = fontCfg;
}
themeManager->reloadCurrentTheme();
accept();
}
@ -317,6 +392,11 @@ void PaletteEditorDialog::onReset()
{
workingConfig[loadedScheme] = savedConfig[loadedScheme];
paletteGrid->loadPalette(savedConfig[loadedScheme]);
// Restore the live theme before re-reading the grid: loadFonts resolves
// unset roles, and the preview has currentFonts pointing at the edited
// config until the theme is reloaded.
themeManager->reloadCurrentTheme();
fontGrid->loadFonts(savedFontConfig);
seedAccentFromScheme(loadedScheme);
}
@ -351,6 +431,18 @@ void PaletteEditorDialog::onRevertToDefault()
workingConfig[loadedScheme] = def;
paletteGrid->loadPalette(def);
seedAccentFromScheme(loadedScheme);
// Revert the shared per-theme fonts the same way: delete the user-written
// fonts.toml so the theme falls back to its shipped default, then reflect
// the resolved fonts in the editor.
QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName()));
// Reload the live theme so the font revert takes effect immediately.
themeManager->reloadCurrentTheme();
const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
savedFontConfig = fontDef;
fontGrid->loadFonts(fontDef);
}
void PaletteEditorDialog::changeEvent(QEvent *e)

View file

@ -15,6 +15,7 @@ class QComboBox;
class QDialogButtonBox;
class QPushButton;
class PaletteGridWidget;
class FontGridWidget;
class QuickSetupPanel;
class QFrame;
class QWidget;
@ -38,8 +39,10 @@ private:
void setupUi();
void retranslateUi();
void refreshChromePalettes();
void updateAdvancedHeight();
void loadScheme(const QString &scheme); // snapshot current, switch, load
void seedAccentFromScheme(const QString &scheme);
void loadFonts(); // shared per theme, not split by light/dark
// Sub-widgets
QWidget *header;
@ -49,6 +52,11 @@ private:
PaletteGridWidget *paletteGrid = nullptr;
QPushButton *paletteGridToggleButton = nullptr;
QFrame *paletteGridSeparator = nullptr;
FontGridWidget *fontGrid = nullptr;
QPushButton *fontToggleButton = nullptr;
QFrame *fontSeparator = nullptr;
QWidget *fontSection = nullptr;
QLabel *fontNoteLabel = nullptr;
QWidget *footer;
QComboBox *schemeComboBox = nullptr;
QDialogButtonBox *buttonBox = nullptr;
@ -68,6 +76,10 @@ private:
QMap<QString, PaletteConfig> workingConfig;
QMap<QString, PaletteConfig> savedConfig;
// Fonts are shared per theme (fonts.toml, not split by light/dark), so a
// single saved config suffices; the live edits are read from fontGrid.
FontConfig savedFontConfig;
QTimer *autoApplyTimer = nullptr;
protected:

View file

@ -127,8 +127,10 @@ static QString usableDefaultStyle(const QString &style)
ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
{
defaultStyleName = usableDefaultStyle(qApp->style()->objectName());
// Capture the untouched application palette before any theme is applied.
// Capture the untouched application palette and font before any theme is
// applied.
defaultPalette = qApp->palette();
defaultFont = qApp->font();
ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
@ -448,6 +450,18 @@ FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath)
return FontConfig::fromThemeDir(themeDirPath);
}
FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName)
{
FontConfig cfg = FontConfig::fromThemeDir(themeDirPath);
if (!cfg.hasFonts()) {
// The shipped fonts.toml may live in the system theme directory rather
// than the resolved (user) theme directory, so built-in themes still
// get their curated fonts.
cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName));
}
return cfg;
}
bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg)
{
if (themeDirPath.isEmpty()) {
@ -520,18 +534,15 @@ void ThemeManager::previewFonts(const FontConfig &cfg)
{
currentFonts = cfg;
// QApplication::setFont already sends a font change to every widget, so a
// repolish pass is not needed for fonts (unlike the palette path).
const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault);
if (it != cfg.fonts.constEnd()) {
qApp->setFont(it.value());
// Repolish every widget immediately so the preview applies without
// waiting for natural expose events, mirroring applyStyleAndPalette.
QStyle *style = qApp->style();
for (QWidget *widget : qApp->allWidgets()) {
style->unpolish(widget);
style->polish(widget);
widget->update();
}
} else {
// A config without InterfaceDefault means "no override": restore the
// startup font instead of leaving the last preview applied.
qApp->setFont(defaultFont);
}
emit fontsChanged();
@ -649,6 +660,14 @@ QFont ThemeManager::appFont(AppFont::Role role) const
return qApp->font();
}
QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const
{
// No per-role scaling and no preview: an unset role resolves to the font
// captured at startup, which is what the theme falls back to once the
// editor closes.
return defaultFont;
}
void ThemeManager::themeChangedSlot()
{
QString themeName = SettingsCache::instance().getThemeName();
@ -715,6 +734,10 @@ void ThemeManager::themeChangedSlot()
const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault);
if (fontIt != currentFonts.fonts.constEnd()) {
qApp->setFont(fontIt.value());
} else {
// No theme font: restore the pristine application font captured at
// startup, so a reverted preview cannot leak into the live theme.
qApp->setFont(defaultFont);
}
emit fontsChanged();

View file

@ -48,6 +48,10 @@ private:
// palette is applied. Used as the base when a theme supplies no palette, so
// switching away from a custom palette restores the original colours.
QPalette defaultPalette;
// Pristine application font captured at startup, before any theme font is
// applied. Restored when a theme (or a preview) supplies no InterfaceDefault
// font, so switching away from a themed font cannot leave it behind.
QFont defaultFont;
QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes;
@ -128,6 +132,9 @@ public:
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig
loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme);
// Load the theme's shipped default fonts, falling back to the system theme
// directory when fonts.toml is absent from the resolved (user) directory.
static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName);
/** @brief Writes cfg to disk as the theme's palette-<scheme>.toml and updates the
* theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave
* and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */
@ -145,6 +152,11 @@ public:
/** @brief Resolves an application font role: the theme's stored [Fonts]
* value when present, otherwise the application font. */
QFont appFont(AppFont::Role role) const;
/** @brief Resolves an application font role against the startup font only,
* ignoring any previewed config in currentFonts. Editors that show
* "what this role resolves to" must use this, because appFont()
* reflects a live preview while the editor is open. */
QFont defaultAppFont(AppFont::Role role) const;
QBrush &getBgBrush(Role zone);
QBrush getExtraBgBrush(Role zone, int zoneId = 0);