diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index b40e26377..23188f490 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -163,6 +163,9 @@ SettingsCache::SettingsCache() // before that rename (idempotent, safe to run on every startup, portable builds included). SettingsMigration::migrateGameFiltersToCamelCase(settingsPath); + // Retire appearance/maxFontSize into per-theme CardText max (idempotent, safe every startup). + SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath); + shortcutsSettings = new ShortcutsSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this); diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 359d1c928..cfd25b18d 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -38,7 +38,6 @@ #include #include #include -#include #include #include #include @@ -187,8 +186,8 @@ bool AbstractCardItem::shouldPaintNameOverPicture() const void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle) { - const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize(); - const int fontSize = std::max(9, MAX_FONT_SIZE); + const int minSize = std::max(1, themeManager->cardTextMinSize()); + const int fontSize = std::max(minSize, std::max(1, themeManager->cardTextMaxSize())); QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect()); diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index 44ad59551..4eab69ee5 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -11,8 +11,6 @@ #include #include -static constexpr int MIN_FONT_SIZE = 6; -static constexpr int MAX_FONT_SIZE = 72; static constexpr int FALLBACK_FONT_SIZE = 10; static const QMap APP_FONT_DESCRIPTIONS = { @@ -46,6 +44,7 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) grid->setColumnStretch(0, 0); grid->setColumnStretch(1, 1); grid->setColumnStretch(2, 0); + grid->setColumnStretch(3, 0); QMetaEnum fontEnum = QMetaEnum::fromType(); for (int i = 0; i < fontEnum.keyCount(); ++i) { @@ -58,14 +57,41 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) grid->addWidget(familyCombo, i, 1); connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); }); + if (role == AppFont::CardText) { + auto *minSpin = new QSpinBox(this); + minSpin->setRange(FontConfig::CARD_TEXT_MIN_SIZE, FontConfig::MAX_FONT_SIZE); + grid->addWidget(minSpin, i, 2); + connect(minSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + + auto *maxSpin = new QSpinBox(this); + maxSpin->setRange(FontConfig::MIN_FONT_SIZE, FontConfig::MAX_FONT_SIZE); + grid->addWidget(maxSpin, i, 3); + connect(maxSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + + // Keep the range consistent: otherwise min 20 / max 12 saves as-is + // and cardTextMaxSize() silently clamps the max up to the min, so + // the max spin shows a size that is never used. Moving one spin + // pulls the other's bound along, but never below the max control's + // own floor: the renderer resolves the max into the same range, so a + // smaller value here would be the same mismatch in reverse. + connect(minSpin, &QSpinBox::valueChanged, this, + [maxSpin](int value) { maxSpin->setMinimum(qMax(value, FontConfig::MIN_FONT_SIZE)); }); + connect(maxSpin, &QSpinBox::valueChanged, this, [minSpin](int value) { minSpin->setMaximum(value); }); + + label->setBuddy(familyCombo); + + fontRows[role] = {label, familyCombo, maxSpin, minSpin}; + continue; + } + auto *sizeSpin = new QSpinBox(this); - sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE); + sizeSpin->setRange(FontConfig::MIN_FONT_SIZE, FontConfig::MAX_FONT_SIZE); grid->addWidget(sizeSpin, i, 2); connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); label->setBuddy(familyCombo); - fontRows[role] = {label, familyCombo, sizeSpin}; + fontRows[role] = {label, familyCombo, sizeSpin, nullptr}; } retranslateUi(); @@ -89,6 +115,25 @@ void FontGridWidget::loadFonts(const FontConfig &cfg) const QFont font = cfg.fonts.value(role, fallback); row.familyCombo->setCurrentFont(font); + if (role == AppFont::CardText && row.minSpin != nullptr) { + // A previous load may have narrowed the linked bounds (min 20 pulls + // max up to 20), which would clamp the values written below. Reset + // both to the full range first; the valueChanged lambdas re-link + // them as the values are set. + row.minSpin->setRange(FontConfig::CARD_TEXT_MIN_SIZE, FontConfig::MAX_FONT_SIZE); + row.sizeSpin->setRange(FontConfig::MIN_FONT_SIZE, FontConfig::MAX_FONT_SIZE); + + // Resolved by FontConfig, which the renderer reads through as well: + // a stored size the editor cannot represent (for example a migrated + // appearance/maxFontSize above 72) shows at the bound the board + // draws, so a save never rewrites a size that was in use. + // Max first, then min: each value re-applies the linked bound, so + // the pair ends up consistent instead of clamping the smaller one. + row.sizeSpin->setValue(cfg.resolvedCardTextMaxSize()); + row.minSpin->setValue(cfg.resolvedCardTextMinSize()); + continue; + } + int size = font.pointSize(); if (size <= 0) { size = qApp->font().pointSize(); @@ -96,7 +141,7 @@ void FontGridWidget::loadFonts(const FontConfig &cfg) if (size <= 0) { size = FALLBACK_FONT_SIZE; } - row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); + row.sizeSpin->setValue(qBound(FontConfig::MIN_FONT_SIZE, size, FontConfig::MAX_FONT_SIZE)); } blockSignals(false); } @@ -114,6 +159,11 @@ FontConfig FontGridWidget::currentFontConfig() const QFont font(row.familyCombo->currentFont().family()); font.setPointSize(row.sizeSpin->value()); cfg.fonts[role] = font; + + if (role == AppFont::CardText && row.minSpin != nullptr) { + cfg.cardTextMinSize = row.minSpin->value(); + cfg.cardTextMaxSize = row.sizeSpin->value(); + } } return cfg; } @@ -129,6 +179,15 @@ void FontGridWidget::retranslateUi() row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); row.familyCombo->setAccessibleName(roleDisplayName(role)); + if (role == AppFont::CardText && row.minSpin != nullptr) { + row.minSpin->setToolTip(tr("Minimum card text size")); + row.minSpin->setAccessibleName(tr("Minimum card text size")); + row.minSpin->setSuffix(tr(" pt")); + row.sizeSpin->setToolTip(tr("Maximum card text size")); + row.sizeSpin->setAccessibleName(tr("Maximum card text size")); + row.sizeSpin->setSuffix(tr(" pt")); + continue; + } row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role))); row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role))); row.sizeSpin->setSuffix(tr(" pt")); diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.h b/cockatrice/src/interface/palette_editor/font_grid_widget.h index 8c80306d3..07ccfa868 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.h @@ -35,6 +35,7 @@ private: QLabel *label = nullptr; QFontComboBox *familyCombo = nullptr; QSpinBox *sizeSpin = nullptr; + QSpinBox *minSpin = nullptr; // CardText only: shrink-to-fit floor }; QMap fontRows; diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 525a46905..e2d0a1f21 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -309,7 +310,28 @@ QPalette PaletteConfig::apply(QPalette base) const bool FontConfig::hasFonts() const { - return !fonts.isEmpty(); + return !fonts.isEmpty() || cardTextMinSize != CARD_TEXT_MIN_SIZE || cardTextMaxSize > 0; +} + +int FontConfig::resolvedCardTextMinSize() const +{ + const int size = cardTextMinSize > 0 ? cardTextMinSize : CARD_TEXT_MIN_SIZE; + return std::clamp(size, CARD_TEXT_MIN_SIZE, MAX_FONT_SIZE); +} + +int FontConfig::resolvedCardTextMaxSize() const +{ + int size = cardTextMaxSize; + if (size <= 0) { + const auto it = fonts.constFind(AppFont::CardText); + size = it != fonts.constEnd() ? it.value().pointSize() : 0; + } + if (size <= 0) { + size = DEFAULT_CARD_TEXT_SIZE; + } + // Mirror the editor's resolution: clamp the max into its control range, then + // raise it to the (already clamped) min so the pair stays consistent. + return std::max(resolvedCardTextMinSize(), std::clamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE)); } QString FontConfig::toToml() const @@ -335,6 +357,14 @@ QString FontConfig::toToml() const entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize); } + if (cardTextMinSize != CARD_TEXT_MIN_SIZE) { + entries += QString("%1 = %2\n").arg(QStringLiteral("CardTextMinSize"), -20).arg(cardTextMinSize); + } + + if (cardTextMaxSize > 0) { + entries += QString("%1 = %2\n").arg(QStringLiteral("CardTextMaxSize"), -20).arg(cardTextMaxSize); + } + if (entries.isEmpty()) { return {}; } @@ -402,6 +432,31 @@ FontConfig FontConfig::fromFile(const QString &filePath) value = value.mid(1, value.size() - 2).trimmed(); } + // CardText size bounds: bare integers, e.g. CardTextMinSize = 2. + // Tolerate quoting like the family entries; skip invalid lines. + QString sizeKey = key; + if (sizeKey.startsWith("AppFont::")) { + sizeKey = sizeKey.mid(QStringLiteral("AppFont::").size()); + } + if (sizeKey.compare("CardTextMinSize", Qt::CaseInsensitive) == 0) { + bool ok = false; + int minSize = value.trimmed().toInt(&ok); + if (!ok || minSize <= 0) { + continue; + } + cfg.cardTextMinSize = minSize; + continue; + } + if (sizeKey.compare("CardTextMaxSize", Qt::CaseInsensitive) == 0) { + bool ok = false; + int maxSize = value.trimmed().toInt(&ok); + if (!ok || maxSize <= 0) { + continue; + } + cfg.cardTextMaxSize = maxSize; + continue; + } + int comma = value.lastIndexOf(','); if (comma < 0) { diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 0848baeb8..cbf1f8abe 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -128,15 +128,41 @@ struct PaletteConfig * @code * [Fonts] * InterfaceDefault = "Inter, 10" + * CardText = "Noto Serif, 12" + * CardTextMinSize = 1 * @endcode + * CardText stores family + MAX size; cardTextMinSize stores the overlay + * shrink-to-fit floor (omitted from TOML when 1). */ struct FontConfig { + /// Point-size range the theme font editor can represent. CardText is + /// resolved through the same bounds where it is rendered, so the board never + /// draws a size the editor cannot show and would clamp away on save. + static constexpr int MIN_FONT_SIZE = 6; + static constexpr int MAX_FONT_SIZE = 72; + /// CardText shrink-to-fit floor, editable below the general font floor. + static constexpr int CARD_TEXT_MIN_SIZE = 1; + /// CardText size when neither CardTextMaxSize nor a CardText family is set. + static constexpr int DEFAULT_CARD_TEXT_SIZE = 12; + QMap fonts; + int cardTextMinSize = CARD_TEXT_MIN_SIZE; + /// Optional size-only maximum for CardText, used by the retired maxFontSize + /// migration so seeding it does not have to pin a family. + int cardTextMaxSize = 0; + + /// CardText min/max resolved to what the font editor shows and a save would + /// write: the stored values (max falling back to the CardText point size, + /// then the default) clamped into the representable range. A seeded legacy + /// size above the editor's maximum therefore renders at that maximum instead + /// of at a size the editor would silently rewrite. + int resolvedCardTextMinSize() const; + int resolvedCardTextMaxSize() const; bool operator==(const FontConfig &rhs) const { - return fonts == rhs.fonts; + return fonts == rhs.fonts && cardTextMinSize == rhs.cardTextMinSize && cardTextMaxSize == rhs.cardTextMaxSize; } bool operator!=(const FontConfig &rhs) const { diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 3a2ecf9cf..ae7fce06e 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -53,6 +53,10 @@ static const QColor PLAYERZONE_BG_DEFAULT = QColor(200, 200, 200); static const QColor STACKZONE_BG_DEFAULT = QColor(113, 43, 43); static const QStringList DEFAULT_RESOURCE_PATHS = {":/resources"}; +// Defined below; forward-declared because isDarkMode() resolves the shipped +// theme directory needed for the theme.cfg fallback. +static QString systemThemesBasePath(); + struct PaletteColorInfo { QPalette::ColorGroup group; @@ -236,7 +240,15 @@ void ThemeManager::ensureThemeDirectoryExists() bool ThemeManager::isDarkMode(const QString &themeDirPath) const { - ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath); + // The theme directory is always named after the theme, so the shipped + // fallback for a user directory that carries no theme.cfg is the same-named + // directory under the system themes base path — mirroring the font and + // palette fallbacks. A resolved directory that does not exist yet is a stock + // theme (getAvailableThemes() maps System/Fusion to a user path that + // writableThemeDir() only creates on save), so it keeps following the OS + // instead of inheriting a shipped ColorScheme such as Dark. + const QString systemThemeDir = QDir(systemThemesBasePath()).absoluteFilePath(QFileInfo(themeDirPath).fileName()); + const ThemeConfig themeConfig = loadColorSchemeConfig(themeDirPath, systemThemeDir); if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) { return true; } else if (themeConfig.colorScheme.compare("Light", Qt::CaseInsensitive) == 0) { @@ -402,6 +414,60 @@ bool ThemeManager::saveGlobalConfig(const QString &themeDirPath, const ThemeConf return cfg.save(themeDirPath); } +ThemeConfig ThemeManager::loadDefaultThemeConfig(const QString &themeDirPath, const QString &systemThemeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + + // The shipped theme.cfg may live in the system theme directory rather than + // the resolved (user) directory. A user directory that only holds a seeded + // fonts.toml (or a saved palette) must still inherit the shipped style and + // colour scheme instead of shadowing them with an empty config. + if (QFileInfo::exists(QDir(themeDirPath).absoluteFilePath("theme.cfg"))) { + return ThemeConfig::fromThemeDir(themeDirPath); + } + return ThemeConfig::fromThemeDir(systemThemeDirPath); +} + +ThemeConfig ThemeManager::loadColorSchemeConfig(const QString &themeDirPath, const QString &systemThemeDirPath) +{ + // Only an existing user directory is layered on the shipped theme: it can + // hold seeded files (fonts.toml, a saved palette) without a theme.cfg of its + // own, so it inherits the shipped style and colour scheme. A directory that + // does not exist yet means the user has never customised this theme, and + // inheriting a shipped Dark scheme there would override the OS preference on + // a fresh profile. + if (themeDirPath.isEmpty() || !QFileInfo(themeDirPath).isDir()) { + return {}; + } + return loadDefaultThemeConfig(themeDirPath, systemThemeDirPath); +} + +QString ThemeManager::themeStyleSheetPath(const QString &themeDirPath, const QString &systemThemeDirPath, bool dark) +{ + const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') + + (dark ? QStringLiteral("dark") : QStringLiteral("light")) + QStringLiteral(".css"); + + // Scheme-qualified stylesheet first, plain style.css as the super fallback, + // both inside the resolved (user) directory before the shipped one. A user + // directory holding only fonts.toml therefore keeps the shipped styling. + const QStringList candidateDirs = {themeDirPath, systemThemeDirPath}; + const QStringList candidateNames = {schemeCss, QString(STYLE_CSS_NAME)}; + for (const QString &dirPath : candidateDirs) { + if (dirPath.isEmpty()) { + continue; + } + for (const QString &name : candidateNames) { + const QString path = QDir(dirPath).absoluteFilePath(name); + if (QFileInfo::exists(path)) { + return path; + } + } + } + return {}; +} + PaletteConfig ThemeManager::loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme) { if (themeDirPath.isEmpty()) { @@ -528,7 +594,10 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem { const QString themeName = SettingsCache::instance().getThemeName(); const QString dirPath = getAvailableThemes().value(themeName); - const ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath); + // Same shipped fallback as themeChangedSlot(): a user directory that only + // holds seeded files must not preview the theme with its style reset. + const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); + const ThemeConfig themeCfg = loadDefaultThemeConfig(dirPath, sysPath); applyStyleAndPalette(themeName, themeCfg, cfg, scheme); } @@ -732,6 +801,7 @@ QFont ThemeManager::cardTextFont() const } QFont serifFont(QStringLiteral("Serif")); serifFont.setStyleHint(QFont::Serif); + serifFont.setPointSize(12); return serifFont; } @@ -753,37 +823,44 @@ QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const return defaultFont; } +int ThemeManager::cardTextMaxSize() const +{ + // Resolved through FontConfig so the board and the font editor share one + // representable range: a stored size the editor cannot show (e.g. a migrated + // appearance/maxFontSize of 100) renders at the editor's maximum instead of + // at a size the next save would silently rewrite. + return currentFonts.resolvedCardTextMaxSize(); +} + +int ThemeManager::cardTextMinSize() const +{ + return currentFonts.resolvedCardTextMinSize(); +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); QString dirPath = getAvailableThemes().value(themeName); currentThemePath = dirPath; QDir dir(dirPath); + const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); // CSS — prefer the scheme-qualified stylesheet (style-dark.css / // style-light.css) when present, else the plain style.css as fallback. - if (!dirPath.isEmpty()) { - const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light"); - const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') + - scheme + QStringLiteral(".css"); - if (dir.exists(schemeCss)) { - qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss)); - } else if (dir.exists(STYLE_CSS_NAME)) { - qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); - } else { - qApp->setStyleSheet(""); - } - } else { - qApp->setStyleSheet(""); - } + // A user theme directory that only carries seeded files falls back to the + // shipped stylesheet of the same theme. + const bool darkMode = isDarkMode(dirPath); + const QString styleSheetPath = themeStyleSheetPath(dirPath, sysPath, darkMode); + qApp->setStyleSheet(styleSheetPath.isEmpty() ? QString() : "file:///" + styleSheetPath); - // load theme.cfg for style + scheme preference - ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath); + // load theme.cfg for style + scheme preference, falling back to the shipped + // theme directory when the user copy does not carry one + ThemeConfig themeCfg = loadDefaultThemeConfig(dirPath, sysPath); // Resolve active scheme: // theme.cfg says Dark/Light → use that // theme.cfg says System or is absent → follow the OS - QString activeScheme = isDarkMode(dirPath) ? "Dark" : "Light"; + QString activeScheme = darkMode ? "Dark" : "Light"; // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); @@ -826,7 +903,6 @@ void ThemeManager::themeChangedSlot() // include the system theme dir as a fallback so shipped assets like // zones/*.png and style.css still resolve for themes that ship only // those files (e.g. Leather, Plasma, Fabric, VelvetMarble). - const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); if (sysPath != dirPath && QDir(sysPath).exists()) { resources << sysPath; } diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 3bcfd7b3c..21a23bb16 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -92,6 +92,8 @@ public: static bool isDirReallyWritable(const QString &dirPath); // Explicit color scheme of the theme: theme.cfg's ColorScheme setting // (Dark/Light), falling back to the OS color scheme when it is "System". + // A resolved directory that does not exist yet is an uncustomised stock + // theme, so it follows the OS rather than the shipped scheme. bool isDarkMode(const QString &themeDirPath) const; // The resolved scheme of the currently active theme. bool isDarkModeActive() const @@ -107,6 +109,20 @@ public: // Load the global theme settings (style + color scheme preference) static ThemeConfig loadGlobalConfig(const QString &themeDirPath); static bool saveGlobalConfig(const QString &themeDirPath, const ThemeConfig &cfg); + // Load a theme's theme.cfg, falling back to the shipped (system) theme + // directory when the resolved user directory does not carry one. A user + // directory seeded with only fonts.toml would otherwise shadow the shipped + // theme and lose its style and colour scheme. + static ThemeConfig loadDefaultThemeConfig(const QString &themeDirPath, const QString &systemThemeDirPath); + // Load the theme.cfg that decides the active colour scheme. Unlike + // loadDefaultThemeConfig(), a resolved directory that does not exist yet is + // a stock theme with no user override: it returns an empty config so the + // caller follows the OS scheme instead of inheriting the shipped one. + static ThemeConfig loadColorSchemeConfig(const QString &themeDirPath, const QString &systemThemeDirPath); + // Resolve the theme stylesheet: the scheme-qualified style-.css + // when shipped, else style.css, preferring the resolved (user) directory + // over the shipped one. Empty when neither directory carries a stylesheet. + static QString themeStyleSheetPath(const QString &themeDirPath, const QString &systemThemeDirPath, bool dark); // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); @@ -170,6 +186,12 @@ public: * "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; + /** @brief CardText max size resolved the way the font editor shows it: + * stored CardTextMaxSize, else the CardText point size, else 12, + * clamped to the editor's representable range. */ + int cardTextMaxSize() const; + /** @brief CardText shrink-to-fit floor resolved the same way. */ + int cardTextMinSize() const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index 56fe0bda4..4111a9cec 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -87,6 +87,7 @@ void CardInfoPictureWithTextOverlayWidget::setOutlineColor(const QColor &color) void CardInfoPictureWithTextOverlayWidget::setFontSize(const int size) { fontSize = size > 0 ? size : 1; + hasExplicitFontSize = true; update(); } @@ -179,10 +180,12 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) } // Calculate the optimal font size. The family comes from the CardText - // theme role; the fontSize member stays the starting size and the - // shrink-to-fit logic below is unchanged. + // theme role; the start size is the explicit size when set (e.g. deck + // preview 24), else the theme max, shrinking down to the theme min floor. QFont font = themeManager->appFont(AppFont::CardText); - int optimalFontSize = fontSize; // Start with the user-defined font size + const int themeMin = std::max(1, themeManager->cardTextMinSize()); + const int themeMax = std::max(themeMin, std::max(1, themeManager->cardTextMaxSize())); + int optimalFontSize = hasExplicitFontSize ? std::max(1, fontSize) : themeMax; const QFontMetrics baseMetrics(font); int textWidth = pixmapRect.width(); @@ -200,7 +203,7 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) } --optimalFontSize; - } while (optimalFontSize > 1); + } while (optimalFontSize > themeMin); // Apply the calculated font size painter.setFont(font); @@ -227,7 +230,7 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) int totalTextHeight = wrappedText.count('\n') * fontMetrics.height() + fontMetrics.height(); // Adjust font size if the total text height exceeds the pixmap height - while (totalTextHeight > pixmapRect.height() && optimalFontSize > 1) { + while (totalTextHeight > pixmapRect.height() && optimalFontSize > themeMin) { --optimalFontSize; font.setPointSize(optimalFontSize); painter.setFont(font); @@ -297,7 +300,9 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const // Get the font metrics for the overlay text QFont font = themeManager->appFont(AppFont::CardText); - font.setPointSize(fontSize); + const int themeMin = std::max(1, themeManager->cardTextMinSize()); + const int themeMax = std::max(themeMin, std::max(1, themeManager->cardTextMaxSize())); + font.setPointSize(hasExplicitFontSize ? std::max(1, fontSize) : themeMax); const QFontMetrics fontMetrics(font); // Calculate the height required for the text diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h index 41019f543..c4a967498 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h @@ -60,6 +60,7 @@ private: QColor textColor; QColor outlineColor; int fontSize; + bool hasExplicitFontSize = false; Qt::Alignment textAlignment; bool highlighted; }; diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index 21c0a79e1..060d98f42 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -284,20 +284,11 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings.cardsDisplay(), &CardsDisplaySettings::setRoundCardCorners); - connect(&maxFontSizeForCardsEdit, qOverload(&QSpinBox::valueChanged), &settings.appearance(), - &AppearanceSettings::setMaxFontSize); - maxFontSizeForCardsEdit.setValue(settings.appearance().getMaxFontSize()); - maxFontSizeForCardsLabel.setBuddy(&maxFontSizeForCardsEdit); - maxFontSizeForCardsEdit.setMinimum(9); - maxFontSizeForCardsEdit.setMaximum(100); - auto *cardsGrid = new QGridLayout; cardsGrid->addWidget(&displayCardNamesCheckBox, 0, 0, 1, 2); cardsGrid->addWidget(&autoRotateSidewaysLayoutCardsCheckBox, 1, 0, 1, 2); cardsGrid->addWidget(&cardScalingCheckBox, 2, 0, 1, 2); cardsGrid->addWidget(&roundCardCornersCheckBox, 3, 0, 1, 2); - cardsGrid->addWidget(&maxFontSizeForCardsLabel, 4, 0, 1, 1); - cardsGrid->addWidget(&maxFontSizeForCardsEdit, 4, 1, 1, 1); cardsGroupBox = new QGroupBox; cardsGroupBox->setLayout(cardsGrid); @@ -658,7 +649,6 @@ void AppearanceSettingsPage::retranslateUi() autoRotateSidewaysLayoutCardsCheckBox.setText(tr("Auto-Rotate cards with sideways layout")); cardScalingCheckBox.setText(tr("Scale cards on mouse over")); roundCardCornersCheckBox.setText(tr("Use rounded card corners")); - maxFontSizeForCardsLabel.setText(tr("Maximum font size for information displayed on cards:")); cardLayoutGroupBox->setTitle(tr("Card layout")); verticalCardOverlapPercentLabel.setText( diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index f919a2a2f..8d008dfeb 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -71,8 +71,6 @@ private: QCheckBox autoRotateSidewaysLayoutCardsCheckBox; QCheckBox cardScalingCheckBox; QCheckBox roundCardCornersCheckBox; - QLabel maxFontSizeForCardsLabel; - QSpinBox maxFontSizeForCardsEdit; QLabel verticalCardOverlapPercentLabel; QSpinBox verticalCardOverlapPercentBox; diff --git a/cockatrice/themes/Fusion/theme.cfg b/cockatrice/themes/Fusion/theme.cfg index 9b38e505e..413e9b94d 100644 --- a/cockatrice/themes/Fusion/theme.cfg +++ b/cockatrice/themes/Fusion/theme.cfg @@ -1,5 +1,5 @@ [Appearance] -ColorScheme = Dark +ColorScheme = System [Style] Name = Fusion diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 14969dae6..4de87991c 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -33,16 +33,6 @@ void AppearanceSettings::setStyleUserList(bool _styleUserList) emit styleUserListChanged(); } -int AppearanceSettings::getMaxFontSize() const -{ - return getValue("maxFontSize", QString(), QString(), 12).toInt(); -} - -void AppearanceSettings::setMaxFontSize(int _max) -{ - setValue(_max, "maxFontSize"); -} - QString AppearanceSettings::getHomeTabBackgroundSource() const { return getValue("homeTabBackgroundSource", QString(), QString(), "themed").toString(); diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index 9d15caddd..1e0823ae1 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -24,8 +24,6 @@ public: void setThemeName(const QString &_themeName); [[nodiscard]] bool getStyleUserList() const; void setStyleUserList(bool _styleUserList); - [[nodiscard]] int getMaxFontSize() const; - void setMaxFontSize(int _max); [[nodiscard]] QString getHomeTabBackgroundSource() const; void setHomeTabBackgroundSource(const QString &_backgroundSource); [[nodiscard]] int getHomeTabBackgroundShuffleFrequency() const; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index d9652cde3..aa97f8da0 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -395,6 +396,10 @@ static void migrateAppearanceSettings(const QString &settingsPath, QSettings &gl { const QMap appearanceKeyMap = { {"theme/name", "appearance/themeName"}, + // Retired into the theme's CardText size, but still read here so users + // who have not migrated past global.ini keep their card text size: + // SettingsCache runs migrateSettingsFromGlobalIni() before + // migrateAppearanceMaxFontSizeToTheme(). {"game/maxfontsize", "appearance/maxFontSize"}, {"home/background", "appearance/homeTabBackgroundSource"}, {"home/background/shuffleTimer", "appearance/homeTabBackgroundShuffleFrequency"}, @@ -424,6 +429,204 @@ static void migrateAppearanceSettings(const QString &settingsPath, QSettings &gl } } +static QString themesPathForSettings(const QString &settingsPath) +{ + QSettings pathsIni(settingsPath + "paths.ini", QSettings::IniFormat); + const QString configured = pathsIni.value("paths/themes").toString().trimmed(); + if (!configured.isEmpty()) { + return configured; + } + // Default layout: /themes/ sibling of /settings/. + return QDir::cleanPath(QDir(settingsPath).absoluteFilePath("../themes")); +} + +static bool fontsTomlHasExplicitCardTextMax(const QString &filePath) +{ + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + + QString currentSection; + QTextStream in(&f); + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) { + continue; + } + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.mid(1, line.length() - 2).trimmed(); + continue; + } + if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) { + continue; + } + int eq = line.indexOf('='); + if (eq < 0) { + continue; + } + QString key = line.left(eq).trimmed(); + QString value = line.mid(eq + 1).trimmed(); + for (int i = 1; i < value.size(); ++i) { + if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) { + value = value.left(i).trimmed(); + break; + } + } + if (value.size() >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) { + value = value.mid(1, value.size() - 2).trimmed(); + } + if (key.startsWith("AppFont::")) { + key = key.mid(QStringLiteral("AppFont::").size()); + } + if (key.compare("CardTextMaxSize", Qt::CaseInsensitive) == 0) { + return true; + } + if (key.compare("CardText", Qt::CaseInsensitive) != 0) { + continue; + } + int comma = value.lastIndexOf(','); + if (comma < 0) { + continue; + } + QString family = value.left(comma).trimmed(); + if (family.size() >= 2 && + ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) { + family = family.mid(1, family.size() - 2).trimmed(); + } + bool ok = false; + int pointSize = value.mid(comma + 1).trimmed().toInt(&ok); + if (!ok || family.isEmpty() || pointSize <= 0) { + continue; + } + return true; + } + return false; +} + +static bool seedCardTextMaxIntoThemeDir(const QString &themeDirPath, int maxSize) +{ + if (themeDirPath.isEmpty() || maxSize <= 0) { + return false; + } + QDir dir(themeDirPath); + if (!dir.exists()) { + return false; + } + const QString filePath = dir.absoluteFilePath("fonts.toml"); + if (fontsTomlHasExplicitCardTextMax(filePath)) { + return false; + } + + QString existing; + if (QFile::exists(filePath)) { + QFile in(filePath); + if (!in.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + existing = QString::fromUtf8(in.readAll()); + } + + // Size-only key: seeding the CardText family here would pin it to Serif + // for the whole board (names, player labels, the completer), which is not + // what the retired maxFontSize setting meant. Let the family keep its + // existing resolution and only constrain the size. + const QString entry = QString("%1 = %2").arg(QStringLiteral("CardTextMaxSize"), -20).arg(maxSize); + + QString out; + if (existing.isEmpty()) { + out = "[Fonts]\n" + entry + "\n"; + } else { + out = existing; + if (!out.endsWith('\n')) { + out += '\n'; + } + + // Insert the key inside an existing [Fonts] section. Appending at EOF + // would land it in whatever section happens to follow, where the font + // parser never reads it. Tracking the last non-blank line of the + // section keeps the file's own comments, ordering and blank lines. + QStringList lines = out.split('\n'); + int insertAt = -1; + for (int i = 0; i < lines.size(); ++i) { + const QString trimmed = lines[i].trimmed(); + if (insertAt < 0) { + if (trimmed.startsWith('[') && trimmed.endsWith(']') && + trimmed.mid(1, trimmed.length() - 2).trimmed().compare("Fonts", Qt::CaseInsensitive) == 0) { + insertAt = i + 1; + } + continue; + } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + break; + } + if (!trimmed.isEmpty()) { + insertAt = i + 1; + } + } + + if (insertAt < 0) { + out += "\n[Fonts]\n" + entry + "\n"; + } else { + lines.insert(insertAt, entry); + out = lines.join('\n'); + } + } + + QFile f(filePath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + return false; + } + QTextStream stream(&f); + stream << out; + return true; +} + +static bool appearanceMaxFontSizeMigrationPending(const QString &settingsPath) +{ + if (!QFile::exists(settingsPath + "appearance.ini")) { + return false; + } + QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); + return appearanceIni.contains("appearance/maxFontSize"); +} + +bool SettingsMigration::migrateAppearanceMaxFontSizeToTheme(const QString &settingsPath) +{ + if (!appearanceMaxFontSizeMigrationPending(settingsPath)) { + return false; + } + + QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); + // Retired default was 12 in AppearanceSettings::getMaxFontSize; only + // non-default values are seeded, default users rely on the theme fallback. + const int legacy = appearanceIni.value("appearance/maxFontSize").toInt(); + if (legacy != 12 && legacy > 0) { + const QString themesPath = themesPathForSettings(settingsPath); + QDir themesDir(themesPath); + const QStringList themes = themesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); + for (const QString &theme : themes) { + seedCardTextMaxIntoThemeDir(themesDir.absoluteFilePath(theme), legacy); + } + + // Shipped themes (Fabric, Leather, ...) resolve to the system theme + // directory unless the user has already saved something for them, and + // System/Fusion only ever exist in the user directory. Seeding existing + // folders alone would therefore drop the retired size for every user on + // a stock theme, so create the active theme's user directory the same + // way ThemeManager::writableThemeDir() does for palette and font saves. + const QString activeTheme = appearanceIni.value("appearance/themeName").toString().trimmed(); + if (!activeTheme.isEmpty() && !themesDir.exists(activeTheme) && themesDir.mkpath(activeTheme)) { + seedCardTextMaxIntoThemeDir(themesDir.absoluteFilePath(activeTheme), legacy); + } + } + + appearanceIni.remove("appearance/maxFontSize"); + appearanceIni.sync(); + return true; +} + static void migrateNetworkSettings(const QString &settingsPath, QSettings &globalIni) { const QMap networkKeyMap = { @@ -821,6 +1024,10 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath) return true; } + if (appearanceMaxFontSizeMigrationPending(settingsPath)) { + return true; + } + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); if (personalIni.value("migration/legacy_complete", false).toBool()) { return false; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h index 0a706af98..7616cb83f 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -18,6 +18,15 @@ public: */ static bool migrateGameFiltersToCamelCase(const QString &settingsPath); + /** + * Moves a non-default appearance/maxFontSize value into each user-writable theme + * directory lacking an explicit CardText max (creating/merging fonts.toml, never + * overwriting an explicit max), then deletes the legacy key. Default (12) or absent + * values only delete the key and create no files, since the ThemeManager fallback + * already covers them. Idempotent, safe to run on every startup. + */ + static bool migrateAppearanceMaxFontSizeToTheme(const QString &settingsPath); + /** * Returns true when at least one of the two migrations would still do work, i.e. when a * backup of the settings directory is worth taking before they run. diff --git a/tests/font_config_test.cpp b/tests/font_config_test.cpp index c8b1fb10d..ed6e3923c 100644 --- a/tests/font_config_test.cpp +++ b/tests/font_config_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace { @@ -186,6 +187,190 @@ TEST(FontConfigTest, InequalityComparesRoles) EXPECT_NE(left, right); } +TEST(FontConfigTest, CardTextMinRoundTrip) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 14); + cfg.cardTextMinSize = 3; + ASSERT_TRUE(cfg.hasFonts()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_EQ(loaded, cfg); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14); + EXPECT_EQ(loaded.cardTextMinSize, 3); +} + +TEST(FontConfigTest, CardTextMinDefaultIsOmitted) +{ + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 12); + EXPECT_EQ(cfg.cardTextMinSize, 1); + + const QString toml = cfg.toToml(); + EXPECT_TRUE(toml.contains(QStringLiteral("CardText"))); + EXPECT_FALSE(toml.contains(QStringLiteral("CardTextMinSize"))); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString filePath = writeTempFile(tmp, "fonts.toml", toml); + EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMinSize, 1); +} + +TEST(FontConfigTest, MalformedMinIsSkipped) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", + "[Fonts]\n" + "CardText = \"Noto Serif, 14\"\n" + "CardTextMinSize = big\n"); + + // Each malformed value parses independently, so rewrite the file per case. + const QStringList badValues = {"big", "0", "-3", "", "Inter, 10"}; + for (const QString &bad : badValues) { + const QString badPath = writeTempFile( + tmp, "bad.toml", QString("[Fonts]\nCardText = \"Noto Serif, 14\"\nCardTextMinSize = %1\n").arg(bad)); + const FontConfig loaded = FontConfig::fromFile(badPath); + EXPECT_EQ(loaded.cardTextMinSize, 1) << "bad value: " << bad.toStdString(); + ASSERT_TRUE(loaded.fonts.contains(AppFont::CardText)); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14); + } + + // The valid file still loads. + EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMinSize, 1); +} + +TEST(FontConfigTest, CardTextMaxRoundTrip) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 14); + cfg.cardTextMaxSize = 18; + ASSERT_TRUE(cfg.hasFonts()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_EQ(loaded, cfg); + EXPECT_EQ(loaded.cardTextMaxSize, 18); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14); +} + +TEST(FontConfigTest, CardTextMaxSizeWithoutFamily) +{ + // The retired maxFontSize setting migrates to a size-only key, so the + // family keeps its existing resolution instead of being pinned to Serif. + FontConfig cfg; + cfg.cardTextMaxSize = 18; + ASSERT_TRUE(cfg.hasFonts()); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_TRUE(loaded.fonts.isEmpty()); + EXPECT_EQ(loaded.cardTextMaxSize, 18); + EXPECT_EQ(loaded, cfg); +} + +TEST(FontConfigTest, MigratedCardTextMaxResolvesIntoTheEditorsRange) +{ + // appearance/maxFontSize came from a control that allowed values above 72 + // and the migration seeds it verbatim, so fonts.toml can hold a size the + // font editor cannot show. 72 is what the editor displays and would save, + // so the board has to draw 72 too instead of rendering 100 until the next + // save silently rewrites the theme. + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString filePath = writeTempFile(tmp, "fonts.toml", "[Fonts]\nCardTextMaxSize = 100\n"); + + const FontConfig migrated = FontConfig::fromFile(filePath); + ASSERT_EQ(migrated.cardTextMaxSize, 100); + EXPECT_EQ(migrated.resolvedCardTextMaxSize(), 72); +} + +TEST(FontConfigTest, CardTextSizesResolveIntoTheEditorsRange) +{ + // The same bound applies when the size comes from the CardText family, a + // stored size below the editor's control floor rises to it, and the max is + // never below the resolved shrink-to-fit floor. + FontConfig fromFamily; + fromFamily.fonts[AppFont::CardText] = makeFont("Noto Serif", 100); + EXPECT_EQ(fromFamily.resolvedCardTextMaxSize(), 72); + + FontConfig belowFloor; + belowFloor.cardTextMaxSize = 3; + EXPECT_EQ(belowFloor.resolvedCardTextMaxSize(), 6); + + FontConfig narrow; + narrow.cardTextMinSize = 20; + narrow.cardTextMaxSize = 12; + EXPECT_EQ(narrow.resolvedCardTextMinSize(), 20); + EXPECT_EQ(narrow.resolvedCardTextMaxSize(), 20); +} + +TEST(FontConfigTest, CardTextMaxDefaultIsOmitted) +{ + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 12); + EXPECT_EQ(cfg.cardTextMaxSize, 0); + + const QString toml = cfg.toToml(); + EXPECT_FALSE(toml.contains(QStringLiteral("CardTextMaxSize"))); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString filePath = writeTempFile(tmp, "fonts.toml", toml); + EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMaxSize, 0); +} + +TEST(FontConfigTest, MalformedMaxIsSkipped) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QStringList badValues = {"big", "0", "-3", "", "Inter, 18"}; + for (const QString &bad : badValues) { + const QString badPath = writeTempFile( + tmp, "bad.toml", QString("[Fonts]\nCardText = \"Noto Serif, 14\"\nCardTextMaxSize = %1\n").arg(bad)); + const FontConfig loaded = FontConfig::fromFile(badPath); + EXPECT_EQ(loaded.cardTextMaxSize, 0) << "bad value: " << bad.toStdString(); + ASSERT_TRUE(loaded.fonts.contains(AppFont::CardText)); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14); + } +} + +TEST(FontConfigTest, InequalityComparesMinSize) +{ + FontConfig left; + left.fonts[AppFont::CardText] = makeFont("Noto Serif", 12); + left.cardTextMinSize = 1; + + FontConfig right = left; + right.cardTextMinSize = 2; + EXPECT_NE(left, right); +} + +TEST(FontConfigTest, InequalityComparesMaxSize) +{ + FontConfig left; + left.fonts[AppFont::CardText] = makeFont("Noto Serif", 12); + left.cardTextMaxSize = 12; + + FontConfig right = left; + right.cardTextMaxSize = 18; + EXPECT_NE(left, right); +} + // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // the fallback then links the bare gtest library without gtest_main. int main(int argc, char **argv) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index acffae9a8..d83c08e36 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -461,19 +461,6 @@ TEST_F(SettingsDefaultsTest, Appearance_StyleUserList_Default) ASSERT_EQ(s.getStyleUserList(), true); } -TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_Default) -{ - AppearanceSettings s(settingsPath, nullptr); - ASSERT_EQ(s.getMaxFontSize(), 12); -} - -TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_SetAndGet) -{ - AppearanceSettings s(settingsPath, nullptr); - s.setMaxFontSize(14); - ASSERT_EQ(s.getMaxFontSize(), 14); -} - TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundSource_Default) { AppearanceSettings s(settingsPath, nullptr); diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp index 2c4dcf7a9..02aeb236a 100644 --- a/tests/settings/settings_migration_test.cpp +++ b/tests/settings/settings_migration_test.cpp @@ -984,6 +984,246 @@ TEST_F(SettingsMigrationTest, IsNotMigrationPendingWithoutLegacyData) ASSERT_FALSE(SettingsMigration::isMigrationPending(settingsPath)); } +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_NonDefaultSeedsThemeAndDeletesKey) +{ + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/MyTheme")); + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + ASSERT_FALSE(readFromIni("appearance.ini", "appearance/maxFontSize").isValid()); + + const QString fontsPath = themesPath + "/MyTheme/fonts.toml"; + ASSERT_TRUE(QFile::exists(fontsPath)); + QFile f(fontsPath); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + EXPECT_TRUE(content.contains("CardTextMaxSize")); + EXPECT_TRUE(content.contains("18")); + // Size-only: the migration must not pin the family, or every CardText + // surface switches to Serif for users who never chose it. + EXPECT_FALSE(content.contains("Serif")); + + ASSERT_FALSE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_SeedsActiveStockTheme) +{ + // A stock theme resolves to the system directory, so there is no user theme + // folder to seed. The migration has to create the active theme's folder, + // otherwise the retired size is deleted without landing anywhere. + const QString themesPath = tempDir.path() + "/themes"; + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/themeName", "Fabric"); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + ASSERT_FALSE(readFromIni("appearance.ini", "appearance/maxFontSize").isValid()); + + const QString fontsPath = themesPath + "/Fabric/fonts.toml"; + ASSERT_TRUE(QFile::exists(fontsPath)); + QFile f(fontsPath); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + EXPECT_TRUE(content.contains("CardTextMaxSize")); + EXPECT_TRUE(content.contains("18")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_KeepsExistingActiveTheme) +{ + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/Fabric")); + { + QFile f(themesPath + "/Fabric/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "[Fonts]\nCardText = \"Inter, 10\"\n"; + } + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/themeName", "Fabric"); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + QFile f(themesPath + "/Fabric/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + EXPECT_TRUE(content.contains("Inter, 10")); + EXPECT_FALSE(content.contains("18")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_DefaultDeletesKeyWithoutFiles) +{ + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/MyTheme")); + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 12); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + ASSERT_FALSE(readFromIni("appearance.ini", "appearance/maxFontSize").isValid()); + ASSERT_FALSE(QFile::exists(themesPath + "/MyTheme/fonts.toml")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_DoesNotOverwriteExplicitMax) +{ + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/MyTheme")); + { + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "[Fonts]\nCardText = \"Inter, 10\"\n"; + } + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + EXPECT_TRUE(content.contains("10")); + EXPECT_FALSE(content.contains("18")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_MergesWithOtherRoles) +{ + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/MyTheme")); + { + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "[Fonts]\nInterfaceDefault = \"Inter, 10\"\n"; + } + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 20); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + EXPECT_TRUE(content.contains("InterfaceDefault")); + EXPECT_TRUE(content.contains("CardTextMaxSize")); + EXPECT_TRUE(content.contains("20")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_InsertsIntoFontsSectionNotAfterIt) +{ + // A hand-edited fonts.toml may keep [Fonts] first and another section after + // it. Appending the seeded key at EOF would land it in that trailing + // section, where the font parser never reads it. + const QString themesPath = tempDir.path() + "/themes"; + ASSERT_TRUE(QDir().mkpath(themesPath + "/MyTheme")); + { + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "[Fonts]\n" + "# keep this comment\n" + "InterfaceDefault = \"Inter, 10\"\n" + "\n" + "[Other]\n" + "Keep = \"me\"\n"; + } + { + QSettings paths(settingsPath + "paths.ini", QSettings::IniFormat); + paths.setValue("paths/themes", themesPath); + paths.sync(); + } + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); + + QFile f(themesPath + "/MyTheme/fonts.toml"); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(f.readAll()); + ASSERT_TRUE(content.contains("CardTextMaxSize")); + EXPECT_TRUE(content.contains("18")); + // The new key belongs to [Fonts], not to the section that follows it ... + const int keyPos = content.indexOf("CardTextMaxSize"); + const int otherSectionPos = content.indexOf("[Other]"); + ASSERT_GE(keyPos, 0); + ASSERT_GE(otherSectionPos, 0); + EXPECT_LT(keyPos, otherSectionPos); + // ... and the file keeps its own comment, ordering and trailing section. + EXPECT_TRUE(content.contains("# keep this comment")); + EXPECT_TRUE(content.contains("InterfaceDefault = \"Inter, 10\"")); + EXPECT_TRUE(content.endsWith("[Other]\nKeep = \"me\"\n")); +} + +TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_AbsentKeyDoesNothing) +{ + ASSERT_FALSE(SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath)); +} + +TEST_F(SettingsMigrationTest, IsMigrationPendingWhenMaxFontSizePresent) +{ + { + QSettings appearance(settingsPath + "appearance.ini", QSettings::IniFormat); + appearance.setValue("appearance/maxFontSize", 18); + appearance.sync(); + } + + ASSERT_TRUE(SettingsMigration::isMigrationPending(settingsPath)); +} + } // namespace int main(int argc, char **argv) diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 329d13458..86bed672f 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -51,13 +51,19 @@ class ThemeMigrationTest : public ::testing::Test { protected: QTemporaryDir tmp; + // Base of the per-user theme tree (/themes). QString themesPath; + // Base of the shipped/install theme tree (/system), deliberately + // separate from themesPath so a fallback cannot pass by reading a user file. + QString systemThemesPath; void SetUp() override { ASSERT_TRUE(tmp.isValid()); themesPath = QDir(tmp.path()).absoluteFilePath("themes"); + systemThemesPath = QDir(tmp.path()).absoluteFilePath("system"); ASSERT_TRUE(QDir().mkpath(themesPath)); + ASSERT_TRUE(QDir().mkpath(systemThemesPath)); } }; @@ -237,6 +243,133 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) } } +TEST_F(ThemeMigrationTest, ThemeConfigFallsBackToTheShippedThemeDir) +{ + // A user theme directory only holds what the user (or a migration) wrote. + // Reading theme.cfg from it alone would shadow the shipped theme and leave + // the board without its style and colour scheme. + const QString userDir = QDir(themesPath).absoluteFilePath("Leather"); + ASSERT_TRUE(QDir().mkpath(userDir)); + writeThemeFile(themesPath, "Leather", "fonts.toml", "[Fonts]\nCardTextMaxSize = 14\n"); + writeThemeFile(systemThemesPath, "Leather", "theme.cfg", + "[Appearance]\nColorScheme = Dark\n\n[Style]\nName = Leather\n"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Leather"); + const ThemeConfig cfg = ThemeManager::loadDefaultThemeConfig(userDir, systemDir); + + EXPECT_EQ(cfg.colorScheme.toStdString(), std::string("Dark")); + EXPECT_EQ(cfg.styleName.toStdString(), std::string("Leather")); +} + +TEST_F(ThemeMigrationTest, UserThemeConfigWinsOverTheShippedOne) +{ + writeThemeFile(themesPath, "Leather", "theme.cfg", "[Appearance]\nColorScheme = Light\n\n[Style]\nName = Custom\n"); + writeThemeFile(systemThemesPath, "Leather", "theme.cfg", + "[Appearance]\nColorScheme = Dark\n\n[Style]\nName = Leather\n"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Leather"); + const ThemeConfig cfg = + ThemeManager::loadDefaultThemeConfig(QDir(themesPath).absoluteFilePath("Leather"), systemDir); + + EXPECT_EQ(cfg.colorScheme.toStdString(), std::string("Light")); + EXPECT_EQ(cfg.styleName.toStdString(), std::string("Custom")); +} + +TEST_F(ThemeMigrationTest, ColorSchemeFollowsTheOsWhenTheResolvedThemeDirIsMissing) +{ + // getAvailableThemes() maps System/Fusion to a user directory that only + // appears on save, so a fresh profile has no resolved directory. A shipped + // theme may set ColorScheme = Dark; inheriting it there would force dark + // mode on every fresh profile regardless of the OS. Only an existing user + // directory is layered on the shipped theme. + const QString userDir = QDir(themesPath).absoluteFilePath("Fusion"); + ASSERT_FALSE(QFileInfo::exists(userDir)); + writeThemeFile(systemThemesPath, "Fusion", "theme.cfg", + "[Appearance]\nColorScheme = Dark\n\n[Style]\nName = Fusion\n"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Fusion"); + const ThemeConfig cfg = ThemeManager::loadColorSchemeConfig(userDir, systemDir); + + // Empty means isDarkMode() falls through to the OS color scheme. + EXPECT_TRUE(cfg.colorScheme.isEmpty()); +} + +TEST_F(ThemeMigrationTest, ColorSchemeInheritsTheShippedSchemeForAnExistingUserDir) +{ + // The migration seeds an existing user directory with fonts.toml only. Such + // a directory still belongs to the shipped theme and must keep its scheme + // instead of silently flipping to the OS one. + const QString userDir = QDir(themesPath).absoluteFilePath("Fusion"); + ASSERT_TRUE(QDir().mkpath(userDir)); + writeThemeFile(themesPath, "Fusion", "fonts.toml", "[Fonts]\nCardTextMaxSize = 14\n"); + writeThemeFile(systemThemesPath, "Fusion", "theme.cfg", + "[Appearance]\nColorScheme = Dark\n\n[Style]\nName = Fusion\n"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Fusion"); + const ThemeConfig cfg = ThemeManager::loadColorSchemeConfig(userDir, systemDir); + + EXPECT_EQ(cfg.colorScheme.toStdString(), std::string("Dark")); +} + +TEST_F(ThemeMigrationTest, DefaultThemeConfigStillCarriesTheShippedStyleWithoutAUserDir) +{ + // themeChangedSlot()/previewPalette() use the unconditional fallback: a + // stock theme with no user directory must still get its shipped styleName, + // even though its colour scheme follows the OS. + const QString userDir = QDir(themesPath).absoluteFilePath("Fusion"); + ASSERT_FALSE(QFileInfo::exists(userDir)); + writeThemeFile(systemThemesPath, "Fusion", "theme.cfg", + "[Appearance]\nColorScheme = Dark\n\n[Style]\nName = Fusion\n"); + + const ThemeConfig cfg = + ThemeManager::loadDefaultThemeConfig(userDir, QDir(systemThemesPath).absoluteFilePath("Fusion")); + + EXPECT_EQ(cfg.styleName.toStdString(), std::string("Fusion")); +} + +TEST_F(ThemeMigrationTest, StyleSheetFallsBackToTheShippedThemeDir) +{ + const QString userDir = QDir(themesPath).absoluteFilePath("Leather"); + ASSERT_TRUE(QDir().mkpath(userDir)); + writeThemeFile(themesPath, "Leather", "fonts.toml", "[Fonts]\nCardTextMaxSize = 14\n"); + writeThemeFile(systemThemesPath, "Leather", "style.css", "shipped-css"); + writeThemeFile(systemThemesPath, "Leather", "style-dark.css", "shipped-dark-css"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Leather"); + + // The scheme-qualified shipped stylesheet wins, else the plain one. + EXPECT_EQ(ThemeManager::themeStyleSheetPath(userDir, systemDir, true).toStdString(), + QDir(systemDir).absoluteFilePath("style-dark.css").toStdString()); + EXPECT_EQ(ThemeManager::themeStyleSheetPath(userDir, systemDir, false).toStdString(), + QDir(systemDir).absoluteFilePath("style.css").toStdString()); +} + +TEST_F(ThemeMigrationTest, StyleSheetPrefersTheUserCopy) +{ + const QString userDir = QDir(themesPath).absoluteFilePath("Leather"); + writeThemeFile(themesPath, "Leather", "style.css", "user-css"); + writeThemeFile(systemThemesPath, "Leather", "style-dark.css", "shipped-dark-css"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Leather"); + + // A user-authored stylesheet is never mixed with the shipped scheme variant. + EXPECT_EQ(ThemeManager::themeStyleSheetPath(userDir, systemDir, true).toStdString(), + QDir(userDir).absoluteFilePath("style.css").toStdString()); +} + +TEST_F(ThemeMigrationTest, StyleSheetIsEmptyWhenNeitherDirectoryHasOne) +{ + const QString userDir = QDir(themesPath).absoluteFilePath("Leather"); + ASSERT_TRUE(QDir().mkpath(userDir)); + writeThemeFile(themesPath, "Leather", "fonts.toml", "[Fonts]\nCardTextMaxSize = 14\n"); + // Only the opposite scheme's stylesheet is shipped, so no fallback matches. + writeThemeFile(systemThemesPath, "Leather", "style-dark.css", "shipped-dark-css"); + + const QString systemDir = QDir(systemThemesPath).absoluteFilePath("Leather"); + + EXPECT_TRUE(ThemeManager::themeStyleSheetPath(userDir, systemDir, false).isEmpty()); +} + // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // the fallback then links the bare gtest library without gtest_main. int main(int argc, char **argv)