Skip to content

Commit ff1675e

Browse files
committed
feat: add light and dark theme selectors in appearance settings
Let users choose separate light and dark themes, and optionally pin appearance instead of following the system color scheme.
1 parent 1f6de85 commit ff1675e

7 files changed

Lines changed: 139 additions & 40 deletions

File tree

extra/config.jsonc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@
8484
}
8585
},
8686

87-
// The general vicinae theme as well as the system icon theme (used for applications and file icons) can be customized
88-
// according to the system appearance.
89-
// When editing the current theme through the vicinae GUI, it will modify the value that maps to the current system appearance.
87+
// Light and dark themes are stored separately. "appearance" chooses which slot is active:
88+
// "system" follows the OS color scheme, while "light" / "dark" pin that slot.
9089
"theme": {
90+
"appearance": "system",
9191
"light": {
9292
"name": "vicinae-light",
9393
"icon_theme": "auto"

src/server/src/config/config.cpp

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,18 @@ template <typename T> T static merge(const auto &v1, const auto &v2) {
3939
return cfg;
4040
}
4141

42+
bool ConfigValue::followsSystemAppearance() const {
43+
return theme.appearance != "light" && theme.appearance != "dark";
44+
}
45+
46+
bool ConfigValue::isLightAppearance() const {
47+
if (theme.appearance == "light") return true;
48+
if (theme.appearance == "dark") return false;
49+
return QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Light;
50+
}
51+
4252
const SystemThemeConfig &ConfigValue::systemTheme() const {
43-
switch (QGuiApplication::styleHints()->colorScheme()) {
44-
case Qt::ColorScheme::Light:
45-
return theme.light;
46-
default:
47-
return theme.dark;
48-
}
53+
return isLightAppearance() ? theme.light : theme.dark;
4954
}
5055

5156
Manager::Manager(fs::path path) : m_userPath(std::move(path)) {
@@ -122,12 +127,10 @@ bool Manager::mergeEntrypointWithUser(const EntrypointId &id, const ProviderItem
122127
}
123128

124129
bool Manager::mergeThemeConfig(const config::Partial<config::SystemThemeConfig> &cfg) {
125-
switch (QGuiApplication::styleHints()->colorScheme()) {
126-
case Qt::ColorScheme::Light:
130+
if (m_user.isLightAppearance()) {
127131
return mergeWithUser({.theme = config::Partial<config::ThemeConfig>{.light = cfg}});
128-
default:
129-
return mergeWithUser({.theme = config::Partial<config::ThemeConfig>{.dark = cfg}});
130132
}
133+
return mergeWithUser({.theme = config::Partial<config::ThemeConfig>{.dark = cfg}});
131134
}
132135

133136
bool Manager::mergeWithUser(const Partial<ConfigValue> &patch) {

src/server/src/config/config.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ struct SystemThemeConfig {
3737
};
3838

3939
struct ThemeConfig {
40+
std::string appearance = "system";
4041
SystemThemeConfig light;
4142
SystemThemeConfig dark;
4243
};
@@ -47,6 +48,7 @@ template <> struct Partial<SystemThemeConfig> {
4748
};
4849

4950
template <> struct Partial<ThemeConfig> {
51+
std::optional<std::string> appearance;
5052
std::optional<Partial<SystemThemeConfig>> light;
5153
std::optional<Partial<SystemThemeConfig>> dark;
5254
};
@@ -345,6 +347,8 @@ struct ConfigValue {
345347
return std::nullopt;
346348
}
347349

350+
bool followsSystemAppearance() const;
351+
bool isLightAppearance() const;
348352
const SystemThemeConfig &systemTheme() const;
349353
};
350354

src/server/src/qml/general-settings-model.cpp

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@
1414
#include <QGuiApplication>
1515
#include <QIcon>
1616
#include <QLocale>
17+
#include <QStyleHints>
18+
#include <optional>
19+
20+
namespace {
21+
22+
QVariantList wrapSection(const QString &title, const QVariantList &items) {
23+
QVariantMap section;
24+
section[QStringLiteral("title")] = title;
25+
section[QStringLiteral("items")] = items;
26+
return {section};
27+
}
28+
29+
QVariant themeDropdownItem(const std::string &id) {
30+
const auto qid = QString::fromStdString(id);
31+
const auto *theme = ThemeService::instance().findTheme(qid);
32+
if (!theme) return qml::makeDropdownItem(qid, qid);
33+
const auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string()))
34+
: ImageURL::builtin(BuiltinIcon::Vicinae);
35+
return qml::makeDropdownItem(qid, theme->name(), qml::imageSourceFor(iconUrl));
36+
}
37+
38+
QVariantList themeItemsForVariant(std::optional<ThemeVariant> variant) {
39+
QVariantList items;
40+
for (const auto &theme : ThemeService::instance().themes()) {
41+
if (variant && theme->variant() != *variant) continue;
42+
const auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string()))
43+
: ImageURL::builtin(BuiltinIcon::Vicinae);
44+
items.append(qml::makeDropdownItem(theme->id(), theme->name(), qml::imageSourceFor(iconUrl)));
45+
}
46+
const QString title = !variant ? GeneralSettingsModel::tr("Themes")
47+
: *variant == ThemeVariant::Light ? GeneralSettingsModel::tr("Light themes")
48+
: GeneralSettingsModel::tr("Dark themes");
49+
return wrapSection(title, items);
50+
}
51+
52+
} // namespace
1753

