caretline

Outline documents

An outline document is a text buffer read as blocks: paragraphs, bullets, numbered items and tasks, nested by indentation. It has its own editing rules (Enter continues a list, Tab nests, Ctrl-T cycles a task, Alt-↑/Alt-↓ move an item with its children) and gives every block a stable identity, so a host can keep its own data per block (a database row, a due date) while the text is edited, undone and redone.

The outline layer lives in src/outline and is on when state.doc.outline is set. Without it the document is plain text and nothing here applies.

$ caretline --outline crates/caretline-app/fixtures/trip.md

The buffer

The text is still one rope, and one text line is one row:

text                               marks          blank row   block
───────────────────────────────    ───────────    ─────────   ───────────────────────────
Booked the flat in Lisbon.         0 @ line 0                 0  paragraph, depth 0
It faces the river.                                              (continuation)
- [ ] Pay the deposit              1 @ line 2     yes          1  task ' ', depth 0
  - ask Ana about her desk         2 @ line 3                  2  bullet, depth 1
![boiler label](files/x.png)       3 @ line 4     yes          3  paragraph, atomic

Everything about a block is derived from the text, the marks and the config, by outline::derive, and remembered until they change (state.blocks()):

BlockInfo fieldMeaning
idThe block’s mark
start, endThe first line’s start, and the content’s end (the last line’s end, before its break)
first_line, line_countIts lines
depth, indentLeading spaces on the first line, as levels and as spaces
kindPara, Bullet or Task
statusA task’s box character
prefix_lenCharacters of indentation and marker. Never a caret stop
hangWhat a host draws before the content: None, Bullet, Number(n), Task(c), Heading(n), Quote, Fence
fenceA code fence: its lines are all continuations
atomicOne line whose whole content is an image, ![caption](path)
gap, attrsWhether a blank row comes before it, and its attributes as set

Blank rows

