├── 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 |
10 |
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 |
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 |
39 |
40 | Congratulations! now, you can run the script by double-clicking the alias on the desktop.
41 |
42 |
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 |
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 |
--------------------------------------------------------------------------------