11: Editing Power Tools - transcript

Download the MP3

Jessica: The QUILL Cast, episode eleven. I'm Jessica. Last episode, Liam walked us through the Keymap Editor, the place where every command in QUILL is rebindable, the diagnostic categories, the keyboard packs, and the honest strategy of rebinding sparingly. Today we use the keys. The editing power tools are the operations between typing and formatting. They turn a draft into a working document, and they get out of the way the fastest once you trust them.

Liam: I'm Liam. Quick recap in one breath. The keymap stores only your overrides as a delta on top of the defaults. The editor reverse-looks up a key the same way it looks up a command name. The conflict prompt names the current owner by friendly title and offers a yes-or-no swap. The diagnostic categories are duplicate shortcuts, invalid bindings, unknown commands, and missing dispatch. Heal removes only the unrecoverable entries. The pack format is .kqp, the validator is shared with the standalone tool, and the on-disk keymap is always written atomically. That was the foundation. Today we use it.

Jessica: Today's frame. We are going to walk the editing power tools the way a serious editor would use them, not as a catalogue but as a flow. Selection first, then marks, then the undo contract that holds it all together, then line surgery, then transforms, then three classic-editor treasures that QUILL brought back on purpose, Repeat, Restore Deleted Text, and Describe Character. We will end with a preview of where the rest of the editing toolkit lives, and homework that takes a few minutes, not a few hours.

Liam: And one quick structural note. QUILL's UI shell is wxPython, and the edit commands are split across several mixins on MainFrame. Selection and marks live in main_frame_selection.py. Line surgery lives in main_frame_line_commands.py. The WordPerfect classics live in main_frame_classic_editor.py. Case and indent live in main_frame.py itself. That modular split is why the menu and the palette always agree. There is one source of truth per command family, and the menus, the command registry, and the keymap editor all read from the same data. We will lean on that fact a few times.


Jessica: Selection first. The grammar is older than the menu. Hold shift and press any movement key from episode seven, characters, words, lines, paragraphs, documents, and the selection grows in the same units. That is the basic verb. QUILL layers four more verbs on top of it, all of them one keystroke away, and all of them bound to commands with stable ids you can search for in the palette. Select Line, Select Paragraph, Select Block, and the ladder-walker Expand Selection, with its sibling Shrink Selection.

Liam: The implementation lives in quill/core/selection.py, and the four span functions are word_span, line_span, paragraph_span, and block_span. Block is the most generous. It is the current Markdown-style block, a heading plus its body, or a paragraph plus its continuation lines. Select All is the obvious special case, control A, the entire document in one move. The Select Word command, which the earlier short version listed alongside Select Line and Select Paragraph, is not actually a standalone command in the menu or palette. Word selection in QUILL is a widget-level behavior, double-click selects a word, and the word boundaries are computed by the same word_span helper Expand Selection uses. It works, but it is not its own command. We should not pretend it is.

Jessica: Expand Selection is the one that becomes a habit. It walks your current selection outward through a fixed ladder: word, line, sentence, paragraph, block, document. The first press expands from nothing, or from your current selection, to the next level up that strictly contains it. The second press expands again, and so on, all the way to the whole document. The status line announces each step, including the word count, so you always know where you are in the ladder. If you overshoot, Shrink Selection walks the ladder back down. The ladder is implemented in core selection's expand_selection helper and is unit-tested, so the boundaries are predictable, not vibes.

Liam: A note for screen-reader users. Select Line announces "Selected line, twelve words." Select Paragraph announces "Selected paragraph, forty-two words." Select Block announces the same shape, with the right scope label. The announcement is a format_announcement call. The words are counted in a way that is robust to Unicode whitespace. The status bar text is what your reader speaks. You do not need to second-guess what the command did. The product tells you.


