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."""
|
||||
|
||||
__version__ = "0.45.1"
|
||||
__version__ = "0.46.0"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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