Compare commits

..

12 Commits

Author SHA1 Message Date
Gud Boi c6125dc4ad Exclude crypto futes from `without_src` sym key
Extend the `col_sym_key` asset-type check in `start_backfill()`
to also exclude crypto-denominated futures (where `src` is
`'crypto_currency'` and `dst` is `'future'`) from the
`without_src=True` fqme path.

Also in `.brokers.binance` backend (it being the guilty culprit in the
discovery of this bug; and why i touched styling this code),

- reformat `make_sub()` fn sig to multiline style in
  `.binance.feed`.
- add backtick around `dict` in `make_sub()` docstring.
- reformat `or` conditionals to multiline style in
  `.binance.feed.get_mkt_info()`.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-11 17:05:41 -04:00
Gud Boi e7c92cda8b Enable console log (from passed down `loglevel`) in `.tsp._history.manage_history()` 2026-03-11 17:05:41 -04:00
Gud Boi 4ea2abf832 Drop `Flume.feed`, it's unused yet causes import cycles.. 2026-03-11 17:05:41 -04:00
Gud Boi ecb7a1f012 Just warn on single-bar nulls instead of bping
Replace the debug breakpoint with a warning-log when a single-bar
null-segment is detected in `get_null_segs()`. This lets the gap
analysis continue while still alerting about the anomaly.

Deats,
- extract the 3-bar window (before, null, after) and calculate
  a `gap: pendulum.Interval` for the warning msg.
- comment-out the old breakpoint block for optional debugging as needed.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-11 17:05:41 -04:00
Gud Boi 2ce04ae22f Lul, drop long unused poetry lock file 2026-03-11 17:05:41 -04:00
Gud Boi f5a08f94c7 Pin `pg` at latest official `0.14.0` release
Keep in masked GH sources lines for easy hackin against upstream
`master` branch when needed as well!
2026-03-11 17:05:41 -04:00
Gud Boi ade6d7856c .ui._editors: log multiline styling and re-leveling 2026-03-11 17:05:41 -04:00
Gud Boi e4c48ba33b .ui._lines: drop unused graphics-item import 2026-03-11 17:05:41 -04:00
Gud Boi 1cada49a2c Add batch-submit API for gap annotations
Introduce `AnnotCtl.add_batch()` and `serve_rc_annots()` batch
handler to submit 1000s of gaps in single IPC msg instead of
per-annot round-trips. Server builds `GapAnnotations` from specs
and handles vectorized timestamp-to-index lookups.

Deats,
- add `'cmd': 'batch'` handler in `serve_rc_annots()`
- vectorized timestamp lookup via `np.searchsorted()` + masking
- build `gap_specs: list[dict]` from rect+arrow specs client-side
- create single `GapAnnotations` item for all gaps server-side
- handle `GapAnnotations.reposition()` in redraw handler
- add profiling to batch path for perf measurement
- support optional individual arrows for A/B comparison

Also,
- refactor `markup_gaps()` to collect specs + single batch call
- add `no_qt_updates()` context mgr for batch render ops
- add profiling to annotation teardown path
- add `GapAnnotations` case to `rm_annot()` match block

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-11 17:05:41 -04:00
Gud Boi bec21b957f Add a `GapAnnotations` path-renderer
For a ~1000x perf gain says ol' claudy, our boi who wrote this entire
patch! Bo

Introduce `GapAnnotations` in `.ui._annotate` for batch-rendering gap
rects/arrows instead of individual `QGraphicsItem` instances. Uses
upstream's `pyqtgraph.Qt.internals.PrimitiveArray` for rects and
a `QPainterPath` for arrows. This API-replicates our prior annotator's
in view shape-graphics but now using (what we're dubbing)
"single-array-multiple-graphics" tech much like our `.ui._curve`
extensions to `pg` B)

Impl deats,
- batch draw ~1000 gaps in single paint call vs 1000 items
- arrows render in scene coords to maintain pixel size on zoom
- add vectorized timestamp-to-index lookup for repositioning
- cache bounding rect, rebuild on `reposition()` calls
- match `SelectRect` + `ArrowItem` visual style/colors
- skip reposition when timeframe doesn't match gap's period

Other,
- fix typo in `LevelMarker` docstring: "graphich" -> "graphic"
- reflow docstring in `qgo_draw_markers()` to 67 char limit

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-11 17:05:41 -04:00
Gud Boi 2051511878 Add info log for shm processing in `ldshm` CLI cmd
Log shm file name and detected period before null segment
processing to aid debugging.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-11 17:05:41 -04:00
Gud Boi c751f5475a Bump to latest official `pyqtgraph` release 2026-03-11 17:05:41 -04:00
16 changed files with 42 additions and 690 deletions

