# 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 . """ Qt event proxying and processing using ``trio`` mem chans. """ from contextlib import asynccontextmanager as acm from typing import Callable import trio from tractor.trionics import gather_contexts from piker.ui.qt import ( QtCore, QWidget, QEvent, keys, gs_keys, pyqtBoundSignal, ) from piker.types import Struct MOUSE_EVENTS = { gs_keys.GraphicsSceneMousePress, gs_keys.GraphicsSceneMouseRelease, keys.MouseButtonPress, keys.MouseButtonRelease, # QtGui.QMouseEvent, } # TODO: maybe consider some constrained ints down the road? # https://pydantic-docs.helpmanual.io/usage/types/#constrained-types class KeyboardMsg(Struct): '''Unpacked Qt keyboard event data. ''' event: QEvent etype: int key: int mods: int txt: str def to_tuple(self) -> tuple: return tuple(self.to_dict().values()) class MouseMsg(Struct): '''Unpacked Qt keyboard event data. ''' event: QEvent etype: int button: int # TODO: maybe add some methods to detect key combos? Or is that gonna be # better with pattern matching? # # ctl + alt as combo # ctlalt = False # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: # ctlalt = True class EventRelay(QtCore.QObject): ''' Relay Qt events over a trio memory channel for async processing. ''' _event_types: set[QEvent] = set() _send_chan: trio.abc.SendChannel = None _filter_auto_repeats: bool = True def eventFilter( self, source: QWidget, ev: QEvent, ) -> None: ''' Qt global event filter: return `False` to pass through and `True` to filter event out. https://doc.qt.io/qt-5/qobject.html#eventFilter https://doc.qt.io/qtforpython/overviews/eventsandfilters.html#event-filters ''' etype = ev.type() # TODO: turn this on and see what we can filter by default (such # as mouseWheelEvent). # print(f'ev: {ev}') if etype not in self._event_types: return False # XXX: we unpack here because apparently doing it # after pop from the mem chan isn't showing the same # event object? no clue wtf is going on there, likely # something to do with Qt internals and calling the # parent handler? if etype in { QEvent.Type.KeyPress, QEvent.Type.KeyRelease, }: msg = KeyboardMsg( event=ev, etype=etype, key=ev.key(), mods=ev.modifiers(), txt=ev.text(), ) # TODO: is there a global setting for this? if ev.isAutoRepeat() and self._filter_auto_repeats: ev.ignore() # filter out this event and stop it's processing # https://doc.qt.io/qt-5/qobject.html#installEventFilter return True # NOTE: the event object instance coming out # the other side is mutated since Qt resumes event # processing **before** running a ``trio`` guest mode # tick, thus special handling or copying must be done. elif etype in MOUSE_EVENTS: # print('f mouse event: {ev}') msg = MouseMsg( event=ev, etype=etype, button=ev.button(), ) else: msg = ev # send event-msg to async handler self._send_chan.send_nowait(msg) # **do not** filter out this event # and instead forward to the source widget # https://doc.qt.io/qt-5/qobject.html#installEventFilter return False @acm async def open_event_stream( source_widget: QWidget, event_types: set[QEvent] = { QEvent.Type.KeyPress, }, filter_auto_repeats: bool = True, ) -> trio.abc.ReceiveChannel: # 1 to force eager sending send, recv = trio.open_memory_channel(16) kc = EventRelay() kc._send_chan = send kc._event_types = event_types kc._filter_auto_repeats = filter_auto_repeats source_widget.installEventFilter(kc) try: async with send: yield recv finally: source_widget.removeEventFilter(kc) @acm async def open_signal_handler( signal: pyqtBoundSignal, async_handler: Callable, ) -> trio.abc.ReceiveChannel: send, recv = trio.open_memory_channel(0) def proxy_args_to_chan(*args): send.send_nowait(args) signal.connect(proxy_args_to_chan) async def proxy_to_handler(): async for args in recv: await async_handler(*args) async with trio.open_nursery() as tn: tn.start_soon(proxy_to_handler) async with send: yield @acm async def open_handlers( source_widgets: list[QWidget], event_types: set[QEvent], # NOTE: if you want to bind in additional kwargs to the handler # pass in a `partial()` instead! async_handler: Callable[ [QWidget, trio.abc.ReceiveChannel], # required handler args None ], # XXX: these are ONLY inputs available to the # `open_event_stream()` event-relay to mem-chan factor above! **open_ev_stream_kwargs, ) -> None: ''' Connect and schedule an async handler function to receive an arbitrary `QWidget`'s events with kb/mouse msgs repacked into structs (see above) and shuttled over a mem-chan to the input `async_handler` to allow interaction-IO processing from a `trio` func-as-task. ''' widget: QWidget streams: list[trio.abc.ReceiveChannel] async with ( trio.open_nursery() as tn, gather_contexts([ open_event_stream( widget, event_types, **open_ev_stream_kwargs, ) for widget in source_widgets ]) as streams, ): for widget, event_recv_stream in zip( source_widgets, streams, ): tn.start_soon( async_handler, widget, event_recv_stream, ) yield