Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
591 changes: 531 additions & 60 deletions rust/apps/lexical-playground-core/src/lib.rs

Large diffs are not rendered by default.

27 changes: 24 additions & 3 deletions rust/apps/lexical-playground-core/src/surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct DisplayRun {
pub range: Range<usize>,
pub format: u32,
pub composing: bool,
/// Whether the run is a link's text.
pub link: bool,
/// The block the run is in (the one a newline ends), an index into
/// [`Document::blocks`](crate::Document::blocks).
pub block: usize,
}

pub struct Surface {
Expand Down Expand Up @@ -97,6 +102,8 @@ impl Surface {
range: offset + run.start..offset + run.end(),
format: run.format,
composing: false,
link: run.link.is_some(),
block: i,
});
}
offset += block.text.len();
Expand All @@ -105,6 +112,8 @@ impl Surface {
range: offset..offset + 1,
format: 0,
composing: false,
link: false,
block: i,
});
offset += 1;
}
Expand Down Expand Up @@ -133,6 +142,8 @@ impl Surface {
range: c.replaced.start..c.replaced.start + c.text.len(),
format: run.format,
composing: true,
link: false,
block: run.block,
});
}
if after.start < after.end {
Expand All @@ -143,10 +154,13 @@ impl Surface {
}
}
if !out.iter().any(|r| r.composing) {
let last = self.playground.document().blocks.len().saturating_sub(1);
out.push(DisplayRun {
range: c.replaced.start..c.replaced.start + c.text.len(),
format: 0,
composing: true,
link: false,
block: last,
});
}
out.retain(|r| r.range.start < r.range.end);
Expand Down Expand Up @@ -179,12 +193,19 @@ impl Surface {
}

fn push_selection(&mut self) {
let sel = self.shown_selection();
self.playground.set_selection(Some(sel));
}

/// The selection the surface shows, which the toolbar reads (with
/// [`Playground::format_at`] and the like): a click or an arrow key
/// moves it before Lexical hears of it at the next edit.
pub fn shown_selection(&self) -> Sel {
let doc = self.playground.document();
let sel = Sel {
Sel {
anchor: doc.pos_at(self.anchor),
focus: doc.pos_at(self.focus),
};
self.playground.set_selection(Some(sel));
}
}

