Branch data Line data Source code
1 : : #include "ThemeManager.h"
2 : : #include <QApplication>
3 : : #include <QFile>
4 : : #include <QLoggingCategory>
5 : : #include <QSettings>
6 : : #include <QStyleHints>
7 : : #include <QTextStream>
8 : : #include <QDebug>
9 : :
10 : : #include <algorithm>
11 : :
12 [ + - + - : 1 : Q_LOGGING_CATEGORY(lcTheme, "mailjd.theme")
+ - - - ]
13 : :
14 : 90032 : ThemeManager& ThemeManager::instance() {
15 [ + + + - : 90032 : static ThemeManager inst;
+ - - - ]
16 : 90032 : return inst;
17 : : }
18 : :
19 : 32 : ThemeManager::ThemeManager(QObject *parent) : QObject(parent) {}
20 : :
21 : 41 : void ThemeManager::applyTheme(Theme theme) {
22 : 41 : m_currentTheme = theme;
23 [ + - ]: 41 : m_currentVars.clear();
24 : :
25 [ + + + - : 41 : m_currentVars = theme == Light ? lightPalette() : darkPalette();
+ - ]
26 : :
27 [ + - + - ]: 41 : QString qss = loadAndProcessQss(":/themes/main.qss", m_currentVars);
28 [ + - ]: 41 : qApp->setStyleSheet(qss);
29 : :
30 [ + - ]: 41 : emit themeChanged(theme);
31 : 41 : }
32 : :
33 : : // Tokens are documented in docs/DESIGN.md §2 — change them there first.
34 : 48 : QMap<QString, QString> ThemeManager::lightPalette() {
35 : : return {
36 : : {"@bg_main", "#ffffff"},
37 : : {"@bg_sidebar", "#f1f3f5"},
38 : : {"@bg_selected", "#e7f1ff"},
39 : : {"@bg_hover", "#e9ecef"},
40 : : {"@text_primary", "#212529"},
41 : : {"@text_secondary", "#6c757d"},
42 : : {"@text_muted", "#adb5bd"},
43 : : {"@accent", "#007bff"},
44 : : {"@accent_hover", "#0069d9"},
45 : : {"@accent_muted", "#cfe2ff"},
46 : : {"@border_light", "#dee2e6"},
47 : : {"@border_medium", "#ced4da"},
48 : : {"@unread_dot", "#007bff"},
49 : : {"@star_active", "#e8a700"},
50 : : {"@star_inactive", "#ced4da"},
51 : : {"@danger", "#d92d2d"},
52 : : {"@danger_bg", "#fff0ef"},
53 : : {"@success", "#28a745"},
54 : : {"@warning", "#e8a700"},
55 : : {"@link", "#007bff"},
56 : : // Markdown editor/preview palette (MarkdownHighlighter/Renderer)
57 : : {"@md_heading", "#1a73e8"},
58 : : {"@md_heading2", "#1967d2"},
59 : : {"@md_heading3", "#4285f4"},
60 : : {"@md_text", "#24292e"},
61 : : {"@md_quote_fg", "#5f6368"},
62 : : {"@md_quote_bg", "#f8f9fa"},
63 : : {"@md_list_marker", "#4285f4"},
64 : : {"@md_cb_unchecked", "#757575"},
65 : : {"@md_cb_checked", "#4caf50"},
66 : : {"@md_hr", "#b0b0b0"},
67 : : {"@md_code_fg", "#6a9955"},
68 : : {"@md_code_bg", "#f6f8fa"},
69 : : {"@md_inline_code", "#d63384"},
70 : : {"@md_inline_code_bg", "#f0f0f0"},
71 : : {"@md_strike", "#9e9e9e"},
72 : : {"@md_table_border", "#e1e4e8"},
73 : : {"@md_quote_border", "#4285f4"},
74 : : // Shortcut-help overlay (dark canvas in both themes)
75 : : {"@overlay_fg", "#d4d4d4"},
76 : : {"@overlay_key", "#569cd6"},
77 : : {"@overlay_value", "#ce9178"},
78 : : {"@overlay_muted", "#808080"}
79 [ + + - - ]: 2016 : };
80 [ + - + - : 48 : }
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
- - - - ]
81 : :
82 : 13 : QMap<QString, QString> ThemeManager::darkPalette() {
83 : : return {
84 : : {"@bg_main", "#1e1e1e"},
85 : : {"@bg_sidebar", "#252525"},
86 : : {"@bg_selected", "#2c3e50"},
87 : : {"@bg_hover", "#323232"},
88 : : {"@text_primary", "#e0e0e0"},
89 : : {"@text_secondary", "#a0a0a0"},
90 : : {"@text_muted", "#606060"},
91 : : {"@accent", "#3daee9"},
92 : : {"@accent_hover", "#2d9ed9"},
93 : : {"@accent_muted", "#1d4f72"},
94 : : {"@border_light", "#323232"},
95 : : {"@border_medium", "#454545"},
96 : : {"@unread_dot", "#3daee9"},
97 : : {"@star_active", "#ffc107"},
98 : : {"@star_inactive", "#454545"},
99 : : {"@danger", "#e05555"},
100 : : {"@danger_bg", "#3a2424"},
101 : : {"@success", "#4caf50"},
102 : : {"@warning", "#ffc107"},
103 : : {"@link", "#3daee9"},
104 : : // Markdown editor/preview palette (MarkdownHighlighter/Renderer)
105 : : {"@md_heading", "#6cb2ff"},
106 : : {"@md_heading2", "#5a9fe8"},
107 : : {"@md_heading3", "#82b6f2"},
108 : : {"@md_text", "#e0e0e0"},
109 : : {"@md_quote_fg", "#a0a0a0"},
110 : : {"@md_quote_bg", "#2a2a2a"},
111 : : {"@md_list_marker", "#82b6f2"},
112 : : {"@md_cb_unchecked", "#909090"},
113 : : {"@md_cb_checked", "#66bb6a"},
114 : : {"@md_hr", "#555555"},
115 : : {"@md_code_fg", "#6a9955"},
116 : : {"@md_code_bg", "#2a2a2a"},
117 : : {"@md_inline_code", "#e083b6"},
118 : : {"@md_inline_code_bg", "#333333"},
119 : : {"@md_strike", "#777777"},
120 : : {"@md_table_border", "#454545"},
121 : : {"@md_quote_border", "#5a9fe8"},
122 : : // Shortcut-help overlay (dark canvas in both themes)
123 : : {"@overlay_fg", "#d4d4d4"},
124 : : {"@overlay_key", "#569cd6"},
125 : : {"@overlay_value", "#ce9178"},
126 : : {"@overlay_muted", "#808080"}
127 [ + + - - ]: 546 : };
128 [ + - + - : 13 : }
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
- - - - ]
129 : :
130 : 92021 : QString ThemeManager::color(const QString &key) const {
131 [ + + ]: 92021 : if (m_currentVars.isEmpty()) {
132 : : // No theme applied yet (early startup, unit tests) — Light values.
133 [ + + + - : 10578 : static const QMap<QString, QString> fallback = lightPalette();
+ - - - ]
134 [ + - ]: 10578 : return fallback.value(key);
135 : : }
136 [ + - ]: 162886 : return m_currentVars.value(key);
137 : : }
138 : :
139 : : // ── 67.B3: shared programmatic palettes ───────────────────
140 : :
141 : 1643 : QColor ThemeManager::confidenceColor(double confidence) {
142 [ + + ]: 1643 : if (confidence >= 0.97)
143 : 4 : return QColor("#2d7d46"); // green — very confident
144 [ + + ]: 1639 : if (confidence >= 0.93)
145 : 4 : return QColor("#4a90d9"); // blue — confident
146 [ + + ]: 1635 : if (confidence >= 0.88)
147 : 6 : return QColor("#cc8a1e"); // orange — medium
148 [ + + ]: 1629 : if (confidence > 0.0)
149 : 3 : return QColor("#c44f4f"); // red — unsure
150 : 1626 : return QColor("#888888"); // neutral fallback
151 : : }
152 : :
153 : 92 : QStringList ThemeManager::calendarPalette() {
154 : 92 : return {QStringLiteral("#4285F4"), QStringLiteral("#0B8043"),
155 : 92 : QStringLiteral("#D50000"), QStringLiteral("#F4511E"),
156 : 92 : QStringLiteral("#8E24AA"), QStringLiteral("#039BE5"),
157 [ + + - - ]: 920 : QStringLiteral("#E67C73"), QStringLiteral("#616161")};
158 [ + - - - : 828 : }
- - ]
159 : :
160 : 4 : QColor ThemeManager::mutedConfidenceColor(double confidence) {
161 [ + + ]: 4 : if (confidence > 0.8)
162 : 1 : return QColor("#5b8abf"); // muted blue
163 [ + + ]: 3 : if (confidence > 0.6)
164 : 2 : return QColor("#9a8c7a"); // warm grey
165 : 1 : return QColor("#aaaaaa"); // light grey
166 : : }
167 : :
168 : 27 : QList<QColor> ThemeManager::labelPalette() {
169 : : return {QColor(0xCC, 0x33, 0x33), // red
170 : : QColor(0xE8, 0x7A, 0x00), // orange
171 : : QColor(0x33, 0x99, 0x33), // green
172 : : QColor(0x33, 0x66, 0xCC), // blue
173 : : QColor(0x99, 0x33, 0xCC), // purple
174 : : QColor(0xCC, 0x99, 0x00), // gold
175 : 27 : QColor(0x88, 0x88, 0x88)}; // gray
176 : : }
177 : :
178 : 84 : QColor ThemeManager::avatarColor(const QString &key) {
179 : : // Deterministic hue from the (lowercased) address; fixed saturation/
180 : : // lightness keep white initials readable in both themes.
181 [ + - + - ]: 84 : const uint hash = qHash(key.trimmed().toLower());
182 : 84 : return QColor::fromHsl(int(hash % 360u), 140, 110);
183 : : }
184 : :
185 : 7 : QColor ThemeManager::overlayScrimColor() {
186 : 7 : return QColor(0, 0, 0, 180);
187 : : }
188 : :
189 : 7 : QColor ThemeManager::overlayCardColor() {
190 : 7 : return QColor(30, 30, 30, 240);
191 : : }
192 : :
193 : : // ── 67.B2: mode handling ──────────────────────────────────
194 : :
195 : 11 : ThemeManager::Theme ThemeManager::resolveTheme(Mode mode) const {
196 [ + + ]: 11 : if (mode == ModeLight)
197 : 4 : return Light;
198 [ + + ]: 7 : if (mode == ModeDark)
199 : 4 : return Dark;
200 : : #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
201 [ + - ]: 3 : if (qApp) {
202 : 3 : return qApp->styleHints()->colorScheme() == Qt::ColorScheme::Dark
203 [ - + ]: 3 : ? Dark
204 : 3 : : Light;
205 : : }
206 : : #endif
207 : : // Qt < 6.5 has no colorSchemeChanged — System degrades to Light.
208 : 0 : return Light;
209 : : }
210 : :
211 : 6 : void ThemeManager::setMode(Mode mode) {
212 : 6 : applyModeInternal(mode, /*persist=*/true);
213 : 6 : }
214 : :
215 : 2 : void ThemeManager::initFromSettings() {
216 [ + - ]: 2 : QSettings settings;
217 : : const QString stored =
218 [ + - ]: 6 : settings.value(QStringLiteral("appearance/theme"),
219 [ + - ]: 6 : QStringLiteral("system")).toString();
220 [ + - + - ]: 2 : applyModeInternal(modeFromString(stored), /*persist=*/false);
221 : 2 : }
222 : :
223 : 8 : void ThemeManager::applyModeInternal(Mode mode, bool persist) {
224 : 8 : m_mode = mode;
225 [ + + ]: 8 : if (persist) {
226 [ + - ]: 6 : QSettings settings;
227 [ + - ]: 12 : settings.setValue(QStringLiteral("appearance/theme"),
228 [ + - ]: 12 : modeToString(mode));
229 : 6 : }
230 : :
231 : : #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
232 : : // Watch the OS scheme once; the handler only acts in System mode.
233 [ + - + + : 8 : if (qApp && !m_systemWatchActive) {
+ + ]
234 : 3 : m_systemWatchActive = true;
235 [ + - + - ]: 3 : connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged, this,
236 : 6 : [this](Qt::ColorScheme) {
237 [ # # ]: 0 : if (m_mode == ModeSystem)
238 : 0 : applyTheme(resolveTheme(ModeSystem));
239 : 0 : });
240 : : }
241 : : #endif
242 : :
243 : 8 : applyTheme(resolveTheme(mode));
244 : 8 : }
245 : :
246 : 10 : ThemeManager::Mode ThemeManager::modeFromString(const QString &value) {
247 [ + + ]: 10 : if (value.compare(QStringLiteral("dark"), Qt::CaseInsensitive) == 0)
248 : 3 : return ModeDark;
249 [ + + ]: 7 : if (value.compare(QStringLiteral("light"), Qt::CaseInsensitive) == 0)
250 : 3 : return ModeLight;
251 : 4 : return ModeSystem;
252 : : }
253 : :
254 : 9 : QString ThemeManager::modeToString(Mode mode) {
255 [ + + + - ]: 9 : switch (mode) {
256 : 8 : case ModeLight: return QStringLiteral("light");
257 : 6 : case ModeDark: return QStringLiteral("dark");
258 : 2 : case ModeSystem: break;
259 : : }
260 : 2 : return QStringLiteral("system");
261 : : }
262 : :
263 : 45 : QString ThemeManager::loadAndProcessQss(const QString &path, const QMap<QString, QString> &vars) {
264 [ + - ]: 45 : QFile file(path);
265 [ + - + + ]: 45 : if (!file.open(QFile::ReadOnly | QFile::Text)) {
266 [ + - + - : 2 : qCWarning(lcTheme) << "Could not open QSS file" << path;
+ - + - +
+ ]
267 : 1 : return QString();
268 : : }
269 : :
270 [ + - ]: 44 : QTextStream stream(&file);
271 [ + - ]: 44 : QString content = stream.readAll();
272 [ + - ]: 44 : file.close();
273 : :
274 : : // Variable interpolation — longest keys first, so prefix-sharing
275 : : // tokens ("@accent" vs "@accent_hover") never corrupt each other.
276 [ + - ]: 44 : QStringList keys = vars.keys();
277 [ + - + - : 44 : std::sort(keys.begin(), keys.end(),
+ - ]
278 : 9392 : [](const QString &a, const QString &b) {
279 : 9392 : return a.size() > b.size();
280 : : });
281 [ + - + - : 1730 : for (const QString &key : keys) {
+ + ]
282 [ + - + - ]: 1686 : content.replace(key, vars.value(key));
283 : : }
284 : :
285 : 44 : return content;
286 : 45 : }
|