diff --git a/gitfourchette/application.py b/gitfourchette/application.py index 61600a0c..286f681c 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) @@ -449,6 +453,7 @@ def _applyPrefs(self, prefDiff: dict[str, Any], writeNow=False): if "qtStyle" in prefDiff: self.applyQtStylePref(forceApplyDefault=True) + self.restyle.emit() if "language" in prefDiff: self.applyLanguagePref() @@ -489,12 +494,28 @@ def applyLanguagePref(self): def applyQtStylePref(self, forceApplyDefault: bool): from gitfourchette import settings + from gitfourchette import themes + + themeColors = themes.resolveTheme(settings.prefs.qtStyle, self.platformDefaultPalette) - if settings.prefs.qtStyle: + 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) + # Note: setStyle() resets the palette, so this must come after it. + if themeColors is not None: + self.setPalette(themes.buildPalette(themeColors)) + else: + # 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()) @@ -594,21 +615,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.qtStyle, 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 100644 index 00000000..96fee942 --- /dev/null +++ b/gitfourchette/assets/style-modern.qss @@ -0,0 +1,584 @@ +/* --------------------------------------------------------------------------- + * "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}; +} + +/* 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}; + 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. */ + +/* 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: none; + 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}; +} + +/* 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; +} + +/* 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. + The commit graph keeps full-width selection bars so that the lane + drawings aren't clipped by rounded corners. */ +Sidebar::item, FileList::item { + border-radius: 5px; +} + +Sidebar { + background: ${sidebarBg}; + color: ${text}; + show-decoration-selected: 1; +} + +/* -------------------------------------------------------------------------- + * 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, 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; +} + +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; + 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..bb4f0ba7 100644 --- a/gitfourchette/forms/contextheader.py +++ b/gitfourchette/forms/contextheader.py @@ -43,9 +43,19 @@ def __init__(self, parent): GFApplication.instance().restyle.connect(self.restyle) def restyle(self): + from gitfourchette import themes + + 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}; }} ContextHeader QLabel {{ color: {fg}; }}") + self.setStyleSheet(f"ContextHeader {{ background-color: {bg}; }}" + f"ContextHeader QLabel {{ color: {fg}; }}") def addButton( self, 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 006e4ada..3bc85741 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,7 +107,12 @@ class Prefs(PrefsFile): _category_general : int = 0 language : str = "" - 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 @@ -438,6 +444,8 @@ class Session(PrefsFile): def qtIsNativeMacosStyle(): # pragma: no cover if not MACOS: return False + 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 new file mode 100644 index 00000000..04c7aada --- /dev/null +++ b/gitfourchette/themes.py @@ -0,0 +1,268 @@ +# ----------------------------------------------------------------------------- +# 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): + """ + 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" + + @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) +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 = "#284a8cff", + onAccent = "#ffffff", + hover = "#12ffffff", + pressed = "#20ffffff", + selInactive = "#343a44", + tabSelected = "#1b1e23", + button = "#2c3138", + buttonHover = "#343a43", + buttonPressed = "#262a31", + input = "#15181d", + inputDisabled = "#1e2127", + scrollHandle = "#2affffff", + scrollHandleHover = "#4effffff", + 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 = "#1e2f6fed", + onAccent = "#ffffff", + hover = "#10000000", + pressed = "#1c000000", + selInactive = "#dde1e8", + tabSelected = "#ffffff", + button = "#ffffff", + buttonHover = "#f4f6f8", + buttonPressed = "#e9ecf1", + input = "#ffffff", + inputDisabled = "#f2f3f6", + scrollHandle = "#38000000", + scrollHandleHover = "#60000000", + tooltipBg = "#2f343c", + tooltipText = "#f0f2f5", + tooltipBorder = "#2f343c", + danger = "#d92b1f", +) + + +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. + """ + + 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 isDarkTheme(fallbackPalette) else MODERN_LIGHT + return None + + +def buildPalette(colors: ThemeColors) -> QPalette: + from gitfourchette.toolbox import mixColors + + Role = QPalette.ColorRole + Group = QPalette.ColorGroup + + 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, QColor(colors.altRow)) + palette.setColor(Role.Text, text) + palette.setColor(Role.Button, button) + palette.setColor(Role.ButtonText, text) + palette.setColor(Role.BrightText, QColor(colors.danger)) + palette.setColor(Role.Highlight, accent) + palette.setColor(Role.HighlightedText, onAccent) + 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, 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. + # 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) + + # 3D bevel roles: Fusion still uses these for frames, grooves and arrows. + 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. + 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, QColor(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 + from gitfourchette.application import GFApplication + + fallbackPalette = GFApplication.instance().platformDefaultPalette + return resolveTheme(settings.prefs.qtStyle, 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..599359d4 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.ModernDark : _p("app theme", "Dark Modern"), + AppTheme.ModernLight : _p("app theme", "Light Modern"), + AppTheme.System : _p("app theme", "System default"), + }, + PathDisplayStyle: { PathDisplayStyle.FullPaths : _("Full paths"), PathDisplayStyle.AbbreviateDirs : _("Abbreviate directories"), @@ -416,6 +424,11 @@ def _init_prefKeys(): "language": _("Language"), "qtStyle": _("Qt style"), + "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(),