mirror of
https://github.com/pyapp-kit/superqt.git
synced 2026-01-04 19:31:24 +01:00
breakout styles
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<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
173
qtrangeslider/_style.py
Normal 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)
|
||||
Reference in New Issue
Block a user