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>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 15:16:42 -04:00
1 parent 0558f0c890
commit bcee87c08f
12 files changed
+321 -106

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"
+16
View File
@@ -8,6 +8,7 @@ use super::{
DocumentEvent, LARGEST_READ, MarkdownDocument,
block::{self, BlockCommand},
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,
+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();
}
+187 -83
View File
@@ -1,14 +1,16 @@
//! 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,
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 +22,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 +41,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
@@ -72,10 +73,12 @@ pub(super) fn edit_table(
.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 +97,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);
let caret = range.start + caret;
Ok((vec![(range, out)], caret..caret))
}
/// A cell's source between its pipes.
@@ -139,37 +171,69 @@ 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,
}
}
/// 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,
) -> (String, usize) {
// Typora pads columns 4 to 60 display columns wide.
let widths: Vec<usize> = (0..aligns.len())
.map(|column| {
(grid.iter())
.map(|cells| cells[column].width())
.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, |width| width.saturating_sub(cell.width()));
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(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');
@@ -180,34 +244,10 @@ 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();
@@ -230,11 +270,12 @@ impl MarkdownDocument {
} else {
"\n"
};
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), "");
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 +288,93 @@ 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 |"
);
}
@@ -307,9 +399,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 +419,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;"));
}
+60 -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,7 @@ 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"
);
}
@@ -143,7 +147,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 +251,58 @@ 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_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 |"
);
}
+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()
})
}
}
}