Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions gitfourchette/assets/icons/diff-whitespace-strict.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions gitfourchette/assets/icons/format-text-wrap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions gitfourchette/assets/icons/paragraph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions gitfourchette/assets/style.qss
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
23 changes: 20 additions & 3 deletions gitfourchette/codeview/codehighlighter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------

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
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
19 changes: 17 additions & 2 deletions gitfourchette/codeview/codeview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
27 changes: 23 additions & 4 deletions gitfourchette/diffarea.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -320,6 +338,7 @@ def applyCustomStyling(self):
self.stageButton,
self.unstageButton,
self.discardButton,
*self.diffButtons.buttons,
):
tweakWidgetFont(smallWidget, 90)

Expand Down
131 changes: 131 additions & 0 deletions gitfourchette/diffbuttons.py
Original file line number Diff line number Diff line change
@@ -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})
2 changes: 1 addition & 1 deletion gitfourchette/diffview/diffhighlighter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions gitfourchette/diffview/diffview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
9 changes: 9 additions & 0 deletions gitfourchette/diffview/specialdiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}.")
Expand Down
Loading