How to Make a Markdown Editor using Tkinter in Python

Learn how you can create a markdown editor using the Tkinter library and regular expressions in Python.
  · 9 min read · Updated aug 2022 · GUI Programming

Kickstart your coding journey with our Python Code Assistant. An AI-powered assistant that's always ready to help. Don't miss out!

Idea

In this tutorial, we will make a markdown editor using the GUI Library Tkinter and regular expressions. We will make it so there is a text area on the left where the user writes and a display area on the right where the markdown is slightly styled. To do this, we need to learn about regular expressions and the tkinter's Text widget and how to style parts of these text widgets.

Let's get started!

Imports

As always, we import some modules and libraries to help us. First, we get everything from Tkinter by using the *. With Tkinter, we will build our Graphical User Interface (GUI). By using this syntax with the asterisk (*) we ensure that we can use the constants defined by Tkinter.

Because windows generated by Tkinter often don't look good, we also get ctypes so we can enable high Dots Per Inch (DPI). You see this in action in the last line of the code block below.

Lastly, we get re which we'll use to find patterns in our text. So we will also learn a little bit about regular expressions:

# Imports
from tkinter import *
import ctypes
import re

# Increas Dots Per inch so it looks sharper
ctypes.windll.shcore.SetProcessDpiAwareness(True)

Setup

Now we get to setting up Tkinter. We start by making a new Tk object and saving it to a variable. Then we set the title, which will appear in the top left of our window. We then also set the window dimensions with the geometry() function. Lastly, we set the font globally to be courier in size 15:

# Setup
root = Tk()
root.title('Markdown Editor')
root.geometry('1000x600')

# Setting the Font globaly
root.option_add('*Font', 'Courier 15')

Styles

Now we make many variables representing style settings for our little program. We use a function to transform these RGB values to hex that we create ourselves. We later see how it is made up. The width is used in the text widget later:

# Style Setup
editorBackground = rgbToHex((40, 40, 40))
editorTextColor = rgbToHex((230, 230, 230))
displayBackground = rgbToHex((60, 60, 60))
displayTextColor = rgbToHex((200, 200, 200))

caretColor = rgbToHex((255, 255, 255))

# Width of the Textareas in characters
width = 10

We also define some fonts. For the edit area, we set Courier, which is redundant, but we do it either way. We then define the display area font as Calibri; this may not work for you, so you change it to Arial or any other font installed on your computer. We also set the sizes and colors for headers from layers 1 to 3:

# Fonts
editorfontName = 'Courier'
displayFontName = 'Calibri'

# Font Sizes
normalSize = 15
h1Size = 40
h2Size = 30
h3Size = 20

# font Colors
h1Color = rgbToHex((240, 240, 240))
h2Color = rgbToHex((200, 200, 200))
h3Color = rgbToHex((160, 160, 160))

Regular Expressions

Now we also define a list of the replacements and stylings made in the display area. This is a list comprised of other lists. In these lists, the first item is the pattern that is searched. The second one is the name. The third is the font data which is a formatted string in our case.

The fourth is the color, and the last is the offset. Later we see what this means when we use this nested list. For now, we only enlargen headers with one, two, and three hashtags, and we make text surrounded with * bold:

# Replacements tell us were to insert tags with the font and colors given
replacements = [
    [
        '^#[a-zA-Z\s\d\?\!\.]+$',
        'Header 1',
        f'{displayFontName} {h1Size}', 
        h1Color,
        0
    ], [
        '^##[a-zA-Z\s\d\?\!\.]+$',
        'Header 2', 
        f'{displayFontName} {h2Size}',
        h2Color,
        0
    ], [
        '^###[a-zA-Z\s\d\?\!\.]+$', 
        'Header 3', 
        f'{displayFontName} {h3Size}', 
        h3Color,
        0        
    ], [
        '\*.+?\*', 
        'Bold', 
        f'{displayFontName} {normalSize} bold', 
        displayTextColor,
        2
    ],
]

Let's take a look at one of the expressions: ^#[a-zA-Z\s\d\?\!\.]+$ will match the largest header made with one # hashtag. The ^ means the beginning of the string and # means itself, [a-zA-Z\s\d\?\!\.] indicates any character, digit, whitespace, '?', . or !. We modify this group with + so it means one or more of this character. And in the end, we have a dollar sign that means the end of the string. We do it this way because markup can not span multiple lines divided by a \n.

Widgets

Now let's make some widgets. We start with the editor, that is just a Text. We give it the root element as a master. We set a height and a width that does not matter here. We set the background and foreground (text) color with our variables. We then put a hefty border and the relief to flat, resulting in nice padding for our text area. We also set the color of the caret with the insertbackground parameter. We then place the widget with pack:

# Making the Editor Area
editor = Text(
    root,
    height=5,
    width=width,
    bg=editorBackground,
    fg=editorTextColor,
    border=30,
    relief=FLAT,
    insertbackground=caretColor
)
editor.pack(expand=1, fill=BOTH, side=LEFT)