View File

@ -586,7 +586,7 @@ async def open_price_feed(
fh, fh,
instrument instrument
) )
) as (chan, first): ) as (first, chan):
yield chan yield chan
@ -653,7 +653,7 @@ async def open_order_feed(
fh, fh,
instrument instrument
) )
) as (chan, first): ) as (first, chan):
yield chan yield chan

View File

@ -1529,7 +1529,7 @@ async def open_client_proxies() -> tuple[
# TODO: maybe this should be the default in tractor? # TODO: maybe this should be the default in tractor?
key=tractor.current_actor().uid, key=tractor.current_actor().uid,
) as (cache_hit, (_, clients)), ) as (cache_hit, (clients, _)),
AsyncExitStack() as stack AsyncExitStack() as stack
): ):
@ -1718,7 +1718,7 @@ async def open_client_proxy(
open_aio_client_method_relay, open_aio_client_method_relay,
client=client, client=client,
event_consumers=event_table, event_consumers=event_table,
) as (chan, first), ) as (first, chan),
trionics.collapse_eg(), # loose-ify trionics.collapse_eg(), # loose-ify
trio.open_nursery() as relay_tn, trio.open_nursery() as relay_tn,

View File

@ -514,8 +514,8 @@ async def open_trade_event_stream(
recv_trade_updates, recv_trade_updates,
client=client, client=client,
) as ( ) as (
trade_event_stream,
_, # first pushed val _, # first pushed val
trade_event_stream,
): ):
task_status.started(trade_event_stream) task_status.started(trade_event_stream)
# block forever to keep session trio-asyncio session # block forever to keep session trio-asyncio session

View File

@ -989,7 +989,7 @@ async def open_aio_quote_stream(
symbol=symbol, symbol=symbol,
contract=contract, contract=contract,
) as (from_aio, contract): ) as (contract, from_aio):
assert contract assert contract

View File

