An edit past an open block wrote it back and dropped its input while it held focus, so focus fell to the window root and Undo reached nothing. The next render passes such focus to the document. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
316 lines
12 KiB
Rust
316 lines
12 KiB
Rust
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<Self>) -> 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<Self>) -> 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<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, 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<Self>) -> 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(),
|
|
}
|
|
}
|
|
}
|