T21a: Documents, editing

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

No files matched your search

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