documents: lists go on, block lines shift, tables change, matches show, fences color by language
The document carries Markdown's editing tools for ElyMark (MARK-06): Enter continues a list item or ends an empty one, Tab and Shift-Tab shift an open block's lines, table rows and columns come and go as one undo step, a match shows in any mode, replacements are one step, and a fenced block colors by its language. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2e196a10a7
commit
ce4a1e9cb5
12 files changed
+680
-8
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
//! Matches shown in any mode; replacements in one step.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::{Context, Window};
|
||||||
|
|
||||||
|
use super::MarkdownDocument;
|
||||||
|
use crate::documents::MarkdownMode;
|
||||||
|
|
||||||
|
impl MarkdownDocument {
|
||||||
|
/// `range` in view: selected, or in its opened block.
|
||||||
|
pub fn show_match(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let line = |document: &Self, cx: &Context<Self>| {
|
||||||
|
document.editor.read(cx).text()[..range.start]
|
||||||
|
.matches('\n')
|
||||||
|
.count()
|
||||||
|
};
|
||||||
|
match self.mode {
|
||||||
|
MarkdownMode::Source | MarkdownMode::Split => {
|
||||||
|
self.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(range), cx));
|
||||||
|
}
|
||||||
|
MarkdownMode::Preview => self.reveal(line(self, cx), window, cx),
|
||||||
|
MarkdownMode::Visual => {
|
||||||
|
if self.commit(cx).is_some() {
|
||||||
|
self.reparse(true, cx);
|
||||||
|
}
|
||||||
|
if !self.read_now(cx) {
|
||||||
|
return log::warn!("markdown document: a match shown while the text is read");
|
||||||
|
}
|
||||||
|
let ix = self.block_at(range.start);
|
||||||
|
let held = self.parsed.blocks.get(ix).map(|block| block.range.clone());
|
||||||
|
let Some(block) =
|
||||||
|
held.filter(|block| block.start <= range.start && range.end <= block.end)
|
||||||
|
else {
|
||||||
|
// Outside one block: its line.
|
||||||
|
return self.reveal(line(self, cx), window, cx);
|
||||||
|
};
|
||||||
|
let start = block.start;
|
||||||
|
self.open(Some(ix), false, window, cx);
|
||||||
|
self.list.scroll_to_reveal_item(ix);
|
||||||
|
if let Some(open) = &self.open {
|
||||||
|
let input = open.input.clone();
|
||||||
|
let inside = range.start - start..range.end - start;
|
||||||
|
input.update(cx, |input, cx| input.select(inside, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `edits` to the text as one undo step.
|
||||||
|
pub fn replace(&mut self, edits: Vec<(Range<usize>, String)>, cx: &mut Context<Self>) {
|
||||||
|
self.commit(cx);
|
||||||
|
log::info!("markdown document: {} replaced", edits.len());
|
||||||
|
self.editor.update(cx, |editor, cx| editor.edit(edits, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Lists go on as typed; lines shift in and out.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::{Context, Window};
|
||||||
|
|
||||||
|
use super::MarkdownDocument;
|
||||||
|
|
||||||
|
/// Spaces one indent step adds.
|
||||||
|
const STEP: usize = 4;
|
||||||
|
|
||||||
|
/// What Enter makes of a list line.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(super) enum Next {
|
||||||
|
/// A new item: this follows the line break.
|
||||||
|
Item(String),
|
||||||
|
/// An empty item ends the list: the line empties.
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item after `line`; none outside a list.
|
||||||
|
pub(super) fn next(line: &str) -> Option<Next> {
|
||||||
|
let indent = line.len() - line.trim_start_matches(' ').len();
|
||||||
|
let rest = &line[indent..];
|
||||||
|
let ruled = rest.chars().filter(|ch| "-*_".contains(*ch)).count() >= 3
|
||||||
|
&& rest.chars().all(|ch| "-*_ ".contains(ch));
|
||||||
|
if ruled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (mark, after) = bullet(rest).or_else(|| ordered(rest))?;
|
||||||
|
let body = &rest[after..];
|
||||||
|
let (task, body) = match body.get(..4) {
|
||||||
|
Some("[ ] " | "[x] " | "[X] ") => ("[ ] ", &body[4..]),
|
||||||
|
_ => ("", body),
|
||||||
|
};
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return Some(Next::End);
|
||||||
|
}
|
||||||
|
Some(Next::Item(format!("{}{mark}{task}", " ".repeat(indent))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bullet marker, and where its text starts.
|
||||||
|
fn bullet(rest: &str) -> Option<(String, usize)> {
|
||||||
|
let mark = rest.chars().next().filter(|ch| "-*+".contains(*ch))?;
|
||||||
|
rest[1..].starts_with(' ').then(|| (format!("{mark} "), 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number marker, counted on; where text starts.
|
||||||
|
fn ordered(rest: &str) -> Option<(String, usize)> {
|
||||||
|
let digits = rest.chars().take_while(char::is_ascii_digit).count();
|
||||||
|
if !(1..=9).contains(&digits) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let close = rest[digits..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.filter(|ch| ".)".contains(*ch))?;
|
||||||
|
if !rest[digits + 1..].starts_with(' ') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let number: u64 = rest[..digits].parse().expect("ascii digits");
|
||||||
|
Some((format!("{}{close} ", number + 1), digits + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines `range` touches, a step in or out.
|
||||||
|
pub(super) fn shift(text: &str, range: Range<usize>, deeper: bool) -> (String, Range<usize>) {
|
||||||
|
let first = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
||||||
|
let last = text[range.end..]
|
||||||
|
.find('\n')
|
||||||
|
.map_or(text.len(), |at| range.end + at);
|
||||||
|
// Each change: where, and bytes added or removed.
|
||||||
|
let mut changes: Vec<(usize, isize)> = Vec::new();
|
||||||
|
let mut at = first;
|
||||||
|
loop {
|
||||||
|
let line = &text[at..text[at..].find('\n').map_or(text.len(), |end| at + end)];
|
||||||
|
let spaces = line.len() - line.trim_start_matches(' ').len();
|
||||||
|
match deeper {
|
||||||
|
true => changes.push((at, STEP as isize)),
|
||||||
|
false if spaces > 0 => changes.push((at, -(spaces.min(STEP) as isize))),
|
||||||
|
false => {}
|
||||||
|
}
|
||||||
|
match text[at..].find('\n') {
|
||||||
|
Some(end) if at + end < last => at += end + 1,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = String::with_capacity(text.len() + changes.len() * STEP);
|
||||||
|
let mut from = 0;
|
||||||
|
for (at, change) in &changes {
|
||||||
|
out.push_str(&text[from..*at]);
|
||||||
|
from = *at;
|
||||||
|
match *change > 0 {
|
||||||
|
true => out.push_str(&" ".repeat(STEP)),
|
||||||
|
false => from += change.unsigned_abs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&text[from..]);
|
||||||
|
let moved = |pos: usize| {
|
||||||
|
let mut shifted = pos as isize;
|
||||||
|
for (at, change) in &changes {
|
||||||
|
match *change > 0 {
|
||||||
|
true if *at <= pos => shifted += change,
|
||||||
|
false if *at < pos => shifted -= (pos - at).min(change.unsigned_abs()) as isize,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shifted as usize
|
||||||
|
};
|
||||||
|
(out, moved(range.start)..moved(range.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownDocument {
|
||||||
|
/// Enter on a list line in source; false leaves it.
|
||||||
|
pub(super) fn source_newline(&mut self, cx: &mut Context<Self>) -> bool {
|
||||||
|
let editor = self.editor.read(cx);
|
||||||
|
let caret = editor.primary().range();
|
||||||
|
if editor.selections().len() != 1 || !caret.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let text = editor.text();
|
||||||
|
let start = text[..caret.start].rfind('\n').map_or(0, |at| at + 1);
|
||||||
|
let end = text[caret.start..]
|
||||||
|
.find('\n')
|
||||||
|
.map_or(text.len(), |at| caret.start + at);
|
||||||
|
let (edit, after) = match next(&text[start..end]) {
|
||||||
|
Some(Next::Item(prefix)) => {
|
||||||
|
let typed = format!("\n{prefix}");
|
||||||
|
let after = caret.start + typed.len();
|
||||||
|
((caret.clone(), typed), after)
|
||||||
|
}
|
||||||
|
Some(Next::End) if caret.start == end => ((start..end, String::new()), start),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
log::info!("markdown document: a list goes on in source");
|
||||||
|
self.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([edit], cx);
|
||||||
|
editor.select(std::iter::once(after..after), cx);
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let input = open.input.clone();
|
||||||
|
let (text, caret) = {
|
||||||
|
let input = input.read(cx);
|
||||||
|
(input.text().to_string(), input.selection())
|
||||||
|
};
|
||||||
|
if !caret.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = text[..caret.start].rfind('\n').map_or(0, |at| at + 1);
|
||||||
|
let end = text[caret.start..]
|
||||||
|
.find('\n')
|
||||||
|
.map_or(text.len(), |at| caret.start + at);
|
||||||
|
let (range, typed) = match next(&text[start..end]) {
|
||||||
|
Some(Next::Item(prefix)) => (caret, format!("\n{prefix}")),
|
||||||
|
Some(Next::End) if caret.start == end => (start..end, String::new()),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
log::info!("markdown document: a list goes on in a block");
|
||||||
|
input.update(cx, |input, cx| {
|
||||||
|
input.select(range, cx);
|
||||||
|
input.insert(&typed, cx);
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab or Shift-Tab in the open block.
|
||||||
|
pub(super) fn block_shift(&mut self, deeper: bool, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let input = open.input.clone();
|
||||||
|
let (text, selection) = {
|
||||||
|
let input = input.read(cx);
|
||||||
|
(input.text().to_string(), input.selection())
|
||||||
|
};
|
||||||
|
let (shifted, selection) = shift(&text, selection, deeper);
|
||||||
|
input.update(cx, |input, cx| {
|
||||||
|
input.set_text(shifted, cx);
|
||||||
|
input.select(selection, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Next, next, shift};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn items_go_on_with_their_marker() {
|
||||||
|
let item = |line| match next(line) {
|
||||||
|
Some(Next::Item(prefix)) => prefix,
|
||||||
|
other => panic!("{line:?} made {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(item("- a"), "- ");
|
||||||
|
assert_eq!(item(" * a"), " * ");
|
||||||
|
assert_eq!(item("9. a"), "10. ");
|
||||||
|
assert_eq!(item("3) a"), "4) ");
|
||||||
|
assert_eq!(item("- [x] done"), "- [ ] ");
|
||||||
|
assert_eq!(next("- "), Some(Next::End));
|
||||||
|
assert_eq!(next("1. [ ] "), Some(Next::End));
|
||||||
|
for plain in ["text", "-a", "* * *", "---", "1.a", ""] {
|
||||||
|
assert_eq!(next(plain), None, "{plain:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_shift_in_and_out_with_the_selection() {
|
||||||
|
assert_eq!(shift("a\nb", 0..0, true), (" a\nb".into(), 4..4));
|
||||||
|
assert_eq!(shift("a\nb", 1..3, true), (" a\n b".into(), 5..11));
|
||||||
|
assert_eq!(shift(" a", 7..7, false), (" a".into(), 3..3));
|
||||||
|
assert_eq!(shift(" a\nb", 1..1, false), ("a\nb".into(), 0..0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
mod edit;
|
mod edit;
|
||||||
|
mod finding;
|
||||||
|
mod lists;
|
||||||
mod parse;
|
mod parse;
|
||||||
mod place;
|
mod place;
|
||||||
|
mod tables;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod visual;
|
mod visual;
|
||||||
@@ -16,6 +19,7 @@ use gpui::{
|
|||||||
|
|
||||||
pub use parse::DocumentHeading;
|
pub use parse::DocumentHeading;
|
||||||
use parse::{Parsed, alike, parsed};
|
use parse::{Parsed, alike, parsed};
|
||||||
|
pub use tables::TableEdit;
|
||||||
|
|
||||||
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Tables change by rows and columns (MARK-06).
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::Context;
|
||||||
|
use markdown::mdast::{self, AlignKind};
|
||||||
|
|
||||||
|
use super::MarkdownDocument;
|
||||||
|
use crate::documents::markdown::{span, tree, walk};
|
||||||
|
|
||||||
|
/// What a table edit does at the caret.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TableEdit {
|
||||||
|
RowAbove,
|
||||||
|
RowBelow,
|
||||||
|
ColumnLeft,
|
||||||
|
ColumnRight,
|
||||||
|
DeleteRow,
|
||||||
|
DeleteColumn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new table: three columns, a head and two rows.
|
||||||
|
const NEW: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |";
|
||||||
|
|
||||||
|
/// The table around `at` after `edit`: its range, text, caret.
|
||||||
|
pub(super) fn edit_table(
|
||||||
|
text: &str,
|
||||||
|
at: usize,
|
||||||
|
edit: TableEdit,
|
||||||
|
) -> Option<(Range<usize>, String, usize)> {
|
||||||
|
let root = tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let (range, table) = nodes.into_iter().find_map(|node| match node {
|
||||||
|
mdast::Node::Table(table) if (span(node).start..=span(node).end).contains(&at) => {
|
||||||
|
Some((span(node), table))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})?;
|
||||||
|
let rows: Vec<&mdast::Node> = table.children.iter().collect();
|
||||||
|
let row = rows
|
||||||
|
.iter()
|
||||||
|
.rposition(|row| span(row).start <= at)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
|
||||||
|
let column = cells_of(rows[row])
|
||||||
|
.iter()
|
||||||
|
.rposition(|cell| span(cell).start <= at)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let width = table.align.len();
|
||||||
|
let mut grid: Vec<Vec<String>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let mut cells: Vec<String> = cells_of(row)
|
||||||
|
.iter()
|
||||||
|
.map(|cell| written(text, cell))
|
||||||
|
.collect();
|
||||||
|
cells.resize(width, String::new());
|
||||||
|
cells
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut aligns = table.align.clone();
|
||||||
|
let (row, column) = match edit {
|
||||||
|
TableEdit::RowAbove | TableEdit::RowBelow => {
|
||||||
|
let below = edit == TableEdit::RowBelow || row == 0;
|
||||||
|
let at = if below { row + 1 } else { row };
|
||||||
|
grid.insert(at, vec![String::new(); width]);
|
||||||
|
(at, column)
|
||||||
|
}
|
||||||
|
TableEdit::DeleteRow if row > 0 => {
|
||||||
|
grid.remove(row);
|
||||||
|
(row.min(grid.len() - 1), column)
|
||||||
|
}
|
||||||
|
TableEdit::ColumnLeft | TableEdit::ColumnRight => {
|
||||||
|
let at = column + usize::from(edit == TableEdit::ColumnRight);
|
||||||
|
grid.iter_mut()
|
||||||
|
.for_each(|cells| cells.insert(at, String::new()));
|
||||||
|
aligns.insert(at, AlignKind::None);
|
||||||
|
(row, at)
|
||||||
|
}
|
||||||
|
TableEdit::DeleteColumn if width > 1 => {
|
||||||
|
grid.iter_mut().for_each(|cells| {
|
||||||
|
cells.remove(column);
|
||||||
|
});
|
||||||
|
aligns.remove(column);
|
||||||
|
(row, column.min(width - 2))
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let (out, caret) = table_text(&grid, &aligns, (row, column));
|
||||||
|
Some((range, out, caret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell's source between its pipes.
|
||||||
|
fn written(text: &str, cell: &mdast::Node) -> String {
|
||||||
|
let inside = cell.children().expect("a cell holds inlines");
|
||||||
|
match (inside.first(), inside.last()) {
|
||||||
|
(Some(first), Some(last)) => text[span(first).start..span(last).end].to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The table's text, and where cell `at`'s text starts.
|
||||||
|
fn table_text(grid: &[Vec<String>], aligns: &[AlignKind], at: (usize, usize)) -> (String, usize) {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut caret = 0;
|
||||||
|
for (row, cells) in grid.iter().enumerate() {
|
||||||
|
for (column, cell) in cells.iter().enumerate() {
|
||||||
|
out.push_str("| ");
|
||||||
|
if (row, column) == at {
|
||||||
|
caret = out.len();
|
||||||
|
}
|
||||||
|
out.push_str(cell);
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push('|');
|
||||||
|
if row == 0 {
|
||||||
|
out.push('\n');
|
||||||
|
for align in aligns {
|
||||||
|
out.push_str(match align {
|
||||||
|
AlignKind::Left => "| :-- ",
|
||||||
|
AlignKind::Right => "| --: ",
|
||||||
|
AlignKind::Center => "| :-: ",
|
||||||
|
AlignKind::None => "| --- ",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push('|');
|
||||||
|
}
|
||||||
|
if row + 1 < grid.len() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(out, caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownDocument {
|
||||||
|
/// `edit` on the table at the caret; false outside one.
|
||||||
|
pub fn edit_table(&mut self, edit: TableEdit, cx: &mut Context<Self>) -> bool {
|
||||||
|
self.commit(cx);
|
||||||
|
let at = self.editor.read(cx).primary().range().start;
|
||||||
|
let text = self.editor.read(cx).text().to_string();
|
||||||
|
let Some((range, table, caret)) = edit_table(&text, at, edit) else {
|
||||||
|
log::info!("markdown document: no table edit {edit:?} at the caret");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
log::info!("markdown document: table edit {edit:?}");
|
||||||
|
let caret = range.start + caret;
|
||||||
|
self.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([(range, table)], cx);
|
||||||
|
editor.select(std::iter::once(caret..caret), cx);
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new table after the caret's line.
|
||||||
|
pub fn insert_table(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.commit(cx);
|
||||||
|
let text = self.editor.read(cx).text().to_string();
|
||||||
|
let at = self.editor.read(cx).primary().range().start;
|
||||||
|
let end = text[at..].find('\n').map_or(text.len(), |found| at + found);
|
||||||
|
let start = text[..at].rfind('\n').map_or(0, |found| found + 1);
|
||||||
|
let before = if text[start..end].trim().is_empty() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"\n\n"
|
||||||
|
};
|
||||||
|
let after = if text[end..].starts_with("\n\n") || end == text.len() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"\n"
|
||||||
|
};
|
||||||
|
let written = format!("{before}{NEW}{after}");
|
||||||
|
let caret = end + before.len() + 2;
|
||||||
|
log::info!("markdown document: a table goes in");
|
||||||
|
self.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([(end..end, written)], cx);
|
||||||
|
editor.select(std::iter::once(caret..caret), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{TableEdit, edit_table};
|
||||||
|
|
||||||
|
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n";
|
||||||
|
|
||||||
|
fn edited(at: &str, edit: TableEdit) -> Option<String> {
|
||||||
|
let at = TABLE.find(at).expect("in the table");
|
||||||
|
let (range, table, _) = edit_table(TABLE, at, edit)?;
|
||||||
|
Some(format!(
|
||||||
|
"{}{table}{}",
|
||||||
|
&TABLE[..range.start],
|
||||||
|
&TABLE[range.end..]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_and_columns_come_and_go() {
|
||||||
|
let around = |table: &str| format!("Intro\n\n{table}\n\nAfter\n");
|
||||||
|
assert_eq!(
|
||||||
|
edited("3", TableEdit::RowAbove).as_deref(),
|
||||||
|
Some(around("| A | B |\n| :-- | --: |\n| 1 | 2 |\n| | |\n| 3 | 4 |").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("A", TableEdit::RowBelow).as_deref(),
|
||||||
|
Some(around("| A | B |\n| :-- | --: |\n| | |\n| 1 | 2 |\n| 3 | 4 |").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("2", TableEdit::ColumnLeft).as_deref(),
|
||||||
|
Some(around("| A | | B |\n| :-- | --- | --: |\n| 1 | | 2 |\n| 3 | | 4 |").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("1", TableEdit::DeleteRow).as_deref(),
|
||||||
|
Some(around("| A | B |\n| :-- | --: |\n| 3 | 4 |").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("B", TableEdit::DeleteColumn).as_deref(),
|
||||||
|
Some(around("| A |\n| :-- |\n| 1 |\n| 3 |").as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_head_stays_and_outside_is_no_table() {
|
||||||
|
assert_eq!(
|
||||||
|
edited("A", TableEdit::DeleteRow),
|
||||||
|
None,
|
||||||
|
"the head row stays"
|
||||||
|
);
|
||||||
|
assert!(edit_table(TABLE, 1, TableEdit::RowBelow).is_none());
|
||||||
|
let single = "| A |\n| - |\n| 1 |\n";
|
||||||
|
assert!(
|
||||||
|
edit_table(single, 2, TableEdit::DeleteColumn).is_none(),
|
||||||
|
"the last column stays"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -368,6 +368,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
|
|||||||
mod history;
|
mod history;
|
||||||
mod links;
|
mod links;
|
||||||
mod place;
|
mod place;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn typed_source_reads_after_a_pause(cx: &mut TestAppContext) {
|
fn typed_source_reads_after_a_pause(cx: &mut TestAppContext) {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use gpui::{Modifiers, TestAppContext};
|
||||||
|
|
||||||
|
use super::{MarkdownMode, open, opened, press, text};
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn enter_in_source_goes_on_with_the_list(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("- a", MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| document.set_caret(0, 3, cx));
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||||
|
cx.update(|window, cx| window.focus(&focus, cx));
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.simulate_input("b");
|
||||||
|
assert_eq!(text(&document, cx), "- a\n- b");
|
||||||
|
cx.simulate_keystrokes("enter enter");
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"- a\n- b\n",
|
||||||
|
"an empty item ends the list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn enter_in_a_block_goes_on_with_the_list(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("1. a\n", MarkdownMode::Visual, cx);
|
||||||
|
press(0, Modifiers::none(), cx);
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.simulate_input("b");
|
||||||
|
assert_eq!(opened(&document, cx), "1. a\n2. b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tab_shifts_a_block_line_in_and_out(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("- a\n- b\n", MarkdownMode::Visual, cx);
|
||||||
|
press(0, Modifiers::none(), cx);
|
||||||
|
cx.simulate_keystrokes("tab");
|
||||||
|
assert_eq!(opened(&document, cx), "- a\n - b");
|
||||||
|
cx.simulate_keystrokes("shift-tab");
|
||||||
|
assert_eq!(opened(&document, cx), "- a\n- b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("| A |\n| - |\n| 1 |\n", MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||||
|
let edited = document.update(cx, |document, cx| {
|
||||||
|
document.edit_table(crate::documents::TableEdit::RowBelow, cx)
|
||||||
|
});
|
||||||
|
assert!(edited);
|
||||||
|
assert_eq!(text(&document, cx), "| A |\n| --- |\n| 1 |\n| |\n");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("Intro\nNext\n", MarkdownMode::Visual, cx);
|
||||||
|
document.update(cx, |document, cx| document.insert_table(cx));
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"Intro\n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nNext\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_match_in_visual_opens_its_block_selected(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("# A\n\nfind me here\n", MarkdownMode::Visual, cx);
|
||||||
|
let at = "# A\n\nfind me here\n".find("me").expect("the word");
|
||||||
|
document.update_in(cx, |document, window, cx| {
|
||||||
|
document.show_match(at..at + 2, window, cx)
|
||||||
|
});
|
||||||
|
let selected = document.read_with(cx, |document, cx| {
|
||||||
|
let input = document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.expect("a block opens")
|
||||||
|
.input
|
||||||
|
.read(cx);
|
||||||
|
input.text()[input.selection()].to_string()
|
||||||
|
});
|
||||||
|
assert_eq!(selected, "me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn every_replacement_is_one_undo_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("a b a", MarkdownMode::Visual, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.replace(vec![(0..1, "x".into()), (4..5, "x".into())], cx)
|
||||||
|
});
|
||||||
|
assert_eq!(text(&document, cx), "x b x");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), "a b a");
|
||||||
|
}
|
||||||
@@ -12,16 +12,19 @@ use crate::{
|
|||||||
theme::{ActiveTheme, Radius, TextSize},
|
theme::{ActiveTheme, Radius, TextSize},
|
||||||
};
|
};
|
||||||
|
|
||||||
actions!(markdown_document, [Undo, Redo]);
|
actions!(markdown_document, [Undo, Redo, Indent, Outdent]);
|
||||||
|
|
||||||
const CONTEXT: &str = "MarkdownDocument";
|
const CONTEXT: &str = "MarkdownDocument";
|
||||||
|
|
||||||
/// Undo and redo while no block is open.
|
/// Undo and redo; Tab shifts an open block's lines.
|
||||||
pub(crate) fn bind_keys(cx: &mut App) {
|
pub(crate) fn bind_keys(cx: &mut App) {
|
||||||
let context = Some(CONTEXT);
|
let context = Some(CONTEXT);
|
||||||
|
let block = Some("MarkdownDocument > ElyInput");
|
||||||
let mut bindings = vec![
|
let mut bindings = vec![
|
||||||
KeyBinding::new("secondary-z", Undo, context),
|
KeyBinding::new("secondary-z", Undo, context),
|
||||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||||
|
KeyBinding::new("tab", Indent, block),
|
||||||
|
KeyBinding::new("shift-tab", Outdent, block),
|
||||||
];
|
];
|
||||||
if !cfg!(target_os = "macos") {
|
if !cfg!(target_os = "macos") {
|
||||||
bindings.push(KeyBinding::new("ctrl-y", Redo, context));
|
bindings.push(KeyBinding::new("ctrl-y", Redo, context));
|
||||||
@@ -143,7 +146,14 @@ impl Render for MarkdownDocument {
|
|||||||
.font_weight(FontWeight::NORMAL);
|
.font_weight(FontWeight::NORMAL);
|
||||||
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
||||||
match self.mode {
|
match self.mode {
|
||||||
MarkdownMode::Source => root.child(self.editor.clone()).into_any_element(),
|
MarkdownMode::Source => root
|
||||||
|
.capture_action(cx.listener(|document, _: &crate::editor::Newline, _, cx| {
|
||||||
|
if document.source_newline(cx) {
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.child(self.editor.clone())
|
||||||
|
.into_any_element(),
|
||||||
MarkdownMode::Split => root
|
MarkdownMode::Split => root
|
||||||
.flex()
|
.flex()
|
||||||
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
||||||
@@ -173,6 +183,17 @@ impl Render for MarkdownDocument {
|
|||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
document.history(true, window, cx);
|
document.history(true, window, cx);
|
||||||
}))
|
}))
|
||||||
|
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
||||||
|
if document.block_newline(cx) {
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|document, _: &Indent, window, cx| {
|
||||||
|
document.block_shift(true, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|document, _: &Outdent, window, cx| {
|
||||||
|
document.block_shift(false, window, cx);
|
||||||
|
}))
|
||||||
.capture_action(cx.listener(|document, _: &Up, window, cx| {
|
.capture_action(cx.listener(|document, _: &Up, window, cx| {
|
||||||
if document.step(false, window, cx) {
|
if document.step(false, window, cx) {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
|||||||
pub use contents::outline;
|
pub use contents::outline;
|
||||||
pub use counts::{ReadingTime, WordCount};
|
pub use counts::{ReadingTime, WordCount};
|
||||||
pub use diagram::Diagram;
|
pub use diagram::Diagram;
|
||||||
pub use document::{DocumentEvent, DocumentHeading, LARGEST_READ, MarkdownDocument};
|
pub use document::{DocumentEvent, DocumentHeading, LARGEST_READ, MarkdownDocument, TableEdit};
|
||||||
pub use epub::{Chapter, EpubReader};
|
pub use epub::{Chapter, EpubReader};
|
||||||
pub use format::{Format, format};
|
pub use format::{Format, format};
|
||||||
pub(crate) use history::Change;
|
pub(crate) use history::Change;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use super::{
|
|||||||
tree::{Inline, Node, Style, parse},
|
tree::{Inline, Node, Style, parse},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
editor::code_colors,
|
editor::{code_colors, code_colors_with},
|
||||||
navigation::{Anchor, Sections},
|
navigation::{Anchor, Sections},
|
||||||
primitives::{FocusRing as _, Icon, IconName},
|
primitives::{FocusRing as _, Icon, IconName},
|
||||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||||
@@ -224,7 +224,12 @@ impl Draw<'_> {
|
|||||||
text.clone().into(),
|
text.clone().into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let styles = code_colors(text, cx);
|
// A named fence reads as its language; prose stays plain.
|
||||||
|
let styles = match language.as_deref() {
|
||||||
|
Some(name) => crate::forms::syntax_fenced(name)
|
||||||
|
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
|
||||||
|
None => code_colors(text, cx),
|
||||||
|
};
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ pub use find::{FindOptions, FindWidget, find_all};
|
|||||||
pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, completions};
|
pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, completions};
|
||||||
pub use hover::HoverInfo;
|
pub use hover::HoverInfo;
|
||||||
pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts};
|
pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts};
|
||||||
pub(crate) use keys::bind_keys;
|
pub(crate) use keys::{Newline, bind_keys};
|
||||||
pub(crate) use layout::stack;
|
pub(crate) use layout::stack;
|
||||||
pub use preview::ThemePreview;
|
pub use preview::ThemePreview;
|
||||||
pub use problems::{Problem, ProblemsPanel};
|
pub use problems::{Problem, ProblemsPanel};
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@ pub use structure::{
|
|||||||
DirtyIndicator, Form, FormError, FormField, FormLabel, FormSection, InlineForm,
|
DirtyIndicator, Form, FormError, FormField, FormLabel, FormSection, InlineForm,
|
||||||
};
|
};
|
||||||
pub use switch::Switch;
|
pub use switch::Switch;
|
||||||
pub(crate) use syntax::{Syntax, syntax_of};
|
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
|
||||||
pub use tags::TagInput;
|
pub use tags::TagInput;
|
||||||
pub(crate) use text::{
|
pub(crate) use text::{
|
||||||
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys,
|
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys,
|
||||||
|
|||||||
@@ -142,6 +142,26 @@ const BY_EXTENSION: &[(&Syntax, &[&str])] = &[
|
|||||||
(&TEX, &["tex", "sty", "bib"]),
|
(&TEX, &["tex", "sty", "bib"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// How code fenced as `name` reads; none for prose.
|
||||||
|
pub(crate) fn syntax_fenced(name: &str) -> Option<&'static Syntax> {
|
||||||
|
let name = name.to_ascii_lowercase();
|
||||||
|
let extension = match name.as_str() {
|
||||||
|
"rust" => "rs",
|
||||||
|
"javascript" | "typescript" => "js",
|
||||||
|
"python" => "py",
|
||||||
|
"ruby" => "rb",
|
||||||
|
"shell" | "console" => "sh",
|
||||||
|
"haskell" => "hs",
|
||||||
|
"clojure" => "clj",
|
||||||
|
"scheme" | "elisp" => "lisp",
|
||||||
|
"erlang" => "erl",
|
||||||
|
"latex" => "tex",
|
||||||
|
"plaintext" | "plain" => "txt",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
syntax_of(&format!("fence.{extension}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// How the file at `path` reads; none for prose.
|
/// How the file at `path` reads; none for prose.
|
||||||
pub(crate) fn syntax_of(path: &str) -> Option<&'static Syntax> {
|
pub(crate) fn syntax_of(path: &str) -> Option<&'static Syntax> {
|
||||||
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
|
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
|
||||||
@@ -159,3 +179,16 @@ pub(crate) fn syntax_of(path: &str) -> Option<&'static Syntax> {
|
|||||||
.find(|(_, exts)| exts.contains(&ext.as_str()));
|
.find(|(_, exts)| exts.contains(&ext.as_str()));
|
||||||
Some(found.map_or(&C_LIKE, |(syntax, _)| *syntax))
|
Some(found.map_or(&C_LIKE, |(syntax, _)| *syntax))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{PYTHON, RUST, SHELL, syntax_fenced};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fences_read_by_their_language() {
|
||||||
|
assert_eq!(syntax_fenced("Rust"), Some(&RUST));
|
||||||
|
assert_eq!(syntax_fenced("python"), Some(&PYTHON));
|
||||||
|
assert_eq!(syntax_fenced("bash"), Some(&SHELL));
|
||||||
|
assert_eq!(syntax_fenced("text"), None, "prose stays plain");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user