Documents: a press in a table opens its cell alone
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 11m56s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Visual mode opened a pressed table whole, as Markdown source. A press
in a table now opens only that cell, in place, as its source in a
one-line field; the rest of the table stays drawn. Tab and Shift-Tab
walk the cells, Up and Down move a row or leave the table, Enter and
Escape write back in one undo step, and a bare pipe typed in a cell is
escaped on write-back. The table drawing moves to render/table.rs and
presses on a cell's padding name the cell.

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-15 00:50:20 -04:00
1 parent 991ca7b548
commit 2594b3fd0f
14 files changed
+591 -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; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, and a table edit writing it back before it runs; 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 keeLine 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.
+20 -6
View File
@@ -7,6 +7,7 @@ use gpui::{AppContext as _, Context};
use super::{
DocumentEvent, LARGEST_READ, MarkdownDocument,
block::{self, BlockCommand, settled},
cells,
format::{Change, EditRefusal, InlineFormat, drop_image, toggle},
tables::{self, TableEdit},
};
@@ -127,9 +128,18 @@ impl MarkdownDocument {
&text[open.range.end..],
),
};
let picked = input.selection();
let (typed, mut picked) = (input.text(), input.selection());
// A cell reads as it writes back.
let typed = match open.cell {
Some(_) => {
let shift = |at: usize| cells::guarded(&typed[..at]).len();
picked = shift(picked.start)..shift(picked.end);
cells::guarded(typed)
}
None => typed.to_string(),
};
let offset = before.len();
let full = format!("{before}{}{after}", input.text());
let full = format!("{before}{typed}{after}");
settle((full, offset, offset + picked.start..offset + picked.end))
}
@@ -144,12 +154,16 @@ impl MarkdownDocument {
) -> Result<(), EditRefusal> {
log::info!("markdown document: {name}");
if let Some(open) = &self.open {
let end = offset + open.input.read(cx).text().len();
let typed = open.input.read(cx).text();
// Offsets read the escaped cell; its field holds it raw.
let raw = open.cell.is_some() && cells::guarded(typed) != typed;
let end = offset + typed.len();
let grown = (edits.iter().map(|(_, with)| with.len()).sum::<usize>() + end)
.checked_sub(edits.iter().map(|(range, _)| range.len()).sum::<usize>());
let inside = edits
.iter()
.all(|(range, _)| offset <= range.start && range.end <= end)
let inside = !raw
&& edits
.iter()
.all(|(range, _)| offset <= range.start && range.end <= end)
&& grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown);
if inside {
let (Some(from), Some(to)) = (
+221
View File
@@ -0,0 +1,221 @@
//! A table cell opens alone, as Typora edits tables.
use std::ops::Range;
use markdown::mdast::Node;
use crate::documents::{
markdown::{file_tree, span, walk},
render::CellAt,
};
/// A cell of the text: which, its source, the caret in it.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Cell {
pub at: CellAt,
pub range: Range<usize>,
pub caret: usize,
}
/// What names a cell in a block.
#[derive(Clone, Copy, Debug)]
pub(super) enum Aim {
/// The cell holding this byte of the text.
Byte(usize),
/// This cell, its caret at the end.
At(CellAt),
First,
Last,
}
/// A way out of a cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Move {
Next,
Previous,
Up,
Down,
}
/// The rows of each table in `root`, in drawing order.
fn tables(root: &Node) -> Vec<Vec<&Node>> {
let mut nodes = Vec::new();
walk(root, &mut nodes);
nodes
.into_iter()
.filter(|node| matches!(node, Node::Table(_)))
.map(|table| {
table
.children()
.expect("a table holds rows")
.iter()
.collect()
})
.collect()
}
fn cells(row: &Node) -> &[Node] {
row.children().expect("a row holds cells")
}
/// The cell `aim` names in `block` of `text`.
pub(super) fn find(text: &str, block: Range<usize>, aim: Aim) -> Option<Cell> {
let root = file_tree(&text[block.clone()]);
let tables = tables(&root);
let at = match aim {
Aim::At(at) => at,
Aim::First if !tables.is_empty() => CellAt {
table: 0,
row: 0,
column: 0,
},
Aim::Last => {
let table = tables.len().checked_sub(1)?;
let row = tables[table].len() - 1;
let column = cells(tables[table][row]).len().checked_sub(1)?;
CellAt { table, row, column }
}
Aim::First => return None,
Aim::Byte(byte) => {
let byte = byte.checked_sub(block.start)?;
let table = (tables.iter()).position(|rows| {
let (first, last) = (rows[0], rows[rows.len() - 1]);
(span(first).start..=span(last).end).contains(&byte)
})?;
let rows = &tables[table];
let row = rows.iter().rposition(|row| span(row).start <= byte)?;
let column = (cells(rows[row]).iter()).rposition(|cell| span(cell).start <= byte)?;
CellAt { table, row, column }
}
};
let cell = cells(tables.get(at.table)?.get(at.row)?).get(at.column)?;
let range = written(&text[block.clone()], cell);
let caret = match aim {
Aim::Byte(byte) => (byte - block.start).clamp(range.start, range.end) - range.start,
_ => range.len(),
};
Some(Cell {
at,
range: block.start + range.start..block.start + range.end,
caret,
})
}
/// The cell `by` reaches from `at`; none past the table's edge.
pub(super) fn beside(text: &str, block: Range<usize>, at: CellAt, by: Move) -> Option<CellAt> {
let root = file_tree(&text[block]);
let tables = tables(&root);
let rows = tables.get(at.table)?;
let width = |row: usize| rows.get(row).map_or(0, |row| cells(row).len());
let (row, column) = match by {
Move::Next if at.column + 1 < width(at.row) => (at.row, at.column + 1),
Move::Next => (at.row + 1, 0),
Move::Previous if at.column > 0 => (at.row, at.column - 1),
Move::Previous => {
let row = at.row.checked_sub(1)?;
(row, width(row).checked_sub(1)?)
}
Move::Up => (at.row.checked_sub(1)?, at.column),
Move::Down => (at.row + 1, at.column),
};
let column = column.min(width(row).checked_sub(1)?);
Some(CellAt {
table: at.table,
row,
column,
})
}
/// A cell's source between its pipes; empty, a place after the opening one.
fn written(text: &str, cell: &Node) -> Range<usize> {
let inside = cell.children().expect("a cell holds inlines");
if let (Some(first), Some(last)) = (inside.first(), inside.last()) {
return span(first).start..span(last).end;
}
let span = span(cell);
let mut at = span.start;
if text[at..span.end].starts_with('|') {
at += 1;
}
if text[at..span.end].starts_with([' ', '\t']) {
at += 1;
}
at..at
}
/// `typed` with each bare `|` escaped, as a cell keeps it.
pub(super) fn guarded(typed: &str) -> String {
let mut out = String::with_capacity(typed.len());
let mut slashes = 0;
for ch in typed.chars() {
if ch == '|' && slashes % 2 == 0 {
out.push('\\');
}
slashes = if ch == '\\' { slashes + 1 } else { 0 };
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::{Aim, CellAt, Move, beside, find, guarded};
const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n";
fn block() -> std::ops::Range<usize> {
7..TABLE.len() - 1
}
fn at(row: usize, column: usize) -> CellAt {
CellAt {
table: 0,
row,
column,
}
}
#[test]
fn a_byte_opens_its_cell_with_the_caret_there() {
let byte = TABLE.find("**b**").expect("in the table") + 2;
let cell = find(TABLE, block(), Aim::Byte(byte)).expect("a cell");
assert_eq!(cell.at, at(0, 1));
assert_eq!(&TABLE[cell.range.clone()], "**b**");
assert_eq!(cell.caret, 2);
}
#[test]
fn an_empty_cell_opens_between_its_pipes() {
let cell = find(TABLE, block(), Aim::At(at(1, 1))).expect("a cell");
assert!(cell.range.is_empty());
let written = [&TABLE[..cell.range.start], "x", &TABLE[cell.range.end..]].concat();
assert!(written.contains("| c | x |"), "{written:?}");
}
#[test]
fn ends_open_the_first_and_last_cells() {
let first = find(TABLE, block(), Aim::First).expect("a cell");
assert_eq!((first.at, &TABLE[first.range]), (at(0, 0), "a"));
let last = find(TABLE, block(), Aim::Last).expect("a cell");
assert_eq!((last.at, &TABLE[last.range]), (at(2, 1), "e"));
assert_eq!(find("plain\n", 0..5, Aim::First), None);
}
#[test]
fn moves_wrap_rows_and_stop_at_edges() {
let to = |from, by| beside(TABLE, block(), from, by);
assert_eq!(to(at(0, 1), Move::Next), Some(at(1, 0)));
assert_eq!(to(at(1, 0), Move::Previous), Some(at(0, 1)));
assert_eq!(to(at(0, 0), Move::Previous), None);
assert_eq!(to(at(2, 1), Move::Next), None);
assert_eq!(to(at(1, 1), Move::Up), Some(at(0, 1)));
assert_eq!(to(at(2, 0), Move::Down), None);
}
#[test]
fn bare_pipes_escape_and_escaped_stay() {
assert_eq!(guarded("a|b"), "a\\|b");
assert_eq!(guarded("a\\|b"), "a\\|b");
assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b");
}
}
+80 -19
View File
@@ -2,7 +2,10 @@ use std::ops::Range;
use gpui::{AppContext as _, Context, Focusable as _, Window};
use super::{DocumentEvent, MarkdownDocument, Open};
use super::{
DocumentEvent, MarkdownDocument, Open, TargetBlock,
cells::{self, Aim, Move},
};
use crate::{
documents::{
MarkdownMode,
@@ -20,6 +23,8 @@ pub(super) enum Landing {
End,
/// A source byte of the drawn read.
At(usize),
/// A table cell, by byte or place.
Cell(Aim),
}
/// Where byte `at` lies after `edit`, if it survives.
@@ -76,8 +81,8 @@ impl MarkdownDocument {
return log::warn!("markdown document: pressed while the text is read again");
}
let text = self.editor.read(cx).text();
let (range, append) = match pressed {
None => (text.len()..text.len(), true),
let (range, append, cell) = match pressed {
None => (text.len()..text.len(), true, None),
Some(start) => {
let found = moved(start, &written).and_then(|start| {
self.parsed
@@ -88,12 +93,36 @@ impl MarkdownDocument {
let Some(block) = found else {
return log::warn!("markdown document: the pressed block changed as one wrote");
};
(block.range.clone(), false)
let table = block.kind == TargetBlock::Table;
let aim = match landing {
Landing::Cell(Aim::Byte(at)) => match moved(at, &written) {
Some(at) => Some(Aim::Byte(at)),
None => {
return log::warn!(
"markdown document: the pressed cell was written over"
);
}
},
Landing::Cell(aim) => Some(aim),
Landing::Start if table => Some(Aim::First),
Landing::End if table => Some(Aim::Last),
_ => None,
};
match aim {
None => (block.range.clone(), false, None),
Some(aim) => match cells::find(text, block.range.clone(), aim) {
Some(cell) => (cell.range, false, Some((cell.at, cell.caret))),
None => {
return log::warn!("markdown document: no cell {aim:?} in the block");
}
},
}
}
};
let caret = match landing {
_ if cell.is_some() => cell.map(|(_, caret)| caret),
Landing::Start => Some(0),
Landing::End => None,
Landing::End | Landing::Cell(_) => None,
Landing::At(at) => match moved(at, &written) {
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
_ => {
@@ -104,9 +133,11 @@ impl MarkdownDocument {
};
let source = text[range.clone()].to_string();
let input = cx.new(|cx| {
TextInput::new(window, cx)
.multi_line(1, usize::MAX)
.highlighter(markdown_highlights)
let input = TextInput::new(window, cx).highlighter(markdown_highlights);
match cell {
Some(_) => input,
None => input.multi_line(1, usize::MAX),
}
});
input.update(cx, |input, cx| {
input.set_text(source, cx);
@@ -118,23 +149,39 @@ impl MarkdownDocument {
let subscriptions = vec![cx.subscribe_in(
&input,
window,
|document, _, event: &InputEvent, window, cx| match event {
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
InputEvent::Blur => {
document.commit(cx);
|document, _, event: &InputEvent, window, cx| {
match event {
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
InputEvent::Blur => {
document.commit(cx);
}
InputEvent::Changed => {
document.typed += 1;
cx.emit(DocumentEvent::Changed)
}
// Enter in a cell writes it back.
InputEvent::Submit
if document
.open
.as_ref()
.is_some_and(|open| open.cell.is_some()) =>
{
document.commit(cx);
window.focus(&document.focus, cx);
}
_ => {}
}
InputEvent::Changed => {
document.typed += 1;
cx.emit(DocumentEvent::Changed)
}
_ => {}
},
)];
log::info!("markdown document: block {range:?} opens");
match cell {
Some((at, _)) => log::info!("markdown document: cell {at:?} at {range:?} opens"),
None => log::info!("markdown document: block {range:?} opens"),
}
self.open = Some(Open {
range,
input,
append,
cell: cell.map(|(at, _)| at),
lent: None,
_subscriptions: subscriptions,
});
@@ -187,7 +234,12 @@ impl MarkdownDocument {
let (lead, written) = match open.append {
true if typed.is_empty() => return None,
true => (joint(text).len(), format!("{}{typed}", joint(text))),
false => (0, typed),
false if open.cell.is_some() => (0, cells::guarded(&typed)),
false => (0, typed.clone()),
};
let caret = match open.cell {
Some(_) => cells::guarded(&typed[..caret]).len(),
None => caret,
};
let at = open.range.start + lead + caret;
let edit = (text[open.range.clone()] != written).then(|| {
@@ -222,6 +274,15 @@ impl MarkdownDocument {
return false;
}
let here = self.block_at(open.range.start);
if let Some(at) = open.cell {
let block = self.parsed.blocks[here].range.clone();
let by = if down { Move::Down } else { Move::Up };
let text = self.editor.read(cx).text();
if let Some(to) = cells::beside(text, block, at, by) {
self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx);
return true;
}
}
let next = match down {
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
false => here.checked_sub(1),
+24 -4
View File
@@ -5,7 +5,11 @@ use std::ops::Range;
use gpui::{Context, Window};
use markdown::mdast;
use super::MarkdownDocument;
use super::{
MarkdownDocument,
cells::{Aim, Move, beside},
edit::Landing,
};
use crate::documents::markdown::{span, tree, walk};
/// Spaces one indent step adds.
@@ -196,7 +200,7 @@ impl MarkdownDocument {
/// Enter on a block list line; false leaves it.
pub(super) fn block_newline(&mut self, cx: &mut Context<Self>) -> bool {
let Some(open) = &self.open else {
let Some(open) = self.open.as_ref().filter(|open| open.cell.is_none()) else {
return false;
};
let input = open.input.clone();
@@ -218,11 +222,27 @@ impl MarkdownDocument {
true
}
/// Tab or Shift-Tab in the open block.
pub(super) fn block_shift(&mut self, deeper: bool, _: &mut Window, cx: &mut Context<Self>) {
/// Tab or Shift-Tab in the open block; in a cell, the next or previous.
pub(super) fn block_shift(
&mut self,
deeper: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(open) = &self.open else {
return;
};
if let Some(at) = open.cell {
let here = self.block_at(open.range.start);
let block = self.parsed.blocks[here].range.clone();
let by = if deeper { Move::Next } else { Move::Previous };
let text = self.editor.read(cx).text();
match beside(text, block, at, by) {
Some(to) => self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx),
None => log::info!("markdown document: no cell {by:?} of {at:?}"),
}
return;
}
let input = open.input.clone();
let (text, selection) = {
let input = input.read(cx);
+5 -1
View File
@@ -1,6 +1,7 @@
mod apart;
mod attach;
mod block;
mod cells;
mod edit;
mod finding;
mod format;
@@ -36,7 +37,7 @@ use super::{
MarkdownMode,
markdown::markdown_highlights,
modes::joint,
render::{OnHeading, OnLink},
render::{CellAt, OnHeading, OnLink},
};
use crate::{
editor::{CodeEditor, EditorEvent, LineNumbers},
@@ -80,6 +81,8 @@ struct Open {
input: Entity<TextInput>,
/// Opened past the end, to add a block.
append: bool,
/// A table cell alone; its bare pipes escape.
cell: Option<CellAt>,
/// Watches an overlay that borrowed focus.
lent: Option<Subscription>,
_subscriptions: Vec<Subscription>,
@@ -243,6 +246,7 @@ impl MarkdownDocument {
let written = match open.append {
true if typed.is_empty() => String::new(),
true => format!("{}{typed}", joint(text)),
false if open.cell.is_some() => cells::guarded(typed),
false => typed.to_string(),
};
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
+12 -5
View File
@@ -3,7 +3,9 @@ use std::rc::Rc;
use gpui::{AppContext as _, Context, MouseButton, Pixels, Point, Window};
use markdown::mdast;
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block};
use super::{
DocumentEvent, MarkdownDocument, cells::Aim, edit::Landing, edit::moved, parse::Block,
};
use crate::documents::{
markdown::{file_tree, span, walk},
render::{OnPoint, Place, Pointed},
@@ -80,15 +82,20 @@ impl MarkdownDocument {
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
}
Place::At(at) => Some(*at),
Place::Block => None,
Place::Cell(_) | Place::Block => None,
}
.map(|at| start + at);
// A press in a table opens only its cell.
let landing = match (&pointed.place, at) {
(Place::Cell(cell), _) => Landing::Cell(Aim::At(*cell)),
(_, Some(at)) if pointed.table => Landing::Cell(Aim::Byte(at)),
(_, Some(at)) => Landing::At(at),
(_, None) => Landing::End,
};
let ix = Some(ix);
document
.update(cx, |document, cx| match button {
MouseButton::Left => {
document.open(ix, at.map_or(Landing::End, Landing::At), window, cx)
}
MouseButton::Left => document.open(ix, landing, window, cx),
MouseButton::Right => document.point(ix, at, pointed, window, cx),
_ => {}
})
+58
View File
@@ -0,0 +1,58 @@
//! A press in a table opens only its cell (R6.1).
use gpui::{Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
use super::{open, opened, text};
use crate::documents::MarkdownMode;
const TABLE: &str = "| a | **bold** |\n| --- | --- |\n| c | |\n";
/// Presses cell `row`, `column` of the first table near its left.
fn press_cell(row: usize, column: usize, cx: &mut VisualTestContext) {
let name = format!("markdown-cell-0-{row}-{column}");
let bounds = cx
.debug_bounds(Box::leak(name.into_boxed_str()))
.expect("the cell is drawn");
let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0));
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
cx.run_until_parked();
}
#[gpui::test]
fn a_press_opens_its_cell_and_writes_one_undo_step(cx: &mut TestAppContext) {
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
press_cell(0, 1, cx);
assert_eq!(opened(&document, cx), "**bold**");
assert!(
cx.debug_bounds("markdown-cell-0-0-0").is_some(),
"the rest stays drawn"
);
press_cell(1, 1, cx);
assert_eq!(opened(&document, cx), "");
cx.simulate_input("x|y");
assert_eq!(
text(&document, cx),
"| a | **bold** |\n| --- | --- |\n| c | x\\|y |\n"
);
cx.simulate_keystrokes("escape");
let editor = document.read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.undo(cx));
assert_eq!(text(&document, cx), TABLE);
}
#[gpui::test]
fn tab_and_arrows_walk_the_cells(cx: &mut TestAppContext) {
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
press_cell(0, 0, cx);
assert_eq!(opened(&document, cx), "a");
cx.simulate_keystrokes("tab");
assert_eq!(opened(&document, cx), "**bold**");
cx.simulate_keystrokes("shift-tab");
assert_eq!(opened(&document, cx), "a");
cx.simulate_keystrokes("down");
assert_eq!(opened(&document, cx), "c");
cx.simulate_keystrokes("enter");
assert!(document.read_with(cx, |document, _| document.open.is_none()));
assert_eq!(text(&document, cx), TABLE);
}
+1
View File
@@ -368,6 +368,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
mod attach;
mod block;
mod block_edges;
mod cells;
mod format;
mod history;
mod lend;
+4 -3
View File
@@ -341,9 +341,10 @@ fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) {
}
#[gpui::test]
fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) {
fn tidy_in_an_open_cell_writes_it_and_tidies(cx: &mut TestAppContext) {
let (document, cx) = open(SMALL, MarkdownMode::Visual, cx);
press(0, Modifiers::none(), cx);
assert_eq!(opened(&document, cx), "2");
document.update(cx, |document, cx| {
document
.edit_table(crate::documents::TableEdit::Tidy, cx)
@@ -351,8 +352,8 @@ fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) {
});
cx.run_until_parked();
assert_eq!(
opened(&document, cx),
"| A | B |\n| ---- | ---- |\n| 1 | 2 |"
text(&document, cx),
"| A | B |\n| ---- | ---- |\n| 1 | 2 |\n"
);
}
+31 -7
View File
@@ -9,7 +9,7 @@ use super::{MarkdownDocument, edit::Landing};
use crate::{
documents::{
MarkdownMode,
render::{Hooks, Pointed, draw_nodes, point_lines},
render::{Hooks, OpenCell, Pointed, draw_nodes, point_lines},
},
forms::{self, Down, Up},
primitives::FocusRing as _,
@@ -72,11 +72,7 @@ impl MarkdownDocument {
// Focus fades all but the reader's block.
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
let open = self.open.as_ref();
let point = cx.listener(|document, event: &MouseDownEvent, window, cx| {
window.prevent_default();
cx.stop_propagation();
document.point_open(event.position, cx);
});
let point = Self::point_in_open(cx);
let field = |open: &super::Open| {
div()
.debug_selector(|| "markdown-open".to_string())
@@ -98,9 +94,25 @@ impl MarkdownDocument {
(_, false) => div().into_any_element(),
};
};
if let Some(open) = open.filter(|open| open.range == block.range) {
if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) {
return field(open);
}
// A cell open in this block, typed in place.
let cell = open.and_then(|open| {
let inside = block.range.start <= open.range.start && open.range.end <= block.range.end;
Some(OpenCell {
at: open.cell.filter(|_| inside)?,
field: std::cell::RefCell::new(Some(
div()
.debug_selector(|| "markdown-open-cell".to_string())
.on_mouse_down(MouseButton::Right, Self::point_in_open(cx))
.rounded(radius)
.bg(colors.sunken)
.child(open.input.clone())
.into_any_element(),
)),
})
});
let shown = match &block.nodes {
Some(nodes) => div()
.flex()
@@ -117,6 +129,7 @@ impl MarkdownDocument {
on_link: &self.on_link,
on_heading: &self.on_heading,
on_point: &on_point,
cell: cell.as_ref(),
},
self.look.scale,
cx,
@@ -155,6 +168,17 @@ impl MarkdownDocument {
}
}
/// A right press in the open field points there.
fn point_in_open(
cx: &mut Context<Self>,
) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static {
cx.listener(|document, event: &MouseDownEvent, window, cx| {
window.prevent_default();
cx.stop_propagation();
document.point_open(event.position, cx);
})
}
/// `element` opens block `ix`; a right press points.
fn pressable(
&self,
+10 -55
View File
@@ -5,7 +5,6 @@ use gpui::{
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
UnderlineStyle, Window, div, prelude::*,
};
use markdown::mdast::AlignKind;
use super::{
contents::anchor,
@@ -23,8 +22,10 @@ use crate::{
mod blocks;
mod point;
mod table;
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
pub(crate) use table::{CellAt, OpenCell};
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
/// Reveals the heading whose source starts at a byte.
@@ -96,6 +97,8 @@ pub(crate) struct Hooks<'a> {
pub on_link: &'a Option<OnLink>,
pub on_heading: &'a Option<OnHeading>,
pub on_point: &'a Option<OnPoint>,
/// The cell typed in, drawn in its place.
pub cell: Option<&'a OpenCell>,
}
/// Draws nodes under one owner's ids and hooks.
@@ -112,6 +115,8 @@ struct Draw<'a> {
lists: std::cell::Cell<usize>,
/// Drawing a table's cells.
table: std::cell::Cell<bool>,
/// Tables drawn so far.
tables: std::cell::Cell<usize>,
}
impl Draw<'_> {
@@ -302,60 +307,7 @@ impl Draw<'_> {
self.point_code(block, Some(layout), text, *from)
}
Node::List { start, items } => self.list(start, items, cx),
Node::Table { aligns, head, rows } => {
let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| {
div()
.flex_1()
.min_w_0()
.flex()
.px_3()
.py_1p5()
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
cell.justify_end()
})
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
cell.justify_center()
})
.when(header, |cell| {
cell.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
})
.child(
div()
.max_w_full()
.flex()
.flex_col()
.gap_1()
.children(self.nodes(blocks, cx)),
)
};
div()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div().flex().bg(colors.hover).children(
head.iter()
.enumerate()
.map(|(column, blocks)| cell(blocks, column, true, cx)),
),
)
.children(rows.iter().map(|row| {
div()
.flex()
.border_t_1()
.border_color(colors.border)
.children(
row.iter()
.enumerate()
.map(|(column, blocks)| cell(blocks, column, false, cx)),
)
}))
.into_any_element()
}
Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
Node::Rule => div()
.debug_selector(|| "markdown-rule".into())
.my_2()
@@ -443,6 +395,7 @@ pub(crate) fn draw_nodes(
scale,
lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
};
draw.nodes(nodes, cx)
}
@@ -458,6 +411,7 @@ impl RenderOnce for MarkdownRenderer {
on_link: &self.on_link,
on_heading: &None,
on_point: &None,
cell: None,
},
next: std::cell::Cell::new(0),
sections: &self.sections,
@@ -466,6 +420,7 @@ impl RenderOnce for MarkdownRenderer {
scale: 1.0,
lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
};
div()
.flex()
+25 -5
View File
@@ -1,11 +1,11 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent,
AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent,
ParentElement as _, TextLayout, Window, div,
};
use super::Draw;
use super::{Draw, table::CellAt};
use crate::documents::tree::Inline;
/// Where a press fell, as the drawing knows it.
@@ -20,6 +20,8 @@ pub(crate) enum Place {
from: usize,
at: usize,
},
/// A table cell, pressed past its text.
Cell(CellAt),
/// Somewhere the block holds no byte for.
Block,
}
@@ -61,6 +63,19 @@ fn pointable(
table: bool,
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
) -> AnyElement {
match on_point {
Some(_) => pressed(div().child(element), on_point, table, point).into_any_element(),
None => element,
}
}
/// `element` itself takes the presses `pointable` hands on.
fn pressed<E: InteractiveElement>(
element: E,
on_point: &Option<OnPoint>,
table: bool,
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
) -> E {
let Some(on_point) = on_point.clone() else {
return element;
};
@@ -79,11 +94,9 @@ fn pointable(
on_point(pointed, button, window, cx);
}
};
div()
element
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
.child(element)
.into_any_element()
}
/// Drawn `text`, its lines written from source byte `from`.
@@ -120,6 +133,13 @@ impl Draw<'_> {
pointable(element, self.hooks.on_point, self.table.get(), point)
}
/// A table cell taking its presses, past its text too.
pub(super) fn pressed_cell(&self, cell: Div, at: CellAt) -> Div {
pressed(cell, self.hooks.on_point, true, move |_| {
Pointed::at(Place::Cell(at))
})
}
/// Drawn `inline`; a press finds its byte through `layout`.
pub(super) fn point_text(
&self,
+99
View File
@@ -0,0 +1,99 @@
use std::cell::RefCell;
use gpui::{AnyElement, App, FontWeight, IntoElement, ParentElement, Styled, div, prelude::*};
use markdown::mdast::AlignKind;
use super::Draw;
use crate::{
documents::tree::Node,
theme::{ActiveTheme, Radius},
};
/// A cell: its table in drawing order, row from the head, column.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CellAt {
pub table: usize,
pub row: usize,
pub column: usize,
}
/// The cell open for typing and its field, drawn once.
pub(crate) struct OpenCell {
pub at: CellAt,
pub field: RefCell<Option<AnyElement>>,
}
impl Draw<'_> {
pub(super) fn table(
&self,
aligns: &[AlignKind],
head: &[Vec<Node>],
rows: &[Vec<Vec<Node>>],
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let table = self.tables.get();
self.tables.set(table + 1);
let cell = |blocks: &[Node], row: usize, column: usize, cx: &App| {
let at = CellAt { table, row, column };
let open = (self.hooks.cell)
.filter(|open| open.at == at)
.and_then(|open| open.field.borrow_mut().take());
let drawn = div()
.debug_selector(move || format!("markdown-cell-{table}-{row}-{column}"))
.flex_1()
.min_w_0()
.flex()
.px_3()
.py_1p5()
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
cell.justify_end()
})
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
cell.justify_center()
})
.when(row == 0, |cell| {
cell.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
})
.child(match open {
Some(field) => div().w_full().child(field),
None => div()
.max_w_full()
.flex()
.flex_col()
.gap_1()
.children(self.nodes(blocks, cx)),
});
self.pressed_cell(drawn, at)
};
div()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div().flex().bg(colors.hover).children(
head.iter()
.enumerate()
.map(|(column, blocks)| cell(blocks, 0, column, cx)),
),
)
.children(rows.iter().enumerate().map(|(row, cells)| {
div()
.flex()
.border_t_1()
.border_color(colors.border)
.children(
cells
.iter()
.enumerate()
.map(|(column, blocks)| cell(blocks, row + 1, column, cx)),
)
}))
.into_any_element()
}
}