/// Moves the caret to `offset` (in the display text, which is the
Expand Down
227 changes: 224 additions & 3 deletions rust/apps/lexical-playground-core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fn playground() -> Playground {
fn reads_the_example_as_blocks_of_runs() {
let p = playground();
let doc = p.document();
assert_eq!(doc.blocks.len(), 2);
assert_eq!(doc.blocks.len(), 8);
let first = &doc.blocks[0];
assert_eq!(first.kind, "paragraph");
assert_eq!(
Expand Down Expand Up @@ -52,7 +52,7 @@ fn typing_enter_and_formatting_edit_the_document() {
};
p.set_selection(Some(Sel::caret(end)));
assert_eq!(p.insert_paragraph().error, None);
assert_eq!(p.document().blocks.len(), 3);
assert_eq!(p.document().blocks.len(), 9);
assert_eq!(p.format_text("bold").error, None);
assert!(p.has_format("bold"));
assert_eq!(p.insert_text("Bold").error, None);
Expand Down Expand Up @@ -130,7 +130,8 @@ fn a_change_the_field_made_replays_as_a_lexical_edit() {
// A cut across the two blocks.
let cut = format!("家{}", &new[new.find("Type").unwrap()..]);
assert_eq!(p.apply_text(&cut).map(|o| o.error), Some(None));
assert_eq!(p.document().blocks.len(), 1);
// The two paragraphs are one; the heading, lists and quote follow.
assert_eq!(p.document().blocks.len(), 7);
assert_eq!(p.document().text(), cut);
assert_eq!(p.apply_text(&cut), None);
// Offsets round-trip through the joined text.
Expand Down Expand Up @@ -299,3 +300,223 @@ fn format_toggles_on_and_off_with_one_press_each() {
assert_eq!(p.step_count() - steps, if press == 0 { 2 } else { 1 });
}
}

#[test]
fn a_click_gives_the_selection_the_format_there_as_selectionchange_does() {
// "Hello from **Rust**": a caret in "Rust" takes its bold, so the
// toolbar shows bold before any edit and typing there stays bold, as in
// a browser; a range shows only the format all of its text has.
let mut p = playground();
let in_rust = Sel::caret(Pos { block: 0, byte: 13 });
assert_eq!(p.format_at(in_rust), format_flag("bold"));
let across = Sel {
anchor: Pos { block: 0, byte: 6 },
focus: Pos { block: 0, byte: 15 },
};
assert_eq!(p.format_at(across), 0);
// A range that ends at the start of "Rust" covers none of it.
let before = Sel {
anchor: Pos { block: 0, byte: 6 },
focus: Pos { block: 0, byte: 11 },
};
assert_eq!(p.format_at(before), 0);
let rust = Sel {
anchor: Pos { block: 0, byte: 11 },
focus: Pos { block: 0, byte: 15 },
};
assert_eq!(p.format_at(rust), format_flag("bold"));
p.set_selection(Some(in_rust));
assert!(p.has_format("bold"));
let steps = p.step_count();
assert_eq!(p.insert_text("y").error, None);
let runs: Vec<(&str, bool)> = p.document().blocks[0]
.runs
.iter()
.map(|r| (r.text.as_str(), r.has_format("bold")))
.collect();
assert_eq!(runs[1], ("Ruyst", true));
// The select step, then the typing.
assert_eq!(p.step_count() - steps, 2);
}

fn caret_in(p: &mut Playground, block: usize, byte: usize) {
p.set_selection(Some(Sel::caret(Pos { block, byte })));
}

#[test]
fn reads_headings_lists_links_and_quotes() {
let p = playground();
let doc = p.document();
let types: Vec<&str> = doc.blocks.iter().map(Block::block_type).collect();
assert_eq!(
types,
[
"paragraph",
"paragraph",
"h2",
"bullet",
"bullet",
"check",
"check",
"quote"
]
);
let link = doc.blocks[4]
.runs
.iter()
.find(|r| r.link.is_some())
.unwrap();
assert_eq!(
(link.text.as_str(), link.link.as_deref()),
("lexical.dev", Some("https://lexical.dev"))
);
// The link's runs are its parent's second child.
assert_eq!(link.child, 1);
assert!(doc.blocks[5].list.as_ref().unwrap().checked);
assert!(!doc.blocks[6].list.as_ref().unwrap().checked);
// An element point in the root is the start of the block it names.
let root = Point {
key: "root".into(),
offset: 3,
text: false,
};
assert_eq!(doc.pos_of(&root), Some(Pos { block: 3, byte: 0 }));
// After the link: an element point after the block's second child.
let after_link = doc.blocks[4].runs.iter().find(|r| r.text == ".").unwrap();
let point = doc.point_at(Pos {
block: 4,
byte: after_link.start + 1,
});
assert!(point.text);
}

#[test]
fn the_block_menu_converts_blocks_and_toggles_lists() {
let mut p = playground();
caret_in(&mut p, 1, 3);
assert_eq!(p.set_block_type("h1").error, None);
assert_eq!(p.document().blocks[1].block_type(), "h1");
assert_eq!(p.block_type(), "h1");
assert_eq!(p.set_block_type("quote").error, None);
assert_eq!(p.block_type(), "quote");
assert_eq!(p.set_block_type("number").error, None);
assert_eq!(p.block_type(), "number");
// The same list again makes it a paragraph.
assert_eq!(p.set_block_type("number").error, None);
assert_eq!(p.block_type(), "paragraph");
assert_eq!(
p.document().blocks[1].text,
"Type, delete, press Enter, or select text and make it bold."
);
}

#[test]
fn enter_in_a_list_item_adds_an_item_and_an_empty_one_ends_the_list() {
let mut p = playground();
let end = p.document().blocks[3].text.len();
caret_in(&mut p, 3, end);
assert_eq!(p.insert_paragraph().error, None);
let doc = p.document();
assert_eq!(doc.blocks[4].block_type(), "bullet");
assert_eq!(doc.blocks[4].text, "");
assert_eq!(p.selection(), Some(Sel::caret(Pos { block: 4, byte: 0 })));
// Enter in the empty item: a paragraph after the list.
assert_eq!(p.insert_paragraph().error, None);
assert_eq!(p.block_type(), "paragraph");
assert_eq!(p.document().blocks.len(), 9);
}

#[test]
fn indenting_a_list_item_nests_it() {
let mut p = playground();
caret_in(&mut p, 4, 2);
assert_eq!(p.indent(false).error, None);
let item = &p.document().blocks[4];
assert_eq!(item.list.as_ref().map(|l| l.depth), Some(1));
assert_eq!(p.indent(true).error, None);
assert_eq!(
p.document().blocks[4].list.as_ref().map(|l| l.depth),
Some(0)
);
}

#[test]
fn links_toggle_on_a_selection_and_off_at_a_caret() {
let mut p = playground();
p.set_selection(Some(Sel {
anchor: Pos { block: 0, byte: 0 },
focus: Pos { block: 0, byte: 5 },
}));
assert_eq!(p.toggle_link(Some("https://example.com")).error, None);
let run = &p.document().blocks[0].runs[0];
assert_eq!(
(run.text.as_str(), run.link.as_deref()),
("Hello", Some("https://example.com"))
);
caret_in(&mut p, 0, 2);
assert_eq!(p.link(), Some("https://example.com"));
assert_eq!(p.toggle_link(None).error, None);
assert!(p.document().blocks[0].runs.iter().all(|r| r.link.is_none()));
assert_eq!(
p.document().blocks[0].text,
"Hello from Rust. This document lives in a lexical-editor editor."
);
}

#[test]
fn clicking_a_check_box_toggles_the_item() {
let mut p = playground();
assert_eq!(p.toggle_checked(6).error, None);
assert!(p.document().blocks[6].list.as_ref().unwrap().checked);
assert_eq!(p.undo().error, None);
assert!(!p.document().blocks[6].list.as_ref().unwrap().checked);
}

#[test]
fn the_tree_shows_lists_above_their_items() {
let tree = playground().document().tree();
assert!(
tree.contains(" (11) list bullet\n (12) listitem bullet\n"),
"{tree}"
);
assert!(tree.contains(" (19) list check\n (20) listitem check ☑\n"));
}

/// Types `text` a character at a time, as keys do.
fn type_keys(p: &mut Playground, text: &str) {
for c in text.chars() {
assert_eq!(p.insert_text(&c.to_string()).error, None, "typing {c:?}");
}
}

#[test]
fn markdown_shortcuts_turn_typed_markdown_into_rich_text() {
let mut p = Playground::new(vec![]).expect("an empty document");
caret_in(&mut p, 0, 0);
type_keys(&mut p, "# Title");
assert_eq!(p.document().blocks[0].block_type(), "h1");
assert_eq!(p.document().blocks[0].text, "Title");
p.insert_paragraph();
type_keys(&mut p, "- one **bold** and `code`");
let item = &p.document().blocks[1];
assert_eq!(item.block_type(), "bullet");
assert_eq!(item.text, "one bold and code");
let run = |text: &str| item.runs.iter().find(|r| r.text == text).unwrap();
assert!(run("bold").has_format("bold"));
assert!(run("code").has_format("code"));
// An empty item ends the list; then a code fence on Enter.
p.insert_paragraph();
p.insert_paragraph();
type_keys(&mut p, "```rust");
p.insert_paragraph();
type_keys(&mut p, "let x = 1;");
let code = &p.document().blocks[2];
assert_eq!(code.kind, "code");
assert_eq!(code.text, "let x = 1;");
// Undo takes the code back to the fence's text.
let steps = p.step_count();
p.undo();
p.undo();
assert_eq!(p.document().blocks[2].text, "```rust");
assert_eq!(p.step_count(), steps + 2);
}
32 changes: 30 additions & 2 deletions rust/apps/lexical-playground-gpui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ zbugs-gpui is. macOS.
- **The toolbar.** Bold, italic, underline, strikethrough, code, highlight,
subscript and superscript follow the selection's format. Select all, and
Reset.
- **The block row.** Normal, H1-H3 and Quote run `$setBlocksType`; the
bullet, numbered and check list buttons dispatch the list commands (the
list the caret is already in turns back into paragraphs, as in the web
playground's block menu). Link toggles a link to `https://` on the
selection, or removes the one at the caret (`TOGGLE_LINK_COMMAND`).
Outdent and Indent dispatch `OUTDENT_CONTENT_COMMAND` and
`INDENT_CONTENT_COMMAND`, as do Shift-Tab and Tab at a block's start
(`$indentOverTab`; elsewhere Tab inserts a tab). In a check item, a
button, or a click on its box, checks or unchecks it (`toggleChecked`). The toolbar follows the
selection the editor shows, so a click shows the format and block there
before the next edit, with the format a browser's `selectionchange` gives
it.
- **Blocks.** Headings draw larger and bolder, list items with their
marker (•, 1., ☐ or ☑) indented by depth, a checked item struck through,
a quote with a bar, a code block monospaced on a tinted background, and
links in blue, underlined.
- **Markdown shortcuts.** Typing markdown turns it into rich text, as
the web playground's `MarkdownShortcutPlugin` does
(`registerMarkdownShortcuts` with `TRANSFORMERS`): `# ` to `###### `
make headings, `> ` a quote, `- `, `* `, `+ ` and `1. ` list items,
`` ``` `` and a space or Return a code block, and `**bold**`, `*italic*`,
`~~strikethrough~~`, `` `code` ``, `==highlight==` and
`[text](url)` their formats and links once the closing tag is typed.
Undo takes a shortcut back to the text typed. Return dispatches
`KEY_ENTER_COMMAND`.
- **Light and dark.** The window follows the Mac's appearance and switches
live when it changes, as the Rune playground does.
- **The side panel.**
Expand All @@ -44,7 +69,7 @@ zbugs-gpui is. macOS.
LEXICAL_REPLAY=/path/to/lexical-playground-session.json pnpm exec vitest run src/replay.test.ts
```

Headings, lists, links, code blocks, HTML and Markdown arrive as the
Code blocks, hashtags, HTML and Markdown arrive as the
port gets the packages ([DESIGN.md](../../DESIGN.md), milestones).

## Running it
Expand Down Expand Up @@ -73,7 +98,10 @@ to edit it.

Built with the `e2e` feature, the app drives its own window once it's up:
it types, formats, composes with an input method, types an emoji and
deletes it, adds a paragraph and a line break, and undoes and redoes, through the input
deletes it, adds a paragraph and a line break, undoes and redoes, and
turns the paragraph into a heading and a list, nests and unnests the item
with Tab and Shift-Tab and ends the list, then types markdown shortcuts
(a heading, bold text, a list item and a code block), through the input
handler and the key bindings, checking the document after each step. It
writes the session to `$LEXICAL_PLAYGROUND_E2E` and exits 0, or 1 with the
step that failed. CI (`.github/workflows/rust-playground.yml`) then replays
Expand Down
Loading
Loading