Compare commits

..
4 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 6b033f3eb0 documents: edits read the caret off a CRLF split
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m47s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
The editor splits lines on LF only, so End can leave the caret
between CR and LF. The edit runner and table insert now settle the
selection before the CR; the line helpers assert they never see a
split instead of guessing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:56:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ce015e7b7b documents: a caret inside CRLF reads as its line's end
The code editor splits lines on LF only, so End and Right can leave the
caret between CR and LF. line_start and line_end now read that spot as
the end of its line, so insert_table keeps CRLF around a new table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:44:59 -04:00
ZacharyZhang-NYandClaude Opus 5.5 07fee552c3 documents: tables keep their line ending; tabs count four
Table rewrites find their prefix and rows by CR, LF and CRLF alike and
write the table's own line ending; insert_table takes the text's. A
cell's width counts a tab as 4 and a control character as 0, as
Typora's wcswidth does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:33:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bcee87c08f documents: table moves and tidy read apart; a code press names its text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:16:42 -04:00
13 changed files with 485 additions and 130 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
Generated
+1
View File
@@ -1685,6 +1685,7 @@ dependencies = [
"similar",
"smallvec",
"unicode-segmentation",
"unicode-width",
"usvg",
"vte",
"wasm-bindgen",
+1
View File
@@ -43,6 +43,7 @@ serde_json = { version = "1", features = ["preserve_order"] }
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
smallvec = "1"
unicode-segmentation = "1"
unicode-width = "0.2"
usvg = "0.46"
vte = { version = "0.15", default-features = false, features = ["std", "ansi"] }
web-time = "1.1"
+26 -3
View File
@@ -6,8 +6,9 @@ use gpui::{AppContext as _, Context};
use super::{
DocumentEvent, LARGEST_READ, MarkdownDocument,
block::{self, BlockCommand},
block::{self, BlockCommand, settled},
format::{Change, EditRefusal, InlineFormat, toggle},
tables::{self, TableEdit},
};
use crate::documents::{MarkdownMode, modes::joint};
@@ -41,6 +42,21 @@ impl MarkdownDocument {
)
}
/// `edit` on the table at the caret, read apart.
pub fn edit_table(
&mut self,
edit: TableEdit,
cx: &mut Context<Self>,
) -> Result<(), EditRefusal> {
self.apart(
format!("table edit {edit:?}"),
move |full, selection| {
tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells)
},
cx,
)
}
/// Reads `read` off the UI thread, then writes its change.
fn apart(
&mut self,
@@ -93,7 +109,8 @@ impl MarkdownDocument {
fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
let text = self.editor.read(cx).text();
let Some(open) = &self.open else {
return (text.to_string(), 0, self.editor.read(cx).primary().range());
let picked = self.editor.read(cx).primary().range();
return settle((text.to_string(), 0, picked));
};
let input = open.input.read(cx);
let (before, after) = match open.append {
@@ -106,7 +123,7 @@ impl MarkdownDocument {
let picked = input.selection();
let offset = before.len();
let full = format!("{before}{}{after}", input.text());
(full, offset, offset + picked.start..offset + picked.end)
settle((full, offset, offset + picked.start..offset + picked.end))
}
/// Writes `change`: into the open block while it stays inside.
@@ -163,3 +180,9 @@ impl MarkdownDocument {
Ok(())
}
}
/// The editor splits lines on LF only; reads start off a CRLF.
fn settle((full, offset, picked): Snapshot) -> Snapshot {
let picked = settled(&full, picked.start)..settled(&full, picked.end);
(full, offset, picked)
}
+12
View File
@@ -251,11 +251,23 @@ fn path<'a>(text: &str, root: &'a Node, at: usize) -> Vec<&'a Node> {
path
}
/// Whether `at` falls between a CR and its LF.
fn splits(text: &str, at: usize) -> bool {
text[..at].ends_with('\r') && text[at..].starts_with('\n')
}
/// `at`, before the CR when it splits a CRLF.
pub(super) fn settled(text: &str, at: usize) -> usize {
if splits(text, at) { at - 1 } else { at }
}
pub(super) fn line_start(text: &str, at: usize) -> usize {
debug_assert!(!splits(text, at), "line_start inside a CRLF");
text[..at].rfind(['\n', '\r']).map_or(0, |at| at + 1)
}
pub(super) fn line_end(text: &str, at: usize) -> usize {
debug_assert!(!splits(text, at), "line_end inside a CRLF");
at + text[at..].find(['\n', '\r']).unwrap_or(text.len() - at)
}
+4
View File
@@ -6,6 +6,8 @@ use std::ops::Range;
use markdown::mdast::Node;
use super::TableRefusal;
use crate::documents::{
markdown::{file_tree, span, walk},
tree::inside,
@@ -50,6 +52,8 @@ pub enum EditRefusal {
Changed,
/// The result would read as other text or blocks.
Misread,
/// A table edit refused, and why.
Cells(TableRefusal),
}
/// The edits and the selection after them.
+9 -2
View File
@@ -61,7 +61,8 @@ pub struct DocumentTarget {
pub link: Option<String>,
pub image: Option<String>,
pub in_table: bool,
pub in_code: bool,
/// The code block's text, pressed in one.
pub code: Option<String>,
}
impl MarkdownDocument {
@@ -115,6 +116,12 @@ impl MarkdownDocument {
};
let kind = block.map(|block| block.kind);
let pressed = block.map(|block| self.drawn(block));
let code = (pointed.code.as_deref().map(str::to_string)).or_else(|| {
match block.and_then(|block| block.nodes.as_deref()) {
Some([Node::Code { text, .. }]) => Some(text.clone()),
_ => None,
}
});
let at = at.or(block.map(|block| block.range.start));
let written = self.commit(cx);
if written.is_some() {
@@ -149,7 +156,7 @@ impl MarkdownDocument {
link: pointed.link,
image: pointed.image,
in_table: pointed.table || kind == Some(TargetBlock::Table),
in_code: pointed.code || kind == Some(TargetBlock::Code),
code,
}));
cx.notify();
}
+250 -104
View File
@@ -1,14 +1,17 @@
//! Tables change by rows and columns (MARK-06).
use std::ops::Range;
use gpui::Context;
use markdown::mdast::{self, AlignKind};
use unicode_width::UnicodeWidthStr;
use super::MarkdownDocument;
use super::{
MarkdownDocument,
block::{eol, line_end, line_start, next_line, prev_line, settled},
format::{Change, EditRefusal},
};
use crate::documents::{
MarkdownMode,
markdown::{span, tree, walk},
markdown::{file_tree, span, walk},
};
/// What a table edit does at the caret.
@@ -20,11 +23,14 @@ pub enum TableEdit {
ColumnRight,
DeleteRow,
DeleteColumn,
MoveRowUp,
MoveRowDown,
MoveColumnLeft,
MoveColumnRight,
/// Every cell padded to its column, as Typora tidies.
Tidy,
}
/// A new table: three columns, a head and two rows.
const NEW: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |";
/// Why a table edit did not happen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableRefusal {
@@ -36,17 +42,13 @@ pub enum TableRefusal {
Beyond,
/// Its lines carry different quote or list marks.
Mixed,
/// Preview takes no edits.
Preview,
/// Nothing lies past the caret's row or column that way.
Edge,
}
/// The table around `at` after `edit`: its range, text, caret.
pub(super) fn edit_table(
text: &str,
at: usize,
edit: TableEdit,
) -> Result<(Range<usize>, String, usize), TableRefusal> {
let root = tree(text);
/// The table around `at` after `edit`, and the caret.
pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Change, TableRefusal> {
let root = file_tree(text);
let mut nodes = Vec::new();
walk(&root, &mut nodes);
let (range, table) = nodes
@@ -58,24 +60,27 @@ pub(super) fn edit_table(
_ => None,
})
.ok_or(TableRefusal::Outside)?;
let prefix = &text[text[..range.start].rfind('\n').map_or(0, |found| found + 1)..range.start];
if text[range.clone()]
.split('\n')
.skip(1)
.any(|line| !line.starts_with(prefix))
{
return Err(TableRefusal::Mixed);
let prefix = &text[line_start(text, range.start)..range.start];
let mut line = range.start;
while let Some(next) = next_line(text, line).filter(|next| *next < range.end) {
if !text[next..].starts_with(prefix) {
return Err(TableRefusal::Mixed);
}
line = next;
}
let eol = eol(&text[range.clone()]);
let rows: Vec<&mdast::Node> = table.children.iter().collect();
let row = rows
.iter()
.rposition(|row| span(row).start <= at)
.unwrap_or(0);
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
let column = cells_of(rows[row])
let cells = cells_of(rows[row]);
let column = cells
.iter()
.rposition(|cell| span(cell).start <= at)
.unwrap_or(0);
let offset = cells.get(column).map_or(0, |cell| within(cell, at));
let width = table.align.len();
// Cells past the head stay as written.
let mut grid: Vec<Vec<String>> = rows
@@ -94,40 +99,69 @@ pub(super) fn edit_table(
let mut aligns = table.align.clone();
let column_edit = matches!(
edit,
TableEdit::ColumnLeft | TableEdit::ColumnRight | TableEdit::DeleteColumn
TableEdit::ColumnLeft
| TableEdit::ColumnRight
| TableEdit::DeleteColumn
| TableEdit::MoveColumnLeft
| TableEdit::MoveColumnRight
);
if column_edit && column >= width {
return Err(TableRefusal::Beyond);
}
let (row, column) = match edit {
let caret = match edit {
TableEdit::RowAbove | TableEdit::RowBelow => {
let below = edit == TableEdit::RowBelow || row == 0;
let at = if below { row + 1 } else { row };
grid.insert(at, vec![String::new(); width]);
(at, column.min(width - 1))
(at, column.min(width - 1), 0)
}
TableEdit::DeleteRow if row > 0 => {
grid.remove(row);
(row.min(grid.len() - 1), column.min(width - 1))
(row.min(grid.len() - 1), column.min(width - 1), 0)
}
TableEdit::ColumnLeft | TableEdit::ColumnRight => {
let at = column + usize::from(edit == TableEdit::ColumnRight);
grid.iter_mut()
.for_each(|cells| cells.insert(at, String::new()));
aligns.insert(at, AlignKind::None);
(row, at)
(row, at, 0)
}
TableEdit::DeleteColumn if width > 1 => {
grid.iter_mut().for_each(|cells| {
cells.remove(column);
});
aligns.remove(column);
(row, column.min(width - 2))
(row, column.min(width - 2), 0)
}
TableEdit::MoveRowUp | TableEdit::MoveRowDown => {
let to = match edit {
TableEdit::MoveRowUp => row.checked_sub(1),
_ => Some(row + 1).filter(|to| *to < grid.len()),
}
.ok_or(TableRefusal::Edge)?;
// GFM reads a head of other width as no table.
if row.min(to) == 0 && grid[row.max(to)].len() > width {
return Err(TableRefusal::Beyond);
}
grid.swap(row, to);
(to, column, offset)
}
TableEdit::MoveColumnLeft | TableEdit::MoveColumnRight => {
let to = match edit {
TableEdit::MoveColumnLeft => column.checked_sub(1),
_ => Some(column + 1).filter(|to| *to < width),
}
.ok_or(TableRefusal::Edge)?;
grid.iter_mut().for_each(|cells| cells.swap(column, to));
aligns.swap(column, to);
(row, to, offset)
}
TableEdit::Tidy => (row, column, offset),
_ => return Err(TableRefusal::Kept),
};
let (out, caret) = table_text(&grid, &aligns, (row, column), prefix);
Ok((range, out, caret))
let (out, caret) = table_text(&grid, &aligns, caret, prefix, eol);
let caret = range.start + caret;
Ok((vec![(range, out)], caret..caret))
}
/// A cell's source between its pipes.
@@ -139,40 +173,83 @@ fn written(text: &str, cell: &mdast::Node) -> String {
}
}
/// The table's text, each line after the first under `prefix`.
/// `at`'s byte in `cell`'s written text.
fn within(cell: &mdast::Node, at: usize) -> usize {
let inside = cell.children().expect("a cell holds inlines");
match (inside.first(), inside.last()) {
(Some(first), Some(last)) => {
let start = span(first).start;
at.clamp(start, span(last).end) - start
}
_ => 0,
}
}
/// Display columns, a tab four and controls none, as Typora's.
fn width(cell: &str) -> usize {
let tabs = cell.matches('\t').count();
let shown: usize = cell
.split(char::is_control)
.map(UnicodeWidthStr::width)
.sum();
shown + 4 * tabs
}
/// The table's text and the caret at row, column, byte.
fn table_text(
grid: &[Vec<String>],
aligns: &[AlignKind],
at: (usize, usize),
at: (usize, usize, usize),
prefix: &str,
eol: &str,
) -> (String, usize) {
// Typora pads columns 4 to 60 display columns wide.
let widths: Vec<usize> = (0..aligns.len())
.map(|column| {
(grid.iter())
.map(|cells| width(&cells[column]))
.fold(4, usize::max)
.min(60)
})
.collect();
let mut out = String::new();
let mut caret = 0;
for (row, cells) in grid.iter().enumerate() {
out.push('|');
for (column, cell) in cells.iter().enumerate() {
out.push_str("| ");
if (row, column) == at {
caret = out.len();
let room = widths
.get(column)
.map_or(0, |widest| widest.saturating_sub(width(cell)));
let before = match aligns.get(column) {
Some(AlignKind::Right) => room,
Some(AlignKind::Center) => room / 2,
_ => 0,
};
out.push(' ');
out.push_str(&" ".repeat(before));
if (row, column) == (at.0, at.1) {
caret = out.len() + at.2;
}
out.push_str(cell);
out.push(' ');
out.push_str(&" ".repeat(room - before));
out.push_str(" |");
}
out.push('|');
if row == 0 {
out.push('\n');
out.push_str(eol);
out.push_str(prefix);
for align in aligns {
out.push_str(match align {
AlignKind::Left => "| :-- ",
AlignKind::Right => "| --: ",
AlignKind::Center => "| :-: ",
AlignKind::None => "| --- ",
});
}
out.push('|');
for (align, width) in aligns.iter().zip(&widths) {
let dashes = match align {
AlignKind::Left => format!(":{}", "-".repeat(width - 1)),
AlignKind::Right => format!("{}:", "-".repeat(width - 1)),
AlignKind::Center => format!(":{}:", "-".repeat(width - 2)),
AlignKind::None => "-".repeat(*width),
};
out.push_str(&format!(" {dashes} |"));
}
}
if row + 1 < grid.len() {
out.push('\n');
out.push_str(eol);
out.push_str(prefix);
}
}
@@ -180,61 +257,34 @@ fn table_text(
}
impl MarkdownDocument {
/// `edit` on the table at the caret, or why not.
pub fn edit_table(
&mut self,
edit: TableEdit,
cx: &mut Context<Self>,
) -> Result<(), TableRefusal> {
if self.mode == MarkdownMode::Preview {
return Err(TableRefusal::Preview);
}
self.commit(cx);
let at = self.editor.read(cx).primary().range().start;
let text = self.editor.read(cx).text().to_string();
let (range, table, caret) = edit_table(&text, at, edit).inspect_err(|why| {
log::info!("markdown document: table edit {edit:?} refused: {why:?}");
})?;
log::info!("markdown document: table edit {edit:?}");
let caret = range.start + caret;
self.editor.update(cx, |editor, cx| {
editor.edit([(range, table)], cx);
editor.select(std::iter::once(caret..caret), cx);
});
Ok(())
}
/// A new table after the caret's line, unless in preview.
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), TableRefusal> {
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
if self.mode == MarkdownMode::Preview {
return Err(TableRefusal::Preview);
return Err(EditRefusal::Preview);
}
self.commit(cx);
let text = self.editor.read(cx).text().to_string();
let at = self.editor.read(cx).primary().range().start;
let end = text[at..].find('\n').map_or(text.len(), |found| at + found);
let start = text[..at].rfind('\n').map_or(0, |found| found + 1);
let at = settled(&text, self.editor.read(cx).primary().range().start);
let (start, end, eol) = (line_start(&text, at), line_end(&text, at), eol(&text));
// A blank line gives way; a written one keeps.
let blank = text[start..end].trim().is_empty();
let above = text[..start.saturating_sub(1)]
.rsplit('\n')
.next()
.unwrap_or_default();
let above = prev_line(&text, start).map_or("", |line| &text[line..line_end(&text, line)]);
let (from, before) = match (blank, above.trim().is_empty()) {
(true, true) => (start, ""),
(true, false) => (start, "\n"),
(false, _) => (end, "\n\n"),
(true, true) => (start, String::new()),
(true, false) => (start, eol.to_string()),
(false, _) => (end, eol.repeat(2)),
};
let after = if text[end..].starts_with("\n\n") || end == text.len() {
let after = if text[end..].starts_with(&eol.repeat(2)) || end == text.len() {
""
} else {
"\n"
eol
};
let written = format!("{before}{NEW}{after}");
let caret = from + before.len() + 2;
let grid = vec![vec![String::new(); 3]; 3];
let (table, caret) = table_text(&grid, &[AlignKind::None; 3], (0, 0, 0), "", eol);
let caret = from + before.len() + caret;
log::info!("markdown document: a table goes in");
self.editor.update(cx, |editor, cx| {
editor.edit([(from..end, written)], cx);
editor.edit([(from..end, format!("{before}{table}{after}"))], cx);
editor.select(std::iter::once(caret..caret), cx);
});
Ok(())
@@ -247,42 +297,126 @@ mod tests {
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n";
/// `text` after `edit` at `at`, the caret as `‸`.
fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
let at = text.find(at).expect("in the table");
let (range, table, _) = edit_table(text, at, edit)?;
Ok(format!(
"{}{table}{}",
&text[..range.start],
&text[range.end..]
))
let (edits, caret) = edit_table(text, at, edit)?;
let (range, table) = &edits[0];
let out = format!("{}{table}{}", &text[..range.start], &text[range.end..]);
Ok(format!("{}‸{}", &out[..caret.start], &out[caret.start..]))
}
fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
edited_in(TABLE, at, edit)
}
fn around(table: &str) -> Result<String, TableRefusal> {
Ok(format!("Intro\n\n{table}\n\nAfter\n"))
}
#[test]
fn rows_and_columns_come_and_go() {
let around = |table: &str| Ok(format!("Intro\n\n{table}\n\nAfter\n"));
assert_eq!(
edited("3", TableEdit::RowAbove),
around("| A | B |\n| :-- | --: |\n| 1 | 2 |\n| | |\n| 3 | 4 |")
around(
"| A | B |\n| :--- | ---: |\n| 1 | 2 |\n| ‸ | |\n| 3 | 4 |"
)
);
assert_eq!(
edited("A", TableEdit::RowBelow),
around("| A | B |\n| :-- | --: |\n| | |\n| 1 | 2 |\n| 3 | 4 |")
around(
"| A | B |\n| :--- | ---: |\n| ‸ | |\n| 1 | 2 |\n| 3 | 4 |"
)
);
assert_eq!(
edited("2", TableEdit::ColumnLeft),
around("| A | | B |\n| :-- | --- | --: |\n| 1 | | 2 |\n| 3 | | 4 |")
around(
"| A | | B |\n| :--- | ---- | ---: |\n| 1 | ‸ | 2 |\n| 3 | | 4 |"
)
);
assert_eq!(
edited("1", TableEdit::DeleteRow),
around("| A | B |\n| :-- | --: |\n| 3 | 4 |")
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |")
);
assert_eq!(
edited("B", TableEdit::DeleteColumn),
around("| A |\n| :-- |\n| 1 |\n| 3 |")
around("| ‸A |\n| :--- |\n| 1 |\n| 3 |")
);
}
#[test]
fn rows_and_columns_move_with_the_caret() {
assert_eq!(
edited("3", TableEdit::MoveRowUp),
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |\n| 1 | 2 |")
);
assert_eq!(
edited("A", TableEdit::MoveRowDown),
around("| 1 | 2 |\n| :--- | ---: |\n| ‸A | B |\n| 3 | 4 |")
);
assert_eq!(
edited("2", TableEdit::MoveColumnLeft),
around("| B | A |\n| ---: | :--- |\n| ‸2 | 1 |\n| 4 | 3 |")
);
assert_eq!(edited("A", TableEdit::MoveRowUp), Err(TableRefusal::Edge));
assert_eq!(edited("3", TableEdit::MoveRowDown), Err(TableRefusal::Edge));
assert_eq!(
edited("1", TableEdit::MoveColumnLeft),
Err(TableRefusal::Edge)
);
assert_eq!(
edited("4", TableEdit::MoveColumnRight),
Err(TableRefusal::Edge)
);
}
#[test]
fn tidy_pads_by_display_width_and_keeps_the_caret() {
let text = "| 名前 | x |\n|:-:|-|\n| 長い名前です | yy‸z |\n";
let at = text.find('‸').unwrap();
let text = text.replace('‸', "");
let (edits, caret) = edit_table(&text, at, TableEdit::Tidy).unwrap();
let out = format!(
"{}‸{}",
&edits[0].1[..caret.start],
&edits[0].1[caret.start..]
);
assert_eq!(
out,
"| 名前 | x |\n| :----------: | ---- |\n| 長い名前です | yy‸z |"
);
}
#[test]
fn a_rewrite_keeps_the_tables_line_ending() {
let crlf = "Before\r\n\r\n| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n\r\nAfter\r\n";
assert_eq!(
edited_in(crlf, "1", TableEdit::Tidy),
Ok("Before\r\n\r\n| A | B |\r\n| ---- | ---- |\r\n| ‸1 | 2 |\r\n\r\nAfter\r\n".into())
);
let cr = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
assert_eq!(
edited_in(cr, "1", TableEdit::Tidy),
Ok("Before\r\r| A | B |\r| ---- | ---- |\r| ‸1 | 2 |\r\rAfter\r".into())
);
let moved = "| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n";
assert_eq!(
edited_in(moved, "1", TableEdit::MoveRowUp),
Ok("| ‸1 | 2 |\r\n| ---- | ---- |\r\n| A | B |\r\n".into())
);
let quoted = "> | A |\r> | - |\r> | 1 |\r";
assert_eq!(
edited_in(quoted, "1", TableEdit::RowBelow),
Ok("> | A |\r> | ---- |\r> | 1 |\r> | ‸ |\r".into())
);
}
#[test]
fn a_tab_counts_four_columns_as_in_typora() {
let text = "| a\tb | x |\n| - | - |\n| q | r |\n";
assert_eq!(
edited_in(text, "q", TableEdit::Tidy),
Ok("| a\tb | x |\n| ------ | ---- |\n| ‸q | r |\n".into())
);
}
@@ -307,9 +441,18 @@ mod tests {
edited_in(text, "3", TableEdit::DeleteColumn),
Err(TableRefusal::Beyond)
);
assert_eq!(
edited_in(text, "3", TableEdit::MoveColumnLeft),
Err(TableRefusal::Beyond)
);
assert_eq!(
edited_in(text, "1", TableEdit::MoveRowUp),
Err(TableRefusal::Beyond),
"a head wider than its delimiter is no table"
);
assert_eq!(
edited_in(text, "1", TableEdit::RowBelow),
Ok("| A | B |\n| --- | --- |\n| 1 | 2 | 3 |\n| | |\n".into())
Ok("| A | B |\n| ---- | ---- |\n| 1 | 2 | 3 |\n| ‸ | |\n".into())
);
}
@@ -318,7 +461,10 @@ mod tests {
let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n";
assert_eq!(
edited_in(text, "1", TableEdit::RowBelow),
Ok("> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n> | | |\n".into())
Ok(
"> | A | B |\n> | ---- | ---- |\n> | 1 | 2 |\n> | ‸ | |\n"
.into()
)
);
let mixed = "- | A |\n | - |\n | 1 |\n";
assert_eq!(
+1 -1
View File
@@ -277,7 +277,7 @@ fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) {
}
/// The refusals a document sends by event.
fn refusals(
pub(super) fn refusals(
document: &Entity<MarkdownDocument>,
cx: &mut VisualTestContext,
) -> Rc<RefCell<Vec<EditRefusal>>> {
+24 -4
View File
@@ -72,7 +72,7 @@ pub(super) fn target(at: usize, block: TargetBlock) -> DocumentTarget {
link: None,
image: None,
in_table: false,
in_code: false,
code: None,
}
}
@@ -120,7 +120,7 @@ fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) {
let heard = heard(&document, cx);
press_on(0, 0, MouseButton::Right, cx);
let expected = DocumentTarget {
in_code: true,
code: Some("let x = 1;".into()),
..target("```rust\n".len(), TargetBlock::Code)
};
assert_eq!(*heard.borrow(), [expected]);
@@ -245,8 +245,9 @@ fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
let events = heard(&document, cx);
press_lines(1, 1, MouseButton::Right, cx);
let at = source.find("eta").unwrap();
let eol = if source.contains('\r') { "\r\n" } else { "\n" };
let expected = DocumentTarget {
in_code: true,
code: Some(format!("alpha{eol}beta")),
..target(at, TargetBlock::Code)
};
assert_eq!(*events.borrow(), [expected], "{source:?}");
@@ -304,7 +305,7 @@ beta
press_lines(2, 0, MouseButton::Right, cx);
let at = source.find("pha").unwrap();
let expected = DocumentTarget {
in_code: true,
code: Some("alpha\nbeta".into()),
..target(at, TargetBlock::Code)
};
assert_eq!(*events.borrow(), [expected]);
@@ -392,3 +393,22 @@ fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) {
let at = source.find("eta").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
}
#[gpui::test]
fn a_press_beside_the_code_still_names_it(cx: &mut TestAppContext) {
let source = "```\nlet x;\n```\n";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
press_at(
bounds.left() + px(4.0),
bounds.top() + px(4.0),
MouseButton::Right,
cx,
);
let code = events
.borrow()
.first()
.and_then(|target| target.code.clone());
assert_eq!(code.as_deref(), Some("let x;"));
}
+139 -3
View File
@@ -47,7 +47,11 @@ fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) {
document.edit_table(crate::documents::TableEdit::RowBelow, cx)
});
assert_eq!(edited, Ok(()));
assert_eq!(text(&document, cx), "| A |\n| --- |\n| 1 |\n| |\n");
cx.run_until_parked();
assert_eq!(
text(&document, cx),
"| A |\n| ---- |\n| 1 |\n| |\n"
);
let editor = document.read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.undo(cx));
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
@@ -61,7 +65,25 @@ fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) {
});
assert_eq!(
text(&document, cx),
"Intro\n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nNext\n"
"Intro\n\n| | | |\n| ---- | ---- | ---- |\n| | | |\n| | | |\n\nNext\n"
);
}
#[gpui::test]
fn a_new_table_after_end_keeps_crlf(cx: &mut TestAppContext) {
let (document, cx) = open("Intro\r\nNext\r\n", MarkdownMode::Source, cx);
let editor = document.read_with(cx, |document, _| document.editor().clone());
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
cx.update(|window, cx| window.focus(&focus, cx));
cx.simulate_keystrokes("end");
let caret = editor.read_with(cx, |editor, _| editor.primary().range().start);
assert_eq!(caret, 6, "End lands between CR and LF");
document.update(cx, |document, cx| {
document.insert_table(cx).expect("inserted")
});
assert_eq!(
text(&document, cx),
"Intro\r\n\r\n| | | |\r\n| ---- | ---- | ---- |\r\n| | | |\r\n| | | |\r\n\r\nNext\r\n"
);
}
@@ -143,7 +165,7 @@ fn preview_takes_no_edits(cx: &mut TestAppContext) {
document.insert_table(cx),
)
});
let preview = Err(crate::documents::TableRefusal::Preview);
let preview = Err(crate::documents::EditRefusal::Preview);
assert_eq!(refused, (false, preview, preview));
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
}
@@ -247,5 +269,119 @@ fn a_table_on_an_indented_blank_line_is_a_table(cx: &mut TestAppContext) {
document.edit_table(crate::documents::TableEdit::ColumnRight, cx)
});
assert_eq!(column, Ok(()), "{}", text(&document, cx));
cx.run_until_parked();
assert!(text(&document, cx).starts_with("| "));
}
const SMALL: &str = "| A | B |\n| - | - |\n| 1 | 2 |\n";
#[gpui::test]
fn a_table_move_reads_apart_as_one_undo_step(cx: &mut TestAppContext) {
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
let moved = document.update(cx, |document, cx| {
document.edit_table(crate::documents::TableEdit::MoveRowUp, cx)
});
assert_eq!(moved, Ok(()));
cx.run_until_parked();
assert_eq!(
text(&document, cx),
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
);
let editor = document.read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.undo(cx));
assert_eq!(text(&document, cx), SMALL);
}
#[gpui::test]
fn a_table_edit_at_an_edge_refuses_by_event(cx: &mut TestAppContext) {
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
let heard = super::format::refusals(&document, cx);
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
document.update(cx, |document, cx| {
document
.edit_table(crate::documents::TableEdit::MoveRowDown, cx)
.expect("taken")
});
cx.run_until_parked();
let edge = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Edge);
assert_eq!(*heard.borrow(), [edge]);
assert_eq!(text(&document, cx), SMALL);
}
#[gpui::test]
fn tidy_in_cr_text_keeps_what_comes_before(cx: &mut TestAppContext) {
let source = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
let (document, cx) = open(source, MarkdownMode::Source, cx);
let at = source.find('1').expect("a cell");
let editor = document.read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
document.update(cx, |document, cx| {
document
.edit_table(crate::documents::TableEdit::Tidy, cx)
.expect("taken")
});
cx.run_until_parked();
assert_eq!(
text(&document, cx),
"Before\r\r| A | B |\r| ---- | ---- |\r| 1 | 2 |\r\rAfter\r"
);
}
#[gpui::test]
fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) {
let (document, cx) = open("Intro\rNext\r", MarkdownMode::Visual, cx);
document.update(cx, |document, cx| {
document.insert_table(cx).expect("inserted")
});
assert_eq!(
text(&document, cx),
"Intro\r\r| | | |\r| ---- | ---- | ---- |\r| | | |\r| | | |\r\rNext\r"
);
}
#[gpui::test]
fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) {
let (document, cx) = open(SMALL, MarkdownMode::Visual, cx);
press(0, Modifiers::none(), cx);
document.update(cx, |document, cx| {
document
.edit_table(crate::documents::TableEdit::Tidy, cx)
.expect("taken")
});
cx.run_until_parked();
assert_eq!(
opened(&document, cx),
"| A | B |\n| ---- | ---- |\n| 1 | 2 |"
);
}
const CRLF: &str = "Intro\r\nNext\r\n";
/// A code block in CRLF text after `keys`.
fn code_block_after(keys: &str, cx: &mut TestAppContext) -> String {
let (document, cx) = open(CRLF, MarkdownMode::Source, cx);
let editor = document.read_with(cx, |document, _| document.editor().clone());
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
cx.update(|window, cx| window.focus(&focus, cx));
cx.simulate_keystrokes(keys);
document.update(cx, |document, cx| {
document
.block(crate::documents::BlockCommand::CodeBlock, cx)
.expect("taken")
});
cx.run_until_parked();
text(&document, cx)
}
const FENCE_AFTER_INTRO: &str = "Intro\r\n\r\n```\r\n\r\n```\r\n\r\nNext\r\n";
#[gpui::test]
fn a_block_after_end_in_crlf_text_reads_the_line(cx: &mut TestAppContext) {
assert_eq!(code_block_after("end", cx), FENCE_AFTER_INTRO);
}
#[gpui::test]
fn a_selection_from_inside_a_crlf_reads_the_line(cx: &mut TestAppContext) {
assert_eq!(code_block_after("end shift-right", cx), FENCE_AFTER_INTRO);
}
+2 -2
View File
@@ -264,7 +264,7 @@ impl Draw<'_> {
} => {
if language.as_deref() == Some("mermaid") {
let diagram = Diagram::new(self.key("diagram"), text.clone());
return self.point_code(diagram.into_any_element(), None, *from);
return self.point_code(diagram.into_any_element(), None, text, *from);
}
if let Some(draw) = self.code {
return draw(
@@ -299,7 +299,7 @@ impl Draw<'_> {
}))
.child(code)
.into_any_element();
self.point_code(block, Some((layout, text)), *from)
self.point_code(block, Some(layout), text, *from)
}
Node::List { start, items } => self.list(start, items, cx),
Node::Table { aligns, head, rows } => {
+15 -10
View File
@@ -29,7 +29,8 @@ pub(crate) struct Pointed {
pub place: Place,
pub link: Option<String>,
pub image: Option<String>,
pub code: bool,
/// The code's text, pressed in a code block.
pub code: Option<Rc<str>>,
pub table: bool,
}
@@ -44,7 +45,7 @@ impl Pointed {
place,
link: None,
image: None,
code: false,
code: None,
table: false,
}
}
@@ -100,7 +101,7 @@ pub(crate) fn point_lines(
.index_for_position(event.position)
.unwrap_or_else(|near| near);
Pointed {
code,
code: code.then(|| text.clone()),
..Pointed::at(Place::Lines {
text: text.clone(),
from,
@@ -146,12 +147,13 @@ impl Draw<'_> {
pub(super) fn point_code(
&self,
element: AnyElement,
drawn: Option<(TextLayout, &str)>,
layout: Option<TextLayout>,
text: &str,
from: usize,
) -> AnyElement {
let table = self.table.get();
match drawn {
Some((layout, text)) => point_lines(
match layout {
Some(layout) => point_lines(
element,
layout,
text,
@@ -159,10 +161,13 @@ impl Draw<'_> {
self.hooks.on_point,
(table, true),
),
None => self.pointable(element, |_| Pointed {
code: true,
..Pointed::block()
}),
None => {
let text: Rc<str> = Rc::from(text);
self.pointable(element, move |_| Pointed {
code: Some(text.clone()),
..Pointed::block()
})
}
}
}