From ca5c17025880c6ce968c2f2eaaed0c0ce89e68d2 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 06:46:14 +0000 Subject: [PATCH 1/2] feat(rust): headings, quotes, lists, links and indent in both playgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The Rune and GPUI playgrounds drew every block as a plain paragraph, read only paragraphs at the root, and had no way to make a heading, quote, list or link, though lexical-editor now has rich-text, list and link. `lexical-playground-core` now: - reads the tree recursively: list items with their list's type, depth, number and `checked`, headings, quotes, indents, and links' text runs with their URL; selection points map through undrawn elements (the root, lists), and the tree view shows lists above their items; - registers rich-text, list, check list and link in the session, and sends Enter, Shift-Enter and formatting as the commands a browser dispatches; - adds `set_block_type` (lists through their commands, the same list again makes paragraphs; other types through `$setBlocksType`), `indent`, `tab` (`$indentOverTab`), `toggle_link`, `toggle_checked`, `block_type_at`, `link_at` and `format_at`; - gives the selection the format a browser's `selectionchange` gives it (`onSelectionChange`: a caret takes its text node's format and style, a range the format all its text shares) in the select step before an edit, and the toolbar reads it from the selection the app shows, so a click into bold text shows bold and typing there stays bold, as in a browser; - gets an example with a heading, a bullet list with a link, a check list and a quote. Rune maps list items to its field's bullets and numbers at their depth, a quote to its quote block, links to `Mark::Link`, headings to larger, bolder decorations and a checked item to strikethrough. GPUI shapes each line on its own, at its block's size and indent, and draws list markers (•, 1., ☐/☑), a quote's bar, links in the JS playground's blue, and checked items struck through; a click on a box toggles it. Both get a second toolbar row (Normal, H1-H3, Quote, the three lists, Link, Outdent, Indent, and a check toggle in a check item), and Tab and Shift-Tab indent and outdent. The GPUI end-to-end run now also makes a heading and a list, nests the item with Tab, unnests it with Shift-Tab and ends the list; CI replays that session in JS. ## Test plan ### Before The block-menu test in `lexical-playground-core`, before `$setBlocksType`: ``` test tests::the_block_menu_converts_blocks_and_toggles_lists ... FAILED ``` Without the selectionchange format in the select step: ``` thread 'tests::a_click_gives_the_selection_the_format_there_as_selectionchange_does' panicked left: ("Ru", true) right: ("Ruyst", true) ``` ### After ``` $ cargo test --workspace --all-features --locked (rust/) test result: ok. 19 passed; 0 failed (lexical-playground-core) $ cargo test --locked (apps/lexical-playground-rune) test result: ok. 9 passed; 0 failed $ cargo build --locked --features e2e && LEXICAL_PLAYGROUND_E2E=/tmp/gpui-e2e.json ./target/debug/lexical-playground-gpui (macOS 27, a real window) e2e: passed $ LEXICAL_REPLAY=/tmp/gpui-e2e.json pnpm exec vitest run src/replay.test.ts Tests 1 passed (1) ``` The GPUI session has 26 steps, including INSERT_UNORDERED_LIST_COMMAND, INDENT_CONTENT_COMMAND and OUTDENT_CONTENT_COMMAND. A 26-step session from `lexical-playground-core` (block menu, lists, Tab, links, a check box, undo and redo) also replays in JS, and changing one character of one recorded result makes the replay fail. fmt, clippy `-D warnings` and rustdoc `-D warnings` pass for the workspace; clippy passes for Rune and, on macOS, for GPUI with `--features e2e`. Screenshots of both apps were inspected: Rune headless (`--snap`), GPUI on macOS 27. Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d --- rust/apps/lexical-playground-core/src/lib.rs | 588 ++++++++++++++++-- .../lexical-playground-core/src/surface.rs | 27 +- .../apps/lexical-playground-core/src/tests.rs | 188 +++++- rust/apps/lexical-playground-gpui/README.md | 21 +- rust/apps/lexical-playground-gpui/src/app.rs | 127 +++- rust/apps/lexical-playground-gpui/src/e2e.rs | 60 ++ .../lexical-playground-gpui/src/editor.rs | 457 ++++++++++---- rust/apps/lexical-playground-rune/README.md | 18 +- rust/apps/lexical-playground-rune/src/main.rs | 90 ++- .../apps/lexical-playground-rune/src/tests.rs | 171 ++++- rust/apps/lexical-playground-rune/src/view.rs | 106 +++- 11 files changed, 1650 insertions(+), 203 deletions(-) diff --git a/rust/apps/lexical-playground-core/src/lib.rs b/rust/apps/lexical-playground-core/src/lib.rs index 39c45fb2ce9..13bcd5d8bd4 100644 --- a/rust/apps/lexical-playground-core/src/lib.rs +++ b/rust/apps/lexical-playground-core/src/lib.rs @@ -44,6 +44,11 @@ pub struct Run { pub style: String, /// The run's start in its block's text, in bytes. pub start: usize, + /// The index, among the block's children, of the child the run is or + /// is inside (a link holds several runs). + pub child: usize, + /// The URL of the link the run is in. + pub link: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -69,21 +74,63 @@ impl Run { } } -/// A child of the root: a paragraph, or later a heading, quote, list ... +/// A block the app draws as a line of text: an element that holds inline +/// content (a paragraph, a heading, a quote, a list item). Lists, and list +/// items that only hold a nested list, aren't drawn themselves; their +/// items are. #[derive(Clone, Debug, PartialEq)] pub struct Block { pub key: String, - /// The node's type (`"paragraph"`, `"heading"`, ...). + /// The node's type (`"paragraph"`, `"heading"`, `"quote"`, + /// `"listitem"`). pub kind: String, /// A heading's `tag` (`"h1"` ...), from its JSON. pub tag: Option, /// The element format (`"left"`, `"center"`, ...; `""` for none). pub align: String, + /// The element's `indent`. + pub indent: usize, + /// For a list item, its list. + pub list: Option, + /// The elements from the root down to the block, each with the index of + /// the child the path goes through: `[("root", 2), ("5", 0)]` for the + /// first item of the list that is the root's third child. + pub path: Vec<(String, usize)>, pub runs: Vec, /// The runs' text, joined. pub text: String, } +/// A list item's list, as the app draws its marker. +#[derive(Clone, Debug, PartialEq)] +pub struct ListInfo { + /// `"bullet"`, `"number"` or `"check"`. + pub list_type: String, + /// How deeply the list is nested; 0 for a top-level list. + pub depth: usize, + /// The item's `value`: its number in a numbered list. + pub value: u32, + /// A check item's `checked`. + pub checked: bool, +} + +impl Block { + /// What the toolbar's block menu says the block is: `"paragraph"`, + /// `"h1"` ... `"h6"`, `"quote"`, or a list item's list type + /// (`"bullet"`, `"number"`, `"check"`), as the JS playground's + /// `blockType`. + pub fn block_type(&self) -> &str { + if let Some(list) = &self.list { + return &list.list_type; + } + match (self.kind.as_str(), &self.tag) { + ("heading", Some(tag)) => tag, + ("quote", _) => "quote", + _ => "paragraph", + } + } +} + /// A position in the document: a block and a byte offset in its text. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Pos { @@ -161,13 +208,19 @@ impl Point { #[derive(Clone, Debug, Default, PartialEq)] pub struct Document { pub blocks: Vec, + /// The elements that hold blocks but aren't drawn themselves (lists, + /// and list items that only hold a nested list), by key, as the tree + /// view names them: `"list bullet"`, `"listitem"`. + pub containers: Vec<(String, String)>, } impl Document { /// Reads the current editor state. Call it in a read of the editor. fn read() -> Document { - let blocks = get_root().get_children().iter().map(read_block).collect(); - Document { blocks } + let mut blocks = Vec::new(); + let mut containers = Vec::new(); + read_blocks(&get_root(), &mut vec![], None, &mut blocks, &mut containers); + Document { blocks, containers } } /// The last position. @@ -208,27 +261,62 @@ impl Document { } Point { key: block.key.clone(), - offset: block.runs.iter().filter(|r| r.start < byte).count(), + offset: block + .runs + .iter() + .filter(|r| r.start < byte) + .map(|r| r.child + 1) + .max() + .unwrap_or(0), text: false, } } + /// The format and style a browser's `selectionchange` gives `sel` + /// (`onSelectionChange` in LexicalEvents.ts): a caret at a text point + /// takes that text node's format and style; a range takes the format + /// every text node it covers has (none when it covers no text), and + /// keeps its style. `None` for a caret at an element point, whose + /// format the editor keeps. + pub fn selection_change_format(&self, sel: Sel) -> Option<(u32, Option)> { + if sel.is_collapsed() { + let point = self.point_at(sel.anchor); + if !point.text { + return None; + } + let run = self.blocks[sel.anchor.block] + .runs + .iter() + .find(|r| r.key == point.key)?; + return Some((run.format, Some(run.style.clone()))); + } + let (start, end) = sel.ordered(); + let mut combined = None; + for (i, block) in self.blocks.iter().enumerate() { + if i < start.block || i > end.block { + continue; + } + for run in block.runs.iter().filter(|r| r.is_text()) { + // Text the range covers some of: a node it only touches at + // its edge is left out, as JS leaves out an empty boundary. + let from = if i == start.block { start.byte } else { 0 }; + let to = if i == end.block { end.byte } else { usize::MAX }; + if run.start < to && run.end() > from && !run.text.is_empty() { + combined = Some(combined.unwrap_or(u32::MAX) & run.format); + } + } + } + Some((combined.unwrap_or(0), None)) + } + /// The position of a selection point, if its node is drawn. pub fn pos_of(&self, point: &Point) -> Option { - if point.key == "root" { - return Some(match self.blocks.get(point.offset) { - Some(_) => Pos { - block: point.offset, - byte: 0, - }, - None => self.end(), - }); - } for (i, block) in self.blocks.iter().enumerate() { if block.key == point.key { let byte = block .runs - .get(point.offset) + .iter() + .find(|r| r.child >= point.offset) .map_or(block.text.len(), |r| r.start); return Some(Pos { block: i, byte }); } @@ -241,7 +329,31 @@ impl Document { return Some(Pos { block: i, byte }); } } - None + // An element point in an element that isn't drawn (the root, a + // list): the start of the first block at or after its child + // `offset`, or the end of its last block. + let under = |b: &Block| { + b.path + .iter() + .find(|(key, _)| *key == point.key) + .map(|p| p.1) + }; + let mut last = None; + for (i, block) in self.blocks.iter().enumerate() { + if let Some(child) = under(block) { + if child >= point.offset { + return Some(Pos { block: i, byte: 0 }); + } + last = Some(i); + } + } + if let Some(i) = last { + return Some(Pos { + block: i, + byte: self.blocks[i].text.len(), + }); + } + (point.key == "root").then(|| self.end()) } /// The document as one string: the blocks' text joined with `\n`, as @@ -308,26 +420,51 @@ impl Document { "capitalize", ]; let mut out = String::from("(root) root\n"); + let mut shown = 0; for block in &self.blocks { + // The undrawn elements on the block's path, the first time a + // block under them comes. + for (depth, (key, _)) in block.path.iter().enumerate().skip(1) { + if let Some(i) = self.containers.iter().position(|(k, _)| k == key) + && i >= shown + { + let pad = " ".repeat(depth); + out.push_str(&format!("{pad}({key}) {}\n", self.containers[i].1)); + shown = i + 1; + } + } let tag = block .tag .as_deref() .map(|t| format!(" {t}")) .unwrap_or_default(); - out.push_str(&format!(" ({}) {}{tag}\n", block.key, block.kind)); + let pad = " ".repeat(block.path.len()); + let list = block + .list + .as_ref() + .map(|l| match l.list_type.as_str() { + "check" => format!(" check {}", if l.checked { "☑" } else { "☐" }), + "number" => format!(" number {}", l.value), + other => format!(" {other}"), + }) + .unwrap_or_default(); + out.push_str(&format!("{pad}({}) {}{tag}{list}\n", block.key, block.kind)); for run in &block.runs { let name = match run.kind { RunKind::Text => "text", RunKind::Tab => "tab", RunKind::LineBreak => "linebreak", }; - out.push_str(&format!(" ({}) {name}", run.key)); + out.push_str(&format!("{pad} ({}) {name}", run.key)); if run.kind == RunKind::Text { out.push_str(&format!(" {:?}", run.text)); } for format in FORMATS.iter().filter(|f| run.has_format(f)) { out.push_str(&format!(" {format}")); } + if let Some(url) = &run.link { + out.push_str(&format!(" link {url}")); + } out.push('\n'); } } @@ -423,52 +560,153 @@ impl Document { } } -fn read_block(node: &NodeRef) -> Block { +/// Whether `node` is drawn as a block: an element that isn't inline and +/// holds no blocks (an empty one too). +fn is_drawn_block(node: &NodeRef) -> bool { + node.is_element() + && !node.is_inline() + && !node.is_root() + && node + .get_children() + .iter() + .all(|c| c.is_inline() || !c.is_element()) + && node.get_type() != "list" +} + +/// Appends the blocks under `node`, whose path from the root is `path`; +/// `list` is the list `node` is an item of. +fn read_blocks( + node: &NodeRef, + path: &mut Vec<(String, usize)>, + list: Option<&ListInfo>, + out: &mut Vec, + containers: &mut Vec<(String, String)>, +) { + let key = if node.is_root() { + "root".to_owned() + } else { + node.get_key().to_owned() + }; + let json = node.export_json(false); + // A list sets what its items are; a list in an item nests one deeper. + let list_here = (node.get_type() == "list").then(|| ListInfo { + list_type: json + .get("listType") + .and_then(Value::as_str) + .unwrap_or("bullet") + .to_owned(), + depth: list.map_or(0, |l| l.depth + 1), + value: 0, + checked: false, + }); + for (i, child) in node.get_children().iter().enumerate() { + path.push((key.clone(), i)); + let list = list_here.as_ref().or(list); + let item = (child.get_type() == "listitem").then(|| { + let json = child.export_json(false); + let mut info = list.cloned().unwrap_or(ListInfo { + list_type: "bullet".into(), + depth: 0, + value: 0, + checked: false, + }); + info.value = json.get("value").and_then(Value::as_f64).unwrap_or(1.0) as u32; + info.checked = json.get("checked").and_then(Value::as_bool) == Some(true); + info + }); + if is_drawn_block(child) { + out.push(read_block(child, path.clone(), item)); + } else if child.is_element() && !child.is_inline() { + let mut name = child.get_type().to_owned(); + if let Some(list_type) = child + .export_json(false) + .get("listType") + .and_then(Value::as_str) + { + name = format!("{name} {list_type}"); + } + containers.push((child.get_key().to_owned(), name)); + read_blocks(child, path, list, out, containers); + } + path.pop(); + } +} + +fn read_block(node: &NodeRef, path: Vec<(String, usize)>, list: Option) -> Block { let json = node.export_json(false); let str_field = |name: &str| json.get(name).and_then(Value::as_str).map(str::to_owned); let mut text = String::new(); let mut runs = Vec::new(); - for child in node.get_children() { - let (kind, run_text, format, style) = match child.get_type() { - "linebreak" => (RunKind::LineBreak, "\n".to_owned(), 0, String::new()), - _ if child.is_text() => { - let data = child.text_data(); - let kind = if child.get_type() == "tab" { - RunKind::Tab - } else { - RunKind::Text - }; - ( - kind, - data.get_text().to_owned(), - data.get_format() as u32, - data.get_style().to_owned(), - ) - } - // Inline elements (links) and decorators come with their - // packages; their text stands in for them until then. - _ => (RunKind::Text, child.get_text_content(), 0, String::new()), - }; - runs.push(Run { - key: child.get_key().to_owned(), - kind, - start: text.len(), - text: run_text.clone(), - format, - style, - }); - text.push_str(&run_text); + for (i, child) in node.get_children().iter().enumerate() { + read_runs(child, i, None, &mut text, &mut runs); } Block { key: node.get_key().to_owned(), kind: node.get_type().to_owned(), tag: str_field("tag"), align: str_field("format").unwrap_or_default(), + indent: json.get("indent").and_then(Value::as_f64).unwrap_or(0.0) as usize, + list, + path, runs, text, } } +/// Appends the runs of `node`, the block's child `child`, inside the link +/// `link` if any: a link's runs are its text's. +fn read_runs( + node: &NodeRef, + child: usize, + link: Option<&str>, + text: &mut String, + runs: &mut Vec, +) { + let (kind, run_text, format, style) = match node.get_type() { + "linebreak" => (RunKind::LineBreak, "\n".to_owned(), 0, String::new()), + "link" | "autolink" => { + let json = node.export_json(false); + let url = json + .get("url") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + for inner in node.get_children() { + read_runs(&inner, child, Some(&url), text, runs); + } + return; + } + _ if node.is_text() => { + let data = node.text_data(); + let kind = if node.get_type() == "tab" { + RunKind::Tab + } else { + RunKind::Text + }; + ( + kind, + data.get_text().to_owned(), + data.get_format() as u32, + data.get_style().to_owned(), + ) + } + // Other inline elements and decorators come with their packages; + // their text stands in for them until then. + _ => (RunKind::Text, node.get_text_content(), 0, String::new()), + }; + runs.push(Run { + key: node.get_key().to_owned(), + kind, + start: text.len(), + text: run_text.clone(), + format, + style, + child, + link: link.map(str::to_owned), + }); + text.push_str(&run_text); +} + /// The flag of a text format (`"bold"` → 1), as `TEXT_TYPE_TO_FORMAT`. pub fn format_flag(name: &str) -> u32 { lexical::toggle_text_format_type(0, name, None) as u32 @@ -608,6 +846,12 @@ impl Playground { pub fn with_clock(children: Vec, clock: Clock) -> Result { let mut script = Object::new(); script.insert("name", "playground".into()); + // `RichTextExtension`, `ListExtension`, `CheckListExtension` and + // `LinkExtension`: what the JS playground's editor has for these + // blocks and links. + for flag in ["richText", "list", "link"] { + script.insert(flag, true.into()); + } let mut history = Object::new(); history.insert("delay", HISTORY_DELAY.into()); script.insert("history", Value::Object(history)); @@ -684,8 +928,27 @@ impl Playground { self.last_error.as_deref() } - /// The selection's pending format (what typed text gets). + /// The selection's pending format (what typed text gets), as the + /// toolbar shows it: see [`Playground::format_at`]. pub fn selection_format(&self) -> u32 { + self.selection + .map_or_else(|| self.model_format(), |sel| self.format_at(sel)) + } + + /// The format the toolbar shows at `sel`: the editor's selection's + /// when `sel` is it, and otherwise the one the next edit's select step + /// gives it (see [`Document::selection_change_format`]), so the toolbar + /// follows a click or an arrow key before the editor hears of it. + pub fn format_at(&self, sel: Sel) -> u32 { + if self.selection_from_model() != Some(sel) + && let Some((format, _)) = self.document.selection_change_format(sel) + { + return format; + } + self.model_format() + } + + fn model_format(&self) -> u32 { self.model_selection .as_array() .filter(|a| a.first().and_then(Value::as_str) == Some("range")) @@ -727,12 +990,15 @@ impl Playground { self.edit("insertRawText", vec![text.into()]) } + /// Enter: `INSERT_PARAGRAPH_COMMAND`, as rich text's `KEY_ENTER_COMMAND` + /// handler dispatches it, so a list item's handler runs. pub fn insert_paragraph(&mut self) -> Outcome { - self.edit("insertParagraph", vec![]) + self.dispatch("INSERT_PARAGRAPH_COMMAND", None) } + /// Shift-Enter: `INSERT_LINE_BREAK_COMMAND`. pub fn insert_line_break(&mut self) -> Outcome { - self.edit("insertLineBreak", vec![]) + self.dispatch("INSERT_LINE_BREAK_COMMAND", Some(false.into())) } /// Backspace (`backward`) or Delete: `deleteCharacter`, `deleteWord` or @@ -793,18 +1059,122 @@ impl Playground { /// `formatText(name)`: toggles a format on the selected text, or on the /// pending format at a caret. pub fn format_text(&mut self, name: &str) -> Outcome { - self.edit("formatText", vec![name.into()]) + self.dispatch("FORMAT_TEXT_COMMAND", Some(name.into())) + } + + /// The block menu: `$setBlocksType` with a paragraph, a heading + /// (`"h1"` ... `"h6"`) or a quote, or a list (`"bullet"`, `"number"`, + /// `"check"`) through its command. Choosing a list the selection is + /// already in makes it paragraphs again, as the JS playground's toolbar + /// does. + pub fn set_block_type(&mut self, block_type: &str) -> Outcome { + let command = match block_type { + "bullet" => "INSERT_UNORDERED_LIST_COMMAND", + "number" => "INSERT_ORDERED_LIST_COMMAND", + "check" => "INSERT_CHECK_LIST_COMMAND", + _ => { + let mut action = Object::new(); + action.insert("do", "setBlocksType".into()); + action.insert("block", block_type.into()); + return self.update(vec![Value::Object(action)]); + } + }; + if self.block_type() == block_type { + return self.set_block_type("paragraph"); + } + self.dispatch(command, None) + } + + /// The selection's block type (its anchor's block), for the toolbar. + pub fn block_type(&self) -> &str { + self.selection + .map_or("paragraph", |sel| self.block_type_at(sel)) + } + + /// The block type of `sel`'s anchor's block. + pub fn block_type_at(&self, sel: Sel) -> &str { + self.document + .blocks + .get(sel.anchor.block) + .map_or("paragraph", Block::block_type) + } + + /// `INDENT_CONTENT_COMMAND` (`outdent`: `OUTDENT_CONTENT_COMMAND`). + pub fn indent(&mut self, outdent: bool) -> Outcome { + let command = if outdent { + "OUTDENT_CONTENT_COMMAND" + } else { + "INDENT_CONTENT_COMMAND" + }; + self.dispatch(command, None) + } + + /// Tab and Shift-Tab, as the JS playground's TabIndentationPlugin + /// (`$indentOverTab`): Shift-Tab outdents, and Tab indents when the + /// selection starts at its block's start or spans blocks, and inserts + /// a tab otherwise. + pub fn tab(&mut self, shift: bool) -> Outcome { + if shift { + return self.indent(true); + } + let indents = self.selection.is_some_and(|s| { + let (first, last) = s.ordered(); + first.byte == 0 || first.block != last.block + }); + if indents { + self.indent(false) + } else { + self.insert_raw_text("\t") + } + } + + /// The URL of the link at the selection's focus, if it is in one. + pub fn link(&self) -> Option<&str> { + self.link_at(self.selection?) + } + + /// The URL of the link at `sel`'s focus, if it is in one. + pub fn link_at(&self, sel: Sel) -> Option<&str> { + let block = self.document.blocks.get(sel.focus.block)?; + let byte = sel.focus.byte; + block + .runs + .iter() + .find(|r| r.start < byte && byte <= r.end() || (r.start == byte && byte == 0)) + .and_then(|r| r.link.as_deref()) + } + + /// The link button: `TOGGLE_LINK_COMMAND` with `url`, or with `null` + /// to remove the link the selection is in. + pub fn toggle_link(&mut self, url: Option<&str>) -> Outcome { + self.dispatch( + "TOGGLE_LINK_COMMAND", + Some(url.map_or(Value::Null, Value::from)), + ) + } + + /// Checks or unchecks the check item drawn as `block` + /// (`ListItemNode.toggleChecked`), as clicking its box does. + pub fn toggle_checked(&mut self, block: usize) -> Outcome { + let Some(key) = self.document.blocks.get(block).map(|b| b.key.clone()) else { + return Outcome::default(); + }; + let mut action = Object::new(); + action.insert("do", "node".into()); + action.insert("key", key.into()); + action.insert("method", "toggleChecked".into()); + self.update(vec![Value::Object(action)]) } /// ⌘Z: `UNDO_COMMAND`, which history handles. The app's selection /// reaches the editor first, as a browser's `selectionchange` does. pub fn undo(&mut self) -> Outcome { - self.dispatch("UNDO_COMMAND") + self.dispatch("UNDO_COMMAND", None) } /// ⌘⇧Z: `REDO_COMMAND`. pub fn redo(&mut self) -> Outcome { - self.dispatch("REDO_COMMAND") + self.dispatch("REDO_COMMAND", None) } /// Whether there is something to undo, as history last said @@ -818,15 +1188,20 @@ impl Playground { self.can_redo } - fn dispatch(&mut self, command: &str) -> Outcome { + /// Dispatches `command` outside an update, as a key handler does, after + /// the app's selection reaches the editor. + fn dispatch(&mut self, command: &str, payload: Option) -> Outcome { if let Some(select) = self.select_action() { - let outcome = self.run_update(vec![select]); + let outcome = self.run_update(select); if outcome.error.is_some() { return outcome; } } let mut step = Object::new(); step.insert("dispatch", command.into()); + if let Some(payload) = payload { + step.insert("payload", payload); + } self.run(Value::Object(step)) } @@ -902,7 +1277,7 @@ impl Playground { /// history merges typing after a click as it does in a browser. pub fn update(&mut self, actions: Vec) -> Outcome { if let Some(select) = self.select_action() { - let outcome = self.run_update(vec![select]); + let outcome = self.run_update(select); if outcome.error.is_some() || actions.is_empty() { return outcome; } @@ -926,7 +1301,13 @@ impl Playground { /// the selection would take that node's format, and the next ⌘B, which /// toggles the selection's format (in JS as here), would set bold again /// instead of clearing it. - fn select_action(&self) -> Option { + /// + /// The step also gives the selection the format (and a caret the + /// style) that a browser's `selectionchange` does + /// (`onSelectionChange` in LexicalEvents.ts): a caret in text takes its + /// text node's, and a range the format all its text shares, so the + /// toolbar shows bold in bold text and typing there stays bold. + fn select_action(&self) -> Option> { let sel = self.selection?; if self.selection_from_model() == Some(sel) { return None; @@ -937,7 +1318,21 @@ impl Playground { action.insert("do", "select".into()); action.insert("anchor", anchor); action.insert("focus", focus); - Some(Value::Object(action)) + let mut actions = vec![Value::Object(action)]; + let selection_method = |method: &str, arg: Value| { + let mut action = Object::new(); + action.insert("do", "selection".into()); + action.insert("method", method.into()); + action.insert("args", Value::Array(vec![arg])); + Value::Object(action) + }; + if let Some((format, style)) = self.document.selection_change_format(sel) { + actions.push(selection_method("setFormat", f64::from(format).into())); + if let Some(style) = style { + actions.push(selection_method("setStyle", style.into())); + } + } + Some(actions) } fn run(&mut self, step: Value) -> Outcome { @@ -1111,8 +1506,81 @@ pub fn example() -> Vec { "Type, delete, press Enter, or select text and make it bold.", 0.0, )]), + element( + "heading", + &[("tag", "h2".into())], + vec![text("Blocks and links", 0.0)], + ), + list( + "bullet", + vec![ + vec![text( + "Headings, quotes and lists come from the toolbar.", + 0.0, + )], + vec![ + text("Links too, like ", 0.0), + element( + "link", + &[("url", "https://lexical.dev".into())], + vec![text("lexical.dev", 0.0)], + ), + text(".", 0.0), + ], + ], + ), + list( + "check", + vec![ + vec![text("Undo with ⌘Z", 0.0)], + vec![text("Click a box to check it", 0.0)], + ], + ), + element( + "quote", + &[], + vec![text("Lexical is an extensible text editor framework.", 0.0)], + ), ] } +/// A serialized element of `kind` with `fields` and `children`. +fn element(kind: &str, fields: &[(&str, Value)], children: Vec) -> Value { + let mut o = Object::new(); + o.insert("type", kind.into()); + for (name, value) in fields { + o.insert(*name, value.clone()); + } + o.insert("children", Value::Array(children)); + Value::Object(o) +} + +/// A serialized list of `list_type` whose items hold `items`. +fn list(list_type: &str, items: Vec>) -> Value { + let items = items + .into_iter() + .enumerate() + .map(|(i, children)| { + let mut fields = vec![("value", ((i + 1) as f64).into())]; + if list_type == "check" { + fields.push(("checked", (i == 0).into())); + } + element("listitem", &fields, children) + }) + .collect(); + element( + "list", + &[ + ("listType", list_type.into()), + ("start", 1.0.into()), + ( + "tag", + if list_type == "number" { "ol" } else { "ul" }.into(), + ), + ], + items, + ) +} + #[cfg(test)] mod tests; diff --git a/rust/apps/lexical-playground-core/src/surface.rs b/rust/apps/lexical-playground-core/src/surface.rs index 016b5e70ced..cad879c780b 100644 --- a/rust/apps/lexical-playground-core/src/surface.rs +++ b/rust/apps/lexical-playground-core/src/surface.rs @@ -26,6 +26,11 @@ pub struct DisplayRun { pub range: Range, 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 { @@ -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(); @@ -105,6 +112,8 @@ impl Surface { range: offset..offset + 1, format: 0, composing: false, + link: false, + block: i, }); offset += 1; } @@ -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 { @@ -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); @@ -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 diff --git a/rust/apps/lexical-playground-core/src/tests.rs b/rust/apps/lexical-playground-core/src/tests.rs index 73c9b16b175..bcb9e96fb2c 100644 --- a/rust/apps/lexical-playground-core/src/tests.rs +++ b/rust/apps/lexical-playground-core/src/tests.rs @@ -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!( @@ -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); @@ -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. @@ -299,3 +300,184 @@ 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")); +} diff --git a/rust/apps/lexical-playground-gpui/README.md b/rust/apps/lexical-playground-gpui/README.md index 4dc58c020b6..69757176ee3 100644 --- a/rust/apps/lexical-playground-gpui/README.md +++ b/rust/apps/lexical-playground-gpui/README.md @@ -28,6 +28,21 @@ 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, and links in blue, underlined. - **Light and dark.** The window follows the Mac's appearance and switches live when it changes, as the Rune playground does. - **The side panel.** @@ -44,7 +59,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 @@ -73,7 +88,9 @@ 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, 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 diff --git a/rust/apps/lexical-playground-gpui/src/app.rs b/rust/apps/lexical-playground-gpui/src/app.rs index 7ac21076036..979f2c60ee2 100644 --- a/rust/apps/lexical-playground-gpui/src/app.rs +++ b/rust/apps/lexical-playground-gpui/src/app.rs @@ -5,7 +5,7 @@ use crate::editor::{EditorEvent, LexicalEditor}; use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; -use lexical_playground_core::{Playground, example, replay_vectors}; +use lexical_playground_core::{Playground, example, format_flag, replay_vectors}; #[derive(Clone, Copy, PartialEq, Eq)] enum Panel { @@ -25,6 +25,23 @@ const FORMATS: [(&str, &str); 8] = [ ("superscript", "x²"), ]; +/// The block menu, as the JS playground's `BlockFormatDropDown`: each +/// button is a `blockType` and its label (the same as the Rune app's). +const BLOCKS: [(&str, &str); 8] = [ + ("paragraph", "Normal"), + ("h1", "H1"), + ("h2", "H2"), + ("h3", "H3"), + ("quote", "Quote"), + ("bullet", "• List"), + ("number", "1. List"), + ("check", "☑ List"), +]; + +/// The URL a new link gets, as the JS playground's link button inserts +/// `https://` for the user to edit. +const NEW_LINK_URL: &str = "https://"; + pub struct PlaygroundApp { editor: Entity, panel: Panel, @@ -82,7 +99,10 @@ impl PlaygroundApp { } fn toolbar(&self, cx: &mut Context) -> impl IntoElement { - let playground = &self.editor.read(cx).surface.playground; + let surface = &self.editor.read(cx).surface; + // The surface's selection, which a click or an arrow key moves + // before Lexical hears of it at the next edit. + let format = surface.playground.format_at(surface.shown_selection()); let mut bar = h_flex() .w_full() .gap_1() @@ -97,7 +117,7 @@ impl PlaygroundApp { .child("Lexical"), ); for (name, label) in FORMATS { - let on = playground.has_format(name); + let on = format & format_flag(name) != 0; let button = Button::new(SharedString::from(format!("format-{name}"))) .small() .label(label) @@ -141,6 +161,106 @@ impl PlaygroundApp { ) } + /// The second row: the block menu, the link button, outdent and indent, + /// and a check item's box. + fn block_bar(&self, cx: &mut Context) -> impl IntoElement { + let surface = &self.editor.read(cx).surface; + let sel = surface.shown_selection(); + let playground = &surface.playground; + let current = playground.block_type_at(sel).to_owned(); + let link = playground.link_at(sel).map(str::to_owned); + let check = playground + .document() + .blocks + .get(sel.anchor.block) + .and_then(|b| b.list.as_ref()) + .filter(|l| l.list_type == "check") + .map(|l| l.checked); + let block = sel.anchor.block; + let toggle = |id: String, label: &str, on: bool| { + let button = Button::new(SharedString::from(id)) + .small() + .label(label.to_owned()) + .selected(on); + if on { button.primary() } else { button.ghost() } + }; + let mut bar = h_flex() + .w_full() + .gap_1() + .px_3() + .py_2() + .border_b_1() + .border_color(cx.theme().border); + for (block_type, label) in BLOCKS { + bar = bar.child( + toggle(format!("block-{block_type}"), label, current == block_type) + .tooltip(format!("Format as {label}")) + .on_click(cx.listener(move |this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.set_block_type(block_type)) + })), + ); + } + bar = bar + .child( + toggle("link".into(), "Link", link.is_some()) + .tooltip(match &link { + Some(url) => format!("Remove the link to {url}"), + None => "Insert a link".to_owned(), + }) + .on_click(cx.listener(|this, _, window, cx| { + let surface = &this.editor.read(cx).surface; + let remove = surface + .playground + .link_at(surface.shown_selection()) + .is_some(); + this.toolbar_edit(window, cx, |p| { + p.toggle_link((!remove).then_some(NEW_LINK_URL)) + }) + })), + ) + .child( + Button::new("outdent") + .small() + .ghost() + .label("Outdent") + .on_click(cx.listener(|this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.indent(true)) + })), + ) + .child( + Button::new("indent") + .small() + .ghost() + .label("Indent") + .on_click(cx.listener(|this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.indent(false)) + })), + ); + if let Some(checked) = check { + bar = bar.child( + toggle( + "check-item".into(), + if checked { "☑ Done" } else { "☐ Done" }, + checked, + ) + .tooltip("Check or uncheck this item") + .on_click(cx.listener(move |this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.toggle_checked(block)) + })), + ); + } + if let Some(url) = link { + bar = bar.child( + div() + .ml_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(url), + ); + } + bar + } + fn panel(&self, cx: &mut Context) -> impl IntoElement { let tab = |id: &'static str, label: &'static str, panel: Panel, cx: &mut Context| { Button::new(id) @@ -303,6 +423,7 @@ impl Render for PlaygroundApp { .bg(cx.theme().background) .text_color(cx.theme().foreground) .child(self.toolbar(cx)) + .child(self.block_bar(cx)) .child( h_flex() .flex_1() diff --git a/rust/apps/lexical-playground-gpui/src/e2e.rs b/rust/apps/lexical-playground-gpui/src/e2e.rs index 63f902e2b0a..b9f64345e59 100644 --- a/rust/apps/lexical-playground-gpui/src/e2e.rs +++ b/rust/apps/lexical-playground-gpui/src/e2e.rs @@ -217,6 +217,66 @@ async fn drive(window: AnyWindowHandle, app: &Entity, cx: &mut As } } ); + // The block menu and Tab, on the paragraph typed above (lines 1 and 2, + // split by the line break). + let block_menu = |block_type: &'static str| { + let editor = editor.clone(); + move |_: &mut Window, cx: &mut App| { + editor.update(cx, |e, cx| e.edit(cx, |p| p.set_block_type(block_type))) + } + }; + let block_is = |want: &'static str, depth: usize| { + let editor = editor.clone(); + move |cx: &mut AsyncApp| -> Step { + let (got, got_depth) = editor.read_with(cx, |e, _| { + let p = &e.surface.playground; + let depth = p.document().blocks[1].list.as_ref().map_or(0, |l| l.depth); + (p.document().blocks[1].block_type().to_owned(), depth) + }); + if (got.as_str(), got_depth) == (want, depth) { + Ok(()) + } else { + Err(format!( + "the block is {got} at depth {got_depth}, not {want} at {depth}" + )) + } + } + }; + step!( + "H1 from the block menu", + block_menu("h1"), + block_is("h1", 0) + ); + step!( + "• List from the block menu", + block_menu("bullet"), + block_is("bullet", 0) + ); + step!( + "↑ to the item's first line", + key(Box::new(Up)), + expect(2, "next") + ); + step!( + "To the line's start", + key(Box::new(LineStart)), + expect(1, "Typed in GPUI, then plain 日本") + ); + step!( + "Tab nests the item", + key(Box::new(InsertTab)), + block_is("bullet", 1) + ); + step!( + "⇧Tab brings it back", + key(Box::new(Outdent)), + block_is("bullet", 0) + ); + step!( + "• List again ends the list", + block_menu("bullet"), + block_is("paragraph", 0) + ); step!( "The third paragraph is untouched", |_: &mut Window, _: &mut App| {}, diff --git a/rust/apps/lexical-playground-gpui/src/editor.rs b/rust/apps/lexical-playground-gpui/src/editor.rs index d479ee916b3..cdbcaf85e8d 100644 --- a/rust/apps/lexical-playground-gpui/src/editor.rs +++ b/rust/apps/lexical-playground-gpui/src/editor.rs @@ -9,7 +9,7 @@ use gpui_kit::component::{ActiveTheme, Theme}; use gpui_kit::*; -use lexical_playground_core::{Outcome, Playground, Surface, Unit, format_flag}; +use lexical_playground_core::{Document, Outcome, Playground, RunKind, Surface, Unit, format_flag}; use std::ops::Range; use std::rc::Rc; use std::time::Duration; @@ -27,6 +27,7 @@ actions!( InsertParagraph, InsertLineBreak, InsertTab, + Outdent, Left, Right, Up, @@ -72,6 +73,7 @@ pub fn bind_keys(cx: &mut App) { KeyBinding::new("enter", InsertParagraph, c), KeyBinding::new("shift-enter", InsertLineBreak, c), KeyBinding::new("tab", InsertTab, c), + KeyBinding::new("shift-tab", Outdent, c), KeyBinding::new("left", Left, c), KeyBinding::new("right", Right, c), KeyBinding::new("up", Up, c), @@ -99,22 +101,123 @@ pub fn bind_keys(cx: &mut App) { ]); } +/// How a line of the display text is drawn: its block's text size and +/// weight (a heading's), its left edge (a list item's or a quote's +/// indent), and what goes in the margin (a list marker, a quote's bar). +#[derive(Clone, Debug)] +struct LineStyle { + /// The block the line is in. + block: usize, + size: Pixels, + /// The height of each of the line's rows. + height: Pixels, + left: Pixels, + /// A list item's marker, on its first line. + marker: Option, + /// Whether the marker is a check box, which a click toggles. + check: bool, + quote: bool, +} + +/// One indent step: a list's nesting level, or a block's `indent`. +const INDENT: f32 = 24.; + +/// A heading's text size and weight, as the JS playground's theme (and the +/// Rune app) draws `h1` ... `h6` in a 16-point body. +fn heading_style(tag: &str) -> Option<(f32, FontWeight)> { + Some(match tag { + "h1" => (26., FontWeight::BOLD), + "h2" => (21., FontWeight::BOLD), + "h3" => (18., FontWeight::SEMIBOLD), + "h4" => (16., FontWeight::SEMIBOLD), + "h5" => (14., FontWeight::SEMIBOLD), + "h6" => (13., FontWeight::SEMIBOLD), + _ => return None, + }) +} + +/// The style of each line of the display text: a block's lines are its +/// first and one more per line break. +fn line_styles(doc: &Document, size: Pixels, height: Pixels) -> Vec { + let mut out = Vec::new(); + for (i, block) in doc.blocks.iter().enumerate() { + let (size, height) = match block.tag.as_deref().and_then(heading_style) { + Some((s, _)) => (px(s), px((s * 1.5).round())), + None => (size, height), + }; + let mut style = LineStyle { + block: i, + size, + height, + left: px(INDENT * block.indent as f32), + marker: None, + check: false, + quote: block.kind == "quote", + }; + if let Some(list) = &block.list { + style.left = px(INDENT * (list.depth + 1) as f32); + style.check = list.list_type == "check"; + style.marker = Some(match list.list_type.as_str() { + "number" => format!("{}.", list.value), + "check" => if list.checked { "☑" } else { "☐" }.to_owned(), + _ => ["•", "◦", "▪"][list.depth % 3].to_owned(), + }); + } else if style.quote { + style.left += px(16.); + } + let breaks = block + .runs + .iter() + .filter(|r| r.kind == RunKind::LineBreak) + .count(); + out.push(style.clone()); + // The marker goes on the item's first line only. + style.marker = None; + out.extend(std::iter::repeat_n(style, breaks)); + } + out +} + +/// A line of the display text as the last paint laid it out. +struct Line { + /// Its first byte in the display text. + start: usize, + top: Pixels, + style: LineStyle, + shape: WrappedLine, +} + +impl Line { + fn rows(&self) -> usize { + self.shape.wrap_boundaries.len() + 1 + } + + fn bottom(&self) -> Pixels { + self.top + self.style.height * self.rows() as f32 + } +} + /// Where the last paint put each line of the display text. struct Layout { bounds: Bounds, - line_height: Pixels, text: String, - /// Each `\n`-separated line: its first byte, its top, and its shape. - lines: Rc>, + lines: Rc>, } impl Layout { /// The line holding byte `index`. - fn line_of(&self, index: usize) -> Option<&(usize, Pixels, WrappedLine)> { + fn line_of(&self, index: usize) -> Option<&Line> { + self.lines.iter().rev().find(|l| l.start <= index) + } + + /// The line at `y` (window coordinates), or the first. + fn line_at(&self, y: Pixels) -> Option<&Line> { + let local = y - self.bounds.origin.y; self.lines .iter() .rev() - .find(|(start, _, _)| *start <= index) + .find(|l| l.top <= local) + .or(self.lines.first()) } /// The byte under `position` (window coordinates). @@ -123,49 +226,63 @@ impl Layout { if local.y < px(0.) { return 0; } - let line = self - .lines - .iter() - .rev() - .find(|(_, top, _)| *top <= local.y) - .or(self.lines.first()); - let Some((start, top, line)) = line else { + let Some(line) = self.line_at(position.y) else { return 0; }; - let at = point(local.x.max(px(0.)), local.y - *top); - match line.closest_index_for_position(at, self.line_height) { - Ok(i) | Err(i) => start + i, + let at = point((local.x - line.style.left).max(px(0.)), local.y - line.top); + match line.shape.closest_index_for_position(at, line.style.height) { + Ok(i) | Err(i) => line.start + i, } } - /// The top left of the caret at byte `index` (window coordinates). - fn position_of(&self, index: usize) -> Option> { - let (start, top, line) = self.line_of(index)?; - let at = line.position_for_index(index - start, self.line_height)?; - Some(self.bounds.origin + point(at.x, *top + at.y)) + /// The top left of the caret at byte `index` (window coordinates), and + /// its height. + fn position_of(&self, index: usize) -> Option<(Point, Pixels)> { + let line = self.line_of(index)?; + let at = line + .shape + .position_for_index(index - line.start, line.style.height)?; + Some(( + self.bounds.origin + point(line.style.left + at.x, line.top + at.y), + line.style.height, + )) + } + + /// The check item whose box is under `position`, as its block. + fn check_at(&self, position: Point) -> Option { + let local = position - self.bounds.origin; + let line = self.line_at(position.y)?; + let hit = line.style.check + && line.style.marker.is_some() + && local.y < line.top + line.style.height + && local.x < line.style.left + && local.x >= line.style.left - px(INDENT); + hit.then_some(line.style.block) } - /// Each row of each line as `(byte range, top left, width)`. - fn rows(&self) -> Vec<(Range, Point, Pixels)> { + /// Each row of each line as `(byte range, top left, width, height)`. + fn rows(&self) -> Vec<(Range, Point, Pixels, Pixels)> { let mut rows = Vec::new(); - for (start, top, line) in self.lines.iter() { - let layout = &line.unwrapped_layout; + for line in self.lines.iter() { + let layout = &line.shape.unwrapped_layout; let mut breaks: Vec = line + .shape .wrap_boundaries .iter() .map(|b| layout.runs[b.run_ix].glyphs[b.glyph_ix].index) .collect(); breaks.insert(0, 0); - breaks.push(line.len()); + breaks.push(line.shape.len()); for (row, pair) in breaks.windows(2).enumerate() { let (from, to) = (pair[0], pair[1]); let x0 = layout.x_for_index(from); let width = layout.x_for_index(to) - x0; - let y = *top + self.line_height * row as f32; + let y = line.top + line.style.height * row as f32; rows.push(( - start + from..start + to, - self.bounds.origin + point(px(0.), y), + line.start + from..line.start + to, + self.bounds.origin + point(line.style.left, y), width, + line.style.height, )); } } @@ -326,14 +443,15 @@ impl LexicalEditor { fn vertical(&mut self, up: bool, extend: bool, cx: &mut Context) { let Some(layout) = &self.layout else { return }; let caret = self.surface.caret(); - let Some(at) = layout.position_of(caret) else { + let Some((at, height)) = layout.position_of(caret) else { return; }; let x = *self.goal_x.get_or_insert(at.x); + // Into the middle of the row above or below, whatever its height. let y = if up { - at.y - layout.line_height * 0.5 + at.y - px(4.) } else { - at.y + layout.line_height * 1.5 + at.y + height + px(4.) }; let to = if y < layout.bounds.top() { 0 @@ -350,8 +468,8 @@ impl LexicalEditor { let to = self.layout.as_ref().and_then(|l| { l.rows() .into_iter() - .find(|(r, _, _)| r.start <= caret && caret <= r.end) - .map(|(r, _, _)| if end { r.end } else { r.start }) + .find(|(r, ..)| r.start <= caret && caret <= r.end) + .map(|(r, ..)| if end { r.end } else { r.start }) }); let text = self.surface.display_text(); let to = to.unwrap_or(if end { text.len() } else { 0 }); @@ -365,6 +483,16 @@ impl LexicalEditor { cx: &mut Context, ) { window.focus(&self.focus_handle, cx); + // A check item's box, as the JS playground's CheckListPlugin + // toggles it on a click. + let check = self + .layout + .as_ref() + .and_then(|l| l.check_at(event.position)); + if let Some(block) = check { + self.edit(cx, |p| p.toggle_checked(block)); + return; + } let Some(layout) = &self.layout else { return }; let at = layout.index_at(event.position); self.selecting = true; @@ -506,8 +634,8 @@ impl EntityInputHandler for LexicalEditor { let layout = self.layout.as_ref()?; let text = &layout.text; let start = lexical_playground_core::byte_of_utf16(text, range.start); - let at = layout.position_of(start)?; - Some(Bounds::new(at, size(px(2.), layout.line_height))) + let (at, height) = layout.position_of(start)?; + Some(Bounds::new(at, size(px(2.), height))) } fn character_index_for_point( @@ -563,11 +691,8 @@ impl Render for LexicalEditor { .on_action(cx.listener(|this, _: &InsertLineBreak, _, cx| { this.edit(cx, Playground::insert_line_break) })) - .on_action( - cx.listener(|this, _: &InsertTab, _, cx| { - this.edit(cx, |p| p.insert_raw_text("\t")) - }), - ) + .on_action(cx.listener(|this, _: &InsertTab, _, cx| this.edit(cx, |p| p.tab(false)))) + .on_action(cx.listener(|this, _: &Outdent, _, cx| this.edit(cx, |p| p.tab(true)))) .on_action(cx.listener(|this, _: &Left, _, cx| this.horizontal(true, false, cx))) .on_action(cx.listener(|this, _: &Right, _, cx| this.horizontal(false, false, cx))) .on_action(cx.listener(|this, _: &SelectLeft, _, cx| this.horizontal(true, true, cx))) @@ -614,10 +739,20 @@ impl Render for LexicalEditor { } } -/// The text runs to shape: each run's format as a font, colors and lines. +/// The text runs to shape: each run's format as a font, colors and lines, +/// with a heading's weight, a link's color and underline, and a checked +/// item struck through, as the JS playground's theme draws them. fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec { let base_font = base.font(); let accent = theme.primary; + let doc = surface.playground.document(); + // The JS playground's link blue (#216FDB), lighter on a dark window: + // the kit's neutral theme draws links in the text color. + let link = if theme.is_dark() { + hsla(214. / 360., 0.9, 0.68, 1.) + } else { + hsla(214. / 360., 0.74, 0.49, 1.) + }; // A marker yellow behind dark text, a deep amber behind light text. let highlight = if theme.is_dark() { hsla(40. / 360., 0.75, 0.3, 1.) @@ -629,26 +764,38 @@ fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec .into_iter() .map(|run| { let has = |name| run.format & format_flag(name) != 0; + let block = doc.blocks.get(run.block); + let heading = block.and_then(|b| b.tag.as_deref()).and_then(heading_style); + let checked = block + .and_then(|b| b.list.as_ref()) + .is_some_and(|l| l.list_type == "check" && l.checked); let mut font = if has("code") { font("Menlo") } else { base_font.clone() }; + if let Some((_, weight)) = heading { + font.weight = weight; + } if has("bold") { font.weight = FontWeight::BOLD; } if has("italic") { font.style = FontStyle::Italic; } - let line = |on: bool| on.then_some(px(1.)); + let color = if run.link { + link + } else if has("subscript") || has("superscript") { + accent + } else if checked { + theme.muted_foreground + } else { + base.color + }; TextRun { len: run.range.len(), font, - color: if has("subscript") || has("superscript") { - accent - } else { - base.color - }, + color, background_color: if has("highlight") { Some(highlight) } else if has("code") { @@ -656,14 +803,16 @@ fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec } else { None }, - underline: (has("underline") || run.composing).then(|| UnderlineStyle { - thickness: px(1.), - color: Some(if run.composing { accent } else { base.color }), - wavy: false, + underline: (has("underline") || run.composing || run.link).then(|| { + UnderlineStyle { + thickness: px(1.), + color: Some(if run.composing { accent } else { color }), + wavy: false, + } }), - strikethrough: line(has("strikethrough")).map(|thickness| StrikethroughStyle { - thickness, - color: Some(base.color), + strikethrough: (has("strikethrough") || checked).then(|| StrikethroughStyle { + thickness: px(1.), + color: Some(color), }), } }) @@ -675,9 +824,13 @@ struct EditorElement { } struct Prepaint { - lines: Rc>, + lines: Rc>, selection: Vec, caret: Option, + /// List markers: where each goes, its row's height, and its shape. + markers: Vec<(Point, Pixels, ShapedLine)>, + /// Quotes' bars. + bars: Vec, } impl IntoElement for EditorElement { @@ -688,33 +841,62 @@ impl IntoElement for EditorElement { } } -/// Shapes `text` at `width`: each line's first byte, top and shape. +/// The part of `runs` (which cover the whole text) over `range`. +fn slice_runs(runs: &[TextRun], range: Range) -> Vec { + let mut out = Vec::new(); + let mut at = 0; + for run in runs { + let (from, to) = (at.max(range.start), (at + run.len).min(range.end)); + at += run.len; + if from < to { + let mut run = run.clone(); + run.len = to - from; + out.push(run); + } + } + out +} + +/// Shapes each line of `text` in its style, wrapping at `width` less the +/// line's indent. fn shape( window: &mut Window, text: &str, runs: &[TextRun], - font_size: Pixels, - line_height: Pixels, + styles: &[LineStyle], width: Option, -) -> Vec<(usize, Pixels, WrappedLine)> { - let shaped = window - .text_system() - .shape_text( - SharedString::from(text.to_owned()), - font_size, - runs, - width, - None, - ) - .unwrap_or_default(); +) -> Vec { let mut out = Vec::new(); let (mut start, mut top) = (0, px(0.)); - for line in shaped { - let rows = line.wrap_boundaries.len() + 1; - let len = line.len(); - out.push((start, top, line)); - start += len + 1; - top += line_height * rows as f32; + for (i, line_text) in text.split('\n').enumerate() { + let style = styles + .get(i) + .or(styles.last()) + .cloned() + .expect("a document has a block"); + let line_runs = slice_runs(runs, start..start + line_text.len()); + let wrap = width.map(|w| (w - style.left).max(px(40.))); + let shape = window + .text_system() + .shape_text( + SharedString::from(line_text.to_owned()), + style.size, + &line_runs, + wrap, + None, + ) + .ok() + .and_then(|lines| lines.into_iter().next()) + .unwrap_or_default(); + let line = Line { + start, + top, + style, + shape, + }; + start += line_text.len() + 1; + top = line.bottom(); + out.push(line); } out } @@ -742,8 +924,11 @@ impl Element for EditorElement { let text = editor.surface.display_text(); let style = window.text_style(); let runs = text_runs(&editor.surface, &style, cx.theme()); - let font_size = style.font_size.to_pixels(window.rem_size()); - let line_height = window.line_height(); + let styles = line_styles( + editor.surface.playground.document(), + style.font_size.to_pixels(window.rem_size()), + window.line_height(), + ); let mut layout_style = Style::default(); layout_style.size.width = relative(1.).into(); let id = @@ -752,10 +937,8 @@ impl Element for EditorElement { AvailableSpace::Definite(w) => Some(w), _ => None, }); - let lines = shape(window, &text, &runs, font_size, line_height, width); - let height = lines.last().map_or(line_height, |(_, top, line)| { - *top + line_height * (line.wrap_boundaries.len() + 1) as f32 - }); + let lines = shape(window, &text, &runs, &styles, width); + let height = lines.last().map_or(px(0.), Line::bottom); size(width.unwrap_or(px(0.)), height) }); (id, ()) @@ -774,30 +957,31 @@ impl Element for EditorElement { let text = editor.surface.display_text(); let style = window.text_style(); let runs = text_runs(&editor.surface, &style, cx.theme()); - let font_size = style.font_size.to_pixels(window.rem_size()); - let line_height = window.line_height(); + let styles = line_styles( + editor.surface.playground.document(), + style.font_size.to_pixels(window.rem_size()), + window.line_height(), + ); let lines = Rc::new(shape( window, &text, &runs, - font_size, - line_height, + &styles, Some(bounds.size.width), )); let layout = Layout { bounds, - line_height, text: text.clone(), lines: lines.clone(), }; let (range, _) = editor.surface.selection(); let mut selection = Vec::new(); if !range.is_empty() { - for (row, origin, width) in layout.rows() { + for (row, origin, width, height) in layout.rows() { if range.end < row.start || range.start > row.end { continue; } - let layout_x = |i: usize| layout.position_of(i).map_or(origin.x, |p| p.x); + let layout_x = |i: usize| layout.position_of(i).map_or(origin.x, |(p, _)| p.x); let x0 = if range.start <= row.start { origin.x } else { @@ -812,10 +996,7 @@ impl Element for EditorElement { }; if x1 > x0 { selection.push(fill( - Bounds::from_corners( - point(x0, origin.y), - point(x1, origin.y + line_height), - ), + Bounds::from_corners(point(x0, origin.y), point(x1, origin.y + height)), cx.theme().selection, )); } @@ -825,13 +1006,49 @@ impl Element for EditorElement { .is_empty() .then(|| layout.position_of(editor.surface.caret())) .flatten() - .map(|at| fill(Bounds::new(at, size(px(2.), line_height)), cx.theme().caret)); + .map(|(at, height)| fill(Bounds::new(at, size(px(2.), height)), cx.theme().caret)); + let mut markers = Vec::new(); + let mut bars = Vec::new(); + let muted = cx.theme().muted_foreground; + for line in lines.iter() { + let top = bounds.origin.y + line.top; + if let Some(marker) = &line.style.marker { + let color = if line.style.check { style.color } else { muted }; + let run = TextRun { + len: marker.len(), + font: style.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window.text_system().shape_line( + SharedString::from(marker.clone()), + line.style.size, + &[run], + None, + ); + let x = bounds.origin.x + line.style.left - shaped.width() - px(8.); + markers.push((point(x, top), line.style.height, shaped)); + } + if line.style.quote { + bars.push(fill( + Bounds::new( + point(bounds.origin.x + line.style.left - px(14.), top), + size(px(3.), line.bottom() - line.top), + ), + muted.opacity(0.5), + )); + } + } self.editor .update(cx, |editor, _| editor.layout = Some(layout)); Prepaint { lines, selection, caret, + markers, + bars, } } @@ -857,27 +1074,37 @@ impl Element for EditorElement { for quad in prepaint.selection.drain(..) { window.paint_quad(quad); } - let line_height = window.line_height(); - for (_, top, line) in prepaint.lines.iter() { - let origin = bounds.origin + point(px(0.), *top); - line.paint_background( - origin, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ) - .ok(); - line.paint( - origin, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ) - .ok(); + for quad in prepaint.bars.drain(..) { + window.paint_quad(quad); + } + for (origin, height, marker) in &prepaint.markers { + marker + .paint(*origin, *height, TextAlign::Left, None, window, cx) + .ok(); + } + for line in prepaint.lines.iter() { + let origin = bounds.origin + point(line.style.left, line.top); + let line_bounds = Bounds::from_corners(origin, bounds.bottom_right()); + line.shape + .paint_background( + origin, + line.style.height, + TextAlign::Left, + Some(line_bounds), + window, + cx, + ) + .ok(); + line.shape + .paint( + origin, + line.style.height, + TextAlign::Left, + Some(line_bounds), + window, + cx, + ) + .ok(); } if focus_handle.is_focused(window) && window.is_window_active() diff --git a/rust/apps/lexical-playground-rune/README.md b/rust/apps/lexical-playground-rune/README.md index 8724606c351..3dc4d5aa0dd 100644 --- a/rust/apps/lexical-playground-rune/README.md +++ b/rust/apps/lexical-playground-rune/README.md @@ -23,6 +23,22 @@ macOS first; it also builds and renders on Linux. - **The toolbar.** Bold, italic, underline, strikethrough, code, highlight, subscript and superscript follow the selection's format, as in the web playground. 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 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.** List items draw as the field's bullets and numbers at their + depth (a check item as a bullet, struck through when checked), a quote + with the field's bar, headings larger and bolder, and links as the + field's links. - **The side panel.** - **Tree:** the node tree with keys. - **JSON:** the editor state's JSON. @@ -37,7 +53,7 @@ macOS first; it also builds and renders on Linux. 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 diff --git a/rust/apps/lexical-playground-rune/src/main.rs b/rust/apps/lexical-playground-rune/src/main.rs index 74477c59afd..1253befed3f 100644 --- a/rust/apps/lexical-playground-rune/src/main.rs +++ b/rust/apps/lexical-playground-rune/src/main.rs @@ -88,13 +88,19 @@ impl State { self.edit.focused = focused; } - /// Hands the field's selection to the editor, before an edit. - fn take_selection(&mut self) { + /// The field's selection, which the toolbar shows: 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.edit.anchor), focus: doc.pos_at(self.edit.focus), - }; + } + } + + /// Hands the field's selection to the editor, before an edit. + fn take_selection(&mut self) { + let sel = self.shown_selection(); self.playground.set_selection(Some(sel)); } @@ -134,7 +140,7 @@ impl State { self.edit_with(|p| p.delete_character(backward)); } } - Key::Tab if !m.shift => self.edit_with(|p| p.insert_raw_text("\t")), + Key::Tab => self.edit_with(|p| p.tab(m.shift)), Key::Command(c) => match c { 'b' => self.edit_with(|p| p.format_text("bold")), 'i' => self.edit_with(|p| p.format_text("italic")), @@ -171,14 +177,23 @@ impl State { } /// The field's model for `doc`: its text, a mark per format the field -/// draws, and its line breaks as soft breaks. +/// draws, its links, a Rune block per line, and its line breaks as soft +/// breaks. pub fn mirror(doc: &Document, selection: Option) -> TextEdit { let mut marks = Vec::new(); + let mut blocks = Vec::new(); let mut offset = 0; for block in &doc.blocks { + let checked = block + .list + .as_ref() + .is_some_and(|l| l.list_type == "check" && l.checked); for run in &block.runs { if run.kind == RunKind::Text { let range = offset + run.start..offset + run.end(); + if range.is_empty() { + continue; + } for (name, mark) in [ ("bold", Mark::Bold), ("italic", Mark::Italic), @@ -186,30 +201,79 @@ pub fn mirror(doc: &Document, selection: Option) -> TextEdit { ("strikethrough", Mark::Strike), ("code", Mark::Code), ] { - if run.has_format(name) && !range.is_empty() { + // A checked item is struck through, as the JS + // playground's `.PlaygroundEditorTheme__listItemChecked`. + if run.has_format(name) || (checked && mark == Mark::Strike) { marks.push(MarkRange::new(range.clone(), mark)); } } + if let Some(url) = &run.link { + marks.push(MarkRange::new(range, Mark::Link(url.clone()))); + } } } + let lines = 1 + block + .runs + .iter() + .filter(|r| r.kind == RunKind::LineBreak) + .count(); + blocks.extend(std::iter::repeat_n(rune_block(block), lines)); offset += block.text.len() + 1; } - let text = doc.text(); - let lines = text.matches('\n').count() + 1; - let mut edit = TextEdit::from_parts(text, marks, vec![Block::Paragraph; lines]) - .with_soft_breaks(doc.soft_breaks()); + let mut edit = + TextEdit::from_parts(doc.text(), marks, blocks).with_soft_breaks(doc.soft_breaks()); let sel = selection.unwrap_or(Sel::caret(Pos::default())); edit.set_selection(doc.offset_of(sel.anchor), doc.offset_of(sel.focus)); edit } -/// Highlight, subscript and superscript, which the field has no marks for, -/// drawn over it. `dark` is the window's appearance: highlight is a marker +/// How the field draws `block`: a list item as a bullet or a number at its +/// list's depth (a check item as a bullet; its box is the toolbar's Check +/// button), a quote with the field's quote bar. Headings are paragraphs +/// with larger text (see [`decorations`]). +fn rune_block(block: &lexical_playground_core::Block) -> Block { + if let Some(list) = &block.list { + let level = list.depth.min(usize::from(rune::MAX_LIST_LEVEL)) as u8; + return match list.list_type.as_str() { + "number" => Block::Numbered(level), + _ => Block::Bullet(level), + }; + } + match block.kind.as_str() { + "quote" => Block::Quote, + "code" => Block::Code, + _ => Block::Paragraph, + } +} + +/// A heading's text size and weight, as the JS playground's theme +/// (`h1` 24px ... `h6`, in the field's 16-point body). +fn heading_style(tag: &str) -> Option<(f32, f32)> { + Some(match tag { + "h1" => (26.0, 700.0), + "h2" => (21.0, 700.0), + "h3" => (18.0, 650.0), + "h4" => (16.0, 650.0), + "h5" => (14.0, 650.0), + "h6" => (13.0, 650.0), + _ => return None, + }) +} + +/// Headings, highlight, subscript and superscript, which the field has no +/// marks or blocks for, drawn over it. `dark` is the window's appearance: highlight is a marker /// yellow behind dark text and a deep amber behind light text. pub fn decorations(doc: &Document, dark: bool) -> Vec { let mut spans = Vec::new(); let mut offset = 0; for block in &doc.blocks { + if let Some((size, weight)) = block.tag.as_deref().and_then(heading_style) + && !block.text.is_empty() + { + let mut span = rune::Span::new(offset, offset + block.text.len()).weight(weight); + span.size = Some(size); + spans.push(span); + } for run in &block.runs { let (start, end) = (offset + run.start, offset + run.end()); if run.kind != RunKind::Text || start == end { diff --git a/rust/apps/lexical-playground-rune/src/tests.rs b/rust/apps/lexical-playground-rune/src/tests.rs index ff834017262..dfd10a1dd0f 100644 --- a/rust/apps/lexical-playground-rune/src/tests.rs +++ b/rust/apps/lexical-playground-rune/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use lexical_playground_core::format_flag; use rune::{Env, Mods, Runtime}; fn runtime() -> Runtime { @@ -27,13 +28,14 @@ fn typed(rt: &mut Runtime, s: &str) { fn typing_return_and_backspace_edit_the_lexical_document() { let mut rt = runtime(); // The caret starts at the document's start. + let blocks = rt.state.playground.document().blocks.len(); typed(&mut rt, "Hi "); press(&mut rt, Key::Enter, Mods::default()); typed(&mut rt, "x"); press(&mut rt, Key::Backspace, Mods::default()); let doc = rt.state.playground.document(); assert_eq!(rt.state.playground.last_error(), None); - assert_eq!(doc.blocks.len(), 3); + assert_eq!(doc.blocks.len(), blocks + 1); assert_eq!(doc.blocks[0].text, "Hi "); assert!(doc.blocks[1].text.starts_with("Hello from ")); // The field mirrors the document, with the caret where Lexical put it. @@ -94,3 +96,170 @@ fn a_toolbar_button_formats_the_selection_and_keeps_the_keyboard() { .starts_with("!") ); } + +/// Presses the button keyed `key`. Rune ignores a press on a button that +/// appeared that instant (the check button appears with the caret in a +/// check item), so a moment passes first, as it does for a person. +fn click(rt: &mut Runtime, key: &str) { + rt.frame(1.0); + let (x, y) = rt.find(key).unwrap_or_else(|| panic!("the {key} button")); + rt.pointer_down(x, y); + rt.pointer_up(x, y); + rt.frame(0.0); +} + +#[test] +fn the_field_draws_the_examples_lists_quote_and_link() { + let rt = runtime(); + let doc = rt.state.playground.document(); + let kinds: Vec<&str> = doc.blocks.iter().map(|b| b.block_type()).collect(); + assert_eq!( + kinds, + [ + "paragraph", + "paragraph", + "h2", + "bullet", + "bullet", + "check", + "check", + "quote" + ] + ); + assert_eq!( + rt.state.edit.blocks(), + &[ + Block::Paragraph, + Block::Paragraph, + Block::Paragraph, + Block::Bullet(0), + Block::Bullet(0), + Block::Bullet(0), + Block::Bullet(0), + Block::Quote, + ] + ); + let link = rt + .state + .edit + .marks() + .iter() + .find(|m| matches!(&m.mark, Mark::Link(url) if url == "https://lexical.dev")) + .expect("the link's mark"); + assert_eq!(&rt.state.edit.text[link.range()], "lexical.dev"); + // The h2 draws larger and heavier. + let heading = doc.offset_of(Pos { block: 2, byte: 0 }); + assert!( + decorations(doc, false) + .iter() + .any(|s| s.start as usize == heading && s.size == Some(21.0)) + ); +} + +#[test] +fn the_block_buttons_convert_the_caret_block_and_show_its_type() { + let mut rt = runtime(); + rt.update(|s, _| s.edit.set_selection(2, 2)); + rt.frame(0.0); + click(&mut rt, "block-quote"); + assert_eq!(rt.state.playground.block_type(), "quote"); + assert_eq!(rt.state.edit.blocks()[0], Block::Quote); + assert!(rt.state.edit.focused); + click(&mut rt, "block-number"); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(0)); + // Tab at an item's start nests it; Shift-Tab brings it back. + rt.update(|s, _| s.edit.set_selection(0, 0)); + press(&mut rt, Key::Tab, Mods::default()); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(1)); + press( + &mut rt, + Key::Tab, + Mods { + shift: true, + ..Mods::default() + }, + ); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(0)); + // The same list again makes it a paragraph. + click(&mut rt, "block-number"); + assert_eq!(rt.state.playground.block_type(), "paragraph"); + assert_eq!(rt.state.edit.blocks()[0], Block::Paragraph); + assert_eq!(rt.state.playground.last_error(), None); +} + +#[test] +fn the_link_button_links_the_selection_and_unlinks_it() { + let mut rt = runtime(); + rt.update(|s, _| s.edit.set_selection(0, 5)); + rt.frame(0.0); + click(&mut rt, "link"); + assert_eq!(rt.state.playground.link(), Some("https://")); + assert!(rt.state.edit.marks().iter().any(|m| { + matches!(&m.mark, Mark::Link(url) if url == "https://") && m.range() == (0..5) + })); + click(&mut rt, "link"); + assert_eq!(rt.state.playground.link(), None); + assert!( + !rt.state + .edit + .marks() + .iter() + .any(|m| matches!(&m.mark, Mark::Link(url) if url == "https://")) + ); +} + +#[test] +fn the_check_button_toggles_the_caret_item_and_strikes_it_when_checked() { + let mut rt = runtime(); + let checked = |rt: &Runtime| { + rt.state.playground.document().blocks[6] + .list + .as_ref() + .is_some_and(|l| l.checked) + }; + let struck = |rt: &Runtime| { + let doc = rt.state.playground.document(); + let item = doc.offset_of(Pos { block: 6, byte: 0 }); + let len = doc.blocks[6].text.len(); + rt.state + .edit + .marks() + .iter() + .any(|m| m.mark == Mark::Strike && m.range() == (item..item + len)) + }; + // The example's second check item starts unchecked. + assert!(!checked(&rt) && !struck(&rt)); + let item = rt + .state + .playground + .document() + .offset_of(Pos { block: 6, byte: 3 }); + rt.update(|s, _| s.edit.set_selection(item, item)); + rt.frame(0.0); + click(&mut rt, "check-item"); + assert!(checked(&rt) && struck(&rt)); + click(&mut rt, "check-item"); + assert!(!checked(&rt) && !struck(&rt)); + assert_eq!(rt.state.playground.last_error(), None); +} + +#[test] +fn the_toolbar_follows_a_click_before_lexical_hears_of_it() { + let mut rt = runtime(); + let steps = rt.state.playground.step_count(); + // Into "Rust", which is bold, then into the h2: no edit yet. + rt.update(|s, _| s.edit.set_selection(13, 13)); + rt.frame(0.0); + let format = rt.state.playground.format_at(rt.state.shown_selection()); + assert_eq!(format, format_flag("bold")); + let h2 = rt + .state + .playground + .document() + .offset_of(Pos { block: 2, byte: 1 }); + rt.update(|s, _| s.edit.set_selection(h2, h2)); + rt.frame(0.0); + let sel = rt.state.shown_selection(); + assert_eq!(rt.state.playground.block_type_at(sel), "h2"); + assert_eq!(rt.state.playground.step_count(), steps); +} diff --git a/rust/apps/lexical-playground-rune/src/view.rs b/rust/apps/lexical-playground-rune/src/view.rs index ca581e8f916..35b64db3a20 100644 --- a/rust/apps/lexical-playground-rune/src/view.rs +++ b/rust/apps/lexical-playground-rune/src/view.rs @@ -2,7 +2,7 @@ //! tree, its JSON, and parity with JS Lexical. use crate::{Panel, State, decorations, on_change, on_key, on_paste, on_window_focus}; -use lexical_playground_core::{Playground, example, replay_vectors}; +use lexical_playground_core::{Playground, example, format_flag, replay_vectors}; use rune::{Color, Cx, El, ScrollSpec, TextStyle, col, row, text, text_input}; use rune_kit::{Button, ButtonVariant, Kit, SegmentedControl}; use std::sync::OnceLock; @@ -28,6 +28,23 @@ const FORMATS: [(&str, &str); 8] = [ ("superscript", "x²"), ]; +/// The block menu, as the JS playground's `BlockFormatDropDown`: each +/// button is a `blockType` and its label. +const BLOCKS: [(&str, &str); 8] = [ + ("paragraph", "Normal"), + ("h1", "H1"), + ("h2", "H2"), + ("h3", "H3"), + ("quote", "Quote"), + ("bullet", "• List"), + ("number", "1. List"), + ("check", "☑ List"), +]; + +/// The URL a new link gets, as the JS playground's link button inserts +/// `https://` for the user to edit. +const NEW_LINK_URL: &str = "https://"; + pub fn view(s: &State, cx: &mut Cx) -> El { let k = kit(cx.system_dark()); col() @@ -36,6 +53,7 @@ pub fn view(s: &State, cx: &mut Cx) -> El { .fill(k.c.page) .on_window_focus(on_window_focus) .child(toolbar(s, k, cx.chrome_insets().leading)) + .child(block_bar(s, k)) .child( row() .w_full() @@ -58,8 +76,9 @@ fn toolbar(s: &State, k: &Kit, leading: f32) -> El { .pl(if leading > 0.0 { leading + 12.0 } else { 10.0 }) .fill(k.c.bg_base); bar = bar.child(text("Lexical").font(k.ty.body_medium).color(k.c.text_base)); + let format = s.playground.format_at(s.shown_selection()); for (name, label) in FORMATS { - let on = s.playground.has_format(name); + let on = format & format_flag(name) != 0; bar = bar.child( Button::new(label) .variant(if on { @@ -95,6 +114,89 @@ fn toolbar(s: &State, k: &Kit, leading: f32) -> El { ) } +fn toggle(label: &str, on: bool, help: String) -> Button { + Button::new(label) + .variant(if on { + ButtonVariant::Contrast + } else { + ButtonVariant::Ghost + }) + .help(help) +} + +/// The second row: the block menu, the link button, indent and outdent, +/// and a check item's box. +fn block_bar(s: &State, k: &Kit) -> El { + let p = &s.playground; + let sel = s.shown_selection(); + let current = p.block_type_at(sel); + let mut bar = row().w_full().gap(6.0).px(10.0).pb(10.0).fill(k.c.bg_base); + for (block_type, label) in BLOCKS { + bar = bar.child( + toggle(label, current == block_type, format!("Format as {label}")) + .on_press(move |s: &mut State| s.toolbar_edit(|p| p.set_block_type(block_type))) + .build(k) + .key(format!("block-{block_type}")), + ); + } + let link = p.link_at(sel).map(str::to_owned); + bar = bar.child( + toggle( + "Link", + link.is_some(), + match &link { + Some(url) => format!("Remove the link to {url}"), + None => "Insert a link".to_owned(), + }, + ) + .on_press(move |s: &mut State| { + let remove = s.playground.link_at(s.shown_selection()).is_some(); + s.toolbar_edit(|p| p.toggle_link((!remove).then_some(NEW_LINK_URL))) + }) + .build(k) + .key("link"), + ); + bar = bar + .child( + Button::new("Outdent") + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut State| s.toolbar_edit(|p| p.indent(true))) + .build(k) + .key("outdent"), + ) + .child( + Button::new("Indent") + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut State| s.toolbar_edit(|p| p.indent(false))) + .build(k) + .key("indent"), + ); + let block = sel.anchor.block; + if let Some(list) = p.document().blocks.get(block).and_then(|b| b.list.as_ref()) + && list.list_type == "check" + { + bar = bar.child( + toggle( + if list.checked { "☑ Done" } else { "☐ Done" }, + list.checked, + "Check or uncheck this item".to_owned(), + ) + .on_press(move |s: &mut State| s.toolbar_edit(|p| p.toggle_checked(block))) + .build(k) + .key("check-item"), + ); + } + if let Some(url) = link { + bar = bar.child( + text(url) + .font(k.ty.small) + .color(k.c.text_muted) + .key("link-url"), + ); + } + bar +} + fn editor(s: &State, k: &Kit) -> El { // The field's own ink is white, for a dark window; the kit's follows // the system's appearance. From 7bfbfca770f515f77472e60bd75c5fb7cea5afb9 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 18:05:37 +0000 Subject: [PATCH 2/2] feat(rust): markdown shortcuts in both desktop playgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Typing Markdown in the Rust playgrounds left it as text: the playground editor never registered the markdown shortcuts, and Return called `insertParagraph` directly, so the Enter-triggered transformers (code fences) could not run. - `lexical-playground-core` registers `register_markdown_shortcuts` with the default transformers. Its script flags become `["richText","list","link","code","markdown"]`, so an exported session replays in JS with the same shortcuts. Return now dispatches `KEY_ENTER_COMMAND`, as a browser keydown does; rich text's handler then dispatches `INSERT_PARAGRAPH_COMMAND` unless the shortcuts or the code block took it. - GPUI draws code blocks with a monospace font, padding and a tinted background, so a typed code fence is visible. Its e2e run types a heading, bold text, a list item and a code fence. - Both READMEs list the shortcuts. - This branch merges `rust-playground-blocks` (#141), which the playgrounds' block rendering needs. ## Test plan ### Before ``` $ cd rust/apps/lexical-playground-core && cargo test --locked markdown thread 'tests::markdown_shortcuts_turn_typed_markdown_into_rich_text' panicked at apps/lexical-playground-core/src/tests.rs:497:5: assertion `left == right` failed left: "paragraph" right: "h1" test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 19 filtered out ``` ### After ``` $ cd rust/apps/lexical-playground-core && cargo test --locked test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out $ cd rust/apps/lexical-playground-rune && cargo test --locked # macOS test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out $ cd rust/apps/lexical-playground-gpui && cargo build --locked --features e2e && \ LEXICAL_PLAYGROUND_E2E=/tmp/s.json ./target/debug/lexical-playground-gpui # macOS e2e: passed $ cd packages/lexical-conformance && LEXICAL_REPLAY=/tmp/s.json pnpm exec vitest run src/replay.test.ts Test Files 1 passed (1) ``` On macOS, real keystrokes typed into GPUI through System Events (a heading, lists, a quote, inline formats, a link, a code fence, then ⌘Z) produced a 163-step session that replays in JS, and the in-app Replay vectors shows 655/655. Real keystrokes could not be delivered to the Rune app on the Mac satellite (it does not redraw for synthetic events, also without this change), so Rune is covered by its runtime tests only. Amp-Thread-ID: https://ampcode.com/threads/T-01a1daf1-150e-709b-aaed-996443a64459 --- rust/apps/lexical-playground-core/src/lib.rs | 17 ++-- .../apps/lexical-playground-core/src/tests.rs | 39 +++++++++ rust/apps/lexical-playground-gpui/README.md | 15 +++- rust/apps/lexical-playground-gpui/src/e2e.rs | 85 ++++++++++++++++++- .../lexical-playground-gpui/src/editor.rs | 30 ++++++- rust/apps/lexical-playground-rune/README.md | 13 ++- .../apps/lexical-playground-rune/src/tests.rs | 43 ++++++++++ 7 files changed, 229 insertions(+), 13 deletions(-) diff --git a/rust/apps/lexical-playground-core/src/lib.rs b/rust/apps/lexical-playground-core/src/lib.rs index 13bcd5d8bd4..072029e8988 100644 --- a/rust/apps/lexical-playground-core/src/lib.rs +++ b/rust/apps/lexical-playground-core/src/lib.rs @@ -846,10 +846,11 @@ impl Playground { pub fn with_clock(children: Vec, clock: Clock) -> Result { let mut script = Object::new(); script.insert("name", "playground".into()); - // `RichTextExtension`, `ListExtension`, `CheckListExtension` and - // `LinkExtension`: what the JS playground's editor has for these - // blocks and links. - for flag in ["richText", "list", "link"] { + // `RichTextExtension`, `ListExtension`, `CheckListExtension`, + // `LinkExtension`, `CodeExtension` and `MarkdownShortcutPlugin` + // (`registerMarkdownShortcuts` with `TRANSFORMERS`): what the JS + // playground's editor has for these blocks, links and shortcuts. + for flag in ["richText", "list", "link", "code", "markdown"] { script.insert(flag, true.into()); } let mut history = Object::new(); @@ -990,10 +991,12 @@ impl Playground { self.edit("insertRawText", vec![text.into()]) } - /// Enter: `INSERT_PARAGRAPH_COMMAND`, as rich text's `KEY_ENTER_COMMAND` - /// handler dispatches it, so a list item's handler runs. + /// Enter: `KEY_ENTER_COMMAND` with no event, as an input layer without + /// a DOM dispatches it. The markdown shortcuts and the code block take + /// it first; otherwise rich text's handler dispatches + /// `INSERT_PARAGRAPH_COMMAND`, so a list item's handler runs. pub fn insert_paragraph(&mut self) -> Outcome { - self.dispatch("INSERT_PARAGRAPH_COMMAND", None) + self.dispatch("KEY_ENTER_COMMAND", None) } /// Shift-Enter: `INSERT_LINE_BREAK_COMMAND`. diff --git a/rust/apps/lexical-playground-core/src/tests.rs b/rust/apps/lexical-playground-core/src/tests.rs index bcb9e96fb2c..b5422045378 100644 --- a/rust/apps/lexical-playground-core/src/tests.rs +++ b/rust/apps/lexical-playground-core/src/tests.rs @@ -481,3 +481,42 @@ fn the_tree_shows_lists_above_their_items() { ); 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); +} diff --git a/rust/apps/lexical-playground-gpui/README.md b/rust/apps/lexical-playground-gpui/README.md index 69757176ee3..fc84a1ac9fd 100644 --- a/rust/apps/lexical-playground-gpui/README.md +++ b/rust/apps/lexical-playground-gpui/README.md @@ -42,7 +42,17 @@ zbugs-gpui is. macOS. 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, and links in blue, underlined. + 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.** @@ -90,7 +100,8 @@ 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, 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, through the input +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 diff --git a/rust/apps/lexical-playground-gpui/src/e2e.rs b/rust/apps/lexical-playground-gpui/src/e2e.rs index b9f64345e59..e5ba1d58abb 100644 --- a/rust/apps/lexical-playground-gpui/src/e2e.rs +++ b/rust/apps/lexical-playground-gpui/src/e2e.rs @@ -2,7 +2,8 @@ //! `e2e` feature and run with `LEXICAL_PLAYGROUND_E2E=`, the app //! drives its own window once it's up, the way macOS does — text through //! the window's input handler (typing and an input method's composition) -//! and keys through their bindings, undo and redo included — checks the +//! and keys through their bindings, undo and redo and the markdown +//! shortcuts included — checks the //! document after each step, //! writes the session to `` as a vector, and exits 0, or 1 with the //! step that failed. CI then replays the file in JS Lexical @@ -285,5 +286,87 @@ async fn drive(window: AnyWindowHandle, app: &Entity, cx: &mut As "Type, delete, press Enter, or select text and make it bold." ) ); + + // Markdown shortcuts (`registerMarkdownShortcuts`), typed a key at a + // time: `## ` before the paragraph's text makes it a heading, `**md**` + // makes bold text, `- ` a list item, and `` ``` `` then Return a code + // block. + let block_at = |i: usize, want: &'static str| { + let editor = editor.clone(); + move |cx: &mut AsyncApp| -> Step { + let got = editor.read_with(cx, |e, _| { + let blocks = &e.surface.playground.document().blocks; + blocks.get(i).map(|b| match b.kind.as_str() { + "code" => "code".to_owned(), + _ => b.block_type().to_owned(), + }) + }); + if got.as_deref() == Some(want) { + Ok(()) + } else { + Err(format!("block {i} is {got:?}, not {want}")) + } + } + }; + let nothing = |_: &mut AsyncApp| -> Step { Ok(()) }; + for k in ["#", "#"] { + step!("Typing ## ", typed(k), nothing); + } + step!("Typing ## ", typed(" "), block_at(1, "h2")); + step!( + "↓ to the heading's second line", + key(Box::new(Down)), + nothing + ); + step!("To its end", key(Box::new(LineEnd)), expect(2, "next")); + for k in [" ", "*", "*", "m", "d", "*"] { + step!("Typing **md**", typed(k), nothing); + } + step!("Typing **md**", typed("*"), expect(2, "next md")); + step!( + "md is bold", + |_: &mut Window, _: &mut App| {}, + |cx: &mut AsyncApp| -> Step { + let bold = editor.read_with(cx, |e, _| { + e.surface.playground.document().blocks[1] + .runs + .iter() + .any(|r| r.text == "md" && r.has_format("bold")) + }); + if bold { + Ok(()) + } else { + Err("md is not bold".into()) + } + } + ); + step!( + "Return", + key(Box::new(InsertParagraph)), + block_at(2, "paragraph") + ); + for k in ["-", " "] { + step!("Typing - ", typed(k), nothing); + } + step!("Typing an item", typed("item"), block_at(2, "bullet")); + step!( + "Return", + key(Box::new(InsertParagraph)), + block_at(3, "bullet") + ); + step!( + "Return ends the list", + key(Box::new(InsertParagraph)), + block_at(3, "paragraph") + ); + for k in ["`", "`", "`"] { + step!("Typing ```", typed(k), nothing); + } + step!( + "Return makes a code block", + key(Box::new(InsertParagraph)), + block_at(3, "code") + ); + step!("Typing code", typed("let x = 1;"), block_at(3, "code")); Ok(()) } diff --git a/rust/apps/lexical-playground-gpui/src/editor.rs b/rust/apps/lexical-playground-gpui/src/editor.rs index cdbcaf85e8d..09125a6ed9d 100644 --- a/rust/apps/lexical-playground-gpui/src/editor.rs +++ b/rust/apps/lexical-playground-gpui/src/editor.rs @@ -117,11 +117,16 @@ struct LineStyle { /// Whether the marker is a check box, which a click toggles. check: bool, quote: bool, + /// A code block's line: monospaced, on a tinted background. + code: bool, } /// One indent step: a list's nesting level, or a block's `indent`. const INDENT: f32 = 24.; +/// How far a code block's text is inset in its background. +const CODE_PADDING: f32 = 12.; + /// A heading's text size and weight, as the JS playground's theme (and the /// Rune app) draws `h1` ... `h6` in a 16-point body. fn heading_style(tag: &str) -> Option<(f32, FontWeight)> { @@ -153,6 +158,7 @@ fn line_styles(doc: &Document, size: Pixels, height: Pixels) -> Vec { marker: None, check: false, quote: block.kind == "quote", + code: block.kind == "code", }; if let Some(list) = &block.list { style.left = px(INDENT * (list.depth + 1) as f32); @@ -164,6 +170,8 @@ fn line_styles(doc: &Document, size: Pixels, height: Pixels) -> Vec { }); } else if style.quote { style.left += px(16.); + } else if style.code { + style.left += px(CODE_PADDING); } let breaks = block .runs @@ -769,7 +777,8 @@ fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec let checked = block .and_then(|b| b.list.as_ref()) .is_some_and(|l| l.list_type == "check" && l.checked); - let mut font = if has("code") { + let in_code_block = block.is_some_and(|b| b.kind == "code"); + let mut font = if has("code") || in_code_block { font("Menlo") } else { base_font.clone() @@ -831,6 +840,8 @@ struct Prepaint { markers: Vec<(Point, Pixels, ShapedLine)>, /// Quotes' bars. bars: Vec, + /// Code blocks' backgrounds, under the selection. + backgrounds: Vec, } impl IntoElement for EditorElement { @@ -1009,6 +1020,7 @@ impl Element for EditorElement { .map(|(at, height)| fill(Bounds::new(at, size(px(2.), height)), cx.theme().caret)); let mut markers = Vec::new(); let mut bars = Vec::new(); + let mut backgrounds = Vec::new(); let muted = cx.theme().muted_foreground; for line in lines.iter() { let top = bounds.origin.y + line.top; @@ -1031,6 +1043,18 @@ impl Element for EditorElement { let x = bounds.origin.x + line.style.left - shaped.width() - px(8.); markers.push((point(x, top), line.style.height, shaped)); } + if line.style.code { + backgrounds.push(fill( + Bounds::new( + point(bounds.origin.x + line.style.left - px(CODE_PADDING), top), + size( + bounds.size.width - line.style.left + px(CODE_PADDING), + line.bottom() - line.top, + ), + ), + muted.opacity(0.12), + )); + } if line.style.quote { bars.push(fill( Bounds::new( @@ -1049,6 +1073,7 @@ impl Element for EditorElement { caret, markers, bars, + backgrounds, } } @@ -1071,6 +1096,9 @@ impl Element for EditorElement { ElementInputHandler::new(bounds, self.editor.clone()), cx, ); + for quad in prepaint.backgrounds.drain(..) { + window.paint_quad(quad); + } for quad in prepaint.selection.drain(..) { window.paint_quad(quad); } diff --git a/rust/apps/lexical-playground-rune/README.md b/rust/apps/lexical-playground-rune/README.md index 3dc4d5aa0dd..4fb01ec716e 100644 --- a/rust/apps/lexical-playground-rune/README.md +++ b/rust/apps/lexical-playground-rune/README.md @@ -37,8 +37,17 @@ macOS first; it also builds and renders on Linux. it. - **Blocks.** List items draw as the field's bullets and numbers at their depth (a check item as a bullet, struck through when checked), a quote - with the field's bar, headings larger and bolder, and links as the - field's links. + with the field's bar, a code block as the field's, headings larger and + bolder, and links as the field's links. +- **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`. - **The side panel.** - **Tree:** the node tree with keys. - **JSON:** the editor state's JSON. diff --git a/rust/apps/lexical-playground-rune/src/tests.rs b/rust/apps/lexical-playground-rune/src/tests.rs index dfd10a1dd0f..528d1a72b18 100644 --- a/rust/apps/lexical-playground-rune/src/tests.rs +++ b/rust/apps/lexical-playground-rune/src/tests.rs @@ -263,3 +263,46 @@ fn the_toolbar_follows_a_click_before_lexical_hears_of_it() { assert_eq!(rt.state.playground.block_type_at(sel), "h2"); assert_eq!(rt.state.playground.step_count(), steps); } + +#[test] +fn markdown_shortcuts_typed_in_the_field_become_rich_text() { + let mut rt = runtime(); + // A new line at the document's end (set directly: End moves to the + // line's end on Linux and to the document's on macOS), then markdown + // as typed keys. + let before = rt.state.playground.document().blocks.len(); + rt.update(|s, _| { + let end = s.edit.text.len(); + s.edit.set_selection(end, end); + }); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "## Title"); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "- **bold** item"); + press(&mut rt, Key::Enter, Mods::default()); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "```"); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "code"); + assert_eq!(rt.state.playground.last_error(), None); + let doc = rt.state.playground.document(); + assert_eq!(doc.blocks.len(), before + 3); + let [heading, item, code] = &doc.blocks[before..] else { + unreachable!() + }; + assert_eq!(heading.block_type(), "h2"); + assert_eq!(heading.text, "Title"); + assert_eq!(item.block_type(), "bullet"); + assert_eq!(item.text, "bold item"); + assert!(item.runs[0].has_format("bold")); + assert_eq!(code.kind, "code"); + assert_eq!(code.text, "code"); + assert!(matches!(rune_block(code), Block::Code)); + // The field mirrors the document, with the caret at the code's end. + assert_eq!(rt.state.edit.text, doc.text()); + let caret = doc.offset_of(Pos { + block: before + 2, + byte: 4, + }); + assert_eq!(rt.state.edit.range(), (caret, caret)); +}