diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg
new file mode 100644
index 00000000..403cbcca
--- /dev/null
+++ b/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg
@@ -0,0 +1,3 @@
+
diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg
new file mode 100644
index 00000000..ae477de6
--- /dev/null
+++ b/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg
@@ -0,0 +1,8 @@
+
diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg
new file mode 100644
index 00000000..7b0d1e7d
--- /dev/null
+++ b/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg
@@ -0,0 +1,8 @@
+
diff --git a/gitfourchette/assets/icons/diff-whitespace-strict.svg b/gitfourchette/assets/icons/diff-whitespace-strict.svg
new file mode 100644
index 00000000..530affaa
--- /dev/null
+++ b/gitfourchette/assets/icons/diff-whitespace-strict.svg
@@ -0,0 +1,3 @@
+
diff --git a/gitfourchette/assets/icons/format-text-wrap.svg b/gitfourchette/assets/icons/format-text-wrap.svg
new file mode 100644
index 00000000..3c8faf89
--- /dev/null
+++ b/gitfourchette/assets/icons/format-text-wrap.svg
@@ -0,0 +1,6 @@
+
diff --git a/gitfourchette/assets/icons/paragraph.svg b/gitfourchette/assets/icons/paragraph.svg
new file mode 100644
index 00000000..76c01493
--- /dev/null
+++ b/gitfourchette/assets/icons/paragraph.svg
@@ -0,0 +1,6 @@
+
diff --git a/gitfourchette/assets/style.qss b/gitfourchette/assets/style.qss
index a665b9c7..00449fe4 100644
--- a/gitfourchette/assets/style.qss
+++ b/gitfourchette/assets/style.qss
@@ -27,4 +27,5 @@ DiffView[dark="true"] { selection-background-color: rgba(255, 255, 255, 26%); }
QFaintSeparator { border: none; background: rgba(0, 0, 0, 15%); }
/* Remove QToolButton dropdown arrow in Fusion style */
+DiffButtons QToolButton::menu-indicator,
GpgButton::menu-indicator { image: none; }
diff --git a/gitfourchette/codeview/codehighlighter.py b/gitfourchette/codeview/codehighlighter.py
index a28d1312..09ea5ef8 100644
--- a/gitfourchette/codeview/codehighlighter.py
+++ b/gitfourchette/codeview/codehighlighter.py
@@ -1,5 +1,5 @@
# -----------------------------------------------------------------------------
-# Copyright (C) 2025 Iliyas Jorio.
+# 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.
# -----------------------------------------------------------------------------
@@ -7,6 +7,7 @@
import logging
from gitfourchette import colors
+from gitfourchette import settings
from gitfourchette.syntax import ColorScheme, LexJob
from gitfourchette.qt import *
from gitfourchette.toolbox import benchmark, CallbackAccumulator
@@ -18,6 +19,10 @@ class CodeHighlighter(QSyntaxHighlighter):
scheme: ColorScheme
lexJobs: list[LexJob]
+ _whitespaceRegex = QRegularExpression("[ \t]+")
+ _whitespaceRegex.optimize()
+ assert _whitespaceRegex.isValid()
+
def __init__(self, parent):
super().__init__(parent)
@@ -50,8 +55,20 @@ def stopLexJobs(self):
self.lexJobs.clear()
def highlightBlock(self, text: str):
- if self.scheme and self.lexJobs:
- self.highlightSyntax(text)
+ if self.scheme:
+ # Highlight pygments tokens
+ if self.lexJobs:
+ self.highlightSyntax(text)
+
+ # Override highlighting on whitespace with a nicer muted color
+ if settings.prefs.showFormattingMarks:
+ charFormat = self.scheme.whitespaceFormat
+ wsIter = self._whitespaceRegex.globalMatch(text)
+ while wsIter.hasNext():
+ match = wsIter.next()
+ self.setFormat(match.capturedStart(), match.capturedLength(), charFormat)
+
+ # Override highlighting on search term
if self.searchTerm:
self.highlightSearch(text)
diff --git a/gitfourchette/codeview/codeview.py b/gitfourchette/codeview/codeview.py
index b759e4ca..53c862ae 100644
--- a/gitfourchette/codeview/codeview.py
+++ b/gitfourchette/codeview/codeview.py
@@ -40,6 +40,8 @@ class CodeView(QPlainTextEdit):
currentLocator: NavLocator
isDetachedWindow: bool
+ FormattingMarkFlags = QTextOption.Flag.ShowTabsAndSpaces
+
def __init__(self, gutterClass, highlighterClass=CodeHighlighter, parent=None):
super().__init__(parent)
@@ -284,6 +286,7 @@ def refreshPrefs(self, changeColorScheme=True):
tabWidth = settings.prefs.tabSpaces
self.setTabStopDistance(QFontMetricsF(monoFont).horizontalAdvance(' ' * tabWidth))
self.refreshWordWrap()
+ self.refreshFormattingMarksOption()
self.setCursorWidth(2)
self.gutter.syncFont(monoFont)
@@ -307,7 +310,6 @@ def setColorScheme(self, scheme: ColorScheme):
styleSheet = scheme.basicQss(self)
self.setStyleSheet(styleSheet)
-
def refreshWordWrap(self):
if settings.prefs.wordWrap:
wrapMode = QPlainTextEdit.LineWrapMode.WidgetWidth
@@ -327,7 +329,20 @@ def refreshWordWrap(self):
def toggleWordWrap(self):
settings.prefs.wordWrap = not settings.prefs.wordWrap
settings.prefs.write()
- self.refreshWordWrap()
+ GFApplication.instance().prefsChanged.emit(["wordWrap"])
+
+ def refreshFormattingMarksOption(self):
+ doc = self.document()
+ if doc is None:
+ return
+ opt = QTextOption(doc.defaultTextOption())
+ flags = opt.flags()
+ if settings.prefs.showFormattingMarks:
+ flags |= self.FormattingMarkFlags
+ else:
+ flags &= ~self.FormattingMarkFlags
+ opt.setFlags(flags)
+ doc.setDefaultTextOption(opt)
# ---------------------------------------------
# Context menu
diff --git a/gitfourchette/diffarea.py b/gitfourchette/diffarea.py
index f9c5a134..bf5e0c9a 100644
--- a/gitfourchette/diffarea.py
+++ b/gitfourchette/diffarea.py
@@ -8,6 +8,8 @@
import typing
from typing import Literal
+from gitfourchette.application import GFApplication
+from gitfourchette.diffbuttons import DiffButtons
from gitfourchette.diffview.diffview import DiffView
from gitfourchette.diffview.specialdiffview import SpecialDiffView
from gitfourchette.filelists.committedfiles import CommittedFiles
@@ -80,6 +82,9 @@ def __init__(self, repoModel, parent):
):
passiveWidget.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+ GFApplication.instance().prefsChanged.connect(self.diffButtons.refreshPrefs)
+ self.diffButtons.refreshPrefs()
+
# Ignore height in size policy to keep DiffArea from jumping around when we're showing a banner.
self.setSizePolicy(self.sizePolicy().horizontalPolicy(), QSizePolicy.Policy.Ignored)
self.setMinimumHeight(175)
@@ -253,13 +258,18 @@ def _makeDiffContainer(self, repoModel):
header.setObjectName("diffHeader")
header.setMinimumHeight(FILEHEADER_HEIGHT)
header.setContentsMargins(4, 0, 4, 0)
- # Don't let header dictate window width if displaying long filename
+ header.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ # Absorb horizontal space so the path stays left and toolbar stays right.
header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Minimum)
+ diffTools = DiffButtons(self)
+
topContainer = QWidget(self)
topLayout = QHBoxLayout(topContainer)
topLayout.setContentsMargins(0, 0, 0, 0)
- topLayout.addWidget(header)
+ topLayout.setSpacing(0)
+ topLayout.addWidget(header, 1)
+ topLayout.addWidget(diffTools, 0)
diff = DiffView(self)
@@ -296,17 +306,25 @@ def _makeDiffContainer(self, repoModel):
self.conflictView = conflict
self.specialDiffView = specialDiff
self.diffView = diff
+ self.diffButtons = diffTools
return stackContainer
def applyCustomStyling(self):
- for smallButton in self.discardButton, self.unstageButton, self.stageButton:
+ for smallButton in (
+ self.discardButton,
+ self.unstageButton,
+ self.stageButton,
+ *self.diffButtons.buttons,
+ ):
smallButton.setMaximumHeight(FILEHEADER_HEIGHT)
- smallButton.setEnabled(False)
smallButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
smallButton.setAutoRaise(True)
smallButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
+ for button in self.stageButton, self.unstageButton, self.discardButton:
+ button.setEnabled(False)
+
for button in self.stageButton, self.unstageButton:
button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
@@ -320,6 +338,7 @@ def applyCustomStyling(self):
self.stageButton,
self.unstageButton,
self.discardButton,
+ *self.diffButtons.buttons,
):
tweakWidgetFont(smallWidget, 90)
diff --git a/gitfourchette/diffbuttons.py b/gitfourchette/diffbuttons.py
new file mode 100644
index 00000000..9d6cae18
--- /dev/null
+++ b/gitfourchette/diffbuttons.py
@@ -0,0 +1,131 @@
+# -----------------------------------------------------------------------------
+# 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.
+# -----------------------------------------------------------------------------
+
+from gitfourchette import settings
+from gitfourchette.application import GFApplication
+from gitfourchette.qt import *
+from gitfourchette.settings import ComparisonMethod
+from gitfourchette.toolbox import *
+from gitfourchette.trtables import TrTables
+
+_ComparisonMethodIconTable = {
+ ComparisonMethod.Strict: "diff-whitespace-strict",
+ ComparisonMethod.IgnoreCrAtEol: "diff-whitespace-ignore-eol",
+ ComparisonMethod.IgnoreCrAtEolAndSpaceChange: "diff-whitespace-ignore-space-change",
+ ComparisonMethod.IgnoreCrAtEolAndAllSpace: "diff-whitespace-ignore-all-space",
+}
+
+
+class DiffButtons(QWidget):
+ def __init__(self, parent):
+ super().__init__(parent)
+
+ self.diffMethodActions: dict[ComparisonMethod, QAction] = {}
+
+ self.wordWrapButton = self._makeWordWrapButton()
+ self.marksButton = self._makeMarksButton()
+ self.diffMethodButton = self._makeDiffMethodButton()
+
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 2, 0)
+ layout.setSpacing(2)
+ for button in self.buttons:
+ layout.addWidget(button)
+
+ @property
+ def buttons(self):
+ return self.wordWrapButton, self.marksButton, self.diffMethodButton
+
+ # -------------------------------------------------------------------------
+ # Constructor helpers
+
+ def _makeWordWrapButton(self):
+ button = QToolButton(self)
+ button.setCheckable(True)
+ button.setIcon(stockIcon("format-text-wrap"))
+ button.setToolTip(TrTables.prefKey("wordWrap"))
+ button.toggled.connect(self.onWordWrapToggled)
+ return button
+
+ def _makeMarksButton(self):
+ button = QToolButton(self)
+ button.setCheckable(True)
+ button.setIcon(stockIcon("paragraph"))
+ button.setToolTip(TrTables.prefKey("showFormattingMarks"))
+ button.toggled.connect(self.onMarksToggled)
+ return button
+
+ def _makeDiffMethodButton(self):
+ menu = QMenu(self)
+
+ actionGroup = QActionGroup(menu)
+ actionGroup.setExclusive(True)
+
+ for method in ComparisonMethod:
+ label = escamp(TrTables.enum(method))
+ action = QAction(label)
+ action.setIcon(stockIcon(_ComparisonMethodIconTable[method]))
+ action.triggered.connect(lambda _dummy, m=method: self.onDiffMethodChosen(m))
+ action.setActionGroup(actionGroup)
+ action.setCheckable(True)
+ menu.addAction(action)
+ self.diffMethodActions[method] = action
+
+ button = QToolButton(self)
+ button.setMenu(menu)
+ button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
+ return button
+
+ # -------------------------------------------------------------------------
+ # Sync with preferences
+
+ def refreshPrefs(self):
+ with QSignalBlockerContext(
+ self.wordWrapButton,
+ self.marksButton,
+ *self.diffMethodActions.values(),
+ ):
+ self.wordWrapButton.setChecked(settings.prefs.wordWrap)
+ self.marksButton.setChecked(settings.prefs.showFormattingMarks)
+
+ method = settings.prefs.comparisonMethod
+ for m, action in self.diffMethodActions.items():
+ action.setChecked(m == method)
+ action = self.diffMethodActions[method]
+
+ toolTip = stripAccelerators(action.text())
+ self.diffMethodButton.setIcon(action.icon())
+ self.diffMethodButton.setToolTip(toolTip)
+
+ # -------------------------------------------------------------------------
+ # Button callbacks
+
+ def onWordWrapToggled(self, checked: bool):
+ if settings.prefs.wordWrap == checked:
+ return
+ settings.prefs.wordWrap = checked
+ settings.prefs.write()
+ GFApplication.instance().prefsChanged.emit(["wordWrap"])
+
+ def onMarksToggled(self, checked: bool):
+ if settings.prefs.showFormattingMarks == checked:
+ return
+ settings.prefs.showFormattingMarks = checked
+ settings.prefs.write()
+ GFApplication.instance().prefsChanged.emit(["showFormattingMarks"])
+
+ def onDiffMethodChosen(self, method: settings.ComparisonMethod):
+ if settings.prefs.comparisonMethod == method:
+ return
+ settings.prefs.comparisonMethod = method
+ settings.prefs.write()
+
+ GFApplication.instance().prefsChanged.emit(["comparisonMethod"])
+
+ # Trigger a reload of the patch
+ # TODO: This is inelegant, but it does the job for now. Ideally prefsChanged would suffice?
+ GFApplication.instance().mainWindow.onAcceptPrefsDialog({"comparisonMethod": method})
diff --git a/gitfourchette/diffview/diffhighlighter.py b/gitfourchette/diffview/diffhighlighter.py
index 576878a8..7d8833c6 100644
--- a/gitfourchette/diffview/diffhighlighter.py
+++ b/gitfourchette/diffview/diffhighlighter.py
@@ -44,7 +44,7 @@ def highlightSyntax(self, text: str):
if not lineData.origin: # Hunk header, etc.
return
- if lineData.origin == '+':
+ if lineData.origin != '-':
lexJob = self.newLexJob
lineNumber = lineData.newLineNo
else:
diff --git a/gitfourchette/diffview/diffview.py b/gitfourchette/diffview/diffview.py
index 9e89d19e..88c6c67a 100644
--- a/gitfourchette/diffview/diffview.py
+++ b/gitfourchette/diffview/diffview.py
@@ -61,6 +61,7 @@ def __init__(self, parent=None):
self.gutter.lineDoubleClicked.connect(self.selectClumpOfLinesAt)
self.gutter.selectionMiddleClicked.connect(self.onMiddleClick)
+
def _initRubberBandButtons(self):
self.stageButton = QToolButton()
self.stageButton.setText(_("Stage Selection"))
diff --git a/gitfourchette/diffview/specialdiff.py b/gitfourchette/diffview/specialdiff.py
index 1484abe1..5f7e9398 100644
--- a/gitfourchette/diffview/specialdiff.py
+++ b/gitfourchette/diffview/specialdiff.py
@@ -21,6 +21,7 @@
from gitfourchette.nav import NavLocator, NavContext, NavFlags
from gitfourchette.porcelain import *
from gitfourchette.qt import *
+from gitfourchette.settings import ComparisonMethod
from gitfourchette.tasks import RepoTask
from gitfourchette.toolbox import *
from gitfourchette.trtables import TrTables
@@ -98,6 +99,14 @@ def noChange(repo: Repo, delta: GitDelta, stderr: str = ""):
assert delta.status in "A?" # added or untracked
message = _("New empty file.")
+ if (newFileExists
+ and oldFileExists
+ and newFile.id != oldFile.id
+ and settings.prefs.comparisonMethod != ComparisonMethod.Strict):
+ message = _("Whitespace changes ignored. Contents otherwise identical.")
+ detailsLine = "{}{} {}.".format(TrTables.prefKey("comparisonMethod"), _(":"), TrTables.enum(settings.prefs.comparisonMethod))
+ details.append(detailsLine)
+
if oldFile.path != newFile.path:
intro = _("Renamed:")
details.append(f"{intro} {hquo(oldFile.path)} → {hquo(newFile.path)}.")
diff --git a/gitfourchette/forms/prefsdialog.py b/gitfourchette/forms/prefsdialog.py
index 92529d7d..0ff3b764 100644
--- a/gitfourchette/forms/prefsdialog.py
+++ b/gitfourchette/forms/prefsdialog.py
@@ -379,6 +379,8 @@ def makeControlWidget(self, key: str, value, caption: str) -> QWidget:
presets[_("Built-in git (sandboxed)")] = builtInGit
presets[_("Auto-detected system git")] = ToolPresets.defaultGit(hostOnly=True)
return self.strControlWithPresets(key, value, presets)
+ elif key == "comparisonMethod":
+ return self.radioEnumControl(key, value, type(value))
elif issubclass(valueType, enum.Enum):
return self.enumControl(key, value, type(value))
elif valueType is int:
@@ -555,6 +557,35 @@ def enumControl(self, prefKey, prefValue, enumType, previewCallback=None):
return control
+ def radioEnumControl(self, prefKey: str, prefValue, enumType):
+ container = QWidget(self)
+ group = QButtonGroup(container)
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(4)
+
+ members = [m for m in enumType if TrTables.enum(m) != ""]
+
+ buttons: list[QRadioButton] = []
+ for i, enumMember in enumerate(members):
+ rb = QRadioButton(TrTables.enum(enumMember), container)
+ group.addButton(rb, i)
+ layout.addWidget(rb)
+ buttons.append(rb)
+ if prefValue == enumMember:
+ rb.setChecked(True)
+
+ if members and not any(b.isChecked() for b in buttons):
+ buttons[0].setChecked(True)
+
+ def on_button_clicked(button):
+ idx = group.id(button)
+ if 0 <= idx < len(members):
+ self.assign(prefKey, members[idx])
+
+ group.buttonClicked.connect(on_button_clicked)
+ return container
+
def qtStyleControl(self, prefKey, prefValue):
defaultCaption = _p("system default theme setting", "System default")
control = QComboBox(self)
diff --git a/gitfourchette/gitdriver/gitdriver.py b/gitfourchette/gitdriver/gitdriver.py
index 4f4c51c1..07dc4b42 100644
--- a/gitfourchette/gitdriver/gitdriver.py
+++ b/gitfourchette/gitdriver/gitdriver.py
@@ -17,6 +17,7 @@
from gitfourchette import settings
from gitfourchette.exttools.toolcommands import ToolCommands
+from gitfourchette.settings import ComparisonMethod
from gitfourchette.gitdriver.gitdelta import GitDelta
from gitfourchette.gitdriver.lfspointer import LfsObjectCacheMissingError
from gitfourchette.gitdriver.parsers import parseGitStatus, parseGitDiffRawZ
@@ -307,16 +308,38 @@ def readDiffRawZ(self) -> list[GitDelta]:
deltas = list(parseGitDiffRawZ(stdout))
return deltas
+ @staticmethod
+ def _gitDiffComparisonArgv(forDisplay: bool) -> list[str]:
+ """
+ Extra `git diff` arguments for line-ending / whitespace handling.
+ Only applied when generating patches for on-screen display so exports,
+ revert, and trash backups stay exact.
+ """
+ if not forDisplay:
+ return []
+ m = settings.prefs.comparisonMethod
+ if m == ComparisonMethod.Strict:
+ return []
+ if m == ComparisonMethod.IgnoreCrAtEol:
+ return ["--ignore-cr-at-eol"]
+ if m == ComparisonMethod.IgnoreCrAtEolAndSpaceChange:
+ return ["--ignore-cr-at-eol", "--ignore-space-change"]
+ if m == ComparisonMethod.IgnoreCrAtEolAndAllSpace:
+ return ["--ignore-cr-at-eol", "--ignore-all-space"]
+ return []
+
@classmethod
def buildDiffCommand(
cls,
delta: GitDelta | tuple[Oid | None, Oid | None] | None,
- binary=True
+ binary=True,
+ forDisplay: bool = False,
) -> list[str]:
tokens = [
"-c", "core.abbrev=no",
"-c", f"diff.context={settings.prefs.contextLines}",
"diff",
+ *cls._gitDiffComparisonArgv(forDisplay),
*argsIf(binary, "--binary"),
]
@@ -367,7 +390,9 @@ def buildDiffCommandLFS(cls, delta: GitDelta) -> list[str]:
"--git-dir=",
"-c", "core.abbrev=no",
"-c", f"diff.context={settings.prefs.contextLines}",
- "diff", "--no-index", "--",
+ "diff",
+ *cls._gitDiffComparisonArgv(True),
+ "--no-index", "--",
delta.old.lfs.objectPath,
delta.new.lfs.objectPath,
]
diff --git a/gitfourchette/mainwindow.py b/gitfourchette/mainwindow.py
index c64ee268..8ea6c99c 100644
--- a/gitfourchette/mainwindow.py
+++ b/gitfourchette/mainwindow.py
@@ -1175,6 +1175,7 @@ def onAcceptPrefsDialog(self, prefDiff: dict):
"largeFileThresholdKB",
"imageFileThresholdKB",
"contextLines",
+ "comparisonMethod",
"maxCommits",
"renderSvg",
"lfsAware",
diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py
index 5d3736b2..442d1227 100644
--- a/gitfourchette/repowidget.py
+++ b/gitfourchette/repowidget.py
@@ -559,9 +559,15 @@ def toggleHideRefPattern(self, refPattern: str, allButThis: bool = False):
# -------------------------------------------------------------------------
- def refreshRepo(self):
+ def refreshRepo(
+ self,
+ effects: TaskEffects = TaskEffects.DefaultRefresh,
+ jumpTo: NavLocator = NavLocator.Empty
+ ):
"""Refresh the repo as soon as possible."""
- self.taskRunner.pendingEpilog.effects |= TaskEffects.DefaultRefresh
+ self.taskRunner.pendingEpilog.effects |= effects
+ if jumpTo:
+ self.taskRunner.pendingEpilog.jumpTo = jumpTo
self.onTaskRunnerReady()
def onTaskRunnerReady(self):
diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py
index 00d45877..8b76b557 100644
--- a/gitfourchette/settings.py
+++ b/gitfourchette/settings.py
@@ -88,6 +88,15 @@ class TabBarClick(enum.StrEnum):
Terminal = "terminal"
+class ComparisonMethod(enum.IntEnum):
+ """How `git diff` treats line endings and whitespace when showing patches."""
+
+ Strict = 0
+ IgnoreCrAtEol = 1
+ IgnoreCrAtEolAndSpaceChange = 2
+ IgnoreCrAtEolAndAllSpace = 3
+
+
@dataclasses.dataclass
class Prefs(PrefsFile):
_filename = "prefs.json"
@@ -111,6 +120,8 @@ class Prefs(PrefsFile):
largeFileThresholdKB : int = 500
wordWrap : bool = False
showStrayCRs : bool = True
+ showFormattingMarks : bool = False
+ comparisonMethod : ComparisonMethod = ComparisonMethod.Strict
_category_imageDiff : int = 0
imageFileThresholdKB : int = 5000
diff --git a/gitfourchette/syntax/colorscheme.py b/gitfourchette/syntax/colorscheme.py
index 0623744f..258766ad 100644
--- a/gitfourchette/syntax/colorscheme.py
+++ b/gitfourchette/syntax/colorscheme.py
@@ -13,7 +13,9 @@
try:
import pygments.styles
+ import pygments.token
hasPygments = True
+ Token = pygments.token.Token
except ImportError: # pragma: no cover
hasPygments = False
@@ -120,11 +122,24 @@ def resolve(cls, name: str) -> ColorScheme:
scheme.scheme[tokenType] = charFormat
with suppress(KeyError):
- scheme.foregroundColor = scheme.scheme[pygments.token.Token.Text].foreground().color()
+ scheme.foregroundColor = scheme.scheme[Token.Text].foreground().color()
+
+ # Set consistent whitespace color
+ wsColor = QColor(scheme.foregroundColor)
+ wsColor.setAlphaF(.18)
+ wsFormat = QTextCharFormat()
+ wsFormat.setForeground(wsColor)
+ scheme.scheme[Token.Whitespace] = wsFormat
cls._cachedScheme = scheme
return scheme
+ @property
+ def whitespaceFormat(self) -> QTextCharFormat:
+ assert hasPygments
+ assert self.scheme
+ return self.scheme[Token.Whitespace]
+
@classmethod
@benchmark
def stylePreviews(cls, withPlugins: bool) -> dict[str, str]:
@@ -153,9 +168,9 @@ def extractColor(style, *tokenTypes):
for styleName in sorted(allStyles):
style = pygments.styles.get_style_by_name(styleName)
bgColor = QColor(style.background_color)
- accent1 = extractColor(style, pygments.token.Name.Class, pygments.token.Text)
- accent2 = extractColor(style, pygments.token.Name.Function, pygments.token.Operator)
- accent3 = extractColor(style, pygments.token.Keyword, pygments.token.Comment)
+ accent1 = extractColor(style, Token.Name.Class, Token.Text)
+ accent2 = extractColor(style, Token.Name.Function, Token.Operator)
+ accent3 = extractColor(style, Token.Keyword, Token.Comment)
# Little icon to preview the colors in this style (colorscheme-chip.svg)
chipColors = f"black={bgColor.name()} white={accent1.name()} red={accent2.name()} blue={accent3.name()}"
@@ -177,8 +192,7 @@ def refreshFallbackScheme(cls):
@classmethod
def fillInFallback(cls, scheme: dict, tokenType) -> QTextCharFormat:
- from pygments.token import Token
-
+ assert hasPygments
assert Token in scheme
assert tokenType not in scheme
diff --git a/gitfourchette/tasks/loadtasks.py b/gitfourchette/tasks/loadtasks.py
index dbd8c612..4b7692c3 100644
--- a/gitfourchette/tasks/loadtasks.py
+++ b/gitfourchette/tasks/loadtasks.py
@@ -341,7 +341,7 @@ def _getPatch(
if loadLfs:
tokens = GitDriver.buildDiffCommandLFS(delta)
else:
- tokens = GitDriver.buildDiffCommand(delta, binary=False)
+ tokens = GitDriver.buildDiffCommand(delta, binary=False, forDisplay=True)
# Run diff command
driver = yield from self.flowCallGit(*tokens, autoFail=False)
diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py
index fb2d00fc..6ebc7e6a 100644
--- a/gitfourchette/trtables.py
+++ b/gitfourchette/trtables.py
@@ -104,7 +104,15 @@ def _init_enums():
from gitfourchette.porcelain import FileMode, NameValidationError
from gitfourchette.toolbox import toLengthVariants
from gitfourchette.sidebar.sidebarmodel import SidebarItem
- from gitfourchette.settings import GraphRowHeight, QtApiNames, GraphRefBoxWidth, RefSort, TabBarClick, FileListClick
+ from gitfourchette.settings import (
+ ComparisonMethod,
+ FileListClick,
+ GraphRefBoxWidth,
+ GraphRowHeight,
+ QtApiNames,
+ RefSort,
+ TabBarClick,
+ )
from gitfourchette.toolbox import PatchPurpose, PathDisplayStyle, AuthorDisplayStyle
from gitfourchette.repomodel import GpgStatus
@@ -207,6 +215,17 @@ def _init_enums():
AuthorDisplayStyle.EmailUserName: _("Abbreviated email"),
},
+ ComparisonMethod: {
+ ComparisonMethod.Strict:
+ _("Recognize line endings and white space differences"),
+ ComparisonMethod.IgnoreCrAtEol:
+ _("Ignore line ending differences"),
+ ComparisonMethod.IgnoreCrAtEolAndSpaceChange:
+ _("Ignore line ending and white space length differences"),
+ ComparisonMethod.IgnoreCrAtEolAndAllSpace:
+ _("Ignore line ending differences and all white space differences"),
+ },
+
GraphRowHeight: {
GraphRowHeight.Cramped : _p("row spacing", "Cramped"),
GraphRowHeight.Tight : _p("row spacing", "Tight"),
@@ -423,6 +442,13 @@ def _init_prefKeys():
"syntaxHighlighting": _("Syntax highlighting"),
"wordWrap": _("Word wrap"),
"showStrayCRs": _("Display alien line endings (CRLF)"),
+ "showFormattingMarks": _("Show formatting marks (tabs and spaces)"),
+ "comparisonMethod": _("Comparison method"),
+ "comparisonMethod_help": paragraphs(
+ _("Controls how {app} runs git diff when showing a patch in the diff view. "
+ "Exporting patches, reverting files, and similar actions still use an exact comparison."),
+ _("Line-ending handling uses Git’s --ignore-cr-at-eol option."),
+ ),
"colorblind": _("“-/+” colors"),
"colorblind_help": _("Background colors for deleted (-) and added (+) lines."),
"renderSvg": _("SVG files"),
diff --git a/test/test_diff.py b/test/test_diff.py
index 14bc3681..7fad848b 100644
--- a/test/test_diff.py
+++ b/test/test_diff.py
@@ -8,8 +8,10 @@
import re
import textwrap
+from gitfourchette import settings
from gitfourchette.diffview.diffview import DiffView
from gitfourchette.nav import NavLocator
+from gitfourchette.settings import ComparisonMethod
from .util import *
@@ -799,9 +801,11 @@ def testToggleWordWrap(tempDir, mainWindow):
rw = mainWindow.openRepo(wd)
dv = rw.diffView
+ wordWrapButton = rw.diffArea.diffButtons.wordWrapButton
rw.jump(NavLocator.inUnstaged("longfile.txt"), check=True)
assert dv.horizontalScrollBar().isVisible()
+ assert not wordWrapButton.isChecked()
# Scroll down a bit and look at the first visible word
dv.verticalScrollBar().setValue(5)
@@ -812,6 +816,8 @@ def testToggleWordWrap(tempDir, mainWindow):
triggerContextMenuAction(dv.viewport(), "word wrap")
QTest.qWait(0)
+ assert wordWrapButton.isChecked() == enableWrap
+
# Horizontal scroll bar should only be visible without wrap
assert dv.horizontalScrollBar().isVisible() == (not enableWrap)
@@ -819,6 +825,36 @@ def testToggleWordWrap(tempDir, mainWindow):
assert dv.firstVisibleBlock().text().startswith("y4x0")
+def testToggleFormattingMarksHeaderButton(tempDir, mainWindow):
+ markFlags = QTextOption.Flag.ShowTabsAndSpaces
+
+ wd = unpackRepo(tempDir)
+ writeFile(f"{wd}/marks.txt", "x\t y\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged("marks.txt"), check=True)
+
+ dv = rw.diffView
+ formattingMarksButton = rw.diffArea.diffButtons.marksButton
+ assert formattingMarksButton is not None
+
+ def formattingMarkBitsSet() -> bool:
+ f = dv.document().defaultTextOption().flags()
+ return (f & markFlags) == markFlags
+
+ assert not settings.prefs.showFormattingMarks
+ assert not formattingMarksButton.isChecked()
+ assert not formattingMarkBitsSet()
+
+ for enableMarks in [True, False]:
+ if formattingMarksButton.isChecked() != enableMarks:
+ formattingMarksButton.click()
+
+ assert formattingMarksButton.isChecked() == enableMarks
+ assert settings.prefs.showFormattingMarks == enableMarks
+ assert formattingMarkBitsSet() == enableMarks
+
+
def testRestoreScrollPositionWithWordWrap(tempDir, mainWindow):
wd = unpackRepo(tempDir)
@@ -1078,3 +1114,51 @@ def testDiffExoticLineEndings(tempDir, mainWindow):
expectedLines[1] = expectedLines[1].removesuffix("\r") + ""
assert expectedLines == reconstructedText[1:]
+
+
+@requiresPygments
+def testDiffTokenizationOnIndentedLineWithIgnoreAllSpace(tempDir, mainWindow):
+ oldText = textwrap.dedent("""\
+ void hello(void) {
+ \tassignment = 0x12345678; // comment
+ }
+ """)
+
+ newText = textwrap.dedent("""\
+ void hello(void) {
+ \tif (indentNextLine)
+ \t\tassignment = 0x12345678; // comment
+ }
+ """)
+
+ wd = unpackRepo(tempDir)
+ with RepoContext(wd) as repo:
+ for revision in oldText, newText:
+ writeFile(f"{wd}/hello.c", revision)
+ repo.index.add("hello.c")
+ repo.create_commit_on_head("hello", TEST_SIGNATURE, TEST_SIGNATURE)
+
+ mainWindow.onAcceptPrefsDialog({
+ # Ignore whitespace for this diff!
+ "comparisonMethod": ComparisonMethod.IgnoreCrAtEolAndAllSpace,
+
+ # Pick a scheme that applies non-default color to identifiers
+ "syntaxHighlighting": "one-dark",
+ })
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inCommit(rw.repo.head_commit_id, "hello.c"), check=True)
+ waitUntilTrue(lambda: rw.diffView.highlighter.newLexJob.lexingComplete)
+
+ doc = rw.diffView.document()
+ block = doc.findBlockByLineNumber(3)
+ assert block.text() == "\t\tassignment = 0x12345678; // comment"
+
+ # Ensure syntax highlighting matches up with the actual tokens in the line
+ colorTokens = []
+ for span in block.layout().formats():
+ token = block.text()[span.start: span.start + span.length].strip()
+ if token:
+ colorTokens.append(token)
+
+ assert colorTokens == ["assignment", "=", "0x12345678", ";", "// comment"]
diff --git a/test/test_diffcomparisonmethods.py b/test/test_diffcomparisonmethods.py
new file mode 100644
index 00000000..39d08368
--- /dev/null
+++ b/test/test_diffcomparisonmethods.py
@@ -0,0 +1,173 @@
+# -----------------------------------------------------------------------------
+# 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.
+# -----------------------------------------------------------------------------
+
+"""
+Exercise Settings → Code → Comparison method against real `git diff` output
+shown in the diff view (tabs vs spaces, run-length spaces, LF vs CRLF).
+"""
+
+import pytest
+
+from gitfourchette.nav import NavLocator
+from gitfourchette.settings import ComparisonMethod
+from .util import *
+
+
+def _commitTextFile(wd: str, relpath: str, contents: str):
+ with RepoContext(wd) as repo:
+ writeFile(f"{wd}{relpath}", contents)
+ repo.index.add_all([relpath])
+ repo.create_commit_on_head(f"add {relpath}", TEST_SIGNATURE, TEST_SIGNATURE)
+
+
+def _setWorktreeText(wd: str, relpath: str, contents: str):
+ writeFile(f"{wd}{relpath}", contents)
+
+
+def _waitUnifiedDiffVisible(rw):
+ assert rw.diffView.isVisible()
+ assert rw.diffView.toPlainText().startswith("@@")
+
+
+def _waitNoChangeSpecialVisible(rw):
+ assert rw.specialDiffView.isVisible()
+ assert findTextInWidget(rw.specialDiffView, r"whitespace changes ignored")
+
+
+def _applyComparisonMethod(mainWindow, method: ComparisonMethod):
+ mainWindow.onAcceptPrefsDialog({"comparisonMethod": method})
+
+
+@pytest.mark.parametrize(
+ ("method", "expectUnifiedDiff"),
+ [
+ (ComparisonMethod.Strict, True),
+ (ComparisonMethod.IgnoreCrAtEol, True),
+ (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False),
+ (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False),
+ ],
+ ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"],
+)
+def testComparisonMethodTabsVsSpacesInDiffView(
+ tempDir, mainWindow, method, expectUnifiedDiff):
+ wd = unpackRepo(tempDir)
+ relpath = "comp_tabs_spaces.txt"
+ _commitTextFile(wd, relpath, "a\tb\n")
+ _setWorktreeText(wd, relpath, "a b\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged(relpath), check=True)
+ _waitUnifiedDiffVisible(rw)
+
+ _applyComparisonMethod(mainWindow, method)
+ if expectUnifiedDiff:
+ _waitUnifiedDiffVisible(rw)
+ else:
+ _waitNoChangeSpecialVisible(rw)
+
+
+def testReloadCurrentPatchWhenSwitchingWhitespaceMode(tempDir, mainWindow):
+ """
+ Changing comparison via reloadCurrentPatchForPrefs (Jump.invoke) must refresh
+ the visible patch without selecting another file.
+ """
+ wd = unpackRepo(tempDir)
+ relpath = "comp_reload_patch.txt"
+ _commitTextFile(wd, relpath, "a\tb\n")
+ _setWorktreeText(wd, relpath, "a b\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged(relpath), check=True)
+ _waitUnifiedDiffVisible(rw)
+
+ _applyComparisonMethod(mainWindow, ComparisonMethod.IgnoreCrAtEolAndAllSpace)
+ _waitNoChangeSpecialVisible(rw)
+
+ _applyComparisonMethod(mainWindow, ComparisonMethod.Strict)
+ _waitUnifiedDiffVisible(rw)
+
+
+def testDiffHeaderWhitespaceMenuReloadPatch(tempDir, mainWindow):
+ """Diff header comparison menu must trigger the same reload as prefs."""
+ wd = unpackRepo(tempDir)
+ relpath = "comp_header_buttons.txt"
+ _commitTextFile(wd, relpath, "a\tb\n")
+ _setWorktreeText(wd, relpath, "a b\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged(relpath), check=True)
+ _waitUnifiedDiffVisible(rw)
+
+ menu = rw.diffArea.diffButtons.diffMethodButton.menu()
+ assert findMenuAction(menu, "recognize line endings and white space").isChecked()
+
+ triggerMenuAction(menu, "ignore line ending.+and all white space")
+ _waitNoChangeSpecialVisible(rw)
+ assert findMenuAction(menu, "ignore line ending.+and all white space").isChecked()
+
+ triggerMenuAction(menu, "recognize line endings and white space")
+ _waitUnifiedDiffVisible(rw)
+ assert findMenuAction(menu, "recognize line endings and white space").isChecked()
+
+
+@pytest.mark.parametrize(
+ ("method", "expectUnifiedDiff"),
+ [
+ (ComparisonMethod.Strict, True),
+ (ComparisonMethod.IgnoreCrAtEol, True),
+ (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False),
+ (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False),
+ ],
+ ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"],
+)
+def testComparisonMethodSpaceRunLengthInDiffview(
+ tempDir, mainWindow, method, expectUnifiedDiff):
+ wd = unpackRepo(tempDir)
+ relpath = "comp_space_runs.txt"
+ _commitTextFile(wd, relpath, "a b c\n")
+ _setWorktreeText(wd, relpath, "a b c\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged(relpath), check=True)
+ _waitUnifiedDiffVisible(rw)
+
+ _applyComparisonMethod(mainWindow, method)
+ if expectUnifiedDiff:
+ _waitUnifiedDiffVisible(rw)
+ else:
+ _waitNoChangeSpecialVisible(rw)
+
+
+@pytest.mark.parametrize(
+ ("method", "expectUnifiedDiff"),
+ [
+ (ComparisonMethod.Strict, True),
+ (ComparisonMethod.IgnoreCrAtEol, False),
+ (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False),
+ (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False),
+ ],
+ ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"],
+)
+def testComparisonMethodLfVsCrlfInDiffView(
+ tempDir, mainWindow, method, expectUnifiedDiff):
+ wd = unpackRepo(tempDir)
+ if WINDOWS:
+ with RepoContext(wd) as repo:
+ repo.config["core.autocrlf"] = "false"
+
+ relpath = "comp_eol.txt"
+ _commitTextFile(wd, relpath, "hello\n")
+ _setWorktreeText(wd, relpath, "hello\r\n")
+
+ rw = mainWindow.openRepo(wd)
+ rw.jump(NavLocator.inUnstaged(relpath), check=True)
+ _waitUnifiedDiffVisible(rw)
+
+ _applyComparisonMethod(mainWindow, method)
+ if expectUnifiedDiff:
+ _waitUnifiedDiffVisible(rw)
+ else:
+ _waitNoChangeSpecialVisible(rw)
diff --git a/test/test_prefs.py b/test/test_prefs.py
index 97713f59..63da8bba 100644
--- a/test/test_prefs.py
+++ b/test/test_prefs.py
@@ -6,8 +6,11 @@
import textwrap
+from gitfourchette import settings
from gitfourchette.forms.prefsdialog import PrefsDialog
+from gitfourchette.gitdriver.gitdriver import GitDriver
from gitfourchette.nav import NavLocator
+from gitfourchette.settings import ComparisonMethod
from gitfourchette.toolbox.fontpicker import FontPicker
from .util import *
@@ -153,6 +156,66 @@ def testPrefsRecreateDiffDocument(tempDir, mainWindow):
assert "" not in rw.diffView.toPlainText()
+def testPrefsShowFormattingMarks(tempDir, mainWindow):
+ markFlags = QTextOption.Flag.ShowTabsAndSpaces
+
+ wd = unpackRepo(tempDir)
+ writeFile(f"{wd}/marks.txt", "x\t y\n")
+ rw = mainWindow.openRepo(wd)
+ assert rw.navLocator.isSimilarEnoughTo(NavLocator.inUnstaged("marks.txt"))
+
+ def formattingMarksBitsSet() -> bool:
+ f = rw.diffView.document().defaultTextOption().flags()
+ return (f & markFlags) == markFlags
+
+ assert not formattingMarksBitsSet()
+
+ dlg = mainWindow.openPrefsDialog("showFormattingMarks")
+ checkBox: QCheckBox = dlg.findChild(QCheckBox, "prefctl_showFormattingMarks")
+ assert checkBox is not None
+ assert not checkBox.isChecked()
+ checkBox.setChecked(True)
+ dlg.accept()
+
+ assert settings.prefs.showFormattingMarks
+ assert formattingMarksBitsSet()
+
+ dlg = mainWindow.openPrefsDialog("showFormattingMarks")
+ checkBox: QCheckBox = dlg.findChild(QCheckBox, "prefctl_showFormattingMarks")
+ checkBox.setChecked(False)
+ dlg.accept()
+
+ assert not settings.prefs.showFormattingMarks
+ assert not formattingMarksBitsSet()
+
+
+def testComparisonMethodGitArgv(monkeypatch):
+ monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEolAndAllSpace)
+ assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol", "--ignore-all-space"]
+
+ monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEolAndSpaceChange)
+ assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol", "--ignore-space-change"]
+
+ monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEol)
+ assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol"]
+
+ monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.Strict)
+ assert GitDriver._gitDiffComparisonArgv(True) == []
+ assert GitDriver._gitDiffComparisonArgv(False) == []
+
+
+def testPrefsComparisonMethodRadios(tempDir, mainWindow):
+ wd = unpackRepo(tempDir)
+ mainWindow.openRepo(wd)
+ dlg = mainWindow.openPrefsDialog("comparisonMethod")
+ container: QWidget = dlg.findChild(QWidget, "prefctl_comparisonMethod")
+ assert container is not None
+ radios = container.findChildren(QRadioButton)
+ assert len(radios) == len(ComparisonMethod)
+ assert sum(rb.isChecked() for rb in radios) == 1
+ dlg.reject()
+
+
def testPrefsUserCommandsSyntaxHighlighter(mainWindow):
# This is just for code coverage for now.
dlg = mainWindow.openPrefsDialog("commands")
diff --git a/test/test_syntaxhighlighting.py b/test/test_syntaxhighlighting.py
index d613529c..9e7c4954 100644
--- a/test/test_syntaxhighlighting.py
+++ b/test/test_syntaxhighlighting.py
@@ -8,7 +8,7 @@
from .util import *
-from gitfourchette.syntax import syntaxHighlightingAvailable, LexJobCache, LexJob
+from gitfourchette.syntax import LexJobCache, LexJob
from gitfourchette.nav import NavLocator
SAMPLE_CODE = """\
@@ -32,7 +32,7 @@ def digestFormatRange(formatRange: QTextLayout.FormatRange):
return (start, length, isStyled)
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testDeferredSyntaxHighlighting(tempDir, mainWindow):
wd = unpackRepo(tempDir)
@@ -67,7 +67,7 @@ def testDeferredSyntaxHighlighting(tempDir, mainWindow):
assert digestFormatRange(formatRange) == (0, len("hello multiline comment"), True)
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testLexJobCaching(tempDir, mainWindow):
mainWindow.onAcceptPrefsDialog({"largeFileThresholdKB": 0})
@@ -112,7 +112,7 @@ def getNewLexJob() -> LexJob:
assert job is getNewLexJob()
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
@pytest.mark.skipif(WINDOWS, reason="TODO: flaky on Windows")
def testEvictLexJobFromCache(tempDir, mainWindow):
mainWindow.onAcceptPrefsDialog({ 'largeFileThresholdKB': 1e6 })
@@ -164,7 +164,7 @@ def getNewLexJob() -> LexJob:
# Simple coverage test
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testSyntaxHighlightingNullOid(tempDir, mainWindow):
wd = unpackRepo(tempDir)
@@ -177,7 +177,7 @@ def testSyntaxHighlightingNullOid(tempDir, mainWindow):
# Simple coverage test
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testSyntaxHighlightingEmptyOid(tempDir, mainWindow):
wd = unpackRepo(tempDir)
@@ -189,7 +189,7 @@ def testSyntaxHighlightingEmptyOid(tempDir, mainWindow):
mainWindow.openRepo(wd)
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testSyntaxHighlightingFillInFallbackTokenTypes(tempDir, mainWindow):
# YAML has bespoke token types that aren't part of the standard Pygments token set,
# e.g. Token.Literal.Scalar.Plain, Token.Punctuation.Indicator.
diff --git a/test/test_tasks_blame.py b/test/test_tasks_blame.py
index 5e5ca246..20409230 100644
--- a/test/test_tasks_blame.py
+++ b/test/test_tasks_blame.py
@@ -21,7 +21,6 @@
from gitfourchette.blameview.blamewindow import BlameWindow
from gitfourchette.nav import NavLocator, NavContext
from gitfourchette.repowidget import RepoWidget
-from gitfourchette.syntax import syntaxHighlightingAvailable
class BlameFixture:
@@ -360,7 +359,7 @@ def testBlameUnborn(tempDir, mainWindow):
acceptQMessageBox(mainWindow, "no commits in this repository")
-@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available")
+@requiresPygments
def testBlameSyntaxHighlighting(tempDir, mainWindow):
wd = unpackRepo(tempDir)
diff --git a/test/util.py b/test/util.py
index 2078c058..9fcefef1 100644
--- a/test/util.py
+++ b/test/util.py
@@ -21,6 +21,7 @@
from gitfourchette.exttools.toolcommands import ToolCommands
from gitfourchette.porcelain import *
from gitfourchette.toolbox import QPoint_zero, stripAccelerators, stripHtml
+from gitfourchette.syntax import syntaxHighlightingAvailable
from . import *
TEST_SIGNATURE = Signature("Test Person", "toto@example.com", 1672600000, 0)
@@ -49,6 +50,10 @@
os.environ.get("TESTFUSE", "") in {"0", ""},
reason="Requires FUSE (test.py --with-fuse)")
+requiresPygments = pytest.mark.skipif(
+ not syntaxHighlightingAvailable,
+ reason="Requires Pygments")
+
_T = TypeVar("_T")
_TInheritsQWidget = TypeVar("_TInheritsQWidget", bound=QWidget)
_TInheritsQDialog = TypeVar("_TInheritsQDialog", bound=QDialog)