[PATCH 4 of 8 DarkThemeSeries 1] qtapp: apply dark theme palette, stylesheet and title bar on startup

0 views
Skip to first unread message

Peter Demcak

unread,
Mar 7, 2026, 1:28:02 PM (3 days ago) Mar 7
to thg...@googlegroups.com
# HG changeset patch
# User Peter Demcak <majs...@gmail.com>
# Date 1772736971 -3600
# Thu Mar 05 19:56:11 2026 +0100
# Node ID 2b63ea12ba5dde4f32a77a3a8ac9e63ceabc31d2
# Parent 6291a26345172b2d235fc8d04bdad999427915c9
qtapp: apply dark theme palette, stylesheet and title bar on startup

Set dark QPalette, generate CSS for all widgets, add custom checkbox
style (DarkItemViewCheckStyle) and dark Windows title bar via DWM API.
Active only when THEME.enabled.

diff -r 6291a2634517 -r 2b63ea12ba5d tortoisehg/hgqt/qtapp.py
--- a/tortoisehg/hgqt/qtapp.py Thu Mar 05 19:54:04 2026 +0100
+++ b/tortoisehg/hgqt/qtapp.py Thu Mar 05 19:56:11 2026 +0100
@@ -18,9 +18,12 @@
PYQT_VERSION,
PYQT_VERSION_STR,
QByteArray,
+ QEvent,
QIODevice,
QLibraryInfo,
QObject,
+ QPoint,
+ QRect,
QSettings,
QSignalMapper,
QSocketNotifier,
@@ -35,7 +38,15 @@
)
from .qtgui import (
QApplication,
+ QDialog,
+ QMainWindow,
QFont,
+ QPainter,
+ QPalette,
+ QPen,
+ QProxyStyle,
+ QStyle,
+ QWidget
)
from .qtnetwork import (
QLocalServer,
@@ -65,6 +76,7 @@
thgrepo,
workbench,
)
+from .theme import THEME

if os.name == 'nt' and getattr(sys, 'frozen', False):
# load QtSvg4.dll and QtXml4.dll by .pyd, so that imageformats/qsvg4.dll
@@ -292,6 +304,380 @@
# nop for instant SIGINT handling
pass

