542 lines
16 KiB
Python
542 lines
16 KiB
Python
# piker: trading gear for hackers
|
|
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
|
|
|
# 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/>.
|
|
|
|
"""
|
|
Qt main window singletons and stuff.
|
|
|
|
"""
|
|
from __future__ import annotations
|
|
import os
|
|
import signal
|
|
import time
|
|
from typing import (
|
|
Callable,
|
|
Union,
|
|
)
|
|
import uuid
|
|
|
|
from piker.ui.qt import (
|
|
Qt,
|
|
QtCore,
|
|
QWidget,
|
|
QMainWindow,
|
|
QApplication,
|
|
QLabel,
|
|
QStatusBar,
|
|
QScreen,
|
|
QCloseEvent,
|
|
QEvent,
|
|
QObject,
|
|
)
|
|
from ..log import get_logger
|
|
from ._style import _font_small, hcolor
|
|
from ._chart import GodWidget
|
|
|
|
|
|
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()
|
|
|
|
# Check for Ctrl+Shift modifier combination
|
|
has_ctrl_shift = (
|
|
(mods & Qt.KeyboardModifier.ControlModifier) and
|
|
(mods & Qt.KeyboardModifier.ShiftModifier)
|
|
)
|
|
|
|
if has_ctrl_shift:
|
|
# Zoom in: Ctrl+Shift+Plus or Ctrl+Shift+Equal
|
|
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
|
|
elif key == Qt.Key.Key_Minus:
|
|
self.main_window.zoom_out()
|
|
return True # consume event
|
|
|
|
# Reset zoom: Ctrl+Shift+0
|
|
elif key == Qt.Key.Key_0:
|
|
self.main_window.reset_zoom()
|
|
return True # consume event
|
|
|
|
# Pass through all other events
|
|
return False
|
|
|
|
|
|
class MultiStatus:
|
|
|
|
bar: QStatusBar
|
|
statuses: list[str]
|
|
|
|
def __init__(self, bar, statuses) -> None:
|
|
self.bar = bar
|
|
self.statuses = statuses
|
|
self._to_clear: set = set()
|
|
self._status_groups: dict[str, (set, Callable)] = {}
|
|
|
|
def open_status(
|
|
|
|
self,
|
|
msg: str,
|
|
final_msg: str | None = None,
|
|
clear_on_next: bool = False,
|
|
group_key: Union[bool, str] | None = False,
|
|
|
|
) -> Union[Callable[..., None], str]:
|
|
'''
|
|
Add a status to the status bar and return a close callback which
|
|
when called will remove the status ``msg``.
|
|
|
|
'''
|
|
for old_msg in self._to_clear:
|
|
try:
|
|
self.statuses.remove(old_msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
self.statuses.append(msg)
|
|
|
|
def remove_msg() -> None:
|
|
try:
|
|
self.statuses.remove(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
self.render()
|
|
|
|
if final_msg is not None:
|
|
self.statuses.append(final_msg)
|
|
self.render()
|
|
self._to_clear.add(final_msg)
|
|
|
|
ret = remove_msg
|
|
|
|
# create a "status group" such that new `.open_status()`
|
|
# calls can be made passing in the returned group key.
|
|
# once all clear callbacks have been called from all statuses
|
|
# in the group the final status msg to be removed will be the one
|
|
# the one provided when `group_key=True`, this way you can
|
|
# create a long living status that completes once all
|
|
# sub-statuses have finished.
|
|
if group_key is True:
|
|
if clear_on_next:
|
|
ValueError("Can't create group status and clear it on next?")
|
|
|
|
# generate a key for a new "status group"
|
|
new_group_key = str(uuid.uuid4())
|
|
|
|
def pop_group_and_clear():
|
|
|
|
subs, final_clear = self._status_groups.pop(new_group_key)
|
|
assert not subs
|
|
return remove_msg()
|
|
|
|
self._status_groups[new_group_key] = (set(), pop_group_and_clear)
|
|
ret = new_group_key
|
|
|
|
elif group_key:
|
|
|
|
def pop_from_group_and_maybe_clear_group():
|
|
# remove the message for this sub-status
|
|
remove_msg()
|
|
|
|
# check to see if all other substatuses have cleared
|
|
group_tup = self._status_groups.get(group_key)
|
|
|
|
if group_tup:
|
|
subs, group_clear = group_tup
|
|
try:
|
|
subs.remove(msg)
|
|
except KeyError:
|
|
raise KeyError(f'no msg {msg} for group {group_key}!?')
|
|
|
|
if not subs:
|
|
group_clear()
|
|
|
|
group = self._status_groups.get(group_key)
|
|
if group:
|
|
group[0].add(msg)
|
|
ret = pop_from_group_and_maybe_clear_group
|
|
|
|
self.render()
|
|
|
|
if clear_on_next:
|
|
self._to_clear.add(msg)
|
|
|
|
return ret
|
|
|
|
def render(self) -> None:
|
|
'''
|
|
Display all open statuses to bar.
|
|
|
|
'''
|
|
if self.statuses:
|
|
self.bar.showMessage(f'{" ".join(self.statuses)}')
|
|
else:
|
|
self.bar.clearMessage()
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
|
|
# XXX: for tiling wms this should scale
|
|
# with the alloted window size.
|
|
# TODO: detect for tiling and if untrue set some size?
|
|
# size = (300, 500)
|
|
godwidget: GodWidget
|
|
|
|
title = 'piker chart (ur symbol is loading bby)'
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
# self.setMinimumSize(*self.size)
|
|
self.setWindowTitle(self.title)
|
|
|
|
# set by runtime after `trio` is engaged.
|
|
self.godwidget: GodWidget | None = None
|
|
|
|
self._status_bar: QStatusBar = None
|
|
self._status_label: QLabel = None
|
|
self._size: tuple[int, int] | None = None
|
|
|
|
# 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 = 4.0 # Start at 200% zoom
|
|
self._min_zoom: float = 0.5
|
|
self._max_zoom: float = 10.0
|
|
self._zoom_step: float = 1.0
|
|
|
|
# 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
|
|
def mode_label(self) -> QLabel:
|
|
|
|
# init mode label
|
|
if not self._status_label:
|
|
|
|
self._status_label = label = QLabel()
|
|
label.setStyleSheet(
|
|
f"""QLabel {{
|
|
color : {hcolor('gunmetal')};
|
|
}}
|
|
"""
|
|
# font-size : {font_size}px;
|
|
)
|
|
label.setTextFormat(
|
|
Qt.TextFormat.MarkdownText
|
|
)
|
|
label.setFont(_font_small.font)
|
|
label.setMargin(2)
|
|
label.setAlignment(
|
|
QtCore.Qt.AlignVCenter
|
|
| QtCore.Qt.AlignRight
|
|
)
|
|
self.statusBar().addPermanentWidget(label)
|
|
label.show()
|
|
|
|
return self._status_label
|
|
|
|
def closeEvent(
|
|
self,
|
|
event: QCloseEvent,
|
|
|
|
) -> None:
|
|
'''Cancel the root actor asap.
|
|
|
|
'''
|
|
# raising KBI seems to get intercepted by by Qt so just use the system.
|
|
os.kill(os.getpid(), signal.SIGINT)
|
|
|
|
@property
|
|
def status_bar(self) -> QStatusBar:
|
|
|
|
# style and cached the status bar on first access
|
|
if not self._status_bar:
|
|
|
|
sb = self.statusBar()
|
|
sb.setStyleSheet((
|
|
f"color : {hcolor('gunmetal')};"
|
|
f"background : {hcolor('default_dark')};"
|
|
f"font-size : {_font_small.px_size}px;"
|
|
"padding : 0px;"
|
|
# "min-height : 19px;"
|
|
# "qproperty-alignment: AlignVCenter;"
|
|
))
|
|
self.setStatusBar(sb)
|
|
self._status_bar = MultiStatus(sb, [])
|
|
|
|
return self._status_bar
|
|
|
|
def set_mode_name(
|
|
self,
|
|
name: str,
|
|
|
|
) -> None:
|
|
|
|
self.mode_label.setText(f'mode:{name}')
|
|
|
|
def on_focus_change(
|
|
self,
|
|
|
|
last: QWidget,
|
|
current: QWidget,
|
|
|
|
) -> None:
|
|
|
|
log.info(f'widget focus changed from {last} -> {current}')
|
|
|
|
if current is not None:
|
|
# cursor left window?
|
|
name = getattr(current, 'mode_name', '')
|
|
self.set_mode_name(name)
|
|
|
|
def current_screen(self) -> QScreen:
|
|
'''
|
|
Get a frickin screen (if we can, gawd).
|
|
|
|
'''
|
|
app = QApplication.instance()
|
|
|
|
for _ in range(3):
|
|
screen = app.screenAt(self.pos())
|
|
log.debug('trying to access QScreen...')
|
|
if screen is None:
|
|
time.sleep(0.5)
|
|
continue
|
|
|
|
break
|
|
else:
|
|
if screen is None:
|
|
# try for the first one we can find
|
|
screen = app.screens()[0]
|
|
|
|
assert screen, "Wow Qt is dumb as shit and has no screen..."
|
|
return screen
|
|
|
|
def configure_to_desktop(
|
|
self,
|
|
size: tuple[int, int] | None = None,
|
|
|
|
) -> None:
|
|
'''
|
|
Explicitly size the window dimensions (for stacked window
|
|
managers).
|
|
|
|
For tina systems (like windoze) try to do a sane window size on
|
|
startup.
|
|
|
|
'''
|
|
# https://stackoverflow.com/a/18975846
|
|
if not size and not self._size:
|
|
# app = QApplication.instance()
|
|
geo = self.current_screen().geometry()
|
|
h, w = geo.height(), geo.width()
|
|
# use approx 1/3 of the area of the screen by default
|
|
self._size = round(w * .666), round(h * .666)
|
|
|
|
self.resize(*size or self._size)
|
|
|
|
def resizeEvent(self, event: QtCore.QEvent) -> None:
|
|
if (
|
|
# event.spontaneous()
|
|
event.oldSize().height == event.size().height
|
|
):
|
|
event.ignore()
|
|
return
|
|
|
|
# XXX: uncomment for debugging..
|
|
# attrs = {}
|
|
# for key in dir(event):
|
|
# if key == '__dir__':
|
|
# continue
|
|
# attr = getattr(event, key)
|
|
# try:
|
|
# attrs[key] = attr()
|
|
# except TypeError:
|
|
# attrs[key] = attr
|
|
|
|
# from pprint import pformat
|
|
# print(
|
|
# f'{pformat(attrs)}\n'
|
|
# f'WINDOW RESIZE: {self.size()}\n\n'
|
|
# )
|
|
self.godwidget.on_win_resize(event)
|
|
event.accept()
|
|
|
|
def zoom_in(self) -> None:
|
|
'''Increase UI zoom level.'''
|
|
new_zoom = min(self._zoom_level + self._zoom_step, self._max_zoom)
|
|
if new_zoom != self._zoom_level:
|
|
self._zoom_level = new_zoom
|
|
self._apply_zoom()
|
|
log.info(f'Zoomed in to {self._zoom_level:.1%}')
|
|
|
|
def zoom_out(self) -> None:
|
|
'''Decrease UI zoom level.'''
|
|
new_zoom = max(self._zoom_level - self._zoom_step, self._min_zoom)
|
|
if new_zoom != self._zoom_level:
|
|
self._zoom_level = new_zoom
|
|
self._apply_zoom()
|
|
log.info(f'Zoomed out to {self._zoom_level:.1%}')
|
|
|
|
def reset_zoom(self) -> None:
|
|
'''Reset UI zoom to 100%.'''
|
|
if self._zoom_level != 1.0:
|
|
self._zoom_level = 1.0
|
|
self._apply_zoom()
|
|
log.info('Reset zoom to 100%')
|
|
|
|
def _apply_zoom(self) -> None:
|
|
'''Apply current zoom level to all UI elements.'''
|
|
from . import _style
|
|
|
|
# reconfigure fonts with zoom multiplier
|
|
_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()
|
|
|
|
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 and 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():
|
|
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()
|
|
|
|
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
|
|
_qt_win: QMainWindow = None
|
|
|
|
|
|
def main_window() -> MainWindow:
|
|
'Return the actor-global Qt window.'
|
|
|
|
global _qt_win
|
|
assert _qt_win
|
|
return _qt_win
|