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>
223 lines
8.3 KiB
Rust
223 lines
8.3 KiB
Rust
use gpui::{
|
|
AnyElement, App, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, KeyBinding,
|
|
MouseButton, MouseDownEvent, ParentElement, Render, StatefulInteractiveElement, Styled, Window,
|
|
actions, div, list,
|
|
};
|
|
|
|
use super::MarkdownDocument;
|
|
use crate::{
|
|
documents::{MarkdownMode, render::draw_nodes},
|
|
forms::{self, Down, Up},
|
|
primitives::FocusRing as _,
|
|
theme::{ActiveTheme, Radius, TextSize},
|
|
};
|
|
|
|
actions!(markdown_document, [Undo, Redo, Indent, Outdent]);
|
|
|
|
const CONTEXT: &str = "MarkdownDocument";
|
|
|
|
/// 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));
|
|
}
|
|
cx.bind_keys(bindings);
|
|
}
|
|
|
|
impl MarkdownDocument {
|
|
/// List item `ix`: a block, or the end.
|
|
fn item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
|
let theme = cx.theme();
|
|
let colors = theme.colors.clone();
|
|
let (radius, body, mono) = (
|
|
theme.radius(Radius::Md),
|
|
theme.text_size(TextSize::Base),
|
|
theme.mono_family.clone(),
|
|
);
|
|
let editing = self.mode == MarkdownMode::Visual;
|
|
let open = self.open.as_ref();
|
|
let field = |open: &super::Open| {
|
|
div()
|
|
.px_3()
|
|
.py_2()
|
|
.rounded(radius)
|
|
.bg(colors.sunken)
|
|
.child(open.input.clone())
|
|
.into_any_element()
|
|
};
|
|
let Some(block) = self.parsed.blocks.get(ix) else {
|
|
return match (open, editing) {
|
|
(Some(open), _) if open.append => field(open),
|
|
(_, true) => self
|
|
.pressable(div().id("more").min_h_8(), None, cx)
|
|
.into_any_element(),
|
|
(_, false) => div().into_any_element(),
|
|
};
|
|
};
|
|
if let Some(open) = open.filter(|open| open.range == block.range) {
|
|
return field(open);
|
|
}
|
|
let shown = match &block.nodes {
|
|
Some(nodes) => div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_3()
|
|
.text_size(body)
|
|
.line_height(body * 1.6)
|
|
.text_color(colors.fg)
|
|
.children(draw_nodes(
|
|
&("render", ix).into(),
|
|
nodes,
|
|
&self.base,
|
|
&self.on_link,
|
|
cx,
|
|
)),
|
|
None => div()
|
|
.font_family(mono)
|
|
.text_color(colors.fg_muted)
|
|
.child(self.parsed.text[block.range.clone()].to_string()),
|
|
};
|
|
let block = div()
|
|
.id(("block", ix))
|
|
.debug_selector(move || format!("markdown-block-{ix}"))
|
|
.px_3()
|
|
.py_1()
|
|
.rounded(radius)
|
|
.child(shown);
|
|
match editing {
|
|
true => self.pressable(block, Some(ix), cx).into_any_element(),
|
|
false => block.into_any_element(),
|
|
}
|
|
}
|
|
|
|
/// `element` opens block `ix` on a press, Enter or Space.
|
|
fn pressable(
|
|
&self,
|
|
element: gpui::Stateful<gpui::Div>,
|
|
ix: Option<usize>,
|
|
cx: &mut Context<Self>,
|
|
) -> gpui::Stateful<gpui::Div> {
|
|
let hover = cx.theme().colors.hover.opacity(0.5);
|
|
element
|
|
.tab_index(0)
|
|
.border_1()
|
|
.border_color(gpui::transparent_black())
|
|
.focus_ring(cx)
|
|
.cursor_text()
|
|
.hover(move |element| element.bg(hover))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
cx.listener(move |document, event: &MouseDownEvent, window, cx| {
|
|
// Cmd or Ctrl leaves the press to a link.
|
|
if event.modifiers.secondary() {
|
|
return;
|
|
}
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
document.open(ix, false, window, cx);
|
|
}),
|
|
)
|
|
.on_click(
|
|
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
|
if event.is_keyboard() {
|
|
document.open(ix, false, window, cx);
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Render for MarkdownDocument {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
// A top line asked before its text was read.
|
|
self.place_top(window, cx);
|
|
let colors = cx.theme().colors.clone();
|
|
let root = div()
|
|
.size_full()
|
|
.bg(colors.surface)
|
|
.font_weight(FontWeight::NORMAL);
|
|
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
|
match self.mode {
|
|
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()))
|
|
.child(div().flex_1().min_w_0().h_full().child(blocks))
|
|
.into_any_element(),
|
|
MarkdownMode::Preview => root
|
|
.track_focus(&self.focus)
|
|
.px_2()
|
|
.py_3()
|
|
.child(blocks)
|
|
.into_any_element(),
|
|
MarkdownMode::Visual => root
|
|
.key_context(CONTEXT)
|
|
.track_focus(&self.focus)
|
|
.on_action(cx.listener(|document, _: &Undo, window, cx| {
|
|
document.history(false, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|document, _: &Redo, window, cx| {
|
|
document.history(true, window, cx);
|
|
}))
|
|
// One history: a block's own never runs.
|
|
.capture_action(cx.listener(|document, _: &forms::Undo, window, cx| {
|
|
cx.stop_propagation();
|
|
document.history(false, window, cx);
|
|
}))
|
|
.capture_action(cx.listener(|document, _: &forms::Redo, window, cx| {
|
|
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();
|
|
}
|
|
}))
|
|
.capture_action(cx.listener(|document, _: &Down, window, cx| {
|
|
if document.step(true, window, cx) {
|
|
cx.stop_propagation();
|
|
}
|
|
}))
|
|
.on_key_down(
|
|
cx.listener(|document, event: &gpui::KeyDownEvent, window, cx| {
|
|
if event.keystroke.key == "escape" && document.open.is_some() {
|
|
cx.stop_propagation();
|
|
document.commit(cx);
|
|
window.focus(&document.focus, cx);
|
|
}
|
|
}),
|
|
)
|
|
.px_2()
|
|
.py_3()
|
|
.child(blocks)
|
|
.into_any_element(),
|
|
}
|
|
}
|
|
}
|