use gpui::{ AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render, StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list, prelude::FluentBuilder as _, }; use super::{MarkdownDocument, edit::Landing}; use crate::{ documents::{ MarkdownMode, render::{Hooks, Pointed, draw_nodes, point_lines}, }, forms::{self, Down, Up}, primitives::FocusRing as _, theme::{ActiveTheme, Radius, TextSize}, }; actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]); const CONTEXT: &str = "MarkdownDocument"; /// How far a faded block recedes. const FADED: f32 = 0.35; /// Modes with a reader's block to keep. fn editing_mode(mode: MarkdownMode) -> bool { mode == MarkdownMode::Visual } /// 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("secondary-v", Paste, 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`, with typewriter room at the ends. fn item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { let (first, last) = (ix == 0, ix == self.parsed.blocks.len()); let room = self.room; div() .when(first, |item| item.pt(room)) .when(last, |item| item.pb(room)) .child(self.block_item(ix, window, cx)) .into_any_element() } /// A block, or the end. fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) -> AnyElement { let editing = self.mode == MarkdownMode::Visual; let drawn = editing && ix < self.parsed.blocks.len(); let on_point = drawn.then(|| self.on_point(ix, cx)); let theme = cx.theme(); let colors = theme.colors.clone(); let (radius, body, mono) = ( theme.radius(Radius::Md), theme.text_size(TextSize::Base) * self.look.scale, theme.mono_family.clone(), ); // Focus fades all but the reader's block. let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx); let open = self.open.as_ref(); let field = |open: &super::Open| { div() .text_size(body) .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, Hooks { base: &self.base, on_link: &self.on_link, on_heading: &self.on_heading, on_point: &on_point, }, self.look.scale, cx, )), None => { let raw = &self.parsed.text[block.range.clone()]; let drawn = StyledText::new(raw.to_string()); let layout = drawn.layout().clone(); let shown = div() .text_size(body) .font_family(mono) .text_color(colors.fg_muted) .child(drawn) .into_any_element(); div().child(point_lines( shown, layout, raw, 0, &on_point, (false, false), )) } }; let block = div() .id(("block", ix)) .debug_selector(move || format!("markdown-block-{ix}")) .px_3() .py_1() .rounded(radius) .when(faded, |block| block.opacity(FADED)) .child(shown); match editing { true => self.pressable(block, Some(ix), cx).into_any_element(), false => block.into_any_element(), } } /// `element` opens block `ix`; a right press points. fn pressable( &self, element: gpui::Stateful, ix: Option, cx: &mut Context, ) -> gpui::Stateful { 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, Landing::End, window, cx); }), ) .on_mouse_down( MouseButton::Right, cx.listener(move |document, _: &MouseDownEvent, window, cx| { window.prevent_default(); cx.stop_propagation(); document.point(ix, None, Pointed::block(), window, cx); }), ) .on_click( cx.listener(move |document, event: &ClickEvent, window, cx| { if event.is_keyboard() { document.open(ix, Landing::End, window, cx); } }), ) } } impl Render for MarkdownDocument { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.take_orphan(window, cx); // A top line asked before its text was read. self.place_top(window, cx); self.show_waiting(window, cx); let colors = cx.theme().colors.clone(); let root = div() .size_full() .bg(colors.surface) .font_weight(FontWeight::NORMAL) .on_drop(cx.listener(|_, dropped: &ExternalPaths, _, cx| { log::info!("markdown document: {} files dropped", dropped.paths().len()); cx.emit(super::Attachment::Files(dropped.paths().to_vec())); })); self.center_current(cx); // Typewriter: half a view past each end. self.make_room(); let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full(); // The text column: its measure, font, centered. let column = |content: gpui::AnyElement, look: &super::DocumentLook| { div().size_full().flex().justify_center().child( div() .h_full() .w_full() .when_some(look.measure, |column, measure| column.max_w(measure)) .when_some(look.font.clone(), |column, font| column.font_family(font)) .child(content), ) }; let blocks = column(blocks.into_any_element(), &self.look); let source = column(self.editor.clone().into_any_element(), &self.look); match self.mode { MarkdownMode::Source => root .capture_action(cx.listener(|document, _: &crate::editor::Paste, _, cx| { if document.paste_picture(cx) { cx.stop_propagation(); } })) .capture_action(cx.listener(|document, _: &crate::editor::Newline, _, cx| { if document.source_newline(cx) { cx.stop_propagation(); } })) .child(source) .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::Paste, _, cx| { if document.paste_picture(cx) { cx.stop_propagation(); } })) .on_action(cx.listener(|document, _: &Paste, _, cx| { document.paste_picture(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(), } } }