├── README.md ├── conversion_chart_ABC_Hebrew.json ├── icon.png ├── language_config.json ├── language_switcher.py ├── requirements.txt ├── run.command ├── typing_fixer.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | # Auto language switcher for MacOS 2 | 3 | no more need to manually change the language when switching between apps, or re-write mistyped text! 4 | 5 | this repo will automatically change the keyboard input language based on the specific app preferences, and fix mistypes. 6 | 7 | 8 | 9 | image 10 | image 11 | 12 | - ment to work if you have 2 languages set on your Mac (the swap is equivalent to '^ + space'). 13 | 14 | ### Instructions: 15 | 1. in the 'run.command' change the folliwing: 16 | 17 | * change the absolute path for the 'language_switcher.py'. 18 | 19 | * optional: the fix activation sequance (default '000'). 20 | 21 | 2. Change the language preferences in the 'language_config.json' file for each app to suit your needs. 22 | 3. create an 'conversion_chart__.json' in the same format for your language (its a somewhat manual process, you can use chatGPT to speef it up). in this repo, MAIN_LAYOUT='ABC' (thats the layout name for English) and SECONDARY_LAYOUT='Hebrew', these names need to match the names for your 'language_config.json' 23 | 4. open terminal in project dir and run the command: 24 | 25 | chmod +x run.command 26 | 27 | 5. Right-click the run.command file and click "Make Alias". 28 | 29 | image 30 | 31 | To make an "app-like" desktop executable (optional): 32 | 33 | 6. Move the alias to the Desktop (or other wanted location). 34 | 7. Copy to clipboard the icon.png image. 35 | 8. Right-click the alias and click "Get Info". 36 | 9. Click the icon in the top left corner of the "Get Info" window and paste the icon.png image. 37 | 38 | image 39 | 40 | Congratulations! now, you can run the script by double-clicking the alias on the desktop. 41 | 42 | image 43 | 44 | On running, a terminal window will open and the script will run in the background. you can see when language 45 | is changed by the prints in the terminal. 46 | 47 | image 48 | 49 | to kill the sctipt simply terminate the terminal window. 50 | 51 | 52 | -------------------------------------------------------------------------------- /conversion_chart_ABC_Hebrew.json: -------------------------------------------------------------------------------- 1 | { 2 | "ABC": { 3 | "q": "/", 4 | "w": "׳", 5 | "e": "ק", 6 | "r": "ר", 7 | "t": "א", 8 | "y": "ט", 9 | "u": "ו", 10 | "i": "ן", 11 | "o": "ם", 12 | "p": "פ", 13 | "a": "ש", 14 | "s": "ד", 15 | "d": "ג", 16 | "f": "כ", 17 | "g": "ע", 18 | "h": "י", 19 | "j": "ח", 20 | "k": "ל", 21 | "l": "ך", 22 | ";": "ף", 23 | "'": ",", 24 | "`": ";", 25 | "z": "ז", 26 | "x": "ס", 27 | "c": "ב", 28 | "v": "ה", 29 | "b": "נ", 30 | "n": "מ", 31 | "m": "צ", 32 | ",": "ת", 33 | ".": "ץ", 34 | "/": "." 35 | }, 36 | "Hebrew": { 37 | "/": "q", 38 | "׳": "w", 39 | "ק": "e", 40 | "ר": "r", 41 | "א": "t", 42 | "ט": "y", 43 | "ו": "u", 44 | "ן": "i", 45 | "ם": "o", 46 | "פ": "p", 47 | "ש": "a", 48 | "ד": "s", 49 | "ג": "d", 50 | "כ": "f", 51 | "ע": "g", 52 | "י": "h", 53 | "ח": "j", 54 | "ל": "k", 55 | "ך": "l", 56 | "ף": ";", 57 | ",": "'", 58 | ";": "`", 59 | "ז": "z", 60 | "ס": "x", 61 | "ב": "c", 62 | "ה": "v", 63 | "נ": "b", 64 | "מ": "n", 65 | "צ": "m", 66 | "ת": ",", 67 | "ץ": ".", 68 | ".": "/" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsafShul/auto_language_switcher_MacOS/e1da5f6eb72ae05475696f47c75affa042eb81da/icon.png -------------------------------------------------------------------------------- /language_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pycharm": "ABC", 3 | "TextEdit": "ABC", 4 | "Safari": "ABC", 5 | "Terminal": "ABC", 6 | "Microsoft Word": "Hebrew", 7 | "Microsoft PowerPoint": "Hebrew", 8 | "Keynote": "ABC", 9 | "Messages": "Hebrew", 10 | "Calendar": "ABC", 11 | "Contacts": "Hebrew", 12 | "Notes": "Hebrew", 13 | "Preview": "ABC", 14 | "Apple Music": "ABC", 15 | "App Store": "ABC", 16 | "System PreferABCces": "ABC", 17 | "Finder": "ABC", 18 | "WhatsApp": "Hebrew", 19 | "Telegram": "Hebrew", 20 | "Zoom": "Hebrew", 21 | "Google Chrome": "ABC" 22 | } 23 | -------------------------------------------------------------------------------- /language_switcher.py: -------------------------------------------------------------------------------- 1 | # imports: 2 | from threading import Lock 3 | from pynput import keyboard, mouse 4 | 5 | from utils import * 6 | from typing_fixer import register_key, reset_keys 7 | 8 | prev_active_window = None 9 | prev_active_window_lock = Lock() 10 | config = load_json(CONFIG_JSON_PATH) 11 | 12 | 13 | # functions: 14 | def change_keyboard_language(language_config: dict, active_window_title: str): 15 | """ 16 | Change the keyboard layout to the wanted layout. 17 | :param language_config: The language config dict. 18 | :param active_window_title: string of the active window title. 19 | :return: None 20 | """ 21 | # Get the wanted layout: 22 | wanted_layout = language_config.get(active_window_title) 23 | 24 | # Check the current keyboard layout: 25 | current_layout = get_current_layout() 26 | 27 | # If the wanted layout is not the current layout, change it: 28 | if wanted_layout and (wanted_layout != current_layout): 29 | print(f'active_window changed to: {active_window_title} -> Changing keyboard layout to: "{wanted_layout}"...') 30 | # Change the keyboard layout to English 31 | subprocess.call(CHANGE_LANGUAGE_SCRIPT, shell=True) 32 | else: 33 | print(f'active_window changed to: {active_window_title} -> Keyboard layout is already correct.') 34 | 35 | 36 | def window_change(x, y, button, pressed): 37 | """ 38 | Change the keyboard layout when the active window changes, also reset the keys_pressed list. 39 | :return: None 40 | """ 41 | global prev_active_window, prev_active_window_lock, config 42 | active_window = run_script(GET_ACTIVE_WINDOW_SCRIPT) 43 | if (not prev_active_window) or (active_window != prev_active_window): 44 | change_keyboard_language(config, active_window) 45 | with prev_active_window_lock: 46 | prev_active_window = active_window 47 | 48 | reset_keys(x, y, button, pressed) 49 | 50 | 51 | # main: 52 | if __name__ == '__main__': 53 | start_message() 54 | with keyboard.Listener(on_press=register_key) as k_listener: 55 | with mouse.Listener(on_click=window_change) as m_listener: 56 | k_listener.join() 57 | m_listener.join() 58 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pynput~=1.7.6 -------------------------------------------------------------------------------- /run.command: -------------------------------------------------------------------------------- 1 | # stop sequence = '888' (you can change it, but it needs to match the primary language in the language_config.json file) 2 | # change here the absolute path to the language_switcher.py file: 3 | clear 4 | python /Users/asafshul/Documents/projects.nosync/auto_language_switcher_MacOS/language_switcher.py -------------------------------------------------------------------------------- /typing_fixer.py: -------------------------------------------------------------------------------- 1 | # imports: 2 | import time 3 | from threading import Lock 4 | 5 | from pynput import keyboard, mouse 6 | from utils import * 7 | 8 | # threading globals: 9 | keys_pressed = [] 10 | listen = True 11 | listen_lock = Lock() 12 | keys_pressed_lock = Lock() 13 | keyboard_controller = keyboard.Controller() 14 | 15 | 16 | # functions: 17 | def re_write_stack() -> None: 18 | """ 19 | Re-write the keys_pressed stack with the correct typing. 20 | :return: None 21 | """ 22 | print('Re-write activated: fixing typing in wrong language...') 23 | global keys_pressed, keys_pressed_lock, listen_lock, listen 24 | 25 | # Get the fixed keys: 26 | fixed_keys_pressed = convert_typing(get_current_layout(), keys_pressed[:-ACTIVATION_SIZE]) 27 | run_fixed_keys = ([keyboard.Key.backspace] * len(keys_pressed)) + fixed_keys_pressed 28 | 29 | # Reset the keys_pressed list and change the keyboard layout: 30 | with keys_pressed_lock: 31 | keys_pressed = [] 32 | 33 | subprocess.call(CHANGE_LANGUAGE_SCRIPT, shell=True) 34 | 35 | # Re-write the keys: 36 | for key in run_fixed_keys: 37 | keyboard_controller.press(key) 38 | keyboard_controller.release(key) 39 | time.sleep(TYPING_DILAY) 40 | 41 | with listen_lock: 42 | listen = True 43 | 44 | 45 | def register_key(key: keyboard.Key) -> None: 46 | """ 47 | Register the pressed key to the stack. 48 | :param key: pynput.keyboard.Key 49 | :return: None 50 | """ 51 | global keys_pressed, listen_lock, listen 52 | if not listen: 53 | return 54 | 55 | # Add the key to the keys_pressed list and handle special keys: 56 | with keys_pressed_lock: 57 | try: 58 | char = key.char 59 | if char: 60 | keys_pressed.append(key.char) 61 | 62 | except AttributeError: 63 | # special keys like shift or enter don't have a char attribute 64 | if key == keyboard.Key.space: 65 | keys_pressed.append(keyboard.Key.space) 66 | elif key == keyboard.Key.backspace and keys_pressed: 67 | keys_pressed.pop() 68 | else: 69 | keys_pressed = [] 70 | 71 | # Check if the activation keys were pressed, if so, re-write the stack: 72 | if keys_pressed and (len(keys_pressed) > ACTIVATION_SIZE): 73 | if (keys_pressed[-ACTIVATION_SIZE:] == PRIME_TYPING_CHANGE_ACTIVATION) or \ 74 | (keys_pressed[-ACTIVATION_SIZE:] == SECONDARY_TYPING_CHANGE_ACTIVATION): 75 | with listen_lock: 76 | listen = False 77 | 78 | re_write_stack() 79 | 80 | 81 | def reset_keys(x, y, button, pressed) -> None: 82 | global keys_pressed, keys_pressed_lock 83 | """ 84 | Empties the keys stack. 85 | """ 86 | with keys_pressed_lock: 87 | keys_pressed = [] 88 | 89 | 90 | # main: 91 | if __name__ == '__main__': 92 | # create threads for the keyboard and mouse listeners 93 | with keyboard.Listener(on_press=register_key) as k_listener: 94 | with mouse.Listener(on_click=reset_keys) as m_listener: 95 | k_listener.join() 96 | m_listener.join() 97 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # imports: 2 | import os 3 | import sys 4 | import copy 5 | import json 6 | import subprocess 7 | 8 | # constants: 9 | TITLE = """ 10 | ███████╗██╗ ██╗██╗████████╗ ██████╗██╗ ██╗███████╗██████╗ 11 | ██╔════╝██║ ██║██║╚══██╔══╝██╔════╝██║ ██║██╔════╝██╔══██╗ 12 | ███████╗██║ █╗ ██║██║ ██║ ██║ ███████║█████╗ ██████╔╝ 13 | ╚════██║██║███╗██║██║ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗ 14 | ███████║╚███╔███╔╝██║ ██║ ╚██████╗██║ ██║███████╗██║ ██║ 15 | ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ 16 | """ 17 | 18 | BUFFER_SIZE = 32 19 | TYPING_DILAY = 0.05 20 | 21 | file_path = sys.argv[0] 22 | WORK_DIR = file_path[:len(file_path) - file_path[::-1].find('/')] 23 | 24 | with open(os.path.join(WORK_DIR, 'run.command')) as f: 25 | lines = f.readlines() 26 | 27 | PRIME_TYPING_CHANGE_ACTIVATION = [c for c in \ 28 | lines[0][lines[0].find("'") + 1: len(lines[0]) - lines[0][::-1].find("'") - 1]] 29 | 30 | ACTIVATION_SIZE = len(PRIME_TYPING_CHANGE_ACTIVATION) 31 | 32 | 33 | CONFIG_JSON_PATH = os.path.join(WORK_DIR, 'language_config.json') 34 | CONVERSION_JSON_PATH = os.path.join(WORK_DIR, 'conversion_chart_ABC_Hebrew.json') 35 | 36 | LAYOUT_RETURN_PREFIX = '"KeyboardLayout Name" = ' 37 | LAYOUT_RETURN_POSTFIX = ';\n }\n)' 38 | 39 | # apple scripts: 40 | CHANGE_LANGUAGE_SCRIPT = "osascript -e 'tell application \"System Events\" to tell process \"SystemUIServer\" to " \ 41 | "keystroke space using {control down}' " 42 | 43 | GET_CURRENT_LAYOUT_SCRIPT = "defaults read ~/Library/Preferences/com.apple.HIToolbox.plist AppleSelectedInputSources" 44 | 45 | GET_ACTIVE_WINDOW_SCRIPT = "osascript -e 'tell app \"System Events\" to get name of first process whose frontmost is " \ 46 | "true' " 47 | 48 | 49 | # functions: 50 | def load_json(path): 51 | """ 52 | Load a json file. 53 | :param path: the path to the json file. 54 | :return: the json file as a dict. 55 | """ 56 | with open(path) as f: 57 | return json.load(f) 58 | 59 | 60 | MAIN_LAYOUT, SECONDARY_LAYOUT = list(load_json(CONVERSION_JSON_PATH).keys()) 61 | CONVERSION_PAIRS = load_json(CONVERSION_JSON_PATH) 62 | 63 | 64 | def run_script(script): 65 | """ 66 | Run a script and return the output. 67 | :param script: applescript string to run. 68 | :return: the output from the activation. 69 | """ 70 | return subprocess.check_output(script, shell=True).decode('utf-8').strip() 71 | 72 | 73 | def get_current_layout(): 74 | """ 75 | Get the current keyboard layout. 76 | :return: the current keyboard layout. 77 | """ 78 | 79 | current_layout = run_script(GET_CURRENT_LAYOUT_SCRIPT) 80 | return current_layout[current_layout.find(LAYOUT_RETURN_PREFIX) + len(LAYOUT_RETURN_PREFIX): 81 | current_layout.find(LAYOUT_RETURN_POSTFIX)] 82 | 83 | 84 | def convert_typing(layout, stack): 85 | """ 86 | Convert the typing from one language to another. 87 | :param stack: text to convert as a list. 88 | :param layout: current keyboard language. 89 | :return: the converted text. 90 | """ 91 | if layout != MAIN_LAYOUT and layout != SECONDARY_LAYOUT: 92 | return copy.deepcopy(stack) 93 | else: 94 | return [CONVERSION_PAIRS[layout][key] if key in CONVERSION_PAIRS[layout] else key 95 | for key in stack] 96 | 97 | 98 | SECONDARY_TYPING_CHANGE_ACTIVATION = convert_typing(MAIN_LAYOUT, PRIME_TYPING_CHANGE_ACTIVATION) 99 | 100 | 101 | def start_message(): 102 | print(TITLE) 103 | print('=' * 65) 104 | print(' ' * 25, 'Activities log:') 105 | print('=' * 65) 106 | print() 107 | --------------------------------------------------------------------------------