We bind the KeyRelease event to our changes() function that we'll define, highlighting the display area and updating the text. We then set the focus on the editor with focus_set(). We also insert some starting text into the editor:

# Bind <KeyReleas> so every change is registered
editor.bind('<KeyRelease>', changes)
editor.focus_set()

# Insert a starting text
editor.insert(INSERT, """#Heading 1

##Heading 2

###Heading 3

This is a *bold* move!


- Markdown Editor -

""")

We also make a Text Area for the Display. It is pretty similar to the editor area. But we set it to be disabled so the user cannot edit it:

# Making the Display Area
display = Text(
    root,
    height=5,
    width=width,
    bg=displayBackground,
    fg=displayTextColor,
    border=30,
    relief=FLAT,
    font=f"{displayFontName} {normalSize}",
)
display.pack(expand=1, fill=BOTH, side=LEFT)

# Disable the Display Area so the user cant write in it
# We will have to toggle it so we can insert text
display['state'] = DISABLED

Helper Functions

Now we define three functions. Keep in mind that we have to insert these definitions early in the code because we have used them in code before.

changes()

This function was bound to the KeyRelease event of the edit area, and it will handle changes, sync the two text areas and highlight certain parts of them.

In the function, we have a parameter called event that is not needed but have to define it because the event will pass itself to the function. Then we set the state of the display to normal so it can be edited. After that, we delete its content with its delete() method. Here 1.0 means the first letter in the first line and END means the end of the content.

def changes(event=None):
    display['state'] = NORMAL

    # Clear the Display Area
    display.delete(1.0, END)

Continuing, we get the editor's text and save it to a variable. We then also copy this value to another variable because, in the following two lines, we remove # and * from the text, so it looks nicer. But we keep the raw version so we can search the patterns. We then insert the text into the display:

    # Insert the content of the Edit Area into the Display Area
    text = editor.get('1.0', END)

    # Save Raw Text for later
    textRaw = text

    # Remove Unwanted Characters
    text = ''.join(text.split('#'))
    text = ''.join(text.split('*'))

    display.insert(1.0, text)

Then we loop through our replacements list and save each item to a different variable by unpacking them:

    # Loop through each replacement, unpacking it fully
    for pattern, name, fontData, colorData, offset in replacements:
        # Get the location indices of the given pattern
        locations = search_re(pattern, textRaw, offset)

Then we call our search_re(text) which will return a list like this. Where each tuple represents the starting and ending position of the matched pattern:

[("1.3", "1.7"), ("2.3", "2.5")]

Then we also loop through this list and add a tag at these positions with the name. These tags allow us to style certain parts of the text.

        # Add tags where the search_re function found the pattern
        for start, end in locations:
            display.tag_add(name, start, end)

Then at the end of the loop, we also configure this tag to use our font data and color. We access the tag by its name.

The last thing we do in the function is set the state to DISABLED once again.

        # Configure the tag to use the specified font and color
        # to this every time to delete the previous tags
        display.tag_config(name, font=fontData, foreground=colorData)

    display['state'] = DISABLED

search_re()

Now let's go over the pattern searching function. We give it the pattern, the text, and the offset. In it, we define a matches list that will be returned in the end:

def search_re(pattern, text, offset):
    matches = []

Then we split the text by \n using the splitlines() method on it, and we enumerate over it. For each line, we use the finditer() function from re which will return a list of the occurrences to loop over it:

    text = text.splitlines()
    for i, line in enumerate(text):
        for match in re.finditer(pattern, line):

Then we append a tuple with the starting and ending position and return the list at the end after the loop:

            matches.append(
                (f"{i + 1}.{match.start()}", f"{i + 1}.{match.end() - offset}")
            )
    return matches

rgbToHex()

Now also cover the small RGB to hex conversion function. By using "string %s" % (value) syntax, we can insert values from a tuple in a string with placeholders a little bit like f"{var}". But with this syntax, we can convert number to hex using %02x where x means to hex and the 02 means convert it to two characters.

# Convert an RGB tuple to a HEX string using the % Operator
# 02 means print 2 characters
# x means hexadecimal
def rgbToHex(rgb):
    return "#%02x%02x%02x" % rgb

Starting

In the end, we call the changes() function, so the styling is applied to the starting text, and we run the main loop:

# Starting the Application
changes()
root.mainloop()

Showcase

Conclusion

Excellent! You have successfully created a markdown editor using Python code! See how you can add more features to this program, such as:

  • Saving and opening .md files. The functionality is covered in the text editor tutorial.
  • Highlighting the code, inline code, and italic styling.

Learn also: How to Make a Planet Simulator with PyGame in Python

Happy coding ♥

Let our Code Converter simplify your multi-language projects. It's like having a coding translator at your fingertips. Don't miss out!

View Full Code Fix My Code
Sharing is caring!



Read Also



Comment panel

    Got a coding query or need some guidance before you comment? Check out this Python Code Assistant for expert advice and handy tips. It's like having a coding tutor right in your fingertips!