# Date 1772930933 -3600
# Sun Mar 08 01:48:53 2026 +0100
# Node ID 5a7d494b3fb1862dcc148b9ffceba4101c92744f
# Parent ec21747b1e33b0928a25f9be8809261360b2053a
qtlib: replace default scrollbars for dark theme to fix Qt stylesheet deadzone
Add CustomScrollBar and applyCustomScrollBars() to work around a Qt stylesheet
issue where oversized scrollbar handles can create a dead zone near the end,
preventing scrolling to the bottom.
The issue occurs when any Scrollbar::handle style parameter is set.
The bug is most visible when the content is only slightly taller than
the viewport (for example, ~15 lines of text out of ~16 lines visible):
the scrollbar handle nearly fills the track, creating a dead zone near
the bottom and preventing scrolling to the true end.
Also add bidirectional scrollbar sync for QsciScintilla widgets and reconnect
QAbstractItemView scrollbar signals to preserve lazy-loading behavior.
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/bookmark.py
--- a/tortoisehg/hgqt/bookmark.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/bookmark.py Sun Mar 08 01:48:53 2026 +0100
@@ -48,6 +48,7 @@
cmdcore,
qtlib,
)
+from .theme import THEME
class BookmarkDialog(QDialog):
@@ -310,6 +311,8 @@
self._onOutgoingMenuRequested)
self.outgoingList.itemSelectionChanged.connect(self._updateActions)
outgoingLayout.addWidget(self.outgoingList)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.outgoingList)
self._outactions = []
a = QAction(_('&Push Bookmark'), self)
@@ -341,6 +344,8 @@
self._onIncomingMenuRequested)
self.incomingList.itemSelectionChanged.connect(self._updateActions)
incomingLayout.addWidget(self.incomingList)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.incomingList)
self._inactions = []
a = QAction(_('P&ull Bookmark'), self)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/chunks.py
--- a/tortoisehg/hgqt/chunks.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/chunks.py Sun Mar 08 01:48:53 2026 +0100
@@ -631,6 +631,8 @@
self.sci.setMarginsBackgroundColor(THEME.backgroundLighter)
self.sci.setMarginsForegroundColor(THEME.control_text)
+ qtlib.applyCustomScrollBars(self.sci)
+
if THEME.enabled and sys.platform == 'win32':
# Draw custom checkboxes for Windows: the margin is 12px wide on this platform
# Not needed for Linux, as the margin is 16px wide
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/cmdui.py
--- a/tortoisehg/hgqt/cmdui.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/cmdui.py Sun Mar 08 01:48:53 2026 +0100
@@ -234,7 +234,8 @@
if THEME.enabled:
self._apply_dark_console_markers()
-
+ qtlib.applyCustomScrollBars(self)
+
qscilib.unbindConflictedKeys(self)
def _initfont(self):
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/docklog.py
--- a/tortoisehg/hgqt/docklog.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/docklog.py Sun Mar 08 01:48:53 2026 +0100
@@ -105,6 +105,9 @@
self.setMarkerBackgroundColor(QColor('#e8f3fe'), self._prompt_marker)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
self.cursorPositionChanged.connect(self._updatePrompt)
# ensure not moving prompt line even if completion list get shorter,
# by allowing to scroll one page below the last line
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/filedialogs.py
--- a/tortoisehg/hgqt/filedialogs.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/filedialogs.py Sun Mar 08 01:48:53 2026 +0100
@@ -580,6 +580,8 @@
sci.setSelectionBackgroundColor(THEME.selection_background)
sci.setSelectionForegroundColor(THEME.selection_text)
+ qtlib.applyCustomScrollBars(sci)
+
self.viewers[side] = sci
blk = blockmatcher.BlockList(self.frame)
blk.linkScrollBar(sci.verticalScrollBar())
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/filelistview.py
--- a/tortoisehg/hgqt/filelistview.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/filelistview.py Sun Mar 08 01:48:53 2026 +0100
@@ -47,6 +47,7 @@
QWidget,
)
+from .theme import THEME
class HgFileListView(QTreeView):
"""Display files and statuses between two revisions or patch"""
@@ -66,6 +67,9 @@
self.setIconSize(qtlib.smallIconSize())
self.setUniformRowHeights(True)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
def _model(self) -> manifestmodel.ManifestModel:
model = self.model()
assert isinstance(model, manifestmodel.ManifestModel)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/fileview.py
--- a/tortoisehg/hgqt/fileview.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/fileview.py Sun Mar 08 01:48:53 2026 +0100
@@ -182,6 +182,8 @@
self.sci.setWhitespaceBackgroundColor(THEME.background)
self.sci.setWhitespaceForegroundColor(THEME.text_margin)
+ qtlib.applyCustomScrollBars(self.sci)
+
hbox.addWidget(self.blk)
hbox.addWidget(self.sci, 1)
hbox.addWidget(self.blksearch)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/grep.py
--- a/tortoisehg/hgqt/grep.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/grep.py Sun Mar 08 01:48:53 2026 +0100
@@ -74,6 +74,7 @@
thgrepo,
visdiff,
)
+from .theme import THEME
# This widget can be embedded in any application that would like to
# provide search features
@@ -636,6 +637,9 @@
self.setModel(MatchModel(repoagent, self))
self.selectionModel().selectionChanged.connect(self.onSelectionChanged)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
@property
def repo(self):
return self._repoagent.rawRepo()
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/guess.py
--- a/tortoisehg/hgqt/guess.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/guess.py Sun Mar 08 01:48:53 2026 +0100
@@ -59,6 +59,7 @@
htmlui,
qtlib,
)
+from .theme import THEME
# Techincal debt
# Try to cut down on the jitter when findRenames is pressed. May
@@ -115,6 +116,8 @@
self.unrevlist.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.unrevlist.doubleClicked.connect(self.onUnrevDoubleClicked)
utvbox.addWidget(self.unrevlist)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.unrevlist)
simhbox = QHBoxLayout()
utvbox.addLayout(simhbox)
@@ -165,6 +168,8 @@
matchvbox.addWidget(matchtv)
matchvbox.addLayout(buthbox)
self.matchtv, self.matchbtn = matchtv, matchbtn
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(matchtv)
def matchselect(s, d):
count = len(matchtv.selectedIndexes())
if count:
@@ -189,6 +194,8 @@
difftb.document().setDefaultStyleSheet(qtlib.thgstylesheet)
diffvbox.addWidget(difftb)
self.difftb = difftb
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(difftb)
self.stbar = cmdui.ThgStatusBar()
layout.addWidget(self.stbar)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/hgignore.py
--- a/tortoisehg/hgqt/hgignore.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/hgignore.py Sun Mar 08 01:48:53 2026 +0100
@@ -66,6 +66,7 @@
qscilib,
qtlib,
)
+from .theme import THEME
if typing.TYPE_CHECKING:
from .thgrepo import RepoAgent
@@ -164,6 +165,9 @@
unknownlist = QListWidget()
uvbox.addWidget(unknownlist)
unknownlist.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(ignorelist)
+ qtlib.applyCustomScrollBars(unknownlist)
unknownlist.currentTextChanged.connect(self.setGlobFilter)
unknownlist.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
unknownlist.customContextMenuRequested.connect(self.menuRequest)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/messageentry.py
--- a/tortoisehg/hgqt/messageentry.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/messageentry.py Sun Mar 08 01:48:53 2026 +0100
@@ -71,6 +71,8 @@
if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
# Base editor colors
self.setPaper(THEME.background)
self.setColor(THEME.text)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/mq.py
--- a/tortoisehg/hgqt/mq.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/mq.py Sun Mar 08 01:48:53 2026 +0100
@@ -66,6 +66,7 @@
qfold,
rejects,
)
+from .theme import THEME
if typing.TYPE_CHECKING:
from typing import (
@@ -718,6 +719,8 @@
self._queueListWidget.customContextMenuRequested.connect(
self._onMenuRequested)
layout.addWidget(self._queueListWidget, 1)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self._queueListWidget)
bbarhbox = QHBoxLayout()
bbarhbox.setSpacing(5)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/qtlib.py
--- a/tortoisehg/hgqt/qtlib.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/qtlib.py Sun Mar 08 01:48:53 2026 +0100
@@ -39,6 +39,7 @@
pyqtSlot,
)
from .qtgui import (
+ QAbstractItemView,
QAction,
QApplication,
QComboBox,
@@ -62,6 +63,7 @@
QPalette,
QPixmap,
QPushButton,
+ QScrollBar,
QShortcut,
QSizePolicy,
QStyle,
@@ -1854,3 +1856,110 @@
return event.position().toPoint() # pytype: disable=attribute-error
else:
return event.pos() # pytype: disable=attribute-error
+
+
+class CustomScrollBar(QScrollBar):
+ """Work around a Qt stylesheet bug where a large scrollbar could create a dead zone
+ that prevented scrolling to the bottom.
+ The issue occurs when any Scrollbar::handle style parameter is set.
+ """
+
+ def __init__(self, orientation, parent=None):
+ super().__init__(orientation, parent)
+ self._grab_offset = None
+ self._grab_value = None
+
+ def _isVertical(self):
+ return self.orientation() == Qt.Orientation.Vertical
+
+ def _posFromEvent(self, event):
+ return event.pos().y() if self._isVertical() else event.pos().x()
+
+ def mousePressEvent(self, event):
+ if self.maximum() > self.minimum():
+ self._grab_offset = self._posFromEvent(event)
+ self._grab_value = self.value()
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ scroll_range = self.maximum() - self.minimum()
+ if self.isSliderDown() and scroll_range > 0 and self._grab_offset is not None:
+ track_length = self.height() if self._isVertical() else self.width()
+ if track_length > 0:
+ delta = self._posFromEvent(event) - self._grab_offset
+ new_position = self._grab_value + (scroll_range + self.pageStep()) * delta / track_length
+ self.setSliderPosition(
+ max(self.minimum(), min(self.maximum(), round(new_position))))
+ return
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ self._grab_offset = None
+ self._grab_value = None
+ super().mouseReleaseEvent(event)
+
+
+def _connectScintillaScrollBar(sci, bar, vertical=True):
+ """Sync a replacement scrollbar (vertical or horizontal) with QsciScintilla.
+
+ QsciScintillaBase caches the original scrollbar pointer, so the
+ replacement never gets range updates automatically. We hook
+ SCN_UPDATEUI + textChanged to push range/position into the new bar,
+ and valueChanged to push the bar position back into Scintilla.
+ """
+ _syncing = [False]
+
+ def _sync():
+ if _syncing[0]:
+ return
+ if sip.isdeleted(bar) or sip.isdeleted(sci):
+ return
+ if vertical:
+ total_scroll_range = sci.SendScintilla(sci.SCI_VISIBLEFROMDOCLINE, sci.SendScintilla(sci.SCI_GETLINECOUNT))
+ visible_range = sci.SendScintilla(sci.SCI_LINESONSCREEN)
+ step, pos_msg = 1, sci.SCI_GETFIRSTVISIBLELINE
+ else:
+ total_scroll_range = sci.SendScintilla(sci.SCI_GETSCROLLWIDTH)
+ visible_range = sci.viewport().width()
+ step, pos_msg = 20, sci.SCI_GETXOFFSET
+ if visible_range <= 0:
+ return
+ _syncing[0] = True
+ bar.setRange(0, max(0, total_scroll_range - visible_range))
+ bar.setPageStep(max(1, visible_range))
+ bar.setSingleStep(step)
+ bar.setValue(sci.SendScintilla(pos_msg))
+ _syncing[0] = False
+
+ def _onBarChanged(val):
+ if sip.isdeleted(bar) or sip.isdeleted(sci):
+ return
+ if not _syncing[0]:
+ _syncing[0] = True
+ msg = sci.SCI_SETFIRSTVISIBLELINE if vertical else sci.SCI_SETXOFFSET
+ sci.SendScintilla(msg, val)
+ _syncing[0] = False
+
+ bar.valueChanged.connect(_onBarChanged)
+ sci.SCN_UPDATEUI.connect(lambda *_: _sync())
+ sci.textChanged.connect(_sync)
+ from .qtcore import QTimer
+ QTimer.singleShot(0, _sync)
+
+
+def applyCustomScrollBars(widget):
+
+ # Replace default scrollbars with custom ones
+ vbar = CustomScrollBar(Qt.Orientation.Vertical)
+ hbar = CustomScrollBar(Qt.Orientation.Horizontal)
+ widget.setVerticalScrollBar(vbar)
+ widget.setHorizontalScrollBar(hbar)
+
+ if isinstance(widget, QAbstractItemView):
+ # Reconnect valueChanged signals to preserve lazy-loading (fetchMore) behavior for RepoView.
+ vbar.valueChanged.connect(widget.verticalScrollbarValueChanged)
+ hbar.valueChanged.connect(widget.horizontalScrollbarValueChanged)
+ elif hasattr(widget, 'SendScintilla'):
+ # Set up bidirectional sync: push Scintilla range/position to bar, and bar changes back to Scintilla
+ _connectScintillaScrollBar(widget, vbar, vertical=True)
+ _connectScintillaScrollBar(widget, hbar, vertical=False)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/rejects.py
--- a/tortoisehg/hgqt/rejects.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/rejects.py Sun Mar 08 01:48:53 2026 +0100
@@ -87,6 +87,8 @@
self.updating = True
self.chunklist.currentRowChanged.connect(self.showChunk)
hbox.addWidget(self.chunklist, 1)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.chunklist)
bvbox = QVBoxLayout()
bvbox.setContentsMargins(2, 2, 2, 2)
@@ -166,6 +168,8 @@
editor.setSelectionBackgroundColor(THEME.selection_background)
editor.setSelectionForegroundColor(THEME.selection_text)
+ qtlib.applyCustomScrollBars(editor)
+
buf = util.bytesio()
try:
buf.write(b'diff -r aaaaaaaaaaaa -r bbbbbbbbbbb %s\n' % path)
@@ -345,6 +349,8 @@
# Caret
self.setCaretForegroundColor(THEME.caret_foreground)
+ qtlib.applyCustomScrollBars(self)
+
def showChunk(self, lines):
utext = []
added = []
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/reporegistry.py
--- a/tortoisehg/hgqt/reporegistry.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/reporegistry.py Sun Mar 08 01:48:53 2026 +0100
@@ -58,6 +58,7 @@
repotreemodel,
settings,
)
+from .theme import THEME
def settingsfilename():
"""Return path to thg-reporegistry.xml as unicode"""
@@ -95,6 +96,9 @@
| QAbstractItemView.EditTrigger.EditKeyPressed)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
def dragEnterEvent(self, event):
if event.source() is self:
# Use the default event handler for internal dragging
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/repoview.py
--- a/tortoisehg/hgqt/repoview.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/repoview.py Sun Mar 08 01:48:53 2026 +0100
@@ -141,6 +141,9 @@
if repoagent.configBool('tortoisehg', 'copy_hash_selection'):
self.clicked.connect(self._copyHashToSelection)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
@property
def repo(self):
return self._repoagent.rawRepo()
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/resolve.py
--- a/tortoisehg/hgqt/resolve.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/resolve.py Sun Mar 08 01:48:53 2026 +0100
@@ -52,6 +52,7 @@
thgrepo,
visdiff,
)
+from .theme import THEME
if typing.TYPE_CHECKING:
from typing import (
@@ -146,6 +147,8 @@
self.utree.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection)
self.utree.setSortingEnabled(True)
hbox.addWidget(self.utree)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.utree)
self.utree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.utreecmenu = QMenu(self)
@@ -200,6 +203,8 @@
self.rtree.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection)
self.rtree.setSortingEnabled(True)
hbox.addWidget(self.rtree)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.rtree)
self.rtree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.rtreecmenu = QMenu(self)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/revdetails.py
--- a/tortoisehg/hgqt/revdetails.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/revdetails.py Sun Mar 08 01:48:53 2026 +0100
@@ -60,6 +60,7 @@
qtlib,
status,
)
+from .theme import THEME
from .filelistview import HgFileListView
from .fileview import HgFileView
from .revpanel import RevPanelWidget
@@ -187,6 +188,9 @@
self.message.setFont(f.font())
f.changed.connect(self.forwardFont)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self.message)
+
self.fileview = HgFileView(self._repoagent, self.messagesplitter)
self.messagesplitter.setStretchFactor(1, 1)
self.fileview.setMinimumSize(QSize(0, 0))
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/settings.py
--- a/tortoisehg/hgqt/settings.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/settings.py Sun Mar 08 01:48:53 2026 +0100
@@ -86,6 +86,7 @@
qtlib,
thgrepo,
)
+from .theme import THEME
if typing.TYPE_CHECKING:
from typing import (
@@ -1524,6 +1525,8 @@
stack = QStackedWidget()
bothbox.addWidget(pageList, 0)
bothbox.addWidget(stack, 1)
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(pageList)
pageList.currentRowChanged.connect(self.activatePage)
self.pages = {}
@@ -1535,6 +1538,8 @@
desctext.setOpenExternalLinks(True)
layout.addWidget(desctext, 2)
self.desctext = desctext
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(desctext)
self.settings = QSettings()
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/status.py
--- a/tortoisehg/hgqt/status.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/status.py Sun Mar 08 01:48:53 2026 +0100
@@ -208,6 +208,7 @@
if THEME.enabled:
tv.setItemDelegate(WctxPreserveStatusColorDelegate(tv))
+ qtlib.applyCustomScrollBars(tv)
vbox.addLayout(hbox)
vbox.addWidget(tv)
diff -r ec21747b1e33 -r 5a7d494b3fb1 tortoisehg/hgqt/sync.py
--- a/tortoisehg/hgqt/sync.py Sun Mar 08 01:36:33 2026 +0100
+++ b/tortoisehg/hgqt/sync.py Sun Mar 08 01:48:53 2026 +0100
@@ -83,6 +83,7 @@
resolve,
thgrepo,
)
+from .theme import THEME
if typing.TYPE_CHECKING:
from typing import (
@@ -1632,6 +1633,9 @@
self.setSelectionMode(QTreeView.SelectionMode.SingleSelection)
self.editable = editable
+ if THEME.enabled:
+ qtlib.applyCustomScrollBars(self)
+
def contextMenuEvent(self, event):
for index in self.selectedRows():
alias = index.data(Qt.ItemDataRole.DisplayRole)