Qt Corner: edit.py
Copyright 2021 Brian Davis - CC-BY-NC-SA
edit.py showcases a minimal code editor written in Python and Qt.
In no particular order, features include
- Autoformatting with black.py.
- Syntax Highlighting using QSyntaxHighlighter (don't you love the obvious names?) and pygments.
- UI Styling with Qt Stylesheets.
- Autoindenting (really naive).
- A MenuBar (I'll get to why this is cool).
- MDI Interface with QDockWidgets. I really like these.
- Integrated Python help.
QTextEdit is a rich text edit widget that can be coerced into doing a number of things. I've used it for logging output, editing text, displaying simple HTML pages, and building a terminal emulator. This power and flexibility is really neat.
But I say coerced because its not exactly setup for any of those use cases. QTextEdit feels kind of like a bag of tools, but its an odd mix of power tools and hand tools. As a text editor its pretty great. You just show() it and load or save the contents from/to files... until you want to convert tabs to spaces or autoindent or add line numbers and then you're overriding keyPressEvent which feels like ripping the motor out of your belt sander because you wanted to change the belt to 800 grit. Its a really powerful technique so long as you think about all the edge cases. Oh you forgot to handle the mouse changing the cursor position? Override another method. I kind of wish it did a little less out of the box and provided more manual tools that could be combined to build up functionality, rather than a powerful set of tools that I have to tear down and rebuild like this.
But once you get the hang of the technique: sub-class QTextEdit, override methods, manipulate QTextCursor and QDocument, and call the base method, I've found I can do a lot of different things with the class, all without leaving Python.
The big draw of QMainWindow to me are the DockAreas. You can add widgets to the main window that dock to the sides (right, left, top, bottom) and the user can resize and move them around. This allows me to add widgets to an application without thinking too hard about arranging them and allows the user a lot of flexibility to resize to fit the content and their workflow. Ideally this window arrangment would be automatic but its a Hard Problem(TM). And my attitude towards Hard Problems is: only try to solve one at a time. And the problem here is designing an editor, not a window manager.
QMenuBar is slick. Tree menus with keyboard hints and shortcuts. I spent a long time building a shortcut engine for a Qt based text editor before I realized pretty much everything I wanted (except modes: see The Quest above) could be had by applying QMenuBar with the added bonus of discoverability. ActionBar generates a menu from a convenient data structure.
I've discovered I really like autoformatters. It's like having only black socks in that a whole class of decisions are made by someone else and I get to focus on what I'm trying to build, not how my code is formatted. I understand that someone might prefer to retain control of that, (believe me, I get obsessive control) but this is one area I've learned to let go of and feel the freedom that comes with that.
I picked black.py because it was the first one I tried and it worked. The only complaint I have is how slow it is. But so far I've just been living with it. In the case of edit.py I used subprocess to call black on the file just after its saved. The return value of black is printed in the QStatusBar (another neat feature of QMainWindow). Bonus: your code is also getting syntax checked on every save.
Speaking of how slow black.py is, I call it in a blocking manner, meaning the GUI locks up while it runs, which can be a couple seconds. I don't like that much. I looked at the problem briefly. Naive application of (Cython)[https://cython.org/] didn't help. Running black in daemon mode probably would but would also require some form of Interprocess Control (IPC). Remember: only solve one hard problem at a time.
Syntax Highlighting using QSyntaxHighlighter
The documentation on QSyntaxHighlighter is, a-hem, thin, but I got there in the end. The basic process involves sub-classing and implementing highlightBlock. There's some magic going on that determines when a block requires highlighting and it doesn't always work right so I probably need to dig into that at some point. For now its going to highlight the entire document on a save (after autoformatting and reloading) and I save often enough that anytime the highlighting gets out of whack, it doesn't stay that way for long.
Integrating with Pygments was a neat problem. Given that QSyntaxHighlighter is getting called on arbitrary blocks, how does pygments what the previous blocks are supposed to be?Digging through the Pygments source code revealed that it maintains a stack of the current code element. That allows the highlighter to answer questions like: is the given line part of a multi-line comment? etc. I found the function in the pygments lexer that returns tokens for a given line, given the current stack. Then I modified it to return not only the offset and token but also the current stack. The lexer stack of the last token in the block is saved into the QTextBlock.userData field. When highlighting an arbitrary block, check for a saved stack in the previous block to pick up where pygments left off.
Add in some code for generating QTextCharFormats (that's what you apply to a range of text to format it) and try loading parameters from a YAML file to populate a few basic styles, mapped to pygments Tokens and we have pretty functional syntax highlighting for a shocking number of languages (pygments has a massive library). I was sort of surprised this was performant. In fact my first iteration was a disaster since I naively tried highlighting the entire file every time there was a change. Unsurprising, that was WAY too slow. But QSyntaxHighter seems to be pretty smart about when to change which blocks and so saves a lot of processing. As I mentioned above it falls down occasionally and fixing that is still an outstanding problem.
UI Styling with Qt Stylesheets
(Qt Stylesheets)[https://doc.qt.io/qt-5/stylesheet-examples.html] are pretty straight forward if you have any experience with CSS. I don't indulge in a lot of styling. Mainly background colors, borders, fonts, etc. You can get pretty radical with background images and such. Arguing that you can't use Qt because its not modern looking or attractive is really a poor argument in my opinion.
For the search functionality I add a QLineEdit widget to the statusbar.
Yeah you can add widgets to statusbars. I've explored this feature a couple times and discovered I'm terribly prone to abuse it. Better UI design probably involves dock widgets instead. But for a really simple way to add widgets to the bottom of the screen... probably use a dock widget instead.
For search as you type you want to override the keyPressEvent of your search box and call the search function of the QTextEdit on every keypress. You want to pass
repeat=True if you are searching again, as in, go to the next instance of the search string, on a return press. I like the simplicity of pressing Escape to leave the search widget but I haven't found a simple, clean way to indicate to the user that's a possibility.
Remember when I mentioned overriding QTextEdit.keyPressEvent? Here's one reason to do that. On a return, check for the previous character and if its a colon (in Python anyway), indent the next line. Of course, its more complicated than that. You need to count the indentation level of the previous line and also colons won't be the only things that need autoindenting and it won't be valid in other languages... but I left this problem here with just the simplest solution. Remember the Only One Hard Problem mantra.
Integrated Python help.
I've been writing Python for over a decade and I just learned about
help(). I don't know if that's an inditement of me or Python's docs... Anyway, good excuse for a QDockWidget. Hit the F1 key and a dock widget get's added with a prompt. Type something in and hit enter and the program will call
help() with the string you provided. That's it. Already super handy, a hundred times faster than switching to Google. For future: make it run on the highlighted text in the editor and with your current module as context.
Oh there is a sneaky trick I used here.
help() is designed to only run in the interpreter so it prints directly to stdout instead of returning a value. To get around that I replace
io.StringIO() and read the results of the
help() call from there. There are perils around messing with
sys.stdout. But used sparingly this technique is powerful. I later learned that
help() is calling the pydoc module, and I can access pydoc instead for the same functionality without
sys.stdout shenanigans. Live and learn.
edit.py wound up more substantial (some would say unfocused) than I intended for Qt Corner. But I was able to showcase a number of fun things I learned during (my quest)[/projects/TheQuest.html] for the greatest editor. This quest actually has a much bigger goal than that, one I've noodled and poked at for a very long time. Hopefully as I pursue I'll be able to add more little examples to the Qt Corner or maybe produce a fully featured application of some sort.