documents: lists go on, block lines shift, tables change, matches show, fences color by language
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 11m41s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 06:14:34 -04:00
1 parent 2e196a10a7
commit ce4a1e9cb5
12 files changed
+680 -8

No files matched your search

+57
View File
@@ -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));
}
}
+219
View File
@@ -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));
}
}
+4
View File
@@ -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::{
+237
View File
@@ -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"
);
}
}
+1
View File
@@ -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) {
+95
View File
@@ -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");
}
+24 -3
View File
@@ -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();
+1 -1
View File
@@ -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;
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+33
View File
@@ -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");
}
}