Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Background daemon/agent/service for fast startup with hotkey #11

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions efck/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
28 changes: 28 additions & 0 deletions efck/__main__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import argparse
import logging
import os
import sys
import time
import tempfile
from pathlib import Path

import psutil

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__)
Expand Down Expand Up @@ -49,13 +53,37 @@ 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()

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():
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
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()
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)


main()
1 change: 1 addition & 0 deletions efck/_qt/pyqt5.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
QPoint,
QRect,
QSize,
QSocketNotifier,
QSortFilterProxyModel,
QStandardPaths,
Qt,
Expand Down
1 change: 1 addition & 0 deletions efck/_qt/pyqt6.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
QPoint,
QRect,
QSize,
QSocketNotifier,
QSortFilterProxyModel,
QStandardPaths,
Qt,
Expand Down
1 change: 1 addition & 0 deletions efck/_qt/pyside6.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
QPoint,
QRect,
QSize,
QSocketNotifier,
QSortFilterProxyModel,
QStandardPaths,
Qt,
Expand Down
1 change: 1 addition & 0 deletions efck/_qt/qtpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
QPoint,
QRect,
QSize,
QSocketNotifier,
QSortFilterProxyModel,
QStandardPaths,
Qt,
Expand Down
4 changes: 4 additions & 0 deletions efck/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from pathlib import Path

from . import IS_WIDOWS
from .tabs import EmojiTab

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -39,6 +40,8 @@
'window_geometry': [360, 400],
'zoom': 100,
'force_clipboard': False,
'tray_agent': True,
'hotkey': ('<cmd>+.' if not IS_WIDOWS else '<ctrl>+<alt>+.'), # TODO: test windows cmd
EmojiTab.__name__: _emoji_filters,
}

Expand Down Expand Up @@ -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
106 changes: 83 additions & 23 deletions efck/gui.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import atexit
import logging
import os
import signal
from pathlib import Path

from . import IS_MACOS
from . import IS_MACOS, IS_WIDOWS
from .qt import *

logger = logging.getLogger(__name__)
Expand All @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -201,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)
Expand Down Expand Up @@ -242,9 +229,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
Expand Down Expand Up @@ -324,7 +311,80 @@ 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.reset_window_position()
self.show()
self.raise_()
return True

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)

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):
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
signal.set_wakeup_fd(w_fd)
signal.signal(OUR_SIGUSR1, lambda sig, frame: None)

def sigusr1_received():
nonlocal notifier, self, r_fd
notifier.setEnabled(False)
signum = ord(os.read(r_fd, 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):
Expand Down
54 changes: 51 additions & 3 deletions efck/tabs/_options.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import copy
import logging

import pynput.keyboard

from ..qt import *


Expand Down Expand Up @@ -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'<b><code>pynput.keyboard.HotKey</code></b>: <a href="{url}">{url}</a>'
)
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)
Expand Down Expand Up @@ -75,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
Expand All @@ -84,6 +130,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)
Expand Down
2 changes: 2 additions & 0 deletions packaging/debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
unicodedata2
pyqt6 # or pyside6 or pyqt5
psutil
pynput
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
'setuptools_scm',
],
install_requires=[
'psutil',
'pynput',
],
extras_require={
'doc': [
Expand Down