+def apply_dark_palette(app):
+ pal = QPalette()
+
+ # Basic colors
+ pal.setColor(qtlib.QtPaletteRole.Window, THEME.background)
+ pal.setColor(qtlib.QtPaletteRole.Base, THEME.background)
+ pal.setColor(qtlib.QtPaletteRole.AlternateBase, THEME.backgroundLighter)
+
+ pal.setColor(qtlib.QtPaletteRole.Text, THEME.text)
+ pal.setColor(qtlib.QtPaletteRole.WindowText, THEME.text)
+
+ pal.setColor(qtlib.QtPaletteRole.Mid, THEME.backgroundLighter)
+ pal.setColor(qtlib.QtPaletteRole.Dark, THEME.background)
+ pal.setColor(qtlib.QtPaletteRole.Light, THEME.backgroundLighter)
+
+ pal.setColor(qtlib.QtPaletteRole.Highlight, THEME.selection_background)
+ pal.setColor(qtlib.QtPaletteRole.HighlightedText, THEME.selection_text)
+
+ pal.setColor(qtlib.QtPaletteRole.Link, THEME.ui_info)
+ pal.setColor(qtlib.QtPaletteRole.LinkVisited, THEME.ui_info)
+
+ # Disabled text
+ pal.setColor(qtlib.QtPaletteGroup.Disabled, qtlib.QtPaletteRole.Text, THEME.text_disabled)
+
+ app.setPalette(pal)
+
+
+def build_dark_stylesheet(THEME):
+ c = THEME
+
+ return f"""
+ /* === Base widgets === */
+ QWidget {{
+ background-color: {c.control_background.name()};
+ color: {c.control_text.name()};
+ }}
+
+ QMainWindow, QDialog {{
+ background-color: {c.background.name()};
+ }}
+
+ /* === Text inputs & views === */
+ QTextEdit, QPlainTextEdit, QLineEdit,
+ QListView, QTreeView, QTableView {{
+ background-color: {c.background.name()};
+ color: {c.control_text.name()};
+ border: 1px solid {c.control_border.name()};
+ selection-background-color: {c.selection_background.name()};
+ selection-color: {c.selection_text.name()};
+ }}
+
+ QTreeView, QListView, QTableView {{
+ alternate-background-color: {c.backgroundLighter.name()};
+ }}
+
+ /* === Buttons === */
+ QPushButton {{
+ background-color: {c.background.name()};
+ color: {c.control_text.name()};
+ border: 1px solid {c.control_border.name()};
+ padding: 5px 10px;
+ border-radius: 2px;
+ }}
+
+ QPushButton:hover {{
+ background-color: {c.control_hover.name()};
+ }}
+
+ QPushButton:pressed {{
+ background-color: {c.control_pressed.name()};
+ }}
+
+ /* === Menus === */
+ QMenuBar, QMenu {{
+ background-color: {c.control_background.name()};
+ color: {c.control_text.name()};
+ border: 1px solid {c.control_border.name()};
+ }}
+
+ QMenu::item:selected {{
+ background-color: {c.control_pressed.name()};
+ }}
+
+ /* === Toolbars & statusbar === */
+ QToolBar {{
+ background-color: {c.control_background.name()};
+ border: 1px solid {c.control_border.name()};
+ }}
+
+ QStatusBar {{
+ background-color: {c.background.name()};
+ color: {c.control_text.name()};
+ border-top: 1px solid #2d2d2d;
+ }}
+
+ /* === Tabs === */
+ QTabWidget::pane {{
+ border: 1px solid {c.control_border.name()};
+ background-color: {c.background.name()};
+ }}
+
+ QTabBar::tab {{
+ background-color: {c.control_background.name()};
+ color: {c.control_text.name()};
+ border: 1px solid {c.control_border.name()};
+ padding: 5px;
+ }}
+
+ QTabBar::tab:selected {{
+ background-color: {c.control_border.name()};
+ color: {c.text_selection.name()};
+ }}
+
+ /* === ScrollBars === */
+ QScrollBar {{
+ background: {THEME.control_background.name()};
+ border: 1px solid {THEME.control_border.name()};
+ }}
+
+ QScrollBar:vertical {{
+ width: 18px;
+ }}
+
+ QScrollBar:horizontal {{
+ height: 18px;
+ }}
+
+ /* HANDLE */
+ QScrollBar::handle {{
+ background: {THEME.backgroundLighter.name()};
+ border: 1px solid {THEME.control_border.name()};
+ border-radius: 6px;
+ margin: 0px; /* critical: avoid dead zones */
+ }}
+
+ QScrollBar::handle:vertical {{
+ min-height: 34px;
+ }}
+
+ QScrollBar::handle:horizontal {{
+ min-width: 34px;
+ }}
+
+ /* HOVER */
+ QScrollBar::handle:hover {{
+ background: {THEME.control_hover.name()};
+ }}
+
+ /* PRESSED / DRAGGING */
+ QScrollBar::handle:pressed,
+ QScrollBar::handle:active,
+ QScrollBar::handle:active:pressed,
+ QScrollBar::handle:vertical:pressed,
+ QScrollBar::handle:vertical:active,
+ QScrollBar::handle:horizontal:pressed,
+ QScrollBar::handle:horizontal:active {{
+ background: {THEME.control_pressed.name()};
+ }}
+
+ /* REMOVE INVISIBLE HIT AREAS */
+ QScrollBar::add-line,
+ QScrollBar::sub-line {{
+ background: none;
+ border: none;
+ width: 0px;
+ height: 0px;
+ }}
+
+ /* REMOVE PAGE AREAS */
+ QScrollBar::add-page,
+ QScrollBar::sub-page {{
+ background: none;
+ }}
+
+
+ /* === Header views === */
+ QHeaderView {{
+ background-color: {c.background.name()};
+ }}
+
+ QHeaderView::section {{
+ background-color: {c.backgroundLighter.name()};
+ color: {c.header_text.name()};
+ padding: 2px 6px;
+ border: 1px solid {c.control_border.name()};
+ font-size: 9pt;
+ }}
+
+ /* === Tooltips === */
+ QToolTip {{
+ background-color: {c.backgroundLighter.name()};
+ color: {c.text.name()};
+ border: 1px solid {c.control_border.name()};
+ }}
+ """
+
+
+class DarkItemViewCheckStyle(QProxyStyle):
+ def drawPrimitive(self, element, option, painter, widget=None):
+ if THEME.enabled and element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck:
+ painter.save()
+ painter.setRenderHint(qtlib.QtPainterRenderHint.Antialiasing, False)
+
+ # Use a reliable rect for item-view checkbox
+ style = self.baseStyle()
+ rect = style.subElementRect(QStyle.SubElement.SE_ItemViewItemCheckIndicator, option, widget)
+ rect = rect.adjusted(2, 2, -2, -2)
+
+ state = option.state
+ is_checked = bool(state & QStyle.StateFlag.State_On)
+ is_partial = bool(state & QStyle.StateFlag.State_NoChange)
+ is_enabled = bool(state & QStyle.StateFlag.State_Enabled)
+
+ # Colors
+ border_color = THEME.text if is_enabled else THEME.text_disabled
+ mark_color = border_color
+
+ # Border (square)
+ pen = QPen(border_color)
+ pen.setWidth(1)
+ painter.setPen(pen)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawRect(rect)
+
+ # Checkmark / tristate
+ pen.setWidth(2)
+ painter.setPen(pen)
+
+ # Shift checkmark by +1px right and +1px down
+ dx = 1
+ dy = 1
+
+ # Enable antialiasing
+ painter.setRenderHint(qtlib.QtPainterRenderHint.Antialiasing, True)
+
+ if is_checked:
+ # Classic checkmark
+ p1 = QPoint(rect.left() + 2 + dx, rect.center().y() + 1 + dy)
+ p2 = QPoint(rect.center().x() - 1 + dx, rect.bottom() - 2 + dy)
+ p3 = QPoint(rect.right() - 2 + dx, rect.top() + 2 + dy)
+ painter.drawLine(p1, p2)
+ painter.drawLine(p2, p3)
+
+ elif is_partial:
+ # Small centered square
+ size = 7
+ cx = rect.center().x()
+ cy = rect.center().y()
+ half = size // 2
+ square = QRect(cx - half + dx, cy - half + dy, size, size)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(mark_color)
+ painter.drawRect(square)
+ painter.setRenderHint(qtlib.QtPainterRenderHint.Antialiasing, False)
+
+ painter.restore()
+ return # stop Qt from drawing anything else
+
+ super().drawPrimitive(element, option, painter, widget)
+
+ def subElementRect(self, element, option, widget=None):
+ rect = super().subElementRect(element, option, widget)
+ if THEME.enabled and element == QStyle.SubElement.SE_ItemViewItemCheckIndicator:
+ # Fusion style returns a smaller hit rect than Windows style;
+ # expand it back so clicks register anywhere on the visible indicator
+ rect = rect.adjusted(-1, -1, 1, 1)
+ return rect
+
+
+def is_windows_11():
+ if sys.platform != 'win32':
+ return False
+ # Windows 11 reports build >= 22000
+ return int(platform.version().split('.')[-1]) >= 22000
+
+def qcolor_to_bgr_dword(color: QColor) -> int:
+ return (color.blue() << 16) | (color.green() << 8) | color.red()
+
+def enable_dark_title_bar(window):
+ if sys.platform != 'win32':
+ return
+
+ try:
+ hwnd = int(window.winId())
+ except Exception:
+ return
+
+ try:
+ import ctypes
+ from ctypes import wintypes
+ import platform
+
+ dwmapi = ctypes.WinDLL("dwmapi")
+ user32 = ctypes.WinDLL("user32")
+ TRUE = wintypes.BOOL(1)
+
+ # Immersive dark mode (Win10/11)
+ dwmapi.DwmSetWindowAttribute(wintypes.HWND(hwnd), wintypes.DWORD(20), ctypes.byref(TRUE), ctypes.sizeof(TRUE))
+ dwmapi.DwmSetWindowAttribute(wintypes.HWND(hwnd), wintypes.DWORD(19), ctypes.byref(TRUE), ctypes.sizeof(TRUE))
+
+ build = int(platform.version().split(".")[-1])
+
+ if build >= 22000:
+ bg = wintypes.DWORD(qcolor_to_bgr_dword(THEME.titlebar_background))
+ fg = wintypes.DWORD(qcolor_to_bgr_dword(THEME.titlebar_text))
+ dwmapi.DwmSetWindowAttribute(wintypes.HWND(hwnd), wintypes.DWORD(35), ctypes.byref(bg), ctypes.sizeof(bg))
+ dwmapi.DwmSetWindowAttribute(wintypes.HWND(hwnd), wintypes.DWORD(36), ctypes.byref(fg), ctypes.sizeof(fg))
+
+ # Force non-client frame refresh
+ SWP_NOMOVE = 0x0002
+ SWP_NOSIZE = 0x0001
+ SWP_NOZORDER = 0x0004
+ SWP_NOACTIVATE = 0x0010
+ SWP_FRAMECHANGED = 0x0020
+
+ user32.SetWindowPos(
+ wintypes.HWND(hwnd), None, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED
+ )
+
+ RDW_INVALIDATE = 0x0001
+ RDW_UPDATENOW = 0x0100
+ RDW_FRAME = 0x0400
+ user32.RedrawWindow(wintypes.HWND(hwnd), None, None, RDW_INVALIDATE | RDW_UPDATENOW | RDW_FRAME)
+
+ # Win10 workaround: force NC activation refresh
+ WM_NCACTIVATE = 0x0086
+ user32.SendMessageW(wintypes.HWND(hwnd), WM_NCACTIVATE, wintypes.WPARAM(0), wintypes.LPARAM(0))
+ user32.SendMessageW(wintypes.HWND(hwnd), WM_NCACTIVATE, wintypes.WPARAM(1), wintypes.LPARAM(0))
+
+ except Exception:
+ pass
+
+class DarkTitleBarFilter(QObject):
+ def __init__(self):
+ super().__init__()
+ self._applied_hwnds = set()
+
+ def _apply_once(self, w):
+ try:
+ hwnd = int(w.winId())
+ except Exception:
+ return
+
+ if hwnd in self._applied_hwnds:
+ return
+
+ self._applied_hwnds.add(hwnd)
+
+ try:
+ enable_dark_title_bar(w)
+ except Exception:
+ pass
+
+ def eventFilter(self, obj, event):
+ if not isinstance(obj, QWidget):
+ return False
+
+ if not obj.isWindow():
+ return False
+
+ et = event.type()
+
+ if et == QEvent.Type.Show:
+ if isinstance(obj, QMainWindow):
+ self._apply_once(obj)
+ elif isinstance(obj, QDialog):
+ QTimer.singleShot(0, lambda w=obj: self._apply_once(w))
+
+ elif et == QEvent.Type.WindowActivate:
+ if isinstance(obj, QDialog):
+ QTimer.singleShot(0, lambda w=obj: self._apply_once(w))
+
+ return False

class GarbageCollector(QObject):
'''
@@ -511,6 +897,15 @@

self._mainapp = QApplication(sys.argv)

+ if THEME.enabled:
+ apply_dark_palette(self._mainapp)
+ base = self._mainapp.setStyle("Fusion") # Needed for comboboxes and checkboxes
+ self._mainapp.setStyle(DarkItemViewCheckStyle(base)) # Custom checkbox style for HgFileListView
+ self._mainapp.setStyleSheet(build_dark_stylesheet(THEME))
+ if sys.platform == 'win32':
+ self._dark_titlebar_filter = DarkTitleBarFilter()
+ self._mainapp.installEventFilter(self._dark_titlebar_filter)
+
self._exccatcher = ExceptionCatcher(ui, self._mainapp, self)
self._gc = GarbageCollector(ui, self)


Reply all
Reply to author
Forward
0 new messages