From ce4a1e9cb5fc712e9d03f5bb703d13685f1cb67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 06:14:34 -0400 Subject: [PATCH] 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) --- src/documents/document/finding.rs | 57 +++++++ src/documents/document/lists.rs | 219 ++++++++++++++++++++++++ src/documents/document/mod.rs | 4 + src/documents/document/tables.rs | 237 ++++++++++++++++++++++++++ src/documents/document/tests/mod.rs | 1 + src/documents/document/tests/tools.rs | 95 +++++++++++ src/documents/document/visual.rs | 27 ++- src/documents/mod.rs | 2 +- src/documents/render.rs | 9 +- src/editor/mod.rs | 2 +- src/forms/mod.rs | 2 +- src/forms/syntax.rs | 33 ++++ 12 files changed, 680 insertions(+), 8 deletions(-) create mode 100644 src/documents/document/finding.rs create mode 100644 src/documents/document/lists.rs create mode 100644 src/documents/document/tables.rs create mode 100644 src/documents/document/tests/tools.rs diff --git a/src/documents/document/finding.rs b/src/documents/document/finding.rs new file mode 100644 index 0000000..38cea91 --- /dev/null +++ b/src/documents/document/finding.rs @@ -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, window: &mut Window, cx: &mut Context) { + let line = |document: &Self, cx: &Context| { + 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, String)>, cx: &mut Context) { + self.commit(cx); + log::info!("markdown document: {} replaced", edits.len()); + self.editor.update(cx, |editor, cx| editor.edit(edits, cx)); + } +} diff --git a/src/documents/document/lists.rs b/src/documents/document/lists.rs new file mode 100644 index 0000000..1b463a7 --- /dev/null +++ b/src/documents/document/lists.rs @@ -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 { + 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, deeper: bool) -> (String, Range) { + 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) -> 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) -> 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) { + 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)); + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 6f4b0af..69d01fb 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -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::{ diff --git a/src/documents/document/tables.rs b/src/documents/document/tables.rs new file mode 100644 index 0000000..63e4dc6 --- /dev/null +++ b/src/documents/document/tables.rs @@ -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, 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> = rows + .iter() + .map(|row| { + let mut cells: Vec = 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], 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) -> 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.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 { + 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" + ); + } +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index b1bd230..50f8d9b 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -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) { diff --git a/src/documents/document/tests/tools.rs b/src/documents/document/tests/tools.rs new file mode 100644 index 0000000..77efe8b --- /dev/null +++ b/src/documents/document/tests/tools.rs @@ -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"); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 07db8f3..836f86f 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -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(); diff --git a/src/documents/mod.rs b/src/documents/mod.rs index d15f886..394fd8c 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -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; diff --git a/src/documents/render.rs b/src/documents/render.rs index 773ab74..57f84d5 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -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() diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 835e2de..7f20615 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -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}; diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 04f7a5c..a37bf61 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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, diff --git a/src/forms/syntax.rs b/src/forms/syntax.rs index 3607621..af29c7b 100644 --- a/src/forms/syntax.rs +++ b/src/forms/syntax.rs @@ -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"); + } +}