From 8752c376175b4201c4c760769baa48fd62210f86 Mon Sep 17 00:00:00 2001 From: Gabriel El Faouzi Date: Thu, 6 Aug 2026 05:48:47 +0200 Subject: [PATCH 1/3] Dark mode for Qt6 on Windows Fusion derives a complete palette from the OS color scheme, so dark mode needs no colors of its own: it is enough not to force the light palette when Windows reports dark. Other platforms stay pinned to light. Moves the theme code out of create_application into modules/util/ui/theme.py. Rebased onto master by dxqb: the theme block now lives in create_application rather than train_ui_qt.py's main(), and _BASE_STYLESHEET carries master's QToolButton rules. Co-Authored-By: Claude Opus 5 (1M context) --- modules/util/ui/pyside6_util.py | 35 +++------------------------ modules/util/ui/theme.py | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 32 deletions(-) create mode 100644 modules/util/ui/theme.py diff --git a/modules/util/ui/pyside6_util.py b/modules/util/ui/pyside6_util.py index 07d77dce5..3ee734595 100644 --- a/modules/util/ui/pyside6_util.py +++ b/modules/util/ui/pyside6_util.py @@ -3,8 +3,8 @@ import sys from abc import ABCMeta -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QPalette +from modules.util.ui.theme import apply_theme + from PySide6.QtWidgets import QApplication, QStyleFactory, QWidget @@ -32,36 +32,7 @@ def create_application() -> QApplication: # controls via OS theme APIs, which breaks once an application stylesheet # is set, producing a flatter look than Fusion's own stylesheet-aware painting. app.setStyle(QStyleFactory.create("Fusion")) - app.styleHints().setColorScheme(Qt.ColorScheme.Light) - - palette = app.palette() - palette.setColor(QPalette.ColorRole.Base, QColor("white")) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor("#e0e0e0")) - app.setPalette(palette) - app.setStyleSheet(""" - QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { - padding: 2px 2px; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - } - QProgressBar { - background-color: #c8c8c8; - } - QToolButton { - padding-top: 0px; - padding-bottom: 0px; - padding-right: 40px; - } - QToolButton::menu-indicator { - subcontrol-origin: padding; - subcontrol-position: right center; - width: 12px; - height: 12px; - right: 10px; - } - """) + apply_theme(app) return app diff --git a/modules/util/ui/theme.py b/modules/util/ui/theme.py new file mode 100644 index 000000000..a1c9bfbb1 --- /dev/null +++ b/modules/util/ui/theme.py @@ -0,0 +1,43 @@ +import platform + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + +IS_WINDOWS = platform.system() == "Windows" + +_BASE_STYLESHEET = """ + QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { + padding: 2px 2px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + } + QProgressBar { + background-color: #c8c8c8; + } + QToolButton { + padding-top: 0px; + padding-bottom: 0px; + padding-right: 40px; + } + QToolButton::menu-indicator { + subcontrol-origin: padding; + subcontrol-position: right center; + width: 12px; + height: 12px; + right: 10px; + } +""" + +def apply_theme(app: QApplication) -> None: + is_dark = app.palette().color(QPalette.ColorRole.Window).lightness() < 128 + palette = app.palette() + if not IS_WINDOWS or not is_dark: + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + palette = app.palette() + palette.setColor(QPalette.ColorRole.Base, QColor("white")) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor("#e0e0e0")) + app.setPalette(palette) + app.setStyleSheet(_BASE_STYLESHEET) From d009995ea5fdb48f663a68ddf9f491fafc77c3ee Mon Sep 17 00:00:00 2001 From: dxqb <183307934+dxqb@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:49:45 +0200 Subject: [PATCH 2/3] Add OT_FORCE_DARK to preview the dark theme without a dark OS The dark path is only reachable when Windows reports a dark color scheme, so reviewers on a light-only system cannot see it. OT_FORCE_DARK=1 makes Qt report dark before any widget is built. Co-Authored-By: Claude Opus 5 (1M context) --- modules/util/ui/theme.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/util/ui/theme.py b/modules/util/ui/theme.py index a1c9bfbb1..3f87e1d8f 100644 --- a/modules/util/ui/theme.py +++ b/modules/util/ui/theme.py @@ -1,3 +1,4 @@ +import os import platform from PySide6.QtCore import Qt @@ -6,6 +7,9 @@ IS_WINDOWS = platform.system() == "Windows" +# Lets reviewers whose OS never reports dark still see the dark theme, by pretending it did. +OT_FORCE_DARK = os.environ.get("OT_FORCE_DARK") == "1" + _BASE_STYLESHEET = """ QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { padding: 2px 2px; @@ -34,7 +38,10 @@ def apply_theme(app: QApplication) -> None: is_dark = app.palette().color(QPalette.ColorRole.Window).lightness() < 128 palette = app.palette() - if not IS_WINDOWS or not is_dark: + if OT_FORCE_DARK: + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + palette = app.palette() + elif not IS_WINDOWS or not is_dark: app.styleHints().setColorScheme(Qt.ColorScheme.Light) palette = app.palette() palette.setColor(QPalette.ColorRole.Base, QColor("white")) From ef8c0a12ec668a7363173159551e1635e317088a Mon Sep 17 00:00:00 2001 From: dxqb <183307934+dxqb@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:54:03 +0200 Subject: [PATCH 3/3] Add a light/dark toggle to the Qt6 top bar apply_theme() takes an explicit `dark` argument; called without it the color scheme still comes from the OS as before. A square button in the top bar flips it at runtime. The call re-applies the stylesheet, which re-polishes existing widgets. Without that, a runtime switch only repaints what draws straight from the application palette, and widgets such as the tabs keep the palette they were polished with. A scheme change regenerates only the palette roles nobody set explicitly, so the white Base written for light mode would otherwise survive into dark. Both schemes' palettes are captured before the first override, and each theme is applied from that pristine copy. Co-Authored-By: Claude Opus 5 (1M context) --- modules/ui/PySide6TopBarView.py | 18 +++++++++++-- modules/util/ui/theme.py | 45 +++++++++++++++++++++++---------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/modules/ui/PySide6TopBarView.py b/modules/ui/PySide6TopBarView.py index 68037fb73..1f2033d23 100644 --- a/modules/ui/PySide6TopBarView.py +++ b/modules/ui/PySide6TopBarView.py @@ -4,9 +4,9 @@ from modules.ui.TopBarController import TopBarController from modules.util.enum.ModelType import ModelType from modules.util.enum.TrainingMethod import TrainingMethod -from modules.util.ui import pyside6_components +from modules.util.ui import pyside6_components, theme -from PySide6.QtWidgets import QFileDialog, QWidget +from PySide6.QtWidgets import QApplication, QFileDialog, QPushButton, QWidget class PySide6TopBarView(BaseTopBarView, QWidget): @@ -33,6 +33,20 @@ def __init__( self.build(self.frame, master, controller, ui_state, change_model_type_callback, change_training_method_callback, load_preset_callback) + # Theme switch. Lives in the PySide6 view, not the shared BaseTopBarView: the ctk top bar + # has no color scheme to switch. A text glyph follows the palette's ButtonText. + self.theme_button = pyside6_components.button( + self.frame, 0, 8, "◐", self.__toggle_theme, + tooltip="Switch between light and dark mode", sticky="v", + ) + # Square and icon-sized: the grid stretches a button to its column otherwise. The side + # comes from a text-less button so the glyph's own metrics don't set it. + side = QPushButton().sizeHint().height() + self.theme_button.setFixedSize(side, side) + + def __toggle_theme(self): + theme.apply_theme(QApplication.instance(), dark=not theme.is_dark_theme) + def _setup_frame_column_weight(self): pyside6_components._layout(self.frame).setColumnStretch(5, 1) diff --git a/modules/util/ui/theme.py b/modules/util/ui/theme.py index 3f87e1d8f..b915127d6 100644 --- a/modules/util/ui/theme.py +++ b/modules/util/ui/theme.py @@ -1,4 +1,3 @@ -import os import platform from PySide6.QtCore import Qt @@ -7,9 +6,6 @@ IS_WINDOWS = platform.system() == "Windows" -# Lets reviewers whose OS never reports dark still see the dark theme, by pretending it did. -OT_FORCE_DARK = os.environ.get("OT_FORCE_DARK") == "1" - _BASE_STYLESHEET = """ QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { padding: 2px 2px; @@ -35,15 +31,38 @@ } """ -def apply_theme(app: QApplication) -> None: - is_dark = app.palette().color(QPalette.ColorRole.Window).lightness() < 128 - palette = app.palette() - if OT_FORCE_DARK: - app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - palette = app.palette() - elif not IS_WINDOWS or not is_dark: - app.styleHints().setColorScheme(Qt.ColorScheme.Light) - palette = app.palette() +# A scheme change regenerates only the palette roles nobody set explicitly. Base is set below +# for light mode, so it would keep that white through every later switch; each scheme is applied +# from a pristine copy taken before the first override instead. +_scheme_palettes = {} + +# Whether the dark palette is the one currently applied. +is_dark_theme = False + + +def _capture_scheme_palettes(app: QApplication) -> None: + original_scheme = app.styleHints().colorScheme() + for scheme in (Qt.ColorScheme.Light, Qt.ColorScheme.Dark): + app.styleHints().setColorScheme(scheme) + _scheme_palettes[scheme] = QPalette(app.palette()) + app.styleHints().setColorScheme(original_scheme) + + +def apply_theme(app: QApplication, dark: bool | None = None) -> None: + global is_dark_theme + + if not _scheme_palettes: + _capture_scheme_palettes(app) + + if dark is None: + is_dark = app.palette().color(QPalette.ColorRole.Window).lightness() < 128 + dark = IS_WINDOWS and is_dark + is_dark_theme = dark + + scheme = Qt.ColorScheme.Dark if dark else Qt.ColorScheme.Light + app.styleHints().setColorScheme(scheme) + palette = QPalette(_scheme_palettes[scheme]) + if not dark: palette.setColor(QPalette.ColorRole.Base, QColor("white")) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor("#e0e0e0")) app.setPalette(palette)