scribe
scribe
A multi-caret text editing model for Crystal.
scribe holds the text, the carets and the undo history, and carries out editing commands: typing, deleting, word and subword motion, multiple carets, box selection, clipboard, line operations and transforms, comments, auto-closing brackets, snippets with tabstops, folding, and find and replace. One document can carry several views, each with its own carets and layout. It draws nothing, reads no input and highlights nothing: syntax tokens come from a host-supplied TokenSource, such as the Tree-sitter highlighter in asekii-editor. The host — a terminal UI, a GUI, a test — implements a few small interfaces, sends commands, and listens for changes.
It has no dependencies beyond the standard library.
Requirements
- Crystal 1.21 or newer
Installation
Add the dependency to your shard.yml:
dependencies:
scribe:
github: shpeckman/scribe
Then run shards install and require it:
require "scribe"
Quick start
A document needs one thing from its host: a Scribe::Graphemes, which finds the grapheme cluster boundaries in a string, classifies a cluster and measures its display width.
require "scribe"
struct Clusters
include Scribe::Graphemes
def boundaries(text : String) : Array(Int32)
marks = [0]
text.each_grapheme { |grapheme| marks << marks.last + grapheme.to_s.bytesize }
marks
end
def kind(cluster : Bytes) : Scribe::ClusterKind
char = String.new(cluster)[0]? || return Scribe::ClusterKind::Other
return Scribe::ClusterKind::Space if char.whitespace?
return Scribe::ClusterKind::Word if char.alphanumeric? || char == '_'
Scribe::ClusterKind::Punct
end
def width(cluster : Bytes) : Int32
1
end
end
text = Scribe::Document.new(Clusters.new, "hello world", filter: Scribe::MultiLine.new)
text.run(Scribe::Command::WordLeft)
text.insert("big ")
text.value # => "hello big world"
text.undo
text.value # => "hello world"
Documents and views
A Document owns the text, the undo History, the marks, the decorations and the annotations. A View owns the carets, the goal column, the box selection and the Layout, and carries out every command. A document has one view from the start, and Document delegates the whole caret and editing API to it, so a single-view host never mentions View at all.
text = Scribe::Document.new(graphemes, "one two", filter: Scribe::MultiLine.new)
text.insert("x") # goes to text.view
other = Scribe::View.new(text)
other.select_range(0, 3) # its own carets, its own layout
text.views.size # => 2
other.close # stops following the document
Two views share the text and the history: an edit from either one shows in both, the other's carets are carried through it as marks are, and undo from either one seats the carets it recorded. view.layout = … is per view, so two panes can wrap the same document at different widths.
Offsets everywhere count grapheme clusters, not bytes or characters; a line break counts as one cluster.
text = Scribe::Document.new(graphemes, "one two", max_length: 80, copy_on_select: true)
text.value # the text, with \n line breaks
text.value = "replaced" # undoable; the caret moves to the end
text.cursor # the head of the primary caret
text.carets # Array(Scribe::Caret), sorted and merged
text.select_range(0, 3)
text.selected_text
text.place_carets([Scribe::Caret.at(0), Scribe::Caret.at(4)])
text.insert("x") # typed text, at every caret
text.paste("a\nb") # one line per caret when the counts match
text.undo
text.redo
| Option | Default | Effect |
|---|---|---|
filter |
SingleLine.new |
What happens to incoming text (see Filters) |
clipboard |
Register.new |
Where copy, cut and paste go |
selection_clipboard |
nil |
Where copy_on_select goes instead of clipboard, for example an X11-style primary selection |
layout |
LineLayout |
How offsets map to rows and columns |
max_length |
nil |
Limit in clusters, shared across carets |
indent |
" " |
Inserted by Tab and Indent, removed by Outdent |
comment |
nil |
Line comment prefix for ToggleComment |
tab_size |
4 |
How many leading spaces Outdent removes when indent is a tab |
auto_indent |
false |
NewLine keeps the current line's leading whitespace |
auto_close |
false |
Typing a bracket or quote inserts its pair (see Typing aids) |
copyable |
true |
false disables copy and cut, for example for a masked password |
copy_on_select |
false |
Copy a selection as soon as it is made, into selection_clipboard when set |
read_only |
false |
Refuse edits from commands, insert, paste, replace_match and replace_all; motion, selection and copy still work, and value= and replace_ranges from code still apply |
final_newline |
false |
export ends the text with one line ending |
Every option except max_length is also a property; max_length= truncates the text when it shrinks. dragging= tells the document a pointer drag is in progress, which holds back copy_on_select until the host calls auto_copy on release.
Line endings
Text is always held with \n breaks. The ending found in the text a document is created with, or given through value=, is kept as line_ending (LF, CRLF or CR), and export returns the text with it:
text = Scribe::Document.new(graphemes, "a\r\nb", filter: Scribe::MultiLine.new)
text.value # => "a\nb"
text.line_ending # => Scribe::LineEnding::CRLF
text.export # => "a\r\nb"
Pasted and typed \r\n and \r become \n as well.
Saving, transactions and read-only
text.mark_saved
text.modified? # false until the text changes again; undoing back to the save point clears it
text.transaction do
text.run(Scribe::Command::DocumentEnd)
text.insert("\n")
text.insert("appended")
end # one undo step, one `moved` and one `edited` notification
transaction returns the block's value and can be nested; undo and redo are refused inside one.
Marks
A Mark is an offset that follows edits, for bookmarks, diagnostics or search highlights:
spot = text.mark(10) # Scribe::Gravity::Left by default
after = text.mark(10, Scribe::Gravity::Right)
spot.offset
text.unmark(spot)
Text inserted exactly at a mark goes after a Left mark and before a Right mark. A mark inside deleted text moves to where the deletion happened.
Commands
run(command, selecting = false) carries out one Scribe::Command. Motion commands extend the selection when selecting is true.
| Group | Commands |
|---|---|
| Motion | Left, Right, WordLeft, WordRight, SubwordLeft, SubwordRight, RowStart, SmartRowStart, RowEnd, Up, Down, PageUp, PageDown, DocumentStart, DocumentEnd |
| Selection | SelectAll, SelectWord, SelectLine, SelectInsideBrackets, SelectAroundBrackets, SelectInsideQuotes, SelectAroundQuotes, Collapse |
| Carets | AddNextOccurrence, SkipOccurrence, SelectAllOccurrences, AddCaretAbove, AddCaretBelow, AddCaretPerLine, RemoveLastCaret |
| Box | BoxUp, BoxDown, BoxLeft, BoxRight |
| Deleting | DeleteBackward, DeleteForward, DeleteWordBackward, DeleteWordForward, DeleteSubwordBackward, DeleteSubwordForward, DeleteToRowStart, DeleteToRowEnd |
| Editing | NewLine, Tab, Indent, Outdent, ToggleComment, DuplicateSelection |
| Lines | MoveLinesUp, MoveLinesDown, DuplicateLines, DeleteLines, SortLines, ReverseLines, UniqueLines, JoinLines, TrimTrailingWhitespace |
| Case | UpperCase, LowerCase, TitleCase |
| Clipboard | Copy, Cut, Paste, PastePrevious |
| History | Undo, Redo |
run returns true when the command applied and false when it did not, so a host can let the key go on to something else:
- Always
true: motion,SelectAll,SelectWord,SelectLine,AddCaretAbove,AddCaretBelow, theBoxcommands, theDeletecommands,Tab,Copy,CutandPaste— even when there was nothing to delete or copy. falsewhen there was nothing to do:CollapseandRemoveLastCaretwith one caret; the bracket and quote selection commands with no target;AddNextOccurrence,SkipOccurrenceandSelectAllOccurrenceswithout a match;DuplicateSelectionwith no selections;PastePreviouswithout an active paste or older entry;NewLine,Indent,Outdent,ToggleCommentand theLinescommands when the text did not change;UndoandRedowith an empty stack.falsefor every command that edits while the document is read-only.
Notes on individual commands:
Up,Down,PageUpandPageDownremember the column a caret started in, so moving through a short line and on to a long one returns to that column. Any other move or edit forgets it.SmartRowStartgoes to the first non-blank cluster of the row, or to the row start when already there.SelectInsideBracketsandSelectAroundBracketsselect the innermost balanced pair around every caret, excluding or including its delimiters. The quote equivalents pair unescaped single or double quotes on one line.DuplicateSelectioninserts a copy immediately after every nonempty selection and selects the copies, preserving their direction. Empty carets stay unchanged.Tabindents every line when a selection spans lines, and inserts the indent otherwise.Outdentremoves one indent, a leading tab, or up to that many spaces.ToggleCommentcomments every non-blank line under the carets at their shallowest indentation, or uncomments them when all are commented. It needs thecommentoption.MoveLines*,Indent,Outdent,ToggleComment,DuplicateLinesandDeleteLinesact once per block of lines under the carets.AddNextOccurrencefirst selects the word under an empty caret.SkipOccurrencemoves the most recent caret on to the next occurrence.SelectAllOccurrencesputs a caret on every occurrence of the selection, or of the word under the caret.SubwordLeftandSubwordRightstop at camelCase humps, underscores and the start of a word, sofooBar_bazhas four stops.SortLines,ReverseLines,UniqueLinesandTrimTrailingWhitespaceact on the whole block of lines under the carets and returnfalsewhen nothing changed.JoinLinesjoins the block, or the next line when a caret is empty.UpperCase,LowerCaseandTitleCasetransform each selection and keep it selected; without a selection they do nothing.AddCaretPerLineputs a caret at the end of every line a selection spans.PastePreviousreplaces the immediately preceding paste with the next older clipboard entry; repeated calls keep cycling. Moving, editing or starting another paste ends that session.Box*grows a rectangular selection from the primary caret.start_box(row, column),select_box(anchor_row, anchor_column, head_row, head_column)andclear_boxlet a host drive it from a pointer;boxreturns the current corners.
Typing is not a command: send insert(text). Pressing Enter in a single-line field is not a command either; the host decides what it means.
Typing aids
With auto_close on, typing (, [, {, " or ' inserts the pair and leaves the caret between them; with a selection it wraps the selection and keeps it selected. Typing a closing character in front of the same character steps over it, a quote typed right after a word stays single, and DeleteBackward between an empty pair removes both. Pasted text is never paired.
Interfaces
The host plugs into a document through these modules. Each is small, and all but Graphemes come with a default.
| Interface | Methods | Default |
|---|---|---|
Graphemes |
boundaries(text), optional boundaries(bytes), kind(cluster : Bytes), width(cluster : Bytes) |
byte boundaries fall back to a string copy |
Filter |
filter(text) |
SingleLine, MultiLine(tab) |
Clipboard |
write(text), read { |text| } |
Register (in memory) |
ClipboardHistory |
size, read(index) { |text| } |
Register (100 entries) |
Layout |
row_count, row_of, row_start, row_end, cell_of, offset_in, page_rows |
LineLayout (one row per line) |
Listener |
applied, edited, moved, decorated |
no-ops to override |
TokenSource |
tokens(buffer, line) |
none |
Tokenizer |
tokens(text) |
none |
Graphemes. boundaries returns the byte offset where each cluster starts, followed by the text's byte size, so "" gives [0] and "ab" gives [0, 1, 2]. Implement the String form; the Bytes form is used for mapped lines and defaults to boundaries(String.new(bytes)), so existing producers keep working. Override it to index non-ASCII mapped lines without that temporary copy. kind returns a ClusterKind (Space, Word, Punct or Other), which drives word motion and deletion, word selection, smart Home, auto-indent and how typing is grouped for undo. width is the display width in columns. Clusters are passed as byte slices into the document's own storage, so a host can classify and measure them without allocating. Give the document the same measurements your renderer uses.
Filters. SingleLine turns line breaks and tabs into spaces and drops other control characters. MultiLine.new(tab) keeps line breaks and replaces tabs with tab. Any struct or class that includes Filter works, for example one that only lets digits through.
Clipboard. read takes a block so a clipboard can answer later, for example after asking a terminal; the paste happens when the block is called. Register also includes ClipboardHistory, keeping the newest 100 unique entries and exposing them through entries; Register.new(limit: n) changes that bound. Other clipboards can opt into PastePrevious by including ClipboardHistory.
Layout. cell_of(offset) returns a Cell (column, row) in display columns, and offset_in(row, column) goes back. A host that wraps lines implements Layout over its wrapped rows and assigns it with document.layout = …; vertical motion, paging (page_rows), smart Home, carets above and below and box selection then follow the wrapped rows.
Listener. document.listen(listener) returns a proc that removes it again.
applied(document, change)runs for eachChangeas it lands, with the buffer in the state right after it. This is the place to update per-line caches such as wrapping.edited(document, changes)runs once per edit or transaction, after the carets are placed, with every change in the order it was applied. Undo reports the changes it applied.moved(view)runs whenever that view's carets change, including after every edit. The view it is given carriesview.document.decorated(document)runs when a source replaces its decorations, andannotated(document)when a source replaces its annotations.
TokenSource. Anything that can return the tokens of a line in a buffer includes TokenSource, so a renderer takes a source without knowing what produced it. Asekii::Syntax::Highlighter from asekii-editor is one. A Token is a start, a count and a kind, with columns counting clusters, and TokenSource::NO_TOKENS is the shared empty result.
Tokenizer. Anything that can tokenize standalone text, with no document behind it, includes Tokenizer: tokens(text) returns one array of Token per line. This is what a renderer needs for a fenced code block or any other snippet it holds as a string. Asekii::Syntax::Tokenizer is one.
Buffer and changes
document.buffer stores each line as UTF-8 bytes with its cluster boundaries, and keeps line offsets in a Scribe::Index: lines in blocks, with a Fenwick tree over the block sums. String-backed lines retain their string; mapped lines retain the mapping and materialize text only on demand. Finding a line, its start, or the line of an offset is logarithmic in the number of blocks plus a scan inside one, and inserting or removing a line costs the same instead of rebuilding the whole index. On a million-line document, 200 edits that each add a line take about 76 ms rather than 1.3 s. A line whose bytes are all ASCII keeps no boundary table at all — its columns are its byte offsets — so ordinary source code costs nothing per cluster; line.ascii? says which kind a line is. Lines over 64 KiB use immutable, cluster-aligned pieces; line.chunked? reports this. Ordinary ASCII splices share unchanged pieces without flattening the line. Host-defined Unicode segmentation can still require a full-line boundary rebuild. Searching is line by line and never builds the whole text.
buffer = text.buffer
buffer.line_count
buffer.position(42) # Scribe::Position(line, column)
buffer.offset(position)
buffer.line_start(3)
buffer.line(3) # Scribe::Line: text, to_slice, mapped?, chunked?, size, ascii?, cluster(column), bytes(column), boundary(column), slice(from, to), each_cluster, each_chunk
buffer.width(3, 0, 10) # display width of columns 0...10 on line 3
buffer.index(3, 12) # the column under display column 12 on line 3
buffer.leading(3) # how many whitespace clusters line 3 starts with
buffer.blank?(3) # whether line 3 is empty or only whitespace
buffer.indentation(3) # display width of line 3's leading whitespace, nil for a blank line (indent guides, hanging wraps)
buffer.find("needle", 10) # next literal match as a cluster range, wrapping around
buffer.find_all("needle")
buffer.revision # how many edits the buffer has seen
buffer.changes_since(7) # the Changes since a revision, or nil when the journal does not reach back
Line byte offsets are kept in a second Fenwick tree, so moving between cluster offsets and UTF-8 byte offsets is logarithmic too. This is what parsers and language servers that count bytes need:
buffer.bytesize # the text's size in bytes, with \n line breaks
buffer.byte_offset(42) # cluster offset → byte offset
buffer.offset_at_byte(97) # byte offset → cluster offset (rounded down; ceil: true rounds up)
buffer.line_byte(3) # the byte offset where line 3 starts
buffer.byte_position(97) # Scribe::Position(line, byte within the line)
buffer.byte_text(10, 20) # the text between two byte offsets
buffer.chunk(97) # the rest of that line as Bytes, "\n" at a line end, empty at the end
buffer.each_chunk(97) { |bytes| consume(bytes) } # contiguous pieces without flattening
chunk returns the rest of the line and can flatten a chunked line. each_chunk yields the same text as contiguous pieces instead, including "\n" at a line end. Line#text and Line#to_slice also flatten on demand.
Units and points. Other tools count positions in other units. Scribe::Units is Bytes, Utf16, CodePoints or Clusters, and a Point is a line and a column in one of them:
point = buffer.point(42, Scribe::Units::Utf16) # Scribe::Point(line, column in UTF-16 code units)
buffer.offset_at(point, Scribe::Units::Utf16) # back to a cluster offset (a column inside a cluster rounds down)
buffer.measure("é👍", Scribe::Units::Utf16) # => 3
buffer.line(3).units(Scribe::Units::Bytes, 0, 5) # the size of columns 0...5 in bytes
A Change records from, the removed and inserted text, and their sizes in clusters; removed_lines and inserted_lines count line breaks. start_point(buffer, unit), old_end_point and new_end_point describe where it starts and where it ended before and after, as a language server or an incremental parser expects; call them from Listener#applied, while the buffer is in the state right after the change. When an edit merges with a neighbouring cluster — a combining accent typed after a letter — the change is widened to whole clusters, so undoing it is exact.
replace_ranges applies several Edits (a range and its new text) as one undo step and moves the carets with the text:
text.replace_ranges([Scribe::Edit.new(0...3, "one"), Scribe::Edit.new(10...10, "!")])
Its text is used as given, without the filter.
Decorations
A decoration marks a range of the text with a kind and a message, for example a compiler error. Its edges move with edits like marks do, without growing when text is typed right at them, and a decoration whose range shrinks to nothing remains, empty, until it is replaced.
text.decorate("lsp", [Scribe::Decoration.new(4...9, "error", "undefined method")])
text.decorations # every decoration, sorted by start
text.decorations(0, 20) # those overlapping 0...20
text.decorations_at(6) # those covering offset 6
text.decorate("lsp", [] of Scribe::Decoration) # clears that source's decorations
Each call replaces every decoration from the same source, so several producers can share a document, and tells listeners through decorated.
Annotations
An annotation is text shown beside the document rather than in it — an inlay hint, a git blame, a line note. Its offset follows edits like a mark, and each source replaces its own set, as decorations do.
text.annotate("lsp", [Scribe::Annotation.new(7, ": Int32", Scribe::Placement::After, "type")])
text.annotations # every annotation, sorted by offset
text.annotations(0, 20) # those between two offsets
text.annotations_at(7)
text.annotations_on(3) # those on line 3
text.annotate("lsp", [] of Scribe::Annotation) # clears that source
Placement is Before, After or RowEnd, and decides which side of the offset the host draws the text on; a Before annotation keeps its place when text is typed at it, the others move with it. Listeners hear annotated.
Folding
Folds belong to a view, so two panes can fold a document differently. A fold is a line range held by marks, so it follows edits.
folds = text.view.folds
folds.fold(2, 8) # collapsed by default; nil for an empty or out-of-range range
folds.collapsed?(2) # the header line
folds.hidden?(5) # a line inside a collapsed fold
folds.toggle(2)
folds.expand(2)
folds.unfold(2)
folds.rows # the visible lines, in order
folds.replace([{0, 4}, {6, 9}]) # every fold at once, as a query would supply them
folds.clear
text.view.lift_carets # moves carets on hidden lines to the end of the visible line above; false when none was hidden
LineLayout consults the view's folds, so row_count, vertical motion, paging, smart Home and carets above and below all skip hidden lines. A host with its own Layout overrides Layout#folds= (a no-op by default) to receive them: the view hands its folds to the layout when they are first made and to every layout assigned later. folds.revision counts fold, unfold, collapse and expand calls, so a layout can tell when to recount its rows. view.folds creates the set on first use and view.folds? tells whether one exists.
Snippets
A snippet is text with tabstops. Snippet.parse turns LSP snippet syntax into plain text plus stop spans, and View#insert_snippet inserts it and selects the first stop.
snippet = text.view.insert_snippet("def ${1:name}($2)\n $0\nend")
text.selected_text # => "name"
text.insert("run") # fills the stop
snippet.next_stop(text.view)
snippet.previous_stop(text.view)
snippet.active?
snippet.close # drops its marks
$1,${1},${1:placeholder},$0(the final stop),${1|one,two|}(the first option) and\$are understood. A${VAR:default}resolves to its default and an unknown$VARto nothing.- A stop that appears more than once becomes one caret per occurrence, so typing fills every mirror at once.
- The stops are held by marks, so filling an early stop carries the later ones.
- Inserting a snippet replaces the primary caret's selection as one undo step. A snippet with no stops is inserted and
nilcomes back.
Diffing
Scribe.diff turns an old and a new text into the smallest set of line edits, for replace_ranges:
text.replace_ranges(Scribe.diff(text.value, formatted, graphemes))
The carets, marks, decorations and annotations outside the changed lines stay where they are, which is what value= cannot do. It is a Myers line diff with common ends trimmed; past limit lines of difference it falls back to one edit covering the changed region.
Opening and saving a file
Document.open keeps a read-only mapping of the file and finds the lines in its bytes. Unchanged lines and long-line pieces stay in the mapping with no per-line string; edits create ordinary string-backed pieces only for changed storage. The caret starts at the beginning, and path remembers the file.
text = Scribe::Document.open(graphemes, "src/app.cr", filter: Scribe::MultiLine.new)
text.buffer.mapped? # true
text.buffer.line(0).to_slice # read-only Bytes into the mapping
text.buffer.line(0).text # materializes and caches a String only when asked
text.line_ending # taken from the file
text.insert("// edited\n")
text.save # writes path and clears modified?
text.save("other/app.cr") # save-as; updates path
save writes through a writable mapping of a temporary file in the destination directory, closes it, then renames it over the destination. The live source mapping is never truncated, and a failed save leaves the destination and document save point unchanged. Existing destination permissions are preserved. Saving a document without a path requires an explicit one.
Scribe::Mapped is the mapping itself, for a host that wants the bytes:
Scribe::Mapped.open(path) do |mapped|
mapped.bytesize
mapped.bytes # a read-only Bytes view; invalid once closed
mapped.sequential # tells the kernel the reads will be sequential
end
Scribe::Mapped.open(path, writable: true) do |mapped|
mapped.bytes[0] = 'x'.ord.to_u8 # MAP_SHARED: writes reach the file
end
Mappings need a POSIX system and refuse a file larger than Int32::MAX. A persistent mapping is released once its buffer and extracted lines are no longer referenced. Do not truncate a mapped file externally while it is in use. String-returning operations such as value, regex search, and line transforms still allocate; byte chunks, motion, and literal search do not need to materialize mapped lines.
Revisions
Buffer#revision counts edits, and the buffer keeps the last 256 of them. Document#map carries an offset or a range from an older revision to the current one, for a result that arrives late:
mark = text.revision
# … a language server answers three keystrokes later …
text.map(offset, mark) # => the offset now, or nil if the journal no longer reaches back
text.map(range, mark) # a range, which does not grow at its edges
Find and replace
Search finds matches in a buffer, case-insensitively by default, with optional whole-word and regular-expression modes. Matches are cluster ranges, cached until the buffer changes. Literal searches and regular expressions proved unable to consume a line break keep matches per line and, after an edit, rescan only the lines the edit touched. Multiline-capable or uncertain regular expressions retain the whole-text rescan.
search = Scribe::Search.new("(\\w+)=(\\d)", regex: true)
found = text.find(search) # selects the next match
text.replace_match(search, found.not_nil!, "$2=$1") # only if that match is still selected
text.find(search, backward: true) # selects the previous match
text.replace_all(search, "$2=$1") # one undo step; returns the count
search.error # the message for an invalid pattern
Tokens
scribe defines the token vocabulary and the two interfaces that carry it; it produces no tokens itself.
Scribe::Token.new(0, 3, "keyword") # start, count, kind — columns count clusters
A TokenSource serves one line of a buffer and is what a renderer reads through:
session = Asekii::Syntax::Session.new(text, Asekii::Syntax::Languages["crystal"])
syntax = Asekii::Syntax::Highlighter.new(session) # includes Scribe::TokenSource
syntax.tokens(text.buffer, 3) # => Array(Scribe::Token)
A Tokenizer serves standalone text, one array of tokens per line, for a snippet with no document behind it:
marker = Asekii::Syntax::Tokenizer.new(Asekii::Syntax::Languages["crystal"], graphemes) # includes Scribe::Tokenizer
marker.tokens("def f\nend") # => [[Token], [Token]]
To examine text outside a document, build a line with Scribe::Line.new(text, graphemes). text.brackets returns the offsets of the bracket pair beside an empty primary caret.
Input recall
Recall keeps the entries submitted from a single-line field and steps through them, remembering the draft:
past = Scribe::Recall.new(limit: 100)
past.push("ls")
past.back("dra") # => "ls"
past.forward # => "dra"
Macros
MacroLog records the commands and the text a view receives, and replays them:
log = Scribe::MacroLog.new
text.view.macros = log
log.record
text.run(Scribe::Command::RowEnd)
text.insert(";")
log.stop
log.replay(text.view) # once, as one undo step
log.replay(text.view, 10) # ten more times
A replay records nothing itself, and each pass is one undo step.
Development
crystal spec
The specs drive documents directly, with a small Graphemes built on the standard library in spec/spec_helper.cr. History takes an optional clock, which the specs use instead of sleeping.
License
MIT
scribe
- 0
- 0
- 0
- 0
- 0
- 2 days ago
- October 29, 2026
Sun, 01 Nov 2026 05:35:22 GMT