Files
Ely-GPUI-Components/src/documents/blocks/view.rs
T

375 lines
14 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, Axis, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
MouseDownEvent, ParentElement, Rems, Render, SharedString, Styled, Window, canvas, div,
prelude::*,
};
use super::{Block, BlockData, BlockEditor, BlockKind, matching, span::Put};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::suggest::{Offer, offers},
forms::replace_trigger,
forms::{
Backspace, Choice, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Pick, Redo, Submit,
Undo, Up,
},
layout::{ScrollArea, bring_into_view},
menus::{Menu, MenuItem, OverflowMenu},
motion::Reorder,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Palette, Radius, TextSize},
};
/// Theme values a block reads, taken before its fields borrow the context.
pub(crate) struct Look {
pub colors: Palette,
pub mono: SharedString,
pub radius: Rems,
pub small: Rems,
pub tiny: Rems,
pub headings: [Rems; 3],
}
impl Look {
fn new(cx: &App) -> Self {
let theme = cx.theme();
Self {
colors: theme.colors.clone(),
mono: theme.mono_family.clone(),
radius: theme.radius(Radius::Md),
small: theme.text_size(TextSize::Sm),
tiny: theme.text_size(TextSize::Xs),
headings: [TextSize::Xxl, TextSize::Xl, TextSize::Lg].map(|size| theme.text_size(size)),
}
}
}
impl BlockEditor {
pub(crate) fn id(cx: &Context<Self>) -> ElementId {
("ely-blocks", cx.entity_id()).into()
}
/// A slash pick: an emptied block becomes the kind; one with text gets the kind below it.
fn pick_kind(
&mut self,
key: u64,
kind: BlockKind,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.index(key) else {
return;
};
let empty = self.blocks[ix]
.fields
.first()
.is_some_and(|field| field.read(cx).is_empty());
let data = BlockData {
texts: vec![String::new(); kind.fields()],
kind: kind.clone(),
};
let placed = if empty {
self.turn_into(key, kind.clone(), window, cx);
key
} else {
self.insert(Some(key), data, window, cx)
};
if kind == BlockKind::Divider {
self.insert(
Some(placed),
BlockData::new(BlockKind::Paragraph, [""]),
window,
cx,
);
}
}
/// A text field with its keys and suggests: `/` in prose opens the kinds.
pub(crate) fn field_view(
&self,
block: &Block,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let (key, field) = (block.key, block.fields[ix].clone());
let entity = cx.entity();
let keys = div()
.w_full()
.capture_action(cx.listener(move |editor, _: &Enter, window, cx| {
if editor.collapse_span(Put::Break, window, cx).is_some() {
return cx.stop_propagation();
}
if editor.on_enter(key, ix, window, cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
if editor.collapse_span(Put::Nothing, window, cx).is_some()
|| editor.on_backspace(key, ix, window, cx)
{
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Up, window, cx| {
if editor.on_vertical(key, ix, false, window, cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Down, window, cx| {
if editor.on_vertical(key, ix, true, window, cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Submit, window, cx| {
cx.stop_propagation();
editor.on_submit(key, window, cx);
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
editor.pressed(key, ix, event, window, cx);
cx.stop_propagation();
}),
)
.child(field.clone());
let slash = move |query: &str, at: usize, caret: usize| -> (Vec<Choice>, Pick) {
let kinds = matching(query);
let rows = kinds
.iter()
.map(|kind| Choice::new(kind.label(), kind.label()).icon(kind.icon()))
.collect();
let (entity, field) = (entity.clone(), field.clone());
let pick: Pick = Rc::new(move |pick: usize, window: &mut Window, cx: &mut App| {
replace_trigger(&field, at, caret, "", cx);
let kind = kinds[pick].clone();
entity.update(cx, |editor, cx| editor.pick_kind(key, kind, window, cx));
});
(rows, pick)
};
let extra: Option<(char, Offer)> =
(block.kind.is_prose() && ix == 0).then_some(('/', &slash));
let id = (Self::id(cx), format!("offers-{key}-{ix}")).into();
offers(id, &block.fields[ix], &self.people, extra, cx)
.wrap(keys, window, cx)
.into_any_element()
}
/// Scrolls the focused caret into view once each time it moves; a wheel moves freely past a still one.
fn reveal_caret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((key, ix)) = self.focused(window, cx) else {
self.revealed = None;
return;
};
let Some(block) = self.index(key).map(|at| &self.blocks[at]) else {
return;
};
let field = block.fields[ix].read(cx);
let at = (
block.fields[ix].entity_id(),
field.selection(),
field.text().len(),
);
if self.revealed.as_ref() == Some(&at) {
return;
}
let Some(caret) = field.caret_bounds() else {
return;
};
self.revealed = Some(at);
if bring_into_view(&self.scroll, caret, Axis::Vertical) {
log::debug!("block editor: the caret came into view");
window.request_animation_frame();
}
}
/// The handle's menu: turn into another kind, duplicate or delete.
fn menu(&self, key: u64, cx: &mut Context<Self>) -> Menu {
let entity = cx.entity();
let turn = super::kind::offered()
.into_iter()
.fold(Menu::new(), |menu, kind| {
let entity = entity.clone();
menu.item(MenuItem::new(kind.label()).icon(kind.icon()).on_click(
move |window, cx| {
let kind = kind.clone();
entity.update(cx, |editor, cx| editor.turn_into(key, kind, window, cx));
},
))
});
let (copy, gone) = (entity.clone(), entity);
Menu::new()
.item(MenuItem::submenu("Turn into", turn).icon(IconName::RefreshCw))
.item(
MenuItem::new("Duplicate")
.icon(IconName::Copy)
.on_click(move |window, cx| {
copy.update(cx, |editor, cx| editor.duplicate(key, window, cx));
}),
)
.separator()
.item(
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(move |window, cx| {
gone.update(cx, |editor, cx| editor.remove(key, window, cx));
}),
)
}
}
impl Render for BlockEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let id = Self::id(cx);
let look = Look::new(cx);
let colors = look.colors.clone();
let focused = self.focused(window, cx).map(|(key, _)| key);
let washed = self.washed().unwrap_or_default();
let mut number = 0;
let mut rows: Vec<(SharedString, AnyElement)> = Vec::new();
for ix in 0..self.blocks.len() {
let block = &self.blocks[ix];
number = if block.kind == BlockKind::Numbered {
number + 1
} else {
0
};
let key = block.key;
let group = SharedString::from(format!("block-{key}"));
let entity = cx.entity();
let plus = IconButton::new((id.clone(), format!("plus-{key}")), IconName::Plus)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Add a block below")
.on_click(move |_, window, cx| {
entity.update(cx, |editor, cx| {
editor.insert(
Some(key),
BlockData::new(BlockKind::Paragraph, ["/"]),
window,
cx,
);
})
});
let handle =
OverflowMenu::new((id.clone(), format!("handle-{key}")), self.menu(key, cx))
.icon(IconName::GripVertical)
.tooltip("Drag to move, press for more");
let content = self.content(&self.blocks[ix], number, &look, window, cx);
let row = div()
.id((id.clone(), format!("row-{key}")))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |editor, _, window, cx| {
let Some(ix) = editor.index(key) else {
return;
};
let end = editor.blocks[ix]
.fields
.first()
.map(|field| field.read(cx).text().len());
if end.is_some() {
editor.focus_field(key, 0, end, window, cx);
}
}),
)
.group(group.clone())
.relative()
.pl_16()
.when(washed.contains(&ix), |row| {
row.bg(colors.selection).rounded(look.radius)
})
.py_0p5()
.child(content)
.child(
div()
.absolute()
.top_0()
.left_0()
.flex()
.opacity(if focused == Some(key) { 1.0 } else { 0.0 })
.group_hover(group, |gutter| gutter.opacity(1.0))
.child(plus)
.child(handle),
);
rows.push((format!("{key}").into(), row.into_any_element()));
}
let entity = cx.entity();
let list = rows.into_iter().fold(
Reorder::new((id.clone(), "blocks")).on_reorder(move |from, to, _, cx| {
entity.update(cx, |editor, cx| editor.move_block(from, to, cx))
}),
|list, (key, row)| list.row(key, row),
);
let (start, editor) = (cx.entity(), cx.entity());
let caret = canvas(
move |_, window, cx| editor.update(cx, |editor, cx| editor.reveal_caret(window, cx)),
|_, _, _, _| {},
)
.absolute();
let column = div()
.flex()
.flex_col()
.capture_action(cx.listener(|editor, _: &Copy, _, cx| {
if editor.copy_span(cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &Cut, window, cx| {
if editor.copy_span(cx) {
cx.stop_propagation();
editor.collapse_span(Put::Nothing, window, cx);
}
}))
.capture_action(cx.listener(|editor, _: &Delete, window, cx| {
if editor.collapse_span(Put::Nothing, window, cx).is_some() {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &DeleteWordLeft, window, cx| {
if editor.collapse_span(Put::Nothing, window, cx).is_some() {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &Undo, window, cx| {
cx.stop_propagation();
editor.undo(window, cx);
}))
.capture_action(cx.listener(|editor, _: &Redo, window, cx| {
cx.stop_propagation();
editor.redo(window, cx);
}))
.child(list)
.child(
div()
.id((id.clone(), "end"))
.min_h_8()
.cursor_text()
.text_color(colors.fg_subtle)
.when(self.blocks.is_empty(), |end| end.child("Press to write"))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
start.update(cx, |editor, cx| {
let last = editor.blocks.last().map(|block| block.key);
editor.insert(
last,
BlockData::new(BlockKind::Paragraph, [""]),
window,
cx,
);
});
}),
)
.child(caret)
.when(self.pressing(), |column| {
column.child(self.drag_listener(cx))
});
ScrollArea::new(id)
.size_full()
.track_scroll(&self.scroll)
.child(column)
}
}