Skip to content

Commit 9e22614

Browse files
committed
Merge callouts: nested foldable admonitions + FoldAnchor DRY, table healing/auto-grow, dev-profile perf, README screenshots, audit fixes
2 parents d169ec5 + 5c42d8c commit 9e22614

28 files changed

Lines changed: 2151 additions & 500 deletions

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ repository = "https://github.com/wilfreddenton/writ"
99
homepage = "https://github.com/wilfreddenton/writ"
1010
documentation = "https://docs.rs/writ"
1111

12+
[package.metadata.docs.rs]
13+
all-features = true
14+
1215
[lints.clippy]
1316
too_many_arguments = "allow"
1417

@@ -100,6 +103,14 @@ tree-sitter-rust = "0.24"
100103
undo = "0.52"
101104
notify-debouncer-full = { version = "0.5", optional = true }
102105

106+
# Optimize dependencies even in dev/test builds. writ's own code stays unoptimized
107+
# (fast incremental compile + debuggable), but the graphics/parsing stack (wgpu, vello,
108+
# parley/swash, tree-sitter, gix, …) runs at opt-level 2. Without this a debug build shapes
109+
# text ~10× slower and its large unoptimized working set page-faults in over the first ~30s
110+
# of use — making the editor feel sluggish at startup even though release is smooth.
111+
[profile.dev.package."*"]
112+
opt-level = 2
113+
103114
[profile.release]
104115
lto = "thin"
105116
codegen-units = 1

README.md

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
A hybrid markdown editor combining raw text editing with live inline rendering.
88

9+
![writ rendering Markdown inline — a clean heading, styled prose, and a syntax-highlighted Rust code block](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/hero.png)
10+
911
## Install
1012