Liam: Marks next. The mark is a remembered position, and it is the most underrated editing verb in QUILL. The flow is three keystrokes. Set a mark. Move anywhere. Then either pop back to the mark, or operate on the span between cursor and mark. The two ends of a long region, two named places, without holding shift through a thousand lines. The implementation is in quill/core/marks.py, and it is two classes. MarkRing is a list of up to twenty positions, newest at the end, with three operations: set_mark, pop_mark, and exchange_point_and_mark. NamedMarks is a string-to-position dictionary for marks you want to come back to by name, like a bookmark for a position rather than a region. Both classes are pure data, no wx, fully unit-tested.

Jessica: The default keymap gives you a quartet of mark commands. Set Mark, Pop Mark, Exchange Point and Mark, List Marks. Set Mark remembers the cursor. Pop Mark jumps back and removes that mark. Exchange Point and Mark is the most powerful of the three. It puts the cursor where the mark was and the mark where the cursor was, so one keystroke toggles you between the two ends of a region. List Marks opens a small message box with every mark, numbered, with line and column. The ring is intentionally temporary. Named marks are for things you want to come back to next week.

Liam: Honest correction time. The previous short version said marks let you operate on the span between mark and cursor. That is not what the code does. Marks are a jump mechanism. They do not define a region. If you want to operate on a region, that is what selection is for. Marks are for navigation across a document, the same way a bookmark is for navigation, except marks are temporary, ring-shaped, and disappear as you pop them. The earlier phrasing was wrong. The mechanism is a stack, the value is calm navigation.

Jessica: And named marks deserve a moment on their own. Set Named Mark prompts for a name, stores the position under that name, and announces, "Named mark beta set at line 142, column 9." Jump to Named Mark shows a list of every named mark, each with its line and column, and jumps to the one you choose. The dictionary is per document, not per file on disk, so closing and reopening the same file starts with an empty named-marks dictionary. That is a deliberate choice. Marks are session memory, not metadata, and the doc stays clean.

Liam: The undo contract. This is the part of the product that makes exploring safe, and it is worth saying out loud because the whole editing experience leans on it. QUILL has two undo paths, and you can be in either. The first is the native undo, the editor's own control Z and control Y. The second is persistent undo, an off-by-default feature that writes the document's text history to disk in atomic JSON files, and lets you undo across sessions, including after a crash. Either way, the same promise holds.

Jessica: The promise. Every change, no matter how big, from a single keystroke to an AI rewrite that applies fifty edits, is undoable, and compound operations undo as one step. You will never press control Z fifty times to walk back one action. The implementation detail that makes that true is the _atomic_replace helper in main_frame.py, which selects a range and writes over it in one operation, so the native editor records a single undo entry instead of a delete and an insert. That helper is the reason a whole-document case transform is one undo step, not two.

Liam: And the persistent-undo layer, when it is on, is just a list of full text snapshots, bounded to the most recent one hundred, written to the app data dir under a per-file hash, and flushed on a debounce plus a force flush on save. The class is in quill/core/undo_store.py. The default limit is one hundred entries. The format is a small JSON list, atomic-write through the same write_json_atomic path everything else uses. The contract from your side is the same. Undo walks the list, redo walks the list forward, one snapshot is one step.

Jessica: The honest bit. Persistent undo is a settings switch, not a default. The reasoning is real. A hundred snapshots of every document is real storage, and the privacy-conscious user may not want their text history persisted across sessions. We surface the toggle in the View menu, and we keep the native undo on either way, so the product stays calm even when the persistent layer is off. The earlier short version implied a single, always-on undo. There are two. Both work. Only one crosses sessions.


Liam: Line surgery. The commands you reach for fifty times a day once you trust them. Move Line Up, Move Line Down, Duplicate Line, Delete Line, Join Lines. The first four are in the Format menu and the command palette, all bound to stable command ids, format.move_line_up, format.move_line_down, format.duplicate_line, format.delete_line. The pure logic for each is in quill/core/line_ops.py. The handlers in main_frame_line_commands.py are a thin shell that wires the editor's text and cursor into those pure functions and routes the result through the announcement pipeline.