Unset, a block’s blank row follows its kind and the block before it: a paragraph has one before and after it (a ## or ### heading only before), list items are tight, and the first block has none. gap: Some(true) or Some(false) overrides that.

A kind or depth change never moves another block. When the task cycle, Tab or Shift-Tab changes a block, every other block keeps the blank row it had. When typing, Backspace or Delete changes the kind or depth of the caret’s block, that block and the one after it keep theirs. Where the default would now differ, the old value is written to the block’s attributes, in the same undo step. Other edits (Enter, joins, moves, paste) let blocks take their defaults.

The caret

No selection end is ever inside a marker, and no caret is ever inside an atomic block. After every message the update loop moves an end that landed there:

The rules

Each rule is one Transaction and one undo step. The mark column says what happens to ids.

MsgWhereDoesMarks
insert_newline (Enter)In a list itemSplits it: the rest goes to a new item with the same marker and indentation (a task starts open, a number goes up by one)The new item gets a new id
At an item’s content startA new empty item aboveThe item keeps its id
On an empty itemIt becomes a paragraph (the list ends)Kept
In a paragraphA soft break—
At a paragraph’s very startA new empty paragraph aboveThe paragraph keeps its id
At the start of a paragraph’s later lineThat line starts a new paragraph (an empty line there is dropped when more follows). So Enter twice at a paragraph’s end starts a new paragraphNew id for the new paragraph
At the end of a paragraph’s line, with more lines belowThe rest becomes a new paragraph, with the caret on a new empty one betweenTwo new ids
In a heading or quoteA new paragraph after it (at its content start: a new empty paragraph above)New id
In a fenceA line break—
On a selected atomic blockA new empty paragraph after itNew id
soft_break (Shift-Enter, Ctrl-J)In an item, heading or quoteA line break inside the block—
ElsewhereAs Enter
delete_backward (Backspace) at a content startA taskRemoves [c] : it becomes a bulletKept
A bullet, numbered item, heading or quoteRemoves the marker and indentation: it becomes a paragraphKept
A paragraph after a paragraphJoins them, keeping the line breakThe lower id goes
A paragraph after anything elseJoins it onto the block above’s last lineThe lower id goes
After an atomic blockSelects that block (a second Backspace removes it)—
delete_forward (Delete) at a block’s endJoins the next block in (its marker goes); before an atomic block, selects itThe next id goes
delete_word_*, delete_to_line_*, kill_lineAt a block’s edgeAs Backspace or Delete
Inside a blockAs in plain text, but never past the content start or into the next line
Backspace or DeleteOn a selected atomic blockRemoves the block; the status says whatIts id goes (undo brings it back, selected)
insert_textOn a selected atomic blockA new paragraph after it with the textNew id
[ ] , [x] or [] completed at the start of a paragraph’s lineThe box becomes a task marker (- [ ] ): that line is a task, on a later line a block of its ownNew id on a later line
indent / outdent (Tab / Shift-Tab)The caret’s block, or every block the selection touchesOne level deeper (at most one below the last non-empty block above) or shallower, keeping the selection. Any block nests under any block: a paragraph under a paragraph, a bullet or a task, an item under a paragraph. With nothing to nest under (or nothing to outdent), the status says soKept
indent on a later line of a paragraph (a caret, no selection)That line becomes a paragraph of its own, one level under the paragraph (Logseq-style: Para line, Enter, first subtask, Tab)New id for the line
task_cycle (Ctrl-T)The caret’s block, or every block the selection touchesThe first block’s next state applies to all: text → [cycle[0]] → [cycle[1]] → text. A bullet’s marker becomes a task’sKept
Inside a multi-line paragraphEach selected line becomes its own task; the lines before and after stay paragraphs, tight against themThe first piece keeps the id; the others get new ones with no blank row
A task back to text, next to a paragraph with no blank row betweenJoins it (the reverse of the split)The joined ids go
set_status { id, ch }A task’s boxSets the box (a click). Never back to textKept
move_block { dir } (Alt-↑ / Alt-↓)The caret’s block with its childrenSwaps with the previous or next sibling and its children. At the end of a list the status says first in its list or last in its listIds move with their blocks
move { by: block } (Ctrl-↑ / Ctrl-↓)To the next block’s content start, or back to this block’s (then the previous one’s)
select_block { id }Selects the block’s content (a triple-click)
select_word_at { pos }Selects the word at pos (a double-click); a click with extend then extends by words
insert_blocks { after, blocks }Host blocks after a block (or at the start), as one stepEach block’s mark if free, else a new id
pasteMarkdown with line breaksRead into blocks: the first joins the text before the caret (taking its shape when there is none), the rest follow, and the text after the caret ends the last. Images are left out and countedNew ids
Whole blocks from the register, on an empty itemThe blocks take the item’s place, with their own kinds and statuses, re-indented to its depthThe cut ids come back
Whole blocks from the register, at a block’s endThey follow the block’s subtree as siblings, at its depthThe cut ids come back
Whole blocks from the register, inside a block’s textAs pasted MarkdownNew ids
The register (or the same text from the system clipboard)Pasted as it was cutThe cut ids come back
Whole blocks from the register, over a selection of whole blocksThe selected blocks go and the register’s take their place, in one step (so a copy pasted back over its own selection changes nothing)The cut ids come back
paste_plain (Alt-V)Paragraphs with their line breaks kept; nothing becomes a listNew ids
copy / cutInside one blockPlain textA cut keeps the removed ids in the register
Across blocksMarkdown: the first block’s text from the selection’s start (its marker only from its content start), then each block with its marker and indentation, a blank line around paragraphs
Whole blocks (from a block’s content start to another block’s end, or to the start of the block after them, as Shift-↓ selects; an empty last block whose marker is selected is taken too)The register takes their lines with markers and indentation; a cut takes the lines out, leaving no empty itemA cut keeps the ids in the register

Effects

EffectWhen
completed { id }A task reached done (task_cycle or set_status): a host may save at once
restoredUndo or redo changed the document: a host re-reads what it keeps per block
block_left { from, to }The primary caret moved to another block: a commit point for a host
notice { text }A status message, when the status bar is off

Markdown in and out

outline::markdown reads and writes Markdown:

A file round-trips exactly when it is written the way to_file writes it. Three things don’t survive a file: an empty paragraph (a fresh line to type on) is left out, an empty continuation line reads back as a blank row (splitting the block), and two paragraphs with no blank row between them read back as one.

In Rust

use caretline::outline::markdown;
use caretline::{update, Effect, MarkId, Msg, OutlineConfig, Viewport};

let mut s = markdown::load("- [ ] Pay rent\n- Buy milk\n", None, Viewport { width: 40, height: 6 }, OutlineConfig::default());
let fx = update(&mut s, Msg::TaskCycle);                  // the caret is on "Pay rent"
assert!(fx.contains(&Effect::Completed { id: MarkId(0) }));
assert_eq!(markdown::to_file(&s), "- [x] Pay rent\n- Buy milk\n");

let blocks = s.blocks().unwrap();                          // derived, cached
assert_eq!(blocks.blocks[1].depth, 0);
ItemDoes
State::enable_outline(config)Makes a state an outline document (marks every block, outside the undo history)
State::blocks() -> Option<Arc<Outline>>The derived blocks; Outline::block_at, get, index_of, subtree_end look them up
State::outline_changed()Call after changing text or marks directly, not through update
Document::blocks()The same, on a document shared by several views
outline::content(doc, id)A block’s content: its lines after the marker, joined with \n
OutlineConfigindent, task_markers (the vocabulary), cycle, atomic_images, numbered
NewBlockA block to insert: depth, kind, status, text, gap, mark
outline_keymap, keymap_for(outline, key), script_to_msgs_forThe outline’s keys

Keys

The outline keymap is the plain one (see messages.md) with these on top:

KeyMsg
Tab / Shift-Tabindent / outdent
Ctrl-Ttask_cycle
Shift-Enter, Ctrl-Jsoft_break
Alt-↑ / Alt-↓move_block
Ctrl-↑ / Ctrl-↓move by block (Shift extends)
Alt-Vpaste_plain

Try it

caretline --outline FILE edits a Markdown file as an outline. The fixtures include trip.md and three saved outline states:

$ caretline --outline crates/caretline-app/fixtures/trip.md --keys '<down><down><down><c-t>' --snapshot 50x18
$ caretline --outline crates/caretline-app/fixtures/trip.md --keys '<down><down><down><down><end><cr>Call Ana<tab>' --snapshot 50x18
$ caretline --state crates/caretline-app/fixtures/outline-split.state.json --snapshot 40x10
$ caretline --outline crates/caretline-app/fixtures/trip.md --keys '<d-down>!<c-s>' --effects

Without a layout the view draws each block’s text as it is, markers included, with blank rows as virtual rows. --layout adds the outline layout (below), with plain hang glyphs:

$ caretline --layout crates/caretline-app/fixtures/trip.md --snapshot 50x16 --no-status-bar
  #   Lisbon trip

      Booked the flat in Lisbon.
      It faces the river.

  [ ] Pay the deposit
      •   ask Ana about her desk
  [ ] Book flights
  [x] Renew passport

      ![boiler label](files/boiler.png)

  1.  Pack
  2.  Leave

The outline layout

A view’s layout (an OutlineLayout) lays an outline document out line by line, with the column geometry as data:

FieldDefaultMeaning
marks2Columns before everything, for marks a host draws (a conflict sign, a flash)
indent4Columns per depth
hang4Columns of the hang: the bullet, number, task box or heading sign
column72The wrap width of depth-0 content
min_column20The narrowest a nested block’s content wraps at
extra_rows{}Rows a host draws after a block, by mark id (its fields on their own row, an inline image)
hang_glyphsfalseDraw a plain glyph in each hang (•, 1., [ ], #, │), for a host that draws none

For each line:

The goal column of ↑/↓ is a screen column, so moving between depths goes straight down. Motion, paging, scrolling, clicks and drawing use the same rows, through one Layout.

The engine draws only text: the marks column and the indentation are blank, and the hang’s cells have the role Hang (blank unless hang_glyphs). A host draws its own hang, marks and fields from the frame’s row info:

ItemDoes
Frame::rowsOne RowInfo per frame row: Text { block, line, row, first, last, chars, x }, Gap { before }, Extra { block, index }, Past, Status
Cell::char_idxThe document char a cell shows, for styling spans (links, tags)
view::hit(doc, view, col, row)What a cell is: Text { pos } (where a click lands), Hang { block }, Marks { block }, Gap { block }, Extra { block, index } or Past
view::render(doc, view)The frame of any view of a document
use caretline::outline::markdown;
use caretline::view::{hit, Hit, RowInfo};
use caretline::{view, OutlineConfig, OutlineLayout, Viewport};

let mut s = markdown::load("- [ ] Pay rent\n- Buy milk\n", None, Viewport { width: 40, height: 4 }, OutlineConfig::default());
s.view.layout = Some(OutlineLayout::default());
let f = view(&s);
assert!(matches!(f.rows[0], RowInfo::Text { first: true, x: 6, .. }));
assert_eq!(hit(&s.doc, &s.view, 3, 0), Hit::Hang { block: s.doc.marks.as_slice()[0].id });

Folds

fold, unfold and toggle_fold (by block id) hide or show a block’s children in one view: folds are the view’s (view.folds), so another view of the same document still shows them. They work with or without a layout. Only a block with children folds.

Cost

Each edit re-derives the blocks once (linear in lines; a few hundred microseconds for 5,000 blocks in a release build) and maps the marks after the first change. The layout lays out only the rows it needs, so the outline layout costs no more than plain drawing, and a second view of the document is only rebased (its selection mapped), never laid out. The scale tests type in a 5,000-block outline, with and without the layout and with a second view open, and check that a key, rendered, stays under 4 ms in a release build (it is about 1 ms). Deriving only the lines an edit touched is the next step when that matters.

This page on GitHub: docs/caretline/outline.md