291 lines
7.5 KiB
Python
291 lines
7.5 KiB
Python
# piker: trading gear for hackers
|
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
Non-shitty labels that don't re-invent the wheel.
|
|
|
|
"""
|
|
from inspect import isfunction
|
|
from typing import Callable, Optional, Any
|
|
|
|
import pyqtgraph as pg
|
|
from PyQt5 import QtGui, QtWidgets
|
|
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
|
from PyQt5.QtCore import QPointF, QRectF, Qt
|
|
|
|
from ._style import (
|
|
DpiAwareFont,
|
|
hcolor,
|
|
_font,
|
|
)
|
|
|
|
|
|
class Label:
|
|
'''
|
|
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
|
|
|
|
After hacking for many days on multiple "label" systems inside
|
|
``pyqtgraph`` yet again we're left writing our own since it seems
|
|
all of those are over complicated, ad-hoc, transform-mangling,
|
|
messes which can't accomplish the simplest things via their inputs
|
|
(such as pinning to the left hand side of a view box).
|
|
|
|
Here we do the simple thing where the label uses callables to figure
|
|
out the (x, y) coordinate "pin point": nice and simple.
|
|
|
|
This type is another effort (see our graphics) to start making
|
|
small, re-usable label components that can actually be used to build
|
|
production grade UIs...
|
|
|
|
'''
|
|
def __init__(
|
|
self,
|
|
view: pg.ViewBox,
|
|
fmt_str: str,
|
|
|
|
color: str = 'default_light',
|
|
x_offset: float = 0,
|
|
font_size: str = 'small',
|
|
opacity: float = 1,
|
|
fields: dict = {},
|
|
parent: pg.GraphicsObject = None,
|
|
update_on_range_change: bool = True,
|
|
|
|
) -> None:
|
|
|
|
vb = self.vb = view
|
|
self._fmt_str = fmt_str
|
|
self._view_xy = QPointF(0, 0)
|
|
|
|
self.scene_anchor: Optional[
|
|
Callable[..., QPointF]
|
|
] = None
|
|
|
|
self._x_offset = x_offset
|
|
|
|
txt = self.txt = QtWidgets.QGraphicsTextItem(parent=parent)
|
|
txt.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
|
|
|
vb.scene().addItem(txt)
|
|
|
|
# configure font size based on DPI
|
|
dpi_font = DpiAwareFont(
|
|
font_size=font_size,
|
|
)
|
|
dpi_font.configure_to_dpi()
|
|
txt.setFont(dpi_font.font)
|
|
txt.setOpacity(opacity)
|
|
|
|
# register viewbox callbacks
|
|
if update_on_range_change:
|
|
vb.sigRangeChanged.connect(self.on_sigrange_change)
|
|
|
|
self._hcolor: str = ''
|
|
self.color = color
|
|
|
|
self.fields = fields
|
|
self.orient_v = 'bottom'
|
|
|
|
self._anchor_func = self.txt.pos().x
|
|
|
|
# not sure if this makes a diff
|
|
self.txt.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
|
|
|
# TODO: edit and selection support
|
|
# https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum
|
|
# self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction)
|
|
|
|
@property
|
|
def color(self) -> str:
|
|
return self._hcolor
|
|
|
|
@color.setter
|
|
def color(self, color: str) -> None:
|
|
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
|
|
self._hcolor = color
|
|
|
|
def update(self) -> None:
|
|
'''
|
|
Update this label either by invoking its user defined anchoring
|
|
function, or by positioning to the last recorded data view
|
|
coordinates.
|
|
|
|
'''
|
|
# move label in scene coords to desired position
|
|
anchor = self.scene_anchor
|
|
if anchor:
|
|
self.txt.setPos(anchor())
|
|
|
|
else:
|
|
# position based on last computed view coordinate
|
|
self.set_view_pos(self._view_xy.y())
|
|
|
|
def on_sigrange_change(self, vr, r) -> None:
|
|
return self.update()
|
|
|
|
@property
|
|
def w(self) -> float:
|
|
return self.txt.boundingRect().width()
|
|
|
|
def scene_br(self) -> QRectF:
|
|
txt = self.txt
|
|
return txt.mapToScene(
|
|
txt.boundingRect()
|
|
).boundingRect()
|
|
|
|
@property
|
|
def h(self) -> float:
|
|
return self.txt.boundingRect().height()
|
|
|
|
def vbr(self) -> QRectF:
|
|
return self.vb.boundingRect()
|
|
|
|
def set_x_anchor_func(
|
|
self,
|
|
func: Callable,
|
|
) -> None:
|
|
assert isinstance(func(), float)
|
|
self._anchor_func = func
|
|
|
|
def set_view_pos(
|
|
self,
|
|
|
|
y: float,
|
|
x: Optional[float] = None,
|
|
|
|
) -> None:
|
|
|
|
if x is None:
|
|
scene_x = self._anchor_func() or self.txt.pos().x()
|
|
x = self.vb.mapToView(QPointF(scene_x, scene_x)).x()
|
|
|
|
# get new (inside the) view coordinates / position
|
|
self._view_xy = QPointF(x, y)
|
|
|
|
# map back to the outer UI-land "scene" coordinates
|
|
s_xy = self.vb.mapFromView(self._view_xy)
|
|
|
|
if self.orient_v == 'top':
|
|
s_xy = QPointF(s_xy.x(), s_xy.y() - self.h)
|
|
|
|
# move label in scene coords to desired position
|
|
self.txt.setPos(s_xy)
|
|
|
|
assert s_xy == self.txt.pos()
|
|
|
|
@property
|
|
def fmt_str(self) -> str:
|
|
return self._fmt_str
|
|
|
|
@fmt_str.setter
|
|
def fmt_str(self, fmt_str: str) -> None:
|
|
self._fmt_str = fmt_str
|
|
|
|
def format(
|
|
self,
|
|
**fields: dict
|
|
|
|
) -> str:
|
|
|
|
out = {}
|
|
|
|
# this is hacky support for single depth
|
|
# calcs of field data from field data
|
|
# ex. to calculate a $value = price * size
|
|
for k, v in fields.items():
|
|
|
|
if isfunction(v):
|
|
out[k] = v(fields)
|
|
|
|
else:
|
|
out[k] = v
|
|
|
|
text = self._fmt_str.format(**out)
|
|
|
|
# for large numbers with a thousands place
|
|
text = text.replace(',', ' ')
|
|
|
|
self.txt.setPlainText(text)
|
|
|
|
def render(self) -> None:
|
|
self.format(**self.fields)
|
|
|
|
def show(self) -> None:
|
|
self.txt.show()
|
|
self.txt.update()
|
|
|
|
def hide(self) -> None:
|
|
self.txt.hide()
|
|
|
|
def delete(self) -> None:
|
|
self.vb.scene().removeItem(self.txt)
|
|
|
|
|
|
class FormatLabel(QLabel):
|
|
'''
|
|
Kinda similar to above but using the widget apis.
|
|
|
|
'''
|
|
def __init__(
|
|
self,
|
|
|
|
fmt_str: str,
|
|
font: QtGui.QFont,
|
|
font_size: int,
|
|
font_color: str,
|
|
|
|
parent=None,
|
|
|
|
) -> None:
|
|
|
|
super().__init__(parent)
|
|
|
|
# by default set the format string verbatim and expect user to
|
|
# call ``.format()`` later (presumably they'll notice the
|
|
# unformatted content if ``fmt_str`` isn't meant to be
|
|
# unformatted).
|
|
self.fmt_str = fmt_str
|
|
self.setText(fmt_str)
|
|
|
|
self.setStyleSheet(
|
|
f"""QLabel {{
|
|
color : {hcolor(font_color)};
|
|
font-size : {font_size}px;
|
|
}}
|
|
"""
|
|
)
|
|
self.setFont(_font.font)
|
|
self.setTextFormat(Qt.MarkdownText) # markdown
|
|
self.setMargin(0)
|
|
|
|
self.setSizePolicy(
|
|
QSizePolicy.Expanding,
|
|
QSizePolicy.Expanding,
|
|
)
|
|
self.setAlignment(
|
|
Qt.AlignVCenter | Qt.AlignLeft
|
|
)
|
|
self.setText(self.fmt_str)
|
|
|
|
def format(
|
|
self,
|
|
**fields: dict[str, Any],
|
|
|
|
) -> str:
|
|
out = self.fmt_str.format(**fields)
|
|
self.setText(out)
|
|
return out
|