use std::{cell::Cell, collections::HashMap, ops::Range, rc::Rc};
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Bounds, CursorStyle, DefiniteLength, DispatchPhase, Edges, Element, ElementId,
ElementInputHandler, Entity, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId,
InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Refineable, RenderOnce, Role, StatefulInteractiveElement,
Style, StyleRefinement, Styled, TextStyle, Window, canvas, div, fill, point, px, size,
};
use super::{cull::Measured, id::BlockId, projection::Snapshot, state::*, style::BlockEditorStyle};
use gpui_kit::base::StyledExt as _;
use gpui_kit::base::text::editor_host::{
BlockNode, FlowPosition, RenderContext, collect_leaf_geometry,
};
const LIST_CHUNK: usize = 32;
#[derive(IntoElement)]
pub struct BlockEditor {
state: Entity<BlockEditorState>,
style: StyleRefinement,
}
impl BlockEditor {
pub fn new(state: &Entity<BlockEditorState>) -> Self {
Self {
state: state.clone(),
style: StyleRefinement::default(),
}
}
}
impl Styled for BlockEditor {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
pub(crate) fn block_role(node: &BlockNode) -> Option<(Role, Option<usize>)> {
Some(match node {
BlockNode::Paragraph(_) => (Role::Paragraph, None),
BlockNode::Heading { level, .. } => (Role::Heading, Some(*level as usize)),
BlockNode::Blockquote { .. } => (Role::Blockquote, None),
BlockNode::List { .. } => (Role::List, None),
BlockNode::CodeBlock(_) => (Role::Code, None),
BlockNode::Table(_) => (Role::Table, None),
BlockNode::HorizontalRule { .. } => (Role::Splitter, None),
BlockNode::Custom { .. } => (Role::Group, None),
BlockNode::ListItem { .. } => (Role::ListItem, None),
BlockNode::Root { .. }
| BlockNode::Break { .. }
| BlockNode::Definition { .. }
| BlockNode::Unknown => return None,
_ => return None,
})
}
impl RenderOnce for BlockEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = gpui_kit::base::Theme::global(cx);
let render_cx = RenderContext::from_app(cx).on_link_click(|_, _, _, _| {});
let text_style = render_cx.style().clone();
let mut style = self.state.read(cx).style.resolved(&theme.tokens);
if style.selection_is_default() {
style = style.with_selection(text_style.selection());
}
let caret_was_visible = {
let state = self.state.read(cx);
state
.scroll_handle
.as_ref()
.zip(state.model.caret())
.is_some_and(|(handle, pos)| {
state
.frame
.borrow()
.caret_bounds(pos, style.caret_width())
.is_some_and(|caret| handle.bounds().contains(&caret.center()))
})
};
let (focus, snapshot, backgrounds, extensions, keep, list_items) =
self.state.update(cx, |state, _| {
let snapshot = state.snapshot();
let mut backgrounds: HashMap<u64, Vec<(Range<usize>, Hsla)>> = HashMap::new();
for (block, range) in state.selection_ranges() {
let range = match state.model.doc.get(block).map(|e| &e.content) {
Some(super::document::BlockContent::Atom(_)) => {
0..state.model.doc.leaf_len(block).unwrap_or(range.end)
}
_ => range,
};
backgrounds
.entry(block.as_u64())
.or_default()
.push((range, style.selection()));
}
(
state.focus_handle.clone(),
snapshot,
backgrounds,
state.model.doc.extensions.clone(),
state.pinned_blocks(),
(!state.cull.borrow().index.is_current(state.model.revision())).then(|| {
let doc = &state.model.doc;
doc.children(doc.root)
.iter()
.filter(|id| {
matches!(
doc.kind(**id),
Some(super::kind::BlockKindData::List { .. })
) && doc.children(**id).len() > LIST_CHUNK
})
.map(|id| (*id, Rc::<[BlockId]>::from(doc.children(*id))))
.collect::<HashMap<_, _>>()
}),
)
});
let mut typography = window.text_style();
typography.refine(&self.style.text);
let node_cx = render_cx
.with_markdown_extensions(extensions)
.with_body_font_size(typography.font_size.to_pixels(window.rem_size()));
let (cull, revision) = {
let state = self.state.read(cx);
(state.cull.clone(), state.model.revision())
};
let mut children: Vec<AnyElement> = Vec::new();
{
let mut cull_state = cull.borrow_mut();
cull_state.measured.clear();
let window_span = cull_state.visible.map(|(top, bottom)| {
let margin = ((bottom - top) * 2.).max(px(600.));
(top - margin, bottom + margin)
});
let cull_state = &mut *cull_state;
if let Some(list_items) = list_items {
let nodes: Vec<&BlockNode> = snapshot
.blocks
.iter()
.flat_map(|b| b.nodes.iter())
.collect();
let mut units = Vec::with_capacity(nodes.len());
let mut node_ix = 0;
for (block_ix, block) in snapshot.blocks.iter().enumerate() {
for (node_in_block, node) in block.nodes.iter().enumerate() {
let flow = FlowPosition::at(&nodes, node_ix, false);
let unit = |id, items| super::cull::UnitDesc {
id,
block: block_ix,
node: node_in_block,
node_ix,
items,
flow,
};
match (node, list_items.get(&block.id)) {
(BlockNode::List { children, .. }, Some(ids))
if ids.len() == children.len() =>
{
for start in (0..ids.len()).step_by(LIST_CHUNK) {
let range = start..(start + LIST_CHUNK).min(ids.len());
units.push(unit(ids[start], Some((range, ids.clone()))));
}
}
_ => units.push(unit(block.id, None)),
}
node_ix += 1;
}
}
cull_state.index.rebuild(
revision,
units.iter().map(|u| {
let covered: &[BlockId] = match &u.items {
Some((range, ids)) => &ids[range.clone()],
None => &[],
};
(u.id, covered)
}),
&cull_state.heights,
);
cull_state.units = units;
cull_state.node_count = nodes.len();
}
let units = &cull_state.units;
let count = cull_state.node_count;
let index = &cull_state.index;
let n = units.len();
let (first, last) = match window_span {
None => (0, n),
Some((top, bottom)) => {
let first = index.find(top.max(px(0.))).min(n);
(first, (index.find(bottom) + 1).min(n).max(first))
}
};
let mut wanted: Vec<usize> = keep
.iter()
.filter_map(|id| index.unit_of.get(id).copied())
.filter(|ix| *ix < first || *ix >= last)
.collect();
wanted.extend(first..last);
wanted.sort_unstable();
wanted.dedup();
let mut cursor = px(0.);
let mut rendered = 0;
for unit_ix in wanted {
let unit = &units[unit_ix];
let node_ix = unit.node_ix;
let top = index.prefix(unit_ix);
if top > cursor {
children.push(div().h(top - cursor).into_any_element());
}
cursor = top + index.height(unit_ix);
rendered += 1;
let flow = unit.flow;
let is_last_node = node_ix + 1 == count;
let node = &snapshot.blocks[unit.block].nodes[unit.node];
let element = match (&unit.items, node) {
(
Some((range, ids)),
BlockNode::List {
children: items,
ordered,
start,
..
},
) => {
let last_chunk = range.end == ids.len();
let chunk = BlockNode::List {
children: items[range.clone()].to_vec(),
ordered: *ordered,
start: if *ordered {
Some(start.unwrap_or(1) + range.start as u32)
} else {
*start
},
span: None,
};
let flow = if range.start == 0 {
flow
} else {
flow.first(false)
};
chunk.render(
node_ix,
flow.last(!last_chunk || is_last_node),
&node_cx,
window,
cx,
)
}
_ => node.render(node_ix, flow.last(is_last_node), &node_cx, window, cx),
};
let element = match block_role(node) {
Some((role, level)) => div()
.id(("block", unit.id.as_u64() as usize))
.role(role)
.when_some(level, |this, level| this.aria_level(level))
.child(element)
.into_any_element(),
None => element,
};
children.push(
Measured {
id: unit.id,
index: unit_ix,
state: cull.clone(),
child: Some(element),
}
.into_any_element(),
);
}
let total = index.prefix(n);
if total > cursor {
children.push(div().h(total - cursor).into_any_element());
}
cull_state.rendered = rendered;
}
let content_bounds = Rc::new(Cell::new(Bounds::default()));
let measured_content = content_bounds.clone();
let mut root_style = Style::default();
root_style.refine(&self.style);
let s = &self.state;
div()
.id("block-editor")
.role(Role::MultilineTextInput)
.when(window.is_a11y_active() || cfg!(test), |this| {
this.aria_value(self.state.read(cx).accessible_value())
})
.key_context(self.state.read(cx).key_context())
.track_focus(&focus)
.cursor(if self.state.read(cx).grip_hover {
CursorStyle::OpenHand
} else if self.state.read(cx).pointer_hand {
CursorStyle::PointingHand
} else {
CursorStyle::IBeam
})
.w_full()
.on_action(window.listener_for(s, BlockEditorState::on_backspace))
.on_action(window.listener_for(s, BlockEditorState::on_delete))
.on_action(window.listener_for(s, BlockEditorState::on_delete_word_left))
.on_action(window.listener_for(s, BlockEditorState::on_delete_word_right))
.on_action(window.listener_for(s, BlockEditorState::on_delete_to_line_start))
.on_action(window.listener_for(s, BlockEditorState::on_delete_to_line_end))
.on_action(window.listener_for(s, BlockEditorState::on_move_paragraph_start))
.on_action(window.listener_for(s, BlockEditorState::on_move_paragraph_end))
.on_action(window.listener_for(s, BlockEditorState::on_select_paragraph_start))
.on_action(window.listener_for(s, BlockEditorState::on_select_paragraph_end))
.on_action(window.listener_for(s, BlockEditorState::on_enter))
.on_action(window.listener_for(s, BlockEditorState::on_structural_enter))
.on_action(window.listener_for(s, BlockEditorState::on_soft_break))
.on_action(window.listener_for(s, BlockEditorState::on_exit_block))
.on_action(window.listener_for(s, BlockEditorState::on_move_left))
.on_action(window.listener_for(s, BlockEditorState::on_move_right))
.on_action(window.listener_for(s, BlockEditorState::on_move_up))
.on_action(window.listener_for(s, BlockEditorState::on_move_down))
.on_action(window.listener_for(s, BlockEditorState::on_move_home))
.on_action(window.listener_for(s, BlockEditorState::on_move_end))
.on_action(window.listener_for(s, BlockEditorState::on_select_left))
.on_action(window.listener_for(s, BlockEditorState::on_move_word_left))
.on_action(window.listener_for(s, BlockEditorState::on_move_word_right))
.on_action(window.listener_for(s, BlockEditorState::on_select_word_left))
.on_action(window.listener_for(s, BlockEditorState::on_select_word_right))
.on_action(window.listener_for(s, BlockEditorState::on_select_right))
.on_action(window.listener_for(s, BlockEditorState::on_select_up))
.on_action(window.listener_for(s, BlockEditorState::on_select_down))
.on_action(window.listener_for(s, BlockEditorState::on_select_home))
.on_action(window.listener_for(s, BlockEditorState::on_select_end))
.on_action(window.listener_for(s, BlockEditorState::on_move_doc_start))
.on_action(window.listener_for(s, BlockEditorState::on_move_doc_end))
.on_action(window.listener_for(s, BlockEditorState::on_select_doc_start))
.on_action(window.listener_for(s, BlockEditorState::on_select_doc_end))
.on_action(window.listener_for(s, BlockEditorState::on_select_all))
.on_action(window.listener_for(s, BlockEditorState::on_undo))
.on_action(window.listener_for(s, BlockEditorState::on_redo))
.on_action(window.listener_for(s, BlockEditorState::on_normal_text))
.on_action(window.listener_for(s, BlockEditorState::on_table_insert_column_left))
.on_action(window.listener_for(s, BlockEditorState::on_table_insert_column_right))
.on_action(window.listener_for(s, BlockEditorState::on_table_insert_row_above))
.on_action(window.listener_for(s, BlockEditorState::on_table_insert_row_below))
.on_action(window.listener_for(s, BlockEditorState::on_table_delete_line))
.on_action(window.listener_for(s, BlockEditorState::on_close_table_menu))
.on_action(window.listener_for(s, BlockEditorState::on_toggle_bold))
.on_action(window.listener_for(s, BlockEditorState::on_toggle_italic))
.on_action(window.listener_for(s, BlockEditorState::on_edit_link))
.on_action(window.listener_for(s, BlockEditorState::on_toggle_code))
.on_action(window.listener_for(s, BlockEditorState::on_indent))
.on_action(window.listener_for(s, BlockEditorState::on_outdent))
.on_action(window.listener_for(s, BlockEditorState::on_copy))
.on_action(window.listener_for(s, BlockEditorState::on_cut))
.on_action(window.listener_for(s, BlockEditorState::on_paste))
.on_action(window.listener_for(s, BlockEditorState::on_native_paste))
.on_action(window.listener_for(s, BlockEditorState::on_paste_plain))
.children(super::table_ui::render_menu(&self.state, cx))
.child(EditorRoot {
state: self.state.clone(),
child: Some(
div()
.w_full()
.refine_style(&self.style)
.child(
div().relative().w_full().children(children).child(
canvas(
move |bounds, _, _| measured_content.set(bounds),
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
),
)
.into_any_element(),
),
content_bounds,
padding: root_style.padding,
typography,
caret_was_visible,
backgrounds: Some(backgrounds),
snapshot,
style,
})
}
}
type LeafBackgrounds = HashMap<u64, Vec<(Range<usize>, Hsla)>>;
struct EditorRoot {
state: Entity<BlockEditorState>,
child: Option<AnyElement>,
backgrounds: Option<LeafBackgrounds>,
snapshot: Rc<Snapshot>,
style: BlockEditorStyle,
content_bounds: Rc<Cell<Bounds<Pixels>>>,
padding: Edges<DefiniteLength>,
typography: TextStyle,
caret_was_visible: bool,
}
impl IntoElement for EditorRoot {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub(crate) struct Prepaint {
hitbox: Hitbox,
}
impl EditorRoot {
fn reveal_caret(&self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
let state = self.state.read(cx);
if !state.reveal.get() {
return;
}
let (Some(handle), Some(pos)) = (state.scroll_handle.clone(), state.model.caret()) else {
state.reveal.set(false);
return;
};
let Some(caret) = state
.frame
.borrow()
.caret_bounds(pos, self.style.caret_width())
else {
return;
};
state.reveal.set(false);
let padding = self
.padding
.to_pixels(bounds.size.map(Into::into), window.rem_size());
let mut view = handle.bounds();
view.origin.y += padding.top;
view.size.height = (view.size.height - padding.top - padding.bottom).max(Pixels::ZERO);
let mut offset = handle.offset();
if caret.top() < view.top() {
offset.y += view.top() - caret.top();
} else if caret.bottom() > view.bottom() {
offset.y -= caret.bottom() - view.bottom();
} else {
return;
}
handle.set_offset(offset);
window.refresh();
}
}
impl Element for EditorRoot {
type RequestLayoutState = AnyElement;
type PrepaintState = Prepaint;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, AnyElement) {
let mut child = self
.child
.take()
.unwrap_or_else(|| div().into_any_element());
let layout_id = child.request_layout(window, cx);
(layout_id, child)
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
child: &mut AnyElement,
window: &mut Window,
cx: &mut App,
) -> Prepaint {
if let Some(backgrounds) = self.backgrounds.take() {
let (_, geometries) = collect_leaf_geometry(backgrounds, || child.prepaint(window, cx));
let mut frame = super::frame::into_frame(
geometries,
self.snapshot.leaves.clone(),
&self.snapshot.tables,
);
frame.content = Some(self.content_bounds.get());
{
let state = self.state.read(cx);
let doc = &state.model.doc;
let shapes: Vec<super::frame::TableShape> = self
.snapshot
.tables
.iter()
.map(|(table, _)| super::frame::TableShape {
table: *table,
grid: super::table::grid(doc, *table),
aligns: match doc.kind(*table) {
Some(super::kind::BlockKindData::Table { aligns }) => aligns.clone(),
_ => Vec::new(),
},
})
.collect();
let pad = window.rem_size() * 0.75;
let right = frame.content.map(|c| c.right());
frame.layout_tables(&shapes, pad, right);
}
*self.state.read(cx).frame.borrow_mut() = frame;
let mask = window.content_mask().bounds;
let content = self.content_bounds.get();
let cull = self.state.read(cx).cull.clone();
let geometry_changed = cull.borrow_mut().set_geometry(
content.size.width,
window.rem_size(),
&self.typography,
);
if geometry_changed {
if self.caret_was_visible {
self.state.read(cx).reveal.set(true);
}
} else {
self.reveal_caret(bounds, window, cx);
}
if geometry_changed || cull.borrow_mut().finish_frame(content, mask) {
let state = self.state.clone();
window.on_next_frame(move |_, cx| state.update(cx, |_, cx| cx.notify()));
}
} else {
child.prepaint(window, cx);
}
Prepaint {
hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
child: &mut AnyElement,
prepaint: &mut Prepaint,
window: &mut Window,
cx: &mut App,
) {
let state = self.state.read(cx);
let focus = state.focus_handle.clone();
let collapsed = state.selection_ranges().is_empty();
let marked = state.marked_range();
let caret = state.model.caret();
let show_caret = state.show_caret(window, cx) && collapsed;
let frame = state.frame.clone();
let menu_line = state.table_menu.as_ref().map(|m| (m.table, m.line));
let grips = state.table_grips_at(window.mouse_position());
let selected_area = state.model.selected_blocks().and_then(|(parent, range)| {
let doc = &state.model.doc;
let ids = doc.children(parent).get(range)?.to_vec();
let frame = frame.borrow();
frame
.leaves
.iter()
.filter(|leaf| {
let path = doc.path(leaf.info.block);
ids.iter()
.any(|id| *id == leaf.info.block || path.contains(id))
})
.map(|leaf| leaf.bounds())
.reduce(|a, b| a.union(&b))
});
window.handle_input(
&focus,
ElementInputHandler::new(bounds, self.state.clone()),
cx,
);
child.paint(window, cx);
let frame = frame.borrow();
let style = &self.style;
let mouse = window.mouse_position();
let hovered = frame
.tables
.iter()
.map(|(_, b)| *b)
.chain(
frame
.leaves
.iter()
.filter(|l| state_plain(&self.state, l.info.block, cx))
.map(|l| l.bounds()),
)
.find(|b| {
mouse.y >= b.top()
&& mouse.y < b.bottom()
&& mouse.x >= b.left() - px(24.)
&& mouse.x < b.right()
});
if let Some(b) = hovered {
let grip = Bounds::new(
point(b.left() - px(14.), b.top() + px(2.)),
size(px(4.), px(16.).min(b.size.height)),
);
window.paint_quad(fill(grip, style.caret().opacity(0.35)).corner_radii(px(2.)));
}
for (table, line, grip) in grips {
let open = menu_line == Some((table, line));
let alpha = if open { 0.8 } else { 0.35 };
window.paint_quad(fill(grip, style.caret().opacity(alpha)).corner_radii(px(2.)));
}
if let Some(area) = selected_area {
let area = area.dilate(px(4.));
window.paint_quad(gpui::quad(
area,
px(4.),
style.selection().opacity(0.35),
px(2.),
style.caret(),
gpui::BorderStyle::Solid,
));
}
if let Some(line) = self.state.read(cx).drop_indicator() {
window.paint_quad(fill(line, style.caret()).corner_radii(px(1.)));
}
if let Some((block, range)) = marked
&& let Some(leaf) = frame.leaf(block)
{
let a = leaf.caret_point(range.start);
let b = leaf.caret_point(range.end);
let thickness = style.caret_width();
let underline = Bounds::new(
point(a.x, a.y + leaf.line_height() - thickness),
size((b.x - a.x).max(thickness), thickness),
);
window.paint_quad(fill(underline, style.composition_underline()));
}
if show_caret
&& let Some(pos) = caret
&& let Some(b) = frame.caret_bounds(pos, style.caret_width())
{
let b = crate::support::snap_caret_to_device_pixels(b, window.scale_factor());
window.paint_quad(fill(b, style.caret()));
}
drop(frame);
let hitbox = prepaint.hitbox.clone();
let entity = self.state.clone();
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble
|| event.button != MouseButton::Left
|| !hitbox.is_hovered(window)
{
return;
}
if !event.modifiers.shift
&& entity.update(cx, |state, cx| {
let hit = state.click_table_grip(event.position, cx);
if !hit {
state.close_table_menu(cx);
}
hit
})
{
let focus = entity.read(cx).focus_handle.clone();
window.focus(&focus, cx);
cx.stop_propagation();
return;
}
if !event.modifiers.shift
&& entity.update(cx, |state, cx| state.click_checkbox(event.position, cx))
{
cx.stop_propagation();
return;
}
entity.update(cx, |state, cx| {
let focus = state.focus_handle.clone();
window.focus(&focus, cx);
if event.modifiers.secondary() && state.follow_link_at(event.position, cx) {
return;
}
state.dragging = true;
if !event.modifiers.shift {
state.arm_block_drag(event.position);
}
state.mouse_at(event.position, event.modifiers.shift, cx);
if !event.modifiers.shift && event.click_count >= 2 {
state.select_unit(event.click_count >= 3, cx);
}
});
});
let entity = self.state.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if event.pressed_button.is_none() {
entity.update(cx, |state, cx| {
state.hover_at(event.position, event.modifiers.secondary(), cx)
});
return;
}
if event.pressed_button != Some(MouseButton::Left) {
return;
}
entity.update(cx, |state, cx| {
if state.block_drag_to(event.position, cx) {
return;
}
if state.dragging {
state.mouse_at(event.position, true, cx);
}
});
});
let entity = self.state.clone();
window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| {
if phase == DispatchPhase::Bubble {
entity.update(cx, |state, cx| {
state.dragging = false;
state.finish_block_drag(cx);
});
}
});
}
}
fn state_plain(state: &Entity<BlockEditorState>, block: BlockId, cx: &App) -> bool {
state.read(cx).model.doc.plain(block).is_some()
}