piker/piker/ui/_label.py

286 lines
7.3 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
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 = {},
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()
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):
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.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