1854
GeneralSettingsModel::GeneralSettingsModel(QObject *parent) : QObject(parent) {
1955
m_windowMaterialModel.setSections(windowMaterialItems());
@@ -34,6 +70,14 @@ void GeneralSettingsModel::refreshDynamicModels() {
3470
m_themeItems = items;
3571
m_themeModel.setSections(items);
3672
}
73+
if (auto items = themeItemsForVariant(ThemeVariant::Light); items != m_lightThemeItems) {
74+
m_lightThemeItems = items;
75+
m_lightThemeModel.setSections(items);
76+
}
77+
if (auto items = themeItemsForVariant(ThemeVariant::Dark); items != m_darkThemeItems) {
78+
m_darkThemeItems = items;
79+
m_darkThemeModel.setSections(items);
80+
}
3781
if (auto items = iconThemeItems(); items != m_iconThemeItems) {
3882
m_iconThemeItems = items;
3983
m_iconThemeModel.setSections(items);
@@ -175,15 +219,22 @@ void GeneralSettingsModel::setFontSize(const QString &v) {
175219
if (ok) cfgManager().mergeWithUser({.font = config::Partial<config::FontConfig>{.normal{.size = val}}});
176220
}
177221

178-
using qml::makeDropdownItem;
222+
bool GeneralSettingsModel::followSystemAppearance() const { return cfg().followsSystemAppearance(); }
179223

180-
static QVariantList wrapSection(const QString &title, const QVariantList &items) {
181-
QVariantMap section;
182-
section[QStringLiteral("title")] = title;
183-
section[QStringLiteral("items")] = items;
184-
return {section};
224+
void GeneralSettingsModel::setFollowSystemAppearance(bool follow) {
225+
if (follow) {
226+
cfgManager().mergeWithUser(
227+
{.theme = config::Partial<config::ThemeConfig>{.appearance = std::string{"system"}}});
228+
return;
229+
}
230+
231+
const bool light = QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Light;
232+
cfgManager().mergeWithUser({.theme = config::Partial<config::ThemeConfig>{
233+
.appearance = light ? std::string{"light"} : std::string{"dark"}}});
185234
}
186235

236+
using qml::makeDropdownItem;
237+
187238
QVariantList GeneralSettingsModel::windowMaterialItems() const {
188239
QVariantList items;
189240
items.append(makeDropdownItem(QStringLiteral("none"), tr("None")));
@@ -206,24 +257,13 @@ void GeneralSettingsModel::selectWindowMaterial(const QString &id) {
206257
{.launcherWindow = config::Partial<config::WindowConfig>{.material = id.toStdString()}});
207258
}
208259

209-
QVariantList GeneralSettingsModel::themeItems() const {
210-
QVariantList items;
211-
for (const auto &theme : ThemeService::instance().themes()) {
212-
auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string()))
213-
: ImageURL::builtin(BuiltinIcon::Vicinae);
214-
items.append(makeDropdownItem(theme->id(), theme->name(), qml::imageSourceFor(iconUrl)));
215-
}
216-
return wrapSection(tr("Themes"), items);
217-
}
260+
QVariantList GeneralSettingsModel::themeItems() const { return themeItemsForVariant(std::nullopt); }
218261

219-
QVariant GeneralSettingsModel::currentTheme() const {
220-
auto id = QString::fromStdString(cfg().systemTheme().name);
221-
auto *theme = ThemeService::instance().findTheme(id);
222-
if (!theme) return makeDropdownItem(id, id);
223-
auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string()))
224-
: ImageURL::builtin(BuiltinIcon::Vicinae);
225-
return makeDropdownItem(id, theme->name(), qml::imageSourceFor(iconUrl));
226-
}
262+
QVariant GeneralSettingsModel::currentTheme() const { return themeDropdownItem(cfg().systemTheme().name); }
263+
264+
QVariant GeneralSettingsModel::currentLightTheme() const { return themeDropdownItem(cfg().theme.light.name); }
265+
266+
QVariant GeneralSettingsModel::currentDarkTheme() const { return themeDropdownItem(cfg().theme.dark.name); }
227267

