T21a: Documents, editing

- markdown read with pulldown-cmark: marks for live styling, blocks, links, words
- forms::Highlight gains weight, slant and strike; measure and paint share runs
- Inter Italic embedded, so emphasis reads slanted
- forms::mention's popup and keys become Suggestions, shared with the editors
- RichTextEditor with FixedFormatToolbar, the floating toolbar, LinkEditor,
  @ and : suggestions, WordCount and ReadingTime
- MarkdownEditor: visual (a pressed block opens as source), source, split
- BlockEditor: prose, lists, to-dos, quotes, callouts, toggles, code, math,
  diagrams the app draws, dividers, tables that grow and merge, pictures to
  resize and align, cards for video and pages, columns, synced text; slash menu,
  markdown shortcuts, handles that drag and open a menu, one undo for all
- TodoItem and Checklist
- icons: heading-1..3, workflow, merge
- gallery: the Documents page with scripted states
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 03:32:24 -04:00
1 parent 0ffa001a61
commit 6a9ce8a2c3
46 files changed
+5635 -193

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
use gpui::{
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::{Block, BlockEditor, BlockKind, media, table, view::Look};
use crate::{
buttons::CopyButton,
forms::Checkbox,
primitives::{Disclosure, Icon, IconName},
theme::IconSize,
typography::Latex,
};
impl BlockEditor {
/// What a block shows, by kind.
pub(crate) fn content(
&self,
block: &Block,
number: usize,
look: &Look,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let colors = look.colors.clone();
let (key, id) = (block.key, Self::id(cx));
let marker = |text: SharedString| {
div()
.flex_none()
.min_w_5()
.text_color(colors.fg_muted)
.child(text)
};
let row = |marker: AnyElement, field: AnyElement| {
div()
.flex()
.gap_1p5()
.child(marker)
.child(div().flex_1().min_w_0().child(field))
};
match &block.kind {
BlockKind::Paragraph => self.field_view(block, 0, window, cx),
BlockKind::Heading(level) => div()
.pt_2()
.text_size(look.headings[(*level as usize).clamp(1, 3) - 1])
.font_weight(FontWeight::SEMIBOLD)
.child(self.field_view(block, 0, window, cx))
.into_any_element(),
BlockKind::Bullet => row(
marker("•".into()).into_any_element(),
self.field_view(block, 0, window, cx),
)
.into_any_element(),
BlockKind::Numbered => row(
marker(format!("{number}.").into()).into_any_element(),
self.field_view(block, 0, window, cx),
)
.into_any_element(),
BlockKind::Todo(done) => {
let (entity, done) = (cx.entity(), *done);
let check = Checkbox::new((id.clone(), format!("todo-{key}")), done).on_change(
move |on, _, cx| {
entity.update(cx, |editor, cx| {
editor.set_kind(key, BlockKind::Todo(on), cx)
})
},
);
row(
check.into_any_element(),
self.field_view(block, 0, window, cx),
)
.when(done, |row| row.text_color(colors.fg_subtle))
.into_any_element()
}
BlockKind::Quote => div()
.pl_3()
.border_l_2()
.border_color(colors.border_strong)
.text_color(colors.fg_muted)
.child(self.field_view(block, 0, window, cx))
.into_any_element(),
BlockKind::Callout => div()
.flex()
.gap_2()
.p_3()
.rounded(look.radius)
.bg(colors.sunken)
.child(
Icon::new(IconName::Lightbulb)
.size(IconSize::Sm)
.color(colors.warning),
)
.child(
div()
.flex_1()
.min_w_0()
.child(self.field_view(block, 0, window, cx)),
)
.into_any_element(),
BlockKind::Toggle(open) => {
let (entity, open) = (cx.entity(), *open);
let chevron = div()
.id((id.clone(), format!("toggle-{key}")))
.flex_none()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
entity.update(cx, |editor, cx| {
editor.set_kind(key, BlockKind::Toggle(!open), cx)
})
})
.child(Disclosure::new(
(id.clone(), format!("disclosure-{key}")),
open,
));
div()
.flex()
.flex_col()
.gap_1()
.child(row(
chevron.into_any_element(),
self.field_view(block, 0, window, cx),
))
.when(open, |toggle| {
toggle.child(div().pl_6().child(self.field_view(block, 1, window, cx)))
})
.into_any_element()
}
BlockKind::Code => {
let text = block.fields[0].read(cx).text().to_string();
div()
.flex()
.flex_col()
.gap_1()
.p_3()
.rounded(look.radius)
.bg(colors.sunken)
.font_family(look.mono.clone())
.text_size(look.small)
.child(
div()
.flex()
.justify_between()
.items_center()
.text_size(look.tiny)
.text_color(colors.fg_subtle)
.child("Code")
.child(CopyButton::new((id.clone(), format!("copy-{key}")), text)),
)
.child(self.field_view(block, 0, window, cx))
.into_any_element()
}
BlockKind::Math => {
let tex = block.fields[0].read(cx).text().to_string();
let shown = match Latex::parse(&tex) {
_ if tex.trim().is_empty() => div().into_any_element(),
Ok(math) => div()
.flex()
.justify_center()
.py_2()
.child(math)
.into_any_element(),
Err(error) => div()
.text_color(colors.danger)
.child(error.to_string())
.into_any_element(),
};
div()
.flex()
.flex_col()
.gap_1()
.p_3()
.rounded(look.radius)
.border_1()
.border_color(colors.border)
.child(
div()
.font_family(look.mono.clone())
.text_size(look.small)
.text_color(colors.fg_muted)
.child(self.field_view(block, 0, window, cx)),
)
.child(shown)
.into_any_element()
}
BlockKind::Diagram => media::diagram(self, block, look, window, cx),
BlockKind::Divider => div()
.py_2()
.child(div().h_0().border_t_1().border_color(colors.border))
.into_any_element(),
BlockKind::Table { .. } => table::table(self, block, look, window, cx),
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
media::media(self, block, look, window, cx)
}
BlockKind::Columns(_) => div()
.flex()
.gap_4()
.children((0..block.fields.len()).map(|ix| {
div()
.flex_1()
.min_w_0()
.when(ix > 0, |column| {
column.pl_4().border_l_1().border_color(colors.border)
})
.child(self.field_view(block, ix, window, cx))
}))
.into_any_element(),
BlockKind::Synced(name) => div()
.flex()
.flex_col()
.gap_1()
.px_3()
.py_2()
.rounded(look.radius)
.border_1()
.border_color(colors.border)
.child(
div()
.flex()
.items_center()
.gap_1()
.text_size(look.tiny)
.text_color(colors.fg_subtle)
.child(
Icon::new(IconName::RefreshCw)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(format!("Synced · {name}")),
)
.child(self.field_view(block, 0, window, cx))
.into_any_element(),
}
}
}
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use gpui::{Context, Window};
use super::{Block, BlockData, BlockEditor, BlockEvent, BlockKind, Snapshot};
impl BlockEditor {
/// Splits a prose block at the caret: the text after it moves to a new block of the kind that follows.
pub(crate) fn split(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
let kind = self.blocks[ix].kind.clone();
let field = self.blocks[ix].fields[0].clone();
if field.read(cx).is_empty()
&& kind != BlockKind::Paragraph
&& kind.next() != BlockKind::Paragraph
{
self.turn_into(key, BlockKind::Paragraph, window, cx);
return;
}
let caret = field.read(cx).cursor();
let tail = field.read(cx).text()[caret..].to_string();
log::info!("block editor: block {key} splits at {caret}");
self.before_change(cx);
field.update(cx, |field, cx| {
let end = field.text().len();
field.select(caret..end, cx);
field.insert("", cx);
});
let key = self.fresh_key();
let next = self.build(
BlockData {
kind: kind.next(),
texts: vec![tail],
},
key,
window,
cx,
);
self.blocks.insert(ix + 1, next);
self.after_change(cx);
self.focus_field(key, 0, Some(0), window, cx);
}
/// Backspace at a prose block's start: another kind turns back into text; text joins the prose above, or an empty block goes.
pub(crate) fn join(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
if self.blocks[ix].kind != BlockKind::Paragraph {
self.turn_into(key, BlockKind::Paragraph, window, cx);
return;
}
let text = self.blocks[ix].fields[0].read(cx).text().to_string();
let Some(above) = ix.checked_sub(1).map(|ix| &self.blocks[ix]) else {
return;
};
if !above.kind.is_prose() {
if text.is_empty() {
self.remove(key, window, cx);
}
return;
}
let (above_key, above_field) = (above.key, above.fields[0].clone());
log::info!("block editor: block {key} joins block {above_key}");
self.before_change(cx);
let joint = above_field.read(cx).text().len();
above_field.update(cx, |field, cx| {
field.select(joint..joint, cx);
field.insert(&text, cx);
});
self.blocks.remove(ix);
self.after_change(cx);
self.focus_field(above_key, 0, Some(joint), window, cx);
}
pub(crate) fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settle(true, cx);
if let Some(previous) = self.history.undo(self.current.clone()) {
log::info!("block editor: undo");
self.restore(previous, window, cx);
}
}
pub(crate) fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(next) = self.history.redo(self.current.clone()) {
log::info!("block editor: redo");
self.restore(next, window, cx);
}
}
/// Puts the document back as `snapshot` holds it, keeping fields whose blocks stay.
fn restore(&mut self, snapshot: Snapshot, window: &mut Window, cx: &mut Context<Self>) {
let mut kept: HashMap<u64, Block> = self
.blocks
.drain(..)
.map(|block| (block.key, block))
.collect();
for (key, data) in &snapshot.0 {
let block = match kept.remove(key) {
Some(mut block) if block.fields.len() == data.texts.len() => {
block.kind = data.kind.clone();
for (field, text) in block.fields.iter().zip(&data.texts) {
if field.read(cx).text() != text {
field.update(cx, |field, cx| field.set_text(text.clone(), cx));
}
}
block
}
_ => self.build(data.clone(), *key, window, cx),
};
self.blocks.push(block);
}
self.current = snapshot;
cx.emit(BlockEvent::Changed);
cx.notify();
}
}
+144
View File
@@ -0,0 +1,144 @@
use gpui::{Context, Window};
use super::{BlockData, BlockEditor, BlockKind};
use crate::documents::modes::at_edge;
/// A new empty paragraph.
fn paragraph() -> BlockData {
BlockData::new(BlockKind::Paragraph, [""])
}
impl BlockEditor {
/// The fields in reading order, as `(block, field)`: a closed toggle hides its body.
fn reading_order(&self) -> Vec<(u64, usize)> {
self.blocks
.iter()
.flat_map(|block| {
let shown = match block.kind {
BlockKind::Toggle(false) => 1,
_ => block.fields.len(),
};
(0..shown).map(move |field| (block.key, field))
})
.collect()
}
/// Enter in a field: prose splits, a toggle's title opens its body, a caption starts text below, a cell steps down a row; elsewhere Enter breaks the line.
pub(crate) fn on_enter(
&mut self,
key: u64,
field: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let kind = self.blocks[self.index(key)].kind.clone();
match kind {
kind if kind.is_prose() => self.split(key, window, cx),
BlockKind::Toggle(open) if field == 0 => {
if !open {
self.set_kind(key, BlockKind::Toggle(true), cx);
}
self.focus_field(key, 1, None, window, cx);
}
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
self.insert(Some(key), paragraph(), window, cx);
}
BlockKind::Table { columns, .. } => {
let below = field + columns;
if below >= self.blocks[self.index(key)].fields.len() {
self.add_row(key, window, cx);
}
self.focus_field(key, below, None, window, cx);
}
_ => return false,
}
true
}
/// Backspace at a field's start: prose joins up or turns back into text; an empty code, math or diagram block turns into text.
pub(crate) fn on_backspace(
&mut self,
key: u64,
field: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let block = &self.blocks[self.index(key)];
let input = block.fields[field].read(cx);
if input.cursor() != 0 || !input.selection().is_empty() {
return false;
}
let empty = input.is_empty();
match &block.kind {
kind if kind.is_prose() => self.join(key, window, cx),
kind if kind.is_source() && empty => {
self.turn_into(key, BlockKind::Paragraph, window, cx)
}
BlockKind::Toggle(_) if field == 0 && block.fields[1].read(cx).is_empty() => {
self.turn_into(key, BlockKind::Paragraph, window, cx)
}
_ => return false,
}
true
}
/// Up or Down on a field's first or last line moves to the field above or below, a table cell by a row.
pub(crate) fn on_vertical(
&mut self,
key: u64,
field: usize,
down: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let block = &self.blocks[self.index(key)];
if !at_edge(block.fields[field].read(cx), down) {
return false;
}
if let BlockKind::Table { columns, .. } = block.kind {
let row = if down {
Some(field + columns)
} else {
field.checked_sub(columns)
};
if let Some(row) = row.filter(|row| *row < block.fields.len()) {
self.focus_field(key, row, None, window, cx);
return true;
}
}
let order = self.reading_order();
let at = order
.iter()
.position(|place| *place == (key, field))
.expect("a focused field is in reading order");
let target = if down {
order[at + 1..].iter().find(|(other, _)| *other != key)
} else {
order[..at].iter().rev().find(|(other, _)| *other != key)
};
let Some(&(target, target_field)) = target else {
if down && !self.blocks.last().is_some_and(|last| last.kind.is_prose()) {
self.insert(Some(key), paragraph(), window, cx);
return true;
}
return false;
};
let end = self.blocks[self.index(target)].fields[target_field]
.read(cx)
.text()
.len();
self.focus_field(
target,
target_field,
Some(if down { 0 } else { end }),
window,
cx,
);
true
}
/// The submit key in any field leaves the block for a new paragraph below.
pub(crate) fn on_submit(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
self.insert(Some(key), paragraph(), window, cx);
}
}
+277
View File
@@ -0,0 +1,277 @@
use gpui::SharedString;
use crate::primitives::IconName;
/// Where a picture sits across its column.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Align {
Left,
#[default]
Center,
Right,
}
/// A picture, clip or page shown in a block: its address, and its width as a share of the column.
#[derive(Clone, Debug, PartialEq)]
pub struct Media {
pub source: SharedString,
pub width: f32,
pub align: Align,
}
impl Media {
pub fn new(source: impl Into<SharedString>) -> Self {
Self {
source: source.into(),
width: 1.0,
align: Align::Center,
}
}
}
/// What a block is. Each holds its text in fields: one for most, a title and a body for a toggle, one per column, a caption for media, one per cell for a table, none for a divider.
#[derive(Clone, Debug, PartialEq)]
pub enum BlockKind {
Paragraph,
Heading(u8),
Bullet,
Numbered,
Todo(bool),
Quote,
Callout,
/// Open or closed; its body shows while open.
Toggle(bool),
Code,
Math,
/// Diagram source, such as Mermaid; the host renders the picture.
Diagram,
Divider,
/// Columns across, the header row first; each `merged` cell, as row and column, folds into the cell on its left.
Table {
columns: usize,
merged: Vec<(usize, usize)>,
},
Image(Media),
Video(Media),
Embed(Media),
/// Side by side, one field each.
Columns(usize),
/// Text shared with every other block synced to the same source.
Synced(SharedString),
}
impl BlockKind {
/// Its name in the slash menu.
pub fn label(&self) -> &'static str {
match self {
Self::Paragraph => "Text",
Self::Heading(1) => "Heading 1",
Self::Heading(2) => "Heading 2",
Self::Heading(_) => "Heading 3",
Self::Bullet => "Bulleted list",
Self::Numbered => "Numbered list",
Self::Todo(_) => "To-do",
Self::Quote => "Quote",
Self::Callout => "Callout",
Self::Toggle(_) => "Toggle",
Self::Code => "Code",
Self::Math => "Math",
Self::Diagram => "Diagram",
Self::Divider => "Divider",
Self::Table { .. } => "Table",
Self::Image(_) => "Image",
Self::Video(_) => "Video",
Self::Embed(_) => "Embed",
Self::Columns(_) => "Columns",
Self::Synced(_) => "Synced block",
}
}
pub fn icon(&self) -> IconName {
match self {
Self::Paragraph => IconName::Type,
Self::Heading(1) => IconName::Heading1,
Self::Heading(2) => IconName::Heading2,
Self::Heading(_) => IconName::Heading3,
Self::Bullet => IconName::List,
Self::Numbered => IconName::ListOrdered,
Self::Todo(_) => IconName::ListTodo,
Self::Quote => IconName::Quote,
Self::Callout => IconName::Info,
Self::Toggle(_) => IconName::ChevronRight,
Self::Code => IconName::Code,
Self::Math => IconName::SquareFunction,
Self::Diagram => IconName::Workflow,
Self::Divider => IconName::Minus,
Self::Table { .. } => IconName::Table,
Self::Image(_) => IconName::Image,
Self::Video(_) => IconName::Video,
Self::Embed(_) => IconName::Globe,
Self::Columns(_) => IconName::Columns2,
Self::Synced(_) => IconName::RefreshCw,
}
}
/// What an empty field of this kind says while it has focus.
pub fn placeholder(&self) -> &'static str {
match self {
Self::Paragraph => "Type '/' for commands",
Self::Heading(_) => self.label(),
Self::Bullet | Self::Numbered => "List",
Self::Todo(_) => "To-do",
Self::Quote => "Quote",
Self::Callout | Self::Toggle(_) | Self::Columns(_) | Self::Synced(_) => "Write",
Self::Code => "Code",
Self::Math => "A formula in TeX, such as e^{i\\pi} + 1 = 0",
Self::Diagram => "A diagram in Mermaid, such as graph LR; a --> b",
Self::Image(_) | Self::Video(_) | Self::Embed(_) => "Add a caption",
Self::Divider | Self::Table { .. } => "",
}
}
/// How many fields a new block of this kind holds.
pub fn fields(&self) -> usize {
match self {
Self::Divider => 0,
Self::Toggle(_) => 2,
Self::Columns(count) => *count,
Self::Table { columns, .. } => columns * 2,
_ => 1,
}
}
/// Whether it can hold `count` fields: a table any whole rows past its header, the rest their own number.
pub fn accepts(&self, count: usize) -> bool {
match self {
Self::Table { columns, .. } => {
*columns > 0 && count >= columns * 2 && count.is_multiple_of(*columns)
}
kind => count == kind.fields(),
}
}
/// Prose: Enter splits it, Backspace at its start turns it back into text or joins it up.
pub fn is_prose(&self) -> bool {
matches!(
self,
Self::Paragraph
| Self::Heading(_)
| Self::Bullet
| Self::Numbered
| Self::Todo(_)
| Self::Quote
| Self::Callout
)
}
/// The kind Enter starts after this one: lists go on, the rest return to text.
pub fn next(&self) -> Self {
match self {
Self::Bullet => Self::Bullet,
Self::Numbered => Self::Numbered,
Self::Todo(_) => Self::Todo(false),
_ => Self::Paragraph,
}
}
/// Lines of monospaced source, where Enter breaks a line.
pub fn is_source(&self) -> bool {
matches!(self, Self::Code | Self::Math | Self::Diagram)
}
}
/// The kinds the slash menu offers, in order; media and synced blocks come from their owner.
pub(crate) fn offered() -> Vec<BlockKind> {
vec![
BlockKind::Paragraph,
BlockKind::Heading(1),
BlockKind::Heading(2),
BlockKind::Heading(3),
BlockKind::Bullet,
BlockKind::Numbered,
BlockKind::Todo(false),
BlockKind::Quote,
BlockKind::Callout,
BlockKind::Toggle(true),
BlockKind::Code,
BlockKind::Math,
BlockKind::Diagram,
BlockKind::Divider,
BlockKind::Table {
columns: 2,
merged: Vec::new(),
},
BlockKind::Columns(2),
]
}
/// The kinds whose name holds `query`, for the slash menu.
pub(crate) fn matching(query: &str) -> Vec<BlockKind> {
let query = query.to_lowercase();
offered()
.into_iter()
.filter(|kind| kind.label().to_lowercase().contains(&query))
.collect()
}
/// A paragraph's leading markdown that makes it another kind, and how many bytes it takes; the space after it is typed last.
pub(crate) fn shortcut(text: &str) -> Option<(BlockKind, usize)> {
let prefixes: [(&str, BlockKind); 11] = [
("# ", BlockKind::Heading(1)),
("## ", BlockKind::Heading(2)),
("### ", BlockKind::Heading(3)),
("- ", BlockKind::Bullet),
("* ", BlockKind::Bullet),
("1. ", BlockKind::Numbered),
("[] ", BlockKind::Todo(false)),
("[ ] ", BlockKind::Todo(false)),
("[x] ", BlockKind::Todo(true)),
("> ", BlockKind::Quote),
("``` ", BlockKind::Code),
];
if text == "---" {
return Some((BlockKind::Divider, 3));
}
prefixes
.into_iter()
.find(|(prefix, _)| text.starts_with(prefix))
.map(|(prefix, kind)| (kind, prefix.len()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_at_a_paragraphs_start_turns_it_into_a_kind() {
assert_eq!(shortcut("## Lift"), Some((BlockKind::Heading(2), 3)));
assert_eq!(shortcut("[x] done"), Some((BlockKind::Todo(true), 4)));
assert_eq!(shortcut("---"), Some((BlockKind::Divider, 3)));
assert_eq!(shortcut("#hashtag"), None);
assert_eq!(shortcut("1.5 pages"), None);
}
#[test]
fn the_slash_menu_filters_by_name() {
let labels: Vec<&str> = matching("head").iter().map(BlockKind::label).collect();
assert_eq!(labels, ["Heading 1", "Heading 2", "Heading 3"]);
assert!(matching("zzz").is_empty());
}
#[test]
fn a_table_holds_whole_rows_past_its_header() {
let table = BlockKind::Table {
columns: 3,
merged: Vec::new(),
};
assert!(table.accepts(9));
assert!(!table.accepts(7) && !table.accepts(3));
assert!(BlockKind::Toggle(true).accepts(2) && !BlockKind::Paragraph.accepts(2));
}
#[test]
fn lists_go_on_and_the_rest_return_to_text() {
assert_eq!(BlockKind::Todo(true).next(), BlockKind::Todo(false));
assert_eq!(BlockKind::Heading(1).next(), BlockKind::Paragraph);
}
}
+371
View File
@@ -0,0 +1,371 @@
use std::{path::Path, sync::Arc};
use gpui::{
AnyElement, AppContext as _, Bounds, Context, EmptyView, EntityId, Image, ImageFormat,
ImageSource, InteractiveElement, IntoElement, ParentElement, Pixels, SharedUri,
StatefulInteractiveElement, Styled, Window, div, img, prelude::*,
};
use regex::Regex;
use super::{Align, Block, BlockEditor, BlockKind, Media, view::Look};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::{Icon, IconName, Measure},
theme::{ControlSize, IconSize},
};
/// Narrowest a picture gets, as a share of its column.
const NARROWEST: f32 = 0.2;
/// A picture's edge on its way, and whose.
struct Resize {
owner: EntityId,
key: u64,
}
/// A web address loads over the network; anything else is a file.
pub(crate) fn source(text: &str) -> ImageSource {
if text.starts_with("http://") || text.starts_with("https://") {
SharedUri::from(text.to_string()).into()
} else {
Path::new(text).into()
}
}
/// The share of `column` a picture set by `align` spans when its edge is at `x`.
pub(crate) fn share(align: Align, column: Bounds<Pixels>, x: Pixels) -> f32 {
let width = f32::from(column.size.width).max(1.0);
let reach = match align {
Align::Left => f32::from(x - column.left()),
Align::Right => f32::from(column.right() - x),
Align::Center => 2.0 * f32::from(x - column.center().x).abs(),
};
(reach / width).clamp(NARROWEST, 1.0)
}
fn media_of(kind: &mut BlockKind) -> &mut Media {
match kind {
BlockKind::Image(media) | BlockKind::Video(media) | BlockKind::Embed(media) => media,
other => unreachable!("{} holds no media", other.label()),
}
}
impl BlockEditor {
/// Sets a picture's width while its edge is dragged; the drag's start records undo.
fn resize(&mut self, key: u64, width: f32, first: bool, cx: &mut Context<Self>) {
if first {
self.before_change(cx);
}
let ix = self.index(key);
media_of(&mut self.blocks[ix].kind).width = width;
self.after_change(cx);
}
pub(crate) fn align(&mut self, key: u64, align: Align, cx: &mut Context<Self>) {
let ix = self.index(key);
let mut kind = self.blocks[ix].kind.clone();
media_of(&mut kind).align = align;
self.set_kind(key, kind, cx);
}
}
/// A picture sized as a share of its column, set left, center or right, with an edge to drag and a caption below; a clip or a page shows as a card to open.
pub(super) fn media(
editor: &BlockEditor,
block: &Block,
look: &Look,
window: &mut Window,
cx: &mut Context<BlockEditor>,
) -> AnyElement {
let (key, id) = (block.key, BlockEditor::id(cx));
let colors = look.colors.clone();
let caption = div()
.text_size(look.small)
.text_color(colors.fg_muted)
.child(editor.field_view(block, 0, window, cx));
let (media, icon) = match &block.kind {
BlockKind::Image(media) => (media.clone(), None),
BlockKind::Video(media) => (media.clone(), Some(IconName::Video)),
BlockKind::Embed(media) => (media.clone(), Some(IconName::Globe)),
other => unreachable!("{} is no media", other.label()),
};
if let Some(icon) = icon {
let address = media.source.clone();
return div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.items_center()
.gap_3()
.p_3()
.rounded(look.radius)
.border_1()
.border_color(colors.border)
.child(Icon::new(icon).size(IconSize::Md).color(colors.fg_muted))
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg_muted)
.child(media.source.clone()),
)
.child(
Button::new((id.clone(), format!("open-{key}")), "Open")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| cx.open_url(&address)),
),
)
.child(caption)
.into_any_element();
}
let column = window.use_keyed_state((id.clone(), format!("column-{key}")), cx, |_, _| {
Bounds::<Pixels>::default()
});
let bounds = *column.read(cx);
let (align, width) = (media.align, media.width);
let owner = cx.entity_id();
let edge = |side: &'static str, cx: &mut Context<BlockEditor>| {
let entity = cx.entity();
div()
.id((id.clone(), format!("edge-{key}-{side}")))
.absolute()
.top_0()
.bottom_0()
.map(|edge| {
if side == "left" {
edge.left_0()
} else {
edge.right_0()
}
})
.w_2()
.cursor_col_resize()
.on_drag(Resize { owner, key }, move |_, _, _, cx| {
entity.update(cx, |editor, cx| editor.resize(key, width, true, cx));
log::info!("image {key}: resize");
cx.new(|_| EmptyView)
})
};
let aligned = |align_to: Align, icon: IconName, cx: &mut Context<BlockEditor>| {
IconButton::new((id.clone(), format!("align-{key}-{align_to:?}")), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(format!("Align {align_to:?}").to_lowercase())
.on_click(cx.listener(move |editor, _, _, cx| editor.align(key, align_to, cx)))
};
let tools = div()
.absolute()
.top_1()
.right_1()
.flex()
.gap_0p5()
.p_0p5()
.rounded(look.radius)
.bg(colors.overlay)
.opacity(0.0)
.group_hover(format!("picture-{key}"), |tools| tools.opacity(1.0))
.child(aligned(Align::Left, IconName::AlignLeft, cx))
.child(aligned(Align::Center, IconName::AlignCenter, cx))
.child(aligned(Align::Right, IconName::AlignRight, cx));
let picture = div()
.id((id.clone(), format!("picture-{key}")))
.group(format!("picture-{key}"))
.relative()
.w(bounds.size.width * width)
.child(
img(source(&media.source))
.id((id.clone(), format!("img-{key}")))
.w(bounds.size.width * width)
.rounded(look.radius),
)
.child(edge("left", cx))
.child(edge("right", cx))
.child(tools);
let entity = cx.entity();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id((id.clone(), format!("column-{key}")))
.w_full()
.on_drag_move(move |event: &gpui::DragMoveEvent<Resize>, _, cx| {
if event.drag(cx).owner != owner || event.drag(cx).key != key {
return;
}
let width = share(align, event.bounds, event.event.position.x);
entity.update(cx, |editor, cx| editor.resize(key, width, false, cx));
})
.child(
Measure::new(
(id.clone(), format!("measure-{key}")),
move |measured, _, cx| {
column.update(cx, |column, cx| {
*column = measured;
cx.notify();
})
},
)
.w_full()
.flex()
.map(|row| match align {
Align::Left => row.justify_start(),
Align::Center => row.justify_center(),
Align::Right => row.justify_end(),
})
.child(
div()
.flex()
.flex_col()
.gap_1()
.w(bounds.size.width * width)
.child(picture)
.child(caption),
),
),
)
.into_any_element()
}
/// A diagram the host drew, and the width its SVG asks for.
#[derive(Clone)]
struct Drawn {
image: Arc<Image>,
width: Option<f32>,
}
/// The width an SVG's root asks for, in pixels, when it gives one.
pub(crate) fn svg_width(svg: &str) -> Option<f32> {
let root = Regex::new(r#"<svg\b[^>]*?\swidth=['"]([0-9.]+)(px)?['"]"#)
.expect("the SVG width pattern compiles");
root.captures(svg)?.get(1)?.as_str().parse().ok()
}
/// A diagram's source above the picture the host draws from it.
pub(super) fn diagram(
editor: &BlockEditor,
block: &Block,
look: &Look,
window: &mut Window,
cx: &mut Context<BlockEditor>,
) -> AnyElement {
let (key, id) = (block.key, BlockEditor::id(cx));
let text = block.fields[0].read(cx).text().to_string();
let drawn = window.use_keyed_state((id.clone(), format!("drawn-{key}")), cx, |_, _| {
None::<(String, Result<Drawn, String>)>
});
let stale = drawn
.read(cx)
.as_ref()
.is_none_or(|(source, _)| *source != text);
if stale && let Some(render) = &editor.diagram {
let picture = render(&text).map(|svg| Drawn {
width: svg_width(&svg),
image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())),
});
log::info!(
"diagram {key}: drawn, {}",
if picture.is_ok() {
"fine"
} else {
"with an error"
}
);
drawn.update(cx, |drawn, _| *drawn = Some((text.clone(), picture)));
}
let colors = look.colors.clone();
let shown: AnyElement = match (editor.diagram.is_some(), drawn.read(cx).clone()) {
_ if text.trim().is_empty() => div().into_any_element(),
(false, _) => div()
.text_color(colors.fg_subtle)
.child("Diagrams draw once the app gives a renderer")
.into_any_element(),
(true, Some((_, Ok(drawn)))) => {
let column =
window.use_keyed_state((id.clone(), format!("diagram-width-{key}")), cx, |_, _| {
Pixels::ZERO
});
let width = drawn.width.map_or(*column.read(cx), |natural| {
Pixels::from(natural).min(*column.read(cx))
});
Measure::new(
(id.clone(), format!("diagram-measure-{key}")),
move |bounds, _, cx| {
column.update(cx, |column, cx| {
*column = bounds.size.width;
cx.notify();
})
},
)
.w_full()
.flex()
.justify_center()
.child(
img(drawn.image)
.id((id.clone(), format!("diagram-{key}")))
.w(width),
)
.into_any_element()
}
(true, Some((_, Err(error)))) => div()
.text_color(colors.danger)
.child(error)
.into_any_element(),
(true, None) => unreachable!("a renderer draws before this shows"),
};
div()
.flex()
.flex_col()
.gap_2()
.p_3()
.rounded(look.radius)
.border_1()
.border_color(colors.border)
.child(
div()
.font_family(look.mono.clone())
.text_size(look.small)
.text_color(colors.fg_muted)
.child(editor.field_view(block, 0, window, cx)),
)
.child(shown)
.into_any_element()
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::*;
#[test]
fn an_svg_gives_its_width_when_it_has_one() {
assert_eq!(
svg_width("<svg xmlns='x' width='504' height='44'>"),
Some(504.0)
);
assert_eq!(svg_width("<svg width=\"120px\">"), Some(120.0));
assert_eq!(
svg_width("<svg viewBox='0 0 10 10'><rect width='5'/></svg>"),
None
);
}
#[test]
fn an_edge_sets_its_share_by_how_the_picture_sits() {
let column = Bounds::new(point(px(100.0), px(0.0)), size(px(400.0), px(10.0)));
assert_eq!(share(Align::Left, column, px(300.0)), 0.5);
assert_eq!(share(Align::Right, column, px(300.0)), 0.5);
assert_eq!(share(Align::Center, column, px(400.0)), 0.5);
assert_eq!(
share(Align::Center, column, px(300.0)),
NARROWEST,
"a picture keeps some width"
);
}
}
+479
View File
@@ -0,0 +1,479 @@
mod content;
mod edit;
mod keys;
mod kind;
mod media;
mod table;
mod view;
use std::{collections::HashMap, rc::Rc};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Focusable, Global, SharedString,
Subscription, Window,
};
pub use kind::{Align, BlockKind, Media};
pub(crate) use kind::{matching, shortcut};
use super::markdown::markdown_highlights;
use crate::forms::{History, InputEvent, TextInput, code_highlights};
/// A block as the editor gives it out and takes it in: its kind and the text of each of its fields.
#[derive(Clone, Debug, PartialEq)]
pub struct BlockData {
pub kind: BlockKind,
pub texts: Vec<String>,
}
impl BlockData {
pub fn new(kind: BlockKind, texts: impl IntoIterator<Item = impl Into<String>>) -> Self {
let texts: Vec<String> = texts.into_iter().map(Into::into).collect();
assert!(
kind.accepts(texts.len()),
"a {} block cannot hold {} fields",
kind.label(),
texts.len()
);
Self { kind, texts }
}
}
/// What a block editor tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockEvent {
Changed,
}
/// Renders a diagram's source to SVG, or says what is wrong with it.
pub(crate) type RenderDiagram = Rc<dyn Fn(&str) -> Result<String, String>>;
/// Text shared by synced blocks, by name, across every editor in the app.
#[derive(Default)]
struct SyncedTexts(HashMap<SharedString, String>);
impl Global for SyncedTexts {}
pub(crate) struct Block {
pub key: u64,
pub kind: BlockKind,
pub fields: Vec<Entity<TextInput>>,
_changes: Vec<Subscription>,
}
/// The whole document, as undo keeps it.
#[derive(Clone, Debug, PartialEq)]
struct Snapshot(Vec<(u64, BlockData)>);
/// A document of blocks, Notion-like: prose, lists and to-dos, quotes and callouts, toggles, code, math, diagrams, dividers, tables, media, columns and synced blocks. Markdown at a paragraph's start changes its kind; `/` offers every kind; `@` and `:` suggest people and emoji; blocks drag by their handle. Undo covers text and structure alike.
pub struct BlockEditor {
pub(crate) blocks: Vec<Block>,
next: u64,
history: History<Snapshot>,
current: Snapshot,
pub(crate) people: Vec<SharedString>,
pub(crate) diagram: Option<RenderDiagram>,
_synced: Subscription,
}
impl EventEmitter<BlockEvent> for BlockEditor {}
impl BlockEditor {
pub fn new(blocks: Vec<BlockData>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let synced = cx.observe_global_in::<SyncedTexts>(window, |editor, _, cx| editor.pull(cx));
let mut editor = Self {
blocks: Vec::new(),
next: 0,
history: History::default(),
current: Snapshot(Vec::new()),
people: Vec::new(),
diagram: None,
_synced: synced,
};
for data in blocks {
let key = editor.fresh_key();
let block = editor.build(data, key, window, cx);
editor.blocks.push(block);
}
editor.current = editor.snapshot(cx);
editor
}
/// Handles `@` suggests.
pub fn people(mut self, handles: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.people = handles.into_iter().map(Into::into).collect();
self
}
/// Draws diagram blocks: their source to SVG, or an error to show in its place.
pub fn diagrams(mut self, render: impl Fn(&str) -> Result<String, String> + 'static) -> Self {
self.diagram = Some(Rc::new(render));
self
}
/// The document as it stands.
pub fn blocks(&self, cx: &App) -> Vec<BlockData> {
self.snapshot(cx)
.0
.into_iter()
.map(|(_, data)| data)
.collect()
}
fn snapshot(&self, cx: &App) -> Snapshot {
Snapshot(
self.blocks
.iter()
.map(|block| {
let texts = block
.fields
.iter()
.map(|field| field.read(cx).text().to_string())
.collect();
(
block.key,
BlockData {
kind: block.kind.clone(),
texts,
},
)
})
.collect(),
)
}
/// A field for `kind`: inline markdown styled as typed, or code colored as code.
fn field(
kind: &BlockKind,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<TextInput> {
let code = *kind == BlockKind::Code;
let field = cx.new(|cx| {
let hint = match kind {
BlockKind::Paragraph | BlockKind::Table { .. } => "",
kind => kind.placeholder(),
};
let field = TextInput::new(window, cx)
.multi_line(1, usize::MAX)
.placeholder(hint);
match (code, kind.is_source()) {
(true, _) => field.highlighter(code_highlights),
(false, true) => field,
(false, false) => field.highlighter(markdown_highlights),
}
});
field.update(cx, |field, cx| field.set_text(text, cx));
field
}
fn fresh_key(&mut self) -> u64 {
self.next += 1;
self.next
}
fn build(
&mut self,
data: BlockData,
key: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> Block {
assert!(
data.kind.accepts(data.texts.len()),
"a {} block cannot hold {} fields",
data.kind.label(),
data.texts.len()
);
let mut texts = data.texts;
if let BlockKind::Synced(name) = &data.kind {
let shared = cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name).cloned());
match shared {
Some(shared) => texts[0] = shared,
None => {
cx.default_global::<SyncedTexts>()
.0
.insert(name.clone(), texts[0].clone());
}
}
}
let fields: Vec<Entity<TextInput>> = texts
.iter()
.map(|text| Self::field(&data.kind, text, window, cx))
.collect();
let changes = fields
.iter()
.map(|field| Self::watch(key, field, window, cx))
.collect();
Block {
key,
kind: data.kind,
fields,
_changes: changes,
}
}
fn watch(
key: u64,
field: &Entity<TextInput>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Subscription {
cx.subscribe_in(field, window, move |editor, field, event, window, cx| {
editor.on_field(key, field, *event, window, cx)
})
}
/// Adds an empty field to a block, at `at` or last.
pub(crate) fn add_field(
&mut self,
key: u64,
at: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ix = self.index(key);
let field = Self::field(&self.blocks[ix].kind, "", window, cx);
let watched = Self::watch(key, &field, window, cx);
let block = &mut self.blocks[ix];
let at = at.unwrap_or(block.fields.len());
block.fields.insert(at, field);
block._changes.insert(at, watched);
}
pub(crate) fn remove_field(&mut self, key: u64, field: usize) {
let ix = self.index(key);
let block = &mut self.blocks[ix];
block.fields.remove(field);
drop(block._changes.remove(field));
}
pub(crate) fn index(&self, key: u64) -> usize {
self.blocks
.iter()
.position(|block| block.key == key)
.unwrap_or_else(|| panic!("block {key} left the editor"))
}
fn on_field(
&mut self,
key: u64,
field: &Entity<TextInput>,
event: InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ix = self.index(key);
match event {
InputEvent::Changed => {
let typed = field.focus_handle(cx).is_focused(window);
if typed
&& self.blocks[ix].kind == BlockKind::Paragraph
&& let Some((kind, length)) = shortcut(field.read(cx).text())
&& field.read(cx).cursor() == length
{
log::info!(
"block editor: markdown turns block {key} into {}",
kind.label()
);
field.update(cx, |field, cx| {
field.select(0..length, cx);
field.insert("", cx);
});
self.turn_into(key, kind, window, cx);
return;
}
if let BlockKind::Synced(name) = &self.blocks[ix].kind {
let text = field.read(cx).text().to_string();
let shared = cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name));
if shared != Some(&text) {
log::info!("block editor: synced {name} written");
cx.default_global::<SyncedTexts>()
.0
.insert(name.clone(), text);
}
}
self.settle(true, cx);
}
InputEvent::Focus if field.read(cx).is_empty() => {
let hint = self.blocks[ix].kind.placeholder();
field.update(cx, |field, cx| field.set_placeholder(hint, cx));
}
InputEvent::Blur if self.blocks[ix].kind == BlockKind::Paragraph => {
field.update(cx, |field, cx| field.set_placeholder("", cx));
}
_ => {}
}
}
/// Takes in synced text written elsewhere.
fn pull(&mut self, cx: &mut Context<Self>) {
let shared: Vec<(Entity<TextInput>, String)> = self
.blocks
.iter()
.filter_map(|block| match &block.kind {
BlockKind::Synced(name) => cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name))
.map(|text| (block.fields[0].clone(), text.clone())),
_ => None,
})
.collect();
for (field, text) in shared {
if field.read(cx).text() != text {
log::debug!("block editor: synced text arrived");
field.update(cx, |field, cx| field.set_text(text, cx));
}
}
}
/// Records what changed as one undo step, a burst of typing as one, and tells the owner.
fn settle(&mut self, typing: bool, cx: &mut Context<Self>) {
let now = self.snapshot(cx);
if now != self.current {
let before = std::mem::replace(&mut self.current, now);
self.history.record(before, typing);
cx.emit(BlockEvent::Changed);
cx.notify();
}
}
/// Records the document before a change to its blocks.
pub(crate) fn before_change(&mut self, cx: &mut Context<Self>) {
self.settle(true, cx);
self.history.record(self.current.clone(), false);
}
/// After a change to the blocks: the new state is current.
pub(crate) fn after_change(&mut self, cx: &mut Context<Self>) {
self.current = self.snapshot(cx);
cx.emit(BlockEvent::Changed);
cx.notify();
}
pub(crate) fn focus_field(
&self,
key: u64,
field: usize,
at: Option<usize>,
window: &mut Window,
cx: &mut App,
) {
let block = &self.blocks[self.index(key)];
let Some(field) = block.fields.get(field) else {
return;
};
if let Some(at) = at {
field.update(cx, |field, cx| field.select(at..at, cx));
}
window.focus(&field.focus_handle(cx));
}
/// Adds a block after `after`, or first, and puts the caret in it.
pub fn insert(
&mut self,
after: Option<u64>,
data: BlockData,
window: &mut Window,
cx: &mut Context<Self>,
) -> u64 {
self.before_change(cx);
let key = self.fresh_key();
let block = self.build(data, key, window, cx);
let at = after.map_or(0, |after| self.index(after) + 1);
log::info!("block editor: {} block at {at}", block.kind.label());
let end = block
.fields
.first()
.map(|field| field.read(cx).text().len());
self.blocks.insert(at, block);
self.after_change(cx);
self.focus_field(key, 0, end, window, cx);
key
}
/// Changes a block's kind, keeping its first field's text; fields the new kind needs start empty.
pub fn turn_into(
&mut self,
key: u64,
kind: BlockKind,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.before_change(cx);
let ix = self.index(key);
let first = self.blocks[ix]
.fields
.first()
.map(|field| field.read(cx).text().to_string())
.unwrap_or_default();
let mut texts = vec![String::new(); kind.fields()];
if let Some(text) = texts.first_mut() {
*text = first;
}
log::info!("block editor: block {key} turns into {}", kind.label());
let block = self.build(BlockData { kind, texts }, key, window, cx);
self.blocks[ix] = block;
self.after_change(cx);
self.focus_field(key, 0, None, window, cx);
}
/// Changes a block's kind in place, as a to-do's box or a toggle's state does.
pub fn set_kind(&mut self, key: u64, kind: BlockKind, cx: &mut Context<Self>) {
let ix = self.index(key);
assert!(
kind.accepts(self.blocks[ix].fields.len()),
"set_kind keeps the fields; turn_into changes them"
);
self.before_change(cx);
self.blocks[ix].kind = kind;
self.after_change(cx);
}
pub fn remove(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
self.before_change(cx);
let ix = self.index(key);
log::info!("block editor: block {key} removed");
self.blocks.remove(ix);
self.after_change(cx);
if let Some(previous) = ix.checked_sub(1).and_then(|ix| self.blocks.get(ix)) {
let key = previous.key;
let end = previous
.fields
.first()
.map(|field| field.read(cx).text().len());
self.focus_field(key, 0, end, window, cx);
}
}
pub fn duplicate(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
let data = self.snapshot(cx).0[ix].1.clone();
self.insert(Some(key), data, window, cx);
}
/// Moves the block at `from` to place `to`.
pub fn move_block(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
self.before_change(cx);
log::info!("block editor: block moved from {from} to {to}");
let block = self.blocks.remove(from);
self.blocks.insert(to.min(self.blocks.len()), block);
self.after_change(cx);
}
/// Whether `key`'s field `field` holds focus.
pub(crate) fn focused(&self, window: &Window, cx: &App) -> Option<(u64, usize)> {
self.blocks.iter().find_map(|block| {
block
.fields
.iter()
.position(|field| field.focus_handle(cx).is_focused(window))
.map(|ix| (block.key, ix))
})
}
}
+287
View File
@@ -0,0 +1,287 @@
use gpui::{
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, Styled,
Window, div, prelude::*,
};
use super::{Block, BlockEditor, BlockKind, view::Look};
use crate::{
buttons::{ButtonVariant, IconButton},
primitives::IconName,
theme::ControlSize,
};
/// A table's shape: columns, rows, and the cells that fold into their left neighbor.
fn shape(kind: &BlockKind, fields: usize) -> (usize, usize, &[(usize, usize)]) {
let BlockKind::Table { columns, merged } = kind else {
unreachable!("a table block is a table")
};
(*columns, fields / columns, merged)
}
/// How many columns the cell at `(row, column)` spans: itself and every cell folded into it.
pub(crate) fn span(merged: &[(usize, usize)], columns: usize, row: usize, column: usize) -> usize {
1 + (column + 1..columns)
.take_while(|next| merged.contains(&(row, *next)))
.count()
}
/// Merges after `column` is removed: its own go, the ones right of it move left.
pub(crate) fn without_column(merged: &[(usize, usize)], column: usize) -> Vec<(usize, usize)> {
merged
.iter()
.filter(|(_, at)| *at != column && *at != column + 1)
.map(|&(row, at)| (row, if at > column { at - 1 } else { at }))
.collect()
}
/// Merges after `row` is removed.
pub(crate) fn without_row(merged: &[(usize, usize)], row: usize) -> Vec<(usize, usize)> {
merged
.iter()
.filter(|(at, _)| *at != row)
.map(|&(at, column)| (if at > row { at - 1 } else { at }, column))
.collect()
}
impl BlockEditor {
/// Adds a row of empty cells at the table's foot.
pub(crate) fn add_row(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
let (columns, _, _) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
self.before_change(cx);
for _ in 0..columns {
self.add_field(key, None, window, cx);
}
log::info!("table {key}: a row added");
self.after_change(cx);
}
/// Adds a column of empty cells at the table's right.
pub(crate) fn add_column(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
let merged = merged.to_vec();
self.before_change(cx);
for row in (0..rows).rev() {
self.add_field(key, Some((row + 1) * columns), window, cx);
}
self.blocks[ix].kind = BlockKind::Table {
columns: columns + 1,
merged,
};
log::info!("table {key}: a column added");
self.after_change(cx);
}
/// Removes the row or column holding cell `cell`; a table keeps a header, a row and a column.
pub(crate) fn remove_line(
&mut self,
key: u64,
cell: usize,
column: bool,
cx: &mut Context<Self>,
) {
let ix = self.index(key);
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
let merged = merged.to_vec();
let (row, at) = (cell / columns, cell % columns);
if (column && columns == 1) || (!column && rows <= 2) {
log::info!("table {key}: its last line stays");
return;
}
self.before_change(cx);
let (gone, kind): (Vec<usize>, BlockKind) = if column {
(
(0..rows).map(|row| row * columns + at).collect(),
BlockKind::Table {
columns: columns - 1,
merged: without_column(&merged, at),
},
)
} else {
(
(row * columns..(row + 1) * columns).collect(),
BlockKind::Table {
columns,
merged: without_row(&merged, row),
},
)
};
for ix_field in gone.into_iter().rev() {
self.remove_field(key, ix_field);
}
self.blocks[ix].kind = kind;
log::info!(
"table {key}: {} removed",
if column { "a column" } else { "a row" }
);
self.after_change(cx);
}
/// Folds the cell right of `cell` into it, moving its text over; or, when already merged, parts them again.
pub(crate) fn merge_right(&mut self, key: u64, cell: usize, cx: &mut Context<Self>) {
let ix = self.index(key);
let (columns, _, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
let (row, column) = (cell / columns, cell % columns);
let spanned = span(merged, columns, row, column);
if column + spanned >= columns && spanned == 1 {
return;
}
let mut merged = merged.to_vec();
self.before_change(cx);
if spanned > 1 {
merged
.retain(|(at_row, at)| !(*at_row == row && *at > column && *at < column + spanned));
log::info!("table {key}: cell {cell} parted");
} else {
let right = self.blocks[ix].fields[cell + 1].read(cx).text().to_string();
if !right.is_empty() {
self.blocks[ix].fields[cell].update(cx, |field, cx| {
let end = field.text().len();
field.select(end..end, cx);
field.insert(&format!(" {right}"), cx);
});
self.blocks[ix].fields[cell + 1].update(cx, |field, cx| field.set_text("", cx));
}
merged.push((row, column + 1));
log::info!("table {key}: cell {cell} merged right");
}
self.blocks[ix].kind = BlockKind::Table { columns, merged };
self.after_change(cx);
}
}
/// The table: a header row, then rows; cells merged across span their neighbors; tools above while a cell has focus.
pub(super) fn table(
editor: &BlockEditor,
block: &Block,
look: &Look,
window: &mut Window,
cx: &mut Context<BlockEditor>,
) -> AnyElement {
let (columns, rows, merged) = shape(&block.kind, block.fields.len());
let merged = merged.to_vec();
let (key, id) = (block.key, BlockEditor::id(cx));
let focused = editor
.focused(window, cx)
.filter(|(at, _)| *at == key)
.map(|(_, cell)| cell);
let colors = &look.colors;
let tool = |name: &'static str,
icon: IconName,
run: fn(&mut BlockEditor, u64, usize, &mut Window, &mut Context<BlockEditor>),
cell: usize,
cx: &mut Context<BlockEditor>| {
IconButton::new((id.clone(), format!("table-{key}-{name}")), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(name)
.on_click(cx.listener(move |editor, _, window, cx| run(editor, key, cell, window, cx)))
};
let tools = focused.map(|cell| {
div()
.flex()
.gap_0p5()
.child(tool(
"Add row",
IconName::Rows2,
|editor, key, _, window, cx| editor.add_row(key, window, cx),
cell,
cx,
))
.child(tool(
"Add column",
IconName::Columns2,
|editor, key, _, window, cx| editor.add_column(key, window, cx),
cell,
cx,
))
.child(tool(
"Remove row",
IconName::Minus,
|editor, key, cell, _, cx| editor.remove_line(key, cell, false, cx),
cell,
cx,
))
.child(tool(
"Remove column",
IconName::X,
|editor, key, cell, _, cx| editor.remove_line(key, cell, true, cx),
cell,
cx,
))
.child(tool(
"Merge or part cells",
IconName::Merge,
|editor, key, cell, _, cx| editor.merge_right(key, cell, cx),
cell,
cx,
))
});
let mut grid = div()
.flex()
.flex_col()
.rounded(look.radius)
.border_1()
.border_color(colors.border);
for row in 0..rows {
let mut line = div().flex().when(row > 0, |line| {
line.border_t_1().border_color(colors.border)
});
if row == 0 {
line = line.bg(colors.hover).font_weight(FontWeight::MEDIUM);
}
for column in 0..columns {
if merged.contains(&(row, column)) {
continue;
}
let cell = row * columns + column;
let wide = span(&merged, columns, row, column);
line = line.child(
div()
.flex_grow()
.flex_basis(gpui::relative(wide as f32 / columns as f32))
.min_w_0()
.px_2()
.py_1()
.when(column > 0, |cell| {
cell.border_l_1().border_color(colors.border)
})
.child(editor.field_view(block, cell, window, cx)),
);
}
grid = grid.child(line);
}
div()
.id((id.clone(), format!("table-{key}")))
.flex()
.flex_col()
.gap_1()
.children(tools)
.child(grid)
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_merged_cell_spans_what_folds_into_it() {
let merged = [(1, 1), (1, 2)];
assert_eq!(span(&merged, 4, 1, 0), 3);
assert_eq!(span(&merged, 4, 0, 0), 1);
assert_eq!(span(&merged, 4, 1, 3), 1);
}
#[test]
fn removing_a_line_moves_the_merges_past_it() {
let merged = [(0, 1), (1, 3), (2, 2)];
assert_eq!(
without_column(&merged, 1),
[(1, 2)],
"a cell folded into the removed column parts"
);
assert_eq!(without_row(&merged, 1), [(0, 1), (1, 2)]);
}
}
+288
View File
@@ -0,0 +1,288 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, Rems, Render, SharedString, Styled, Window, div, prelude::*,
};
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::suggest::{Offer, offers, replace_trigger},
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
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 ix = self.index(key);
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.on_enter(key, ix, window, cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
if 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| 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()
}
/// 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 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 end = editor.blocks[editor.index(key)]
.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()
.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 = cx.entity();
div()
.id(id.clone())
.w_full()
.flex()
.flex_col()
.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, "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,
);
});
}),
)
}
}