1113
```bash
@@ -55,7 +57,9 @@ comment = "#7A88B8"
5557

5658
### Inline git diff
5759

58-
When the open file lives in a git repository, writ renders a live inline diff against `HEAD`: added lines and words are tinted green, deleted lines appear as red "ghost" rows above their position, all with the same markdown rendering as the rest of the document. writ watches the file, so edits made by an external tool (e.g. an AI agent) reload and re-diff live.
60+
When the open file lives in a git repository, writ renders a live inline diff against `HEAD`: added lines and words are tinted green, deleted lines appear as red "ghost" rows above their position, all with the same markdown rendering as the rest of the document. writ watches both the file and the repository's `HEAD`, so edits made by an external tool (e.g. an AI agent) reload and re-diff live — and committing, amending, or switching branches re-bases the diff immediately, even when the working file itself is untouched.
61+
62+
![Inline git diff: red ghost rows for deletions above green added lines, with word-level highlighting, all rendered as Markdown](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/diff.png)
5963

6064
### GitHub integration
6165

@@ -96,19 +100,15 @@ On Linux, using a faster linker significantly improves build times. See [Zed's l
96100

97101
### Build Profiles
98102

99-
Debug builds are significantly slower, especially for image loading and text rendering. For day-to-day development with better performance, use the `release-fast` profile:
100-
101-
```bash
102-
cargo run --profile release-fast -- --file path/to/document.md
103-
```
103+
The dev profile optimizes dependencies (`opt-level = 2` for all deps, via `[profile.dev.package."*"]`) while keeping writ's own code unoptimized and debuggable — so a plain `cargo run` is comfortably interactive (the graphics/parsing stack — wgpu, Vello, Parley, tree-sitter — runs at full speed). Dependencies compile once, so incremental writ builds stay fast.
104104

105105
For maximum runtime performance (slower compile times), use a full release build:
106106

107107
```bash
108108
cargo run --release -- --file path/to/document.md
109109
```
110110

111-
The release profile enables thin LTO and single codegen unit for best optimization. The `release-fast` profile trades some runtime performance for faster compilation by disabling LTO and using parallel codegen units.
111+
The release profile enables thin LTO and a single codegen unit for best optimization; the `release-fast` profile (`cargo run --profile release-fast`) trades some of that for faster compilation by disabling LTO and using parallel codegen units.
112112

113113
## Features
114114

@@ -126,6 +126,12 @@ Unordered list markers (`-`) are replaced with bullet symbols when the cursor is
126126

127127
Nesting is fully supported. A task item inside a blockquote is represented internally as a stack of layers, and each layer contributes its visual treatment independently.
128128

129+
### Callouts
130+
131+
Obsidian-style callouts — a blockquote whose first line is `> [!note]` (or `tip`, `warning`, `danger`, `success`, `question`, `example`, `quote`, …) — render inline with a type icon and accent color. Twelve types with their aliases are recognized. Add `+`/`-` after the type (`> [!tip]-`) to make a callout foldable (and start it collapsed); callouts nest, and each nesting level folds independently with the same gutter-chevron gestures as headings and lists. A custom title after the type (`> [!warning] Be careful`) replaces the default. As with other markers, the raw `[!type]` syntax reveals while your cursor is on the header line.
132+
133+
![Obsidian-style callouts: note, foldable tip, warning, and danger, each with its own icon and accent color](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/callouts.png)
134+
129135
### Smart Enter and Tab
130136

131137
Enter inserts a raw newline—no magic. Shift+Enter continues the current container by copying markers from the current line (e.g., on `- item|`, Shift+Enter creates `\n- `). Shift+Alt+Enter creates an indented continuation for nested paragraphs within list items.
@@ -142,24 +148,32 @@ Full selection support with click, drag (with edge auto-scroll), shift+arrow key
142148

143149
### GFM Tables
144150

145-
Pipe tables render as a live grid — bold header, per-column alignment (`:--`/`:-:`/`--:`), and inline styles (bold, code, links) inside cells. Move your cursor into a table and it reveals the raw pipe source for editing; move out and it snaps back to the grid. Type a header row like `| Name | Age |` and press Shift+Enter to scaffold the delimiter and a body row; Tab moves between cells (and appends a row from the last cell).
151+
Pipe tables render as a live grid — bold header, per-column alignment (`:--`/`:-:`/`--:`), and inline styles (bold, code, links) inside cells. Move your cursor into a table and it reveals the raw pipe source for editing; move out and it snaps back to the grid. Type a header row like `| Name | Age |` and press Shift+Enter to scaffold the delimiter and a body row; Tab moves between cells (and appends a row from the last cell). Type a new column into the header and the delimiter and every body row grow to match — in place, wherever you added it. Backspace on a freshly-added empty row removes it in one press.
152+
153+
![A GFM table rendered as a grid with a bold header and right-aligned numeric columns](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/tables.png)
146154

147155
### Find and Replace
148156

149157
Ctrl+F opens a find bar docked above the status bar; Ctrl+H adds the replace row. Matches highlight live with a count, and the current match is emphasized; Enter/Shift+Enter cycle through them. Toggle regular expressions (Alt+R, with `$1` capture groups in the replacement) and case sensitivity (Alt+C). Enter in the replace field swaps the current match, Ctrl+Enter replaces all in one undo step. Click into the document to keep the bar open while you edit.
150158

151159
### Folding
152160

153-
Headings and nested lists fold from the gutter. Hover the left margin to reveal a chevron on any foldable line and click to collapse its section; the outline mirrors what's folded. Modifier-clicks scope the fold: Ctrl folds every section at that level, Shift folds recursively, Ctrl+Shift folds that level and everything deeper. Jumping to a hidden line auto-unfolds it. Task lists fold the same way, so a long checklist collapses to its parent item.
161+
Headings, nested lists, and callouts all fold from the gutter through one unified model. Hover the left margin to reveal a chevron on any foldable line and click to collapse its section; the outline mirrors what's folded. Modifier-clicks scope the fold: Ctrl folds every section at that level, Shift folds recursively, Ctrl+Shift folds that level and everything deeper. Jumping to a hidden line auto-unfolds it. Task lists fold the same way, so a long checklist collapses to its parent item.
162+
163+
![A collapsed callout showing its gutter chevron, above a foldable heading section and a nested list](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/folding.png)
154164

155165
### Mermaid Diagrams
156166

157167
Fenced ` ```mermaid ` blocks render inline as diagrams. Rendering runs off the UI thread and is cached, so scrolling stays smooth. Drag a selection through a diagram (or move the cursor onto its block) and it de-sugars back to the raw source you're actually editing, then snaps back to the rendered diagram when you leave.
158168

