Files
Ely-GPUI-Components/src/documents/document/visual.rs
T
ZacharyZhang-NYandClaude Opus 5.5 6c2d28c56e
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 12m5s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
documents: a dropped block hands its focus to the document
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>
2026-10-14 17:39:24 -04:00

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(),
}
}
}