228268
QVariantList GeneralSettingsModel::fontItems() const {
229269
QVariantList items;
@@ -292,6 +332,20 @@ void GeneralSettingsModel::selectTheme(const QString &id) {
292332
cfgManager().mergeThemeConfig({.name = id.toStdString()});
293333
}
294334

335+
void GeneralSettingsModel::selectLightTheme(const QString &id) {
336+
config::Partial<config::ThemeConfig> theme{
337+
.light = config::Partial<config::SystemThemeConfig>{.name = id.toStdString()}};
338+
if (!followSystemAppearance()) theme.appearance = "light";
339+
cfgManager().mergeWithUser({.theme = theme});
340+
}
341+
342+
void GeneralSettingsModel::selectDarkTheme(const QString &id) {
343+
config::Partial<config::ThemeConfig> theme{
344+
.dark = config::Partial<config::SystemThemeConfig>{.name = id.toStdString()}};
345+
if (!followSystemAppearance()) theme.appearance = "dark";
346+
cfgManager().mergeWithUser({.theme = theme});
347+
}
348+
295349
void GeneralSettingsModel::selectFont(const QString &id) {
296350
cfgManager().mergeWithUser(
297351
{.font = config::Partial<config::FontConfig>{.normal = {.family = id.toStdString()}}});

src/server/src/qml/general-settings-model.hpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,22 @@ class GeneralSettingsModel : public QObject {
3333
Q_PROPERTY(
3434
bool nativeTextRendering READ nativeTextRendering WRITE setNativeTextRendering NOTIFY configChanged)
3535
Q_PROPERTY(QString fontSize READ fontSize WRITE setFontSize NOTIFY configChanged)
36+
Q_PROPERTY(bool followSystemAppearance READ followSystemAppearance WRITE setFollowSystemAppearance NOTIFY
37+
configChanged)
3638
Q_PROPERTY(CompletionModel *windowMaterialModel READ windowMaterialModel CONSTANT)
3739
Q_PROPERTY(QVariant currentWindowMaterial READ currentWindowMaterial NOTIFY configChanged)
3840
Q_PROPERTY(CompletionModel *themeModel READ themeModel CONSTANT)
41+
Q_PROPERTY(CompletionModel *lightThemeModel READ lightThemeModel CONSTANT)
42+
Q_PROPERTY(CompletionModel *darkThemeModel READ darkThemeModel CONSTANT)
3943
Q_PROPERTY(CompletionModel *fontModel READ fontModel CONSTANT)
4044
Q_PROPERTY(CompletionModel *iconThemeModel READ iconThemeModel CONSTANT)
4145
Q_PROPERTY(CompletionModel *faviconServiceModel READ faviconServiceModel CONSTANT)
4246
Q_PROPERTY(CompletionModel *keybindingSchemeModel READ keybindingSchemeModel CONSTANT)
4347
Q_PROPERTY(CompletionModel *languageModel READ languageModel CONSTANT)
4448
Q_PROPERTY(QVariant currentLanguage READ currentLanguage NOTIFY configChanged)
4549
Q_PROPERTY(QVariant currentTheme READ currentTheme NOTIFY configChanged)
50+
Q_PROPERTY(QVariant currentLightTheme READ currentLightTheme NOTIFY configChanged)
51+
Q_PROPERTY(QVariant currentDarkTheme READ currentDarkTheme NOTIFY configChanged)
4652
Q_PROPERTY(QVariant currentFont READ currentFont NOTIFY configChanged)
4753
Q_PROPERTY(QVariant currentIconTheme READ currentIconTheme NOTIFY configChanged)
4854
Q_PROPERTY(QVariant currentFaviconService READ currentFaviconService NOTIFY configChanged)
@@ -95,9 +101,13 @@ class GeneralSettingsModel : public QObject {
95101
void setInputServerEnabled(bool v);
96102
QString fontSize() const;
97103
void setFontSize(const QString &v);
104+
bool followSystemAppearance() const;
105+
void setFollowSystemAppearance(bool v);
98106

99107
CompletionModel *windowMaterialModel() { return &m_windowMaterialModel; }
100108
CompletionModel *themeModel() { return &m_themeModel; }
109+
CompletionModel *lightThemeModel() { return &m_lightThemeModel; }
110+
CompletionModel *darkThemeModel() { return &m_darkThemeModel; }
101111
CompletionModel *fontModel() { return &m_fontModel; }
102112
CompletionModel *iconThemeModel() { return &m_iconThemeModel; }
103113
CompletionModel *faviconServiceModel() { return &m_faviconServiceModel; }
@@ -109,12 +119,16 @@ class GeneralSettingsModel : public QObject {
109119

110120
QVariant currentLanguage() const;
111121
QVariant currentTheme() const;
122+
QVariant currentLightTheme() const;
123+
QVariant currentDarkTheme() const;
112124
QVariant currentFont() const;
113125
QVariant currentIconTheme() const;
114126
QVariant currentFaviconService() const;
115127
QVariant currentKeybindingScheme() const;
116128

117129
Q_INVOKABLE void selectTheme(const QString &id);
130+
Q_INVOKABLE void selectLightTheme(const QString &id);
131+
Q_INVOKABLE void selectDarkTheme(const QString &id);
118132
Q_INVOKABLE void selectFont(const QString &id);
119133
Q_INVOKABLE void selectIconTheme(const QString &id);
120134
Q_INVOKABLE void selectFaviconService(const QString &id);
@@ -139,11 +153,15 @@ class GeneralSettingsModel : public QObject {
139153

140154
CompletionModel m_windowMaterialModel{this};
141155
CompletionModel m_themeModel{this};
156+
CompletionModel m_lightThemeModel{this};
157+
CompletionModel m_darkThemeModel{this};
142158
CompletionModel m_fontModel{this};
143159
CompletionModel m_iconThemeModel{this};
144160
CompletionModel m_faviconServiceModel{this};
145161
CompletionModel m_keybindingSchemeModel{this};
146162
CompletionModel m_languageModel{this};
147163
QVariantList m_themeItems;
164+
QVariantList m_lightThemeItems;
165+
QVariantList m_darkThemeItems;
148166
QVariantList m_iconThemeItems;
149167
};

src/server/src/qml/qml/AppearanceSettingsPage.qml

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,31 @@ Flickable {
3737

3838
SettingsGroup {
3939
SettingsRow {
40-
label: qsTr("Theme")
40+
label: qsTr("Follow system appearance")
41+
description: qsTr("Automatically switch between the light and dark themes based on your system appearance.")
42+
SettingsToggle {
43+
checked: root.model.followSystemAppearance
44+
onToggled: checked => root.model.followSystemAppearance = checked
45+
}
46+
}
47+
48+
SettingsRow {
49+
label: qsTr("Light theme")
50+
SearchableDropdown {
51+
width: parent.width
52+
model: root.model.lightThemeModel
53+
currentItem: root.model.currentLightTheme
54+
onActivated: item => root.model.selectLightTheme(item.id)
55+
}
56+
}
57+
58+
SettingsRow {
59+
label: qsTr("Dark theme")
4160
SearchableDropdown {
4261
width: parent.width
43-
model: root.model.themeModel
44-
currentItem: root.model.currentTheme
45-
onActivated: item => root.model.selectTheme(item.id)
62+
model: root.model.darkThemeModel
63+
currentItem: root.model.currentDarkTheme
64+
onActivated: item => root.model.selectDarkTheme(item.id)
4665
}
4766
}
4867

src/server/src/server.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ int startServer(const ServerLaunchOptions &launchOpts) {
512512

513513
QObject::connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, [&]() {
514514
auto &value = cfgService->value();
515+
if (!value.followsSystemAppearance()) return;
515516
auto &theme = value.systemTheme();
516517

517518
if (theme.iconTheme != "auto") {

0 commit comments

Comments
 (0)