Compare commits

...

1 commit

Author SHA1 Message Date
Lukas Brübach
3def6b00ef [Fonts] Add AppFont roles and FontConfig fonts.toml round-trip 2026-11-02 23:14:56 +01:00
4 changed files with 399 additions and 2 deletions

View file

@ -304,4 +304,150 @@ QPalette PaletteConfig::apply(QPalette base) const
}
return base;
}
}
bool FontConfig::hasFonts() const
{
return !fonts.isEmpty();
}
QString FontConfig::toToml() const
{
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
QString entries;
for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) {
const char *roleName = roleEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
QString family = it.value().family().trimmed();
int pointSize = it.value().pointSize();
if (family.isEmpty() || pointSize <= 0) {
continue;
}
entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize);
}
if (entries.isEmpty()) {
return {};
}
return "[Fonts]\n" + entries + "\n";
}
QString FontConfig::fileName()
{
return "fonts.toml";
}
FontConfig FontConfig::fromFile(const QString &filePath)
{
FontConfig cfg;
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return cfg;
}
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
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();
// Strip inline comments if preceded by whitespace
for (int i = 1; i < value.size(); ++i) {
if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) {
value = value.left(i).trimmed();
break;
}
}
// Strip surrounding quotes, if any
if (value.size() >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) {
value = value.mid(1, value.size() - 2).trimmed();
}
int comma = value.lastIndexOf(',');
if (comma < 0) {
continue;
}
QString family = value.left(comma).trimmed();
// Tolerate a partially-quoted family, e.g. "Inter", 10
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;
}
if (key.startsWith("AppFont::")) {
key = key.mid(QStringLiteral("AppFont::").size());
}
int roleInt = roleEnum.keyToValue(key.toUtf8().constData());
if (roleInt < 0) {
continue;
}
// Canonical family + point size only: a fresh QFont carries default
// (non-bold, non-italic) weight/style, so nothing caller-side leaks in.
// No validation against installed fonts; Qt substitution applies.
QFont font(family);
font.setPointSize(pointSize);
cfg.fonts[static_cast<AppFont::Role>(roleInt)] = font;
}
return cfg;
}
FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
{
if (themeDirPath.isEmpty()) {
return {};
}
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
}

View file

@ -2,6 +2,7 @@
#define COCKATRICE_THEME_CONFIG_H
#include <QColor>
#include <QFont>
#include <QMap>
#include <QPalette>
#include <QSharedPointer>
@ -23,6 +24,23 @@ enum Role
Q_ENUM_NS(Role)
} // namespace AppColor
// Application-specific font roles, one configurable family + point size per
// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per
// theme, not split by light/dark). Only family and point size are stored;
// weight/italic are stripped on load and stay caller-side concerns.
namespace AppFont
{
Q_NAMESPACE
enum Role
{
InterfaceDefault,
ChatLog,
CardText,
DeckList,
};
Q_ENUM_NS(Role)
} // namespace AppFont
struct ThemeConfig
{
QString colorScheme;
@ -60,4 +78,33 @@ struct PaletteConfig
QPalette apply(QPalette base) const;
};
#endif // COCKATRICE_THEME_CONFIG_H
/** @brief Per-purpose font configuration, stored as family + point size only.
*
* Serialized to a shared fonts.toml file with a [Fonts] section, e.g.
* @code
* [Fonts]
* InterfaceDefault = "Inter, 10"
* @endcode
*/
struct FontConfig
{
QMap<AppFont::Role, QFont> fonts;
bool operator==(const FontConfig &rhs) const
{
return fonts == rhs.fonts;
}
bool operator!=(const FontConfig &rhs) const
{
return !(*this == rhs);
}
bool hasFonts() const;
QString toToml() const;
static QString fileName();
static FontConfig fromFile(const QString &filePath);
static FontConfig fromThemeDir(const QString &themeDirPath);
};
#endif // COCKATRICE_THEME_CONFIG_H

View file

@ -412,6 +412,15 @@ cockatrice_add_test(
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
font_config_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
font_config_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
loader_local_matching_test

195
tests/font_config_test.cpp Normal file
View file

@ -0,0 +1,195 @@
#include "interface/theme_config.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFont>
#include <QIODevice>
#include <QString>
#include <QStringLiteral>
#include <QTemporaryDir>
#include <QTextStream>
#include <gtest/gtest.h>
namespace
{
// Writes content to a file inside tmp, creating parents as needed.
QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content)
{
const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath);
EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath()));
QFile file(filePath);
EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
QTextStream out(&file);
out << content;
file.close();
return filePath;
}
QFont makeFont(const QString &family, int pointSize)
{
QFont font(family);
font.setPointSize(pointSize);
return font;
}
} // namespace
TEST(FontConfigTest, FileNameIsFontsToml)
{
EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml"));
}
TEST(FontConfigTest, EmptyByDefault)
{
FontConfig cfg;
EXPECT_FALSE(cfg.hasFonts());
EXPECT_TRUE(cfg.toToml().isEmpty());
EXPECT_EQ(cfg, FontConfig());
EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml"));
EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString()));
}
TEST(FontConfigTest, RoundTripPreservesFamilyAndSize)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9);
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12);
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::InterfaceDefault].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10);
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9);
EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif"));
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11);
EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto"));
EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12);
}
TEST(FontConfigTest, RoundTripStripsWeightAndItalic)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
QFont styled("Inter");
styled.setPointSize(10);
styled.setBold(true);
styled.setItalic(true);
FontConfig cfg;
cfg.fonts[AppFont::ChatLog] = styled;
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10);
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold());
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic());
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10));
}
TEST(FontConfigTest, ToTomlFormat)
{
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
const QString toml = cfg.toToml();
EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]")));
EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault")));
EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\"")));
}
TEST(FontConfigTest, MalformedLinesAreSkipped)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml",
"# a comment\n"
"; another comment\n"
"\n"
"StrayBeforeSection = \"Inter, 10\"\n"
"\n"
"[Other]\n"
"ChatLog = \"ShouldBeIgnored, 10\"\n"
"\n"
"[Fonts]\n"
"InterfaceDefault = \"Inter, 10\"\n"
"ChatLog = \"JetBrains Mono\", 9 # inline comment\n"
"CardText = Noto Serif, 11\n"
"AppFont::DeckList = \"Roboto, 12\"\n"
"UnknownRole = \"Inter, 10\"\n"
"QPalette::Window = \"Inter, 10\"\n"
"NoComma = \"Inter\"\n"
"EmptyFamily = \", 10\"\n"
"ZeroSize = \"Inter, 0\"\n"
"NegativeSize = \"Inter, -3\"\n"
"NotANumber = \"Inter, big\"\n"
"NotAnEntry\n");
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.hasFonts());
EXPECT_EQ(loaded.fonts.size(), 4);
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9));
EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11));
EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12));
}
TEST(FontConfigTest, FromThemeDirReadsFontsToml)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme");
ASSERT_TRUE(QDir().mkpath(themeDir));
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromThemeDir(themeDir);
EXPECT_EQ(loaded, cfg);
// A theme directory without fonts.toml loads as empty.
const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme");
ASSERT_TRUE(QDir().mkpath(bareDir));
EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts());
}
TEST(FontConfigTest, InequalityComparesRoles)
{
FontConfig left;
left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
FontConfig right = left;
EXPECT_EQ(left, right);
EXPECT_FALSE(left != right);
right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11);
EXPECT_NE(left, right);
right = left;
right.fonts[AppFont::ChatLog] = makeFont("Inter", 10);
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)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}