// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "ViewerTheme.h" #include #include #include #include #include #include #include namespace { bool dark_theme = false; const QColor DARK_WINDOW(0x1A, 0x1D, 0x3A); // a deep indigo const QColor DARK_TEXT(0xE3, 0xE7, 0xEC); bool SystemIsDark() { #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) return QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark; #else return false; #endif } // Text roles are set for the active and inactive groups only, so disabled text keeps the greyed // colour the palette derived for it. void SetTextColor(QPalette &pal, const QColor &color) { for (auto group: {QPalette::Active, QPalette::Inactive}) for (auto role: {QPalette::WindowText, QPalette::Text, QPalette::ButtonText, QPalette::ToolTipText}) pal.setColor(group, role, color); } // Salmon panels, white entry fields that fall back to salmon when disabled, and a navy accent for // selections and progress-bar chunks. It starts from Fusion's own (light) palette, not the system // one, which is dark under a dark OS theme; the text is set to black because on macOS the palette's // text colours come from the platform theme and are grey. QPalette LightPalette() { QPalette pal = QApplication::style()->standardPalette(); pal.setColor(QPalette::Window, QColor(255, 235, 230)); pal.setColor(QPalette::Base, Qt::white); pal.setColor(QPalette::AlternateBase, QColor(255, 245, 242)); pal.setColor(QPalette::Button, QColor(255, 235, 230)); pal.setColor(QPalette::Disabled, QPalette::Base, QColor(255, 235, 230)); pal.setColor(QPalette::Highlight, QColor(0x1f, 0x3a, 0x5f)); pal.setColor(QPalette::HighlightedText, Qt::white); SetTextColor(pal, Qt::black); return pal; } // The same layout in the dark: indigo panels, entry fields darker still, and a lighter navy for // selections so they stand out from the panels. QPalette(button) derives the bevel shades and the // disabled text from the one colour. QPalette DarkPalette() { QPalette pal(DARK_WINDOW); pal.setColor(QPalette::Window, DARK_WINDOW); pal.setColor(QPalette::Base, QColor(0x12, 0x14, 0x2B)); pal.setColor(QPalette::AlternateBase, QColor(0x24, 0x28, 0x4A)); pal.setColor(QPalette::Button, DARK_WINDOW); pal.setColor(QPalette::Disabled, QPalette::Base, DARK_WINDOW); pal.setColor(QPalette::ToolTipBase, QColor(0x2A, 0x2E, 0x55)); pal.setColor(QPalette::Highlight, QColor(0x2F, 0x5A, 0x8F)); pal.setColor(QPalette::HighlightedText, Qt::white); pal.setColor(QPalette::Link, QColor(0x8A, 0xB4, 0xF8)); pal.setColor(QPalette::PlaceholderText, QColor(0x80, 0x84, 0xA8)); SetTextColor(pal, DARK_TEXT); // The derived disabled text is nearly the panel colour, so a greyed-out field looked empty. // A muted lavender still reads (about 4.8:1 on the panels, against 13:1 for normal text). for (auto role: {QPalette::WindowText, QPalette::Text, QPalette::ButtonText}) pal.setColor(QPalette::Disabled, role, QColor(0x85, 0x8A, 0xAE)); return pal; } void ApplyTheme() { const ThemeChoice choice = LoadThemeChoice(); #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) // Once the application has asked for a scheme, Qt reports that scheme instead of the desktop's, // so the request is withdrawn before System looks at what the desktop is using. if (choice == ThemeChoice::System) QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Unknown); #endif dark_theme = choice == ThemeChoice::Dark || (choice == ThemeChoice::System && SystemIsDark()); #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) // The window frame is drawn by the OS (macOS, Windows), not by Qt, and follows the desktop's // scheme unless the application asks for one - as apps with their own theme switch do, so the // title bar matches the window under it. if (choice != ThemeChoice::System) QGuiApplication::styleHints()->setColorScheme(dark_theme ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light); #endif QApplication::setPalette(dark_theme ? DarkPalette() : LightPalette()); // With an application stylesheet in place, widgets take their palette when they are polished // and do not see a later palette change; setting the stylesheet again re-polishes them all. // Fusion fills QGroupBox interiors with a flat colour of its own; transparent makes them show // the window background like the rest of the UI (the dock resize handles are drawn by // SeparatorHandleStyle, not the stylesheet). qApp->setStyleSheet("QGroupBox { background-color: transparent; }"); emit ThemeNotifier::Instance()->changed(); } } ThemeNotifier *ThemeNotifier::Instance() { static ThemeNotifier notifier; return ¬ifier; } ThemeChoice LoadThemeChoice() { const QString value = QSettings("PSI", "jfjoch_viewer").value("theme", "system").toString(); if (value == "light") return ThemeChoice::Light; if (value == "dark") return ThemeChoice::Dark; return ThemeChoice::System; } void SetThemeChoice(ThemeChoice choice) { const char *value = "system"; if (choice == ThemeChoice::Light) value = "light"; if (choice == ThemeChoice::Dark) value = "dark"; QSettings("PSI", "jfjoch_viewer").setValue("theme", value); ApplyTheme(); } void InitTheme() { ApplyTheme(); #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) // Re-applied on any desktop scheme change, not only under System: the palette set here must also // survive the platform reacting to the change with a palette of its own. QObject::connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, ThemeNotifier::Instance(), [] { ApplyTheme(); }); #endif } bool DarkTheme() { return dark_theme; } void SetThemedStyleSheet(QWidget *widget, const std::function &style) { widget->setStyleSheet(style()); QObject::connect(ThemeNotifier::Instance(), &ThemeNotifier::changed, widget, [widget, style] { widget->setStyleSheet(style()); }); } QColor AccentTextColor() { return dark_theme ? QColor(0x9D, 0xBB, 0xE3) : QColor(0x1F, 0x3A, 0x5F); } QColor IconInkColor() { return dark_theme ? DARK_TEXT : QColor(0x2B, 0x2B, 0x2B); } QString SliderStyle() { // On the dark panels the navy handle would vanish, so it takes the light blue of the headings. const QString groove = dark_theme ? "#363A66" : "#F3D9D4"; const QString handle = dark_theme ? "#9DBBE3" : "#1F3A5F"; const QString handle_hover = dark_theme ? "#BCD2EE" : "#16314F"; return QString( "QSlider::groove:horizontal { height:6px; background:%1; border-radius:3px; }" "QSlider::sub-page:horizontal { background:#FA7268; border-radius:3px; }" "QSlider::handle:horizontal { background:%2; width:14px; margin:-5px 0; border-radius:7px; }" "QSlider::handle:horizontal:hover { background:%3; }").arg(groove, handle, handle_hover); } void StyleChart(QChart *chart) { chart->setTheme(dark_theme ? QChart::ChartThemeDark : QChart::ChartThemeLight); if (dark_theme) chart->setBackgroundBrush(QApplication::palette().color(QPalette::Base)); }