@ -75,9 +75,6 @@ class Axis(pg.AxisItem):
self.pi = plotitem self.pi = plotitem
self._dpi_font = _font self._dpi_font = _font
# store for later recalculation on zoom
self._typical_max_str = typical_max_str
self.setTickFont(_font.font) self.setTickFont(_font.font)
font_size = self._dpi_font.font.pixelSize() font_size = self._dpi_font.font.pixelSize()
@ -159,41 +156,6 @@ class Axis(pg.AxisItem):
def size_to_values(self) -> None: def size_to_values(self) -> None:
pass pass
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate axis sizing after zoom change.'''
# IMPORTANT: tell Qt we're about to change geometry
self.prepareGeometryChange()
self._dpi_font = font
self.setTickFont(font.font)
font_size = font.font.pixelSize()
# recalculate text offset based on new font size
text_offset = None
if self.orientation in ('bottom',):
text_offset = floor(0.25 * font_size)
elif self.orientation in ('left', 'right'):
text_offset = floor(font_size / 2)
if text_offset:
self.setStyle(tickTextOffset=text_offset)
# recalculate bounding rect with new font
# Note: typical_max_str should be stored from init
if not hasattr(self, '_typical_max_str'):
self._typical_max_str = '100 000.000 ' # fallback default
self.typical_br = font._qfm.boundingRect(self._typical_max_str)
# Update PyQtGraph's internal text size tracking
# This is critical - PyQtGraph uses these internally for auto-expand
if self.orientation in ['left', 'right']:
self.textWidth = self.typical_br.width()
else:
self.textHeight = self.typical_br.height()
# resize axis to fit new font - this triggers PyQtGraph's auto-expand
self.size_to_values()
def txt_offsets(self) -> tuple[int, int]: def txt_offsets(self) -> tuple[int, int]:
return tuple(self.style['tickTextOffset']) return tuple(self.style['tickTextOffset'])
@ -294,14 +256,7 @@ class PriceAxis(Axis):
self._min_tick = size self._min_tick = size
def size_to_values(self) -> None: def size_to_values(self) -> None:
# Call PyQtGraph's internal width update mechanism self.setWidth(self.typical_br.width())
# This respects autoExpandTextSpace and updates min/max constraints
self._updateWidth()
# tell Qt our preferred size changed so layout recalculates
self.updateGeometry()
# force parent plot item to recalculate its layout
if self.pi and hasattr(self.pi, 'updateGeometry'):
self.pi.updateGeometry()
# XXX: drop for now since it just eats up h space # XXX: drop for now since it just eats up h space
@ -345,14 +300,7 @@ class DynamicDateAxis(Axis):
} }
def size_to_values(self) -> None: def size_to_values(self) -> None:
# Call PyQtGraph's internal height update mechanism self.setHeight(self.typical_br.height() + 1)
# This respects autoExpandTextSpace and updates min/max constraints
self._updateHeight()
# tell Qt our preferred size changed so layout recalculates
self.updateGeometry()
# force parent plot item to recalculate its layout
if self.pi and hasattr(self.pi, 'updateGeometry'):
self.pi.updateGeometry()
def _indexes_to_timestrs( def _indexes_to_timestrs(
self, self,

View File

@ -203,9 +203,6 @@ def run_qtractor(
if is_windows: if is_windows:
window.configure_to_desktop() window.configure_to_desktop()
# install global keyboard shortcuts for UI zoom
window.install_global_zoom_filter()
# actually render to screen # actually render to screen
window.show() window.show()
app.exec_() app.exec_()

View File

@ -124,13 +124,6 @@ class Edit(QLineEdit):
self.sizeHint() self.sizeHint()
self.update() self.update()
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.dpi_font = font
self.setFont(font.font)
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def focus(self) -> None: def focus(self) -> None:
self.selectAll() self.selectAll()
self.show() self.show()
@ -248,14 +241,6 @@ class Selection(QComboBox):
icon_size = round(h * 0.75) icon_size = round(h * 0.75)
self.setIconSize(QSize(icon_size, icon_size)) self.setIconSize(QSize(icon_size, icon_size))
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.setFont(font.font)
# recalculate heights with new font
self.resize()
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def set_items( def set_items(
self, self,
keys: list[str], keys: list[str],
@ -446,39 +431,6 @@ class FieldsForm(QWidget):
self.fields[key] = select self.fields[key] = select
return select return select
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font, _font_small
# update stored font size
self._font_size = _font_small.px_size - 2
# update all labels
for name, label in self.labels.items():
if hasattr(label, 'update_font'):
label.update_font(_font.font, self._font_size - 1)
# update all fields (edits, selects)
for key, field in self.fields.items():
# first check for our custom update_fonts method (Edit, Selection)
if hasattr(field, 'update_fonts'):
field.update_fonts(_font)
# then handle stylesheet updates for those without custom methods
elif hasattr(field, 'setStyleSheet'):
# regenerate stylesheet with new font size
field.setStyleSheet(
f"""QLineEdit {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
field.setFont(_font.font)
# for Selection widgets that need style updates
if hasattr(field, 'set_style'):
field.set_style(color='gunmetal', font_size=self._font_size)
async def handle_field_input( async def handle_field_input(
@ -681,37 +633,6 @@ class FillStatusBar(QProgressBar):
self.setRange(0, int(slots)) self.setRange(0, int(slots))
self.setValue(value) self.setValue(value)
def update_fonts(self, font_size: int) -> None:
'''Update font size after zoom change.'''
from ._style import _font_small
self.font_size = font_size
# regenerate stylesheet with new font size
self.setStyleSheet(
f"""
QProgressBar {{
text-align: center;
font-size : {self.font_size - 2}px;
background-color: {hcolor('papas_special')};
color : {hcolor('papas_special')};
border: {self.border_px}px solid {hcolor('default_light')};
border-radius: 2px;
}}
QProgressBar::chunk {{
background-color: {hcolor('default_spotlight')};
color: {hcolor('bracket')};
border-radius: 2px;
}}
"""
)
self.setFont(_font_small.font)
def mk_fill_status_bar( def mk_fill_status_bar(

View File

@ -334,19 +334,3 @@ class FormatLabel(QLabel):
out = self.fmt_str.format(**fields) out = self.fmt_str.format(**fields)
self.setText(out) self.setText(out)
return out return out
def update_font(
self,
font: QtGui.QFont,
font_size: int,
font_color: str = 'default_lightest',
) -> None:
'''Update font after zoom change.'''
self.setStyleSheet(
f"""QLabel {{
color : {hcolor(font_color)};
font-size : {font_size}px;
}}
"""
)
self.setFont(font)

View File

@ -178,26 +178,6 @@ class SettingsPane:
# encompasing high level namespace # encompasing high level namespace
order_mode: OrderMode | None = None # typing: ignore # noqa order_mode: OrderMode | None = None # typing: ignore # noqa
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font_small
# update form fields
if self.form and hasattr(self.form, 'update_fonts'):
self.form.update_fonts()
# update fill status bar
if self.fill_bar and hasattr(self.fill_bar, 'update_fonts'):
self.fill_bar.update_fonts(_font_small.px_size)
# update labels with new fonts
if self.step_label:
self.step_label.setFont(_font_small.font)
if self.pnl_label:
self.pnl_label.setFont(_font_small.font)
if self.limit_label:
self.limit_label.setFont(_font_small.font)
def set_accounts( def set_accounts(
self, self,
names: list[str], names: list[str],

View File

@ -174,13 +174,6 @@ class CompleterView(QTreeView):
self.setStyleSheet(f"font: {size}px") self.setStyleSheet(f"font: {size}px")
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
self.set_font_size(_font.px_size)
self.setIndentation(_font.px_size)
self.setFont(_font.font)
self.updateGeometry()
def resize_to_results( def resize_to_results(
self, self,
w: float | None = 0, w: float | None = 0,
@ -637,29 +630,6 @@ class SearchWidget(QtWidgets.QWidget):
| align_flag.AlignLeft, | align_flag.AlignLeft,
) )
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
# regenerate label stylesheet with new font size
self.label.setStyleSheet(
f"""QLabel {{
color : {hcolor('default_lightest')};
font-size : {_font.px_size - 2}px;
}}
"""
)
self.label.setFont(_font.font)
# update search bar and view fonts
if hasattr(self.bar, 'update_fonts'):
self.bar.update_fonts(_font)
elif hasattr(self.bar, 'setFont'):
self.bar.setFont(_font.font)
if hasattr(self.view, 'update_fonts'):
self.view.update_fonts()
self.updateGeometry()
def focus(self) -> None: def focus(self) -> None:
self.show() self.show()
self.bar.focus() self.bar.focus()

View File

@ -79,13 +79,9 @@ class DpiAwareFont:
self._font_inches: float = None self._font_inches: float = None
self._screen = None self._screen = None
def _set_qfont_px_size( def _set_qfont_px_size(self, px_size: int) -> None:
self, self._qfont.setPixelSize(px_size)
px_size: int,
) -> int:
self._qfont.setPixelSize(int(px_size))
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
return self.px_size
@property @property
def screen(self) -> QtGui.QScreen: def screen(self) -> QtGui.QScreen:
@ -128,22 +124,17 @@ class DpiAwareFont:
return size return size
def configure_to_dpi( def configure_to_dpi(self, screen: QtGui.QScreen | None = None):
self,
screen: QtGui.QScreen | None = None,
zoom_level: float = 1.0,
) -> int:
''' '''
Set an appropriately sized font size depending on the screen DPI Set an appropriately sized font size depending on the screen DPI.
or scale the size according to `zoom_level`.
If we end up needing to generalize this more here there are If we end up needing to generalize this more here there are resources
resources listed in the script in listed in the script in ``snippets/qt_screen_info.py``.
``snippets/qt_screen_info.py``.
''' '''
if self._font_size is not None: if self._font_size is not None:
return self._set_qfont_px_size(self._font_size * zoom_level) self._set_qfont_px_size(self._font_size)
return
# NOTE: if no font size set either in the [ui] section of the # NOTE: if no font size set either in the [ui] section of the
# config or not yet computed from our magic scaling calcs, # config or not yet computed from our magic scaling calcs,
@ -162,7 +153,7 @@ class DpiAwareFont:
ldpi = pdpi ldpi = pdpi
mx_dpi = max(pdpi, ldpi) mx_dpi = max(pdpi, ldpi)
# mn_dpi = min(pdpi, ldpi) mn_dpi = min(pdpi, ldpi)
scale = round(ldpi/pdpi, ndigits=2) scale = round(ldpi/pdpi, ndigits=2)
if mx_dpi <= 97: # for low dpi use larger font sizes if mx_dpi <= 97: # for low dpi use larger font sizes
@ -171,7 +162,7 @@ class DpiAwareFont:
else: # hidpi use smaller font sizes else: # hidpi use smaller font sizes
inches = _font_sizes['hi'][self._font_size_calc_key] inches = _font_sizes['hi'][self._font_size_calc_key]
# dpi = mn_dpi dpi = mn_dpi
mult = 1.0 mult = 1.0
@ -206,25 +197,24 @@ class DpiAwareFont:
# always going to hit that error in range mapping from inches: # always going to hit that error in range mapping from inches:
# float to px size: int. # float to px size: int.
self._font_inches = inches self._font_inches = inches
font_size = math.floor(inches * pdpi) font_size = math.floor(inches * dpi)
# apply zoom level multiplier
font_size = int(font_size * zoom_level)
log.debug( log.debug(
f"screen:{screen.name()}\n" f"screen:{screen.name()}\n"
f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n" f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n"
f"zoom_level: {zoom_level}\n"
f"\nOur best guess font size is {font_size}\n" f"\nOur best guess font size is {font_size}\n"
) )
# apply the size # apply the size
return self._set_qfont_px_size(font_size) self._set_qfont_px_size(font_size)
def boundingRect(self, value: str) -> QtCore.QRectF: def boundingRect(self, value: str) -> QtCore.QRectF:
if self.screen is None:
screen = self.screen
if screen is None:
raise RuntimeError("You must call .configure_to_dpi() first!") raise RuntimeError("You must call .configure_to_dpi() first!")
unscaled_br: QtCore.QRectF = self._qfm.boundingRect(value) unscaled_br = self._qfm.boundingRect(value)
return QtCore.QRectF( return QtCore.QRectF(
0, 0,
0, 0,
@ -238,22 +228,12 @@ _font = DpiAwareFont()
_font_small = DpiAwareFont(_font_size_key='small') _font_small = DpiAwareFont(_font_size_key='small')
def _config_fonts_to_screen( def _config_fonts_to_screen() -> None:
zoom_level: float = 1.0 'configure global DPI aware font sizes'
) -> int:
'''
Configure global DPI aware font size(s).
If `zoom_level` is provided we apply it to auto-calculated
DPI-aware font.
Return the new `DpiAwareFont.px_size`.
'''
global _font, _font_small global _font, _font_small
_font.configure_to_dpi(zoom_level=zoom_level) _font.configure_to_dpi()
_font_small.configure_to_dpi(zoom_level=zoom_level) _font_small.configure_to_dpi()
return _font.px_size
def get_fonts() -> tuple[ def get_fonts() -> tuple[

View File

@ -18,7 +18,6 @@
Qt main window singletons and stuff. Qt main window singletons and stuff.
""" """
from __future__ import annotations
import os import os
import signal import signal
import time import time
@ -39,107 +38,15 @@ from piker.ui.qt import (
QScreen, QScreen,
QCloseEvent, QCloseEvent,
QSettings, QSettings,
QEvent,
QObject,
) )
from ..log import get_logger from ..log import get_logger
from . import _style from ._style import _font_small, hcolor
from ._style import (
_font_small,
hcolor,
)
from ._widget import GodWidget from ._widget import GodWidget
log = get_logger(__name__) log = get_logger(__name__)
class GlobalZoomEventFilter(QObject):
'''
Application-level event filter for global UI zoom shortcuts.
This filter intercepts keyboard events BEFORE they reach widgets,
allowing us to implement global UI zoom shortcuts that take precedence
over widget-specific shortcuts.
Shortcuts:
- Ctrl+Shift+Plus/Equal: Zoom in
- Ctrl+Shift+Minus: Zoom out
- Ctrl+Shift+0: Reset zoom
'''
def __init__(self, main_window: MainWindow):
super().__init__()
self.main_window = main_window
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
'''
Filter keyboard events for global zoom shortcuts.
Returns True to filter out (consume) the event, False to pass through.
'''
if event.type() == QEvent.Type.KeyPress:
key = event.key()
mods = event.modifiers()
# Mask out the KeypadModifier which Qt sometimes adds
mods = mods & ~Qt.KeyboardModifier.KeypadModifier
# Check if we have Ctrl+Shift (both required)
has_ctrl = bool(
mods
&
Qt.KeyboardModifier.ControlModifier
)
_has_shift = bool(
mods
&
Qt.KeyboardModifier.ShiftModifier
)
# Only handle UI zoom if BOTH Ctrl and Shift are pressed
# For Plus key: user presses Cmd+Shift+Equal (which makes Plus)
# For Minus key: user presses Cmd+Shift+Minus
if (
has_ctrl
# and
# has_shift
):
# Zoom in: Ctrl+Shift+Plus
# Note: Plus key usually comes as Key_Equal with Shift modifier
if key in (
Qt.Key.Key_Plus,
Qt.Key.Key_Equal,
):
self.main_window.zoom_in()
return True # consume event
# Zoom out: Ctrl+Shift+Minus
# Note: On some keyboards Shift+Minus produces '_' (Underscore)
elif key in (
Qt.Key.Key_Minus,
Qt.Key.Key_Underscore,
):
self.main_window.zoom_out()
return True # consume event
# Reset zoom: Ctrl+Shift+0
# Note: On some keyboards Shift+0 produces ')' (ParenRight)
elif key in (
Qt.Key.Key_0,
Qt.Key.Key_ParenRight,
):
self.main_window.reset_zoom()
return True # consume event
# Pass through if only Ctrl (no Shift) - this goes to chart zoom
# Pass through all other events too
return False
return False
class MultiStatus: class MultiStatus:
bar: QStatusBar bar: QStatusBar
@ -282,24 +189,6 @@ class MainWindow(QMainWindow):
self.restoreGeometry(geometry) self.restoreGeometry(geometry)
log.debug('Restored window geometry from previous session') log.debug('Restored window geometry from previous session')
# zoom level for UI scaling (1.0 = 100%, 1.5 = 150%, etc)
# Change this value to set the default startup zoom level
self._zoom_level: float = 1.0 # Start at 100% (normal)
self._min_zoom: float = 0.5
self._max_zoom: float = 3.0 # Reduced from 10.0 to prevent extreme cropping
self._zoom_step: float = 0.2 # 20% per keypress
# event filter for global zoom shortcuts
self._zoom_filter: GlobalZoomEventFilter | None = None
def install_global_zoom_filter(self) -> None:
'''Install application-level event filter for global UI zoom shortcuts.'''
if self._zoom_filter is None:
self._zoom_filter = GlobalZoomEventFilter(self)
app = QApplication.instance()
app.installEventFilter(self._zoom_filter)
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
@property @property
def mode_label(self) -> QLabel: def mode_label(self) -> QLabel:
@ -468,201 +357,6 @@ class MainWindow(QMainWindow):
self.godwidget.on_win_resize(event) self.godwidget.on_win_resize(event)
event.accept() event.accept()
def zoom_in(self) -> None:
'''
Increase overall UI-widgets zoom level by scaling it the
global font sizes.
'''
new_zoom: float = min(
self._zoom_level + self._zoom_step,
self._max_zoom,
)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
font_size: int = self._apply_zoom()
log.info(
f'Zoomed in UI\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
def zoom_out(self) -> float:
'''
Decrease UI zoom level.
'''
new_zoom: float = max(self._zoom_level - self._zoom_step, self._min_zoom)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
font_size: int = self._apply_zoom()
log.info(
f'Zoomed out UI\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
return new_zoom
def reset_zoom(self) -> None:
'''
Reset UI zoom to 100%.
'''
if self._zoom_level != 1.0:
self._zoom_level = 1.0
font_size: int = self._apply_zoom()
log.info(
f'Reset zoom level\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
return self._zoom_level
def _apply_zoom(self) -> int:
'''
Apply current zoom level to all UI elements.
'''
# reconfigure fonts with zoom multiplier
font_size: int = _style._config_fonts_to_screen(
zoom_level=self._zoom_level
)
# update status bar styling with new font size
if self._status_bar:
sb = self.statusBar()
sb.setStyleSheet((
f"color : {hcolor('gunmetal')};"
f"background : {hcolor('default_dark')};"
f"font-size : {_style._font_small.px_size}px;"
"padding : 0px;"
))
# force update of mode label if it exists
if self._status_label:
self._status_label.setFont(_style._font_small.font)
# update godwidget and its children
if self.godwidget:
# update search widget if it exists
if hasattr(self.godwidget, 'search') and self.godwidget.search:
self.godwidget.search.update_fonts()
# update order mode panes in all chart views
self._update_chart_order_panes()
# recursively update all other widgets with stylesheets
self._refresh_widget_fonts(self.godwidget)
self.godwidget.update()
return font_size
def _update_chart_order_panes(self) -> None:
'''
Update order entry panels in all charts.
'''
if not self.godwidget:
return
# iterate through all linked splits (hist and rt)
for splits_name in ['hist_linked', 'rt_linked']:
splits = getattr(self.godwidget, splits_name, None)
if not splits:
continue
# get main chart
chart = getattr(splits, 'chart', None)
if chart:
# update axes
self._update_chart_axes(chart)
# update order pane
if hasattr(chart, 'view'):
view = chart.view
if hasattr(view, 'order_mode') and view.order_mode:
order_mode = view.order_mode
if hasattr(order_mode, 'pane') and order_mode.pane:
order_mode.pane.update_fonts()
# also check subplots
subplots = getattr(splits, 'subplots', {})
for name, subplot_chart in subplots.items():
# update subplot axes
self._update_chart_axes(subplot_chart)
# update subplot order pane
if hasattr(subplot_chart, 'view'):
subplot_view = subplot_chart.view
if hasattr(subplot_view, 'order_mode') and subplot_view.order_mode:
subplot_order_mode = subplot_view.order_mode
if hasattr(subplot_order_mode, 'pane') and subplot_order_mode.pane:
subplot_order_mode.pane.update_fonts()
# resize all sidepanes to match main chart's sidepane width
# this ensures volume/subplot sidepanes match the main chart
if splits and hasattr(splits, 'resize_sidepanes'):
splits.resize_sidepanes()
def _update_chart_axes(self, chart) -> None:
'''Update axis fonts and sizing for a chart.'''
from . import _style
# update price axis (right side)
if hasattr(chart, 'pi') and chart.pi:
plot_item = chart.pi
# get all axes from plot item
for axis_name in ['left', 'right', 'bottom', 'top']:
axis = plot_item.getAxis(axis_name)
if axis and hasattr(axis, 'update_fonts'):
axis.update_fonts(_style._font)
# force plot item to recalculate its entire layout
plot_item.updateGeometry()
# force chart widget to update
if hasattr(chart, 'updateGeometry'):
chart.updateGeometry()
# trigger a full scene update
if hasattr(chart, 'update'):
chart.update()
def _refresh_widget_fonts(self, widget: QWidget) -> None:
'''
Recursively update font sizes in all child widgets.
This handles widgets that have font-size hardcoded in their stylesheets.
'''
from . import _style
# recursively process all children
for child in widget.findChildren(QWidget):
# skip widgets that have their own update_fonts method (handled separately)
if hasattr(child, 'update_fonts'):
continue
# update child's stylesheet if it has font-size
child_stylesheet = child.styleSheet()
if child_stylesheet and 'font-size' in child_stylesheet:
# for labels and simple widgets, regenerate stylesheet
# this is a heuristic - may need refinement
try:
child.setFont(_style._font.font)
except (AttributeError, RuntimeError):
pass
# update child's font
try:
child.setFont(_style._font.font)
except (AttributeError, RuntimeError):
pass
# singleton app per actor # singleton app per actor
_qt_win: QMainWindow = None _qt_win: QMainWindow = None

View File

@ -42,7 +42,6 @@ from PyQt6.QtCore import (
QSize, QSize,
QModelIndex, QModelIndex,
QItemSelectionModel, QItemSelectionModel,
QObject,
pyqtBoundSignal, pyqtBoundSignal,
pyqtRemoveInputHook, pyqtRemoveInputHook,
QSettings, QSettings,

View File

@ -1,64 +0,0 @@
#!env xonsh
'''
Compute the pxs-per-inch (PPI) naively for the local DE.
NOTE, currently this only supports the `sway`-TWM on wayland.
!TODO!
- [ ] support Xorg (and possibly other OSs as well?
- [ ] conver this to pure py code, dropping the `.xsh` specifics
instead for `subprocess` API calls?
- [ ] possibly unify all this with `./qt_screen_info.py` as part of
a "PPI config wizard" or something, but more then likely we'll
have lib-ified version inside modden/piker by then?
'''
import math
import json
# XXX, xonsh part using "subprocess mode"
disp_infos: list[dict] = json.loads($(wlr-randr --json))
lappy: dict = disp_infos[0]
dims: dict[str, int] = lappy['physical_size']
w_cm: int = dims['width']
h_cm: int = dims['height']
# cm per inch
cpi: float = 25.4
# compute "diagonal" size (aka hypot)
diag_inches: float = math.sqrt((h_cm/cpi)**2 + (w_cm/cpi)**2)
# compute reso-hypot / inches-hypot
hi_res: dict[str, float|bool] = lappy['modes'][0]
w_px: int = hi_res['width']
h_px: int = hi_res['height']
diag_pxs: float = math.sqrt(h_px**2 + w_px**2)
unscaled_ppi: float = diag_pxs/diag_inches
# retrieve TWM info on the display (including scaling info)
sway_disp_info: dict = json.loads($(swaymsg -r -t get_outputs))[0]
scale: float = sway_disp_info['scale']
print(
f'output: {sway_disp_info["name"]!r}\n'
f'--- DIMENSIONS ---\n'
f'w_cm: {w_cm!r}\n'
f'h_cm: {h_cm!r}\n'
f'w_px: {w_px!r}\n'
f'h_cm: {h_px!r}\n'
f'\n'
f'--- DIAGONALS ---\n'
f'diag_inches: {diag_inches!r}\n'
f'diag_pxs: {diag_pxs!r}\n'
f'\n'
f'--- PPI-related-info ---\n'
f'(DE reported) scale: {scale!r}\n'
f'unscaled PPI: {unscaled_ppi!r}\n'
f'|_ =sqrt(h_px**2 + w_px**2) / sqrt(h_in**2 + w_in**2)\n'
f'scaled PPI: {unscaled_ppi/scale!r}\n'
f'|_ =unscaled_ppi/scale\n'
)

View File

@ -31,8 +31,8 @@ Resource list for mucking with DPIs on multiple screens:
- https://doc.qt.io/qt-5/qguiapplication.html#screenAt - https://doc.qt.io/qt-5/qguiapplication.html#screenAt
''' '''
import os
from pyqtgraph import QtGui
from PyQt6 import ( from PyQt6 import (
QtCore, QtCore,
QtWidgets, QtWidgets,
@ -43,11 +43,6 @@ from PyQt6.QtCore import (
QSize, QSize,
QRect, QRect,
) )
from pyqtgraph import QtGui
# https://doc.qt.io/qt-6/highdpi.html#environment-variable-reference
os.environ['QT_USE_PHYSICAL_DPI'] = '1'
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
# must be set before creating the application # must be set before creating the application
@ -63,22 +58,13 @@ if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
True, True,
) )
# NOTE, inherits `QGuiApplication`
# https://doc.qt.io/qt-6/qapplication.html
# https://doc.qt.io/qt-6/qguiapplication.html
app = QtWidgets.QApplication([]) app = QtWidgets.QApplication([])
#
# ^TODO? various global DPI settings?
# [ ] DPI rounding policy,
# - https://doc.qt.io/qt-6/qt.html#HighDpiScaleFactorRoundingPolicy-enum
# - https://doc.qt.io/qt-6/qguiapplication.html#setHighDpiScaleFactorRoundingPolicy
window = QtWidgets.QMainWindow() window = QtWidgets.QMainWindow()
main_widget = QtWidgets.QWidget() main_widget = QtWidgets.QWidget()
window.setCentralWidget(main_widget) window.setCentralWidget(main_widget)
window.show() window.show()
_main_pxr: float = main_widget.devicePixelRatioF() pxr: float = main_widget.devicePixelRatioF()
# explicitly get main widget and primary displays # explicitly get main widget and primary displays
current_screen: QtGui.QScreen = app.screenAt( current_screen: QtGui.QScreen = app.screenAt(
@ -91,13 +77,7 @@ for screen in app.screens():
name: str = screen.name() name: str = screen.name()
model: str = screen.model().rstrip() model: str = screen.model().rstrip()
size: QSize = screen.size() size: QSize = screen.size()
geo: QRect = screen.geometry() geo: QRect = screen.availableGeometry()
# device-pixel-ratio
# https://doc.qt.io/qt-6/highdpi.html
pxr: float = screen.devicePixelRatio()
unscaled_size: QSize = pxr * size
phydpi: float = screen.physicalDotsPerInch() phydpi: float = screen.physicalDotsPerInch()
logdpi: float = screen.logicalDotsPerInch() logdpi: float = screen.logicalDotsPerInch()
is_primary: bool = screen is primary_screen is_primary: bool = screen is primary_screen
@ -108,12 +88,11 @@ for screen in app.screens():
f'|_primary: {is_primary}\n' f'|_primary: {is_primary}\n'
f' _current: {is_current}\n' f' _current: {is_current}\n'
f' _model: {model}\n' f' _model: {model}\n'
f' _size: {size}\n' f' _screen size: {size}\n'
f' _geometry: {geo}\n' f' _screen geometry: {geo}\n'
f' _devicePixelRatio(): {pxr}\n' f' _devicePixelRationF(): {pxr}\n'
f' _unscaled-size: {unscaled_size!r}\n' f' _physical dpi: {phydpi}\n'
f' _physical-dpi: {phydpi}\n' f' _logical dpi: {logdpi}\n'
f' _logical-dpi: {logdpi}\n'
) )
# app-wide font info # app-wide font info
@ -131,8 +110,8 @@ str_w: int = str_br.width()
print( print(
f'------ global font settings ------\n' f'------ global font settings ------\n'
f'font dpi: {fontdpi!r}\n' f'font dpi: {fontdpi}\n'
f'font height: {font_h!r}\n' f'font height: {font_h}\n'
f'string bounding rect: {str_br!r}\n' f'string bounding rect: {str_br}\n'
f'string width : {str_w!r}\n' f'string width : {str_w}\n'
) )

View File

@ -1,36 +0,0 @@
import pytest
from piker.ui._style import DpiAwareFont
class MockScreen:
def __init__(self, pdpi, ldpi, name="MockScreen"):
self._pdpi = pdpi
self._ldpi = ldpi
self._name = name
def physicalDotsPerInch(self):
return self._pdpi
def logicalDotsPerInch(self):
return self._ldpi
def name(self):
return self._name
@pytest.mark.parametrize(
"pdpi, ldpi, expected_px",
[
(96, 96, 9), # normal DPI
(169, 96, 15), # HiDPI
(120, 96, 10), # mid-DPI
]
)
def test_font_px_size(pdpi, ldpi, expected_px):
font = DpiAwareFont()
font.configure_to_dpi(screen=MockScreen(pdpi, ldpi))
px = font.px_size
print(f"{pdpi}x{ldpi} DPI -> Computed pixel size: {px}")
assert px == expected_px