From 1e58361bd4e8811edd97544386d397673c53c2ee Mon Sep 17 00:00:00 2001 From: Kernc Date: Sun, 25 Dec 2022 04:50:41 +0100 Subject: [PATCH 1/7] ENH/PERF: Add persistent system tray agent and fast hotkey support --- efck/__init__.py | 1 + efck/__main__.py | 41 +++++++++++++++++++++++++++++++++++- efck/config.py | 4 ++++ efck/gui.py | 39 ++++++++++++++++++++++++++++++++--- efck/tabs/_options.py | 48 ++++++++++++++++++++++++++++++++++++++++--- requirements.txt | 2 ++ 6 files changed, 128 insertions(+), 7 deletions(-) diff --git a/efck/__init__.py b/efck/__init__.py index 9e63e43..0d2d023 100644 --- a/efck/__init__.py +++ b/efck/__init__.py @@ -11,6 +11,7 @@ if not QApplication.instance(): import sys qApp = QApplication(sys.argv) + qApp.setQuitOnLastWindowClosed(False) qApp.setApplicationName('efck-chat-keyboard') qApp.setApplicationDisplayName('Efck Chat Keyboard') qApp.setApplicationVersion(__version__) diff --git a/efck/__main__.py b/efck/__main__.py index 1239dba..261467f 100644 --- a/efck/__main__.py +++ b/efck/__main__.py @@ -1,11 +1,15 @@ import argparse import logging +import os +import signal import sys import time import tempfile from pathlib import Path -from . import __version__, CONFIG_DIRS, cli_args +import psutil + +from . import IS_WIDOWS, __version__, CONFIG_DIRS, cli_args from .qt import QApplication, QT_API, QT_VERSION_STR logger = logging.getLogger(__name__) @@ -49,13 +53,48 @@ def main(): logger.info('Qt version: %s %s, platform: %s', QT_API, QT_VERSION_STR, QApplication.platformName()) logger.info('Config directories: %s', CONFIG_DIRS) + check_if_another_process_is_running_and_raise_it(lambda: window.show()) + from .gui import MainWindow from .config import load_config load_config() window = MainWindow() window.show() + window.reset_hotkey_listener() sys.exit(QApplication.instance().exec()) +def check_if_another_process_is_running_and_raise_it(signal_handler): + OUR_SIGUSR1 = signal.SIGBREAK if IS_WIDOWS else signal.SIGUSR1 + + def is_process_running(name): + for proc in psutil.process_iter(attrs=['name', 'exe', 'cmdline', 'pid'], ad_value=None): + if not proc: + continue + pinfo = proc.info + if (((pinfo['name'] or '').startswith(name) or + name in (pinfo['exe'] or '') or + name in ' '.join(pinfo['cmdline'])) and + proc.pid != os.getpid()): + logger.debug('Process match: %s %s', proc, pinfo) + return proc + + proc = is_process_running('efck') + if proc: + os.kill(proc.pid, OUR_SIGUSR1) + logger.info('efck-chat-keyboard instance is already running, ' + 'sending SIGUSR1 to pid %d. Quitting.', proc.pid) + sys.exit(0) + + def sigusr1_handler(_signum, _frame): + logger.info('Received SIGUSR1. Showing up!') + nonlocal prev_handler + signal_handler() + if callable(prev_handler): + prev_handler() + + prev_handler = signal.signal(OUR_SIGUSR1, sigusr1_handler) + + main() diff --git a/efck/config.py b/efck/config.py index 450f491..9898598 100644 --- a/efck/config.py +++ b/efck/config.py @@ -3,6 +3,7 @@ import os from pathlib import Path +from . import IS_WIDOWS from .tabs import EmojiTab logger = logging.getLogger(__name__) @@ -39,6 +40,8 @@ 'window_geometry': [360, 400], 'zoom': 100, 'force_clipboard': False, + 'tray_agent': True, + 'hotkey': ('+.' if not IS_WIDOWS else '++.'), # TODO: test windows cmd EmojiTab.__name__: _emoji_filters, } @@ -85,4 +88,5 @@ def dump_config(): logger.info('Error opening config file for writing: %s', e) continue logger.info('Config dumped %ssuccessfully to "%s"', "UN" if not success else "", fd.name) + logger.debug('User config: %s', config_state) return success and fd.name diff --git a/efck/gui.py b/efck/gui.py index 53e67f0..fbe9ffa 100644 --- a/efck/gui.py +++ b/efck/gui.py @@ -242,9 +242,9 @@ def keyPressEvent(self, event: QKeyEvent): key, getattr(event.modifiers(), 'value', event.modifiers()), # PyQt6 text) - # Escape key exits the app + # Escape key exits the app / minimizes to tray if key == Qt.Key.Key_Escape or event.matches(QKeySequence.StandardKey.Cancel): - QApplication.instance().quit() + self.exit() tab = self.current_tab # Don't handle other keypresses on Options tab here @@ -324,7 +324,40 @@ def on_activated(self): tab.activated(force_clipboard=force_clipboard) - QApplication.instance().quit() + self.exit() + + def exit(self): + from .config import config_state + + if config_state['tray_agent']: + super().close() + if self.current_tab: + self.current_tab.line_edit.clear() + else: + QApplication.instance().quit() + + _listener = None + + def reset_hotkey_listener(self): + import pynput.keyboard + + from .config import config_state + + if self._listener: + self._listener.stop() + + def on_hotkey(): + logger.info(f'Hotkey "{config_state["hotkey"]}" pressed. Raising window.') + nonlocal self + self.show() + self.raise_() + + if config_state['tray_agent']: + try: + self._listener = pynput.keyboard.GlobalHotKeys({config_state['hotkey']: on_hotkey}) + self._listener.start() + except ValueError: + logger.exception('Invalid hotkey??? %s', config_state) class _TabPrivate(QWidget): diff --git a/efck/tabs/_options.py b/efck/tabs/_options.py index d1adbbe..f01ccdc 100644 --- a/efck/tabs/_options.py +++ b/efck/tabs/_options.py @@ -1,6 +1,8 @@ import copy import logging +import pynput.keyboard + from ..qt import * @@ -35,15 +37,53 @@ def _change_zoom(): main_box = QGroupBox(self) self.layout().addWidget(main_box) main_box.setLayout(QVBoxLayout(main_box)) - force_clipboard_cb = QCheckBox( + + check = QCheckBox( 'Force &clipboard', parent=self, checked=config_state['force_clipboard'], toolTip='Copy selected emoji/text into the clipboard in addition to typing it out. \n' "Useful if typeout (default action) doesn't work on your system.") - force_clipboard_cb.stateChanged.connect( + check.stateChanged.connect( lambda state: config_state.__setitem__('force_clipboard', bool(state))) - main_box.layout().addWidget(force_clipboard_cb) + main_box.layout().addWidget(check) + + check = QCheckBox( + 'Fast startup (background service) with keyboard hotkey:', + parent=self, + checked=config_state['tray_agent'], + toolTip='Minimize app to background instead of closing it. ' + 'This enables much faster subsequent startup.') + check.stateChanged.connect( + lambda state: (config_state.__setitem__('tray_agent', bool(state)), + hotkey_edit.setEnabled(state))) + + url = 'https://pynput.readthedocs.io/en/latest/keyboard.html#pynput.keyboard.Key' + hotkey_edit = QLineEdit( + config_state['hotkey'], + parent=self, + enabled=check.isChecked(), + toolTip=f'Hotkey syntax format is as accepted by ' + f'pynput.keyboard.HotKey: {url}' + ) + hotkey_edit.textEdited.connect( + lambda text: is_hotkey_valid(text) and config_state.__setitem__('hotkey', text)) + + def is_hotkey_valid(text): + try: + pynput.keyboard.HotKey.parse(text) + except ValueError: + hotkey_edit.setStyleSheet('QLineEdit {border: 3px solid red}') + return False + hotkey_edit.setStyleSheet('') + return True + + box = QWidget() + box.setLayout(QHBoxLayout()) + box.layout().setContentsMargins(0, 0, 0, 0) + box.layout().addWidget(check) + box.layout().addWidget(hotkey_edit) + main_box.layout().addWidget(box) box = QWidget(self) main_box.layout().addWidget(box) @@ -84,6 +124,8 @@ def save_dirty(self, exiting=False) -> bool: self._initial_config = copy.deepcopy(config_state) if not exiting: + self.nativeParentWidget().reset_hotkey_listener() + for tab in self.nativeParentWidget().tabs: tab.init_delegate(config=config_state.get(tab.__class__.__name__), zoom=config_state.get('zoom', 100) / 100) diff --git a/requirements.txt b/requirements.txt index a1fefc1..00ca9ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ unicodedata2 pyqt6 # or pyside6 or pyqt5 +psutil +pynput From 4f17c96a7cc0db781434428c0264a2ef236c021a Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 27 Dec 2022 17:38:49 +0100 Subject: [PATCH 2/7] Use `signal.set_wakeup_fd()` to make signals work with Qt event loop --- efck/__main__.py | 39 +++++++++++------------------- efck/_qt/pyqt5.py | 1 + efck/_qt/pyqt6.py | 1 + efck/_qt/pyside6.py | 1 + efck/_qt/qtpy.py | 1 + efck/gui.py | 59 +++++++++++++++++++++++++++++++++------------ 6 files changed, 62 insertions(+), 40 deletions(-) diff --git a/efck/__main__.py b/efck/__main__.py index 261467f..54c9627 100644 --- a/efck/__main__.py +++ b/efck/__main__.py @@ -1,7 +1,6 @@ import argparse import logging import os -import signal import sys import time import tempfile @@ -9,7 +8,8 @@ import psutil -from . import IS_WIDOWS, __version__, CONFIG_DIRS, cli_args +from . import __version__, CONFIG_DIRS, cli_args +from .gui import OUR_SIGUSR1 from .qt import QApplication, QT_API, QT_VERSION_STR logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def main(): logger.info('Qt version: %s %s, platform: %s', QT_API, QT_VERSION_STR, QApplication.platformName()) logger.info('Config directories: %s', CONFIG_DIRS) - check_if_another_process_is_running_and_raise_it(lambda: window.show()) + check_if_another_process_is_running_and_raise_it() from .gui import MainWindow from .config import load_config @@ -65,36 +65,25 @@ def main(): sys.exit(QApplication.instance().exec()) -def check_if_another_process_is_running_and_raise_it(signal_handler): - OUR_SIGUSR1 = signal.SIGBREAK if IS_WIDOWS else signal.SIGUSR1 - - def is_process_running(name): - for proc in psutil.process_iter(attrs=['name', 'exe', 'cmdline', 'pid'], ad_value=None): - if not proc: - continue +def check_if_another_process_is_running_and_raise_it(): + def is_process_running(): + our_pid = os.getpid() + p = psutil.Process(our_pid) + key = {'name': p.name(), 'exe': p.exe(), 'cmdline': p.cmdline()} + for proc in filter(None, psutil.process_iter(attrs=['name', 'exe', 'cmdline', 'pid'], + ad_value=None)): pinfo = proc.info - if (((pinfo['name'] or '').startswith(name) or - name in (pinfo['exe'] or '') or - name in ' '.join(pinfo['cmdline'])) and - proc.pid != os.getpid()): - logger.debug('Process match: %s %s', proc, pinfo) + pinfo.pop('pid') + if pinfo == key and proc.pid != our_pid: + logger.debug('Process match: %s %s', proc, proc.info) return proc - proc = is_process_running('efck') + proc = is_process_running() if proc: os.kill(proc.pid, OUR_SIGUSR1) logger.info('efck-chat-keyboard instance is already running, ' 'sending SIGUSR1 to pid %d. Quitting.', proc.pid) sys.exit(0) - def sigusr1_handler(_signum, _frame): - logger.info('Received SIGUSR1. Showing up!') - nonlocal prev_handler - signal_handler() - if callable(prev_handler): - prev_handler() - - prev_handler = signal.signal(OUR_SIGUSR1, sigusr1_handler) - main() diff --git a/efck/_qt/pyqt5.py b/efck/_qt/pyqt5.py index d3313fa..06e0f26 100644 --- a/efck/_qt/pyqt5.py +++ b/efck/_qt/pyqt5.py @@ -9,6 +9,7 @@ QPoint, QRect, QSize, + QSocketNotifier, QSortFilterProxyModel, QStandardPaths, Qt, diff --git a/efck/_qt/pyqt6.py b/efck/_qt/pyqt6.py index cf9ddae..d3a25cf 100644 --- a/efck/_qt/pyqt6.py +++ b/efck/_qt/pyqt6.py @@ -9,6 +9,7 @@ QPoint, QRect, QSize, + QSocketNotifier, QSortFilterProxyModel, QStandardPaths, Qt, diff --git a/efck/_qt/pyside6.py b/efck/_qt/pyside6.py index 34037b6..3fd0803 100644 --- a/efck/_qt/pyside6.py +++ b/efck/_qt/pyside6.py @@ -9,6 +9,7 @@ QPoint, QRect, QSize, + QSocketNotifier, QSortFilterProxyModel, QStandardPaths, Qt, diff --git a/efck/_qt/qtpy.py b/efck/_qt/qtpy.py index a13cafc..8030a2e 100644 --- a/efck/_qt/qtpy.py +++ b/efck/_qt/qtpy.py @@ -9,6 +9,7 @@ QPoint, QRect, QSize, + QSocketNotifier, QSortFilterProxyModel, QStandardPaths, Qt, diff --git a/efck/gui.py b/efck/gui.py index fbe9ffa..c061510 100644 --- a/efck/gui.py +++ b/efck/gui.py @@ -1,8 +1,10 @@ import atexit import logging +import signal +import socket from pathlib import Path -from . import IS_MACOS +from . import IS_MACOS, IS_WIDOWS from .qt import * logger = logging.getLogger(__name__) @@ -13,6 +15,8 @@ Qt.Key.Key_4, Qt.Key.Key_5, Qt.Key.Key_6, Qt.Key.Key_7, Qt.Key.Key_8, Qt.Key.Key_9} +OUR_SIGUSR1 = signal.SIGBREAK if IS_WIDOWS else signal.SIGUSR1 + def fire_after(self, timer_attr, callback, interval_ms): try: @@ -71,28 +75,16 @@ def __init__(self): from .config import config_state # Init the main app/tabbed widget - - def _initial_window_geometry(): - mouse_pos = QCursor.pos() - geometry = config_state['window_geometry'] - logger.debug('Window geometry: %s', geometry) - valid_geom = QGuiApplication.primaryScreen().availableGeometry() - PAD_PX = 50 - top_left = [max(mouse_pos.x() - geometry[0], valid_geom.x() + PAD_PX), - max(mouse_pos.y() - geometry[1], valid_geom.y() + PAD_PX)] - geometry = [min(geometry[0], valid_geom.width() - 2 * PAD_PX), - min(geometry[1], valid_geom.height() - 2 * PAD_PX)] - return top_left + geometry - super().__init__( windowTitle=QApplication.instance().applicationName(), - geometry=QRect(*_initial_window_geometry()), windowIcon=QIcon(str(ICON_DIR / 'logo.png')), documentMode=True, usesScrollButtons=True, # FIXME: Reduce tabs right margin on macOS # https://forum.qt.io/topic/119371/text-in-qtabbar-on-macos-is-truncated-or-elided-by-default-although-there-is-empty-space/10 ) + self.reset_window_position() + self.install_sigusr1_handler() self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | @@ -349,8 +341,10 @@ def reset_hotkey_listener(self): def on_hotkey(): logger.info(f'Hotkey "{config_state["hotkey"]}" pressed. Raising window.') nonlocal self + self.reset_window_position() self.show() self.raise_() + return True if config_state['tray_agent']: try: @@ -359,6 +353,41 @@ def on_hotkey(): except ValueError: logger.exception('Invalid hotkey??? %s', config_state) + def reset_window_position(self): + from .config import config_state + + mouse_pos = QCursor.pos() + QPoint(0, -40) # distance from mouse padding + geometry = config_state['window_geometry'] + logger.debug('Window geometry: %s', geometry) + valid_geom = QGuiApplication.primaryScreen().availableGeometry() + PAD_PX = 50 + top_left = [max(mouse_pos.x() - geometry[0], valid_geom.x() + PAD_PX), + max(mouse_pos.y() - geometry[1], valid_geom.y() + PAD_PX)] + geometry = [min(geometry[0], valid_geom.width() - 2 * PAD_PX), + min(geometry[1], valid_geom.height() - 2 * PAD_PX)] + self.setGeometry(QRect(*top_left + geometry)) + + def install_sigusr1_handler(self): + self._socket_pair = rsock, wsock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) + self._notifier = notifier = QSocketNotifier(rsock.fileno(), QSocketNotifier.Type.Read, self) + # https://stackoverflow.com/questions/4938723/what-is-the-correct-way-to/37229299#37229299 + wsock.setblocking(False) + signal.set_wakeup_fd(wsock.fileno()) + signal.signal(OUR_SIGUSR1, lambda sig, frame: None) + + def sigusr1_received(): + nonlocal notifier, self, rsock + notifier.setEnabled(False) + signum = ord(rsock.recv(1)) + if signum == OUR_SIGUSR1: + logger.info('Handled SIGUSR1. Showing up!') + self.reset_window_position() + self.show() + self.raise_() + notifier.setEnabled(True) + + notifier.activated.connect(sigusr1_received) + class _TabPrivate(QWidget): def __init__(self, parent, options_tab, textEdited, activated): From bbf0d2aa7a2c7cbcc3df623052640a7ec63d0287 Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 27 Dec 2022 19:22:57 +0100 Subject: [PATCH 3/7] Also save config when Options tab is hidden via Esc key --- efck/gui.py | 5 ----- efck/tabs/_options.py | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/efck/gui.py b/efck/gui.py index c061510..3720670 100644 --- a/efck/gui.py +++ b/efck/gui.py @@ -193,11 +193,6 @@ def _on_tab_changed(idx): tab.line_edit.setText(prev_text) tab.line_edit.setFocus() - if prev_idx == OPTIONS_TAB_IDX: - # Reload models - if options_tab.save_dirty(): - for tab in self.tabs: - tab.reset_model() prev_idx = idx self.currentChanged.connect(_on_tab_changed) diff --git a/efck/tabs/_options.py b/efck/tabs/_options.py index f01ccdc..544f638 100644 --- a/efck/tabs/_options.py +++ b/efck/tabs/_options.py @@ -115,6 +115,12 @@ def add_section(self, name, widget: QWidget): box.layout().addWidget(widget) self.layout().addWidget(box) + def hideEvent(self, event): + if self.save_dirty(): + # Reload models + for tab in self.nativeParentWidget().tabs: + tab.reset_model() + def save_dirty(self, exiting=False) -> bool: """Returns True if config had changed and emoji need reloading""" from ..config import dump_config, config_state From 5ce80cb75d66d3e8b97055fe356e0242ea52072f Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 27 Dec 2022 19:28:29 +0100 Subject: [PATCH 4/7] Avoid ResourceWarning: unclosed socket ... --- efck/gui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/efck/gui.py b/efck/gui.py index 3720670..8a599db 100644 --- a/efck/gui.py +++ b/efck/gui.py @@ -363,12 +363,15 @@ def reset_window_position(self): self.setGeometry(QRect(*top_left + geometry)) def install_sigusr1_handler(self): - self._socket_pair = rsock, wsock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) + self._socket_pair = (rsock, wsock) = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self._notifier = notifier = QSocketNotifier(rsock.fileno(), QSocketNotifier.Type.Read, self) # https://stackoverflow.com/questions/4938723/what-is-the-correct-way-to/37229299#37229299 wsock.setblocking(False) signal.set_wakeup_fd(wsock.fileno()) signal.signal(OUR_SIGUSR1, lambda sig, frame: None) + # Avoid ResourceWarning on exit + atexit.register(rsock.close) + atexit.register(wsock.close) def sigusr1_received(): nonlocal notifier, self, rsock From d698fa8ad5235d5b4d50d05fea828dc557a1a274 Mon Sep 17 00:00:00 2001 From: Kernc Date: Wed, 28 Dec 2022 02:24:53 +0100 Subject: [PATCH 5/7] Add requirements to setup.py --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 7a863c1..ef29ab9 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,8 @@ 'setuptools_scm', ], install_requires=[ + 'psutil', + 'pynput', ], extras_require={ 'doc': [ From 7277e6e5e5172767d3d21fef1d833bf35e92b3e5 Mon Sep 17 00:00:00 2001 From: Kernc Date: Wed, 28 Dec 2022 02:52:01 +0100 Subject: [PATCH 6/7] Use os.pipe() socket pair for Widows/macOS --- efck/gui.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/efck/gui.py b/efck/gui.py index 8a599db..aabc374 100644 --- a/efck/gui.py +++ b/efck/gui.py @@ -1,7 +1,7 @@ import atexit import logging +import os import signal -import socket from pathlib import Path from . import IS_MACOS, IS_WIDOWS @@ -363,20 +363,20 @@ def reset_window_position(self): self.setGeometry(QRect(*top_left + geometry)) def install_sigusr1_handler(self): - self._socket_pair = (rsock, wsock) = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) - self._notifier = notifier = QSocketNotifier(rsock.fileno(), QSocketNotifier.Type.Read, self) + r_fd, w_fd = os.pipe() + if not IS_WIDOWS: + os.set_blocking(w_fd, False) + atexit.register(os.close, r_fd) + atexit.register(os.close, w_fd) + self._notifier = notifier = QSocketNotifier(r_fd, QSocketNotifier.Type.Read, self) # https://stackoverflow.com/questions/4938723/what-is-the-correct-way-to/37229299#37229299 - wsock.setblocking(False) - signal.set_wakeup_fd(wsock.fileno()) + signal.set_wakeup_fd(w_fd) signal.signal(OUR_SIGUSR1, lambda sig, frame: None) - # Avoid ResourceWarning on exit - atexit.register(rsock.close) - atexit.register(wsock.close) def sigusr1_received(): - nonlocal notifier, self, rsock + nonlocal notifier, self, r_fd notifier.setEnabled(False) - signum = ord(rsock.recv(1)) + signum = ord(os.read(r_fd, 1)) if signum == OUR_SIGUSR1: logger.info('Handled SIGUSR1. Showing up!') self.reset_window_position() From ad0f972601b03a1f392a03443260e43bd08de1cc Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 3 Jan 2023 18:48:05 +0100 Subject: [PATCH 7/7] Add Debian Depends for python3-psutil, python3-pynput Fixes https://github.com/efck-chat-keyboard/efck/issues/14 Thanks @Symbian9 --- packaging/debian/control | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/debian/control b/packaging/debian/control index b6a8d66..4614e06 100644 --- a/packaging/debian/control +++ b/packaging/debian/control @@ -20,6 +20,8 @@ Architecture: all Depends: ${python3:Depends}, ${misc:Depends}, python3-pyqt6 | python3-pyqt5, + python3-psutil, + python3-pynput, fonts-noto-color-emoji Recommends: python3-unicodedata2, xdotool,