Add a dpi aware font wrapper type

bar_select
Tyler Goodlet 2020-11-02 12:02:05 -05:00
parent 307c501763
commit 9e7aa3f9bf
2 changed files with 207 additions and 75 deletions

View File

@ -1,21 +1,23 @@
""" """
Chart axes graphics and behavior. Chart axes graphics and behavior.
""" """
from typing import List, Tuple from typing import List, Tuple, Optional
import pandas as pd import pandas as pd
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF
from ._style import _font, hcolor from ._style import DpiAwareFont, hcolor, _font
from ..data._source import float_digits from ..data._source import float_digits
_axis_pen = pg.mkPen(hcolor('bracket')) _axis_pen = pg.mkPen(hcolor('bracket'))
class Axis(pg.AxisItem): class Axis(pg.AxisItem):
"""A better axis that sizes to typical tick contents considering font size.
"""
def __init__( def __init__(
self, self,
linked_charts, linked_charts,
@ -27,10 +29,10 @@ class Axis(pg.AxisItem):
super().__init__(**kwargs) super().__init__(**kwargs)
self.setTickFont(_font) self.setTickFont(_font.font)
self.setStyle(**{ self.setStyle(**{
'textFillLimits': [(0, 0.666)], 'textFillLimits': [(0, 0.666)],
'tickFont': _font, 'tickFont': _font.font,
# 'tickTextWidth': 100, # 'tickTextWidth': 100,
# 'tickTextHeight': 20, # 'tickTextHeight': 20,
# 'tickTextWidth': 40, # 'tickTextWidth': 40,
@ -42,9 +44,9 @@ class Axis(pg.AxisItem):
}) })
# self.setLabel(**{'font-size': '10pt'}) # self.setLabel(**{'font-size': '10pt'})
self.setTickFont(_font) self.setTickFont(_font.font)
self.setPen(_axis_pen) self.setPen(_axis_pen)
self.typical_br = _font._fm.boundingRect(typical_max_str) self.typical_br = _font._qfm.boundingRect(typical_max_str)
# size the pertinent axis dimension to a "typical value" # size the pertinent axis dimension to a "typical value"
self.resize() self.resize()
@ -92,17 +94,18 @@ class DynamicDateAxis(Axis):
self, self,
indexes: List[int], indexes: List[int],
) -> List[str]: ) -> List[str]:
bars = self.linked_charts.chart._array bars = self.linked_charts.chart._array
times = bars['time']
bars_len = len(bars) bars_len = len(bars)
# delay = times[-1] - times[times != times[-1]][-1] times = bars['time']
delay = times[-1] - times[-2]
epochs = times[list( epochs = times[list(
map(int, filter(lambda i: i < bars_len, indexes)) map(int, filter(lambda i: i < bars_len, indexes))
)] )]
# TODO: **don't** have this hard coded shift to EST # TODO: **don't** have this hard coded shift to EST
dts = pd.to_datetime(epochs, unit='s') # - 4*pd.offsets.Hour() dts = pd.to_datetime(epochs, unit='s') # - 4*pd.offsets.Hour()
delay = times[-1] - times[-2]
return dts.strftime(self.tick_tpl[delay]) return dts.strftime(self.tick_tpl[delay])
def tickStrings(self, values: List[float], scale, spacing): def tickStrings(self, values: List[float], scale, spacing):
@ -111,9 +114,8 @@ class DynamicDateAxis(Axis):
class AxisLabel(pg.GraphicsObject): class AxisLabel(pg.GraphicsObject):
_font = _font
_w_margin = 0 _w_margin = 0
_h_margin = 3 _h_margin = 0
def __init__( def __init__(
self, self,
@ -122,46 +124,62 @@ class AxisLabel(pg.GraphicsObject):
bg_color: str = 'bracket', bg_color: str = 'bracket',
fg_color: str = 'black', fg_color: str = 'black',
opacity: int = 1, opacity: int = 1,
font_size: Optional[int] = None,
): ):
super().__init__(parent) super().__init__(parent)
self.parent = parent self.parent = parent
self.opacity = opacity self.opacity = opacity
self.label_str = '' self.label_str = ''
self.digits = digits self.digits = digits
self._txt_br: QtCore.QRect = None self._txt_br: QtCore.QRect = None
self._dpifont = DpiAwareFont()
self._dpifont.configure_to_dpi(_font._screen)
if font_size is not None:
self._dpifont._set_qfont_px_size(font_size)
# self._font._fm = QtGui.QFontMetrics(self._font)
self.bg_color = pg.mkColor(hcolor(bg_color)) self.bg_color = pg.mkColor(hcolor(bg_color))
self.fg_color = pg.mkColor(hcolor(fg_color)) self.fg_color = pg.mkColor(hcolor(fg_color))
self.pic = QtGui.QPicture() # self.pic = QtGui.QPicture()
p = QtGui.QPainter(self.pic) # p = QtGui.QPainter(self.pic)
self.rect = None self.rect = None
p.setPen(self.fg_color) # p.setPen(self.fg_color)
p.setOpacity(self.opacity)
self.setFlag(self.ItemIgnoresTransformations) self.setFlag(self.ItemIgnoresTransformations)
def paint(self, p, option, widget): def paint(self, p, option, widget):
p.drawPicture(0, 0, self.pic) # p.drawPicture(0, 0, self.pic)
p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
if self.label_str: if self.label_str:
if not self.rect: if not self.rect:
self._size_br_from_str(self.label_str) self._size_br_from_str(self.label_str)
p.setFont(_font) p.setFont(self._dpifont.font)
p.setPen(self.fg_color) p.setPen(self.fg_color)
p.setOpacity(self.opacity)
p.fillRect(self.rect, self.bg_color) p.fillRect(self.rect, self.bg_color)
# this adds a nice black outline around the label for some odd # this adds a nice black outline around the label for some odd
# reason; ok by us # reason; ok by us
p.drawRect(self.rect) p.drawRect(self.rect)
p.drawText(option.rect, self.text_flags, self.label_str) p.drawText(self.rect, self.text_flags, self.label_str)
def boundingRect(self): # noqa def boundingRect(self): # noqa
# if self.label_str:
# self._size_br_from_str(self.label_str)
# return self.rect
# return QtCore.QRectF()
return self.rect or QtCore.QRectF() return self.rect or QtCore.QRectF()
def _size_br_from_str(self, value: str) -> None: def _size_br_from_str(self, value: str) -> None:
@ -169,9 +187,21 @@ class AxisLabel(pg.GraphicsObject):
around provided string contents. around provided string contents.
""" """
txt_br = self._font._fm.boundingRect(value) # size the filled rect to text and/or parent axis
txt_h, txt_w = txt_br.height(), txt_br.width() br = self._txt_br = self._dpifont.boundingRect(value)
# px_per_char = self._font._fm.averageCharWidth()
# br = br * 1.88
txt_h, txt_w = br.height(), br.width()
print(f'orig: {txt_h}')
# txt_h = (br.topLeft() - br.bottomRight()).y()
# txt_w = len(value) * px_per_char
# txt_w *= 1.88
# txt_h *= 1.88
# print(f'calced: {txt_h}')
h, w = self.size_hint() h, w = self.size_hint()
self.rect = QtCore.QRectF( self.rect = QtCore.QRectF(
0, 0, 0, 0,
(w or txt_w) + self._w_margin, (w or txt_w) + self._w_margin,
@ -190,15 +220,12 @@ class AxisLabel(pg.GraphicsObject):
class XAxisLabel(AxisLabel): class XAxisLabel(AxisLabel):
_w_margin = 8 _w_margin = 0
_h_margin = 0 _h_margin = 0
text_flags = ( text_flags = (
QtCore.Qt.TextDontClip QtCore.Qt.TextDontClip
| QtCore.Qt.AlignCenter | QtCore.Qt.AlignCenter
# | QtCore.Qt.AlignTop
# | QtCore.Qt.AlignVCenter
# | QtCore.Qt.AlignHCenter
) )
def size_hint(self) -> Tuple[float, float]: def size_hint(self) -> Tuple[float, float]:
@ -211,43 +238,50 @@ class XAxisLabel(AxisLabel):
data: float, # data for text data: float, # data for text
offset: int = 1 # if have margins, k? offset: int = 1 # if have margins, k?
) -> None: ) -> None:
timestrs = self.parent._indexes_to_timestrs([int(data)]) timestrs = self.parent._indexes_to_timestrs([int(data)])
if not timestrs.any(): if not timestrs.any():
return return
self.label_str = timestrs[0] self.label_str = timestrs[0]
width = self.boundingRect().width() width = self.boundingRect().width()
new_pos = QPointF(abs_pos.x() - width / 2 - offset, 0) self.setPos(QPointF(
self.setPos(new_pos) abs_pos.x() - width / 2, # - offset,
0
))
class YAxisLabel(AxisLabel): class YAxisLabel(AxisLabel):
text_flags = ( text_flags = (
QtCore.Qt.AlignLeft # QtCore.Qt.AlignLeft
QtCore.Qt.AlignVCenter
| QtCore.Qt.TextDontClip | QtCore.Qt.TextDontClip
| QtCore.Qt.AlignVCenter
) )
def size_hint(self) -> Tuple[float, float]: def size_hint(self) -> Tuple[float, float]:
# size to parent axis width # size to parent axis width
return None, self.parent.width() return None, self.parent.width()
def tick_to_string(self, tick_pos):
# WTF IS THIS FORMAT?
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
def update_label( def update_label(
self, self,
abs_pos: QPointF, # scene coords abs_pos: QPointF, # scene coords
data: float, # data for text data: float, # data for text
offset: int = 1 # if have margins, k? offset: int = 1 # if have margins, k?
) -> None: ) -> None:
self.label_str = self.tick_to_string(data)
height = self.boundingRect().height() # this is read inside ``.paint()``
new_pos = QPointF(0, abs_pos.y() - height / 2 - offset) self.label_str = '{data: ,.{digits}f}'.format(
self.setPos(new_pos) digits=self.digits, data=data).replace(',', ' ')
br = self.boundingRect()
h = br.height()
self.setPos(QPointF(
0,
abs_pos.y() - h / 2 #- offset
))
class YSticky(YAxisLabel): class YSticky(YAxisLabel):
@ -264,29 +298,40 @@ class YSticky(YAxisLabel):
self._chart = chart self._chart = chart
chart.sigRangeChanged.connect(self.update_on_resize) chart.sigRangeChanged.connect(self.update_on_resize)
self._last_datum = (None, None)
def update_on_resize(self, vr, r): def update_on_resize(self, vr, r):
# TODO: add an `.index` to the array data-buffer layer # TODO: add an `.index` to the array data-buffer layer
# and make this way less shitty... # and make this way less shitty...
chart = self._chart index, last = self._last_datum
a = chart._array if index is not None:
fields = a.dtype.fields
if fields and 'close' in fields:
index, last = a[-1][['index', 'close']]
else:
# non-ohlc case
index = len(a) - 1
last = a[chart.name][-1]
self.update_from_data( self.update_from_data(
index, index,
last, last,
) )
# chart = self._chart
# a = chart._array
# fields = a.dtype.fields
# if fields and 'close' in fields:
# index, last = a[-1][['index', 'close']]
# else: # non-ohlc case
# index = len(a) - 1
# last = a[chart.name][-1]
# self.update_from_data(
# index,
# last,
# )
def update_from_data( def update_from_data(
self, self,
index: int, index: int,
value: float, value: float,
) -> None: ) -> None:
self._last_datum = (index, value)
self.update_label( self.update_label(
self._chart.mapFromView(QPointF(index, value)), self._chart.mapFromView(QPointF(index, value)),
value value

View File

@ -2,26 +2,70 @@
Qt UI styling. Qt UI styling.
""" """
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtGui from PyQt5 import QtCore, QtGui
from qdarkstyle.palette import DarkPalette from qdarkstyle.palette import DarkPalette
from ..log import get_logger from ..log import get_logger
log = get_logger(__name__) log = get_logger(__name__)
# def configure_font_to_dpi(screen: QtGui.QScreen):
# """Set an appropriately sized font size depending on the screen DPI.
# If we end up needing to generalize this more here there are resources
# listed in the script in ``snippets/qt_screen_info.py``.
# """
# dpi = screen.physicalDotsPerInch()
# font_size = round(_font_inches_we_like * dpi)
# log.info(
# f"\nscreen:{screen.name()} with DPI: {dpi}"
# f"\nbest font size is {font_size}\n"
# )
# global _font
# _font.setPixelSize(font_size)
# _font._fm = QtGui.QFontMetrics(_font)
# return _font
# chart-wide font # chart-wide font
# font size 6px / 53 dpi (3x scaled down on 4k hidpi) # font size 6px / 53 dpi (3x scaled down on 4k hidpi)
_font_inches_we_like = 6 / 53 _font_inches_we_like = 6 / 53 # px / (px / inch) = inch
# use pixel size to be cross-resolution compatible?
_font = QtGui.QFont("Hack")
_font.setPixelSize(6) # default
# TODO: re-compute font size when main widget switches screens?
# https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3
def configure_font_to_dpi(screen: QtGui.QScreen): class DpiAwareFont:
def __init__(
self,
name: str = 'Hack',
) -> None:
self.name = name
self._qfont = QtGui.QFont(name)
self._iwl = _font_inches_we_like
self._qfm = QtGui.QFontMetrics(self._qfont)
self._font_size = None
self._physical_dpi = None
self._screen = None
def _set_qfont_px_size(self, px_size: int) -> None:
# self._qfont = QtGui.Qfont(self.name)
self._qfont.setPixelSize(px_size)
self._qfm = QtGui.QFontMetrics(self._qfont)
@property
def font(self):
return self._qfont
@property
def px_size(self):
return self._qfont.pixelSize()
# def set_px_size(self, size: int) -> None:
# pass
def configure_to_dpi(self, screen: QtGui.QScreen):
"""Set an appropriately sized font size depending on the screen DPI. """Set an appropriately sized font size depending on the screen DPI.
If we end up needing to generalize this more here there are resources If we end up needing to generalize this more here there are resources
@ -29,18 +73,57 @@ def configure_font_to_dpi(screen: QtGui.QScreen):
""" """
dpi = screen.physicalDotsPerInch() dpi = screen.physicalDotsPerInch()
font_size = round(_font_inches_we_like * dpi) font_size = round(self._iwl * dpi)
log.info( log.info(
f"\nscreen:{screen.name()} with DPI: {dpi}" f"\nscreen:{screen.name()} with DPI: {dpi}"
f"\nbest font size is {font_size}\n" f"\nbest font size is {font_size}\n"
) )
self._set_qfont_px_size(font_size)
self._font_size = font_size
self._physical_dpi = dpi
self._screen = screen
global _font def boundingRect(self, value: str) -> QtCore.QRectF:
_font.setPixelSize(font_size) screen = self._screen
_font._fm = QtGui.QFontMetrics(_font) if screen is None:
raise RuntimeError("You must call .configure_to_dpi() first!")
return _font unscaled_br = self._qfm.boundingRect(value)
# XXX: for wtv absolutely fucked reason, the scaling only applies
# to everything when the current font size **is not** the size
# needed to get the original desired text height... :mindblow:
if self._font_size != 6:
# scalar = self._qfm.fontDpi() / self._physical_dpi
scalar = screen.logicalDotsPerInch() / screen.physicalDotsPerInch()
# scalar = 100 / screen.physicalDotsPerInch()
# assert 0
print(f'SCALAR {scalar}')
return QtCore.QRectF(
# unscaled_br.x(),
# unscaled_br.y(),
0,
0,
unscaled_br.width() * scalar,
unscaled_br.height() * scalar,
)
else:
return QtCore.QRectF(
# unscaled_br.x(),
# unscaled_br.y(),
0,
0,
unscaled_br.width(),
unscaled_br.height(),
)
# use pixel size to be cross-resolution compatible?
_font = DpiAwareFont()
# TODO: re-compute font size when main widget switches screens?
# https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3
# _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1]) # _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
@ -69,6 +152,7 @@ def hcolor(name: str) -> str:
"""Hex color codes by hipster speak. """Hex color codes by hipster speak.
""" """
return { return {
# lives matter # lives matter
'black': '#000000', 'black': '#000000',
'erie_black': '#1B1B1B', 'erie_black': '#1B1B1B',
@ -99,6 +183,9 @@ def hcolor(name: str) -> str:
'vwap_blue': '#0582fb', 'vwap_blue': '#0582fb',
'dodger_blue': '#1e90ff', # like the team? 'dodger_blue': '#1e90ff', # like the team?
'panasonic_blue': '#0040be', # from japan 'panasonic_blue': '#0040be', # from japan
# 'bid_blue': '#0077ea', # like the L1
'bid_blue': '#3094d9', # like the L1
'aquaman': '#39abd0',
# traditional # traditional
'tina_green': '#00cc00', 'tina_green': '#00cc00',