Writing a Text Editor in 7 Minutes using Textual

I was trying to prepare a lightning talk at work and thought about a small experiment I did with Textual. I played around with writing a client that interacts with Outlook through the win32 API. Trying to open the Python file in Vim, I noticed I didn't have Vim installed 😱. That's when the question hit me: Could you live-code a text editor during my lightning talk, in 10 minutes or less? Spoiler: yes, yes you can. You just need the right tools.

A few things before we get started. The full code that I will walk through in this post is available in this gist. The dependencies for this little application are available here. In the post, I will use ... in the code to signify the code we have already written. This is to keep things a bit neater. Now, let's dive in!

Before getting into the main part, I will add some scaffolding for the application. First, we will add all the imports for the application. Then we create a class Editor that inherits from the App class from Textual. This is all that is required to create an empty application. I also want to solve argument parsing right off the bat. For this, we will use ArgumentParser from argparse in the standard library. Here we take a single argument, folder, the folder the editor should open. We run some basic input validation, like checking that the folder exists. We also assume that the user wants to open the parent folder if we have a file in the path.

from argparse import ArgumentParser
from pathlib import Path

from textual.app import App
from textual.widgets import TextArea, DirectoryTree
from textual.containers import Horizontal

class Editor(App):
    pass

if __name__ == "__main__":
    parser = ArgumentParser("My Editor")
    parser.add_argument("folder", type=Path)

    args = parser.parse_args()
    folder: Path = args.folder

    if not folder.exists():
        raise FileNotFoundError(f"No folder found for path: {folder}")
    if not folder.is_dir():
        folder = folder.parent

    app = Editor(folder)
    app.run()

As we expect to pass a folder to the Editor class, we have to implement the __init__ method.

class Editor(App):
    def __init__(self, folder):
        super().__init__()
        self.folder = folder
        self.file = None

We take the folder as an input argument, storing it as a field. We also create a field for file that we will use to keep track of the current file open in the editor. Note that it´s essential to do the super().__init__() call here so textual can wire up the application properly.

Running the application, we still see just an empty screen, which is quite boring. So, let´s start getting some widgets on the screen.

class Editor(App):
    ...
    def compose(self):
        yield Horizontal(
            DirectoryTree(self.folder),
            TextArea("", id="editor")
        )

Now we get a screen with two columns. To the left, it shows the file and folder structure of the folder we asked it to open. To the right, it shows an empty text area. You can write in the text area, however, you can't open any file. Let's fix that next.

Textual uses the naming of methods to hook into the event system. This has the general syntax of _on_<object_type>_<event>. Here we want to hook into:

  • an event on the DirectoryTree -> _on_directory_tree_<event>

  • and trigger when a file is selected -> _on_directory_tree_file_selected

class Editor(App):
    ...
    def _on_directory_tree_file_selected(self, event):
        path: Path = event.path
        if not path.is_file():
            return

        self.file = path

        text_editor = self.query_one("#editor")
        text_editor.text = self.file.read_text()
        text_editor.language = "python" if self.file.suffix == ".py" else None

In this method, we check that the path is indeed a file, returning early otherwise. We make sure to assign this path to self.file, we will need this later. To open the file, we need to get a reference to the editor text area. We can query for it using the ID we defined earlier, prefixing it with a #. Now we copy the text from the file into the text area. To add some flair, we also add syntax highlighting from Python files. Note that we need an extra dependency for syntax highlighting, using pip install textual[syntax] installs this for you.

Yaaaay, now we can edit the file in our editor! We are done!

... but wait, how do I save the file ???

To do this in Textual, we will use keyboard bindings and actions. Let's define bindings for saving a file as ctrl+s. And, while we're here, let's beat Vim in quitability by binding ctrl+q to quit the editor 😉. We declare binding by assigning a list of tuples to a class-level variable named BINDING. The first value is the keyboard shortcut, and the second value, "save_file" and "quit", will be mapped to actions. The action quit is already defined in Textual, but save_file isn't. Instead, we define our own method named action_save_file that Textual will, by using the name, hook up to the binding.

class Editor:
    BINDINGS = [
        ("ctrl+s", "save_file"),
        ("ctrl+q", "quit"),
    ]
    ...

    def action_save_file(self):
        if self.file is None:
            return
        editor = self.query_one("#editor")
        self.file.write_text(editor.text)

We return early if no file is open. Otherwise, we get the text area and write the text content back into the original file. It's for this step that we need self.file, to know where to store the new content.

Below is a screenshot of the editor we just coded up. It’s not the most impresive editor in the world, but it’s pretty damn good for something you can write in 7 miuntes.

Terminal with the editor open. Left side is a directory tree and a python file with syntax highlighting shows on the right.

I got a bit carried away and wrote an extended version of the editor; the code is available here, and a preview is shown below. This adds some extra elements to the UI and a command palette, allowing you to open files in the directory. The command palette is a powerful feature built into Textual that allows for searchable, dynamically generated commands. In the improved editor, I used this to generate a list of commands to open files in the directory. I will not go into the details here, but in the gist for the improved editor, you can see how this was done.

Terminal with the editor open. Left side is a directory tree and a python file with syntax highlighting shows on the right. It has a bar at the bottom displaying keyboard shortcuts. I also has a clock in the top right corner.

Finally, now that you know how it's done, can you beat my time and write it in less than 7 minutes? Or maybe extend the application in some interesting way? Maybe you have some other tool that would work well as a terminal application? Either way, I would recommend that you experiment a bit with textual. It’s a powerful library and really, really fun to play with.

0
Subscribe to my newsletter

Read articles from Fredrik Sjöstrand directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Fredrik Sjöstrand
Fredrik Sjöstrand

Hello! I've been working as a developer since 2019 when I got my combined bachelor's and master's degree in computer science and engineering with a specialization in machine learning and AI. Since then I have mainly been developing CLI applications, services, and data-heavy solutions using Python. I have deployed an automation system with a machine learning core. Finally, over the last two years, I have been doing a lot of data analytics and visualizations. Changes between roles and projects, but Python remains 🐍