Jessica: The selection-aware piece is worth its own moment. In QUILL, Move Line Up and Move Line Down operate on the current line when there is no selection, and on a multi-line block when there is one. The status bar reports "Moved line up" for a single line, "Moved three lines up" for a three-line selection, and "Already at the top" or "Already at the bottom" at the document edges. That selection awareness is the part the earlier short version of this episode under-emphasized. The block move is the feature. Single-line is the special case.

Liam: Duplicate Line and Delete Line work on the line the cursor is on, and they feed the deletion ring behind the scenes. We will get to Restore Deleted Text in a moment, but the connection is real. When Delete Line removes a line, the ring records it. When Duplicate Line inserts a copy, the ring stays empty, because nothing was removed. The status bar reports "Duplicated line" and "Deleted line" respectively, and the editor repositions the cursor to the start of the affected line. The implementation in line_ops.py records into the deletion ring through the _apply_line_operation helper, and the recording is what makes Restore Deleted Text work later.

Jessica: Join Lines is the quiet one. With no selection, it joins the caret's paragraph into one line. With a multi-line selection, it joins the whole selection, paragraph by paragraph, keeping blank lines as paragraph separators. The earlier short version said Join Lines only merged the first two lines. That was true in an earlier build. The current code, in line_ops.py, calls join_selected_lines, which collapses every run of non-blank lines in the selection into a single line. The change was issue one-three-five. If a doc anywhere still says it only joins two lines, the doc is stale.


Liam: Section surgery. The Format menu also has Move Section Up and Move Section Down, bound to alt shift up and alt shift down by default. The command ids are format.move_section_up and format.move_section_down. The pure logic is in quill/core/markdown_sections.py. A section is a Markdown heading, or an HTML heading, plus the body lines between it and the next heading of equal or higher level. The move lifts the whole section, heading and body, and slides it past its sibling. The honest boundary is that section move is only available on a markup surface, that is, when the document is Markdown or HTML. Plain-text documents are explicitly rejected at the surface gate, with the announcement "Section move is only available in Markdown or HTML documents." The result is one of four enumerated outcomes, OK, NO_SECTION, TOP, BOTTOM, NO_SIBLING, each with its own announcement.

Jessica: And transforms, for the text you already have. The five case commands live in the Format menu, with the friendly titles Upper Case, Lower Case, Title Case, Sentence Case, and Toggle Case. The pure functions are in quill/core/transforms.py, to_upper, to_lower, to_title, to_toggle_case, to_sentence_case. The handler in main_frame.py, _transform_selection_or_document, is what you actually feel. The interesting part is the if-then-else at the top. If you have a selection, the transform applies to the selection. If you do not, the handler reaches for the word at the cursor and applies the transform to that word. So Upper Case, with no selection, capitalizes the word your cursor is on. With a selection, it capitalizes the whole selection. That word-at-cursor behavior is the answer to the common request, I just want to retype this one word in title case without selecting it.

Liam: The five transforms in plain language. Upper Case and Lower Case are str.upper and str.lower, the obvious behaviors. Title Case is str.title, capitalizing the first letter of every word. Sentence Case is the smart one. It splits the text on sentence-ending punctuation and capitalizes the first letter of each segment, leaving the rest lowercase, so "hello. how are you?" becomes "Hello. How are you?" Toggle Case is str.swapcase, every uppercase becomes lowercase and vice versa, which is the underrated tool for code snippets and identifier renaming. And the autoformat-while-you-type features, smart quotes and smart dashes, are separate settings, both off by default, in Preferences, Editing. They are not transforms. The earlier short version grouped them with the case commands. The honest picture is that they are a different mechanism entirely.


Jessica: The three classic-editor treasures. Repeat Next Command, Restore Deleted Text, Describe Character at Cursor. The command ids are edit.repeat_command, edit.restore_deletion, and power.describe_character. The handlers live in main_frame_classic_editor.py, which is the dedicated module the team extracted to keep main_frame.py within its size budget. The pure logic for each lives in core: char_describe.py for the third, deletion_ring.py for the second, and the arm_repeat method on CommandRegistry for the first. That separation is the pattern. Pure logic in core, wx shell in ui, command id in the registry, the manifest in the power tools table.

