Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports. * jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls. * Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results. * Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable. * Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate. * Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do. * Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence. * Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags. * Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check. * Rugnux: Clear error messages when a data set needs more GPU or host memory than is available. Reviewed-on: #83 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
233 lines
10 KiB
C++
233 lines
10 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "ViewerTheme.h"
|
|
|
|
#include <QApplication>
|
|
#include <QChart>
|
|
#include <QPalette>
|
|
#include <QSettings>
|
|
#include <QStyle>
|
|
#include <QStyleHints>
|
|
#include <QWidget>
|
|
|
|
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<QString()> &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);
|
|
}
|
|
|
|
QColor AccentFillColor() {
|
|
return dark_theme ? QColor(0x2F, 0x5A, 0x8F) : QColor(0x1F, 0x3A, 0x5F);
|
|
}
|
|
|
|
QColor AccentFillHoverColor() {
|
|
return dark_theme ? QColor(0x3B, 0x6B, 0xA5) : QColor(0x16, 0x31, 0x4F);
|
|
}
|
|
|
|
QString HeroButtonStyle() {
|
|
// The armed (checked) state stays filled like its sibling - a full coral fill read as a different
|
|
// kind of button - and is marked by a coral outline instead.
|
|
return QString(
|
|
"QPushButton { background-color:%1; color:white; border:2px solid transparent;"
|
|
" border-radius:3px; padding:3px 8px; } QPushButton:hover { background-color:%2; }"
|
|
" QPushButton:checked { border-color:#FA7268; } QPushButton:disabled { background-color:%3; }")
|
|
.arg(AccentFillColor().name(), AccentFillHoverColor().name(), dark_theme ? "#4A4F73" : "#9aa6b3");
|
|
}
|
|
|
|
QColor GuideLineColor() {
|
|
return dark_theme ? QColor(0xFA, 0x72, 0x68, 150) : QColor(200, 0, 0, 150);
|
|
}
|
|
|
|
QColor PlotSeriesColor(int index) {
|
|
// The first two are the theme's own: a lighter navy than the headings on light (the heading navy
|
|
// drawn this thick reads as black) and the headings' light blue on dark; then the amber of the
|
|
// caution text. The rest are softened hues of the same weight. Against the plot area that is
|
|
// 3.2-5.7:1 on light (3:1 is the floor for a graphic) and 8-11:1 on dark. Coral and red are
|
|
// left out: they mark the hover lines, the current image and the alerts.
|
|
static const QColor light[] = {
|
|
QColor(0x3A, 0x6E, 0xA5), QColor(0xB8, 0x86, 0x0B), QColor(0x16, 0x8A, 0x80),
|
|
QColor(0x8E, 0x4B, 0xA0), QColor(0x5B, 0x8C, 0x1A), QColor(0xC2, 0x2E, 0x6E),
|
|
QColor(0x6B, 0x7A, 0x90), QColor(0x9C, 0x6A, 0x3A),
|
|
};
|
|
static const QColor dark[] = {
|
|
QColor(0x9D, 0xBB, 0xE3), QColor(0xE9, 0xC4, 0x6A), QColor(0x5F, 0xD4, 0xC4),
|
|
QColor(0xC9, 0xA0, 0xDC), QColor(0xA3, 0xD9, 0x7A), QColor(0xF4, 0x9A, 0xC1),
|
|
QColor(0xAE, 0xB8, 0xC8), QColor(0xD9, 0xB3, 0x8C),
|
|
};
|
|
constexpr int n = static_cast<int>(sizeof(light) / sizeof(light[0]));
|
|
const int i = ((index % n) + n) % n;
|
|
return dark_theme ? dark[i] : light[i];
|
|
}
|
|
|
|
QColor PlotMarkerColor() {
|
|
return dark_theme ? QColor(0xFA, 0x72, 0x68) : QColor(0xD9, 0x49, 0x3A);
|
|
}
|
|
|
|
QColor AlertTextColor() {
|
|
return dark_theme ? QColor(0xFF, 0x7B, 0x72) : QColor(0xC0, 0x39, 0x2B);
|
|
}
|
|
|
|
QColor CautionTextColor() {
|
|
return dark_theme ? QColor(0xE9, 0xC4, 0x6A) : QColor(0xB8, 0x86, 0x0B);
|
|
}
|
|
|
|
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 StyleChartAxes(QChart *chart) {
|
|
const QColor grid = dark_theme ? QColor(0x36, 0x3A, 0x66) : QColor(0xF3, 0xD9, 0xD4);
|
|
for (auto *axis: chart->axes()) {
|
|
axis->setGridLineColor(grid);
|
|
if (dark_theme) // the frame too: Qt's is a light grey that outshines the data
|
|
axis->setLinePenColor(QColor(0x5A, 0x5F, 0x8C));
|
|
}
|
|
}
|
|
|
|
void StyleChart(QChart *chart) {
|
|
chart->setTheme(dark_theme ? QChart::ChartThemeDark : QChart::ChartThemeLight);
|
|
if (dark_theme)
|
|
chart->setBackgroundBrush(QApplication::palette().color(QPalette::Base));
|
|
}
|