Create Your Own Text Expander with Python

I have been using text expansion as a powerful productivity tool to save countless keystrokes and hours of typing time. I was used to using a-Tex and AutoHotKey on Windows, but when I switched to Linux, I searched for an alternative. I tried AutoKey and Espanso but couldn't find a suitable option. Finally, I decided to create my own text expander.

In this post, I will walk you through the creation of Auto Text Expander for your Ubuntu 2024.04 LTS machine.

What is a Text Expander?

A text expander is a tool that automatically replaces shortcuts (triggers) with longer pieces of text (expansions). For example, by typing ";; email," the tool can automatically expand it to your full email address. This tool can be incredibly useful for frequently typed phrases, addresses, code snippets, or any text you find yourself repeating often.

How Our Text Expander Works

Our custom text expander uses Python to:

  1. Listen for keyboard input

  2. Match typed text against predefined triggers

  3. Replace triggers with their corresponding expansions

The script runs in the background as a system service, ensuring it's always available when you need it.

Setting Up the Text Expander

Prerequisites

  • Python 3

  • pynput library (install with pip install pynput)

Step 1: Create the Python Script

Create a new file named text_expander.py in your project directory (e.g., ~/dev/AutoExpander/text_expander.py). Paste the following code:

### Step 1:

import time
import threading
import logging
import json
import os
from pynput import keyboard

# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# Buffer to store typed characters
buffer = ""
# Create a keyboard controller instance globally
controller = keyboard.Controller()

# Default expansions
DEFAULT_EXPANSIONS = {
    ";;email": "meeran@duck.com",
    }

TEXT_EXPANSION_CONFIG_FILE = "~/dotfiles/local/text-expander-config.json"

# Load or create expansions JSON file
def load_or_create_expansions():
    json_path = os.path.expanduser(TEXT_EXPANSION_CONFIG_FILE)
    directory = os.path.dirname(json_path)

    try:
        # Create directory if it doesn't exist
        os.makedirs(directory, exist_ok=True)

        # Try to load existing file
        if os.path.exists(json_path):
            with open(json_path, 'r') as file:
                return json.load(file)
        else:
            # Create new file with default expansions
            with open(json_path, 'w') as file:
                json.dump(DEFAULT_EXPANSIONS, file, indent=4)
            logging.info(f"Created new expansions file at {json_path}")
            return DEFAULT_EXPANSIONS
    except Exception as e:
        logging.error(f"Error loading or creating expansions: {str(e)}")
        return {}

# Load expansions
expansions = load_or_create_expansions()

def type_string(string):
    for line in string.split('\n'):
        for char in line:
            if char == '@':
                controller.press(keyboard.Key.shift)
                controller.press('2')
                controller.release('2')
                controller.release(keyboard.Key.shift)
            else:
                controller.press(char)
                controller.release(char)
            time.sleep(0.01)  # Small delay between keystrokes
        controller.press(keyboard.Key.enter)
        controller.release(keyboard.Key.enter)

def on_press(key):
    global buffer
    try:
        # Check if the key has a character value
        char = key.char
        buffer += char
        # Check if the buffer ends with any of the triggers
        for trigger, expansion in expansions.items():
            if buffer.endswith(trigger):
                # Delete the trigger characters using backspace
                for _ in range(len(trigger)):
                    controller.press(keyboard.Key.backspace)
                    controller.release(keyboard.Key.backspace)
                # Type the expansion
                type_string(expansion)
                # Clear the buffer
                buffer = ""
                break
        # Keep buffer size reasonable (last 20 characters)
        buffer = buffer[-20:]
    except AttributeError:
        # Handle non-character keys (shift, ctrl, etc.) without affecting buffer
        pass
    except Exception as e:
        logging.error(f"Error in on_press: {str(e)}")

def run_listener():
    try:
        # Start listening for keypresses
        with keyboard.Listener(on_press=on_press) as listener:
            logging.info("Keyboard listener started")
            listener.join()
    except Exception as e:
        logging.error(f"Error in run_listener: {str(e)}")

# Running the listener in a background thread
listener_thread = threading.Thread(target=run_listener, daemon=True)
listener_thread.start()

# Main thread can perform other tasks or just sleep
try:
    logging.info("Auto Expander script started")
    while True:
        time.sleep(1)  # Keep the main thread alive
except KeyboardInterrupt:
    logging.info("Script interrupted by user.")
except Exception as e:
    logging.error(f"Unexpected error in main thread: {str(e)}")

### Step 2: Create a System Service

To run the text expander as a system service, create a new service file:

```bash
sudo nano /etc/systemd/system/text-expander.service

Add the following content (adjust paths as necessary):

[Unit]
Description=Text Expander Python Script
After=network.target

[Service]
ExecStart=/usr/bin/python3 /home/your_username/dev/AutoExpander/text_expander.py
WorkingDirectory=/home/your_username/dev/AutoExpander
StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartSec=5
User=your_username
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/your_username/.Xauthority

[Install]
WantedBy=default.target

Replace your_username with your actual username.

Step 3: Enable and Start the Service

Run these commands to enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable text-expander
sudo systemctl start text-expander

Configuring Your Text Expansions

The text expander uses a JSON file to store your expansions. By default, it's located at ~/dotfiles/local/text-expansion.json. If the file doesn't exist, the script will create it with some default expansions.

To add or modify expansions, edit this JSON file:

nano ~/dev/text-expansion.json

The file structure is simple:

{
    ";;email": "your.email@example.com",
    ";;phone": "123-456-7890",
    ";;addr": "123 Main St, City, State, ZIP"
}

Add new triggers and expansions as needed. You can use any character as prefix instead of ";;"

Making Changes

  1. Modify Expansions: Edit the text-expansion.json file and save your changes. Restart the service for changes to take effect:

     sudo systemctl restart text-expander
    
  2. Update the Script: If you make changes to the Python script, save the file and restart the service:

     sudo systemctl restart text-expander
    
  3. Check Status: To check if the service is running correctly:

     sudo systemctl status text-expander
    
  4. View Logs: To view the logs for troubleshooting:

     sudo journalctl -u text-expander -n 50 --no-pager
    

Conclusion

"With this custom text expander, you have a powerful and flexible tool to boost your productivity. It is easily customizable and runs smoothly as a system service. Remember, with great power comes great responsibility."