169+
![A Mermaid flowchart rendered inline in writ](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/mermaid.png)
170+
159171
### Math
160172

161173
LaTeX math renders inline via RaTeX with the KaTeX fonts embedded. Block `$$…$$` renders as centered display math on its own line; inline `$…$` renders as a baseline-aligned box within the text (the GitHub/pandoc rule keeps prose like "$5 and $10" literal). Glyphs take the theme foreground color. As with mermaid, moving the cursor into a span reveals its raw source for editing.
162174

175+
![Inline and block LaTeX math rendered in writ](https://raw.githubusercontent.com/wilfreddenton/writ/main/assets/math.png)
176+
163177
## Library Usage
164178

165179
writ is also a library: its rendering and editing layers work independently of the
@@ -229,7 +243,7 @@ and opt into only what you need:
229243

230244
```toml
231245
# render-only: pulls in none of tokio/reqwest/gix/github/winit
232-
writ = { version = "0.15", default-features = false }
246+
writ = { version = "0.16", default-features = false }
233247
```
234248

235249
| Feature | Adds |
@@ -269,12 +283,6 @@ This manual extraction approach was chosen over tree-sitter's built-in injection
269283

270284
Currently Rust and Bash are supported; adding a language requires just the grammar crate and a highlights.scm query file. Highlights are cached and only recomputed after edits.
271285

272-
## Known Issues
273-
274-
### Short Headings Not Styled While Typing
275-
276-
When typing `# Hello`, tree-sitter doesn't recognize it as a heading until enough content is present or a newline is added. This is a quirk of the tree-sitter-md grammar. The heading styling appears once you press Enter or type enough characters.
277-
278-
### Ordered List Continuation Shows Wrong Number
286+
## Limitations
279287

280-
Pressing Shift+Enter on an ordered list item inserts `1. ` as a placeholder. The correct number appears after you start typing, when tree-sitter recognizes the list structure and auto-numbering corrects it.
288+
Only **ATX** headings (`# Heading`) are recognized — setext headings (a line underlined with `===` or `---`) render as plain text and don't appear in the outline or fold. This is deliberate: setext text and its underline live on separate lines, so full support means both collecting *and* rendering it across the pair, for a syntax `#` dominates in practice.

assets/callouts.png

60.9 KB
Loading

assets/diff.png

182 KB
Loading

assets/folding.png

72.2 KB
Loading

assets/hero.png

89.6 KB
Loading

assets/math.png

35.8 KB
Loading

assets/mermaid.png

51.5 KB
Loading

assets/tables.png

61 KB
Loading

src/buffer.rs

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use undo::Record;
99
/// Global counter for unique buffer versions.
1010
static NEXT_VERSION: AtomicU64 = AtomicU64::new(1);
1111

12+
use crate::callout::CalloutInfo;
1213
use crate::highlight::{HighlightSpan, Highlighter};
1314
use crate::inline::{StyledRegion, extract_all_inline_styles, styles_in_range};
1415
use crate::marker::{
@@ -20,7 +21,7 @@ use crate::math::MathBlock;
2021
#[cfg(feature = "mermaid")]
2122
use crate::mermaid::MermaidBlock;
2223
use crate::parser::{MarkdownParser, MarkdownTree};
23-
use crate::table::{RowKind, TableInfo};
24+
use crate::table::{RowKind, TableInfo, row_kind_at_line};
2425

2526
/// Compute the byte range for a line (excludes trailing newline).
2627
fn compute_line_byte_range(rope: &Rope, line_idx: usize) -> Range<usize> {
@@ -192,24 +193,28 @@ impl RenderSnapshot {
192193
(offset < table.block.end).then_some(table)
193194
}
194195

195-
/// Map a buffer line to the table and row role it belongs to, if any. A line
196-
/// belongs to a row when the row's `line` byte range contains the line's start.
196+
/// Map a buffer line to the table and row role it belongs to, if any. Compares by
197+
/// line *number* (not byte containment): tree-sitter's error recovery can start a
198+
/// row node mid-line (after the leading `|`), so a byte-range `contains` would miss
199+
/// the row's own line.
197200
pub fn table_row_at_line(&self, line_idx: usize) -> Option<(&TableInfo, RowKind)> {
198201
let line_start = self.line_byte_range(line_idx).start;
199202
let table = self.table_containing_offset(line_start)?;
203+
let kind = row_kind_at_line(table, line_idx, |b| self.rope.byte_to_line(b))?;
204+
Some((table, kind))
205+
}
200206

201-
if table.header.line.contains(&line_start) {
202-
return Some((table, RowKind::Header));
203-
}
204-
if table.delimiter_line.contains(&line_start) {
205-
return Some((table, RowKind::Delimiter));
206-
}
207-
for (i, row) in table.body.iter().enumerate() {
208-
if row.line.contains(&line_start) {
209-
return Some((table, RowKind::Body(i)));
210-
}
211-
}
212-
None
207+
/// Every detected callout, in document order.
208+
pub fn callouts(&self) -> &[CalloutInfo] {
209+
&self.parsed.callouts
210+
}
211+
212+
/// The callout whose `[!type]` header is on `line_idx`, if any.
213+
pub fn callout_header_at_line(&self, line_idx: usize) -> Option<&CalloutInfo> {
214+
self.parsed
215+
.callouts
216+
.iter()
217+
.find(|c| c.header_line == line_idx)
213218
}
214219

215220
/// All ```` ```mermaid ```` fences in the document, scanned once. Computed per build
@@ -717,30 +722,11 @@ impl BufferContent {
717722
self.text.len_bytes() == 0
718723
}
719724

720-
/// Check if buffer ends with the given string (efficient, doesn't copy whole buffer).
721-
pub fn ends_with(&self, suffix: &str) -> bool {
722-
let len = self.text.len_bytes();
723-
let suffix_len = suffix.len();
724-
if len < suffix_len {
725-
return false;
726-
}
727-
let start = len - suffix_len;
728-
self.text
729-
.byte_slice(start..len)
730-
.as_str()
731-
.map(|s| s == suffix)
732-
.unwrap_or(false)
733-
}
734-
735-
/// Get a single byte at the given offset, if it exists.
725+
/// Get a single byte at the given offset, if it exists. `get_byte` indexes the rope
726+
/// by byte directly — never slicing — so it's safe when `offset` is the interior byte
727+
/// of a multi-byte UTF-8 codepoint (a `byte_slice(offset..offset+1)` would panic there).
736728
pub fn byte_at(&self, offset: usize) -> Option<u8> {
737-
if offset >= self.text.len_bytes() {
738-
return None;
739-
}
740-
self.text
741-
.byte_slice(offset..offset + 1)
742-
.as_str()
743-
.and_then(|s| s.bytes().next())
729+
self.text.get_byte(offset)
744730
}
745731

746732
pub fn rope(&self) -> &Rope {
@@ -768,6 +754,11 @@ impl BufferContent {
768754
&self.parsed().list_items
769755
}
770756

757+
/// Callouts in document order. Rc-cached with the parse tree.
758+
pub fn callouts(&self) -> &[CalloutInfo] {
759+
&self.parsed().callouts
760+
}
761+
771762
/// Compute LineMarkers for a specific line on demand.
772763
pub fn line_markers(&self, line_idx: usize) -> LineMarkers {
773764
line_markers_from(&self.parsed, &self.text, line_idx)
@@ -1126,6 +1117,17 @@ impl FromStr for Buffer {
11261117
mod tests {
11271118
use super::*;
11281119

1120+
#[test]
1121+
fn byte_at_handles_multibyte_boundaries() {
1122+
// `é` is 0xC3 0xA9; `byte_at` must return each byte without panicking (the old
1123+
// `byte_slice(offset..offset+1)` panicked on the interior byte of a codepoint).
1124+
let buf: Buffer = "é!".parse().unwrap();
1125+
assert_eq!(buf.byte_at(0), Some(0xC3));
1126+
assert_eq!(buf.byte_at(1), Some(0xA9));
1127+
assert_eq!(buf.byte_at(2), Some(b'!'));
1128+
assert_eq!(buf.byte_at(3), None);
1129+
}
1130+
11291131
fn apply_diff(old: &str, new: &str) -> String {
11301132
let (start, old_end, replacement) = minimal_diff(old, new);
11311133
let mut result = String::new();

0 commit comments

Comments
 (0)