diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index c4b81de86..4eab69ee5 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -11,10 +11,6 @@ #include #include -static constexpr int MIN_FONT_SIZE = 6; -static constexpr int MAX_FONT_SIZE = 72; -static constexpr int CARD_TEXT_MIN_SIZE = 1; -static constexpr int CARD_TEXT_MAX_SIZE = 12; static constexpr int FALLBACK_FONT_SIZE = 10; static const QMap APP_FONT_DESCRIPTIONS = { @@ -63,20 +59,23 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) if (role == AppFont::CardText) { auto *minSpin = new QSpinBox(this); - minSpin->setRange(CARD_TEXT_MIN_SIZE, MAX_FONT_SIZE); + 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(MIN_FONT_SIZE, MAX_FONT_SIZE); + 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. - connect(minSpin, &QSpinBox::valueChanged, this, [maxSpin](int value) { maxSpin->setMinimum(value); }); + // 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); @@ -86,7 +85,7 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) } 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(); }); @@ -121,29 +120,17 @@ void FontGridWidget::loadFonts(const FontConfig &cfg) // 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(CARD_TEXT_MIN_SIZE, MAX_FONT_SIZE); - row.sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE); - - // The max spin is a size-only override. Without CardTextMaxSize it - // follows the CardText family size, and without CardText at all it - // must match cardTextMaxSize()'s default rather than the system - // startup size, which currentFontConfig() would otherwise bake in. - const int fallbackMax = cfg.fonts.contains(role) ? font.pointSize() : CARD_TEXT_MAX_SIZE; - int maxSize = cfg.cardTextMaxSize > 0 ? cfg.cardTextMaxSize : fallbackMax; - if (maxSize <= 0) { - maxSize = CARD_TEXT_MAX_SIZE; - } - int minSize = cfg.cardTextMinSize > 0 ? cfg.cardTextMinSize : themeManager->cardTextMinSize(); - minSize = qBound(CARD_TEXT_MIN_SIZE, minSize, MAX_FONT_SIZE); - maxSize = qBound(MIN_FONT_SIZE, maxSize, MAX_FONT_SIZE); - if (maxSize < minSize) { - maxSize = minSize; - } + 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(maxSize); - row.minSpin->setValue(minSize); + row.sizeSpin->setValue(cfg.resolvedCardTextMaxSize()); + row.minSpin->setValue(cfg.resolvedCardTextMinSize()); continue; } @@ -154,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); } diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index a75330db4..5562fd905 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -308,7 +309,28 @@ QPalette PaletteConfig::apply(QPalette base) const bool FontConfig::hasFonts() const { - return !fonts.isEmpty() || cardTextMinSize != 1 || cardTextMaxSize > 0; + 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 @@ -334,7 +356,7 @@ QString FontConfig::toToml() const entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize); } - if (cardTextMinSize != 1) { + if (cardTextMinSize != CARD_TEXT_MIN_SIZE) { entries += QString("%1 = %2\n").arg(QStringLiteral("CardTextMinSize"), -20).arg(cardTextMinSize); } diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index ebad7ef46..9a17089e2 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -92,12 +92,30 @@ struct PaletteConfig */ 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 = 1; + 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 && cardTextMinSize == rhs.cardTextMinSize && cardTextMaxSize == rhs.cardTextMaxSize; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index e23284393..39824662a 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -52,6 +52,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; @@ -235,7 +239,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 Fusion's 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) { @@ -401,6 +413,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 the shipped scheme there would override the OS preference on a + // fresh profile (Fusion ships Dark). + 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()) { @@ -527,7 +593,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); } @@ -705,25 +774,16 @@ QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const int ThemeManager::cardTextMaxSize() const { - if (currentFonts.cardTextMaxSize > 0) { - return currentFonts.cardTextMaxSize; - } - const auto it = currentFonts.fonts.constFind(AppFont::CardText); - if (it != currentFonts.fonts.constEnd()) { - const int size = it.value().pointSize(); - if (size > 0) { - return size; - } - } - return 12; + // 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 { - if (currentFonts.cardTextMinSize > 0) { - return currentFonts.cardTextMinSize; - } - return 1; + return currentFonts.resolvedCardTextMinSize(); } void ThemeManager::themeChangedSlot() @@ -732,31 +792,24 @@ void ThemeManager::themeChangedSlot() 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); @@ -799,7 +852,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 0cd44125e..d4d6af0dc 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); @@ -169,10 +185,11 @@ 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: stored CardTextMaxSize, else the CardText - * point size, else 12. */ + /** @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: stored CardTextMinSize, else 1. */ + /** @brief CardText shrink-to-fit floor resolved the same way. */ int cardTextMinSize() const; QBrush &getBgBrush(Role zone); diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index 3be3c0bf2..aa97f8da0 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -532,23 +532,46 @@ static bool seedCardTextMaxIntoThemeDir(const QString &themeDirPath, int maxSize // 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\n").arg(QStringLiteral("CardTextMaxSize"), -20).arg(maxSize); + const QString entry = QString("%1 = %2").arg(QStringLiteral("CardTextMaxSize"), -20).arg(maxSize); QString out; if (existing.isEmpty()) { out = "[Fonts]\n" + entry + "\n"; - } else if (existing.contains("[Fonts]", Qt::CaseInsensitive)) { - out = existing; - if (!out.endsWith('\n')) { - out += '\n'; - } - out += entry; } else { out = existing; if (!out.endsWith('\n')) { out += '\n'; } - out += "\n[Fonts]\n" + entry + "\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); diff --git a/tests/font_config_test.cpp b/tests/font_config_test.cpp index 93c9cefb6..ed6e3923c 100644 --- a/tests/font_config_test.cpp +++ b/tests/font_config_test.cpp @@ -282,6 +282,42 @@ TEST(FontConfigTest, CardTextMaxSizeWithoutFamily) 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; diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp index 4fea3c085..02aeb236a 100644 --- a/tests/settings/settings_migration_test.cpp +++ b/tests/settings/settings_migration_test.cpp @@ -1160,6 +1160,54 @@ TEST_F(SettingsMigrationTest, AppearanceMaxFontSize_MergesWithOtherRoles) 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)); diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index d77c2f62d..1d1c74303 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -47,13 +47,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)); } }; @@ -149,6 +155,133 @@ TEST_F(ThemeMigrationTest, IsIdempotent) EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css")); } +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. Fusion + // ships 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)