pub struct Doc {Show 13 fields
pub format: Format,
pub path: PathBuf,
pub source: String,
pub caret: usize,
pub anchor: Option<usize>,
pub dirty: bool,
pub status: Option<String>,
pub view: View,
pub vmap: VisualMap,
pub scroll: usize,
pub body_origin: (u16, u16),
pub body_height: u16,
pub drawn_caret: Option<usize>,
/* private fields */
}Fields§
§format: Format§path: PathBuf§source: StringCurrent source, refreshed from the editor after every successful edit.
caret: usizeThe caret, as a byte offset into source (always on a char boundary).
anchor: Option<usize>The selection’s fixed end, if a selection is active; the moving end is
the caret. None means no selection.
dirty: bool§status: Option<String>§view: View§vmap: VisualMapThe rendered map for the WYSIWYG view; empty in the source view. Movement and clicks read it to stay in visible space.
scroll: usize§body_origin: (u16, u16)§body_height: u16§drawn_caret: Option<usize>The caret as of the last frame drawn, or None before the first.
Scrolling is the viewport’s business, not the caret’s: the view follows the caret when the caret moves, but a wheel that doesn’t touch the caret has to be free to scroll away from it — otherwise the view is pinned to the caret and stops dead at the edge of the document you can see. Comparing against this is what tells the two apart, and it catches a caret set by any route, including a frontend assigning the field itself.
Implementations§
Source§impl Doc
impl Doc
Sourcepub fn open(path: PathBuf) -> Result<Self>
pub fn open(path: PathBuf) -> Result<Self>
Examples found in repository?
34fn main() {
35 for kb in [10usize, 100, 1000] {
36 let src = body(kb * 1024);
37 println!("=== {} KB ===", src.len() / 1024);
38
39 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
40 let nodes = ed.nodes().unwrap();
41 let map = wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None);
42 println!(" ({} AST nodes, {} map rows)", nodes.len(), map.rows.len());
43
44 println!(" -- per edit (unavoidable today) --");
45 time("twig edit_range (reparse)", 5, || {
46 ed.edit_range(src.len() / 2, src.len() / 2, "x").is_ok()
47 });
48 time("twig nodes() FFI marshal", 5, || ed.nodes().unwrap().len());
49 time("wysiwyg::build", 5, || {
50 wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None)
51 .rows
52 .len()
53 });
54 {
55 // The incremental path with a warm cache and nothing changed: the
56 // floor cost the block cache adds even on a pure repaint — hash every
57 // block, clone every reused row, recollect stops. No subtree is
58 // marshalled (every block hits). The real keystroke win shows up in
59 // "Doc::insert + rebuild" below, which re-marshals only the edited
60 // block and reuses the rest.
61 let mut cache = wysiwyg::BlockCache::default();
62 let top = ed.child_spans(None).unwrap();
63 let _ = wysiwyg::build_cached(
64 &top,
65 &src,
66 None,
67 false,
68 &HashMap::new(),
69 None,
70 &mut cache,
71 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
72 );
73 time("wysiwyg::build_cached (all reused)", 5, || {
74 let top = ed.child_spans(None).unwrap();
75 wysiwyg::build_cached(
76 &top,
77 &src,
78 None,
79 false,
80 &HashMap::new(),
81 None,
82 &mut cache,
83 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
84 )
85 .rows
86 .len()
87 });
88 }
89
90 println!(" -- claimed hot, actually noise --");
91 time("twig source_str() (full copy)", 5, || {
92 ed.source_str().unwrap().len()
93 });
94 let clean = src.clone();
95 time("dirty compare (full cmp)", 5, || src == clean);
96
97 println!(" -- what the GUI adds on a cache miss --");
98 time("clone every row's glyphs", 5, || {
99 map.rows
100 .iter()
101 .map(|r| r.glyphs.clone())
102 .collect::<Vec<_>>()
103 .len()
104 });
105 time("hash every glyph (cache key?)", 5, || {
106 let mut n = 0u64;
107 for r in &map.rows {
108 let mut h = std::collections::hash_map::DefaultHasher::new();
109 for g in &r.glyphs {
110 g.ch.hash(&mut h);
111 }
112 n ^= h.finish();
113 }
114 n
115 });
116
117 println!(" -- the whole path, as a frontend calls it --");
118 let mut p = std::env::temp_dir();
119 p.push(format!("leaf_bench_{kb}.md"));
120 std::fs::write(&p, &src).unwrap();
121 let mut d = Doc::open(p).unwrap();
122 d.view = View::Wysiwyg;
123 d.place_caret(src.len() / 2, false);
124 d.build_visual_unwrapped();
125 time("build_visual (cached: a repaint)", 200, || {
126 d.build_visual_unwrapped()
127 });
128 time("Doc::insert + rebuild (a keystroke)", 5, || {
129 d.insert("x");
130 d.build_visual_unwrapped();
131 });
132 println!();
133 }
134}Sourcepub fn from_source(source: String, format: Format) -> Result<Self>
pub fn from_source(source: String, format: Format) -> Result<Self>
Build a document from an in-memory string, the format named explicitly —
the portable, filesystem-free counterpart to Doc::open (which reads a
path and sniffs the format from its extension). A wasm or FFI host, which
has no path to read, uses this: it hands over bytes it fetched however it
could, and later persists Doc::source however it can (a browser
download, localStorage, a backend PUT) and calls Doc::mark_saved.
No file backs the result, so it starts untitled (Doc::is_untitled is
true) exactly like a Doc::blank that has been given content.
Sourcepub fn blank() -> Result<Self>
pub fn blank() -> Result<Self>
An untitled, empty document — the + button and a leaf launched with
no file argument. Nothing on disk backs it until a Doc::save_as.
It is Markdown, because a format has to be chosen before a name exists to
read one from: detect_format reads the extension and an untitled
document has neither. Markdown is what leaf’s own files are, what its
block markers are already written for (insert_block_prefix), and the
extension a Save As will overwhelmingly pick — a wrong guess here would
mean typing djot into a buffer parsing it as Markdown. Note that Save As
doesn’t revisit this: see Doc::save_as.
Sourcepub fn is_untitled(&self) -> bool
pub fn is_untitled(&self) -> bool
Whether this document has no file behind it yet — a Doc::blank that
has never been saved. The question a ⌘S handler asks to know it should
open a Save As picker instead (Doc::save won’t guess a name), and the
header asks to know the name it shows is a placeholder.
pub fn toggle_view(&mut self)
Sourcepub fn markup_mode(&self) -> MarkupMode
pub fn markup_mode(&self) -> MarkupMode
The current markup-exposure preference (see MarkupMode).
Sourcepub fn set_markup_mode(&mut self, mode: MarkupMode)
pub fn set_markup_mode(&mut self, mode: MarkupMode)
Set the markup-exposure preference. Both of its axes take effect at
once: the editing one on the next insert, and the
rendering one on the next build — which is why this drops the cached
visual map and the per-block render cache, exactly as
set_line_flow does.
Sourcepub fn set_line_flow(&mut self, mode: LineFlow)
pub fn set_line_flow(&mut self, mode: LineFlow)
Set the soft-break flow preference. The mode changes how every block lays
out, so a change drops the cached visual map and the per-block render
cache, forcing the next build_visual to rebuild under the new flow.
pub fn view_name(&self) -> &'static str
Sourcepub fn build_visual(&mut self, width: usize)
pub fn build_visual(&mut self, width: usize)
Rebuild the WYSIWYG visual map for the current tree at width columns
(called by the renderer each frame it’s in the WYSIWYG view).
Build the WYSIWYG map, wrapped at width display columns.
Cheap to call every frame, which is what both frontends do: the map is a pure function of the document and the wrap width, so a call that would rebuild the same map returns the one already built. Only an edit (or a resize) pays.
That isn’t a micro-optimisation. A frontend repaints for reasons that have
nothing to do with the text — a blinking caret, a scroll, a focus change —
and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
marshalling twig’s AST across the C ABI. Paid twice a second by the GUI’s
blink timer, that was 14% of a core spent redrawing an unchanged document.
(cargo run --release -p leaf-core --example bench for the numbers.)
Sourcepub fn build_visual_unwrapped(&mut self)
pub fn build_visual_unwrapped(&mut self)
Build the WYSIWYG map with each block as a single unwrapped row — for a frontend (the GUI) that wraps at its own proportional pixel width rather than a fixed character column.
Examples found in repository?
34fn main() {
35 for kb in [10usize, 100, 1000] {
36 let src = body(kb * 1024);
37 println!("=== {} KB ===", src.len() / 1024);
38
39 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
40 let nodes = ed.nodes().unwrap();
41 let map = wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None);
42 println!(" ({} AST nodes, {} map rows)", nodes.len(), map.rows.len());
43
44 println!(" -- per edit (unavoidable today) --");
45 time("twig edit_range (reparse)", 5, || {
46 ed.edit_range(src.len() / 2, src.len() / 2, "x").is_ok()
47 });
48 time("twig nodes() FFI marshal", 5, || ed.nodes().unwrap().len());
49 time("wysiwyg::build", 5, || {
50 wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None)
51 .rows
52 .len()
53 });
54 {
55 // The incremental path with a warm cache and nothing changed: the
56 // floor cost the block cache adds even on a pure repaint — hash every
57 // block, clone every reused row, recollect stops. No subtree is
58 // marshalled (every block hits). The real keystroke win shows up in
59 // "Doc::insert + rebuild" below, which re-marshals only the edited
60 // block and reuses the rest.
61 let mut cache = wysiwyg::BlockCache::default();
62 let top = ed.child_spans(None).unwrap();
63 let _ = wysiwyg::build_cached(
64 &top,
65 &src,
66 None,
67 false,
68 &HashMap::new(),
69 None,
70 &mut cache,
71 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
72 );
73 time("wysiwyg::build_cached (all reused)", 5, || {
74 let top = ed.child_spans(None).unwrap();
75 wysiwyg::build_cached(
76 &top,
77 &src,
78 None,
79 false,
80 &HashMap::new(),
81 None,
82 &mut cache,
83 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
84 )
85 .rows
86 .len()
87 });
88 }
89
90 println!(" -- claimed hot, actually noise --");
91 time("twig source_str() (full copy)", 5, || {
92 ed.source_str().unwrap().len()
93 });
94 let clean = src.clone();
95 time("dirty compare (full cmp)", 5, || src == clean);
96
97 println!(" -- what the GUI adds on a cache miss --");
98 time("clone every row's glyphs", 5, || {
99 map.rows
100 .iter()
101 .map(|r| r.glyphs.clone())
102 .collect::<Vec<_>>()
103 .len()
104 });
105 time("hash every glyph (cache key?)", 5, || {
106 let mut n = 0u64;
107 for r in &map.rows {
108 let mut h = std::collections::hash_map::DefaultHasher::new();
109 for g in &r.glyphs {
110 g.ch.hash(&mut h);
111 }
112 n ^= h.finish();
113 }
114 n
115 });
116
117 println!(" -- the whole path, as a frontend calls it --");
118 let mut p = std::env::temp_dir();
119 p.push(format!("leaf_bench_{kb}.md"));
120 std::fs::write(&p, &src).unwrap();
121 let mut d = Doc::open(p).unwrap();
122 d.view = View::Wysiwyg;
123 d.place_caret(src.len() / 2, false);
124 d.build_visual_unwrapped();
125 time("build_visual (cached: a repaint)", 200, || {
126 d.build_visual_unwrapped()
127 });
128 time("Doc::insert + rebuild (a keystroke)", 5, || {
129 d.insert("x");
130 d.build_visual_unwrapped();
131 });
132 println!();
133 }
134}Sourcepub fn set_media_rows(&mut self, rows: HashMap<String, usize>)
pub fn set_media_rows(&mut self, rows: HashMap<String, usize>)
Tell the model how many visual rows each block image should reserve, keyed
by the image’s destination. A terminal frontend calls this once it has
decoded and measured its pictures — core does no image I/O, so this is the
only way it learns a height — and the next Doc::build_visual lays each
placeholder out that tall (the label row plus blank filler rows the
frontend paints the raster over). A destination left out of the map falls
back to the bare one-row placeholder, which is also what a frontend that
can’t draw pictures (or lays them out in its own units, like the GUI) gets
by never calling this.
Cheap to call every frame with the same map: only a change invalidates the built map (and the block-row cache, since a height isn’t part of a block’s bytes and so wouldn’t otherwise re-render it). Steady state is a no-op, so a frontend can just hand over its current measurements each frame.
Sourcepub fn revision(&self) -> u64
pub fn revision(&self) -> u64
The revision the document’s text is at — bumped by every edit, undo, redo, and reload, and by nothing else. A frontend caches against this to tell a repaint that needs new work from one that doesn’t.
It counts edits, not distinct texts: typing x and deleting it again
lands on the same text two revisions later. Work is only ever rebuilt
needlessly, never wrongly reused.
pub fn format_name(&self) -> &'static str
Whether this document’s format offers any door in — false only for a
wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
a frontend may as well open the file read-only.
This is a much weaker claim than the name suggests, and driving per-button
state from it is exactly the mistake to avoid: HTML answers true because
it spells the inline marks with a tag pair (<strong>, <em>, <code>)
while a heading, a quote, a list, a task box, a link and a code fence all
remain unspellable there. Ask capabilities — or
supports — per control.
Sourcepub fn supports(&self, gesture: Gesture) -> bool
pub fn supports(&self, gesture: Gesture) -> bool
Whether this document’s format can spell gesture, which is twig’s own
answer rather than a copy of it: Format::supports reads the same
Syntax table the Editor method consults before refusing.
It is a fact about the format, not about the caret. true does not
promise the gesture succeeds where it is standing — a link over a table
border still fails — only that it will not fail with
UnsupportedFormat. Gray out on false; don’t read true as “this
will work here”.
Sourcepub fn capabilities(&self) -> Capabilities
pub fn capabilities(&self) -> Capabilities
Every control’s enabled state in one read — what a toolbar builds itself
from when a document opens or its format changes. See Capabilities.
Sourcepub fn file_name(&self) -> String
pub fn file_name(&self) -> String
The name to show for this document. An untitled one has no file to name it, and both frontends put this straight on screen — an empty path renders as an empty header, so it says so instead.
Sourcepub fn selection(&self) -> Option<(usize, usize)>
pub fn selection(&self) -> Option<(usize, usize)>
The selection as an ordered [start, end) byte range, or None when the
caret and anchor coincide (an empty selection is no selection).
Sourcepub fn selected_text(&self) -> Option<&str>
pub fn selected_text(&self) -> Option<&str>
The selected text, or None when there’s no selection — the source
slice a copy/cut hands to the system clipboard.
The AST breadcrumb at the caret (root → deepest), e.g.
doc › para › strong. Read live from twig via ancestors_at.
Sourcepub fn edit(&mut self, start: usize, end: usize, text: &str)
pub fn edit(&mut self, start: usize, end: usize, text: &str)
Replace the byte range [start, end) with text, re-anchoring the caret
after it. The public form of the internal splice — a pixel frontend that
hit-tests to a byte offset (or an IME that hands back an explicit range)
edits through this, the same twig edit_range the caret ops use.
Sourcepub fn insert(&mut self, text: &str)
pub fn insert(&mut self, text: &str)
Insert typed text at the caret, replacing the selection if there is one.
A single typed character coalesces with the run of typing before it; a
newline or a multi-character insert is its own undo step.
Typed input only — clipboard text goes through paste.
Examples found in repository?
34fn main() {
35 for kb in [10usize, 100, 1000] {
36 let src = body(kb * 1024);
37 println!("=== {} KB ===", src.len() / 1024);
38
39 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
40 let nodes = ed.nodes().unwrap();
41 let map = wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None);
42 println!(" ({} AST nodes, {} map rows)", nodes.len(), map.rows.len());
43
44 println!(" -- per edit (unavoidable today) --");
45 time("twig edit_range (reparse)", 5, || {
46 ed.edit_range(src.len() / 2, src.len() / 2, "x").is_ok()
47 });
48 time("twig nodes() FFI marshal", 5, || ed.nodes().unwrap().len());
49 time("wysiwyg::build", 5, || {
50 wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None)
51 .rows
52 .len()
53 });
54 {
55 // The incremental path with a warm cache and nothing changed: the
56 // floor cost the block cache adds even on a pure repaint — hash every
57 // block, clone every reused row, recollect stops. No subtree is
58 // marshalled (every block hits). The real keystroke win shows up in
59 // "Doc::insert + rebuild" below, which re-marshals only the edited
60 // block and reuses the rest.
61 let mut cache = wysiwyg::BlockCache::default();
62 let top = ed.child_spans(None).unwrap();
63 let _ = wysiwyg::build_cached(
64 &top,
65 &src,
66 None,
67 false,
68 &HashMap::new(),
69 None,
70 &mut cache,
71 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
72 );
73 time("wysiwyg::build_cached (all reused)", 5, || {
74 let top = ed.child_spans(None).unwrap();
75 wysiwyg::build_cached(
76 &top,
77 &src,
78 None,
79 false,
80 &HashMap::new(),
81 None,
82 &mut cache,
83 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
84 )
85 .rows
86 .len()
87 });
88 }
89
90 println!(" -- claimed hot, actually noise --");
91 time("twig source_str() (full copy)", 5, || {
92 ed.source_str().unwrap().len()
93 });
94 let clean = src.clone();
95 time("dirty compare (full cmp)", 5, || src == clean);
96
97 println!(" -- what the GUI adds on a cache miss --");
98 time("clone every row's glyphs", 5, || {
99 map.rows
100 .iter()
101 .map(|r| r.glyphs.clone())
102 .collect::<Vec<_>>()
103 .len()
104 });
105 time("hash every glyph (cache key?)", 5, || {
106 let mut n = 0u64;
107 for r in &map.rows {
108 let mut h = std::collections::hash_map::DefaultHasher::new();
109 for g in &r.glyphs {
110 g.ch.hash(&mut h);
111 }
112 n ^= h.finish();
113 }
114 n
115 });
116
117 println!(" -- the whole path, as a frontend calls it --");
118 let mut p = std::env::temp_dir();
119 p.push(format!("leaf_bench_{kb}.md"));
120 std::fs::write(&p, &src).unwrap();
121 let mut d = Doc::open(p).unwrap();
122 d.view = View::Wysiwyg;
123 d.place_caret(src.len() / 2, false);
124 d.build_visual_unwrapped();
125 time("build_visual (cached: a repaint)", 200, || {
126 d.build_visual_unwrapped()
127 });
128 time("Doc::insert + rebuild (a keystroke)", 5, || {
129 d.insert("x");
130 d.build_visual_unwrapped();
131 });
132 println!();
133 }
134}Sourcepub fn paste(&mut self, text: &str)
pub fn paste(&mut self, text: &str)
Insert clipboard text at the caret, replacing the selection if there is
one — always its own undo step, whatever its length.
Provenance is the whole point, and only the caller has it. insert reads
a lone character as a keystroke and folds it into the run around it,
which is right for typing and wrong for a one-character paste: that paste
would vanish mid-run on an undo it was never part of, and the characters
the user actually typed would go with it. Length can’t tell the two
apart — ⌘V of x and typing x are the same string — so the door the
caller comes through is what says which happened.
Sourcepub fn edit_composing(&mut self, start: usize, end: usize, text: &str)
pub fn edit_composing(&mut self, start: usize, end: usize, text: &str)
Replace [start, end) with text as one step of an IME composition —
the same splice as edit, but marked so the run of steps
folds into a single undo.
A composition is one act of writing. Typing かんじ and picking 感じ is a
dozen calls here, each replacing the last one’s provisional bytes, and an
undo step per call means undoing a word means pressing ⌘Z until the reading
unspools backwards through kana — the intermediate states were never text
the user wrote. Only the frontend knows a call is provisional (the bytes
look like any other edit), so the door the caller comes through is what
says so, exactly as it is for paste versus
insert.
Pair with end_composition, or the next
composition folds into this one.
Sourcepub fn end_composition(&mut self)
pub fn end_composition(&mut self)
Close the open composition run, so the next one is its own undo step. Call when the IME commits or withdraws a composition.
Only clears a composition run: a frontend that reports an end it never began (some IMEs unmark unprompted) would otherwise split the run of typing around it into two undo steps for no reason the user can see.
Sourcepub fn selection_html(&mut self) -> Option<String>
pub fn selection_html(&mut self) -> Option<String>
The selection rendered as HTML, for the clipboard’s text/html flavor —
what lets a paste into Docs/Mail/Slack keep its formatting. None when
nothing is selected, or when the selection doesn’t render (the caller
still has selected_text, which is what to publish
as text/plain either way).
The fragment is a source substring, and that is the honest limit here.
It’s parsed standalone, so a selection whose meaning depends on its
surroundings converts as what it literally says rather than what it looks
like on screen: half a list item is a paragraph, a row torn out of a table
is the text of a row, the ** of a bold run selected without its closing
** is two asterisks. Every one of those still renders — there’s no
error to report — it just renders as the fragment and not as the document.
Widening the range to whole blocks would publish text the user didn’t
select, which is a worse lie than a fragment being a fragment; the plain
flavor has the same substring, so the two flavors at least agree.
Sourcepub fn paste_html(&mut self, html: &str) -> bool
pub fn paste_html(&mut self, html: &str) -> bool
Paste the clipboard’s text/html flavor, converting it to this document’s
format first. Its own undo step, like any paste.
Returns whether it landed. false means the HTML didn’t convert to
anything worth pasting — the caller should fall back to the plain flavor
rather than treat it as an error. The html module has the full list of
what that covers: a table twig won’t build, markup it doesn’t recognise,
an empty result.
Sourcepub fn indent(&mut self)
pub fn indent(&mut self)
Indent the selected lines — or the caret’s line, with no selection — by one level (Tab).
Sourcepub fn outdent(&mut self)
pub fn outdent(&mut self)
Take one indent level back off the selected lines, or the caret’s line (Shift+Tab). A line with no indentation is left exactly as it is.
A line with less than a full level gives back what it has rather than refusing: outdent’s job is to walk a line left, and real documents — hand written, or reflowed by some other editor — are full of indentation that was never a clean multiple of anything. Refusing there would strand the line at a depth Shift+Tab couldn’t undo.
Sourcepub fn newline(&mut self)
pub fn newline(&mut self)
The Enter key.
In source view it’s a literal newline. In WYSIWYG it’s AST-aware: a
bare \n is only a markdown soft break (same paragraph), so the block the
caret is in decides what actually gets written.
- paragraph → twig’s
Editor::split_block, which parts the block at the caret and reopens its container - list item → likewise: the next item, its indent, quote
prefix and
[ ]box all reproduced by twig — except an empty item, which exits the list - block quote → likewise: a new paragraph inside the quote
- heading → a new paragraph, not another heading
- code block → a literal newline (stay in the block)
- blank line → a literal newline (one Backspace undoes it)
LineFlow::Preserve→ a single soft break, which renders as a visible line
Where split_block is used it replaces markup leaf used to spell by hand,
and it is better at it: it drops the whitespace the caret was sitting in
front of instead of stranding it at the head of the second half, and it
knows continuations leaf’s marker scan never covered — a checklist item
continues as an unchecked checklist item rather than a plain bullet.
The exceptions above are exceptions because split_block is either wrong
there or refuses: parting a fence yields two fences with the code split
between them, parting a heading yields a second heading where every editor
gives a paragraph, and a blank line, an empty item, a setext heading and a
table all report an error rather than a split.
pub fn backspace(&mut self)
pub fn delete_forward(&mut self)
Sourcepub fn delete_word_back(&mut self)
pub fn delete_word_back(&mut self)
Delete from the caret back to the start of the previous word (⌥⌫ / Ctrl+⌫). Deletes the selection instead when one is active.
Sourcepub fn delete_word_forward(&mut self)
pub fn delete_word_forward(&mut self)
Delete from the caret forward to the end of the next word (⌥⌦ / Ctrl+Del). Deletes the selection instead when one is active.
Sourcepub fn delete_to_line_start(&mut self)
pub fn delete_to_line_start(&mut self)
Delete from the caret back to the start of its line (⌘⌫). Deletes the selection instead when one is active, as every other delete here does.
The line is the view’s own — the one Home and End work on, so in WYSIWYG
a soft-wrapped row is a line. It is not Home’s target, though: Home
stops at the first character and this takes the indentation with it, the
way Cocoa’s deleteToBeginningOfLine: does. Stopping at the text would
leave an indent behind that nothing can then ask to delete, where a caret
left at column 0 is one press of Home away from either.
Sourcepub fn delete_to_line_end(&mut self)
pub fn delete_to_line_end(&mut self)
Kill from the caret to the end of its line (^K). Deletes the selection instead when one is active.
At the end of the line it does nothing, rather than pulling the line below up into this one. Joining has no meaning to give it in both views at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and there is nothing there to delete, while the newline a source line ends with is only half of the blank line that separates two paragraphs — deleting one leaves a soft break, which is not the join it looks like. The views agreeing is worth more than emacs’ second press, and Delete is already the key that joins.
Sourcepub fn toggle(&mut self, kind: InlineKind)
pub fn toggle(&mut self, kind: InlineKind)
Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps the toggled region selected so a second press cleanly reverses it.
Sourcepub fn set_block(&mut self, kind: BlockKind)
pub fn set_block(&mut self, kind: BlockKind)
Convert the block at the caret to a heading level or paragraph.
Sourcepub fn current_heading_level(&mut self) -> Option<u32>
pub fn current_heading_level(&mut self) -> Option<u32>
The heading level of the text block at the caret, or None when that
block is not a heading.
Sourcepub fn active_inline_marks(&mut self) -> InlineMarks
pub fn active_inline_marks(&mut self) -> InlineMarks
The inline marks in force at the caret (or over the selection) — what a
toolbar draws lit, and the block-level Doc::current_heading_level’s
inline counterpart. Cheap enough to call every frame: one twig
ancestors_at query per caret (two with a selection), each walking root
→ deepest node at one offset. It never snapshots the tree the way
current_heading_level does, and the returned set is a Copy bitset, so
the only allocation is twig’s own small ancestor Vec.
A selection reports a mark only when the mark covers all of it.
That’s what every real toolbar means by an active button — Bold lit over
a half-bold selection would claim a press turns bold off, when
Doc::toggle hands the range to twig and gets the whole thing bolded.
Whole-coverage is asked as “is the same mark node standing over both the
first and the last character?”: inline nodes are contiguous, so one node
covering both ends covers every byte between them. Two touching runs
(**a****b**) are two nodes, and correctly light nothing.
At a bare caret a mark is active when the caret stands inside the mark’s
span — span.start <= caret < span.end, delimiters included, which is
what makes the boundaries behave. In a **bold** b the offsets from the
opening * (2) through the last byte of the closing ** (9) are all
bold, so the WYSIWYG caret both before b and after d (the delimiters
are hidden, and those offsets are 4 and 8) reports bold — matching where
typing would actually land inside the marked run. The offset one past the
mark (10) is the text after it and reports nothing, at the end of the
buffer exactly as in the middle.
Sourcepub fn toggle_heading(&mut self, level: u32)
pub fn toggle_heading(&mut self, level: u32)
Toggle a heading at the caret: if the block is already this heading level, revert it to a paragraph; otherwise convert it to this heading level. This gives the heading commands the same toggle feel as bold/italic/code — re-applying a heading a line already has turns it back into body text.
Sourcepub fn toggle_blockquote(&mut self)
pub fn toggle_blockquote(&mut self)
Toggle a block quote around the selection, or around the block at the caret — the toolbar’s Quote button.
Sourcepub fn toggle_list(&mut self, ordered: bool)
pub fn toggle_list(&mut self, ordered: bool)
Toggle a numbered (ordered) or bulleted list over the selection, or
over the block at the caret — one op with the kind as a flag, the way
toggle_heading takes its level, so a frontend needs no twig type to
name the two buttons.
Pressing the other list’s button while in a list converts in place rather than nesting, so the pair reads as one three-state control (bulleted / numbered / neither) rather than two independent wrappers.
Sourcepub fn task_checked_at_caret(&mut self) -> Option<bool>
pub fn task_checked_at_caret(&mut self) -> Option<bool>
Whether the list item at the caret carries a checkbox, and which way it
faces — Some(true) ticked, Some(false) empty, None for a plain list
item or no item at all. What a toolbar reads to light its checkbox button.
Sourcepub fn task_checked_at(&mut self, offset: usize) -> Option<bool>
pub fn task_checked_at(&mut self, offset: usize) -> Option<bool>
task_checked_at_caret for an arbitrary
offset — what a frontend asks before deciding a click landed on a box.
Sourcepub fn toggle_task_checked(&mut self)
pub fn toggle_task_checked(&mut self)
Tick or untick the task item at the caret (the checkbox’s keyboard half).
A no-op with a reported reason when the caret is in no task item — minting
a box here is toggle_task_item’s job.
Sourcepub fn toggle_task_at(&mut self, offset: usize)
pub fn toggle_task_at(&mut self, offset: usize)
Tick or untick the task item covering offset — what a click on a
rendered checkbox is. Separate from the caret form because a click carries
its own offset and must not first move the caret there: ticking a box
three paragraphs away should not take the cursor with it.
Sourcepub fn toggle_task_item(&mut self)
pub fn toggle_task_item(&mut self)
Give the list item at the caret a checkbox, or take its checkbox away — the gesture that converts between a plain bullet and a task. A new box arrives unticked.
Sourcepub fn caret_in_table(&mut self) -> bool
pub fn caret_in_table(&mut self) -> bool
Whether the caret is inside a table — what a frontend asks to enable or disable its table controls.
An HTML <table> still answers true: the caret really is in a table,
and the reason the grid controls stay dark there is
Capabilities::table, which is a fact about the document’s format
rather than about the caret. A frontend needs both.
Sourcepub fn table_insert_row(&mut self, below: bool)
pub fn table_insert_row(&mut self, below: bool)
Insert an empty row below (below) or above the caret’s row.
Sourcepub fn table_delete_row(&mut self)
pub fn table_delete_row(&mut self)
Delete the caret’s row (not the header, not the last body row).
Sourcepub fn table_insert_column(&mut self, right: bool)
pub fn table_insert_column(&mut self, right: bool)
Insert an empty column right (right) or left of the caret’s column.
Sourcepub fn table_delete_column(&mut self)
pub fn table_delete_column(&mut self)
Delete the caret’s column (unless it is the only one).
Sourcepub fn table_set_alignment(&mut self, alignment: Alignment)
pub fn table_set_alignment(&mut self, alignment: Alignment)
Set the caret’s column to alignment.
Sourcepub fn table_move_row(&mut self, down: bool)
pub fn table_move_row(&mut self, down: bool)
Move the caret’s row one place down (down) or up, within the body rows.
Sourcepub fn table_move_column(&mut self, right: bool)
pub fn table_move_column(&mut self, right: bool)
Move the caret’s column one place right (right) or left.
Sourcepub fn insert_link(&mut self, destination: &str)
pub fn insert_link(&mut self, destination: &str)
Link the selection to destination — the toolbar’s Link button. With no
selection it acts at the caret, which re-points a link the caret is
already standing in (twig replaces an existing link’s destination and
keeps its text) and otherwise spells a link that has no text of its own:
an autolink (<https://x.dev>) where the destination is one, and
[destination](destination) where it isn’t.
destination reaches twig raw. Escaping it is format knowledge and the
two formats genuinely disagree — Markdown ends a destination at the first
space and moves it into <…>, djot reads that <…> as part of the URL
itself — so the side holding the document is the side that gets to spell
it. A destination twig can’t carry at all (one with a newline) comes back
as an error rather than a quietly rewritten URL.
Sourcepub fn insert_image(&mut self, destination: &str, alt: &str)
pub fn insert_image(&mut self, destination: &str, alt: &str)
Insert a block-level image at the caret: . Any
selection becomes the alt text (so “select a caption, insert image” labels
it); with no selection, alt is used — empty for none. The caret lands
just past the inserted image.
Both halves go through twig (insert_literal for the alt text,
insert_image for the image), so neither is spelled here. That used to be a
format!, and it was wrong the first time an app inserted a real filename:
Markdown ends a destination at the first space, so  is
not an image at all — and the fix is per-format, since moving into the
<…> form is exactly wrong for Djot, where <…> becomes the URL itself.
Sourcepub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str)
pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str)
Insert a block-level image, video, or audio at the caret. The image case
is insert_image; video and audio are spelled as
HTML elements, which is the only spelling Markdown and Djot have for them:
<video src="clip.mp4" controls>alt</video>
<audio src="take.mp3" controls>alt</audio>HTML rather than a ::video{…} directive deliberately. A directive means
something only to an app that knows the vocabulary, so the document would
read as literal punctuation everywhere else; <video> is what every other
renderer already understands, and what leaf’s own reader picks back up
through html_elements promotion (see [parse_extensions]).
The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark’s
HTML-block tag list to cover <video>/<audio>/<picture> under
html_elements. Before that only the multi-line form parsed as a block at
all, and this wrote three lines to work around it.
controls is always written: a player with no transport is a still frame
the reader can’t do anything with. Any selection becomes the element’s
fallback text, exactly as it becomes an image’s alt.
The same verbatim-insertion caveat as insert_image
applies, and bites harder here: a " in destination closes the
attribute. A frontend taking these from a file picker is fine; one taking
them from free text should keep them tame.
Sourcepub fn insert_thematic_break(&mut self)
pub fn insert_thematic_break(&mut self)
Insert a thematic break at the caret — the toolbar’s Horizontal Rule
button. Spelling and placement are both twig’s; leaf used to write ---
itself, which was the Markdown spelling in a djot document too.
A rule is a block, so insert_thematic_break alone has nowhere to put one
mid-paragraph and lands it after the caret’s whole block. To get a rule
at the caret — the paragraph parted in two around it, which is what a
rule button is understood to do — the paragraph is first divided with
split_block and the rule then aimed at the first half. Aiming it at
the offset split_block returns puts the rule after the second half
instead, which is a rule in the right document and the wrong place.
Only a plain paragraph is split. Everywhere else the rule simply lands after the block, which is both twig’s own answer and the better one: splitting a fenced code block would leave two fences with a rule between them, and splitting a list item would mint an item nobody asked for on the way to a rule that lands after the list regardless. A table and a setext heading refuse the split outright, so they take the same path by themselves.
Sourcepub fn link_destination_at_caret(&mut self) -> Option<String>
pub fn link_destination_at_caret(&mut self) -> Option<String>
The destination of the link under the caret — what a Link prompt shows so
⌘K on an existing link edits its URL instead of asking for it again.
None when the caret stands in no link.
An autolink carries no separate destination: its text is the URL, so that’s what comes back for one.
Sourcepub fn link_destination_at(&mut self, off: usize) -> Option<String>
pub fn link_destination_at(&mut self, off: usize) -> Option<String>
The destination of the link at off.
link_destination_at_caret for a place
the caret isn’t.
The offset form exists for the same reason
footnote_at’s does: a frontend drawing a piece of
the document somewhere else — a footnote’s text in a popover, say — has
rows and runs but no caret in them, and still needs to know which of those
runs a reader can follow.
Sourcepub fn locate(&mut self, id: &str) -> Option<Landing>
pub fn locate(&mut self, id: &str) -> Option<Landing>
Where the locator id lands in this document — the #v2 half of a
chapter.dj#v2, resolved to the block it names. None when nothing here
answers to it.
The other end of a link, and the reason this exists: without it a destination has only file granularity, so following a citation into a chapter drops the reader at the top of it to hunt for the verse. Which is also why it is a document query rather than a caret one — the document being asked is usually not the one the reader is in.
Three readings, tried in order, because the same #some-heading is
written three ways across the formats leaf opens:
- A declared id, exactly as written: djot’s
{#v1}on a block, and the auto-ids djot mints for its headings. The only exact answer, so it goes first — a document that says{#v1}has settled the question. - A declared id, slugged. djot spells a heading’s auto-id
Some-Heading-Here; nearly every tool that writes a link to one spells it#some-heading-here. Comparing slugs is what lets a link authored anywhere land on a djot heading. - A heading’s text, slugged. Markdown has no ids at all — twig mints
none and
{#custom}is literal text in a Markdown heading — so for the format most vaults are written in, the heading’s own words are the only thing a fragment can name. This is the rule every Markdown renderer already follows, which is what makes#a-headingmean in diaryx what it means on the web.
Ties go to the earliest match, then to the widest: a duplicated id is the document’s mistake and the first one is the answer every anchor implementation gives, while preferring the wider span picks the section over the heading that opens it — more for a peek to show, same place to land.
Sourcepub fn insert_footnote(&mut self)
pub fn insert_footnote(&mut self)
Write a footnote at the caret — the toolbar’s Footnote button, and the one gesture in the footnote story that authors rather than follows.
Both halves go in as one twig edit: the [^1] where the caret is, and
the [^1]: definition at the end of the document. Half a footnote is not
a footnote — a bare reference with nothing defining it renders as literal
brackets — so a single button that wrote only the reference would leave
the author to hand-spell the other half in a document that had just
stopped showing them what the first half meant. One edit also means one
undo takes both back.
The definition’s body is left empty and the caret lands in it, which
is the whole point of pressing the button: nobody wants a reference to a
note they have not written yet. Getting back to where they were writing
is footnote_definition_at_caret —
the same return leg a reader following a reference already uses, so the
author is left standing on the near end of a round trip that works.
A selection collapses to its end rather than being replaced: a reference annotates the words before it, so “select the claim, add a footnote” should mark that claim, not consume it.
Sourcepub fn footnote_at_caret(&mut self) -> Option<FootnoteRef>
pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef>
The footnote reference under the caret, resolved to the note it names.
footnote_at at the caret’s offset.
Sourcepub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef>
pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef>
The footnote reference at off, resolved to the note it names — what a
frontend shows when a reader activates a [^1].
A reference is not a link node, so
link_destination_at_caret does not
(and should not) answer for one: a link names a destination to leave for,
a reference names a note that is already in this document. Following one
is a move within the page, which is why this hands back an offset
rather than something to open.
Offset-based rather than caret-only because the gesture that wants this
most is the one that must not move the caret: a pointer hovering a [1]
asks what note it names without disturbing where the reader was typing.
The caret is just the offset a click already placed —
footnote_at_caret passes it.
None when off stands in no reference. A reference whose note the
document never defines is not None — it answers with the label it
looked for and no text, which is what lets a frontend say so instead of
silently doing nothing.
Sourcepub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef>
pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef>
The footnote definition the caret stands in, and where the reference
that names it is. footnote_definition_at
at the caret’s offset.
Sourcepub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef>
pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef>
The footnote definition spanning off, and where the reference that
names it is — the return leg of footnote_at.
The mirror image, deliberately: the same gesture that takes a reader from
[1] down to the note takes them from the note back up to [1], so
following a footnote is a round trip rather than a fall. It needs no
memory of how the reader arrived — the document says where the reference
is — which is what makes it work for a reader who scrolled to the notes
themselves, and what keeps it right after an edit moves either end.
None when off stands in no definition. A definition nothing cites is
not None, for FootnoteRef’s reason in reverse: it answers with
its label and no offset, so a frontend can say “nothing refers to this”
rather than offer a jump that goes nowhere.
Sourcepub fn image_destination_at_caret(&mut self) -> Option<String>
pub fn image_destination_at_caret(&mut self) -> Option<String>
The destination of the image under the caret — what an image prompt shows
so editing an existing image starts from its current URL instead of blank,
the image analogue of link_destination_at_caret.
None when the caret stands in no image. A caret resting just after a
block image (its trailing stop) is still “in” it — the half-open span test
excludes that offset, which is the intended precision: past the image is
past it.
Sourcepub fn code_language_at_caret(&mut self) -> Option<String>
pub fn code_language_at_caret(&mut self) -> Option<String>
The language of the fenced code block the caret stands in — what a
language prompt shows so editing it starts from the current value rather
than blank. None when the caret is in no code block, or in one whose
fence carries no language (or an indented block, which has no fence).
Sourcepub fn caret_in_fenced_code(&mut self) -> bool
pub fn caret_in_fenced_code(&mut self) -> bool
Whether the caret stands in a fenced code block — the one a language
prompt could edit. A frontend gates its “set language” affordance on this
(an indented block, which can’t carry a language, reports false).
Sourcepub fn set_code_language(&mut self, lang: &str)
pub fn set_code_language(&mut self, lang: &str)
Set (or clear, with "") the language of the fenced code block the caret
is in — the prompt’s confirm. A no-op when the caret is in no fenced
block, and a reported error for a language the format’s fence cannot
carry.
twig rewrites the info string, so the fence’s own width — measured
against a body neither side touches — is kept, and a language holding a
space, a line end or the fence character is refused rather than written
out to reparse as something else. Leaf used to splice over the info span
itself and trim() the input, which handled the one bad case it had
thought of.
Sourcepub fn undo(&mut self)
pub fn undo(&mut self)
Undo the last edit step (⌘Z / ^Z), putting the caret and selection back where they were when that step began.
Sourcepub fn redo(&mut self)
pub fn redo(&mut self)
Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and selection back where that step originally left them.
pub fn save(&mut self)
Sourcepub fn save_as(&mut self, path: PathBuf)
pub fn save_as(&mut self, path: PathBuf)
Save As: write the document to path and move it there — self.path
becomes path, and every later Doc::save writes the new file. That’s
what Save As means; a copy would leave the user editing a document whose
name is no longer where their keystrokes go.
The move only happens if the bytes actually landed. A failed write leaves
the path, dirty, and the disk watermark exactly as they were, with the
same save failed: … status a failed Doc::save sets — the document
must never come away believing it was saved.
An existing path is overwritten, and the caller is the one that knows
whether to ask first: a Save As picker has already run that prompt, and a
second confirmation from down here would be the same question twice.
format does not follow the new extension. The buffer is parsed as
the format it was opened with, and re-reading it as another one is a
conversion — a different, lossy operation that would throw away the undo
history — not a rename. So notes.md saved as notes.dj holds Markdown
in a .dj file, and format_name() keeps honestly saying markdown
until it’s reopened.
Sourcepub fn mark_saved(&mut self)
pub fn mark_saved(&mut self)
Re-base the document’s saved watermark to the current bytes: clears
dirty, records source as the new clean state (so undoing back to here
clears the flag again), and re-stamps the on-disk hash.
Doc::save/Doc::save_as call this after a write lands. It is also
the hook a filesystem-free host calls itself once it has persisted
Doc::source its own way (a browser download, localStorage, a backend
PUT) — which is why it is public and touches no filesystem: the bytes
are already where that host wants them, and this just tells the model they
are safe.
Sourcepub fn disk_state(&self) -> DiskState
pub fn disk_state(&self) -> DiskState
What the file looks like now against the bytes leaf last read or wrote.
Reads the file and hashes it (see disk_hash for why it isn’t an mtime),
so this is a filesystem round-trip, not a per-frame question — ask it
when a window regains focus, on a timer, or before a save.
This only reports the file. Whether the document also has unsaved edits
is dirty, and the interesting case is the conjunction: dirty plus
DiskState::Changed means a save overwrites someone’s work and a
Doc::reload discards the user’s. leaf-core deliberately won’t choose —
it has no way to ask — so it hands a frontend both halves and lets it put
the question to the person who can answer it.
Sourcepub fn reload(&mut self)
pub fn reload(&mut self)
Re-read the file and replace the document with what’s there — the other
answer to a DiskState::Changed.
Discards unsaved changes and the undo history, unconditionally. It
doesn’t check dirty first: a frontend that wants to protect unsaved
work asks (dirty + Doc::disk_state) before calling this, and one
reloading a clean document shouldn’t have to argue with a guard. The
history goes because twig’s undo stack belongs to the buffer, and these
are different bytes — replaying a step recorded against the old ones onto
them would corrupt the document, and nothing here can honestly rebase it.
The caret keeps its byte offset, clamped to the new length; the selection is dropped. Anything cleverer would be a lie: leaf doesn’t know how the file changed, so it can’t know where the caret “still” is. Clamping keeps it where the user left it in the common case (a change further down the file, or none in the text they’re sitting in), and never puts it somewhere invalid. A selection has two such offsets and no such excuse — silently reinterpreting one over changed bytes would arm the next keystroke to delete something the user never selected.
Nothing is touched unless the whole reload succeeds; a failure leaves the document alone with a status.
Sourcepub fn place_caret(&mut self, offset: usize, extend: bool)
pub fn place_caret(&mut self, offset: usize, extend: bool)
Place the caret at byte offset (clamped to a char boundary), extending
the selection when extend is set. The public form of move_to, for a
frontend that hit-tests pixels straight to a source offset.
Examples found in repository?
34fn main() {
35 for kb in [10usize, 100, 1000] {
36 let src = body(kb * 1024);
37 println!("=== {} KB ===", src.len() / 1024);
38
39 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
40 let nodes = ed.nodes().unwrap();
41 let map = wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None);
42 println!(" ({} AST nodes, {} map rows)", nodes.len(), map.rows.len());
43
44 println!(" -- per edit (unavoidable today) --");
45 time("twig edit_range (reparse)", 5, || {
46 ed.edit_range(src.len() / 2, src.len() / 2, "x").is_ok()
47 });
48 time("twig nodes() FFI marshal", 5, || ed.nodes().unwrap().len());
49 time("wysiwyg::build", 5, || {
50 wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None)
51 .rows
52 .len()
53 });
54 {
55 // The incremental path with a warm cache and nothing changed: the
56 // floor cost the block cache adds even on a pure repaint — hash every
57 // block, clone every reused row, recollect stops. No subtree is
58 // marshalled (every block hits). The real keystroke win shows up in
59 // "Doc::insert + rebuild" below, which re-marshals only the edited
60 // block and reuses the rest.
61 let mut cache = wysiwyg::BlockCache::default();
62 let top = ed.child_spans(None).unwrap();
63 let _ = wysiwyg::build_cached(
64 &top,
65 &src,
66 None,
67 false,
68 &HashMap::new(),
69 None,
70 &mut cache,
71 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
72 );
73 time("wysiwyg::build_cached (all reused)", 5, || {
74 let top = ed.child_spans(None).unwrap();
75 wysiwyg::build_cached(
76 &top,
77 &src,
78 None,
79 false,
80 &HashMap::new(),
81 None,
82 &mut cache,
83 |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
84 )
85 .rows
86 .len()
87 });
88 }
89
90 println!(" -- claimed hot, actually noise --");
91 time("twig source_str() (full copy)", 5, || {
92 ed.source_str().unwrap().len()
93 });
94 let clean = src.clone();
95 time("dirty compare (full cmp)", 5, || src == clean);
96
97 println!(" -- what the GUI adds on a cache miss --");
98 time("clone every row's glyphs", 5, || {
99 map.rows
100 .iter()
101 .map(|r| r.glyphs.clone())
102 .collect::<Vec<_>>()
103 .len()
104 });
105 time("hash every glyph (cache key?)", 5, || {
106 let mut n = 0u64;
107 for r in &map.rows {
108 let mut h = std::collections::hash_map::DefaultHasher::new();
109 for g in &r.glyphs {
110 g.ch.hash(&mut h);
111 }
112 n ^= h.finish();
113 }
114 n
115 });
116
117 println!(" -- the whole path, as a frontend calls it --");
118 let mut p = std::env::temp_dir();
119 p.push(format!("leaf_bench_{kb}.md"));
120 std::fs::write(&p, &src).unwrap();
121 let mut d = Doc::open(p).unwrap();
122 d.view = View::Wysiwyg;
123 d.place_caret(src.len() / 2, false);
124 d.build_visual_unwrapped();
125 time("build_visual (cached: a repaint)", 200, || {
126 d.build_visual_unwrapped()
127 });
128 time("Doc::insert + rebuild (a keystroke)", 5, || {
129 d.insert("x");
130 d.build_visual_unwrapped();
131 });
132 println!();
133 }
134}Sourcepub fn select_all(&mut self)
pub fn select_all(&mut self)
Select the whole document (⌘A / Ctrl+A) — everything reachable in the active view, so in WYSIWYG it starts below hidden frontmatter (copy won’t grab the metadata) while the source view still selects the literal whole.
Sourcepub fn select_word_at(&mut self, offset: usize)
pub fn select_word_at(&mut self, offset: usize)
Select the word (or whitespace / punctuation run) at offset — the
double-click gesture. Anchors on the run’s start with the caret at its
end so a following Shift-motion extends from the far edge.
Sourcepub fn select_block_at(&mut self, offset: usize)
pub fn select_block_at(&mut self, offset: usize)
Select the whole enclosing text block (paragraph, heading, list item’s
text…) at offset — the triple-click gesture. Reads the range straight
from the AST (twig’s content_span), so it selects the entire logical
paragraph even when that paragraph soft-wraps across several visual rows —
where a visual-row-based select breaks down, because one source offset at
a wrap boundary belongs to two rows at once.
pub fn move_left(&mut self, extend: bool)
pub fn move_right(&mut self, extend: bool)
Sourcepub fn move_word_left(&mut self, extend: bool)
pub fn move_word_left(&mut self, extend: bool)
Move to the start of the previous word (⌥← / Ctrl+←).
Sourcepub fn move_word_right(&mut self, extend: bool)
pub fn move_word_right(&mut self, extend: bool)
Move to the end of the next word (⌥→ / Ctrl+→).
pub fn move_up(&mut self, extend: bool)
pub fn move_down(&mut self, extend: bool)
Sourcepub fn move_home(&mut self, extend: bool)
pub fn move_home(&mut self, extend: bool)
Home: to the first character on the line, or to column 0 when the caret is already on it — the two-press toggle every editor spells this way. The indentation is somewhere the caret has to be able to reach and almost never where a reader is headed, so it costs the second press.
Sourcepub fn cell_hop(&mut self, forward: bool) -> bool
pub fn cell_hop(&mut self, forward: bool) -> bool
Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
cell’s whole content selected (see Self::select_cell). Returns false
when the caret isn’t in a table, or is already in the last/first cell — the
frontend then does whatever Tab normally does (indent), so Tab keeps its
meaning everywhere else.
Sourcepub fn cell_move_vertical(&mut self, down: bool) -> bool
pub fn cell_move_vertical(&mut self, down: bool) -> bool
Move the caret to the cell directly above (down == false) or below in
the same column, landing with the cell’s whole content selected (see
Self::select_cell). Returns false at the grid’s top/bottom edge (or
when the caret isn’t in a table), so the frontend can fall through — the
vertical counterpart of [cell_hop].
A ragged row that is short a column clamps to its last cell, so Down never falls out of the table over a gap the row above happened to have.
Sourcepub fn cell_tab(&mut self, forward: bool) -> bool
pub fn cell_tab(&mut self, forward: bool) -> bool
Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
fresh row and entering it when it runs off the last one; Shift+Tab steps
back and simply stays put at the very first cell. false when the caret
isn’t in a table.
Sourcepub fn cell_return(&mut self) -> bool
pub fn cell_return(&mut self) -> bool
Return inside a table: drop to the cell below in the same column,
appending a new row when the caret is already in the last one. false
when the caret isn’t in a table, so the frontend inserts a newline.
Sourcepub fn cell_line_break(&mut self) -> bool
pub fn cell_line_break(&mut self) -> bool
Shift+Return inside a table: insert a hard line break within the current
cell, via twig’s insert_line_break. false when the caret isn’t in a
table, so the frontend inserts an ordinary line break.
A table row is a single source line, so the newline-spelled hard break
can’t live in a cell. twig spells the in-cell break the format’s way
(<br> for Markdown) and reparses it as a semantic hard_break, so the
break round-trips as structure the renderer reads back as a line — not the
opaque raw HTML the old raw-splice left behind.
Djot has no idiomatic in-cell break, so twig refuses it
(UnsupportedFormat) rather than emit a <br> that any other djot reader
would render as the literal text <br>. The gesture is still consumed
there — returning false would let the frontend insert a real newline,
which splits the one-line row — it just leaves the cell unchanged and says
so on the status line. A rollback (EditConflict) is swallowed the same.
Which formats refuse is Capabilities::cell_line_break, and the two
have to be read together: djot is not the only false, and naming it in
the message was already a guess that HTML — which spells the break as its
own <br> — would have made wrong.
Sourcepub fn move_doc_start(&mut self, extend: bool)
pub fn move_doc_start(&mut self, extend: bool)
Move the caret to the very start of the document (⌘↑ on macOS, Ctrl+Home on Windows/Linux).
Sourcepub fn move_doc_end(&mut self, extend: bool)
pub fn move_doc_end(&mut self, extend: bool)
Move the caret to the very end of the document (⌘↓ on macOS, Ctrl+End on Windows/Linux).
Sourcepub fn click(&mut self, row: usize, col: usize, extend: bool)
pub fn click(&mut self, row: usize, col: usize, extend: bool)
Point the caret at the body cell (row, col) the mouse landed on —
col being a cell of the terminal grid, which is what a display column
is. A click on the far cell of a wide character lands at that
character’s start; the mapping’s own doc-comments carry the rule.
Sourcepub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize)
pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize)
Settle scroll for a frame about to be drawn: follow the caret onto the
screen if it has moved since the last frame, and never scroll past the
last of rows.
Only if it has moved — that’s the whole point. Revealing the caret on every frame ties the viewport to it, and a scroll wheel that fights the caret for the viewport loses: the view snaps back the instant it tries to pass the caret’s row, so the document can’t be scrolled beyond what’s already on screen. A caret move is the frontend’s cue to follow; a scroll with the caret sitting still is the reader’s cue to leave it alone.