Documents: a press in a table opens its cell alone
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:
1 parent
991ca7b548
commit
2594b3fd0f
14 files changed
+591
-106
No files matched your search
@@ -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)) = (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user