diff --git a/README.md b/README.md index 99e4ea2..aa7d344 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ These screenshots show `QRangeSlider` (multiple handles) next to the native `QSl style of `QSlider` – with or without tick marks. When styles have been applied using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then `QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits -from QSlider). +from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`, +then you can also target it directly in your style sheet. > The code for these example widgets is [here](examples/demo_widget.py) diff --git a/qtrangeslider/_qrangeslider.py b/qtrangeslider/_qrangeslider.py index f362054..5e76bd2 100644 --- a/qtrangeslider/_qrangeslider.py +++ b/qtrangeslider/_qrangeslider.py @@ -1,6 +1,6 @@ -import re from typing import List, Sequence, Tuple +from ._style import RangeSliderStyle, update_styles_from_stylesheet from .qtcompat import QtGui from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal from .qtcompat.QtWidgets import ( @@ -12,7 +12,7 @@ from .qtcompat.QtWidgets import ( QWidget, ) -Control = Tuple[str, int] +ControlType = Tuple[str, int] class QRangeSlider(QSlider): @@ -23,9 +23,11 @@ class QRangeSlider(QSlider): # The value is the positions of *all* handles. sliderMoved = Signal(tuple) - NULL_CTRL = ("None", -1) + _NULL_CTRL = ("None", -1) - def __init__(self, orientation=Qt.Horizontal, parent: QWidget = None): + def __init__( + self, orientation: Qt.Orientation = Qt.Horizontal, parent: QWidget = None + ): super().__init__(orientation, parent) # list of values @@ -33,29 +35,23 @@ class QRangeSlider(QSlider): # list of current positions of each handle. Must be same length as _value # If tracking is enabled (the default) this will be identical to _value self._position: List[int] = [20, 80] - self._pressedControl: Control = self.NULL_CTRL - self._hoverControl: Control = self.NULL_CTRL + self._pressedControl: ControlType = self._NULL_CTRL + self._hoverControl: ControlType = self._NULL_CTRL # whether bar length is constant when dragging the bar # if False, the bar can shorten when dragged beyond min/max self._bar_is_stiff = True # whether clicking on the bar moves all handles, or just the nearest handle. self._bar_moves_all = True + self._should_draw_bar = True # for keyboard nav self._repeatMultiplier = 1 # TODO # for wheel nav - self._offset_accum = 0 + self._tick__accum = 0 # color - self._bar_color_active = QtGui.QColor("#3B88FD") - self._bar_color_inactive = QtGui.QColor("#8F8F8F") - self._bar_color_disabled = QtGui.QColor("#BBBBBB") - self._bar_height = 3 - self._bar_width = 3 - - # try to parse QSS for the bar height and color - self._parse_stylesheet = True + self._style = RangeSliderStyle() def value(self) -> Tuple[int, ...]: return tuple(self._value) @@ -103,6 +99,19 @@ class QRangeSlider(QSlider): opt.sliderPosition = 0 return opt + def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider): + + brush = self._style.brush(self.palette().currentColorGroup()) + + r_bar = self._barRect(opt) + if isinstance(brush, QtGui.QGradient): + brush.setStart(r_bar.topLeft()) + brush.setFinalStop(r_bar.bottomRight()) + + painter.setPen(Qt.NoPen) + painter.setBrush(brush) + painter.drawRect(r_bar) + def paintEvent(self, a0: QtGui.QPaintEvent) -> None: """Paint the slider.""" # initialize painter and options @@ -113,21 +122,8 @@ class QRangeSlider(QSlider): opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks painter.drawComplexControl(QStyle.CC_Slider, opt) - # draw bar - r_bar = self._barRect(opt) - if self._bar_color_active is not None: # FIXME - if self.palette().currentColorGroup() == QtGui.QPalette.Active: - color = self._bar_color_active - elif self.palette().currentColorGroup() == QtGui.QPalette.Inactive: - color = self._bar_color_inactive - else: - color = self._bar_color_disabled - else: - color = self.palette().color(QtGui.QPalette.Highlight) - if isinstance(color, QtGui.QGradient): - color.setStart(r_bar.topLeft()) - color.setFinalStop(r_bar.bottomRight()) - painter.fillRect(r_bar, color) + if self._should_draw_bar: + self._drawBar(painter, opt) # draw handles opt.subControls = QStyle.SC_SliderHandle @@ -151,7 +147,7 @@ class QRangeSlider(QSlider): def event(self, ev: QEvent) -> bool: if ev.type() == QEvent.StyleChange: - self._traverseStyleSheet() + update_styles_from_stylesheet(self) if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove): old_hover = self._hoverControl self._hoverControl = self._getControlAtPos(ev.pos()) @@ -229,7 +225,7 @@ class QRangeSlider(QSlider): return ev.accept() old_pressed = self._pressedControl - self._pressedControl = self.NULL_CTRL + self._pressedControl = self._NULL_CTRL self.setRepeatAction(QSlider.SliderNoAction) if old_pressed[0] in ("handle", "bar"): self.setSliderDown(False) @@ -274,21 +270,17 @@ class QRangeSlider(QSlider): r_bar = QRectF(r_groove) hdl_low, *_, hdl_high = self._handleRects(opt) - tp = self.tickPosition() - if tp & QSlider.TicksAbove: - displace = 4 - elif tp & QSlider.TicksBelow: - displace = -4 - else: - displace = 0 + thickness = self._style.thickness(opt.orientation) + tick_offset = self._style.offset(self.tickPosition()) + if opt.orientation == Qt.Horizontal: - r_bar.setTop(r_bar.center().y() - self._bar_height / 2 + displace) - r_bar.setHeight(self._bar_height) + r_bar.setTop(r_bar.center().y() - thickness / 2 + tick_offset) + r_bar.setHeight(thickness) r_bar.setLeft(hdl_low.center().x()) r_bar.setRight(hdl_high.center().x()) else: - r_bar.setLeft(r_bar.center().x() - self._bar_width / 2 + displace) - r_bar.setWidth(self._bar_width) + r_bar.setLeft(r_bar.center().x() - thickness / 2 + tick_offset) + r_bar.setWidth(thickness) r_bar.setBottom(hdl_low.center().y()) r_bar.setTop(hdl_high.center().y()) @@ -296,7 +288,7 @@ class QRangeSlider(QSlider): def _getControlAtPos( self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False - ) -> Control: + ) -> ControlType: """Update self._pressedControl based on ev.pos().""" if not opt: opt = self._getStyleOption() @@ -333,7 +325,7 @@ class QRangeSlider(QSlider): elif closest_handle: return ("handle", hdl_idx) - return self.NULL_CTRL + return self._NULL_CTRL def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint: # to take half of the slider off for the setSliderPosition call we use the @@ -454,98 +446,7 @@ class QRangeSlider(QSlider): def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None: return # TODO - def _traverseStyleSheet(self): - - qss = self.styleSheet() - p = self - while p.parent(): - qss = p.styleSheet() + qss - p = p.parent() - qss = QApplication.instance().styleSheet() + qss - - # Find bar color - # TODO: optional horizontal or vertical - match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S) - if match: - orientation, content = match.groups() - for line in reversed(content.splitlines()): - bgrd = re.search(r"background(-color)?:\s*([^;]+)", line) - if bgrd: - self._bar_color_active = parse_color(bgrd.groups()[-1]) - # TODO: bar color inactive? - # TODO: bar color disabled? - class_name = type(self).__name__ - _ss = f"\n{class_name}::sub-page:{orientation}{{background: none}}" - # TODO: block double event - self.setStyleSheet(qss + _ss) - break - - # Find bar height/width - for orient, dim in (("horizontal", "height"), ("vertical", "width")): - match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S) - if match: - for line in reversed(match.groups()[0].splitlines()): - bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line) - if bgrd: - setattr(self, f"_bar_{dim}", float(bgrd.groups()[-1])) - def _bound(min_: int, max_: int, value: int) -> int: """Return value bounded by min_ and max_.""" return max(min_, min(max_, value)) - - -# Styles Parsing ############## - -qlineargrad_pattern = re.compile( - r""" - qlineargradient\( - x1:\s*(?P\d*\.?\d+),\s* - y1:\s*(?P\d*\.?\d+),\s* - x2:\s*(?P\d*\.?\d+),\s* - y2:\s*(?P\d*\.?\d+),\s* - stop:0\s*(?P\S+),.* - stop:1\s*(?P\S+) - \)""", - re.X, -) - -qradial_pattern = re.compile( - r""" - qradialgradient\( - cx:\s*(?P\d*\.?\d+),\s* - cy:\s*(?P\d*\.?\d+),\s* - radius:\s*(?P\d*\.?\d+),\s* - fx:\s*(?P\d*\.?\d+),\s* - fy:\s*(?P\d*\.?\d+),\s* - stop:0\s*(?P\S+),.* - stop:1\s*(?P\S+) - \)""", - re.X, -) - - -def parse_color(color: str): - qc = QtGui.QColor(color) - if qc.isValid(): - return qc - - # try linear gradient: - match = qlineargrad_pattern.match(color) - if match: - grad = QtGui.QLinearGradient(*[float(i) for i in match.groups()[:4]]) - grad.setColorAt(0, QtGui.QColor(match.groupdict()["stop0"])) - grad.setColorAt(1, QtGui.QColor(match.groupdict()["stop1"])) - return grad - - # try linear gradient: - match = qradial_pattern.match(color) - print("match", match.groupdict()) - if match: - grad = QtGui.QRadialGradient(*[float(i) for i in match.groups()[:5]]) - grad.setColorAt(0, QtGui.QColor(match.groupdict()["stop0"])) - grad.setColorAt(1, QtGui.QColor(match.groupdict()["stop1"])) - return grad - - # fallback to dark gray - return QtGui.QColor("#333") diff --git a/qtrangeslider/_style.py b/qtrangeslider/_style.py new file mode 100644 index 0000000..41844c9 --- /dev/null +++ b/qtrangeslider/_style.py @@ -0,0 +1,173 @@ +import platform +import re +from dataclasses import dataclass, replace +from typing import Union + +from .qtcompat.QtCore import Qt +from .qtcompat.QtGui import ( + QColor, + QGradient, + QLinearGradient, + QPalette, + QRadialGradient, +) +from .qtcompat.QtWidgets import QApplication, QSlider + + +@dataclass +class RangeSliderStyle: + brush_active: str = None + brush_inactive: str = None + brush_disabled: str = None + pen_active: str = None + pen_inactive: str = None + pen_disabled: str = None + vertical_thickness: float = None + horizontal_thickness: float = None + tick_offest: float = None + + def brush(self, cg: QPalette.ColorGroup) -> Union[QGradient, QColor]: + attr = { + QPalette.Active: "brush_active", # 0 + QPalette.Disabled: "brush_disabled", # 1 + QPalette.Inactive: "brush_inactive", # 2 + }[cg] + val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr) + if isinstance(val, str): + val = QColor(val) + return val + + def offset(self, tp: QSlider.TickPosition) -> int: + val = self.tick_offest or SYSTEM_STYLE.tick_offest + if tp & QSlider.TicksAbove: + return val + elif tp & QSlider.TicksBelow: + return -val + else: + return 0 + + def thickness(self, orientation: Qt.Orientation) -> float: + if orientation == Qt.Horizontal: + return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness + else: + return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness + + +# ########## System-specific default styles ############ + +CATALINA_STYLE = RangeSliderStyle( + brush_active="#3B88FD", + brush_inactive="#8F8F8F", + brush_disabled="#BBBBBB", + horizontal_thickness=3, + vertical_thickness=3, + tick_offest=4, +) + +BIG_SUR_STYLE = replace(CATALINA_STYLE) + +SYSTEM = platform.system() +if SYSTEM == "Darwin": + if int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 11: + SYSTEM_STYLE = BIG_SUR_STYLE + else: + SYSTEM_STYLE = CATALINA_STYLE +elif SYSTEM == "Windows": + SYSTEM_STYLE = RangeSliderStyle() +elif SYSTEM == "Linux": + LINUX = True + SYSTEM_STYLE = RangeSliderStyle() +else: + SYSTEM_STYLE = RangeSliderStyle() + + +# ################ Stylesheet parsing logic ######################## + +qlineargrad_pattern = re.compile( + r""" + qlineargradient\( + x1:\s*(?P\d*\.?\d+),\s* + y1:\s*(?P\d*\.?\d+),\s* + x2:\s*(?P\d*\.?\d+),\s* + y2:\s*(?P\d*\.?\d+),\s* + stop:0\s*(?P\S+),.* + stop:1\s*(?P\S+) + \)""", + re.X, +) + +qradial_pattern = re.compile( + r""" + qradialgradient\( + cx:\s*(?P\d*\.?\d+),\s* + cy:\s*(?P\d*\.?\d+),\s* + radius:\s*(?P\d*\.?\d+),\s* + fx:\s*(?P\d*\.?\d+),\s* + fy:\s*(?P\d*\.?\d+),\s* + stop:0\s*(?P\S+),.* + stop:1\s*(?P\S+) + \)""", + re.X, +) + + +def parse_color(color: str) -> Union[str, QGradient]: + qc = QColor(color) + if qc.isValid(): + return qc + + # try linear gradient: + match = qlineargrad_pattern.match(color) + if match: + grad = QLinearGradient(*[float(i) for i in match.groups()[:4]]) + grad.setColorAt(0, QColor(match.groupdict()["stop0"])) + grad.setColorAt(1, QColor(match.groupdict()["stop1"])) + return grad + + # try linear gradient: + match = qradial_pattern.match(color) + print("match", match.groupdict()) + if match: + grad = QRadialGradient(*[float(i) for i in match.groups()[:5]]) + grad.setColorAt(0, QColor(match.groupdict()["stop0"])) + grad.setColorAt(1, QColor(match.groupdict()["stop1"])) + return grad + + # fallback to dark gray + return "#333" + + +def update_styles_from_stylesheet(obj): + qss = obj.styleSheet() + p = obj + while p.parent(): + qss = p.styleSheet() + qss + p = p.parent() + qss = QApplication.instance().styleSheet() + qss + + # Find bar color + # TODO: optional horizontal or vertical + match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S) + if match: + orientation, content = match.groups() + for line in reversed(content.splitlines()): + bgrd = re.search(r"background(-color)?:\s*([^;]+)", line) + if bgrd: + obj._style.brush_active = parse_color(bgrd.groups()[-1]) + # TODO: bar color inactive? + # TODO: bar color disabled? + class_name = type(obj).__name__ + _ss = f"\n{class_name}::sub-page:{orientation}{{background: none}}" + # TODO: block double event + obj.setStyleSheet(qss + _ss) + break + + # Find bar height/width + for orient, dim in (("horizontal", "height"), ("vertical", "width")): + match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S) + if match: + for line in reversed(match.groups()[0].splitlines()): + bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line) + if bgrd: + thickness = float(bgrd.groups()[-1]) + setattr(obj._style, f"{orient}_thickness", thickness)