Liam: Repeat Next Command. The WordPerfect Editor repeat feature for a keyboard-first editor. Run it, type a count, press enter, and the very next command dispatched through the command registry runs that many times. The implementation is one int on the command registry, _pending_repeat, set by arm_repeat, consumed by run, and clamped to a maximum of one thousand so a typo or a runaway macro cannot spin the editor. The arming command itself is registered non-repeatable, so pressing the Repeat command three times in a row does not multiply itself into nine prompts. A repeat count of zero is refused with "enter a count of one or more." A non-number is refused with "enter a whole number." That is the whole mechanism.

Jessica: In practice. Press the repeat key, type a count, the prompt says "Repeat the next command how many times?" with two as the default, press enter, then press the next command. The status bar says "Next command will repeat five times." Run the next command, and it runs five times. The repeat fires on the next command dispatched through the registry, so it works for movement keys, deletions, line moves, anything that resolves through the registry. It does not work for raw typing at the editor surface. That is by design. The registry is the chokepoint. Raw typing is not.

Liam: Restore Deleted Text. The WordPerfect Editor "Cancel" buffer, modernized. QUILL's structured delete commands, the ones that go through _apply_line_operation in main_frame.py, record what they removed into a small ring. Restore Deleted Text opens a SingleChoiceDialog of the last few deletions, newest first, with a short preview, and re-inserts the chosen one at the cursor. The ring lives in quill/core/deletion_ring.py, with a default size of three. The recorded text is the contiguous span that disappeared, computed by stripping the common prefix and suffix, and the ring is pure data, no wx.

Jessica: The honest bit. Restore Deleted Text is a different kind of undo. Undo walks the document's edit history, in place, and reverts the last change. Restore reaches into a side buffer of recent deletions and re-inserts one of them at the current cursor, leaving the rest of the document exactly as it is. The two are complementary. The earlier short version implied Restore was just a kind of Undo, which is the wrong mental model. Undo means "revert the last change in place." Restore means "I deleted that paragraph two paragraphs ago, and I want it back, right here, please."


Liam: Describe Character at Cursor. The screen-reader descendant of WordPerfect's "Reveal Codes." Press it, and QUILL inspects the character at the cursor and renders a small accessible dialog with the glyph, its Unicode name, its code point in hex and decimal, its general category, and a plain-language note for the invisibles that bite writers. The status bar carries a one-line summary, and the dialog is the same read-only dialog the F1 help uses, so a screen reader reads it in one pass. The pure function is in quill/core/char_describe.py. The handler in main_frame_classic_editor.py is fourteen lines, all of them wiring.

Jessica: The invisibles that get the special treatment are the ones the earlier short version called out, and they are worth hearing again. The no-break space, U+00A0, the one that looks like a space but never wraps. The zero-width space, U+200B, the one that is invisible and can break searches and word counts. The zero-width joiner and non-joiner, U+200D and U+200C. The byte-order mark, U+FEFF, the one that often shows up as a stray BOM. The smart quotes, U+2018 through U+201D. The en dash and em dash, U+2013 and U+2014. And the line endings, LF and CR. Each of these has a plain-language note in the detail block, so a screen-reader user hears "Note: looks like a space but never wraps, often pasted from the web" and knows exactly what to do.


Liam: Do this now. Pause the audio. Open any document, a draft will do. Find a sentence with a phrase you wrote in lowercase that should have been title case. Select the phrase. Run the Title Case command, from the Format menu, the command palette, or the QUILL key selection actions if you remember that. Notice the status bar. "Title case applied to selection." Then run the same command with no selection, the cursor sitting on a single word, and notice. "Title case applied to current word." That second behaviour is the word-at-cursor fallback, the one most editors do not have. Come back when you have done that, and we will keep going.

