breakout styles

This commit is contained in:
Talley Lambert
2021-04-25 09:09:52 -04:00
parent 487921c791
commit 46e966ffcc
3 changed files with 212 additions and 137 deletions

View File

@@ -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 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 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 `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) > The code for these example widgets is [here](examples/demo_widget.py)

View File

@@ -1,6 +1,6 @@
import re
from typing import List, Sequence, Tuple from typing import List, Sequence, Tuple
from ._style import RangeSliderStyle, update_styles_from_stylesheet
from .qtcompat import QtGui from .qtcompat import QtGui
from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
from .qtcompat.QtWidgets import ( from .qtcompat.QtWidgets import (
@@ -12,7 +12,7 @@ from .qtcompat.QtWidgets import (
QWidget, QWidget,
) )
Control = Tuple[str, int] ControlType = Tuple[str, int]
class QRangeSlider(QSlider): class QRangeSlider(QSlider):
@@ -23,9 +23,11 @@ class QRangeSlider(QSlider):
# The value is the positions of *all* handles. # The value is the positions of *all* handles.
sliderMoved = Signal(tuple) 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) super().__init__(orientation, parent)
# list of values # list of values
@@ -33,29 +35,23 @@ class QRangeSlider(QSlider):
# list of current positions of each handle. Must be same length as _value # 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 # If tracking is enabled (the default) this will be identical to _value
self._position: List[int] = [20, 80] self._position: List[int] = [20, 80]
self._pressedControl: Control = self.NULL_CTRL self._pressedControl: ControlType = self._NULL_CTRL
self._hoverControl: Control = self.NULL_CTRL self._hoverControl: ControlType = self._NULL_CTRL
# whether bar length is constant when dragging the bar # whether bar length is constant when dragging the bar
# if False, the bar can shorten when dragged beyond min/max # if False, the bar can shorten when dragged beyond min/max
self._bar_is_stiff = True self._bar_is_stiff = True
# whether clicking on the bar moves all handles, or just the nearest handle. # whether clicking on the bar moves all handles, or just the nearest handle.
self._bar_moves_all = True self._bar_moves_all = True
self._should_draw_bar = True
# for keyboard nav # for keyboard nav
self._repeatMultiplier = 1 # TODO self._repeatMultiplier = 1 # TODO
# for wheel nav # for wheel nav
self._offset_accum = 0 self._tick__accum = 0
# color # color
self._bar_color_active = QtGui.QColor("#3B88FD") self._style = RangeSliderStyle()
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
def value(self) -> Tuple[int, ...]: def value(self) -> Tuple[int, ...]:
return tuple(self._value) return tuple(self._value)
@@ -103,6 +99,19 @@ class QRangeSlider(QSlider):
opt.sliderPosition = 0 opt.sliderPosition = 0
return opt 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: def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
"""Paint the slider.""" """Paint the slider."""
# initialize painter and options # initialize painter and options
@@ -113,21 +122,8 @@ class QRangeSlider(QSlider):
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
painter.drawComplexControl(QStyle.CC_Slider, opt) painter.drawComplexControl(QStyle.CC_Slider, opt)
# draw bar if self._should_draw_bar:
r_bar = self._barRect(opt) self._drawBar(painter, 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)
# draw handles # draw handles
opt.subControls = QStyle.SC_SliderHandle opt.subControls = QStyle.SC_SliderHandle
@@ -151,7 +147,7 @@ class QRangeSlider(QSlider):
def event(self, ev: QEvent) -> bool: def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.StyleChange: if ev.type() == QEvent.StyleChange:
self._traverseStyleSheet() update_styles_from_stylesheet(self)
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove): if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
old_hover = self._hoverControl old_hover = self._hoverControl
self._hoverControl = self._getControlAtPos(ev.pos()) self._hoverControl = self._getControlAtPos(ev.pos())
@@ -229,7 +225,7 @@ class QRangeSlider(QSlider):
return return
ev.accept() ev.accept()
old_pressed = self._pressedControl old_pressed = self._pressedControl
self._pressedControl = self.NULL_CTRL self._pressedControl = self._NULL_CTRL
self.setRepeatAction(QSlider.SliderNoAction) self.setRepeatAction(QSlider.SliderNoAction)
if old_pressed[0] in ("handle", "bar"): if old_pressed[0] in ("handle", "bar"):
self.setSliderDown(False) self.setSliderDown(False)
@@ -274,21 +270,17 @@ class QRangeSlider(QSlider):
r_bar = QRectF(r_groove) r_bar = QRectF(r_groove)
hdl_low, *_, hdl_high = self._handleRects(opt) hdl_low, *_, hdl_high = self._handleRects(opt)
tp = self.tickPosition() thickness = self._style.thickness(opt.orientation)
if tp & QSlider.TicksAbove: tick_offset = self._style.offset(self.tickPosition())
displace = 4
elif tp & QSlider.TicksBelow:
displace = -4
else:
displace = 0
if opt.orientation == Qt.Horizontal: if opt.orientation == Qt.Horizontal:
r_bar.setTop(r_bar.center().y() - self._bar_height / 2 + displace) r_bar.setTop(r_bar.center().y() - thickness / 2 + tick_offset)
r_bar.setHeight(self._bar_height) r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x()) r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x()) r_bar.setRight(hdl_high.center().x())
else: else:
r_bar.setLeft(r_bar.center().x() - self._bar_width / 2 + displace) r_bar.setLeft(r_bar.center().x() - thickness / 2 + tick_offset)
r_bar.setWidth(self._bar_width) r_bar.setWidth(thickness)
r_bar.setBottom(hdl_low.center().y()) r_bar.setBottom(hdl_low.center().y())
r_bar.setTop(hdl_high.center().y()) r_bar.setTop(hdl_high.center().y())
@@ -296,7 +288,7 @@ class QRangeSlider(QSlider):
def _getControlAtPos( def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
) -> Control: ) -> ControlType:
"""Update self._pressedControl based on ev.pos().""" """Update self._pressedControl based on ev.pos()."""
if not opt: if not opt:
opt = self._getStyleOption() opt = self._getStyleOption()
@@ -333,7 +325,7 @@ class QRangeSlider(QSlider):
elif closest_handle: elif closest_handle:
return ("handle", hdl_idx) return ("handle", hdl_idx)
return self.NULL_CTRL return self._NULL_CTRL
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint: def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
# to take half of the slider off for the setSliderPosition call we use the # 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: def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
return # TODO 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: def _bound(min_: int, max_: int, value: int) -> int:
"""Return value bounded by min_ and max_.""" """Return value bounded by min_ and max_."""
return max(min_, min(max_, value)) return max(min_, min(max_, value))
# Styles Parsing ##############
qlineargrad_pattern = re.compile(
r"""
qlineargradient\(
x1:\s*(?P<x1>\d*\.?\d+),\s*
y1:\s*(?P<y1>\d*\.?\d+),\s*
x2:\s*(?P<x2>\d*\.?\d+),\s*
y2:\s*(?P<y2>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\S+)
\)""",
re.X,
)
qradial_pattern = re.compile(
r"""
qradialgradient\(
cx:\s*(?P<cx>\d*\.?\d+),\s*
cy:\s*(?P<cy>\d*\.?\d+),\s*
radius:\s*(?P<radius>\d*\.?\d+),\s*
fx:\s*(?P<fx>\d*\.?\d+),\s*
fy:\s*(?P<fy>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\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")

173
qtrangeslider/_style.py Normal file
View File

@@ -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<x1>\d*\.?\d+),\s*
y1:\s*(?P<y1>\d*\.?\d+),\s*
x2:\s*(?P<x2>\d*\.?\d+),\s*
y2:\s*(?P<y2>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\S+)
\)""",
re.X,
)
qradial_pattern = re.compile(
r"""
qradialgradient\(
cx:\s*(?P<cx>\d*\.?\d+),\s*
cy:\s*(?P<cy>\d*\.?\d+),\s*
radius:\s*(?P<radius>\d*\.?\d+),\s*
fx:\s*(?P<fx>\d*\.?\d+),\s*
fy:\s*(?P<fy>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\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)