diff --git a/python3-pkg-efck/output.py b/python3-pkg-efck/output.py new file mode 100644 index 0000000..ceb2cb2 --- /dev/null +++ b/python3-pkg-efck/output.py @@ -0,0 +1,196 @@ +import logging +import os +import shutil +import subprocess +from tempfile import NamedTemporaryFile + +from . import IS_MACOS, IS_X11, IS_WAYLAND, IS_WIDOWS, PLATFORM +from .qt import * + +logger = logging.getLogger(__name__) + + +def type_chars(text: str, force_clipboard): + TYPEOUT_COMMANDS = ( + (IS_X11, ['xdotool', 'type', text]), + (IS_X11 or IS_WAYLAND, ['ydotool', 'type', '--next-delay', '0', '--key-delay', '0', text]), + (IS_X11 or IS_WAYLAND, ['wtype', text]), + (IS_MACOS, lambda: _type_macos(text)), + (IS_WIDOWS, lambda: _type_windos(text)) + ) + once = False + res = -1 # 0/Falsy = success + for cond, args in TYPEOUT_COMMANDS: + if cond: + if not once: + logger.info('Typing out text: %s', text) + once = True + + if callable(args): + res = args() + break + + if not shutil.which(args[0]): + logger.warning('Platform "%s" but command "%s" unavailable', PLATFORM, args[0]) + continue + logger.info('Executing: %s', args) + proc = subprocess.run(args) + res = proc.returncode + if not res: + break + logger.warning('Subprocess exit code/error: %s', res) + else: + logger.error('No command applies. Please see above for additional warnings.') + + if res == 0 and not force_clipboard: + # Supposedly we're done + return + + # Otherwise + _copy_to_clipboard(text) + + +def _copy_to_clipboard(text): + # Copy the emoji to global system clipboard + QApplication.instance().clipboard().setText(text, QClipboard.Mode.Clipboard) + assert QApplication.instance().clipboard().text(QClipboard.Mode.Clipboard) == text + logger.info('Text copied to clipboard: %s', text) + + # And raise a desktop notification about it ... +""" + # Export emoji to icon for the notification + qicon, icon_fname = None, None + try: + text_is_emoji = len(text) == 1 + if text_is_emoji: + def _emoji_to_qicon(text, filename): + pixmap = QPixmap(128, 128) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + from .tabs import EmojiTab + font = QFont(EmojiTab.Delegate.ICON_FONT) + font.setPixelSize(int(round(pixmap.rect().height() * .95))) + painter.setFont(font) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, text) + del painter # https://stackoverflow.com/a/59605570/1090455 + res = pixmap.toImage().save(filename) + if not res: + logger.warning('Failed to save emoji icon to "%s"', filename) + return QIcon(pixmap) + + with NamedTemporaryFile(prefix=QApplication.instance().applicationName() + '-', + suffix='.png', delete=False) as fd: + qicon = _emoji_to_qicon(text, fd.name) + icon_fname = fd.name + + notification_msg = [QApplication.instance().applicationName(), + f'Text {text!r} copied to clipboard.'] + if shutil.which('notify-send'): + # XDG notification looks better than QSystemTrayIcon message, so try it first + subprocess.run(['notify-send', + '--expire-time', '5000', + '--icon', icon_fname or 'info', + '--urgency', 'low', + *notification_msg]) + elif QSystemTrayIcon.isSystemTrayAvailable(): + from efck.gui import ICON_DIR + + tray = QSystemTrayIcon(QIcon(QPixmap(str(ICON_DIR / 'awesome-emoji.png'))), + QApplication.instance(), visible=True) + if qicon: + notification_msg.append(qicon) # FIXME: Why isn't qicon shown in the tooltip??? + # notification_msg.append(QSystemTrayIcon.Information) # Yet this works + tray.showMessage(*notification_msg, msecs=5000) + else: + logger.warning('No desktop notification system (notify-send) or systray detected') + finally: + if icon_fname: + os.remove(icon_fname) +""" + +def _type_macos(text): + # https://apple.stackexchange.com/questions/171709/applescript-get-active-application + # https://stackoverflow.com/questions/41673019/insert-emoji-into-focused-text-input + # https://stackoverflow.com/questions/60385810/inserting-chinese-characters-in-applescript + # https://apple.stackexchange.com/questions/288536/is-it-possible-to-keystroke-special-characters-in-applescript + _OSASCRIPT = ''' +set prev_contents to the clipboard +set the clipboard to "{}" +tell application "System Events" + keystroke "v" using command down + key code 124 -- right arrow clears the selection + delay 0.05 +end tell +set the clipboard to prev_contents +''' + text = text.replace('"', '\\"') + proc = subprocess.run(['sh', '-c', f"sleep .2 && nohup osascript -e '{_OSASCRIPT.format(text)}' & disown"]) + return proc.returncode + + +def _type_windos(text): + import ctypes + from ctypes.wintypes import DWORD, LONG, WORD, PULONG + + # God help us! + + # Adapted from: + # https://github.com/Drov3r/Forza/blob/b3e489c1447f2fdc46081e053008aaa1ab77a12a/control.py#L63 + # https://stackoverflow.com/questions/22291282/using-sendinput-to-send-unicode-characters-beyond-uffff + + class KeyboardInput(ctypes.Structure): + _fields_ = [("wVk", WORD), + ("wScan", WORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", PULONG)] + + class HardwareInput(ctypes.Structure): + _fields_ = [("uMsg", DWORD), + ("wParamL", WORD), + ("wParamH", WORD)] + + class MouseInput(ctypes.Structure): + _fields_ = [("dx", LONG), + ("dy", LONG), + ("mouseData", DWORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", PULONG)] + + class Input_I(ctypes.Union): + _fields_ = [("ki", KeyboardInput), + ("mi", MouseInput), + ("hi", HardwareInput)] + + class Input(ctypes.Structure): + _fields_ = [("type", DWORD), + ("ii", Input_I)] + + def as_wchars(ch): + assert len(ch) == 1, (ch, len(ch)) + bytes = ch.encode('utf-16be') + while bytes: + yield int.from_bytes(bytes[:2], 'big') + bytes = bytes[2:] + + TYPE_KEYBOARD_INPUT = 1 + KEYEVENTF_UNICODE = 0x4 + KEYEVENTF_KEYUP = 0x2 + KEYEVENTF_KEYDOWN = 0 + + inputs = [] + for ch in text: + for wch in as_wchars(ch): + for flag in (KEYEVENTF_KEYDOWN, + KEYEVENTF_KEYUP): + input = Input(TYPE_KEYBOARD_INPUT) + # XXX: Assuming ctypes.Structure is memset to 0 at initialization? + input.ii.ki.wScan = WORD(wch) + input.ii.ki.dwFlags = KEYEVENTF_UNICODE | flag + inputs.append(input) + + inputs = (Input * len(inputs))(*inputs) + n = ctypes.windll.user32.SendInput(len(inputs), inputs, ctypes.sizeof(Input)) + assert n == len(inputs) + return 0 diff --git a/python3-pkg-efck/tabs/_options.py b/python3-pkg-efck/tabs/_options.py new file mode 100644 index 0000000..63bfeaa --- /dev/null +++ b/python3-pkg-efck/tabs/_options.py @@ -0,0 +1,90 @@ +import copy +import logging + +from ..qt import * + + +logger = logging.getLogger(__name__) + + +class OptionsTab(QWidget): + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.setLayout(QVBoxLayout(self)) + + from ..config import config_state + + self._initial_config = copy.deepcopy(config_state) + + def zoom_changed(value): + nonlocal ONE_TICK_IN_PCT, slider_label, change_zoom_timer + slider_label.setText(f'Zoom: {value * ONE_TICK_IN_PCT:3d}%') + change_zoom_timer.start() + + def _change_zoom(): + nonlocal ONE_TICK_IN_PCT, config_state, zoom_slider, self + config_state['zoom'] = zoom = zoom_slider.value() * ONE_TICK_IN_PCT + logger.info('Set zoom: %f', zoom) + + change_zoom_timer = QTimer( + parent=self, + singleShot=True, + timeout=_change_zoom, + interval=200) + + main_box = QGroupBox(self) + self.layout().addWidget(main_box) + main_box.setLayout(QVBoxLayout(main_box)) + force_clipboard_cb = QCheckBox( + 'Force &clipboard', + parent=self, + 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.setChecked(config_state.__getitem__('force_clipboard')) + force_clipboard_cb.stateChanged.connect( + lambda state: config_state.__setitem__('force_clipboard', bool(state))) + main_box.layout().addWidget(force_clipboard_cb) + + box = QWidget(self) + main_box.layout().addWidget(box) + box.setLayout(QHBoxLayout(box)) + box.layout().setContentsMargins(0, 0, 0, 0) + ONE_TICK_IN_PCT = 5 + zoom_slider = QSlider( + parent=self, + orientation=Qt.Orientation.Horizontal, + tickPosition=QSlider.TickPosition.TicksBothSides, + minimum=70 // ONE_TICK_IN_PCT, + maximum=200 // ONE_TICK_IN_PCT, + pageStep=30 // ONE_TICK_IN_PCT, + singleStep=10 // ONE_TICK_IN_PCT, + tickInterval=10 // ONE_TICK_IN_PCT, + value=config_state['zoom'] // ONE_TICK_IN_PCT, + ) + zoom_slider.valueChanged.connect(zoom_changed) + slider_label = QLabel(f'Z&oom: {zoom_slider.value() * ONE_TICK_IN_PCT:3d}%') + slider_label.setBuddy(zoom_slider) + box.layout().addWidget(slider_label) + box.layout().addWidget(zoom_slider) + + def add_section(self, name, widget: QWidget): + box = QGroupBox(name.replace('&', ''), self) + widget.setParent(box) + box.setLayout(QVBoxLayout(box)) + box.layout().setContentsMargins(0, 0, 0, 0) + box.layout().addWidget(widget) + self.layout().addWidget(box) + + def save_dirty(self, exiting=False) -> bool: + """Returns True if config had changed and emoji need reloading""" + from ..config import dump_config, config_state + logger.debug('Saving config state if changed') + if config_state != self._initial_config: + dump_config() + self._initial_config = copy.deepcopy(config_state) + + if not exiting: + for tab in self.nativeParentWidget().tabs: + tab.init_delegate(config=config_state.get(tab.__class__.__name__), + zoom=config_state.get('zoom', 100) / 100) + return True