diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 772aab9..136933a 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.45.1" +__version__ = "0.46.0" diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index 53c416a..f09a051 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -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: diff --git a/lintunes/theme.py b/lintunes/theme.py index 5275d2c..a94b1a1 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -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 diff --git a/tests/test_round80.py b/tests/test_round80.py new file mode 100644 index 0000000..2696094 --- /dev/null +++ b/tests/test_round80.py @@ -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