From 9e7aa3f9bfbc2f102cf29a44604e7dca367222be Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 2 Nov 2020 12:02:05 -0500 Subject: [PATCH] Add a dpi aware font wrapper type --- piker/ui/_axes.py | 143 +++++++++++++++++++++++++++++---------------- piker/ui/_style.py | 139 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 207 insertions(+), 75 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 6be1d154..b4b910ce 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -1,21 +1,23 @@ """ Chart axes graphics and behavior. """ -from typing import List, Tuple +from typing import List, Tuple, Optional import pandas as pd import pyqtgraph as pg from PyQt5 import QtCore, QtGui from PyQt5.QtCore import QPointF -from ._style import _font, hcolor +from ._style import DpiAwareFont, hcolor, _font from ..data._source import float_digits _axis_pen = pg.mkPen(hcolor('bracket')) class Axis(pg.AxisItem): + """A better axis that sizes to typical tick contents considering font size. + """ def __init__( self, linked_charts, @@ -27,10 +29,10 @@ class Axis(pg.AxisItem): super().__init__(**kwargs) - self.setTickFont(_font) + self.setTickFont(_font.font) self.setStyle(**{ 'textFillLimits': [(0, 0.666)], - 'tickFont': _font, + 'tickFont': _font.font, # 'tickTextWidth': 100, # 'tickTextHeight': 20, # 'tickTextWidth': 40, @@ -42,9 +44,9 @@ class Axis(pg.AxisItem): }) # self.setLabel(**{'font-size': '10pt'}) - self.setTickFont(_font) + self.setTickFont(_font.font) 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" self.resize() @@ -92,17 +94,18 @@ class DynamicDateAxis(Axis): self, indexes: List[int], ) -> List[str]: + bars = self.linked_charts.chart._array - times = bars['time'] bars_len = len(bars) - # delay = times[-1] - times[times != times[-1]][-1] - delay = times[-1] - times[-2] + times = bars['time'] epochs = times[list( map(int, filter(lambda i: i < bars_len, indexes)) )] # TODO: **don't** have this hard coded shift to EST dts = pd.to_datetime(epochs, unit='s') # - 4*pd.offsets.Hour() + + delay = times[-1] - times[-2] return dts.strftime(self.tick_tpl[delay]) def tickStrings(self, values: List[float], scale, spacing): @@ -111,9 +114,8 @@ class DynamicDateAxis(Axis): class AxisLabel(pg.GraphicsObject): - _font = _font _w_margin = 0 - _h_margin = 3 + _h_margin = 0 def __init__( self, @@ -122,46 +124,62 @@ class AxisLabel(pg.GraphicsObject): bg_color: str = 'bracket', fg_color: str = 'black', opacity: int = 1, + font_size: Optional[int] = None, ): super().__init__(parent) self.parent = parent self.opacity = opacity self.label_str = '' self.digits = digits + 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.fg_color = pg.mkColor(hcolor(fg_color)) - self.pic = QtGui.QPicture() - p = QtGui.QPainter(self.pic) + # self.pic = QtGui.QPicture() + # p = QtGui.QPainter(self.pic) self.rect = None - p.setPen(self.fg_color) - p.setOpacity(self.opacity) + # p.setPen(self.fg_color) self.setFlag(self.ItemIgnoresTransformations) 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 not self.rect: self._size_br_from_str(self.label_str) - p.setFont(_font) + p.setFont(self._dpifont.font) p.setPen(self.fg_color) + p.setOpacity(self.opacity) p.fillRect(self.rect, self.bg_color) # this adds a nice black outline around the label for some odd # reason; ok by us 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 + # if self.label_str: + # self._size_br_from_str(self.label_str) + # return self.rect + + # return QtCore.QRectF() + return self.rect or QtCore.QRectF() def _size_br_from_str(self, value: str) -> None: @@ -169,9 +187,21 @@ class AxisLabel(pg.GraphicsObject): around provided string contents. """ - txt_br = self._font._fm.boundingRect(value) - txt_h, txt_w = txt_br.height(), txt_br.width() + # size the filled rect to text and/or parent axis + 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() + self.rect = QtCore.QRectF( 0, 0, (w or txt_w) + self._w_margin, @@ -190,15 +220,12 @@ class AxisLabel(pg.GraphicsObject): class XAxisLabel(AxisLabel): - _w_margin = 8 + _w_margin = 0 _h_margin = 0 text_flags = ( QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter - # | QtCore.Qt.AlignTop - # | QtCore.Qt.AlignVCenter - # | QtCore.Qt.AlignHCenter ) def size_hint(self) -> Tuple[float, float]: @@ -211,43 +238,50 @@ class XAxisLabel(AxisLabel): data: float, # data for text offset: int = 1 # if have margins, k? ) -> None: + timestrs = self.parent._indexes_to_timestrs([int(data)]) if not timestrs.any(): return self.label_str = timestrs[0] + width = self.boundingRect().width() - new_pos = QPointF(abs_pos.x() - width / 2 - offset, 0) - self.setPos(new_pos) + self.setPos(QPointF( + abs_pos.x() - width / 2, # - offset, + 0 + )) class YAxisLabel(AxisLabel): text_flags = ( - QtCore.Qt.AlignLeft + # QtCore.Qt.AlignLeft + QtCore.Qt.AlignVCenter | QtCore.Qt.TextDontClip - | QtCore.Qt.AlignVCenter ) def size_hint(self) -> Tuple[float, float]: # size to parent axis 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( self, abs_pos: QPointF, # scene coords data: float, # data for text offset: int = 1 # if have margins, k? ) -> None: - self.label_str = self.tick_to_string(data) - height = self.boundingRect().height() - new_pos = QPointF(0, abs_pos.y() - height / 2 - offset) - self.setPos(new_pos) + + # this is read inside ``.paint()`` + self.label_str = '{data: ,.{digits}f}'.format( + 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): @@ -264,29 +298,40 @@ class YSticky(YAxisLabel): self._chart = chart chart.sigRangeChanged.connect(self.update_on_resize) + self._last_datum = (None, None) def update_on_resize(self, vr, r): # TODO: add an `.index` to the array data-buffer layer # and make this way less shitty... - 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, - ) + index, last = self._last_datum + if index is not None: + self.update_from_data( + index, + 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( self, index: int, value: float, ) -> None: + self._last_datum = (index, value) self.update_label( self._chart.mapFromView(QPointF(index, value)), value diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 24a4eac1..8f1e9425 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -2,46 +2,129 @@ Qt UI styling. """ import pyqtgraph as pg -from PyQt5 import QtGui +from PyQt5 import QtCore, QtGui from qdarkstyle.palette import DarkPalette from ..log import get_logger 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 # 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 + + +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. + + 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(self._iwl * dpi) + log.info( + f"\nscreen:{screen.name()} with DPI: {dpi}" + 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 + + def boundingRect(self, value: str) -> QtCore.QRectF: + screen = self._screen + if screen is None: + raise RuntimeError("You must call .configure_to_dpi() first!") + + 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 = QtGui.QFont("Hack") -_font.setPixelSize(6) # default +_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 - -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 - - # _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1]) # splitter widget config @@ -69,6 +152,7 @@ def hcolor(name: str) -> str: """Hex color codes by hipster speak. """ return { + # lives matter 'black': '#000000', 'erie_black': '#1B1B1B', @@ -99,6 +183,9 @@ def hcolor(name: str) -> str: 'vwap_blue': '#0582fb', 'dodger_blue': '#1e90ff', # like the team? 'panasonic_blue': '#0040be', # from japan + # 'bid_blue': '#0077ea', # like the L1 + 'bid_blue': '#3094d9', # like the L1 + 'aquaman': '#39abd0', # traditional 'tina_green': '#00cc00',