Documents: cells keep their row's pipes; long reads finish cell moves
An emptied bare edge cell writes a pipe so its column stays. The caret reads the escaped write-back. A right press beside an edited cell keeps its place. A move that waits on a long read opens once the read lands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2b6bd1d369
commit
f1771b3880
8 files changed
+224
-44
No files matched your search
@@ -132,10 +132,10 @@ impl MarkdownDocument {
|
||||
// A cell reads as it writes back.
|
||||
let typed = match open.cell {
|
||||
Some(_) => {
|
||||
let closed = cells::closed(text, open.range.end);
|
||||
let shift = |at: usize| cells::kept_at(typed, at, closed);
|
||||
let edges = cells::edges(text, open.range.clone());
|
||||
let shift = |at: usize| cells::kept_at(typed, at, edges);
|
||||
picked = shift(picked.start)..shift(picked.end);
|
||||
cells::kept(typed, closed)
|
||||
cells::kept(typed, edges)
|
||||
}
|
||||
None => typed.to_string(),
|
||||
};
|
||||
@@ -157,8 +157,8 @@ impl MarkdownDocument {
|
||||
if let Some(open) = &self.open {
|
||||
let typed = open.input.read(cx).text();
|
||||
// Offsets read the escaped cell; its field holds it raw.
|
||||
let closed = cells::closed(self.editor.read(cx).text(), open.range.end);
|
||||
let raw = open.cell.is_some() && cells::kept(typed, closed) != typed;
|
||||
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||
let raw = open.cell.is_some() && cells::kept(typed, edges) != 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>());
|
||||
|
||||
@@ -157,32 +157,55 @@ fn guarded(typed: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a pipe follows the cell source ending at `end`.
|
||||
pub(super) fn closed(text: &str, end: usize) -> bool {
|
||||
text[end..].starts_with('|')
|
||||
/// What borders a cell's source in its row.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Edges {
|
||||
/// A pipe follows at once.
|
||||
closed: bool,
|
||||
/// No pipe before it on its line.
|
||||
first: bool,
|
||||
/// No pipe after it on its line.
|
||||
last: bool,
|
||||
}
|
||||
|
||||
/// `typed` as a cell keeps it, a `closed` pipe intact.
|
||||
pub(super) fn kept(typed: &str, closed: bool) -> String {
|
||||
/// The borders of the cell source at `range` of `text`.
|
||||
pub(super) fn edges(text: &str, range: Range<usize>) -> Edges {
|
||||
let line = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let end = (text[range.end..].find('\n')).map_or(text.len(), |at| range.end + at);
|
||||
let (before, after) = (&text[line..range.start], &text[range.end..end]);
|
||||
Edges {
|
||||
closed: after.starts_with('|'),
|
||||
first: !before.contains('|'),
|
||||
last: !after.contains('|'),
|
||||
}
|
||||
}
|
||||
|
||||
/// `typed` as a cell keeps it, its row's pipes intact.
|
||||
pub(super) fn kept(typed: &str, edges: Edges) -> String {
|
||||
let mut out = guarded(typed);
|
||||
let slashes = out.len() - out.trim_end_matches('\\').len();
|
||||
if closed && slashes % 2 == 1 {
|
||||
if edges.closed && slashes % 2 == 1 {
|
||||
out.push('\\');
|
||||
}
|
||||
// An emptied bare edge cell keeps its column.
|
||||
if out.is_empty() && (edges.first || edges.last) {
|
||||
out.push('|');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte `at` of `typed` within `kept(typed, closed)`.
|
||||
pub(super) fn kept_at(typed: &str, at: usize, closed: bool) -> usize {
|
||||
/// Byte `at` of `typed` within `kept(typed, edges)`.
|
||||
pub(super) fn kept_at(typed: &str, at: usize, edges: Edges) -> usize {
|
||||
match at == typed.len() {
|
||||
true => kept(typed, closed).len(),
|
||||
true if typed.is_empty() && !edges.first => 0,
|
||||
true => kept(typed, edges).len(),
|
||||
false => guarded(&typed[..at]).len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Aim, CellAt, Move, beside, closed, find, guarded, kept, kept_at};
|
||||
use super::{Aim, CellAt, Edges, Move, beside, edges, find, guarded, kept, kept_at};
|
||||
|
||||
const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n";
|
||||
|
||||
@@ -242,36 +265,74 @@ mod tests {
|
||||
assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b");
|
||||
}
|
||||
|
||||
/// Cell `(row, column)` of `text`'s one table, written as `typed`.
|
||||
fn write(text: &str, row: usize, column: usize, typed: &str) -> String {
|
||||
let block = 0..text.len() - 1;
|
||||
let cell = find(text, block, Aim::At(at(row, column))).expect("a cell");
|
||||
let kept = kept(typed, edges(text, cell.range.clone()));
|
||||
[&text[..cell.range.start], &kept, &text[cell.range.end..]].concat()
|
||||
}
|
||||
|
||||
/// The source of each cell of `text`'s one table.
|
||||
fn grid(text: &str) -> Vec<Vec<String>> {
|
||||
let block = 0..text.len() - 1;
|
||||
(0..)
|
||||
.map_while(|row| {
|
||||
let cells: Vec<String> = (0..)
|
||||
.map_while(|column| {
|
||||
let cell = find(text, block.clone(), Aim::At(at(row, column)))?;
|
||||
Some(text[cell.range].to_string())
|
||||
})
|
||||
.collect();
|
||||
(!cells.is_empty()).then_some(cells)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_backslash_keeps_the_next_pipe() {
|
||||
assert_eq!(kept("a\\", true), "a\\\\");
|
||||
assert_eq!(kept("a\\\\", true), "a\\\\");
|
||||
assert_eq!(kept_at("a\\", 2, true), 3);
|
||||
assert_eq!(kept_at("a\\b", 2, true), 2);
|
||||
let text = "|a|b|\n|---|---|\n|c|d|\n";
|
||||
let cell = find(text, 0..text.len() - 1, Aim::First).expect("a cell");
|
||||
let shut = closed(text, cell.range.end);
|
||||
let written = [
|
||||
&text[..cell.range.start],
|
||||
&kept("a\\", shut),
|
||||
&text[cell.range.end..],
|
||||
]
|
||||
.concat();
|
||||
let b = find(&written, 0..written.len() - 1, Aim::At(at(0, 1))).expect("still a table");
|
||||
assert_eq!(&written[b.range], "b");
|
||||
let shut = Edges {
|
||||
closed: true,
|
||||
first: false,
|
||||
last: false,
|
||||
};
|
||||
assert_eq!(kept("a\\", shut), "a\\\\");
|
||||
assert_eq!(kept("a\\\\", shut), "a\\\\");
|
||||
assert_eq!(kept_at("a\\", 2, shut), 3);
|
||||
assert_eq!(kept_at("a\\b", 2, shut), 2);
|
||||
let written = write("|a|b|\n|---|---|\n|c|d|\n", 0, 0, "a\\");
|
||||
assert_eq!(grid(&written)[0], ["a\\\\", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backslash_before_padding_stays_single() {
|
||||
let text = "| a\\ | b |\n| --- | --- |\n";
|
||||
let cell = find(text, 0..text.len() - 1, Aim::First).expect("a cell");
|
||||
assert_eq!(&text[cell.range.clone()], "a\\");
|
||||
assert!(!closed(text, cell.range.end));
|
||||
assert_eq!(kept("a\\", false), "a\\");
|
||||
assert_eq!(write(text, 0, 0, "a\\"), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emptied_bare_edge_cell_keeps_its_column() {
|
||||
let text = "alpha | beta\n--- | ---\nc | d\n";
|
||||
let rows = |written: &str| grid(written).iter().map(Vec::len).collect::<Vec<_>>();
|
||||
for (row, column) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
let written = write(text, row, column, "");
|
||||
assert_eq!(rows(&written), [2, 2], "{written:?}");
|
||||
assert_eq!(grid(&written)[row][column], "", "{written:?}");
|
||||
}
|
||||
assert_eq!(grid(&write(text, 1, 0, ""))[1], ["", "d"]);
|
||||
let empty = edges(text, 0..5);
|
||||
assert_eq!(kept_at("", 0, empty), 1, "past its new pipe");
|
||||
let last = edges(text, 8..12);
|
||||
assert_eq!(kept_at("", 0, last), 0, "before its new pipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_pipes_shift_later_bytes() {
|
||||
assert_eq!(kept_at("||x", 2, false), 4);
|
||||
let open = Edges {
|
||||
closed: false,
|
||||
first: false,
|
||||
last: false,
|
||||
};
|
||||
assert_eq!(kept_at("||x", 2, open), 4);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,17 @@ pub(super) enum Landing {
|
||||
Cell(Aim),
|
||||
}
|
||||
|
||||
impl Landing {
|
||||
/// The same landing after `edit`, if it survives.
|
||||
fn moved(self, edit: &Option<(Range<usize>, usize)>) -> Option<Self> {
|
||||
Some(match self {
|
||||
Self::At(at) => Self::At(moved(at, edit)?),
|
||||
Self::Cell(Aim::Byte(at)) => Self::Cell(Aim::Byte(moved(at, edit)?)),
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where byte `at` lies after `edit`, if it survives.
|
||||
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
||||
match edit {
|
||||
@@ -69,6 +80,7 @@ impl MarkdownDocument {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.waiting = None;
|
||||
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||
pressed => pressed.flatten().map(|block| block.range.start),
|
||||
@@ -77,6 +89,19 @@ impl MarkdownDocument {
|
||||
if written.is_some() {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) && written.is_some() {
|
||||
// A long write-back reads first; it opens after.
|
||||
let start = match pressed {
|
||||
None => Some(None),
|
||||
Some(start) => moved(start, &written).map(Some),
|
||||
};
|
||||
let (Some(start), Some(landing)) = (start, landing.moved(&written)) else {
|
||||
return log::warn!("markdown document: the pressed block was written over");
|
||||
};
|
||||
log::info!("markdown document: a block opens once its text is read");
|
||||
self.waiting = Some((start, landing, self.edits + self.writes as u64));
|
||||
return;
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
@@ -188,6 +213,25 @@ impl MarkdownDocument {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A block asked open before its text was read, now opened.
|
||||
pub(super) fn open_waiting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.waiting.is_none() || !self.read_now(cx) {
|
||||
return;
|
||||
}
|
||||
let (start, landing, expected) = self.waiting.take().expect("checked above");
|
||||
if self.edits != expected {
|
||||
return log::info!("markdown document: a waiting block lapsed with its text");
|
||||
}
|
||||
let ix = match start {
|
||||
None => None,
|
||||
Some(start) => match (self.parsed.blocks.iter()).position(|b| b.range.start == start) {
|
||||
Some(ix) => Some(ix),
|
||||
None => return log::warn!("markdown document: the waiting block was read away"),
|
||||
},
|
||||
};
|
||||
self.open(ix, landing, window, cx);
|
||||
}
|
||||
|
||||
/// Takes the focus a dropped block still holds.
|
||||
pub(super) fn take_orphan(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(orphan) = self.orphan.take() else {
|
||||
@@ -231,15 +275,15 @@ impl MarkdownDocument {
|
||||
let typed = open.input.read(cx).text().to_string();
|
||||
let caret = open.input.read(cx).cursor();
|
||||
let text = self.editor.read(cx).text();
|
||||
let closed = cells::closed(text, open.range.end);
|
||||
let edges = cells::edges(text, open.range.clone());
|
||||
let (lead, written) = match open.append {
|
||||
true if typed.is_empty() => return None,
|
||||
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
||||
false if open.cell.is_some() => (0, cells::kept(&typed, closed)),
|
||||
false if open.cell.is_some() => (0, cells::kept(&typed, edges)),
|
||||
false => (0, typed.clone()),
|
||||
};
|
||||
let caret = match open.cell {
|
||||
Some(_) => cells::kept_at(&typed, caret, closed),
|
||||
Some(_) => cells::kept_at(&typed, caret, edges),
|
||||
None => caret,
|
||||
};
|
||||
let at = open.range.start + lead + caret;
|
||||
|
||||
@@ -109,6 +109,8 @@ pub struct MarkdownDocument {
|
||||
writes: usize,
|
||||
/// A match asked before its text was read; its version.
|
||||
showing: Option<(Range<usize>, u64)>,
|
||||
/// A block asked open before its text was read.
|
||||
waiting: Option<(Option<usize>, edit::Landing, u64)>,
|
||||
look: DocumentLook,
|
||||
/// Typing in the open block, counted.
|
||||
typed: u64,
|
||||
@@ -181,6 +183,7 @@ impl MarkdownDocument {
|
||||
asks: 0,
|
||||
writes: 0,
|
||||
showing: None,
|
||||
waiting: None,
|
||||
look: DocumentLook::default(),
|
||||
typed: 0,
|
||||
centered: None,
|
||||
@@ -246,7 +249,9 @@ 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::kept(typed, cells::closed(text, open.range.end)),
|
||||
false if open.cell.is_some() => {
|
||||
cells::kept(typed, cells::edges(text, open.range.clone()))
|
||||
}
|
||||
false => typed.to_string(),
|
||||
};
|
||||
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
||||
|
||||
use super::{MarkdownDocument, Placement};
|
||||
use super::{MarkdownDocument, Placement, cells};
|
||||
use crate::documents::{MarkdownMode, modes::joint};
|
||||
|
||||
/// Line and column, from zero, of byte `at` in `text`.
|
||||
@@ -25,7 +25,15 @@ impl MarkdownDocument {
|
||||
true => joint(editor.text()).len(),
|
||||
false => 0,
|
||||
};
|
||||
point(&self.text(cx), open.range.start + joined + input.cursor())
|
||||
// A cell's caret reads its escaped write-back.
|
||||
let cursor = match open.cell {
|
||||
Some(_) => {
|
||||
let edges = cells::edges(editor.text(), open.range.clone());
|
||||
cells::kept_at(input.text(), input.cursor(), edges)
|
||||
}
|
||||
None => input.cursor(),
|
||||
};
|
||||
point(&self.text(cx), open.range.start + joined + cursor)
|
||||
}
|
||||
|
||||
/// The selected Markdown: the source's, or the open block's.
|
||||
|
||||
@@ -187,7 +187,9 @@ impl MarkdownDocument {
|
||||
let now = (self.parsed.blocks.iter())
|
||||
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||
.map(|block| self.drawn(block));
|
||||
if written.is_some() && pressed.is_some() && now != pressed {
|
||||
// A cell beside the written one keeps its place.
|
||||
let beside = pointed.table && now.as_ref().is_some_and(|now| now.0 == TargetBlock::Table);
|
||||
if written.is_some() && pressed.is_some() && now != pressed && !beside {
|
||||
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||
}
|
||||
self.editor
|
||||
@@ -220,8 +222,8 @@ impl MarkdownDocument {
|
||||
// A cell's typed pipes shift its bytes.
|
||||
let pressed = match open.cell {
|
||||
Some(_) => {
|
||||
let closed = cells::closed(self.editor.read(cx).text(), open.range.end);
|
||||
cells::kept_at(input.text(), pressed, closed)
|
||||
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||
cells::kept_at(input.text(), pressed, edges)
|
||||
}
|
||||
None => pressed,
|
||||
};
|
||||
|
||||
@@ -241,3 +241,62 @@ fn a_right_press_on_the_open_cells_padding_points_at_its_end(cx: &mut TestAppCon
|
||||
);
|
||||
assert_eq!(opened(&document, cx), "alpha||", "the cell stays open");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_caret_after_a_typed_pipe_reads_the_escaped_cell(cx: &mut TestAppContext) {
|
||||
for (typed, column) in [("|中", 5), ("|x", 5)] {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_keystrokes("backspace backspace backspace backspace backspace");
|
||||
cx.simulate_input(typed);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (0, column), "{typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn emptied_bare_edge_cells_keep_the_table(cx: &mut TestAppContext) {
|
||||
let cases = [
|
||||
(0, 0, 5, "| | beta\n--- | ---\nc | d\n"),
|
||||
(0, 1, 4, "alpha | |\n--- | ---\nc | d\n"),
|
||||
(1, 0, 1, "alpha | beta\n--- | ---\n| | d\n"),
|
||||
];
|
||||
for (row, column, chars, written) in cases {
|
||||
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||
press_cell(row, column, cx);
|
||||
for _ in 0..chars {
|
||||
cx.simulate_keystrokes("backspace");
|
||||
}
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(text(&document, cx), written);
|
||||
press_cell(1, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "d", "{written:?} is still a table");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_beside_an_edited_cell_points(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
press_cell_with(0, 1, MouseButton::Right, cx);
|
||||
let at = heard.borrow().last().expect("heard").at;
|
||||
let written = "| alphaX | beta |\n| --- | --- |\n| c | d |\n";
|
||||
assert_eq!(text(&document, cx), written);
|
||||
assert_eq!(at, written.find("beta").expect("the cell") + 4);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tab_in_a_long_document_opens_the_next_cell_once_read(cx: &mut TestAppContext) {
|
||||
let long = format!("{WORDS}\n```\n{}\n```\n", "x".repeat(super::super::AT_ONCE));
|
||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| document.reveal(0, window, cx));
|
||||
super::frames(cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
cx.simulate_keystrokes("tab");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(opened(&document, cx), "beta");
|
||||
assert!(text(&document, cx).starts_with("| alphaX | beta |"));
|
||||
}
|
||||
@@ -230,6 +230,7 @@ impl Render for MarkdownDocument {
|
||||
// A top line asked before its text was read.
|
||||
self.place_top(window, cx);
|
||||
self.show_waiting(window, cx);
|
||||
self.open_waiting(window, cx);
|
||||
let colors = cx.theme().colors.clone();
|
||||
let root = div()
|
||||
.size_full()
|
||||
|
||||
Reference in new issue
Block a user