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 finding;
|
||||
mod lists;
|
||||
mod parse;
|
||||
mod place;
|
||||
mod tables;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod visual;
|
||||
@@ -16,6 +19,7 @@ use gpui::{
|
||||
|
||||
pub use parse::DocumentHeading;
|
||||
use parse::{Parsed, alike, parsed};
|
||||
pub use tables::TableEdit;
|
||||
|
||||
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
||||
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 links;
|
||||
mod place;
|
||||
mod tools;
|
||||
|
||||
#[gpui::test]
|
||||
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},
|
||||
};
|
||||
|
||||
actions!(markdown_document, [Undo, Redo]);
|
||||
actions!(markdown_document, [Undo, Redo, Indent, Outdent]);
|
||||
|
||||
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) {
|
||||
let context = Some(CONTEXT);
|
||||
let block = Some("MarkdownDocument > ElyInput");
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("secondary-z", Undo, context),
|
||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||
KeyBinding::new("tab", Indent, block),
|
||||
KeyBinding::new("shift-tab", Outdent, block),
|
||||
];
|
||||
if !cfg!(target_os = "macos") {
|
||||
bindings.push(KeyBinding::new("ctrl-y", Redo, context));
|
||||
@@ -143,7 +146,14 @@ impl Render for MarkdownDocument {
|
||||
.font_weight(FontWeight::NORMAL);
|
||||
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
||||
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
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
||||
@@ -173,6 +183,17 @@ impl Render for MarkdownDocument {
|
||||
cx.stop_propagation();
|
||||
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| {
|
||||
if document.step(false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
|
||||
@@ -34,7 +34,7 @@ pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use contents::outline;
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
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 format::{Format, format};
|
||||
pub(crate) use history::Change;
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::{
|
||||
tree::{Inline, Node, Style, parse},
|
||||
};
|
||||
use crate::{
|
||||
editor::code_colors,
|
||||
editor::{code_colors, code_colors_with},
|
||||
navigation::{Anchor, Sections},
|
||||
primitives::{FocusRing as _, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
@@ -224,7 +224,12 @@ impl Draw<'_> {
|
||||
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()
|
||||
.flex()
|
||||
.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 hover::HoverInfo;
|
||||
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 use preview::ThemePreview;
|
||||
pub use problems::{Problem, ProblemsPanel};
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ pub use structure::{
|
||||
DirtyIndicator, Form, FormError, FormField, FormLabel, FormSection, InlineForm,
|
||||
};
|
||||
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(crate) use text::{
|
||||
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"]),
|
||||
];
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn syntax_of(path: &str) -> Option<&'static Syntax> {
|
||||
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()));
|
||||
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