Jessica: Welcome back. You have just touched the heart of the transforms layer. The selection case, the word-at-cursor case, and the same command id doing different things based on context. That is the pattern. We will see it again in Describe Character, which inspects the cursor, and in the mark commands, which remember positions. Tools that read the cursor, not just the selection, are the ones that feel like a partner, not a typewriter.

Liam: Two more everyday companions. The Review Buffer, and Copy With Source. The Review Buffer is a read-only dialog that opens with the current selection in a text control, so you can scan a passage at your own speed, in a context where screen-reader focus is not fighting the editor's. It is not a side-by-side scratch space. The earlier short version called it that, and the wording was generous. It is a modal dialog, accessible, and it closes with a single button or escape. It is the right tool when a passage is long enough to lose you and short enough to fit on one page.

Jessica: Copy With Source. Run it, and QUILL copies the current selection, plus a small source reference, to the clipboard. The reference includes the document name, the line and column, and a short fingerprint of the file. The output is plain text, selection first, then a blank line, then the source. If nothing is selected, the whole line at the cursor is used, and the status tells you. The earlier short version called this gold for research workflows, and we stand by that. When you paste the result into a notebook or a vault entry, the source is right there, no detective work later.


Liam: A code-verified recap. Selection uses span functions in quill/core/selection.py, with Expand Selection walking word to line to sentence to paragraph to block to document, and announcing the scope. Marks use MarkRing and NamedMarks from quill/core/marks.py. Undo uses two paths, native and persistent, with _atomic_replace keeping whole-document transforms as one step and quill/core/undo_store.py handling the per-file persistent history. Line surgery uses quill/core/line_ops.py, with selection-aware move-line commands. Case transforms use quill/core/transforms.py and apply to selection first, then word at cursor. Repeat uses arm_repeat on the command registry, clamped at one thousand. Restore uses DeletionRing from quill/core/deletion_ring.py. Describe Character uses describe_character from quill/core/char_describe.py. Every claim in this episode is one of those modules.

Jessica: And the honest corrections, in one breath. Persistent undo is a settings switch, off by default, not a single always-on undo. The Join Lines command joins the whole selection, paragraph by paragraph, not just the first two lines, and that change was issue one-three-five. The Review Buffer is a read-only dialog, not a staging buffer, and the staging buffer is the Copy Tray in episode fifteen. The mark ring is a temporary-jump mechanism, and named marks are a different, named-position mechanism, not the same feature. There is no standalone Select Word command. Smart quotes and smart dashes are two separate Preferences settings, both off by default, not a single smart-typography switch. Those are the places the earlier short version of this episode was less than exact.


Liam: Homework, four steps. One: pick a messy paragraph, run Expand Selection four times, hear the scope labels at each step, then run Shrink Selection twice to walk back. Notice the status bar at every step. Two: set a mark in the middle of your document, jump to the top, run Exchange Point and Mark, and notice you are now in the middle. Run it again and you are back at the top. Three: find a sentence with a phrase in the wrong case, run Title Case on the selection, then run the same command on a single word with no selection. Notice the word-at-cursor behaviour. Four: delete a line with Delete Line, type something else, then run Restore Deleted Text, pick the deletion from the list, and notice your undo history survived unchanged. The two layers stayed separate, on purpose.

Jessica: Next episode, episode twelve: find, replace, and the deep search tools. Search across files, regular expressions that sound scarier than they are, and the search results panel that doubles as a navigation surface. Today's tools edited what was in front of you. Next time we go looking, by text and by pattern, across one file and across many. We are forty-three episodes into a fifty-four-episode series, with the audio studio, the agents, the vault, and the cast still ahead. The editing power tools are the verbs the rest of the series stands on, and we are glad you are here for it.

Liam: I'm Liam.

Jessica: I'm Jessica. Edit like you mean it, and undo like you trust the product.

Back to all episodes