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:
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.45.1"
|
__version__ = "0.46.0"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from PyQt6.QtCore import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
||||||
QFontMetrics, QDesktopServices, QCursor,
|
QFontMetrics, QDesktopServices, QCursor, QPalette,
|
||||||
)
|
)
|
||||||
from PyQt6.QtDBus import (
|
from PyQt6.QtDBus import (
|
||||||
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
|
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
|
||||||
@@ -30,15 +30,90 @@ AUTOSCROLL_MARGIN = 28
|
|||||||
|
|
||||||
|
|
||||||
class ClickToJumpScrollStyle(QProxyStyle):
|
class ClickToJumpScrollStyle(QProxyStyle):
|
||||||
"""Makes a left-click on the scrollbar trough jump the thumb straight to
|
"""The app's scrollbars. A left-click on the trough jumps the thumb
|
||||||
that spot (absolute position) instead of paging toward it a step at a time.
|
straight to that spot (absolute position) instead of paging toward it a
|
||||||
Wraps the default app style and only overrides the one hint."""
|
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):
|
def styleHint(self, hint, option=None, widget=None, returnData=None):
|
||||||
if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition:
|
if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition:
|
||||||
return 1
|
return 1
|
||||||
return super().styleHint(hint, option, widget, returnData)
|
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,
|
def autoscroll_direction(y: int, height: int, accepted: bool,
|
||||||
margin: int = AUTOSCROLL_MARGIN) -> int:
|
margin: int = AUTOSCROLL_MARGIN) -> int:
|
||||||
|
|||||||
+43
-8
@@ -168,14 +168,13 @@ def apply_theme(app, prefs):
|
|||||||
font.setPointSize(metrics["font_pt"])
|
font.setPointSize(metrics["font_pt"])
|
||||||
app.setFont(font)
|
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)
|
button = palette.color(QPalette.ColorRole.Button)
|
||||||
app.setStyleSheet(f"""
|
app.setStyleSheet(button_emboss_qss(button))
|
||||||
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)}
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def emboss_stops(color: QColor, pressed: bool = False) -> list:
|
def emboss_stops(color: QColor, pressed: bool = False) -> list:
|
||||||
@@ -207,6 +206,7 @@ EMBOSS_LIGHTER = 103
|
|||||||
EMBOSS_DARKER = 103
|
EMBOSS_DARKER = 103
|
||||||
EMBOSS_RIM = 116
|
EMBOSS_RIM = 116
|
||||||
EMBOSS_RIM_WIDTH = 0.08 # fraction of the height each rim takes
|
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
|
HOVER_DARKER = 107 # hover: the whole button, slightly darker
|
||||||
PRESSED_DARKER = 112
|
PRESSED_DARKER = 112
|
||||||
|
|
||||||
@@ -257,7 +257,42 @@ def paint_deboss(painter, rect, color: QColor, radius: float):
|
|||||||
return path
|
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,
|
extra: str = "", checked_color: QColor | None = None,
|
||||||
checked_text: str = "") -> str:
|
checked_text: str = "") -> str:
|
||||||
"""The one look every button shares: the emboss, the outline, a hover that
|
"""The one look every button shares: the emboss, the outline, a hover that
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user