v0.46.0: scrollbars lose their arrows; the thumb is embossed like a button

The app style now paints the scrollbars itself: the trough runs the full
length with no arrow buttons, and the thumb gets the buttons' 4 px corners
and emboss, with rims as tall in pixels as a button's however long the
thumb. Bar thickness moved from QSS to the style's pixel metric, since any
QSS rule on a scrollbar part makes Qt draw the whole bar, arrows included.
This commit is contained in:
2026-10-10 10:44:05 -07:00
parent c312751c86
commit 617402657f
4 changed files with 223 additions and 13 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.45.1"
__version__ = "0.46.0"
+79 -4
View File
@@ -12,7 +12,7 @@ from PyQt6.QtCore import (
)
from PyQt6.QtGui import (
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
QFontMetrics, QDesktopServices, QCursor,
QFontMetrics, QDesktopServices, QCursor, QPalette,
)
from PyQt6.QtDBus import (
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
@@ -30,15 +30,90 @@ AUTOSCROLL_MARGIN = 28
class ClickToJumpScrollStyle(QProxyStyle):
"""Makes a left-click on the scrollbar trough jump the thumb straight to
that spot (absolute position) instead of paging toward it a step at a time.
Wraps the default app style and only overrides the one hint."""
"""The app's scrollbars. A left-click on the trough jumps the thumb
straight to that spot (absolute position) instead of paging toward it a
step at a time; there are no arrow buttons at the ends (the trough runs
the full length); and the thumb is drawn as a button — the same corner
radius and the same emboss, lit from above in either orientation.
Painted here rather than in the style sheet: any QSS rule on a scrollbar
sub-control makes Qt draw the whole bar itself, and a QSS gradient is
measured in fractions of the thumb, so a long thumb would get rims ten
times a button's. ``extent`` (the bar's thickness) is set by
``theme.apply_theme`` from the UI scale."""
extent = 14
def styleHint(self, hint, option=None, widget=None, returnData=None):
if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition:
return 1
return super().styleHint(hint, option, widget, returnData)
def pixelMetric(self, metric, option=None, widget=None):
if metric == QStyle.PixelMetric.PM_ScrollBarExtent:
return self.extent
if metric == QStyle.PixelMetric.PM_ScrollBarSliderMin:
return self.extent * 2
return super().pixelMetric(metric, option, widget)
def subControlRect(self, control, option, sub, widget=None):
if control != QStyle.ComplexControl.CC_ScrollBar:
return super().subControlRect(control, option, sub, widget)
SC = QStyle.SubControl
rect = option.rect
horizontal = option.orientation == Qt.Orientation.Horizontal
length = rect.width() if horizontal else rect.height()
span = option.maximum - option.minimum
if span <= 0:
thumb = length
else:
thumb = length * option.pageStep // (span + option.pageStep)
thumb = min(length, max(thumb, self.pixelMetric(
QStyle.PixelMetric.PM_ScrollBarSliderMin, option, widget)))
start = QStyle.sliderPositionFromValue(
option.minimum, option.maximum, option.sliderPosition,
length - thumb, option.upsideDown)
def along(a, b):
if horizontal:
return QRect(rect.x() + a, rect.y(), b - a, rect.height())
return QRect(rect.x(), rect.y() + a, rect.width(), b - a)
if sub == SC.SC_ScrollBarSlider:
return along(start, start + thumb)
if sub == SC.SC_ScrollBarSubPage:
return along(0, start)
if sub == SC.SC_ScrollBarAddPage:
return along(start + thumb, length)
if sub == SC.SC_ScrollBarGroove:
return QRect(rect)
return QRect() # SubLine / AddLine / First / Last: no arrows
def drawComplexControl(self, control, option, painter, widget=None):
if control != QStyle.ComplexControl.CC_ScrollBar:
return super().drawComplexControl(control, option, painter, widget)
from lintunes import theme
SC = QStyle.SubControl
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillRect(option.rect, option.palette.color(
QPalette.ColorRole.Window).darker(104))
if option.maximum > option.minimum:
thumb = QRectF(self.subControlRect(
control, option, SC.SC_ScrollBarSlider, widget))
# inset so the 1px outline lands on whole pixels, with a hair of
# trough showing around it
thumb.adjust(1.5, 1.5, -1.5, -1.5)
color = option.palette.color(QPalette.ColorRole.Button)
hot = (option.activeSubControls & SC.SC_ScrollBarSlider
and option.state & (QStyle.StateFlag.State_MouseOver
| QStyle.StateFlag.State_Sunken))
if hot:
color = color.darker(theme.HOVER_DARKER)
theme.paint_emboss(painter, thumb, color, theme.BUTTON_RADIUS,
rim=theme.button_rim_px(option.fontMetrics))
painter.restore()
def autoscroll_direction(y: int, height: int, accepted: bool,
margin: int = AUTOSCROLL_MARGIN) -> int:
+43 -8
View File
@@ -168,14 +168,13 @@ def apply_theme(app, prefs):
font.setPointSize(metrics["font_pt"])
app.setFont(font)
bar = metrics["scrollbar"]
# The scrollbars are painted by the app style (track_table's
# ClickToJumpScrollStyle), not the style sheet: a QSS rule on any of
# their parts would make Qt draw them itself, arrows and all.
from lintunes.gui.track_table import ClickToJumpScrollStyle
ClickToJumpScrollStyle.extent = metrics["scrollbar"]
button = palette.color(QPalette.ColorRole.Button)
app.setStyleSheet(f"""
QScrollBar:vertical {{ width: {bar}px; }}
QScrollBar:horizontal {{ height: {bar}px; }}
QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }}
{button_emboss_qss(button)}
""")
app.setStyleSheet(button_emboss_qss(button))
def emboss_stops(color: QColor, pressed: bool = False) -> list:
@@ -207,6 +206,7 @@ EMBOSS_LIGHTER = 103
EMBOSS_DARKER = 103
EMBOSS_RIM = 116
EMBOSS_RIM_WIDTH = 0.08 # fraction of the height each rim takes
BUTTON_RADIUS = 4 # every button's corners, and the scrollbar thumb's
HOVER_DARKER = 107 # hover: the whole button, slightly darker
PRESSED_DARKER = 112
@@ -257,7 +257,42 @@ def paint_deboss(painter, rect, color: QColor, radius: float):
return path
def button_face_qss(selector: str, color: QColor, *, radius: int = 4,
def button_rim_px(metrics) -> float:
"""How tall a button's rims are, in pixels, for a font with these
QFontMetrics: ``EMBOSS_RIM_WIDTH`` of a button's height, which is the
text line plus padding, outline and Fusion's margin (14 px). For things
shaped otherwise (a scrollbar thumb) that still want a button's emboss."""
return EMBOSS_RIM_WIDTH * (metrics.height() + 14)
def paint_emboss(painter, rect, color: QColor, radius: float, rim: float):
"""A button's face for a widget that paints itself: ``emboss_stops`` lit
from above, but with rims ``rim`` pixels tall whatever ``rect``'s height,
so a long scrollbar thumb is embossed exactly as much as a button."""
from PyQt6.QtCore import QPointF
from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen
path = QPainterPath()
path.addRoundedRect(rect, radius, radius)
height = max(rect.height(), 1.0)
edge = min(rim / height, 0.49)
stops = emboss_stops(color)
positions = [0, edge, 0.5, 1 - edge, 1]
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
for pos, (_, stop) in zip(positions, stops):
gradient.setColorAt(pos, stop)
painter.fillPath(path, gradient)
painter.setPen(QPen(color.darker(135), 1))
painter.drawPath(path)
# the darker bottom edge (emboss_edges)
painter.save()
painter.setClipRect(rect.adjusted(-1, rect.height() / 2, 1, 1))
painter.setPen(QPen(color.darker(155), 1))
painter.drawPath(path)
painter.restore()
return path
def button_face_qss(selector: str, color: QColor, *, radius: int = BUTTON_RADIUS,
extra: str = "", checked_color: QColor | None = None,
checked_text: str = "") -> str:
"""The one look every button shares: the emboss, the outline, a hover that
+100
View File
@@ -0,0 +1,100 @@
"""Round 80: scrollbars lose their arrow buttons, and the thumb is a button.
- No arrows: the trough runs the bar's full length and the arrow rects are
empty, so nothing paints or hit-tests there.
- The thumb gets the buttons' corner radius and emboss, with rims as tall in
pixels as a button's, however long the thumb.
- The bar's thickness comes from the UI scale through the style, not QSS
(any QSS rule on a scrollbar part makes Qt draw the arrows itself).
"""
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QPushButton, QScrollBar, QStyle, QStyleOptionSlider
from lintunes import theme
from lintunes.gui.track_table import ClickToJumpScrollStyle
SC = QStyle.SubControl
def _option(bar):
opt = QStyleOptionSlider()
bar.initStyleOption(opt)
return opt
def _bar(qapp, orientation=Qt.Orientation.Vertical):
bar = QScrollBar(orientation)
bar.setStyle(ClickToJumpScrollStyle())
bar.setRange(0, 900)
bar.setPageStep(100)
bar.resize(14, 400) if orientation == Qt.Orientation.Vertical \
else bar.resize(400, 14)
return bar
def test_no_arrow_buttons(qapp):
bar = _bar(qapp)
style, opt = bar.style(), _option(bar)
cc = QStyle.ComplexControl.CC_ScrollBar
assert style.subControlRect(cc, opt, SC.SC_ScrollBarAddLine, bar).isEmpty()
assert style.subControlRect(cc, opt, SC.SC_ScrollBarSubLine, bar).isEmpty()
assert style.subControlRect(cc, opt, SC.SC_ScrollBarGroove, bar) == opt.rect
def test_thumb_spans_the_full_trough(qapp):
for orientation in (Qt.Orientation.Vertical, Qt.Orientation.Horizontal):
bar = _bar(qapp, orientation)
style, cc = bar.style(), QStyle.ComplexControl.CC_ScrollBar
bar.setValue(0)
top = style.subControlRect(cc, _option(bar), SC.SC_ScrollBarSlider, bar)
bar.setValue(900)
end = style.subControlRect(cc, _option(bar), SC.SC_ScrollBarSlider, bar)
if orientation == Qt.Orientation.Vertical:
assert top.top() == 0 and end.bottom() == 399
assert top.height() == 40 # 100 / (900 + 100) of 400
else:
assert top.left() == 0 and end.right() == 399
def test_tiny_page_keeps_a_grabbable_thumb(qapp):
bar = _bar(qapp)
bar.setRange(0, 100000)
rect = bar.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar,
_option(bar), SC.SC_ScrollBarSlider, bar)
assert rect.height() == ClickToJumpScrollStyle.extent * 2
def test_thickness_follows_ui_scale(qapp):
class Prefs(dict):
def get(self, key, default=None):
return "large" if key == "ui_scale" else default
before = ClickToJumpScrollStyle.extent
try:
theme.apply_theme(qapp, Prefs())
assert ClickToJumpScrollStyle.extent == theme.UI_SCALES["large"]["scrollbar"]
assert "QScrollBar" not in qapp.styleSheet()
finally:
ClickToJumpScrollStyle.extent = before
def test_thumb_rims_match_a_real_button(qapp):
qapp.setStyleSheet(theme.button_emboss_qss(QColor("#d0d0d0")))
try:
button = QPushButton("OK")
height = button.sizeHint().height()
assert abs(theme.button_rim_px(button.fontMetrics())
- theme.EMBOSS_RIM_WIDTH * height) < 1
finally:
qapp.setStyleSheet("")
def test_thumb_paints(qapp):
bar = _bar(qapp)
bar.setValue(450)
image = bar.grab().toImage()
thumb = bar.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar,
_option(bar), SC.SC_ScrollBarSlider, bar)
trough = image.pixelColor(7, 5)
middle = image.pixelColor(7, thumb.center().y())
assert middle != trough