-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit cd98b4a
Showing
6 changed files
with
293 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
acro_replace*/ | ||
.idea/ | ||
venv/ | ||
acro_replace.exe* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
test,other | ||
teh,the | ||
adn,and |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
from functools import partial | ||
from os import startfile | ||
from os.path import exists | ||
from sys import argv | ||
|
||
from keyboard import unhook_all, write, _State, KEY_UP, all_modifiers, hook, _word_listeners | ||
from systrayicon import SysTrayIcon | ||
|
||
CONFIG = f'{argv[0]}.config' | ||
|
||
|
||
def open_config(_): | ||
if not exists(CONFIG): | ||
with open(CONFIG, 'w'): | ||
pass | ||
startfile(CONFIG) | ||
|
||
|
||
def load_config(_): | ||
def w(replace, event): | ||
write(replace + event.replace('space', ' ')) | ||
|
||
unhook_all() | ||
if not exists(CONFIG): | ||
with open(CONFIG, 'w'): | ||
pass | ||
with open(CONFIG, 'rt') as file: | ||
for pair in file: | ||
source, replace = pair.replace('\n', '').split(',', 1) | ||
replace = '\b' * (len(source) + 1) + replace | ||
add_word_listener(source, partial(w, replace), ['space', '.', ',', '?', '!', '-', '_', ')', ':', ';', '"']) | ||
|
||
|
||
def main(): | ||
load_config(None) | ||
SysTrayIcon(None, 'Text Replace', (('Open Config', None, open_config), ('Reload Config', None, load_config),)) | ||
|
||
|
||
def add_word_listener(word, callback, triggers=['space'], match_suffix=False, timeout=2): | ||
""" | ||
Invokes a callback every time a sequence of characters is typed (e.g. 'pet') and followed by a trigger key (e.g. space). Modifiers (e.g. alt, ctrl, shift) are ignored. | ||
- `word` the typed text to be matched. E.g. 'pet'. | ||
- `callback` is an argument-less function to be invoked each time the word is typed. | ||
- `triggers` is the list of keys that will cause a match to be checked. If the user presses some key that is not a character (len>1) and not in triggers, the characters so far will be discarded. By default the trigger is only `space`. | ||
- `match_suffix` defines if endings of words should also be checked instead of only whole words. E.g. if true, typing 'carpet'+space will trigger the listener for 'pet'. Defaults to false, only whole words are checked. | ||
- `timeout` is the maximum number of seconds between typed characters before the current word is discarded. Defaults to 2 seconds. | ||
Returns the event handler created. To remove a word listener use `remove_word_listener(word)` or `remove_word_listener(handler)`. | ||
Note: all actions are performed on key down. Key up events are ignored. | ||
Note: word matches are **case sensitive**. | ||
""" | ||
state = _State() | ||
state.current = '' | ||
state.time = -1 | ||
|
||
def handler(event): | ||
name = event.name | ||
if event.event_type == KEY_UP or name in all_modifiers: | ||
return | ||
if timeout and event.time - state.time > timeout: | ||
state.current = '' | ||
state.time = event.time | ||
matched = state.current == word or (match_suffix and state.current.endswith(word)) | ||
if name in triggers and matched: | ||
callback(name) | ||
state.current = '' | ||
elif len(name) > 1: | ||
state.current = '' | ||
else: | ||
state.current += name | ||
|
||
hooked = hook(handler) | ||
|
||
def remove(): | ||
hooked() | ||
del _word_listeners[word] | ||
del _word_listeners[handler] | ||
del _word_listeners[remove] | ||
_word_listeners[word] = _word_listeners[handler] = _word_listeners[remove] = remove | ||
# TODO: allow multiple word listeners and removing them correctly. | ||
return remove | ||
|
||
|
||
if __name__ == '__main__': | ||
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
nuitka==0.6.16.2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
keyboard==0.13.5 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
from os.path import isfile | ||
from typing import Callable | ||
|
||
import win32con | ||
from win32api import GetSystemMetrics # package pywin32 | ||
from win32gui_struct import PackMENUITEMINFO | ||
|
||
try: | ||
import winxpgui as win32gui | ||
except ImportError: | ||
import win32gui | ||
|
||
|
||
class SysTrayIcon: | ||
'''TODO''' | ||
QUIT = 'QUIT' | ||
SPECIAL_ACTIONS = [QUIT] | ||
|
||
FIRST_ID = 1023 | ||
|
||
def __init__(self, icon: str, hover_text: str, menu_options: tuple[tuple[str, str, Callable[['SysTrayIcon'], None]]] = None, on_quit: Callable[['SysTrayIcon'], None] = None, default_menu_index: int = 0, window_class_name: str = 'SysTrayIconPy'): | ||
|
||
self.icon = icon | ||
self.hover_text = hover_text | ||
self.on_quit = on_quit | ||
|
||
self._next_action_id = self.FIRST_ID | ||
self.menu_actions_by_id = set() | ||
self.menu_options = self._add_ids_to_menu_options(list(menu_options + (('Quit', None, self.QUIT),) if menu_options else (('Quit', None, self.QUIT),))) | ||
self.menu_actions_by_id = dict(self.menu_actions_by_id) | ||
del self._next_action_id | ||
|
||
self.default_menu_index = default_menu_index | ||
self.window_class_name = window_class_name | ||
|
||
# Register the Window class. | ||
window_class = win32gui.WNDCLASS() | ||
hinst = window_class.hInstance = win32gui.GetModuleHandle(None) | ||
window_class.lpszClassName = self.window_class_name | ||
window_class.style = win32con.CS_VREDRAW | win32con.CS_HREDRAW | ||
window_class.hCursor = win32gui.LoadCursor(0, win32con.IDC_ARROW) | ||
window_class.hbrBackground = win32con.COLOR_WINDOW | ||
window_class.lpfnWndProc = {win32gui.RegisterWindowMessage("TaskbarCreated"): self.restart, | ||
win32con.WM_DESTROY: self.destroy, | ||
win32con.WM_COMMAND: self.command, | ||
win32con.WM_USER + 20: self.notify} # could also specify a wndproc. | ||
# Create the Window. | ||
self.hwnd = win32gui.CreateWindow(win32gui.RegisterClass(window_class), self.window_class_name, win32con.WS_OVERLAPPED | win32con.WS_SYSMENU, 0, 0, win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, 0, 0, hinst, None) | ||
win32gui.UpdateWindow(self.hwnd) | ||
self.notify_id = None | ||
self.refresh_icon() | ||
|
||
win32gui.PumpMessages() | ||
|
||
def _add_ids_to_menu_options(self, menu_options: list[tuple[str, str, Callable[['SysTrayIcon'], None]]]) -> list[tuple[str, str, Callable[['SysTrayIcon'], None], int]]: | ||
result = [] | ||
for menu_option in menu_options: | ||
option_text, option_icon, option_action = menu_option | ||
if callable(option_action) or option_action in self.SPECIAL_ACTIONS: | ||
self.menu_actions_by_id.add((self._next_action_id, option_action)) | ||
result.append(menu_option + (self._next_action_id,)) | ||
elif non_string_iterable(option_action): | ||
result.append((option_text, option_icon, self._add_ids_to_menu_options(option_action), self._next_action_id)) | ||
else: | ||
print('Unknown item', option_text, option_icon, option_action) | ||
self._next_action_id += 1 | ||
return result | ||
|
||
def refresh_icon(self): | ||
# Try and find a custom icon | ||
hinst = win32gui.GetModuleHandle(None) | ||
if self.icon and isfile(self.icon): | ||
hicon = win32gui.LoadImage(hinst, self.icon, win32con.IMAGE_ICON, 0, 0, win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE) | ||
else: | ||
print("Can't find icon file - using default.") | ||
hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) | ||
|
||
message = win32gui.NIM_MODIFY if self.notify_id else win32gui.NIM_ADD | ||
self.notify_id = (self.hwnd, 0, win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP, win32con.WM_USER + 20, hicon, self.hover_text) | ||
win32gui.Shell_NotifyIcon(message, self.notify_id) | ||
|
||
def restart(self, hwnd, msg, wparam, lparam: int): | ||
self.refresh_icon() | ||
|
||
def destroy(self, hwnd, msg, wparam, lparam: int): | ||
if self.on_quit: | ||
self.on_quit(self) | ||
win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, (self.hwnd, 0)) | ||
win32gui.PostQuitMessage(0) # Terminate the app. | ||
|
||
def notify(self, hwnd, msg, wparam, lparam: int): | ||
if lparam == win32con.WM_LBUTTONDBLCLK: | ||
self.execute_menu_option(self.default_menu_index + self.FIRST_ID) | ||
elif lparam == win32con.WM_RBUTTONUP: | ||
self.show_menu() | ||
elif lparam == win32con.WM_LBUTTONUP: | ||
pass | ||
return True | ||
|
||
def show_menu(self): | ||
menu = win32gui.CreatePopupMenu() | ||
self.create_menu(menu, self.menu_options) | ||
# win32gui.SetMenuDefaultItem(menu, 1000, 0) | ||
|
||
pos = win32gui.GetCursorPos() | ||
# See http://msdn.microsoft.com/library/default.asp?url=/library/en-us/winui/menus_0hdi.asp | ||
win32gui.SetForegroundWindow(self.hwnd) | ||
win32gui.TrackPopupMenu(menu, win32con.TPM_LEFTALIGN, pos[0], pos[1], 0, self.hwnd, None) | ||
win32gui.PostMessage(self.hwnd, win32con.WM_NULL, 0, 0) | ||
|
||
def create_menu(self, menu, menu_options: list[tuple[str, str, Callable[['SysTrayIcon'], None], int]]): | ||
for option_text, option_icon, option_action, option_id in menu_options[::-1]: | ||
if option_icon: | ||
option_icon = self.prep_menu_icon(option_icon) | ||
|
||
if option_id in self.menu_actions_by_id: | ||
item, extras = PackMENUITEMINFO(text=option_text, hbmpItem=option_icon, wID=option_id) | ||
win32gui.InsertMenuItem(menu, 0, 1, item) | ||
else: | ||
submenu = win32gui.CreatePopupMenu() | ||
self.create_menu(submenu, option_action) | ||
item, extras = PackMENUITEMINFO(text=option_text, hbmpItem=option_icon, hSubMenu=submenu) | ||
win32gui.InsertMenuItem(menu, 0, 1, item) | ||
|
||
def prep_menu_icon(self, icon): | ||
# First load the icon. | ||
ico_x = GetSystemMetrics(win32con.SM_CXSMICON) | ||
ico_y = GetSystemMetrics(win32con.SM_CYSMICON) | ||
hicon = win32gui.LoadImage(0, icon, win32con.IMAGE_ICON, ico_x, ico_y, win32con.LR_LOADFROMFILE) | ||
|
||
hdcBitmap = win32gui.CreateCompatibleDC(0) | ||
hbm = win32gui.CreateCompatibleBitmap(win32gui.GetDC(0), ico_x, ico_y) | ||
hbmOld = win32gui.SelectObject(hdcBitmap, hbm) | ||
# Fill the background. | ||
win32gui.FillRect(hdcBitmap, (0, 0, 16, 16), win32gui.GetSysColorBrush(win32con.COLOR_MENU)) | ||
# unclear if brush needs to be feed. Best clue I can find is: "GetSysColorBrush returns a cached brush instead of allocating a new one." - implies no DeleteObject | ||
# draw the icon | ||
win32gui.DrawIconEx(hdcBitmap, 0, 0, hicon, ico_x, ico_y, 0, 0, win32con.DI_NORMAL) | ||
win32gui.SelectObject(hdcBitmap, hbmOld) | ||
win32gui.DeleteDC(hdcBitmap) | ||
|
||
return hbm | ||
|
||
def command(self, hwnd, msg, wparam, lparam: int): | ||
self.execute_menu_option(win32gui.LOWORD(wparam)) | ||
|
||
def execute_menu_option(self, id: int): | ||
menu_action = self.menu_actions_by_id[id] | ||
if menu_action == self.QUIT: | ||
win32gui.DestroyWindow(self.hwnd) | ||
else: | ||
menu_action(self) | ||
|
||
|
||
def non_string_iterable(obj): | ||
try: | ||
iter(obj) | ||
except TypeError: | ||
return False | ||
else: | ||
return not isinstance(obj, str) | ||
|
||
|
||
# Minimal self test. You'll need a bunch of ICO files in the current working directory in order for this to work... | ||
if __name__ == '__main__': | ||
import itertools, glob | ||
|
||
icons = itertools.cycle(glob.glob('*.ico')) | ||
hover_text = "SysTrayIcon.py Demo" | ||
|
||
|
||
def hello(sysTrayIcon): | ||
print("Hello World.") | ||
|
||
|
||
def simon(sysTrayIcon): | ||
print("Hello Simon.") | ||
|
||
|
||
def switch_icon(sysTrayIcon): | ||
sysTrayIcon.icon = next(icons) | ||
sysTrayIcon.refresh_icon() | ||
|
||
|
||
menu_options = (('Say Hello', next(icons), hello), | ||
('Switch Icon', None, switch_icon), | ||
('A sub-menu', next(icons), (('Say Hello to Simon', next(icons), simon), | ||
('Switch Icon', next(icons), switch_icon), | ||
)) | ||
) | ||
|
||
|
||
def bye(sysTrayIcon): | ||
print('Bye, then.') | ||
|
||
|
||
SysTrayIcon(next(icons), hover_text, menu_options, on_quit=bye, default_menu_index=1) |