From 5aa8aa6839bc4de90950bdd4f2a062be651d17e6 Mon Sep 17 00:00:00 2001 From: Ciansen Date: Sat, 15 Aug 2026 01:36:46 +0200 Subject: [PATCH 1/5] feat: add built-in modern theme (light & dark) Ship a flat, roomy look that doesn't depend on the desktop's widget style, so the app looks consistent everywhere instead of inheriting whatever Breeze/Adwaita/Fusion happens to provide. New "Theme" setting in General: Modern (follow system), Modern Light, Modern Dark, or Desktop environment for the previous behavior. Defaults to Modern; unit tests keep the desktop look so pixel-precise tests aren't at the mercy of our own metrics. * themes.py: color tokens, QPalette and stylesheet construction * assets/style-modern.qss: stylesheet template fed by those tokens * Theme forces the Fusion style and can be switched without a restart * ContextHeader: use theme colors instead of a translucent white overlay --- gitfourchette/application.py | 58 ++- gitfourchette/assets/style-modern.qss | 585 ++++++++++++++++++++++++++ gitfourchette/forms/contextheader.py | 17 +- gitfourchette/settings.py | 6 + gitfourchette/themes.py | 278 ++++++++++++ gitfourchette/trtables.py | 15 + 6 files changed, 945 insertions(+), 14 deletions(-) create mode 100755 gitfourchette/assets/style-modern.qss create mode 100755 gitfourchette/themes.py diff --git a/gitfourchette/application.py b/gitfourchette/application.py index 61600a0c..1fa8d28e 100644 --- a/gitfourchette/application.py +++ b/gitfourchette/application.py @@ -40,6 +40,9 @@ class GFApplication(QApplication): qtbaseTranslator: QTranslator tempDir: QTemporaryDir platformDefaultStyleName: str + platformDefaultPalette: QPalette + restyling: bool = False + """Re-entrance guard for onRestyle().""" # Heavyweight state mainWindow: MainWindow | None @@ -98,8 +101,9 @@ def __init__(self, argv: list[str], barebones=False): if not (MACOS and APP_FREEZE_COMMIT): self.setWindowIcon(QIcon("assets:icons/gitfourchette.png")) - # Get system default style name before applying further styling + # Get system default style & palette before applying further styling self.platformDefaultStyleName = self.style().objectName() + self.platformDefaultPalette = QPalette(self.palette()) # Install translators for system language # (for command line parser to display localized text) @@ -447,8 +451,9 @@ def _applyPrefs(self, prefDiff: dict[str, Any], writeNow=False): self.dispatchSimplePrefsToStandaloneClasses() - if "qtStyle" in prefDiff: + if "qtStyle" in prefDiff or "appTheme" in prefDiff: self.applyQtStylePref(forceApplyDefault=True) + self.restyle.emit() if "language" in prefDiff: self.applyLanguagePref() @@ -489,12 +494,25 @@ def applyLanguagePref(self): def applyQtStylePref(self, forceApplyDefault: bool): from gitfourchette import settings + from gitfourchette import themes + + themeColors = themes.resolveTheme(settings.prefs.appTheme, self.platformDefaultPalette) if settings.prefs.qtStyle: self.setStyle(settings.prefs.qtStyle) + elif themeColors is not None: + # Our themes are designed on top of Fusion. Native styles (Breeze, + # Windows, macOS) ignore or mangle much of the stylesheet. + self.setStyle("Fusion") elif forceApplyDefault: self.setStyle(self.platformDefaultStyleName) + # Note: setStyle() resets the palette, so this must come after it. + if themeColors is not None: + self.setPalette(themes.buildPalette(themeColors)) + else: + self.setPalette(self.platformDefaultPalette) + if MACOS: self.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, settings.qtIsNativeMacosStyle()) @@ -594,21 +612,39 @@ def eventFilter(self, watched, event: QEvent): # ------------------------------------------------------------------------- def onRestyle(self): + from gitfourchette import settings + from gitfourchette import themes from gitfourchette.toolbox.iconbank import clearStockIconCache from gitfourchette.toolbox.qtutils import isDarkTheme from gitfourchette.syntax.colorscheme import ColorScheme - # Force RecolorSvgIconEngine to re-render the icons - clearStockIconCache() - QPixmapCache.clear() + # setStyleSheet() below may alter the main window's effective palette, + # which sends us right back here through the PaletteChange event filter. + # Bail out instead of recursing until the stack blows up. + if self.restyling: + return + self.restyling = True + + try: + # Force RecolorSvgIconEngine to re-render the icons + clearStockIconCache() + QPixmapCache.clear() + + styleSheet = Path(QFile("assets:style.qss").fileName()).read_text() + if isDarkTheme(): # Append dark override + darkSupplement = Path(QFile("assets:style-dark.qss").fileName()).read_text() + styleSheet += darkSupplement + + # Append our own theme, if any (its rules take precedence over the above) + themeColors = themes.resolveTheme(settings.prefs.appTheme, self.platformDefaultPalette) + if themeColors is not None: + styleSheet += themes.buildStyleSheet(themeColors) - styleSheet = Path(QFile("assets:style.qss").fileName()).read_text() - if isDarkTheme(): # Append dark override - darkSupplement = Path(QFile("assets:style-dark.qss").fileName()).read_text() - styleSheet += darkSupplement - self.setStyleSheet(styleSheet) + self.setStyleSheet(styleSheet) - ColorScheme.refreshFallbackScheme() + ColorScheme.refreshFallbackScheme() + finally: + self.restyling = False # ------------------------------------------------------------------------- # Utilities diff --git a/gitfourchette/assets/style-modern.qss b/gitfourchette/assets/style-modern.qss new file mode 100755 index 00000000..0324b16c --- /dev/null +++ b/gitfourchette/assets/style-modern.qss @@ -0,0 +1,585 @@ +/* --------------------------------------------------------------------------- + * "Modern" theme for GitFourchette. + * + * This is a template: color tokens are substituted by gitfourchette/themes.py + * (see string.Template and ThemeColors). + * It is appended to style.qss (and style-dark.qss), so its rules win over + * those of the base stylesheet at equal specificity. + * + * Keep in mind that many colors come from the QPalette built in themes.py. + * Only add rules here for things that a palette cannot express (metrics, + * rounded corners, hover states, etc.) + * ------------------------------------------------------------------------ */ + +/* -------------------------------------------------------------------------- + * Window chrome + * ------------------------------------------------------------------------ */ + +QMainWindow, QDialog, #QTabWidget2, #QTW2StackedWidget { + background: ${bg}; +} + +QMainWindow::separator { + background: ${border}; + width: 1px; + height: 1px; +} + +QToolTip { + background: ${tooltipBg}; + color: ${tooltipText}; + border: 1px solid ${tooltipBorder}; + border-radius: 6px; + padding: 4px 7px; +} + +QStatusBar { + background: ${bg}; + color: ${textDim}; + border-top: 1px solid ${borderSoft}; +} + +QStatusBar::item { + border: none; +} + +/* -------------------------------------------------------------------------- + * Menu bar & menus + * ------------------------------------------------------------------------ */ + +QMenuBar { + background: ${bg}; + color: ${text}; + border: none; + padding: 2px 3px; +} + +QMenuBar::item { + background: transparent; + color: ${text}; + padding: 4px 9px; + margin: 0px 1px; + border-radius: 6px; +} + +QMenuBar::item:selected { + background: ${hover}; +} + +QMenuBar::item:pressed { + background: ${accent}; + color: ${onAccent}; +} + +QMenu { + background: ${elevated}; + color: ${text}; + border: 1px solid ${border}; + border-radius: 8px; + padding: 5px 4px; +} + +QMenu::item { + background: transparent; + padding: 5px 24px 5px 26px; + border-radius: 5px; + margin: 0px 2px; +} + +QMenu::item:selected { + background: ${accent}; + color: ${onAccent}; +} + +QMenu::item:disabled { + background: transparent; + color: ${textFaint}; +} + +QMenu::separator { + height: 1px; + background: ${borderSoft}; + margin: 5px 8px; +} + +QMenu::icon { + margin-left: 7px; +} + +QMenu::indicator { + margin-left: 8px; + width: 13px; + height: 13px; +} + +/* -------------------------------------------------------------------------- + * Main toolbar + * ------------------------------------------------------------------------ */ + +QToolBar { + background: ${bg}; + border: none; + padding: 4px 6px; + spacing: 2px; +} + +QToolBar#GFToolbar { + border-bottom: 1px solid ${borderSoft}; +} + +QToolBar::separator { + background: ${border}; + width: 1px; + height: 1px; + margin: 5px 7px; +} + +/* -------------------------------------------------------------------------- + * Buttons + * ------------------------------------------------------------------------ */ + +QToolButton { + background: transparent; + color: ${text}; + border: 1px solid transparent; + border-radius: 7px; + padding: 3px 7px; +} + +QToolButton:hover, QToolButton:focus { + background: ${hover}; +} + +QToolButton:pressed, QToolButton:checked, QToolButton:on { + background: ${pressed}; +} + +QToolButton:disabled { + background: transparent; + color: ${textFaint}; +} + +QToolButton::menu-button { + background: transparent; + border: none; + border-top-right-radius: 7px; + border-bottom-right-radius: 7px; + width: 14px; +} + +QToolButton::menu-arrow { + width: 8px; + height: 8px; +} + +QPushButton { + background: ${button}; + color: ${text}; + border: 1px solid ${border}; + border-radius: 7px; + padding: 5px 14px; + min-width: 56px; +} + +QPushButton:hover { + background: ${buttonHover}; + border-color: ${borderStrong}; +} + +QPushButton:pressed { + background: ${buttonPressed}; +} + +QPushButton:default, QPushButton:focus { + border-color: ${accent}; +} + +QPushButton:default { + background: ${accent}; + color: ${onAccent}; +} + +QPushButton:default:hover { + background: ${accentHover}; + border-color: ${accentHover}; +} + +QPushButton:default:pressed { + background: ${accentPressed}; + border-color: ${accentPressed}; +} + +QPushButton:disabled { + background: ${button}; + color: ${textFaint}; + border-color: ${borderSoft}; +} + +QPushButton:flat { + background: transparent; + border-color: transparent; +} + +QPushButton:flat:hover { + background: ${hover}; +} + +QPushButton::menu-indicator { + subcontrol-origin: padding; + subcontrol-position: center right; + right: 6px; +} + +/* -------------------------------------------------------------------------- + * Text entry & combo boxes + * ------------------------------------------------------------------------ */ + +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit, QPlainTextEdit { + background: ${input}; + color: ${text}; + border: 1px solid ${border}; + border-radius: 7px; + padding: 3px 7px; + selection-background-color: ${accent}; + selection-color: ${onAccent}; +} + +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus, +QTextEdit:focus, QPlainTextEdit:focus, QComboBox:on { + border-color: ${accent}; +} + +QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled, +QComboBox:disabled, QTextEdit:disabled, QPlainTextEdit:disabled { + background: ${inputDisabled}; + color: ${textFaint}; + border-color: ${borderSoft}; +} + +QLineEdit[readOnly="true"] { + background: ${inputDisabled}; +} + +/* Note: don't restyle QComboBox::drop-down. As soon as that subcontrol has a + rule of its own, Qt stops drawing the style's arrow and expects an image. */ + +QComboBox QAbstractItemView { + background: ${elevated}; + color: ${text}; + border: 1px solid ${border}; + border-radius: 7px; + padding: 3px; + selection-background-color: ${accent}; + selection-color: ${onAccent}; +} + +/* -------------------------------------------------------------------------- + * Tab bars + * ------------------------------------------------------------------------ */ + +QTabWidget::pane { + background: ${surface}; + border: none; + border-top: 1px solid ${borderSoft}; +} + +QTabBar { + background: transparent; + qproperty-drawBase: 0; +} + +QTabBar::tab { + background: transparent; + color: ${textDim}; + border: none; + border-radius: 7px; + padding: 5px 11px; + margin: 3px 2px; +} + +QTabBar::tab:hover { + background: ${hover}; + color: ${text}; +} + +QTabBar::tab:selected { + background: ${tabSelected}; + color: ${text}; +} + +QTabBar::tab:disabled { + color: ${textFaint}; +} + +QTabBar::close-button { + subcontrol-position: right; + border-radius: 4px; + margin: 1px; +} + +QTabBar::close-button:hover { + background: ${pressed}; +} + +QTabBar::scroller { + width: 0px; +} + +/* Repo tab strip: browser-style tabs that sit on the window background and + merge into the content area beneath the selected one. */ +#QTabBar2 { + background: ${bg}; +} + +#QTabBar2::tab { + padding: 6px 12px; + margin: 4px 1px 0px 1px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; +} + +#QTabBar2::tab:selected { + background: ${surface}; + color: ${text}; +} + +/* -------------------------------------------------------------------------- + * Splitters + * ------------------------------------------------------------------------ */ + +QSplitter::handle { + background: ${bg}; +} + +QSplitter::handle:horizontal { + width: 5px; +} + +QSplitter::handle:vertical { + height: 5px; +} + +QSplitter::handle:hover, QSplitter::handle:pressed { + background: ${accent}; +} + +QFaintSeparator { + background: ${borderSoft}; + border: none; +} + +/* -------------------------------------------------------------------------- + * Item views + * ------------------------------------------------------------------------ */ + +QTreeView, QListView, QTableView, QColumnView { + background: ${surface}; + color: ${text}; + border: none; + outline: 0; + alternate-background-color: ${altRow}; +} + +QAbstractItemView::item { + color: ${text}; + border: none; +} + +QAbstractItemView::item:hover { + background: ${hover}; +} + +QAbstractItemView::item:selected { + background: ${selInactive}; + color: ${text}; +} + +QAbstractItemView::item:selected:active { + background: ${accent}; + color: ${onAccent}; +} + +/* Rounded selection pills in the sidebar & file lists (Fork-style). + The commit graph keeps full-width selection bars so that the lane + drawings aren't clipped by rounded corners. */ +Sidebar::item, FileList::item, DirtyFiles::item, StagedFiles::item, CommittedFiles::item { + border-radius: 5px; +} + +Sidebar { + background: ${sidebarBg}; + color: ${text}; + show-decoration-selected: 1; +} + +QHeaderView { + background: ${bg}; + border: none; +} + +QHeaderView::section { + background: ${bg}; + color: ${textDim}; + border: none; + border-bottom: 1px solid ${borderSoft}; + padding: 4px 8px; +} + +QHeaderView::section:hover { + background: ${hover}; +} + +/* -------------------------------------------------------------------------- + * Scroll bars + * ------------------------------------------------------------------------ */ + +QScrollBar:vertical { + background: transparent; + width: 12px; + margin: 0; + border: none; +} + +QScrollBar:horizontal { + background: transparent; + height: 12px; + margin: 0; + border: none; +} + +QScrollBar::handle:vertical { + background: ${scrollHandle}; + border-radius: 3px; + min-height: 32px; + margin: 3px 3px 3px 4px; +} + +QScrollBar::handle:horizontal { + background: ${scrollHandle}; + border-radius: 3px; + min-width: 32px; + margin: 4px 3px 3px 3px; +} + +QScrollBar::handle:hover, QScrollBar::handle:pressed { + background: ${scrollHandleHover}; +} + +QScrollBar::add-line, QScrollBar::sub-line { + background: transparent; + border: none; + width: 0; + height: 0; +} + +QScrollBar::up-arrow, QScrollBar::down-arrow, +QScrollBar::left-arrow, QScrollBar::right-arrow { + background: transparent; + image: none; + width: 0; + height: 0; +} + +QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; +} + +QScrollArea { + background: transparent; + border: none; +} + +/* -------------------------------------------------------------------------- + * Misc controls + * ------------------------------------------------------------------------ */ + +QGroupBox { + border: 1px solid ${borderSoft}; + border-radius: 8px; + margin-top: 10px; + padding: 8px 4px 4px 4px; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 9px; + padding: 0 4px; + color: ${textDim}; +} + +/* No fixed height here: some progress bars display their text. */ +QProgressBar { + background: ${input}; + border: none; + border-radius: 5px; + text-align: center; + color: ${text}; +} + +QProgressBar::chunk { + background: ${accent}; + border-radius: 5px; +} + +QSlider::groove:horizontal { + background: ${input}; + height: 4px; + border-radius: 2px; +} + +QSlider::sub-page:horizontal { + background: ${accent}; + border-radius: 2px; +} + +QSlider::handle:horizontal { + background: ${text}; + width: 12px; + height: 12px; + margin: -5px 0; + border-radius: 6px; +} + +QToolBox::tab, QDockWidget::title { + background: ${bg}; + border: none; + border-radius: 6px; + padding: 4px; +} + +/* -------------------------------------------------------------------------- + * GitFourchette-specific widgets + * ------------------------------------------------------------------------ */ + +/* Code panes must stay flush - no rounded frame, no inner padding. */ +CodeView, DiffView, BlameTextEdit, SpecialDiffView { + background: ${surface}; + border: none; + border-radius: 0; + padding: 0; +} + +/* Panel titles above the file lists and the diff */ +#dirtyHeader, #stagedHeader, #committedHeader, #diffHeader { + color: ${textDim}; + padding-left: 2px; +} + +ContextHeader, Banner.diff { + border: none; + padding: 3px 2px; +} + +SearchBar QLineEdit { + border: 1px solid ${border}; + border-radius: 7px; + padding: 3px 7px; +} + +SearchBar[red="true"] QLineEdit { + color: ${danger}; + border: 1px solid ${danger}; +} diff --git a/gitfourchette/forms/contextheader.py b/gitfourchette/forms/contextheader.py index c28bc641..98eb621f 100644 --- a/gitfourchette/forms/contextheader.py +++ b/gitfourchette/forms/contextheader.py @@ -43,9 +43,20 @@ def __init__(self, parent): GFApplication.instance().restyle.connect(self.restyle) def restyle(self): - bg = mutedTextColorHex(self, .07) - fg = mutedTextColorHex(self, .8) - self.setStyleSheet(f"ContextHeader {{ background-color: {bg}; }} ContextHeader QLabel {{ color: {fg}; }}") + from gitfourchette import themes + + theme = themes.currentTheme() + if theme is not None: + bg = theme.bg + fg = theme.textDim + border = f"border-bottom: 1px solid {theme.borderSoft};" + else: + bg = mutedTextColorHex(self, .07) + fg = mutedTextColorHex(self, .8) + border = "" + + self.setStyleSheet(f"ContextHeader {{ background-color: {bg}; {border} }}" + f"ContextHeader QLabel {{ color: {fg}; }}") def addButton( self, diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index 006e4ada..cbeb9715 100644 --- a/gitfourchette/settings.py +++ b/gitfourchette/settings.py @@ -19,6 +19,7 @@ from gitfourchette.prefsfile import PrefsFile from gitfourchette.qt import * from gitfourchette.syntax import PygmentsPresets, ColorScheme +from gitfourchette.themes import AppTheme from gitfourchette.toolbox.benchmark import BENCHMARK_LOGGING_LEVEL from gitfourchette.toolbox.gitutils import AuthorDisplayStyle from gitfourchette.toolbox.pathutils import PathDisplayStyle @@ -106,6 +107,9 @@ class Prefs(PrefsFile): _category_general : int = 0 language : str = "" + # Keep the desktop's look in unit tests so that pixel-precise tests + # aren't at the mercy of our own stylesheet's metrics. + appTheme : AppTheme = AppTheme.System if APP_TESTMODE else AppTheme.Modern qtStyle : str = "" pathDisplayStyle : PathDisplayStyle = PathDisplayStyle.FullPaths refSort : RefSort = RefSort.TimeDesc @@ -438,6 +442,8 @@ class Session(PrefsFile): def qtIsNativeMacosStyle(): # pragma: no cover if not MACOS: return False + if prefs.appTheme.isModern: # our own themes force the Fusion style + return False return (not prefs.qtStyle) or (prefs.qtStyle.lower() == "macos") diff --git a/gitfourchette/themes.py b/gitfourchette/themes.py new file mode 100755 index 00000000..9d7282bf --- /dev/null +++ b/gitfourchette/themes.py @@ -0,0 +1,278 @@ +# ----------------------------------------------------------------------------- +# Copyright (C) 2026 Iliyas Jorio. +# This file is part of GitFourchette, distributed under the GNU GPL v3. +# For full terms, see the included LICENSE file. +# ----------------------------------------------------------------------------- + +""" +Built-in "Modern" look: a flat, roomy theme that doesn't depend on the +desktop environment's widget style. + +A theme is a bunch of color tokens (ThemeColors). The tokens feed both a +QPalette (for everything Qt draws natively, including custom item delegates) +and assets/style-modern.qss (for metrics, rounded corners and hover states +that a palette can't express). +""" + +from __future__ import annotations + +import dataclasses +import enum +from contextlib import suppress +from pathlib import Path +from string import Template + +from gitfourchette.qt import * + + +class AppTheme(enum.StrEnum): + System = "" + Modern = "modern" + ModernLight = "modern-light" + ModernDark = "modern-dark" + + @property + def isModern(self) -> bool: + return self != AppTheme.System + + +@dataclasses.dataclass(frozen=True) +class ThemeColors: + dark: bool + + bg: str + """Window chrome: toolbar, tab strip, menu bar, status bar.""" + surface: str + """Content background: item views, code panes.""" + sidebarBg: str + elevated: str + """Popups: menus, combobox dropdowns.""" + altRow: str + + border: str + borderSoft: str + borderStrong: str + + text: str + textDim: str + textFaint: str + + accent: str + accentHover: str + accentPressed: str + accentGhost: str + onAccent: str + + hover: str + pressed: str + selInactive: str + tabSelected: str + + button: str + buttonHover: str + buttonPressed: str + + input: str + inputDisabled: str + + scrollHandle: str + scrollHandleHover: str + + tooltipBg: str + tooltipText: str + tooltipBorder: str + + danger: str + + def asDict(self) -> dict[str, str]: + return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)} + + +MODERN_DARK = ThemeColors( + dark = True, + bg = "#23262c", + surface = "#1b1e23", + sidebarBg = "#1f2228", + elevated = "#2b2f36", + altRow = "#1f2228", + border = "#343941", + borderSoft = "#2b2f36", + borderStrong = "#454b55", + text = "#d7dbe1", + textDim = "#939aa6", + textFaint = "#666d78", + accent = "#4a8cff", + accentHover = "#5f9bff", + accentPressed = "#3a79e6", + accentGhost = "rgba(74, 140, 255, 40)", + onAccent = "#ffffff", + hover = "rgba(255, 255, 255, 18)", + pressed = "rgba(255, 255, 255, 32)", + selInactive = "#343a44", + tabSelected = "#1b1e23", + button = "#2c3138", + buttonHover = "#343a43", + buttonPressed = "#262a31", + input = "#15181d", + inputDisabled = "#1e2127", + scrollHandle = "rgba(255, 255, 255, 42)", + scrollHandleHover = "rgba(255, 255, 255, 78)", + tooltipBg = "#2f343c", + tooltipText = "#e4e7ec", + tooltipBorder = "#3d434c", + danger = "#ff6b60", +) + +MODERN_LIGHT = ThemeColors( + dark = False, + bg = "#eef0f3", + surface = "#ffffff", + sidebarBg = "#f6f7f9", + elevated = "#ffffff", + altRow = "#f7f8fa", + border = "#d5d9e0", + borderSoft = "#e4e7ec", + borderStrong = "#bcc2cb", + text = "#1f2329", + textDim = "#6a727d", + textFaint = "#a2a8b1", + accent = "#2f6fed", + accentHover = "#4681f2", + accentPressed = "#255fd6", + accentGhost = "rgba(47, 111, 237, 30)", + onAccent = "#ffffff", + hover = "rgba(0, 0, 0, 16)", + pressed = "rgba(0, 0, 0, 28)", + selInactive = "#dde1e8", + tabSelected = "#ffffff", + button = "#ffffff", + buttonHover = "#f4f6f8", + buttonPressed = "#e9ecf1", + input = "#ffffff", + inputDisabled = "#f2f3f6", + scrollHandle = "rgba(0, 0, 0, 56)", + scrollHandleHover = "rgba(0, 0, 0, 96)", + tooltipBg = "#2f343c", + tooltipText = "#f0f2f5", + tooltipBorder = "#2f343c", + danger = "#d92b1f", +) + + +def systemPrefersDark(fallbackPalette: QPalette | None = None) -> bool: + """ + Detect whether the desktop environment asks for a dark color scheme. + + Falls back to sniffing a palette (typically the palette captured at boot, + before we've overwritten it with a theme of our own). + """ + + with suppress(AttributeError, NameError): + scheme = QGuiApplication.styleHints().colorScheme() + if scheme == Qt.ColorScheme.Dark: + return True + if scheme == Qt.ColorScheme.Light: + return False + + palette = fallbackPalette if fallbackPalette is not None else QApplication.palette() + return palette.color(QPalette.ColorRole.Base).value() < palette.color(QPalette.ColorRole.Text).value() + + +def resolveTheme(theme: AppTheme, fallbackPalette: QPalette | None = None) -> ThemeColors | None: + """Return the color tokens for a theme, or None to keep the system theme.""" + + if theme == AppTheme.ModernDark: + return MODERN_DARK + if theme == AppTheme.ModernLight: + return MODERN_LIGHT + if theme == AppTheme.Modern: + return MODERN_DARK if systemPrefersDark(fallbackPalette) else MODERN_LIGHT + return None + + +def _c(spec: str) -> QColor: + """Parse a theme token into a QColor ('#rrggbb' or 'rgba(r, g, b, a)').""" + + spec = spec.strip() + if spec.startswith("rgba("): + r, g, b, a = (int(x) for x in spec[5:-1].split(",")) + return QColor(r, g, b, a) + return QColor(spec) + + +def _blend(over: QColor, under: QColor) -> QColor: + """Flatten a translucent color onto an opaque one (QPalette wants opaque).""" + + a = over.alphaF() + return QColor( + round(over.red() * a + under.red() * (1 - a)), + round(over.green() * a + under.green() * (1 - a)), + round(over.blue() * a + under.blue() * (1 - a))) + + +def buildPalette(colors: ThemeColors) -> QPalette: + Role = QPalette.ColorRole + Group = QPalette.ColorGroup + + bg = _c(colors.bg) + surface = _c(colors.surface) + text = _c(colors.text) + textDim = _c(colors.textDim) + textFaint = _c(colors.textFaint) + accent = _c(colors.accent) + onAccent = _c(colors.onAccent) + button = _c(colors.button) + selInactive = _c(colors.selInactive) + + palette = QPalette() + + palette.setColor(Role.Window, bg) + palette.setColor(Role.WindowText, text) + palette.setColor(Role.Base, surface) + palette.setColor(Role.AlternateBase, _c(colors.altRow)) + palette.setColor(Role.Text, text) + palette.setColor(Role.Button, button) + palette.setColor(Role.ButtonText, text) + palette.setColor(Role.BrightText, _c(colors.danger)) + palette.setColor(Role.Highlight, accent) + palette.setColor(Role.HighlightedText, onAccent) + palette.setColor(Role.ToolTipBase, _c(colors.tooltipBg)) + palette.setColor(Role.ToolTipText, _c(colors.tooltipText)) + palette.setColor(Role.PlaceholderText, textFaint) + palette.setColor(Role.Link, accent) + palette.setColor(Role.LinkVisited, _c(colors.accentPressed)) + + # 3D bevel roles: Fusion still uses these for frames, grooves and arrows. + palette.setColor(Role.Light, _blend(_c(colors.hover), bg)) + palette.setColor(Role.Midlight, _c(colors.borderSoft)) + palette.setColor(Role.Mid, _c(colors.border)) + palette.setColor(Role.Dark, _c(colors.borderStrong)) + palette.setColor(Role.Shadow, QColor(0, 0, 0, 90 if colors.dark else 40)) + + # Unfocused windows get a muted selection instead of a screaming accent. + palette.setColor(Group.Inactive, Role.Highlight, selInactive) + palette.setColor(Group.Inactive, Role.HighlightedText, text) + + for role in (Role.WindowText, Role.Text, Role.ButtonText): + palette.setColor(Group.Disabled, role, textFaint) + palette.setColor(Group.Disabled, Role.Highlight, selInactive) + palette.setColor(Group.Disabled, Role.HighlightedText, textDim) + palette.setColor(Group.Disabled, Role.Base, _c(colors.inputDisabled)) + palette.setColor(Group.Disabled, Role.Link, textDim) + + return palette + + +def currentTheme() -> ThemeColors | None: + """Color tokens of the theme in effect, or None if we defer to the desktop.""" + + from gitfourchette import settings + + app = QApplication.instance() + fallbackPalette = getattr(app, "platformDefaultPalette", None) + return resolveTheme(settings.prefs.appTheme, fallbackPalette) + + +def buildStyleSheet(colors: ThemeColors) -> str: + template = Path(QFile("assets:style-modern.qss").fileName()).read_text(encoding="utf-8") + return Template(template).substitute(colors.asDict()) diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py index a0d63c09..943ee17a 100644 --- a/gitfourchette/trtables.py +++ b/gitfourchette/trtables.py @@ -115,6 +115,7 @@ def _init_enums(): RefSort, TabBarClick, ) + from gitfourchette.themes import AppTheme from gitfourchette.toolbox import PatchPurpose, PathDisplayStyle, AuthorDisplayStyle from gitfourchette.repomodel import GpgStatus @@ -208,6 +209,13 @@ def _init_enums(): SidebarItem.Spacer : "---", }, + AppTheme: { + AppTheme.Modern : _p("app theme", "Modern (follow system)"), + AppTheme.ModernLight : _p("app theme", "Modern Light"), + AppTheme.ModernDark : _p("app theme", "Modern Dark"), + AppTheme.System : _p("app theme", "Desktop environment"), + }, + PathDisplayStyle: { PathDisplayStyle.FullPaths : _("Full paths"), PathDisplayStyle.AbbreviateDirs : _("Abbreviate directories"), @@ -415,7 +423,14 @@ def _init_prefKeys(): "userCommands": _p("Prefs", "Custom Commands"), "language": _("Language"), + "appTheme": _("Theme"), + "appTheme_help": paragraphs( + _("{app}’s built-in themes give the app a consistent look on any desktop.", app=APP_DISPLAY_NAME), + _("Pick Desktop environment if you’d rather have the app blend in " + "with the rest of your system.")), "qtStyle": _("Qt style"), + "qtStyle_help": _("Leave this on “System default” unless you want to override the widget style " + "that the theme picks for you."), "shortHashChars": _("Shorten hashes to # characters"), "shortTimeFormat": _("Date/time format"), "shortTimeFormat_help": TrTables._timeFormatTable(), From 95bef7051148da99dbb8265f1fdbe576e51bd779 Mon Sep 17 00:00:00 2001 From: Ciansen Date: Sat, 15 Aug 2026 04:29:18 +0200 Subject: [PATCH 2/5] fix: drop executable bit on new theme files themes.py and style-modern.qss were committed with mode 100755, which trips ruff's EXE002 (executable file without a shebang). ruff runs before mypy and the tests in every CI matrix config, so this failed all six test jobs. Co-Authored-By: Claude Opus 5 --- gitfourchette/assets/style-modern.qss | 0 gitfourchette/themes.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 gitfourchette/assets/style-modern.qss mode change 100755 => 100644 gitfourchette/themes.py diff --git a/gitfourchette/assets/style-modern.qss b/gitfourchette/assets/style-modern.qss old mode 100755 new mode 100644 diff --git a/gitfourchette/themes.py b/gitfourchette/themes.py old mode 100755 new mode 100644 From 16df81e09ee16ffe4c95f8761762afc690bb7dad Mon Sep 17 00:00:00 2001 From: Ciansen Date: Sat, 15 Aug 2026 15:04:40 +0200 Subject: [PATCH 3/5] fix(review): code review --- gitfourchette/application.py | 17 ++-- gitfourchette/assets/style-modern.qss | 65 ++++++------ gitfourchette/forms/contextheader.py | 21 ++-- gitfourchette/forms/prefsdialog.py | 28 ++++-- gitfourchette/settings.py | 12 ++- gitfourchette/themes.py | 136 ++++++++++++++------------ gitfourchette/trtables.py | 20 ++-- 7 files changed, 161 insertions(+), 138 deletions(-) diff --git a/gitfourchette/application.py b/gitfourchette/application.py index 1fa8d28e..286f681c 100644 --- a/gitfourchette/application.py +++ b/gitfourchette/application.py @@ -451,7 +451,7 @@ def _applyPrefs(self, prefDiff: dict[str, Any], writeNow=False): self.dispatchSimplePrefsToStandaloneClasses() - if "qtStyle" in prefDiff or "appTheme" in prefDiff: + if "qtStyle" in prefDiff: self.applyQtStylePref(forceApplyDefault=True) self.restyle.emit() @@ -496,14 +496,14 @@ def applyQtStylePref(self, forceApplyDefault: bool): from gitfourchette import settings from gitfourchette import themes - themeColors = themes.resolveTheme(settings.prefs.appTheme, self.platformDefaultPalette) + themeColors = themes.resolveTheme(settings.prefs.qtStyle, self.platformDefaultPalette) - if settings.prefs.qtStyle: - self.setStyle(settings.prefs.qtStyle) - elif themeColors is not None: + if themeColors is not None: # Our themes are designed on top of Fusion. Native styles (Breeze, # Windows, macOS) ignore or mangle much of the stylesheet. self.setStyle("Fusion") + elif settings.prefs.qtStyle: + self.setStyle(settings.prefs.qtStyle) elif forceApplyDefault: self.setStyle(self.platformDefaultStyleName) @@ -511,7 +511,10 @@ def applyQtStylePref(self, forceApplyDefault: bool): if themeColors is not None: self.setPalette(themes.buildPalette(themeColors)) else: - self.setPalette(self.platformDefaultPalette) + # Empty palette: let the style/desktop provide the colors again. + # (Don't restore platformDefaultPalette - it may be stale if the + # user has changed their system palette while the app is running.) + self.setPalette(QPalette()) if MACOS: self.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, settings.qtIsNativeMacosStyle()) @@ -636,7 +639,7 @@ def onRestyle(self): styleSheet += darkSupplement # Append our own theme, if any (its rules take precedence over the above) - themeColors = themes.resolveTheme(settings.prefs.appTheme, self.platformDefaultPalette) + themeColors = themes.resolveTheme(settings.prefs.qtStyle, self.platformDefaultPalette) if themeColors is not None: styleSheet += themes.buildStyleSheet(themeColors) diff --git a/gitfourchette/assets/style-modern.qss b/gitfourchette/assets/style-modern.qss index 0324b16c..96fee942 100644 --- a/gitfourchette/assets/style-modern.qss +++ b/gitfourchette/assets/style-modern.qss @@ -71,11 +71,12 @@ QMenuBar::item:pressed { color: ${onAccent}; } +/* No border-radius here: the popup window itself is rectangular, so rounded + corners would just expose the square backdrop behind them. */ QMenu { background: ${elevated}; color: ${text}; border: 1px solid ${border}; - border-radius: 8px; padding: 5px 4px; } @@ -263,11 +264,13 @@ QLineEdit[readOnly="true"] { /* Note: don't restyle QComboBox::drop-down. As soon as that subcontrol has a rule of its own, Qt stops drawing the style's arrow and expects an image. */ +/* The popup is wrapped in a container frame drawn by the style. Don't add a + second (rounded) frame around the view, or the style's rectangular one + shows through beneath it. */ QComboBox QAbstractItemView { background: ${elevated}; color: ${text}; - border: 1px solid ${border}; - border-radius: 7px; + border: none; padding: 3px; selection-background-color: ${accent}; selection-color: ${onAccent}; @@ -311,15 +314,9 @@ QTabBar::tab:disabled { color: ${textFaint}; } -QTabBar::close-button { - subcontrol-position: right; - border-radius: 4px; - margin: 1px; -} - -QTabBar::close-button:hover { - background: ${pressed}; -} +/* Note: don't restyle QTabBar::close-button. As soon as that subcontrol has a + drawable rule of its own, QStyleSheetStyle paints the rule instead of the + style's "x" glyph, leaving the button invisible (see PE_IndicatorTabClose). */ QTabBar::scroller { width: 0px; @@ -401,10 +398,10 @@ QAbstractItemView::item:selected:active { color: ${onAccent}; } -/* Rounded selection pills in the sidebar & file lists (Fork-style). +/* Rounded selection pills in the sidebar & file lists. The commit graph keeps full-width selection bars so that the lane drawings aren't clipped by rounded corners. */ -Sidebar::item, FileList::item, DirtyFiles::item, StagedFiles::item, CommittedFiles::item { +Sidebar::item, FileList::item { border-radius: 5px; } @@ -414,23 +411,6 @@ Sidebar { show-decoration-selected: 1; } -QHeaderView { - background: ${bg}; - border: none; -} - -QHeaderView::section { - background: ${bg}; - color: ${textDim}; - border: none; - border-bottom: 1px solid ${borderSoft}; - padding: 4px 8px; -} - -QHeaderView::section:hover { - background: ${hover}; -} - /* -------------------------------------------------------------------------- * Scroll bars * ------------------------------------------------------------------------ */ @@ -555,24 +535,43 @@ QToolBox::tab, QDockWidget::title { * ------------------------------------------------------------------------ */ /* Code panes must stay flush - no rounded frame, no inner padding. */ -CodeView, DiffView, BlameTextEdit, SpecialDiffView { +CodeView, SpecialDiffView { background: ${surface}; border: none; border-radius: 0; padding: 0; } +/* QToolButton's padding would squash the 16x16 icon into an ellipse. */ +QHintButton { + padding: 0px; + min-width: 16px; + min-height: 16px; +} + /* Panel titles above the file lists and the diff */ #dirtyHeader, #stagedHeader, #committedHeader, #diffHeader { color: ${textDim}; padding-left: 2px; } -ContextHeader, Banner.diff { +Banner.diff { + border: none; + padding: 3px 2px; +} + +/* ContextHeader.restyle() leaves us alone while one of our themes is active. */ +ContextHeader { + background: ${bg}; border: none; + border-bottom: 1px solid ${borderSoft}; padding: 3px 2px; } +ContextHeader QLabel { + color: ${textDim}; +} + SearchBar QLineEdit { border: 1px solid ${border}; border-radius: 7px; diff --git a/gitfourchette/forms/contextheader.py b/gitfourchette/forms/contextheader.py index 98eb621f..bb4f0ba7 100644 --- a/gitfourchette/forms/contextheader.py +++ b/gitfourchette/forms/contextheader.py @@ -45,17 +45,16 @@ def __init__(self, parent): def restyle(self): from gitfourchette import themes - theme = themes.currentTheme() - if theme is not None: - bg = theme.bg - fg = theme.textDim - border = f"border-bottom: 1px solid {theme.borderSoft};" - else: - bg = mutedTextColorHex(self, .07) - fg = mutedTextColorHex(self, .8) - border = "" - - self.setStyleSheet(f"ContextHeader {{ background-color: {bg}; {border} }}" + if themes.currentTheme() is not None: + # Our themes style ContextHeader in style-modern.qss. Clear any + # widget stylesheet we may have set before the theme kicked in; + # it would win over the application stylesheet. + self.setStyleSheet("") + return + + bg = mutedTextColorHex(self, .07) + fg = mutedTextColorHex(self, .8) + self.setStyleSheet(f"ContextHeader {{ background-color: {bg}; }}" f"ContextHeader QLabel {{ color: {fg}; }}") def addButton( diff --git a/gitfourchette/forms/prefsdialog.py b/gitfourchette/forms/prefsdialog.py index 67937c14..9467fa62 100644 --- a/gitfourchette/forms/prefsdialog.py +++ b/gitfourchette/forms/prefsdialog.py @@ -16,6 +16,7 @@ from gitfourchette.qt import * from gitfourchette.settings import SHORT_DATE_PRESETS, prefs from gitfourchette.syntax import ColorScheme, PygmentsPresets +from gitfourchette.themes import AppTheme from gitfourchette.toolbox import * from gitfourchette.trtables import TrTables @@ -592,17 +593,28 @@ def enumControl(self, prefKey, prefValue, enumType, previewCallback=None) -> QCo return control def qtStyleControl(self, prefKey, prefValue): - defaultCaption = _p("system default theme setting", "System default") + """ + Single dropdown for the app's look: the system default, our built-in + themes, then the native Qt styles offered by this machine. + """ + control = QComboBox(self) - control.addItem(defaultCaption, userData="") - if not prefValue: - control.setCurrentIndex(0) - control.insertSeparator(1) - for availableStyle in QStyleFactory.keys(): # noqa: SIM118 - control.addItem(availableStyle, userData=availableStyle) - if prefValue == availableStyle: + + def addEntry(caption: str, styleName: str): + control.addItem(caption, userData=styleName) + if prefValue == styleName: control.setCurrentIndex(control.count() - 1) + addEntry(TrTables.enum(AppTheme.System), str(AppTheme.System)) + + control.insertSeparator(control.count()) + for theme in (AppTheme.Modern, AppTheme.ModernDark, AppTheme.ModernLight): + addEntry(TrTables.enum(theme), str(theme)) + + control.insertSeparator(control.count()) + for availableStyle in QStyleFactory.keys(): # noqa: SIM118 + addEntry(availableStyle, availableStyle) + def onPickStyle(index): styleName = control.itemData(index, Qt.ItemDataRole.UserRole) self.assign(prefKey, styleName) diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index cbeb9715..3bc85741 100644 --- a/gitfourchette/settings.py +++ b/gitfourchette/settings.py @@ -107,10 +107,12 @@ class Prefs(PrefsFile): _category_general : int = 0 language : str = "" - # Keep the desktop's look in unit tests so that pixel-precise tests - # aren't at the mercy of our own stylesheet's metrics. - appTheme : AppTheme = AppTheme.System if APP_TESTMODE else AppTheme.Modern - qtStyle : str = "" + # Either one of our own themes (see AppTheme), or the name of a native Qt + # style, or "" for the system default. + # On KDE, be a good citizen and stick to the system-provided theme + # (typically Breeze). In unit tests, keep the desktop's look as well so that + # pixel-precise tests aren't at the mercy of our own stylesheet's metrics. + qtStyle : str = str(AppTheme.System if (KDE or APP_TESTMODE) else AppTheme.Modern) pathDisplayStyle : PathDisplayStyle = PathDisplayStyle.FullPaths refSort : RefSort = RefSort.TimeDesc showToolBar : bool = True @@ -442,7 +444,7 @@ class Session(PrefsFile): def qtIsNativeMacosStyle(): # pragma: no cover if not MACOS: return False - if prefs.appTheme.isModern: # our own themes force the Fusion style + if AppTheme.isOurs(prefs.qtStyle): # our own themes force the Fusion style return False return (not prefs.qtStyle) or (prefs.qtStyle.lower() == "macos") diff --git a/gitfourchette/themes.py b/gitfourchette/themes.py index 9d7282bf..fbf99a3d 100644 --- a/gitfourchette/themes.py +++ b/gitfourchette/themes.py @@ -26,14 +26,21 @@ class AppTheme(enum.StrEnum): + """ + Our built-in themes. These share the Prefs.qtStyle namespace with the + native Qt style names (Breeze, Fusion, Windows...), so their values must + not collide with anything QStyleFactory may return. + """ + System = "" Modern = "modern" ModernLight = "modern-light" ModernDark = "modern-dark" - @property - def isModern(self) -> bool: - return self != AppTheme.System + @classmethod + def isOurs(cls, styleName: str) -> bool: + """True if a Prefs.qtStyle value refers to one of our themes.""" + return styleName in (cls.Modern, cls.ModernLight, cls.ModernDark) @dataclasses.dataclass(frozen=True) @@ -104,10 +111,10 @@ def asDict(self) -> dict[str, str]: accent = "#4a8cff", accentHover = "#5f9bff", accentPressed = "#3a79e6", - accentGhost = "rgba(74, 140, 255, 40)", + accentGhost = "#284a8cff", onAccent = "#ffffff", - hover = "rgba(255, 255, 255, 18)", - pressed = "rgba(255, 255, 255, 32)", + hover = "#12ffffff", + pressed = "#20ffffff", selInactive = "#343a44", tabSelected = "#1b1e23", button = "#2c3138", @@ -115,8 +122,8 @@ def asDict(self) -> dict[str, str]: buttonPressed = "#262a31", input = "#15181d", inputDisabled = "#1e2127", - scrollHandle = "rgba(255, 255, 255, 42)", - scrollHandleHover = "rgba(255, 255, 255, 78)", + scrollHandle = "#2affffff", + scrollHandleHover = "#4effffff", tooltipBg = "#2f343c", tooltipText = "#e4e7ec", tooltipBorder = "#3d434c", @@ -139,10 +146,10 @@ def asDict(self) -> dict[str, str]: accent = "#2f6fed", accentHover = "#4681f2", accentPressed = "#255fd6", - accentGhost = "rgba(47, 111, 237, 30)", + accentGhost = "#1e2f6fed", onAccent = "#ffffff", - hover = "rgba(0, 0, 0, 16)", - pressed = "rgba(0, 0, 0, 28)", + hover = "#10000000", + pressed = "#1c000000", selInactive = "#dde1e8", tabSelected = "#ffffff", button = "#ffffff", @@ -150,8 +157,8 @@ def asDict(self) -> dict[str, str]: buttonPressed = "#e9ecf1", input = "#ffffff", inputDisabled = "#f2f3f6", - scrollHandle = "rgba(0, 0, 0, 56)", - scrollHandleHover = "rgba(0, 0, 0, 96)", + scrollHandle = "#38000000", + scrollHandleHover = "#60000000", tooltipBg = "#2f343c", tooltipText = "#f0f2f5", tooltipBorder = "#2f343c", @@ -167,86 +174,89 @@ def systemPrefersDark(fallbackPalette: QPalette | None = None) -> bool: before we've overwritten it with a theme of our own). """ - with suppress(AttributeError, NameError): + from gitfourchette.toolbox import isDarkTheme + + # QStyleHints.colorScheme() and Qt.ColorScheme require Qt 6.5. + # Older bindings raise AttributeError here; drop the suppress along with + # support for Qt < 6.5. + with suppress(AttributeError): scheme = QGuiApplication.styleHints().colorScheme() if scheme == Qt.ColorScheme.Dark: return True if scheme == Qt.ColorScheme.Light: return False - palette = fallbackPalette if fallbackPalette is not None else QApplication.palette() - return palette.color(QPalette.ColorRole.Base).value() < palette.color(QPalette.ColorRole.Text).value() + return isDarkTheme(fallbackPalette) -def resolveTheme(theme: AppTheme, fallbackPalette: QPalette | None = None) -> ThemeColors | None: - """Return the color tokens for a theme, or None to keep the system theme.""" +def resolveTheme(styleName: str, fallbackPalette: QPalette | None = None) -> ThemeColors | None: + """ + Return the color tokens for one of our themes. + + Returns None if styleName isn't ours, i.e. it names a native Qt style or + it's empty (system default) - in that case we don't touch the palette. + """ - if theme == AppTheme.ModernDark: + if styleName == AppTheme.ModernDark: return MODERN_DARK - if theme == AppTheme.ModernLight: + if styleName == AppTheme.ModernLight: return MODERN_LIGHT - if theme == AppTheme.Modern: + if styleName == AppTheme.Modern: return MODERN_DARK if systemPrefersDark(fallbackPalette) else MODERN_LIGHT return None -def _c(spec: str) -> QColor: - """Parse a theme token into a QColor ('#rrggbb' or 'rgba(r, g, b, a)').""" - - spec = spec.strip() - if spec.startswith("rgba("): - r, g, b, a = (int(x) for x in spec[5:-1].split(",")) - return QColor(r, g, b, a) - return QColor(spec) - - -def _blend(over: QColor, under: QColor) -> QColor: - """Flatten a translucent color onto an opaque one (QPalette wants opaque).""" - - a = over.alphaF() - return QColor( - round(over.red() * a + under.red() * (1 - a)), - round(over.green() * a + under.green() * (1 - a)), - round(over.blue() * a + under.blue() * (1 - a))) - - def buildPalette(colors: ThemeColors) -> QPalette: + from gitfourchette.toolbox import mixColors + Role = QPalette.ColorRole Group = QPalette.ColorGroup - bg = _c(colors.bg) - surface = _c(colors.surface) - text = _c(colors.text) - textDim = _c(colors.textDim) - textFaint = _c(colors.textFaint) - accent = _c(colors.accent) - onAccent = _c(colors.onAccent) - button = _c(colors.button) - selInactive = _c(colors.selInactive) + bg = QColor(colors.bg) + surface = QColor(colors.surface) + text = QColor(colors.text) + textDim = QColor(colors.textDim) + textFaint = QColor(colors.textFaint) + accent = QColor(colors.accent) + onAccent = QColor(colors.onAccent) + button = QColor(colors.button) + selInactive = QColor(colors.selInactive) + + # Flatten the translucent hover tint onto the background (QPalette wants opaque colors). + hover = QColor(colors.hover) + hoverOpaque = mixColors(bg, hover, ratio=hover.alphaF()) + hoverOpaque.setAlphaF(1) palette = QPalette() palette.setColor(Role.Window, bg) palette.setColor(Role.WindowText, text) palette.setColor(Role.Base, surface) - palette.setColor(Role.AlternateBase, _c(colors.altRow)) + palette.setColor(Role.AlternateBase, QColor(colors.altRow)) palette.setColor(Role.Text, text) palette.setColor(Role.Button, button) palette.setColor(Role.ButtonText, text) - palette.setColor(Role.BrightText, _c(colors.danger)) + palette.setColor(Role.BrightText, QColor(colors.danger)) palette.setColor(Role.Highlight, accent) palette.setColor(Role.HighlightedText, onAccent) - palette.setColor(Role.ToolTipBase, _c(colors.tooltipBg)) - palette.setColor(Role.ToolTipText, _c(colors.tooltipText)) + palette.setColor(Role.ToolTipBase, QColor(colors.tooltipBg)) + palette.setColor(Role.ToolTipText, QColor(colors.tooltipText)) palette.setColor(Role.PlaceholderText, textFaint) palette.setColor(Role.Link, accent) - palette.setColor(Role.LinkVisited, _c(colors.accentPressed)) + palette.setColor(Role.LinkVisited, QColor(colors.accentPressed)) + + # GF uses the accent color in CodeRubberBand. Qt provides a blueish accent + # color by default, but KDE lets the user set their own accent color, and + # it'll come through unless we override it. + # (Role.Accent doesn't exist in old Qt versions.) + with suppress(AttributeError): + palette.setColor(Role.Accent, accent) # 3D bevel roles: Fusion still uses these for frames, grooves and arrows. - palette.setColor(Role.Light, _blend(_c(colors.hover), bg)) - palette.setColor(Role.Midlight, _c(colors.borderSoft)) - palette.setColor(Role.Mid, _c(colors.border)) - palette.setColor(Role.Dark, _c(colors.borderStrong)) + palette.setColor(Role.Light, hoverOpaque) + palette.setColor(Role.Midlight, QColor(colors.borderSoft)) + palette.setColor(Role.Mid, QColor(colors.border)) + palette.setColor(Role.Dark, QColor(colors.borderStrong)) palette.setColor(Role.Shadow, QColor(0, 0, 0, 90 if colors.dark else 40)) # Unfocused windows get a muted selection instead of a screaming accent. @@ -257,7 +267,7 @@ def buildPalette(colors: ThemeColors) -> QPalette: palette.setColor(Group.Disabled, role, textFaint) palette.setColor(Group.Disabled, Role.Highlight, selInactive) palette.setColor(Group.Disabled, Role.HighlightedText, textDim) - palette.setColor(Group.Disabled, Role.Base, _c(colors.inputDisabled)) + palette.setColor(Group.Disabled, Role.Base, QColor(colors.inputDisabled)) palette.setColor(Group.Disabled, Role.Link, textDim) return palette @@ -267,10 +277,10 @@ def currentTheme() -> ThemeColors | None: """Color tokens of the theme in effect, or None if we defer to the desktop.""" from gitfourchette import settings + from gitfourchette.application import GFApplication - app = QApplication.instance() - fallbackPalette = getattr(app, "platformDefaultPalette", None) - return resolveTheme(settings.prefs.appTheme, fallbackPalette) + fallbackPalette = GFApplication.instance().platformDefaultPalette + return resolveTheme(settings.prefs.qtStyle, fallbackPalette) def buildStyleSheet(colors: ThemeColors) -> str: diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py index 943ee17a..599359d4 100644 --- a/gitfourchette/trtables.py +++ b/gitfourchette/trtables.py @@ -210,10 +210,10 @@ def _init_enums(): }, AppTheme: { - AppTheme.Modern : _p("app theme", "Modern (follow system)"), - AppTheme.ModernLight : _p("app theme", "Modern Light"), - AppTheme.ModernDark : _p("app theme", "Modern Dark"), - AppTheme.System : _p("app theme", "Desktop environment"), + AppTheme.Modern : _p("app theme", "Modern (Follow System)"), + AppTheme.ModernDark : _p("app theme", "Dark Modern"), + AppTheme.ModernLight : _p("app theme", "Light Modern"), + AppTheme.System : _p("app theme", "System default"), }, PathDisplayStyle: { @@ -423,14 +423,12 @@ def _init_prefKeys(): "userCommands": _p("Prefs", "Custom Commands"), "language": _("Language"), - "appTheme": _("Theme"), - "appTheme_help": paragraphs( - _("{app}’s built-in themes give the app a consistent look on any desktop.", app=APP_DISPLAY_NAME), - _("Pick Desktop environment if you’d rather have the app blend in " - "with the rest of your system.")), "qtStyle": _("Qt style"), - "qtStyle_help": _("Leave this on “System default” unless you want to override the widget style " - "that the theme picks for you."), + "qtStyle_help": paragraphs( + _("{app}’s built-in themes give the app a consistent look on any desktop.", app=APP_DISPLAY_NAME), + _("Pick System default if you’d rather have the app blend in " + "with the rest of your system, or pick one of the Qt styles " + "installed on this machine to override the widget style.")), "shortHashChars": _("Shorten hashes to # characters"), "shortTimeFormat": _("Date/time format"), "shortTimeFormat_help": TrTables._timeFormatTable(), From f7ef80ade047b88d967bc8e73d442f028b1d9b14 Mon Sep 17 00:00:00 2001 From: Ciansen Date: Sat, 15 Aug 2026 15:18:03 +0200 Subject: [PATCH 4/5] chore: remove systemPrefersDark --- gitfourchette/themes.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/gitfourchette/themes.py b/gitfourchette/themes.py index fbf99a3d..772a6d52 100644 --- a/gitfourchette/themes.py +++ b/gitfourchette/themes.py @@ -165,30 +165,6 @@ def asDict(self) -> dict[str, str]: danger = "#d92b1f", ) - -def systemPrefersDark(fallbackPalette: QPalette | None = None) -> bool: - """ - Detect whether the desktop environment asks for a dark color scheme. - - Falls back to sniffing a palette (typically the palette captured at boot, - before we've overwritten it with a theme of our own). - """ - - from gitfourchette.toolbox import isDarkTheme - - # QStyleHints.colorScheme() and Qt.ColorScheme require Qt 6.5. - # Older bindings raise AttributeError here; drop the suppress along with - # support for Qt < 6.5. - with suppress(AttributeError): - scheme = QGuiApplication.styleHints().colorScheme() - if scheme == Qt.ColorScheme.Dark: - return True - if scheme == Qt.ColorScheme.Light: - return False - - return isDarkTheme(fallbackPalette) - - def resolveTheme(styleName: str, fallbackPalette: QPalette | None = None) -> ThemeColors | None: """ Return the color tokens for one of our themes. @@ -197,12 +173,14 @@ def resolveTheme(styleName: str, fallbackPalette: QPalette | None = None) -> The it's empty (system default) - in that case we don't touch the palette. """ + from gitfourchette.toolbox.qtutils import isDarkTheme + if styleName == AppTheme.ModernDark: return MODERN_DARK if styleName == AppTheme.ModernLight: return MODERN_LIGHT if styleName == AppTheme.Modern: - return MODERN_DARK if systemPrefersDark(fallbackPalette) else MODERN_LIGHT + return MODERN_DARK if isDarkTheme(fallbackPalette) else MODERN_LIGHT return None From 7e7b74b7a8544b3508404f987281cadab90f1f3f Mon Sep 17 00:00:00 2001 From: Ciansen Date: Sat, 15 Aug 2026 15:29:56 +0200 Subject: [PATCH 5/5] chore: added suppress comments --- gitfourchette/themes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gitfourchette/themes.py b/gitfourchette/themes.py index 772a6d52..04c7aada 100644 --- a/gitfourchette/themes.py +++ b/gitfourchette/themes.py @@ -165,6 +165,7 @@ def asDict(self) -> dict[str, str]: danger = "#d92b1f", ) + def resolveTheme(styleName: str, fallbackPalette: QPalette | None = None) -> ThemeColors | None: """ Return the color tokens for one of our themes. @@ -226,7 +227,8 @@ def buildPalette(colors: ThemeColors) -> QPalette: # GF uses the accent color in CodeRubberBand. Qt provides a blueish accent # color by default, but KDE lets the user set their own accent color, and # it'll come through unless we override it. - # (Role.Accent doesn't exist in old Qt versions.) + # Qt 6.6 introduces QPalette.ColorRole.Accent; older versions raise + # AttributeError here. Drop the suppress along with support for Qt < 6.6. with suppress(AttributeError): palette.setColor(Role.Accent, accent)