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:
46 files changed
+5635
-193
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::markdown::{reading_minutes, words};
|
||||
use crate::{
|
||||
theme::ActiveTheme,
|
||||
typography::{format::plural, tabular},
|
||||
};
|
||||
|
||||
/// Share of a limit past which the count warns.
|
||||
const NEAR: f32 = 0.9;
|
||||
|
||||
/// Words and characters in a text, as one quiet line; against a limit, characters warn near it and turn to danger past it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WordCount {
|
||||
text: SharedString,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl WordCount {
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts characters against `limit`.
|
||||
pub fn limit(mut self, limit: usize) -> Self {
|
||||
assert!(limit > 0, "a character limit of zero allows nothing");
|
||||
self.limit = Some(limit);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WordCount {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let characters = self.text.graphemes(true).count();
|
||||
let words = plural(words(&self.text) as u64, "word", "words");
|
||||
let (characters, color) = match self.limit {
|
||||
Some(limit) => (
|
||||
format!("{characters}/{limit}"),
|
||||
if characters > limit {
|
||||
colors.danger
|
||||
} else if characters as f32 >= limit as f32 * NEAR {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
},
|
||||
),
|
||||
None => (
|
||||
plural(characters as u64, "character", "characters"),
|
||||
colors.fg_subtle,
|
||||
),
|
||||
};
|
||||
tabular(div().flex().gap_1().text_color(colors.fg_subtle))
|
||||
.child(words)
|
||||
.child("·")
|
||||
.child(div().text_color(color).child(characters))
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a text takes to read, at an adult's pace.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReadingTime {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl ReadingTime {
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReadingTime {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let minutes = reading_minutes(words(&self.text));
|
||||
tabular(div().text_color(cx.theme().colors.fg_subtle)).child(if minutes == 0 {
|
||||
"Nothing to read yet".to_string()
|
||||
} else {
|
||||
format!("{minutes} min read")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Entity};
|
||||
|
||||
use super::markdown::{Mark, marks};
|
||||
use crate::forms::TextInput;
|
||||
|
||||
/// A format the toolbars apply to markdown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
Bullet,
|
||||
Numbered,
|
||||
Task,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The marker an inline format wraps its text in.
|
||||
fn marker(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Bold => Some("**"),
|
||||
Self::Italic => Some("*"),
|
||||
Self::Strike => Some("~~"),
|
||||
Self::Code => Some("`"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix a line format starts its lines with; numbered lines count up from one.
|
||||
fn prefix(self, nth: usize) -> String {
|
||||
match self {
|
||||
Self::Heading(level) => format!("{} ", "#".repeat(level.clamp(1, 6) as usize)),
|
||||
Self::Quote => "> ".into(),
|
||||
Self::Bullet => "- ".into(),
|
||||
Self::Numbered => format!("{}. ", nth + 1),
|
||||
Self::Task => "- [ ] ".into(),
|
||||
_ => unreachable!("an inline format has no prefix"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered line's prefix, such as `12. `, in bytes; none for other lines.
|
||||
fn number_prefix(line: &str) -> usize {
|
||||
let digits = line.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits > 0 && line[digits..].starts_with(". ") {
|
||||
digits + 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `line` already starts as `format` writes it.
|
||||
fn has(format: Format, line: &str) -> bool {
|
||||
match format {
|
||||
Format::Task => line.starts_with("- [ ] ") || line.starts_with("- [x] "),
|
||||
Format::Bullet => {
|
||||
(line.starts_with("- ") || line.starts_with("* ")) && !has(Format::Task, line)
|
||||
}
|
||||
Format::Numbered => number_prefix(line) > 0,
|
||||
_ => line.starts_with(&format.prefix(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's own prefix: heading hashes, quote, bullet, number or task box, with its space.
|
||||
fn line_prefix(line: &str) -> usize {
|
||||
let hashes = line.chars().take_while(|ch| *ch == '#').count();
|
||||
if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
|
||||
return hashes + 1;
|
||||
}
|
||||
for mark in ["- [ ] ", "- [x] ", "> ", "- ", "* "] {
|
||||
if line.starts_with(mark) {
|
||||
return mark.len();
|
||||
}
|
||||
}
|
||||
number_prefix(line)
|
||||
}
|
||||
|
||||
/// `text` with `format` applied over `selection`, and where the selection lands. An inline format wraps the selection, or unwraps it when already wrapped; an empty one leaves the caret between its markers. A line format prefixes each touched line, or takes the prefix off when every line has it.
|
||||
pub fn apply(text: &str, selection: Range<usize>, format: Format) -> (String, Range<usize>) {
|
||||
assert!(
|
||||
selection.start <= selection.end && selection.end <= text.len(),
|
||||
"the selection {selection:?} lies outside {} bytes",
|
||||
text.len()
|
||||
);
|
||||
if format == Format::Link {
|
||||
let label = &text[selection.clone()];
|
||||
let linked = format!("{}[{label}]()", &text[..selection.start]);
|
||||
let caret = linked.len() - 1;
|
||||
return (format!("{linked}{}", &text[selection.end..]), caret..caret);
|
||||
}
|
||||
if let Some(marker) = format.marker() {
|
||||
let (before, after) = (&text[..selection.start], &text[selection.end..]);
|
||||
let inner = &text[selection.clone()];
|
||||
if before.ends_with(marker) && after.starts_with(marker) {
|
||||
let start = selection.start - marker.len();
|
||||
let unwrapped = format!("{}{inner}{}", &before[..start], &after[marker.len()..]);
|
||||
return (unwrapped, start..selection.end - marker.len());
|
||||
}
|
||||
let shift = marker.len();
|
||||
let wrapped = format!("{before}{marker}{inner}{marker}{after}");
|
||||
return (wrapped, selection.start + shift..selection.end + shift);
|
||||
}
|
||||
let first = text[..selection.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let last = text[selection.end..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| selection.end + at);
|
||||
let lines: Vec<&str> = text[first..last].split('\n').collect();
|
||||
let every = lines.iter().all(|line| has(format, line));
|
||||
let changed: Vec<String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(nth, line)| {
|
||||
let bare = &line[line_prefix(line)..];
|
||||
if every {
|
||||
bare.to_string()
|
||||
} else {
|
||||
format!("{}{bare}", format.prefix(nth))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let block = changed.join("\n");
|
||||
let out = format!("{}{block}{}", &text[..first], &text[last..]);
|
||||
(out, first..first + block.len())
|
||||
}
|
||||
|
||||
/// The one stretch where `new` differs from `old`: the range it replaces and what replaces it.
|
||||
fn changed<'a>(old: &str, new: &'a str) -> (Range<usize>, &'a str) {
|
||||
let head: usize = old
|
||||
.chars()
|
||||
.zip(new.chars())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
let tail: usize = old[head..]
|
||||
.chars()
|
||||
.rev()
|
||||
.zip(new[head..].chars().rev())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
(head..old.len() - tail, &new[head..new.len() - tail])
|
||||
}
|
||||
|
||||
/// Applies `format` to the field's selection as one undo step, then selects what it formatted.
|
||||
pub fn format(field: &Entity<TextInput>, format: Format, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
let text = input.text().to_string();
|
||||
let (next, selection) = apply(&text, input.selection(), format);
|
||||
let (range, inserted) = changed(&text, &next);
|
||||
log::info!("format {format:?}: {range:?} becomes {inserted:?}");
|
||||
input.select(range, cx);
|
||||
input.insert(inserted, cx);
|
||||
input.select(selection, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The formats that hold all of `selection`, for a toolbar to show pressed.
|
||||
pub fn active(text: &str, selection: Range<usize>) -> Vec<Format> {
|
||||
let mut on: Vec<Format> = marks(text)
|
||||
.into_iter()
|
||||
.filter(|(range, _)| range.start <= selection.start && selection.end <= range.end)
|
||||
.filter_map(|(_, mark)| match mark {
|
||||
Mark::Strong => Some(Format::Bold),
|
||||
Mark::Emphasis => Some(Format::Italic),
|
||||
Mark::Strike => Some(Format::Strike),
|
||||
Mark::Code => Some(Format::Code),
|
||||
Mark::Link => Some(Format::Link),
|
||||
Mark::Heading(level) => Some(Format::Heading(level)),
|
||||
Mark::Quote => Some(Format::Quote),
|
||||
Mark::Syntax => None,
|
||||
})
|
||||
.collect();
|
||||
let line = &text[text[..selection.start].rfind('\n').map_or(0, |at| at + 1)..];
|
||||
on.extend(
|
||||
[Format::Task, Format::Bullet, Format::Numbered]
|
||||
.into_iter()
|
||||
.find(|list| has(*list, line)),
|
||||
);
|
||||
on
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inline_formats_wrap_and_unwrap() {
|
||||
let (bold, selection) = apply("a lift b", 2..6, Format::Bold);
|
||||
assert_eq!((bold.as_str(), selection.clone()), ("a **lift** b", 4..8));
|
||||
assert_eq!(
|
||||
apply(&bold, selection, Format::Bold),
|
||||
("a lift b".into(), 2..6)
|
||||
);
|
||||
assert_eq!(apply("ab", 1..1, Format::Code), ("a``b".into(), 2..2));
|
||||
assert_eq!(
|
||||
apply("see docs", 4..8, Format::Link),
|
||||
("see [docs]()".into(), 11..11)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_formats_prefix_every_touched_line_or_take_it_off() {
|
||||
let text = "one\ntwo\nthree";
|
||||
let (listed, selection) = apply(text, 1..6, Format::Numbered);
|
||||
assert_eq!(listed, "1. one\n2. two\nthree");
|
||||
assert_eq!(apply(&listed, selection, Format::Numbered).0, text);
|
||||
assert_eq!(apply("## Old", 3..3, Format::Heading(1)).0, "# Old");
|
||||
assert_eq!(apply("- item", 2..2, Format::Task).0, "- [ ] item");
|
||||
assert_eq!(apply("- [x] done", 2..2, Format::Task).0, "done");
|
||||
assert_eq!(apply("- [ ] task", 2..2, Format::Bullet).0, "- task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_is_the_stretch_between_what_stayed() {
|
||||
assert_eq!(changed("a lift b", "a **lift** b"), (2..6, "**lift**"));
|
||||
assert_eq!(changed("same", "same"), (4..4, ""));
|
||||
assert_eq!(changed("héllo", "hello"), (1..3, "e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressed_formats_hold_the_selection() {
|
||||
let text = "- [ ] a **bold** word";
|
||||
assert_eq!(active(text, 10..12), [Format::Bold, Format::Task]);
|
||||
assert_eq!(active("## Lift", 4..4), [Format::Heading(2)]);
|
||||
assert_eq!(active("12. twelve", 5..5), [Format::Numbered]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::markdown::link_at;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Enter, Input, Run, TextInput, float, is_url, surface},
|
||||
primitives::{FocusScope, IconName, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
|
||||
/// What the editor edits: a link's range and words, or the selection that becomes one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Linking {
|
||||
pub range: Range<usize>,
|
||||
pub label: String,
|
||||
/// The address, when the range is already a link.
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
impl Linking {
|
||||
/// The link around the selection, or the selection itself.
|
||||
pub fn at(text: &str, selection: Range<usize>) -> Self {
|
||||
match link_at(text, selection.start) {
|
||||
Some((range, label, address)) if selection.end <= range.end => Self {
|
||||
range,
|
||||
label,
|
||||
address: Some(address),
|
||||
},
|
||||
_ => Self {
|
||||
label: text[selection.clone()].to_string(),
|
||||
range: selection,
|
||||
address: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The link written in markdown for `address`; with no words of its own it reads as its address.
|
||||
pub fn written(&self, address: &str) -> String {
|
||||
let label = if self.label.is_empty() {
|
||||
address
|
||||
} else {
|
||||
&self.label
|
||||
};
|
||||
format!("[{label}]({address})")
|
||||
}
|
||||
}
|
||||
|
||||
/// The address field and whether it took focus yet.
|
||||
struct Editing {
|
||||
address: Entity<TextInput>,
|
||||
seeded: bool,
|
||||
}
|
||||
|
||||
/// Replaces `range` of the field's text with `text` as one undo step, and closes.
|
||||
fn write(field: &Entity<TextInput>, range: Range<usize>, text: &str, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(range, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Edits the link at the caret, or links the selection, in a panel under it: the address is checked as it is typed and applied with Enter; the link can be removed or opened from the same place. Escape or a press outside closes it, and focus returns to the text. Render it only while open.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LinkEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl LinkEditor {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for LinkEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
let linking = Linking::at(input.text(), input.selection());
|
||||
let anchor = input.bounds_for(linking.range.start);
|
||||
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
||||
let editing = window.use_keyed_state(
|
||||
(self.id.clone(), "editing"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Editing>| Editing {
|
||||
address: cx.new(|cx| TextInput::new(window, cx).placeholder("https://")),
|
||||
seeded: false,
|
||||
},
|
||||
);
|
||||
let address = editing.read(cx).address.clone();
|
||||
if !editing.read(cx).seeded {
|
||||
log::info!("link editor: open over {:?}", linking.range);
|
||||
let written = linking.address.clone().unwrap_or_default();
|
||||
address.update(cx, |field, cx| field.set_text(written, cx));
|
||||
window.focus(&address.focus_handle(cx));
|
||||
editing.update(cx, |editing, _| editing.seeded = true);
|
||||
}
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let close: Run = {
|
||||
let (takeover, on_close) = (takeover.clone(), self.on_close.clone());
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("link editor: closed");
|
||||
give_back(&takeover, window, cx);
|
||||
on_close(window, cx);
|
||||
})
|
||||
};
|
||||
let typed = address.read(cx).text().trim().to_string();
|
||||
let valid = is_url(&typed);
|
||||
let apply: Run = {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
let typed = typed.clone();
|
||||
Rc::new(move |window, cx| {
|
||||
if !is_url(&typed) {
|
||||
return;
|
||||
}
|
||||
log::info!("link editor: {typed} over {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.written(&typed), cx);
|
||||
close(window, cx);
|
||||
})
|
||||
};
|
||||
let remove: Option<Run> = linking.address.as_ref().map(|_| {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
log::info!("link editor: unlinked {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.label, cx);
|
||||
close(window, cx);
|
||||
}) as Run
|
||||
});
|
||||
let Some(anchor) = anchor else {
|
||||
return div().id(self.id);
|
||||
};
|
||||
let (enter, escape, out) = (apply.clone(), close.clone(), close.clone());
|
||||
let press = apply.clone();
|
||||
let opened = linking.address.clone();
|
||||
let panel = div()
|
||||
.id((self.id.clone(), "panel"))
|
||||
.occlude()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
enter(window, cx);
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_down_out(move |_, window, cx| out(window, cx))
|
||||
.child(
|
||||
FocusScope::new(&focus).trap().child(
|
||||
surface((self.id.clone(), "surface"), cx)
|
||||
.p_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().w(cx.theme().menu_width()).child(
|
||||
Input::new(&address)
|
||||
.size(ControlSize::Sm)
|
||||
.invalid(!typed.is_empty() && !valid),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "apply"), "Apply")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!valid)
|
||||
.on_click(move |_, window, cx| press(window, cx)),
|
||||
)
|
||||
.when_some(opened, |row, url| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "open"), IconName::ExternalLink)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Open link")
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
)
|
||||
})
|
||||
.when_some(remove, |row, remove| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "remove"), IconName::Trash2)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Remove link")
|
||||
.on_click(move |_, window, cx| remove(window, cx)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
div().id(self.id).child(float(anchor, 1, panel, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Linking;
|
||||
|
||||
#[test]
|
||||
fn a_link_is_edited_whole_and_a_selection_becomes_one() {
|
||||
let text = "see [the docs](https://ely.dev) here";
|
||||
let inside = Linking::at(text, 8..8);
|
||||
assert_eq!(
|
||||
(inside.range, inside.address.as_deref()),
|
||||
(4..31, Some("https://ely.dev"))
|
||||
);
|
||||
let plain = Linking::at(text, 32..36);
|
||||
assert_eq!(
|
||||
(
|
||||
plain.range.clone(),
|
||||
plain.label.as_str(),
|
||||
plain.address.clone()
|
||||
),
|
||||
(32..36, "here", None)
|
||||
);
|
||||
assert_eq!(plain.written("https://x.dev"), "[here](https://x.dev)");
|
||||
let bare = Linking::at(text, 0..0);
|
||||
assert_eq!(
|
||||
bare.written("https://x.dev"),
|
||||
"[https://x.dev](https://x.dev)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, FontWeight};
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{forms::Highlight, theme::ActiveTheme};
|
||||
|
||||
/// The markdown this crate reads: GitHub's tables, strikethrough and task lists, footnotes, and math between dollars.
|
||||
pub(crate) fn options() -> Options {
|
||||
Options::ENABLE_TABLES
|
||||
| Options::ENABLE_STRIKETHROUGH
|
||||
| Options::ENABLE_TASKLISTS
|
||||
| Options::ENABLE_FOOTNOTES
|
||||
| Options::ENABLE_MATH
|
||||
}
|
||||
|
||||
/// How a stretch of markdown prints while it is edited.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
/// The characters that write the marks: asterisks, backticks, brackets, hashes.
|
||||
Syntax,
|
||||
}
|
||||
|
||||
/// The run of `ch` that starts `text`, in bytes.
|
||||
fn leading(text: &str, ch: char) -> usize {
|
||||
text.chars()
|
||||
.take_while(|found| *found == ch)
|
||||
.map(char::len_utf8)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each.
|
||||
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
|
||||
let mut out = Vec::new();
|
||||
let mut links: Vec<(Range<usize>, Option<Range<usize>>)> = Vec::new();
|
||||
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
|
||||
let span = &text[range.clone()];
|
||||
match event {
|
||||
Event::Start(Tag::Strong | Tag::Emphasis | Tag::Strikethrough) => {
|
||||
let mark = match event {
|
||||
Event::Start(Tag::Strong) => Mark::Strong,
|
||||
Event::Start(Tag::Emphasis) => Mark::Emphasis,
|
||||
_ => Mark::Strike,
|
||||
};
|
||||
let first = span.chars().next().expect("a styled span has its marker");
|
||||
let edge = leading(span, first).min(span.len() / 2);
|
||||
let edge = match mark {
|
||||
Mark::Emphasis => edge.min(first.len_utf8()),
|
||||
_ => edge,
|
||||
};
|
||||
out.push((range.clone(), mark));
|
||||
out.push((range.start..range.start + edge, Mark::Syntax));
|
||||
out.push((range.end - edge..range.end, Mark::Syntax));
|
||||
}
|
||||
Event::Code(_) => {
|
||||
let ticks = leading(span, '`');
|
||||
out.push((range.clone(), Mark::Code));
|
||||
out.push((range.start..range.start + ticks, Mark::Syntax));
|
||||
out.push((range.end - ticks..range.end, Mark::Syntax));
|
||||
}
|
||||
Event::Start(Tag::Link { .. }) => links.push((range, None)),
|
||||
Event::Text(_) if !links.is_empty() => {
|
||||
let (_, inner) = links.last_mut().expect("inside a link");
|
||||
*inner = Some(match inner.take() {
|
||||
Some(inner) => inner.start..range.end,
|
||||
None => range,
|
||||
});
|
||||
}
|
||||
Event::End(TagEnd::Link) => {
|
||||
let (whole, inner) = links.pop().expect("a link ends after it starts");
|
||||
out.push((whole.clone(), Mark::Link));
|
||||
if let Some(inner) = inner {
|
||||
out.push((whole.start..inner.start, Mark::Syntax));
|
||||
out.push((inner.end..whole.end, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
let hashes = leading(span, '#');
|
||||
out.push((range.clone(), Mark::Heading(level as u8)));
|
||||
if hashes > 0 {
|
||||
let spaced = hashes + leading(&span[hashes..], ' ');
|
||||
out.push((range.start..range.start + spaced, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => {
|
||||
out.push((range.clone(), Mark::Quote));
|
||||
let mut at = range.start;
|
||||
for line in span.split_inclusive('\n') {
|
||||
let marker = line.len() - line.trim_start_matches(['>', ' ']).len();
|
||||
out.push((at..at + marker, Mark::Syntax));
|
||||
at += line.len();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.retain(|(range, _)| !range.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// Markdown styled as it is typed: strong and headings heavier, emphasis slanted, strikes struck, code washed, links in their tone; the syntax that writes them recedes.
|
||||
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let plain = Highlight::new(colors.fg);
|
||||
marks(text)
|
||||
.into_iter()
|
||||
.map(|(range, mark)| {
|
||||
let range = match mark {
|
||||
Mark::Strike => {
|
||||
let span = &text[range.clone()];
|
||||
let edge = span.len() - span.trim_start_matches('~').len();
|
||||
range.start + edge..range.end - edge
|
||||
}
|
||||
_ => range,
|
||||
};
|
||||
let highlight = match mark {
|
||||
Mark::Strong | Mark::Heading(_) => Highlight {
|
||||
weight: Some(FontWeight::SEMIBOLD),
|
||||
..plain
|
||||
},
|
||||
Mark::Emphasis => Highlight {
|
||||
italic: true,
|
||||
..plain
|
||||
},
|
||||
Mark::Strike => Highlight {
|
||||
strike: true,
|
||||
..Highlight::new(colors.fg_muted)
|
||||
},
|
||||
Mark::Code => Highlight {
|
||||
background: Some(colors.sunken),
|
||||
..plain
|
||||
},
|
||||
Mark::Link => Highlight::new(colors.link),
|
||||
Mark::Quote => Highlight::new(colors.fg_muted),
|
||||
Mark::Syntax => Highlight::new(colors.fg_subtle),
|
||||
};
|
||||
(range, highlight)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A link written `[text](address)` around byte `at`: its range, its text and its address.
|
||||
pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> {
|
||||
Parser::new_ext(text, options())
|
||||
.into_offset_iter()
|
||||
.find_map(|(event, range)| match event {
|
||||
Event::Start(Tag::Link { dest_url, .. })
|
||||
if range.start <= at && at <= range.end && text[range.clone()].starts_with('[') =>
|
||||
{
|
||||
let written = &text[range.clone()];
|
||||
let label = &written[1..written.rfind("](")?];
|
||||
Some((range, label.to_string(), dest_url.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document's top-level blocks, as ranges of its source.
|
||||
pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
|
||||
let mut depth = 0usize;
|
||||
let mut out = Vec::new();
|
||||
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(_) => {
|
||||
if depth == 0 {
|
||||
out.push(range);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => depth -= 1,
|
||||
Event::Rule if depth == 0 => out.push(range),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|range| {
|
||||
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
||||
range.start..range.start + kept
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Words in `text` as a reader counts them; each ideograph counts as one.
|
||||
pub fn words(text: &str) -> usize {
|
||||
text.unicode_words().count()
|
||||
}
|
||||
|
||||
/// Words an adult reads in a minute, on average.
|
||||
const PACE: usize = 238;
|
||||
|
||||
/// Minutes to read `words`, at least one when there are any.
|
||||
pub fn reading_minutes(words: usize) -> usize {
|
||||
words.div_ceil(PACE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn found(text: &str) -> Vec<(&str, Mark)> {
|
||||
marks(text)
|
||||
.into_iter()
|
||||
.map(|(range, mark)| (&text[range], mark))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_carry_their_markers_as_syntax() {
|
||||
use Mark::*;
|
||||
assert_eq!(
|
||||
found("a **bold** and *it* ~~no~~"),
|
||||
[
|
||||
("**bold**", Strong),
|
||||
("**", Syntax),
|
||||
("**", Syntax),
|
||||
("*it*", Emphasis),
|
||||
("*", Syntax),
|
||||
("*", Syntax),
|
||||
("~~no~~", Strike),
|
||||
("~~", Syntax),
|
||||
("~~", Syntax),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_links_and_headings_mark_their_syntax() {
|
||||
use Mark::*;
|
||||
assert_eq!(found("`x`"), [("`x`", Code), ("`", Syntax), ("`", Syntax)]);
|
||||
assert_eq!(
|
||||
found("see [the docs](https://ely.dev)"),
|
||||
[
|
||||
("[the docs](https://ely.dev)", Link),
|
||||
("[", Syntax),
|
||||
("](https://ely.dev)", Syntax)
|
||||
]
|
||||
);
|
||||
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_gives_its_range_text_and_address() {
|
||||
let text = "see [the docs](https://ely.dev) or <https://x.dev>";
|
||||
assert_eq!(
|
||||
link_at(text, 6),
|
||||
Some((4..31, "the docs".to_string(), "https://ely.dev".to_string()))
|
||||
);
|
||||
assert_eq!(link_at(text, 1), None);
|
||||
assert_eq!(link_at(text, 40), None, "an autolink has no text to edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_are_the_top_level_stretches() {
|
||||
let text = "# Lift\n\nBlend **it**.\n\n- a\n- b\n\n---\n";
|
||||
let blocks: Vec<&str> = block_ranges(text)
|
||||
.into_iter()
|
||||
.map(|range| &text[range])
|
||||
.collect();
|
||||
assert_eq!(blocks, ["# Lift", "Blend **it**.", "- a\n- b", "---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_takes_a_minute_per_pace() {
|
||||
assert_eq!(words("Blend toward white, by lift."), 5);
|
||||
assert_eq!(words("你好世界"), 4);
|
||||
assert_eq!(
|
||||
(reading_minutes(0), reading_minutes(1), reading_minutes(239)),
|
||||
(0, 1, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
mod blocks;
|
||||
mod counts;
|
||||
mod format;
|
||||
mod link;
|
||||
mod markdown;
|
||||
mod modes;
|
||||
mod render;
|
||||
mod rich;
|
||||
mod suggest;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod todo;
|
||||
mod toolbar;
|
||||
mod tree;
|
||||
|
||||
use gpui::App;
|
||||
|
||||
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
pub use format::{Format, format};
|
||||
pub use link::LinkEditor;
|
||||
pub use markdown::markdown_highlights;
|
||||
pub use modes::{MarkdownEditor, MarkdownMode};
|
||||
pub use render::MarkdownRenderer;
|
||||
pub use rich::RichTextEditor;
|
||||
pub use todo::{Checklist, TodoItem};
|
||||
pub use toolbar::FixedFormatToolbar;
|
||||
|
||||
/// Binds the documents keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
rich::bind_keys(cx);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, ElementId, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Styled, Subscription,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
counts::{ReadingTime, WordCount},
|
||||
markdown::{block_ranges, markdown_highlights},
|
||||
render::MarkdownRenderer,
|
||||
rich::RichTextEditor,
|
||||
suggest::offers,
|
||||
toolbar::FixedFormatToolbar,
|
||||
};
|
||||
use crate::{
|
||||
buttons::SegmentedControl,
|
||||
forms::{Down, InputEvent, TextInput, Up},
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// How a markdown editor shows its text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MarkdownMode {
|
||||
/// The document as it reads; a pressed block opens as its source.
|
||||
Visual,
|
||||
/// The markdown, styled as it is typed.
|
||||
Source,
|
||||
/// The source beside the document it makes.
|
||||
Split,
|
||||
}
|
||||
|
||||
impl MarkdownMode {
|
||||
const ALL: [(Self, &'static str); 3] = [
|
||||
(Self::Visual, "Visual"),
|
||||
(Self::Source, "Source"),
|
||||
(Self::Split, "Split"),
|
||||
];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|(mode, _)| *mode == self)
|
||||
.map(|(_, name)| *name)
|
||||
.expect("every mode is named")
|
||||
}
|
||||
}
|
||||
|
||||
type OnMode = Rc<dyn Fn(MarkdownMode, &mut Window, &mut App)>;
|
||||
|
||||
/// The block open as its source: where it came from, its own field, and whether it adds a block at the end.
|
||||
struct Open {
|
||||
range: Range<usize>,
|
||||
input: Entity<TextInput>,
|
||||
append: bool,
|
||||
_blur: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Visual {
|
||||
open: Option<Open>,
|
||||
}
|
||||
|
||||
/// `range` moved by an edit that wrote `written` bytes over `replaced`, when it lies after it.
|
||||
fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Range<usize> {
|
||||
if range.start < replaced.end {
|
||||
return range;
|
||||
}
|
||||
let (start, end) = (
|
||||
range.start - replaced.len() + written,
|
||||
range.end - replaced.len() + written,
|
||||
);
|
||||
start..end
|
||||
}
|
||||
|
||||
/// What joins a block added at the end to the text before it.
|
||||
fn joint(document: &str) -> &'static str {
|
||||
if document.is_empty() || document.ends_with("\n\n") {
|
||||
""
|
||||
} else if document.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
"\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the open block back over its range as one undo step, and closes it; the range replaced and the bytes written.
|
||||
fn commit(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
cx: &mut App,
|
||||
) -> Option<(Range<usize>, usize)> {
|
||||
let open = state.update(cx, |visual, cx| {
|
||||
cx.notify();
|
||||
visual.open.take()
|
||||
})?;
|
||||
let written = open.input.read(cx).text().to_string();
|
||||
let document = field.read(cx).text().to_string();
|
||||
let written = match open.append {
|
||||
true if written.is_empty() => return Some((open.range, 0)),
|
||||
true => format!("{}{written}", joint(&document)),
|
||||
false => written,
|
||||
};
|
||||
if document[open.range.clone()] != written {
|
||||
log::info!("markdown editor: block {:?} written back", open.range);
|
||||
field.update(cx, |document, cx| {
|
||||
document.select(open.range.clone(), cx);
|
||||
document.insert(&written, cx);
|
||||
});
|
||||
}
|
||||
Some((open.range, written.len()))
|
||||
}
|
||||
|
||||
/// Opens the block at `range` in a field of its own, so undo stays with it; the caret at its start or end.
|
||||
fn open(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
range: Range<usize>,
|
||||
append: bool,
|
||||
at_start: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let source = field.read(cx).text()[range.clone()].to_string();
|
||||
log::info!("markdown editor: block {range:?} opens");
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx));
|
||||
let blur = {
|
||||
let (state, field) = (state.clone(), field.clone());
|
||||
cx.subscribe(&input, move |_, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Blur {
|
||||
commit(&state, &field, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
state.update(cx, |visual, cx| {
|
||||
visual.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
_blur: blur,
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the caret sits on the field's first line, or with `last` its last.
|
||||
pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool {
|
||||
let edge = if last { input.text().len() } else { 0 };
|
||||
match (input.bounds_for(input.cursor()), input.bounds_for(edge)) {
|
||||
(Some(caret), Some(edge)) => caret.top() == edge.top(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown written three ways: visual, where the document reads as it will and a pressed block opens as its source; source, styled as it is typed; and split, the source beside the document. The toolbar, links, suggestions and counts come with each.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
mode: MarkdownMode,
|
||||
people: Vec<SharedString>,
|
||||
on_mode: Option<OnMode>,
|
||||
}
|
||||
|
||||
impl MarkdownEditor {
|
||||
/// Give the field several lines and `markdown_highlights` as its highlighter.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>, mode: MarkdownMode) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
mode,
|
||||
people: Vec::new(),
|
||||
on_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub fn on_mode(
|
||||
mut self,
|
||||
handler: impl Fn(MarkdownMode, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_mode = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state =
|
||||
window.use_keyed_state((self.id.clone(), "visual"), cx, |_, _| Visual::default());
|
||||
let switch = {
|
||||
let (state, field, on_mode) = (state.clone(), self.field.clone(), self.on_mode.clone());
|
||||
MarkdownMode::ALL
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new((self.id.clone(), "mode"), self.mode.name())
|
||||
.size(ControlSize::Sm),
|
||||
|switch, (_, name)| switch.segment(*name, *name, None),
|
||||
)
|
||||
.on_change(move |name, window, cx| {
|
||||
let (mode, _) = MarkdownMode::ALL
|
||||
.into_iter()
|
||||
.find(|(_, named)| *named == name.as_ref())
|
||||
.expect("a segment names a mode");
|
||||
commit(&state, &field, cx);
|
||||
log::info!("markdown editor: {name}");
|
||||
if let Some(on_mode) = &on_mode {
|
||||
on_mode(mode, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
match self.mode {
|
||||
MarkdownMode::Source => RichTextEditor::new(self.id, &self.field)
|
||||
.people(self.people)
|
||||
.toolbar_end(switch)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Split => {
|
||||
let text = SharedString::from(self.field.read(cx).text().to_string());
|
||||
RichTextEditor::new(self.id.clone(), &self.field)
|
||||
.people(self.people)
|
||||
.toolbar_end(switch)
|
||||
.beside(MarkdownRenderer::new((self.id, "preview"), text))
|
||||
.into_any_element()
|
||||
}
|
||||
MarkdownMode::Visual => visual(self, state, switch, window, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The visual body: each block rendered, the open one as its source.
|
||||
fn visual(
|
||||
editor: MarkdownEditor,
|
||||
state: Entity<Visual>,
|
||||
switch: SegmentedControl,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let (colors, radius, frame, bar, tiny) = {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors.clone(),
|
||||
theme.radius(Radius::Md),
|
||||
theme.radius(Radius::Lg),
|
||||
theme.control_height(ControlSize::Lg),
|
||||
theme.text_size(TextSize::Xs),
|
||||
)
|
||||
};
|
||||
let document = editor.field.read(cx).text().to_string();
|
||||
let blocks = block_ranges(&document);
|
||||
let opened = state
|
||||
.read(cx)
|
||||
.open
|
||||
.as_ref()
|
||||
.map(|open| (open.range.clone(), open.input.clone(), open.append));
|
||||
let field = editor.field.clone();
|
||||
let source = |input: &Entity<TextInput>, window: &mut Window, cx: &mut App| {
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
offers(
|
||||
(editor.id.clone(), "offers").into(),
|
||||
input,
|
||||
&editor.people,
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.wrap(input.clone(), window, cx),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
for (ix, range) in blocks.iter().enumerate() {
|
||||
if let Some((open_range, input, false)) = &opened
|
||||
&& open_range == range
|
||||
{
|
||||
rows.push(source(input, window, cx));
|
||||
continue;
|
||||
}
|
||||
let (state, field, pressed) = (state.clone(), field.clone(), range.clone());
|
||||
rows.push(
|
||||
div()
|
||||
.id((editor.id.clone(), format!("block-{ix}")))
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.cursor_text()
|
||||
.hover(|block| block.bg(colors.hover.opacity(0.5)))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let range = match commit(&state, &field, cx) {
|
||||
Some((replaced, written)) => shifted(pressed.clone(), &replaced, written),
|
||||
None => pressed.clone(),
|
||||
};
|
||||
open(&state, &field, range, false, false, window, cx);
|
||||
})
|
||||
.child(MarkdownRenderer::new(
|
||||
(editor.id.clone(), format!("render-{ix}")),
|
||||
document[range.clone()].to_string(),
|
||||
))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
if let Some((_, input, true)) = &opened {
|
||||
rows.push(source(input, window, cx));
|
||||
}
|
||||
let (more_state, more_field) = (state.clone(), field.clone());
|
||||
let more = div()
|
||||
.id((editor.id.clone(), "more"))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.cursor_text()
|
||||
.text_color(colors.fg_subtle)
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
commit(&more_state, &more_field, cx);
|
||||
let end = more_field.read(cx).text().len();
|
||||
open(&more_state, &more_field, end..end, true, false, window, cx);
|
||||
})
|
||||
.when(document.is_empty() && opened.is_none(), |more| {
|
||||
more.child("Start writing")
|
||||
})
|
||||
.when(!document.is_empty(), |more| more.min_h_8());
|
||||
let open_input = opened.as_ref().map(|(_, input, _)| input.clone());
|
||||
let edges = |last: bool| {
|
||||
let (state, field) = (state.clone(), field.clone());
|
||||
move |window: &mut Window, cx: &mut App| -> bool {
|
||||
let Some((range, input)) = state
|
||||
.read(cx)
|
||||
.open
|
||||
.as_ref()
|
||||
.map(|open| (open.range.clone(), open.input.clone()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !at_edge(input.read(cx), last) {
|
||||
return false;
|
||||
}
|
||||
let blocks = block_ranges(field.read(cx).text());
|
||||
let target = if last {
|
||||
blocks.into_iter().find(|block| block.start >= range.end)
|
||||
} else {
|
||||
blocks
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|block| block.end <= range.start)
|
||||
};
|
||||
let Some(target) = target else { return false };
|
||||
let target = match commit(&state, &field, cx) {
|
||||
Some((replaced, written)) => shifted(target, &replaced, written),
|
||||
None => target,
|
||||
};
|
||||
open(&state, &field, target, false, last, window, cx);
|
||||
true
|
||||
}
|
||||
};
|
||||
let (up, down) = (edges(false), edges(true));
|
||||
let (escape_state, escape_field) = (state.clone(), field.clone());
|
||||
let text = SharedString::from(document);
|
||||
div()
|
||||
.id(editor.id.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(frame)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(bar)
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div().children(open_input.map(|input| {
|
||||
FixedFormatToolbar::new((editor.id.clone(), "tools"), &input)
|
||||
})),
|
||||
)
|
||||
.child(switch),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id((editor.id.clone(), "blocks"))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.capture_action(move |_: &Up, window, cx| {
|
||||
if up(window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, window, cx| {
|
||||
if down(window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if event.keystroke.key == "escape" && escape_state.read(cx).open.is_some() {
|
||||
cx.stop_propagation();
|
||||
commit(&escape_state, &escape_field, cx);
|
||||
}
|
||||
})
|
||||
.children(rows)
|
||||
.child(more),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(tiny)
|
||||
.child(WordCount::new(text.clone()))
|
||||
.child(ReadingTime::new(text)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{joint, shifted};
|
||||
|
||||
#[test]
|
||||
fn later_blocks_move_by_what_an_edit_wrote() {
|
||||
assert_eq!(shifted(10..14, &(2..6), 9), 15..19);
|
||||
assert_eq!(
|
||||
shifted(0..2, &(2..6), 9),
|
||||
0..2,
|
||||
"a block before the edit stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_at_the_end_starts_a_paragraph() {
|
||||
assert_eq!(
|
||||
(joint(""), joint("a"), joint("a\n"), joint("a\n\n")),
|
||||
("", "\n\n", "\n", "")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
|
||||
UnderlineStyle, Window, div, prelude::*,
|
||||
};
|
||||
use pulldown_cmark::Alignment;
|
||||
|
||||
use super::tree::{Inline, Node, Style, parse};
|
||||
use crate::{
|
||||
editor::code_colors,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::Latex,
|
||||
};
|
||||
|
||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
|
||||
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownRenderer {
|
||||
id: ElementId,
|
||||
source: SharedString,
|
||||
on_link: Option<OnLink>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
source: source.into(),
|
||||
on_link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes link presses instead of the browser.
|
||||
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_link = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders nodes with one set of links handlers and ids under one owner.
|
||||
struct Draw<'a> {
|
||||
id: &'a ElementId,
|
||||
on_link: &'a Option<OnLink>,
|
||||
next: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
fn key(&self, what: &str) -> ElementId {
|
||||
let at = self.next.get();
|
||||
self.next.set(at + 1);
|
||||
(self.id.clone(), format!("{what}-{at}")).into()
|
||||
}
|
||||
|
||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rule = theme.underline_thickness();
|
||||
let styles: Vec<_> = inline
|
||||
.styles
|
||||
.iter()
|
||||
.map(|(range, style)| {
|
||||
let highlight = match style {
|
||||
Style::Strong => HighlightStyle {
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Emphasis => HighlightStyle {
|
||||
font_style: Some(FontStyle::Italic),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Strike => HighlightStyle {
|
||||
strikethrough: Some(StrikethroughStyle {
|
||||
thickness: rule,
|
||||
color: Some(colors.fg_muted),
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Code | Style::Math => HighlightStyle {
|
||||
background_color: Some(colors.sunken),
|
||||
color: Some(if *style == Style::Math {
|
||||
colors.syntax.function
|
||||
} else {
|
||||
colors.fg
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Link => HighlightStyle {
|
||||
color: Some(colors.link),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: rule,
|
||||
color: Some(colors.link.opacity(0.4)),
|
||||
wavy: false,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Footnote => HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
};
|
||||
(range.clone(), highlight)
|
||||
})
|
||||
.collect();
|
||||
let styled =
|
||||
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||
if inline.links.is_empty() {
|
||||
return styled.into_any_element();
|
||||
}
|
||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
||||
let on_link = self.on_link.clone();
|
||||
InteractiveText::new(self.key("links"), styled)
|
||||
.on_click(ranges, move |ix, window, cx| {
|
||||
let url = &urls[ix];
|
||||
log::info!("markdown: open {url}");
|
||||
match &on_link {
|
||||
Some(on_link) => on_link(url, window, cx),
|
||||
None => cx.open_url(url),
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||
nodes.iter().map(|node| self.node(node, cx)).collect()
|
||||
}
|
||||
|
||||
fn node(&self, node: &Node, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
match node {
|
||||
Node::Paragraph(inline) => div()
|
||||
.text_color(colors.fg)
|
||||
.child(self.inline(inline, cx))
|
||||
.into_any_element(),
|
||||
Node::Heading(level, inline) => {
|
||||
let size = match level {
|
||||
1 => TextSize::Xxl,
|
||||
2 => TextSize::Xl,
|
||||
3 => TextSize::Lg,
|
||||
_ => TextSize::Md,
|
||||
};
|
||||
div()
|
||||
.pt_2()
|
||||
.text_size(theme.text_size(size))
|
||||
.font_weight(if *level <= 2 {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::MEDIUM
|
||||
})
|
||||
.text_color(colors.fg)
|
||||
.child(self.inline(inline, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::Quote(inner) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.pl_4()
|
||||
.border_l_2()
|
||||
.border_color(colors.border_strong)
|
||||
.text_color(colors.fg_muted)
|
||||
.children(self.nodes(inner, cx))
|
||||
.into_any_element(),
|
||||
Node::Code { language, text } => {
|
||||
let styles = code_colors(text, cx);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.syntax.variable)
|
||||
.children(language.clone().map(|name| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(name)
|
||||
}))
|
||||
.child(StyledText::new(text.clone()).with_highlights(styles))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::List { start, items } => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(items.iter().enumerate().map(|(ix, (task, body))| {
|
||||
let marker: AnyElement = match (task, start) {
|
||||
(Some(done), _) => Icon::new(if *done {
|
||||
IconName::SquareCheck
|
||||
} else {
|
||||
IconName::Square
|
||||
})
|
||||
.size(IconSize::Sm)
|
||||
.color(if *done {
|
||||
colors.focus
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
})
|
||||
.into_any_element(),
|
||||
(None, Some(first)) => div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(format!("{}.", first + ix as u64))
|
||||
.into_any_element(),
|
||||
(None, None) => div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child("•")
|
||||
.into_any_element(),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.min_w_5()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.child(marker),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.when(*task == Some(true), |body| {
|
||||
body.text_color(colors.fg_subtle)
|
||||
})
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
}))
|
||||
.into_any_element(),
|
||||
Node::Table { aligns, head, rows } => {
|
||||
let cell = |inline: &Inline, column: usize, header: bool, cx: &App| {
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.when(aligns.get(column) == Some(&Alignment::Right), |cell| {
|
||||
cell.justify_end()
|
||||
})
|
||||
.when(aligns.get(column) == Some(&Alignment::Center), |cell| {
|
||||
cell.justify_center()
|
||||
})
|
||||
.when(header, |cell| {
|
||||
cell.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_muted)
|
||||
})
|
||||
.child(self.inline(inline, cx))
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div().flex().bg(colors.hover).children(
|
||||
head.iter()
|
||||
.enumerate()
|
||||
.map(|(column, inline)| cell(inline, column, true, cx)),
|
||||
),
|
||||
)
|
||||
.children(rows.iter().map(|row| {
|
||||
div()
|
||||
.flex()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.children(
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(column, inline)| cell(inline, column, false, cx)),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::Rule => div()
|
||||
.my_2()
|
||||
.h_0()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.into_any_element(),
|
||||
Node::Math(tex) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(match Latex::parse(tex) {
|
||||
Ok(math) => math.into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
})
|
||||
.into_any_element(),
|
||||
Node::Image { alt, url } => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Image)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(div().flex_1().child(alt.clone()))
|
||||
.child(div().text_color(colors.fg_subtle).child(url.clone()))
|
||||
.into_any_element(),
|
||||
Node::Footnote { label, body } => div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(div().flex_none().child(format!("[{label}]")))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownRenderer {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let nodes = parse(&self.source);
|
||||
let draw = Draw {
|
||||
id: &self.id,
|
||||
on_link: &self.on_link,
|
||||
next: std::cell::Cell::new(0),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.line_height(theme.text_size(TextSize::Base) * 1.6)
|
||||
.children(draw.nodes(&nodes, cx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, KeyBinding, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, actions, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
counts::{ReadingTime, WordCount},
|
||||
format::{Format, active, format},
|
||||
link::LinkEditor,
|
||||
suggest::offers,
|
||||
toolbar::{FixedFormatToolbar, INLINE, tool},
|
||||
};
|
||||
use crate::{
|
||||
forms::{Run, TextInput},
|
||||
overlays::FloatingToolbar,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
actions!(ely_rich_text, [ToggleBold, ToggleItalic, EditLink]);
|
||||
|
||||
const CONTEXT: &str = "ElyRichText";
|
||||
|
||||
/// Binds the rich text keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-b", ToggleBold, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-i", ToggleItalic, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-k", EditLink, Some(CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Markdown that reads as it is written: styles show as they are typed, the toolbar above and the one over a selection apply them, a link opens its editor, `@` and `:` suggest people and emoji, and the foot counts words and minutes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RichTextEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
people: Vec<SharedString>,
|
||||
end: Option<AnyElement>,
|
||||
beside: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl RichTextEditor {
|
||||
/// Give the field several lines and `markdown_highlights` as its highlighter.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
people: Vec::new(),
|
||||
end: None,
|
||||
beside: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sits at the toolbar's far end, such as a switch of views.
|
||||
pub fn toolbar_end(mut self, element: impl IntoElement) -> Self {
|
||||
self.end = Some(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sits beside the text, as a preview does.
|
||||
pub fn beside(mut self, element: impl IntoElement) -> Self {
|
||||
self.beside = Some(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RichTextEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
assert!(
|
||||
input.is_multi_line(),
|
||||
"rich text {:?} needs a field of several lines",
|
||||
self.id
|
||||
);
|
||||
let text = SharedString::from(input.text().to_string());
|
||||
let pressed = active(input.text(), input.selection());
|
||||
let linking = window.use_keyed_state((self.id.clone(), "linking"), cx, |_, _| false);
|
||||
let set_linking = |open: bool| -> Run {
|
||||
let linking = linking.clone();
|
||||
Rc::new(move |_, cx| {
|
||||
log::info!(
|
||||
"rich text: link editor {}",
|
||||
if open { "opens" } else { "closes" }
|
||||
);
|
||||
linking.update(cx, |linking, cx| {
|
||||
*linking = open;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
};
|
||||
let (open_link, close_link) = (set_linking(true), set_linking(false));
|
||||
let float_id: ElementId = (self.id.clone(), "float").into();
|
||||
let floating = FloatingToolbar::new(float_id.clone(), &self.field).children(
|
||||
INLINE.iter().map(|tool_of| {
|
||||
let on = pressed.contains(&tool_of.0);
|
||||
tool(
|
||||
&float_id,
|
||||
&self.field,
|
||||
*tool_of,
|
||||
on,
|
||||
Some(open_link.clone()),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let suggestions = offers(
|
||||
(self.id.clone(), "offers").into(),
|
||||
&self.field,
|
||||
&self.people,
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.wrap(self.field.clone(), window, cx);
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (bold, italic, link) = (self.field.clone(), self.field.clone(), open_link.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(CONTEXT)
|
||||
.on_action(move |_: &ToggleBold, _, cx| format(&bold, Format::Bold, cx))
|
||||
.on_action(move |_: &ToggleItalic, _, cx| format(&italic, Format::Italic, cx))
|
||||
.on_action(move |_: &EditLink, window, cx| link(window, cx))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
FixedFormatToolbar::new((self.id.clone(), "tools"), &self.field)
|
||||
.on_link(move |window, cx| open_link(window, cx)),
|
||||
)
|
||||
.children(self.end),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().px_4().py_3().child(suggestions))
|
||||
.children(self.beside.map(|beside| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.child(beside)
|
||||
})),
|
||||
)
|
||||
.child(floating)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(WordCount::new(text.clone()))
|
||||
.child(ReadingTime::new(text)),
|
||||
)
|
||||
.when(*linking.read(cx), |editor| {
|
||||
editor.child(LinkEditor::new(
|
||||
(self.id.clone(), "link"),
|
||||
&self.field,
|
||||
move |window, cx| close_link(window, cx),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, Entity, SharedString, Window};
|
||||
|
||||
use crate::forms::{
|
||||
Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching,
|
||||
};
|
||||
|
||||
/// A trigger of the owner's: from the query and where it runs, the rows and what a pick does.
|
||||
pub(crate) type Offer<'a> = &'a dyn Fn(&str, usize, usize) -> (Vec<Choice>, Pick);
|
||||
|
||||
/// An emoji name needs this many letters before `:` suggests, so a colon in prose stays quiet.
|
||||
const EMOJI_QUERY: usize = 2;
|
||||
|
||||
/// Emoji shown for a `:` query.
|
||||
const EMOJI_SHOWN: usize = 6;
|
||||
|
||||
/// An offer of nothing: no rows, so no pick can come.
|
||||
fn nothing() -> (Vec<Choice>, Pick) {
|
||||
(
|
||||
Vec::new(),
|
||||
Rc::new(|_: usize, _: &mut Window, _: &mut App| unreachable!("an empty offer has no row")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
|
||||
pub(crate) fn replace_trigger(
|
||||
field: &Entity<TextInput>,
|
||||
at: usize,
|
||||
caret: usize,
|
||||
text: &str,
|
||||
cx: &mut App,
|
||||
) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(at..caret, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// What `@` and `:` offer at the field's caret: people by handle, emoji by name; `extra` adds a trigger of the owner's, such as `/` in blocks.
|
||||
pub(crate) fn offers(
|
||||
id: ElementId,
|
||||
field: &Entity<TextInput>,
|
||||
people: &[SharedString],
|
||||
extra: Option<(char, Offer)>,
|
||||
cx: &App,
|
||||
) -> Suggestions {
|
||||
let input = field.read(cx);
|
||||
let (text, caret) = (input.text().to_string(), input.cursor());
|
||||
let mut marks = vec!['@', ':'];
|
||||
marks.extend(extra.as_ref().map(|(mark, _)| *mark));
|
||||
let active = active_trigger(&text, caret, &marks);
|
||||
let query = active.map_or("", |(at, mark)| &text[at + mark.len_utf8()..caret]);
|
||||
let (rows, pick): (Vec<Choice>, Pick) = match active {
|
||||
Some((at, '@')) => {
|
||||
let found = handles_matching(people, query);
|
||||
let rows = found
|
||||
.iter()
|
||||
.map(|name| Choice::new(name.clone(), format!("@{name}")))
|
||||
.collect();
|
||||
let field = field.clone();
|
||||
(
|
||||
rows,
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
log::info!("mention: @{}", found[pick]);
|
||||
replace_trigger(&field, at, caret, &format!("@{} ", found[pick]), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some((at, ':')) if query.len() >= EMOJI_QUERY => {
|
||||
let found: Vec<&'static emojis::Emoji> = emoji_found(&query.to_lowercase())
|
||||
.into_iter()
|
||||
.take(EMOJI_SHOWN)
|
||||
.collect();
|
||||
let rows = found
|
||||
.iter()
|
||||
.map(|emoji| {
|
||||
let name = emoji.shortcode().unwrap_or(emoji.name());
|
||||
Choice::new(name, format!("{} {name}", emoji.as_str()))
|
||||
})
|
||||
.collect();
|
||||
let field = field.clone();
|
||||
(
|
||||
rows,
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
log::info!("emoji: {}", found[pick].name());
|
||||
replace_trigger(&field, at, caret, found[pick].as_str(), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some((at, mark)) => match &extra {
|
||||
Some((extra_mark, build)) if *extra_mark == mark => build(query, at, caret),
|
||||
_ => nothing(),
|
||||
},
|
||||
None => nothing(),
|
||||
};
|
||||
Suggestions {
|
||||
id,
|
||||
state: field.clone(),
|
||||
trigger: active.map(|(at, _)| at),
|
||||
rows,
|
||||
pick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{BlockData, BlockEditor, BlockKind};
|
||||
use crate::{forms, theme::Theme};
|
||||
|
||||
fn open(
|
||||
blocks: Vec<BlockData>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<BlockEditor>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
super::bind_keys(cx);
|
||||
});
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx));
|
||||
settle(cx);
|
||||
(editor, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn blocks(
|
||||
editor: &Entity<BlockEditor>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Vec<(BlockKind, Vec<String>)> {
|
||||
cx.update(|_, cx| {
|
||||
editor
|
||||
.read(cx)
|
||||
.blocks(cx)
|
||||
.into_iter()
|
||||
.map(|block| (block.kind, block.texts))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts the caret in block `ix`'s first field at byte `at`.
|
||||
fn caret(editor: &Entity<BlockEditor>, ix: usize, at: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
let field = editor.read(cx).blocks[ix].fields[0].clone();
|
||||
field.update(cx, |field, cx| field.select(at..at, cx));
|
||||
window.focus(&field.focus_handle(cx));
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec<String>) {
|
||||
(kind, vec![text.to_string()])
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx);
|
||||
caret(&editor, 0, 2, cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(
|
||||
vec![
|
||||
BlockData::new(BlockKind::Paragraph, ["tone "]),
|
||||
BlockData::new(BlockKind::Paragraph, ["lift"]),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
caret(&editor, 1, 0, cx);
|
||||
cx.simulate_keystrokes("backspace");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[text(BlockKind::Paragraph, "tone lift")]
|
||||
);
|
||||
cx.simulate_keystrokes("cmd-z");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[
|
||||
text(BlockKind::Paragraph, "tone "),
|
||||
text(BlockKind::Paragraph, "lift")
|
||||
],
|
||||
"undo brings the joined block back"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx);
|
||||
caret(&editor, 0, 0, cx);
|
||||
cx.simulate_input("## Lift");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx);
|
||||
caret(&editor, 0, 0, cx);
|
||||
cx.simulate_keystrokes("backspace");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(300.0), gpui::px(10.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert!(focused.is_some(), "the press focuses the block's field");
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx).len(), 2);
|
||||
}
|
||||
|
||||
/// The editor at a fixed width inside a row, as a page holds it.
|
||||
struct Hosted(Entity<BlockEditor>);
|
||||
|
||||
impl gpui::Render for Hosted {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement, Styled};
|
||||
gpui::div()
|
||||
.flex()
|
||||
.child(gpui::div().w(gpui::px(760.0)).child(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
super::bind_keys(cx);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|window, cx| {
|
||||
let editor = cx.new(|cx| {
|
||||
BlockEditor::new(
|
||||
vec![BlockData::new(BlockKind::Paragraph, ["lift"])],
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
Hosted(editor)
|
||||
});
|
||||
settle(cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(700.0), gpui::px(10.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let editor = cx.update(|_, cx| host.read(cx).0.clone());
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert!(focused.is_some(), "the press focuses the block's field");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(300.0), gpui::px(1.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert_eq!(
|
||||
focused,
|
||||
Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Checkbox, OnFlag},
|
||||
motion::ProgressBar,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
type OnItem = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A to-do: its box and its words; done, the words go quiet and struck through.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TodoItem {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
done: bool,
|
||||
on_toggle: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl TodoItem {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, done: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
done,
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TodoItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let check = Checkbox::new(self.id, self.done);
|
||||
let check = match self.on_toggle {
|
||||
Some(on_toggle) => check.on_change(move |done, window, cx| on_toggle(done, window, cx)),
|
||||
None => check,
|
||||
};
|
||||
div().flex().items_center().gap_2().child(check).child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.when(self.done, |label| {
|
||||
label.line_through().text_color(colors.fg_subtle)
|
||||
})
|
||||
.child(self.label),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// To-dos under a title, with how many are done as a count and a bar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checklist {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
items: Vec<(SharedString, bool)>,
|
||||
on_toggle: Option<OnItem>,
|
||||
}
|
||||
|
||||
impl Checklist {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
items: impl IntoIterator<Item = (impl Into<SharedString>, bool)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|(label, done)| (label.into(), done))
|
||||
.collect(),
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells which item changed, by index, and whether it is now done.
|
||||
pub fn on_toggle(
|
||||
mut self,
|
||||
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Checklist {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let done = self.items.iter().filter(|(_, done)| *done).count();
|
||||
let total = self.items.len();
|
||||
let share = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
done as f32 / total as f32
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(div().font_weight(FontWeight::MEDIUM).child(self.title))
|
||||
.child(
|
||||
tabular(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted),
|
||||
)
|
||||
.child(format!("{done} of {total}")),
|
||||
),
|
||||
)
|
||||
.child(ProgressBar::new((self.id.clone(), "done"), share))
|
||||
.children(
|
||||
self.items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, is_done))| {
|
||||
let item =
|
||||
TodoItem::new((self.id.clone(), format!("item-{ix}")), label, is_done);
|
||||
match &self.on_toggle {
|
||||
Some(on_toggle) => {
|
||||
let on_toggle = on_toggle.clone();
|
||||
item.on_toggle(move |done, window, cx| {
|
||||
on_toggle(ix, done, window, cx)
|
||||
})
|
||||
}
|
||||
None => item,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use gpui::{App, ElementId, Entity, IntoElement, Keystroke, ParentElement, RenderOnce, Window};
|
||||
|
||||
use super::format::{Format, active, format};
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem, shortcut_text},
|
||||
forms::{Run, TextInput},
|
||||
primitives::IconName,
|
||||
shell::{Toolbar, ToolbarGroup, ToolbarSeparator},
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
/// A tool: the format it applies, its icon, its name and its key.
|
||||
type Tool = (Format, IconName, &'static str, Option<&'static str>);
|
||||
|
||||
/// The inline styles, which the floating toolbar shows too.
|
||||
pub(crate) const INLINE: &[Tool] = &[
|
||||
(Format::Bold, IconName::Bold, "Bold", Some("secondary-b")),
|
||||
(
|
||||
Format::Italic,
|
||||
IconName::Italic,
|
||||
"Italic",
|
||||
Some("secondary-i"),
|
||||
),
|
||||
(
|
||||
Format::Strike,
|
||||
IconName::Strikethrough,
|
||||
"Strikethrough",
|
||||
None,
|
||||
),
|
||||
(Format::Code, IconName::Code, "Code", None),
|
||||
(Format::Link, IconName::Link, "Link", Some("secondary-k")),
|
||||
];
|
||||
|
||||
const HEADINGS: &[Tool] = &[
|
||||
(Format::Heading(1), IconName::Heading1, "Heading 1", None),
|
||||
(Format::Heading(2), IconName::Heading2, "Heading 2", None),
|
||||
(Format::Heading(3), IconName::Heading3, "Heading 3", None),
|
||||
];
|
||||
|
||||
const BLOCKS: &[Tool] = &[
|
||||
(Format::Quote, IconName::Quote, "Quote", None),
|
||||
(Format::Bullet, IconName::List, "Bulleted list", None),
|
||||
(
|
||||
Format::Numbered,
|
||||
IconName::ListOrdered,
|
||||
"Numbered list",
|
||||
None,
|
||||
),
|
||||
(Format::Task, IconName::ListTodo, "To-do list", None),
|
||||
];
|
||||
|
||||
/// One tool as a toggle: pressed while `on`; a press applies its format, or opens the link editor.
|
||||
pub(crate) fn tool(
|
||||
id: &ElementId,
|
||||
field: &Entity<TextInput>,
|
||||
(format_of, icon, name, key): Tool,
|
||||
on: bool,
|
||||
on_link: Option<Run>,
|
||||
) -> ToggleButton {
|
||||
let tip = match key {
|
||||
Some(key) => {
|
||||
let stroke = Keystroke::parse(key).expect("a tool's key parses");
|
||||
format!("{name} {}", shortcut_text(&stroke))
|
||||
}
|
||||
None => name.to_string(),
|
||||
};
|
||||
let field = field.clone();
|
||||
ToggleButton::new(
|
||||
(id.clone(), name),
|
||||
ToggleItem::new(name).icon(icon).tooltip(tip),
|
||||
on,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, window, cx| match (&on_link, format_of) {
|
||||
(Some(open), Format::Link) => open(window, cx),
|
||||
_ => format(&field, format_of, cx),
|
||||
})
|
||||
}
|
||||
|
||||
/// The formats a markdown field takes, in a row above it: inline styles, headings, quote and lists. A tool shows pressed while the selection holds its format; a press applies it and leaves focus in the field.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FixedFormatToolbar {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_link: Option<Run>,
|
||||
}
|
||||
|
||||
impl FixedFormatToolbar {
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The link tool opens the owner's link editor instead of writing `[text]()`.
|
||||
pub fn on_link(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_link = Some(std::rc::Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FixedFormatToolbar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
let pressed = active(input.text(), input.selection());
|
||||
let group = |tools: &[Tool]| {
|
||||
ToolbarGroup::new().children(tools.iter().map(|tool_of| {
|
||||
let on = pressed.contains(&tool_of.0);
|
||||
tool(&self.id, &self.field, *tool_of, on, self.on_link.clone())
|
||||
}))
|
||||
};
|
||||
Toolbar::new()
|
||||
.child(group(INLINE))
|
||||
.child(ToolbarSeparator)
|
||||
.child(group(HEADINGS))
|
||||
.child(ToolbarSeparator)
|
||||
.child(group(BLOCKS))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
|
||||
|
||||
use super::markdown::options;
|
||||
|
||||
/// How a stretch of inline text prints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Style {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Math,
|
||||
Footnote,
|
||||
}
|
||||
|
||||
/// Inline content: its text, the styled stretches of it, and the address of each link, in order.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Inline {
|
||||
pub text: String,
|
||||
pub styles: Vec<(Range<usize>, Style)>,
|
||||
pub links: Vec<(Range<usize>, String)>,
|
||||
}
|
||||
|
||||
/// A block of a markdown document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
Paragraph(Inline),
|
||||
Heading(u8, Inline),
|
||||
Quote(Vec<Node>),
|
||||
Code {
|
||||
language: Option<String>,
|
||||
text: String,
|
||||
},
|
||||
/// Items with their blocks, numbered from `start` when ordered, each with its checkbox when a task.
|
||||
List {
|
||||
start: Option<u64>,
|
||||
items: Vec<(Option<bool>, Vec<Node>)>,
|
||||
},
|
||||
Table {
|
||||
aligns: Vec<Alignment>,
|
||||
head: Vec<Inline>,
|
||||
rows: Vec<Vec<Inline>>,
|
||||
},
|
||||
Rule,
|
||||
Math(String),
|
||||
Image {
|
||||
alt: String,
|
||||
url: String,
|
||||
},
|
||||
Footnote {
|
||||
label: String,
|
||||
body: Vec<Node>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parses into nodes as it goes: a stack of open containers and the inline text being built.
|
||||
#[derive(Default)]
|
||||
struct Builder {
|
||||
stack: Vec<(Frame, Vec<Node>)>,
|
||||
inline: Inline,
|
||||
open: Vec<(usize, Style)>,
|
||||
link: Vec<(usize, String)>,
|
||||
image: Option<(String, String)>,
|
||||
code: Option<(Option<String>, String)>,
|
||||
head: Vec<Inline>,
|
||||
row: Vec<Inline>,
|
||||
rows: Vec<Vec<Inline>>,
|
||||
}
|
||||
|
||||
enum Frame {
|
||||
Quote,
|
||||
List(Option<u64>, Vec<(Option<bool>, Vec<Node>)>),
|
||||
Item(Option<bool>),
|
||||
Table(Vec<Alignment>),
|
||||
Footnote(String),
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn take_inline(&mut self) -> Inline {
|
||||
std::mem::take(&mut self.inline)
|
||||
}
|
||||
}
|
||||
|
||||
/// `markdown` as blocks.
|
||||
pub fn parse(markdown: &str) -> Vec<Node> {
|
||||
let mut built = Builder::default();
|
||||
let mut out: Vec<Node> = Vec::new();
|
||||
for event in Parser::new_ext(markdown, options()) {
|
||||
match event {
|
||||
Event::Start(Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell) => {}
|
||||
Event::End(TagEnd::Paragraph) => {
|
||||
let text = built.take_inline();
|
||||
if !text.text.is_empty() {
|
||||
push(&mut built, &mut out, Node::Paragraph(text));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Heading(level)) => {
|
||||
let text = built.take_inline();
|
||||
push(&mut built, &mut out, Node::Heading(depth(level), text));
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => built.stack.push((Frame::Quote, Vec::new())),
|
||||
Event::Start(Tag::List(start)) => {
|
||||
flush(&mut built, &mut out);
|
||||
built
|
||||
.stack
|
||||
.push((Frame::List(start, Vec::new()), Vec::new()));
|
||||
}
|
||||
Event::Start(Tag::Item) => built.stack.push((Frame::Item(None), Vec::new())),
|
||||
Event::TaskListMarker(done) => {
|
||||
if let Some((Frame::Item(task), _)) = built.stack.last_mut() {
|
||||
*task = Some(done);
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
flush(&mut built, &mut out);
|
||||
let (frame, nodes) = built.stack.pop().expect("an item was open");
|
||||
let Frame::Item(task) = frame else {
|
||||
unreachable!("an item closes an item")
|
||||
};
|
||||
if let Some((Frame::List(_, items), _)) = built.stack.last_mut() {
|
||||
items.push((task, nodes));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
let (frame, _) = built.stack.pop().expect("a list was open");
|
||||
let Frame::List(start, items) = frame else {
|
||||
unreachable!("a list closes a list")
|
||||
};
|
||||
push(&mut built, &mut out, Node::List { start, items });
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(_)) => {
|
||||
let (_, nodes) = built.stack.pop().expect("a quote was open");
|
||||
push(&mut built, &mut out, Node::Quote(nodes));
|
||||
}
|
||||
Event::Start(Tag::FootnoteDefinition(label)) => built
|
||||
.stack
|
||||
.push((Frame::Footnote(label.to_string()), Vec::new())),
|
||||
Event::End(TagEnd::FootnoteDefinition) => {
|
||||
let (frame, body) = built.stack.pop().expect("a footnote was open");
|
||||
let Frame::Footnote(label) = frame else {
|
||||
unreachable!("a footnote closes a footnote")
|
||||
};
|
||||
push(&mut built, &mut out, Node::Footnote { label, body });
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
let language = match kind {
|
||||
CodeBlockKind::Fenced(name) if !name.is_empty() => Some(name.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
built.code = Some((language, String::new()));
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
let (language, text) = built.code.take().expect("a code block was open");
|
||||
push(
|
||||
&mut built,
|
||||
&mut out,
|
||||
Node::Code {
|
||||
language,
|
||||
text: text.trim_end_matches('\n').to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Event::Start(Tag::Table(aligns)) => {
|
||||
built.stack.push((Frame::Table(aligns), Vec::new()))
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
let cell = built.take_inline();
|
||||
built.row.push(cell);
|
||||
}
|
||||
Event::End(TagEnd::TableHead) => built.head = std::mem::take(&mut built.row),
|
||||
Event::End(TagEnd::TableRow) => {
|
||||
let row = std::mem::take(&mut built.row);
|
||||
built.rows.push(row);
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
let (frame, _) = built.stack.pop().expect("a table was open");
|
||||
let Frame::Table(aligns) = frame else {
|
||||
unreachable!("a table closes a table")
|
||||
};
|
||||
let (head, rows) = (
|
||||
std::mem::take(&mut built.head),
|
||||
std::mem::take(&mut built.rows),
|
||||
);
|
||||
push(&mut built, &mut out, Node::Table { aligns, head, rows });
|
||||
}
|
||||
Event::Rule => push(&mut built, &mut out, Node::Rule),
|
||||
Event::DisplayMath(math) => push(&mut built, &mut out, Node::Math(math.to_string())),
|
||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||
built.image = Some((String::new(), dest_url.to_string()))
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
let (alt, url) = built.image.take().expect("an image was open");
|
||||
push(&mut built, &mut out, Node::Image { alt, url });
|
||||
}
|
||||
Event::Start(Tag::Strong) => built.open.push((built.inline.text.len(), Style::Strong)),
|
||||
Event::Start(Tag::Emphasis) => {
|
||||
built.open.push((built.inline.text.len(), Style::Emphasis))
|
||||
}
|
||||
Event::Start(Tag::Strikethrough) => {
|
||||
built.open.push((built.inline.text.len(), Style::Strike))
|
||||
}
|
||||
Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) => {
|
||||
let (start, style) = built.open.pop().expect("a style closes after it opens");
|
||||
let end = built.inline.text.len();
|
||||
built.inline.styles.push((start..end, style));
|
||||
}
|
||||
Event::Start(Tag::Link { dest_url, .. }) => built
|
||||
.link
|
||||
.push((built.inline.text.len(), dest_url.to_string())),
|
||||
Event::End(TagEnd::Link) => {
|
||||
let (start, url) = built.link.pop().expect("a link closes after it opens");
|
||||
let range = start..built.inline.text.len();
|
||||
built.inline.styles.push((range.clone(), Style::Link));
|
||||
built.inline.links.push((range, url));
|
||||
}
|
||||
Event::Text(text) => match (&mut built.code, &mut built.image) {
|
||||
(Some((_, code)), _) => code.push_str(&text),
|
||||
(None, Some((alt, _))) => alt.push_str(&text),
|
||||
(None, None) => built.inline.text.push_str(&text),
|
||||
},
|
||||
Event::Code(code) => styled(&mut built.inline, &code, Style::Code),
|
||||
Event::InlineMath(math) => styled(&mut built.inline, &math, Style::Math),
|
||||
Event::FootnoteReference(label) => {
|
||||
styled(&mut built.inline, &format!("[{label}]"), Style::Footnote)
|
||||
}
|
||||
Event::SoftBreak => built.inline.text.push(' '),
|
||||
Event::HardBreak => built.inline.text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut built, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn styled(inline: &mut Inline, text: &str, style: Style) {
|
||||
let start = inline.text.len();
|
||||
inline.text.push_str(text);
|
||||
inline.styles.push((start..inline.text.len(), style));
|
||||
}
|
||||
|
||||
fn depth(level: HeadingLevel) -> u8 {
|
||||
level as u8
|
||||
}
|
||||
|
||||
/// A finished node goes into the open container, or the document.
|
||||
fn push(built: &mut Builder, out: &mut Vec<Node>, node: Node) {
|
||||
match built.stack.last_mut() {
|
||||
Some((_, nodes)) => nodes.push(node),
|
||||
None => out.push(node),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text left from a tight list item becomes its paragraph.
|
||||
fn flush(built: &mut Builder, out: &mut Vec<Node>) {
|
||||
if !built.inline.text.is_empty() {
|
||||
let text = built.take_inline();
|
||||
push(built, out, Node::Paragraph(text));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_nest_as_written() {
|
||||
let nodes = parse(
|
||||
"# Lift\n\nBlend **toward** [white](https://ely.dev).\n\n- [x] clamp\n- [ ] gamma\n\n> quoted\n\n```rust\nfn main() {}\n```\n\n---\n",
|
||||
);
|
||||
assert!(matches!(&nodes[0], Node::Heading(1, text) if text.text == "Lift"));
|
||||
let Node::Paragraph(line) = &nodes[1] else {
|
||||
panic!("a paragraph, not {:?}", nodes[1])
|
||||
};
|
||||
assert_eq!(line.text, "Blend toward white.");
|
||||
assert_eq!(line.styles, [(6..12, Style::Strong), (13..18, Style::Link)]);
|
||||
assert_eq!(line.links, [(13..18, "https://ely.dev".to_string())]);
|
||||
let Node::List { start: None, items } = &nodes[2] else {
|
||||
panic!("a list, not {:?}", nodes[2])
|
||||
};
|
||||
assert_eq!(
|
||||
items.iter().map(|(task, _)| *task).collect::<Vec<_>>(),
|
||||
[Some(true), Some(false)]
|
||||
);
|
||||
assert!(matches!(&items[0].1[0], Node::Paragraph(text) if text.text == "clamp"));
|
||||
assert!(matches!(&nodes[3], Node::Quote(inner) if inner.len() == 1));
|
||||
assert_eq!(
|
||||
nodes[4],
|
||||
Node::Code {
|
||||
language: Some("rust".into()),
|
||||
text: "fn main() {}".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes[5], Node::Rule);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_math_stands_alone() {
|
||||
assert_eq!(parse("$$c = 1$$"), [Node::Math("c = 1".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_keep_their_cells_and_alignment() {
|
||||
let nodes = parse("| Name | Lift |\n|:--|--:|\n| accent | 0.5 |\n");
|
||||
let Node::Table { aligns, head, rows } = &nodes[0] else {
|
||||
panic!("a table, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(aligns, &[Alignment::Left, Alignment::Right]);
|
||||
assert_eq!(
|
||||
head.iter()
|
||||
.map(|cell| cell.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Name", "Lift"]
|
||||
);
|
||||
assert_eq!(rows[0][1].text, "0.5");
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -83,13 +83,7 @@ pub(crate) fn lex(source: &str) -> Vec<(Range<usize>, Kind)> {
|
||||
|
||||
fn painted(spans: impl Iterator<Item = (Range<usize>, Hsla)>) -> Vec<(Range<usize>, Highlight)> {
|
||||
spans
|
||||
.map(|(range, color)| {
|
||||
let highlight = Highlight {
|
||||
color,
|
||||
background: None,
|
||||
};
|
||||
(range, highlight)
|
||||
})
|
||||
.map(|(range, color)| (range, Highlight::new(color)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -237,13 +237,7 @@ pub fn expression_highlights(source: &str, cx: &App) -> Vec<(Range<usize>, Highl
|
||||
Token::Op(_) => syntax.operator,
|
||||
Token::Open | Token::Close | Token::Comma => syntax.punctuation,
|
||||
};
|
||||
(
|
||||
range.clone(),
|
||||
Highlight {
|
||||
color,
|
||||
background: None,
|
||||
},
|
||||
)
|
||||
(range.clone(), Highlight::new(color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ impl RenderOnce for IconPicker {
|
||||
}
|
||||
|
||||
/// Emoji whose name or shortcode holds `query`, a lowercase string; all for an empty one.
|
||||
fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> {
|
||||
pub(crate) fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> {
|
||||
emojis::iter()
|
||||
.filter(|emoji| {
|
||||
query.is_empty()
|
||||
|
||||
+114
-90
@@ -1,13 +1,13 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, prelude::*,
|
||||
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Stateful, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
Highlight, Input, TextInput,
|
||||
options::{Choice, Popup},
|
||||
options::{Choice, Pick, Popup},
|
||||
text::{Down, Enter, Up},
|
||||
};
|
||||
use crate::theme::ActiveTheme;
|
||||
@@ -72,8 +72,8 @@ pub fn mention_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)
|
||||
(
|
||||
range,
|
||||
Highlight {
|
||||
color: link,
|
||||
background: Some(link.opacity(0.08)),
|
||||
..Highlight::new(link)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -86,6 +86,94 @@ struct Picking {
|
||||
dismissed: Option<usize>,
|
||||
}
|
||||
|
||||
/// Rows offered at a trigger in a field's text, and what a pick does: arrows choose, Enter or a press picks, Escape dismisses until the trigger ends.
|
||||
pub(crate) struct Suggestions {
|
||||
pub id: ElementId,
|
||||
pub state: Entity<TextInput>,
|
||||
/// Where the trigger being typed starts.
|
||||
pub trigger: Option<usize>,
|
||||
pub rows: Vec<Choice>,
|
||||
pub pick: Pick,
|
||||
}
|
||||
|
||||
impl Suggestions {
|
||||
/// Wraps the element that shows the field, with the rows under the trigger while it is typed.
|
||||
pub fn wrap(self, field: impl IntoElement, window: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default());
|
||||
let input = self.state.read(cx);
|
||||
let focused = input.focus().is_focused(window);
|
||||
let anchor = self.trigger.and_then(|ix| input.bounds_for(ix));
|
||||
let dismissed = dismissal(picking.read(cx).dismissed, self.trigger);
|
||||
if dismissed != picking.read(cx).dismissed {
|
||||
picking.update(cx, |picking, _| picking.dismissed = dismissed);
|
||||
}
|
||||
let open = focused
|
||||
&& !self.rows.is_empty()
|
||||
&& self.trigger.is_some_and(|ix| dismissed != Some(ix));
|
||||
let count = self.rows.len();
|
||||
let highlighted = picking.read(cx).highlighted.min(count.saturating_sub(1));
|
||||
log::debug!(
|
||||
"suggestions {:?}: open {open}, {count} rows, anchor {anchor:?}",
|
||||
self.id
|
||||
);
|
||||
let (up, down, escape) = (picking.clone(), picking.clone(), picking);
|
||||
let (enter, trigger) = (self.pick.clone(), self.trigger);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.w_full()
|
||||
.capture_action(move |_: &Up, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
up.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + count - 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
down.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
enter(highlighted, window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if open && event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape.update(cx, |picking, cx| {
|
||||
picking.dismissed = trigger;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(field)
|
||||
.when_some(anchor.filter(|_| open), |field, anchor| {
|
||||
field.child(
|
||||
Popup {
|
||||
id: "suggestions".into(),
|
||||
anchor,
|
||||
rows: &self.rows,
|
||||
highlighted: Some(highlighted),
|
||||
checked: None,
|
||||
pick: self.pick,
|
||||
dismiss: None,
|
||||
scroll: None,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A field where `@` or `#` opens suggestions at the caret. Arrows choose; Enter picks.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MentionInput {
|
||||
@@ -122,46 +210,34 @@ impl MentionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The handles after `trigger` that hold the typed query, a few at most.
|
||||
pub(crate) fn handles_matching(handles: &[SharedString], query: &str) -> Vec<SharedString> {
|
||||
let query = query.to_lowercase();
|
||||
handles
|
||||
.iter()
|
||||
.filter(|name| name.to_lowercase().contains(&query))
|
||||
.take(SHOWN)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl RenderOnce for MentionInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default());
|
||||
let input = self.state.read(cx);
|
||||
let (text, caret) = (input.text().to_string(), input.cursor());
|
||||
let focused = input.focus().is_focused(window);
|
||||
let marks: Vec<char> = self.triggers.iter().map(|(trigger, _)| *trigger).collect();
|
||||
let active = active_trigger(&text, caret, &marks);
|
||||
let anchor = active.and_then(|(ix, _)| input.bounds_for(ix));
|
||||
let matches: Vec<SharedString> = active
|
||||
.map(|(ix, trigger)| {
|
||||
let query = text[ix + trigger.len_utf8()..caret].to_lowercase();
|
||||
let names = &self
|
||||
.triggers
|
||||
.iter()
|
||||
.find(|(mark, _)| *mark == trigger)
|
||||
.expect("from triggers")
|
||||
.1;
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| name.to_lowercase().contains(&query))
|
||||
.take(SHOWN)
|
||||
.cloned()
|
||||
.collect()
|
||||
handles_matching(names, &text[ix + trigger.len_utf8()..caret])
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let dismissed = dismissal(picking.read(cx).dismissed, active.map(|(ix, _)| ix));
|
||||
if dismissed != picking.read(cx).dismissed {
|
||||
picking.update(cx, |picking, _| picking.dismissed = dismissed);
|
||||
}
|
||||
let open =
|
||||
focused && !matches.is_empty() && active.is_some_and(|(ix, _)| dismissed != Some(ix));
|
||||
let highlighted = picking
|
||||
.read(cx)
|
||||
.highlighted
|
||||
.min(matches.len().saturating_sub(1));
|
||||
log::debug!(
|
||||
"mention input: open {open}, {} matches, caret {anchor:?}",
|
||||
matches.len()
|
||||
);
|
||||
let rows: Vec<Choice> = matches
|
||||
.iter()
|
||||
.map(|name| {
|
||||
@@ -171,8 +247,8 @@ impl RenderOnce for MentionInput {
|
||||
Choice::new(name.clone(), label)
|
||||
})
|
||||
.collect();
|
||||
let insert = {
|
||||
let (state, matches) = (self.state.clone(), matches.clone());
|
||||
let insert: Pick = {
|
||||
let state = self.state.clone();
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
let Some((ix, trigger)) = active else {
|
||||
return;
|
||||
@@ -185,66 +261,14 @@ impl RenderOnce for MentionInput {
|
||||
});
|
||||
})
|
||||
};
|
||||
let count = matches.len();
|
||||
let (up, down, enter, escape) = (
|
||||
picking.clone(),
|
||||
picking.clone(),
|
||||
insert.clone(),
|
||||
picking.clone(),
|
||||
);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.w_full()
|
||||
.capture_action(move |_: &Up, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
up.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + count - 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
down.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
enter(highlighted, window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if open && event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape.update(cx, |picking, cx| {
|
||||
picking.dismissed = active.map(|(ix, _)| ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(Input::new(&self.state))
|
||||
.when_some(anchor.filter(|_| open), |field, anchor| {
|
||||
field.child(
|
||||
Popup {
|
||||
id: "mentions".into(),
|
||||
anchor,
|
||||
rows: &rows,
|
||||
highlighted: Some(highlighted),
|
||||
checked: None,
|
||||
pick: insert,
|
||||
dismiss: None,
|
||||
scroll: None,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
})
|
||||
Suggestions {
|
||||
id: self.id,
|
||||
state: self.state.clone(),
|
||||
trigger: active.map(|(ix, _)| ix),
|
||||
rows,
|
||||
pick: insert,
|
||||
}
|
||||
.wrap(Input::new(&self.state), window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -64,6 +64,7 @@ pub use date::{
|
||||
};
|
||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||
pub use files::{DropZone, FileInput};
|
||||
pub(crate) use glyphs::emoji_found;
|
||||
pub use glyphs::{EmojiPicker, IconPicker};
|
||||
pub use group::{InputAddon, InputGroup};
|
||||
pub use hotkey::HotkeyInput;
|
||||
@@ -74,6 +75,7 @@ pub use knob::Knob;
|
||||
pub use listbox::ListBox;
|
||||
pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
|
||||
pub use mention::{MentionInput, mention_highlights};
|
||||
pub(crate) use mention::{Suggestions, active_trigger, handles_matching};
|
||||
pub use multi::MultiSelect;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
@@ -95,7 +97,9 @@ pub use structure::{
|
||||
};
|
||||
pub use switch::Switch;
|
||||
pub use tags::TagInput;
|
||||
pub(crate) use text::{Down, Enter, History, Submit, Up, bind_keys, from_utf16, to_utf16};
|
||||
pub(crate) use text::{
|
||||
Backspace, Down, Enter, History, Redo, Submit, Undo, Up, bind_keys, from_utf16, to_utf16,
|
||||
};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
|
||||
@@ -90,13 +90,7 @@ pub fn regex_highlights(pattern: &str, cx: &App) -> Vec<(Range<usize>, Highlight
|
||||
Part::Anchor => syntax.constant,
|
||||
Part::Alternation => syntax.operator,
|
||||
};
|
||||
(
|
||||
range,
|
||||
Highlight {
|
||||
color,
|
||||
background: None,
|
||||
},
|
||||
)
|
||||
(range, Highlight::new(color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+105
-49
@@ -1,11 +1,11 @@
|
||||
use gpui::{
|
||||
App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId,
|
||||
ElementInputHandler, Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId,
|
||||
MouseMoveEvent, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window,
|
||||
WrappedLine, fill, point, relative, size,
|
||||
ElementInputHandler, Entity, FontStyle, GlobalElementId, Hsla, InspectorElementId, IntoElement,
|
||||
LayoutId, MouseMoveEvent, Pixels, Point, SharedString, StrikethroughStyle, Style, TextAlign,
|
||||
TextRun, UnderlineStyle, Window, WrappedLine, fill, point, relative, size,
|
||||
};
|
||||
|
||||
use super::{Layout, TextInput};
|
||||
use super::{Highlight, Layout, TextInput};
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
pub(crate) struct TextElement {
|
||||
@@ -54,11 +54,11 @@ fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun {
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `len` bytes into runs: highlights and the marked range over a base color.
|
||||
/// Splits `len` bytes into runs: highlights, and the underlined marked range, over a base run.
|
||||
fn runs(
|
||||
len: usize,
|
||||
base: TextRun,
|
||||
spans: Vec<(std::ops::Range<usize>, Hsla, Option<Hsla>, bool)>,
|
||||
spans: Vec<(std::ops::Range<usize>, Highlight, bool)>,
|
||||
thickness: Pixels,
|
||||
) -> Vec<TextRun> {
|
||||
let mut cuts: Vec<usize> = vec![0, len];
|
||||
@@ -74,15 +74,27 @@ fn runs(
|
||||
len: end - start,
|
||||
..base.clone()
|
||||
};
|
||||
for (range, color, background, underline) in &spans {
|
||||
for (range, highlight, underline) in &spans {
|
||||
if range.start <= start && end <= range.end {
|
||||
run.color = *color;
|
||||
if background.is_some() {
|
||||
run.background_color = *background;
|
||||
run.color = highlight.color;
|
||||
if highlight.background.is_some() {
|
||||
run.background_color = highlight.background;
|
||||
}
|
||||
if let Some(weight) = highlight.weight {
|
||||
run.font.weight = weight;
|
||||
}
|
||||
if highlight.italic {
|
||||
run.font.style = FontStyle::Italic;
|
||||
}
|
||||
if highlight.strike {
|
||||
run.strikethrough = Some(StrikethroughStyle {
|
||||
thickness,
|
||||
color: Some(highlight.color),
|
||||
});
|
||||
}
|
||||
if *underline {
|
||||
run.underline = Some(UnderlineStyle {
|
||||
color: Some(*color),
|
||||
color: Some(highlight.color),
|
||||
thickness,
|
||||
wavy: false,
|
||||
});
|
||||
@@ -95,6 +107,34 @@ fn runs(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What to show, whether it is the placeholder, and its runs, the same for measure and paint.
|
||||
fn shown_runs(input: &TextInput, window: &Window, cx: &App) -> (String, bool, Vec<TextRun>) {
|
||||
let theme = cx.theme();
|
||||
let (text, placeholder) = shown(input);
|
||||
let color = window.text_style().color;
|
||||
let base = plain_run(
|
||||
text.len(),
|
||||
window,
|
||||
if placeholder {
|
||||
theme.colors.fg_subtle
|
||||
} else {
|
||||
color
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
if !placeholder {
|
||||
for (range, highlight) in input.highlights(cx) {
|
||||
spans.push((range, highlight, false));
|
||||
}
|
||||
if let Some(marked) = input.marked() {
|
||||
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
|
||||
spans.push((marked, Highlight::new(color), true));
|
||||
}
|
||||
}
|
||||
let runs = runs(text.len(), base, spans, theme.underline_thickness());
|
||||
(text, placeholder, runs)
|
||||
}
|
||||
|
||||
/// Starts of the hard lines in `text`.
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
@@ -129,9 +169,8 @@ impl Element for TextElement {
|
||||
style.size.height = line_height.into();
|
||||
return (window.request_layout(style, [], cx), ());
|
||||
};
|
||||
let (text, _) = shown(input);
|
||||
let (text, _, runs) = shown_runs(input, window, cx);
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let run = plain_run(text.len(), window, Hsla::default());
|
||||
let layout = window.request_measured_layout(style, move |known, available, window, _| {
|
||||
let width = known.width.or(match available.width {
|
||||
AvailableSpace::Definite(width) => Some(width),
|
||||
@@ -142,7 +181,7 @@ impl Element for TextElement {
|
||||
.shape_text(
|
||||
SharedString::from(text.clone()),
|
||||
font_size,
|
||||
std::slice::from_ref(&run),
|
||||
&runs,
|
||||
width,
|
||||
None,
|
||||
)
|
||||
@@ -169,47 +208,16 @@ impl Element for TextElement {
|
||||
cx: &mut App,
|
||||
) -> Prepaint {
|
||||
let theme = cx.theme();
|
||||
let (selection_color, caret_color, subtle) = (
|
||||
theme.colors.selection,
|
||||
theme.colors.focus,
|
||||
theme.colors.fg_subtle,
|
||||
);
|
||||
let (selection_color, caret_color) = (theme.colors.selection, theme.colors.focus);
|
||||
let caret_width = theme.caret_width().to_pixels(window.rem_size());
|
||||
let thickness = theme.underline_thickness();
|
||||
let input = self.input.read(cx);
|
||||
let (text, placeholder) = shown(input);
|
||||
let base = plain_run(
|
||||
text.len(),
|
||||
window,
|
||||
if placeholder {
|
||||
subtle
|
||||
} else {
|
||||
window.text_style().color
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
if !placeholder {
|
||||
for (range, highlight) in input.highlights(cx) {
|
||||
spans.push((range, highlight.color, highlight.background, false));
|
||||
}
|
||||
if let Some(marked) = input.marked() {
|
||||
let color = window.text_style().color;
|
||||
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
|
||||
spans.push((marked, color, None, true));
|
||||
}
|
||||
}
|
||||
let (text, placeholder, runs) = shown_runs(input, window, cx);
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let line_height = window.line_height();
|
||||
let wrap = input.rows().map(|_| bounds.size.width);
|
||||
let shaped = window
|
||||
.text_system()
|
||||
.shape_text(
|
||||
text.clone().into(),
|
||||
font_size,
|
||||
&runs(text.len(), base, spans, thickness),
|
||||
wrap,
|
||||
None,
|
||||
)
|
||||
.shape_text(text.clone().into(), font_size, &runs, wrap, None)
|
||||
.expect("text input shaping failed");
|
||||
let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect();
|
||||
let focused = input.focus().is_focused(window);
|
||||
@@ -400,7 +408,55 @@ fn selection_rects(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::line_starts;
|
||||
use gpui::{FontWeight, font, hsla, px};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_highlight_sets_weight_slant_and_strike_on_its_runs() {
|
||||
let base = TextRun {
|
||||
len: 3,
|
||||
font: font("Inter"),
|
||||
color: hsla(0., 0., 0., 1.),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let bold = Highlight {
|
||||
weight: Some(FontWeight::SEMIBOLD),
|
||||
..Highlight::new(hsla(0., 0., 0.5, 1.))
|
||||
};
|
||||
let slanted = Highlight {
|
||||
italic: true,
|
||||
strike: true,
|
||||
..Highlight::new(hsla(0., 0., 0.2, 1.))
|
||||
};
|
||||
let split = runs(
|
||||
3,
|
||||
base,
|
||||
vec![(0..2, bold, false), (1..3, slanted, false)],
|
||||
px(1.),
|
||||
);
|
||||
let styles: Vec<_> = split
|
||||
.iter()
|
||||
.map(|run| {
|
||||
(
|
||||
run.len,
|
||||
run.font.weight,
|
||||
run.font.style,
|
||||
run.strikethrough.is_some(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
styles,
|
||||
[
|
||||
(1, FontWeight::SEMIBOLD, FontStyle::Normal, false),
|
||||
(1, FontWeight::SEMIBOLD, FontStyle::Italic, true),
|
||||
(1, FontWeight::NORMAL, FontStyle::Italic, true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_starts_follow_newlines() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use gpui::{FontWeight, Hsla};
|
||||
|
||||
/// Styles a span of the text: its color, an optional wash behind it, and its weight, slant and strike.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Highlight {
|
||||
pub color: Hsla,
|
||||
pub background: Option<Hsla>,
|
||||
pub weight: Option<FontWeight>,
|
||||
pub italic: bool,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
/// A span in `color`, its font as the field's.
|
||||
pub fn new(color: Hsla) -> Self {
|
||||
Self {
|
||||
color,
|
||||
background: None,
|
||||
weight: None,
|
||||
italic: false,
|
||||
strike: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
mod actions;
|
||||
mod edit;
|
||||
mod element;
|
||||
mod highlight;
|
||||
mod ime;
|
||||
|
||||
use std::{ops::Range, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, Pixels, Point, SharedString,
|
||||
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString,
|
||||
Subscription, Task, Window, WrappedLine,
|
||||
};
|
||||
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys};
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys};
|
||||
use edit::Snapshot;
|
||||
pub(crate) use edit::{History, from_utf16, to_utf16};
|
||||
pub use highlight::Highlight;
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
@@ -26,13 +28,6 @@ pub enum InputEvent {
|
||||
Blur,
|
||||
}
|
||||
|
||||
/// Colors a span of the text: foreground, and an optional wash behind it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Highlight {
|
||||
pub color: Hsla,
|
||||
pub background: Option<Hsla>,
|
||||
}
|
||||
|
||||
type Highlighter = Rc<dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>>;
|
||||
type Filter = Rc<dyn Fn(char) -> bool>;
|
||||
pub(crate) type Fit = Rc<dyn Fn(&str, Range<usize>, &str) -> (String, usize)>;
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod buttons;
|
||||
pub mod charts;
|
||||
pub mod data_display;
|
||||
pub mod debug;
|
||||
pub mod documents;
|
||||
pub mod editor;
|
||||
pub mod feedback;
|
||||
pub mod finance;
|
||||
@@ -44,4 +45,5 @@ pub fn init(cx: &mut App) {
|
||||
forms::bind_keys(cx);
|
||||
editor::bind_keys(cx);
|
||||
terminal::bind_keys(cx);
|
||||
documents::bind_keys(cx);
|
||||
}
|
||||
@@ -146,6 +146,9 @@ icons! {
|
||||
HardDrive => "hard-drive",
|
||||
Hash => "hash",
|
||||
Heading => "heading",
|
||||
Heading1 => "heading-1",
|
||||
Heading2 => "heading-2",
|
||||
Heading3 => "heading-3",
|
||||
Heart => "heart",
|
||||
History => "history",
|
||||
Hourglass => "hourglass",
|
||||
@@ -180,6 +183,7 @@ icons! {
|
||||
Maximize2 => "maximize-2",
|
||||
MemoryStick => "memory-stick",
|
||||
Menu => "menu",
|
||||
Merge => "merge",
|
||||
MessageSquare => "message-square",
|
||||
MicOff => "mic-off",
|
||||
Mic => "mic",
|
||||
@@ -272,6 +276,7 @@ icons! {
|
||||
WholeWord => "whole-word",
|
||||
WifiOff => "wifi-off",
|
||||
Wifi => "wifi",
|
||||
Workflow => "workflow",
|
||||
Wrench => "wrench",
|
||||
X => "x",
|
||||
Zap => "zap",
|
||||
|
||||
Reference in new issue
Block a user