T21a: Documents, editing
- markdown read with pulldown-cmark: marks for live styling, blocks, links, words - forms::Highlight gains weight, slant and strike; measure and paint share runs - Inter Italic embedded, so emphasis reads slanted - forms::mention's popup and keys become Suggestions, shared with the editors - RichTextEditor with FixedFormatToolbar, the floating toolbar, LinkEditor, @ and : suggestions, WordCount and ReadingTime - MarkdownEditor: visual (a pressed block opens as source), source, split - BlockEditor: prose, lists, to-dos, quotes, callouts, toggles, code, math, diagrams the app draws, dividers, tables that grow and merge, pictures to resize and align, cards for video and pages, columns, synced text; slash menu, markdown shortcuts, handles that drag and open a menu, one undo for all - TodoItem and Checklist - icons: heading-1..3, workflow, merge - gallery: the Documents page with scripted states
This commit is contained in:
46 files changed
+5635
-193
No files matched your search
@@ -6,12 +6,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
|
||||
- Rust 1.95, edition 2024. One crate: `ely-gpui-component`.
|
||||
- gpui 0.2.2 from crates.io. Feature `runtime_shaders` is on by default: machines without Xcode lack the Metal compiler.
|
||||
- Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 and JetBrains Mono 2.304 (OFL).
|
||||
- Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 (regular, medium, semibold, italic) and JetBrains Mono 2.304 (OFL).
|
||||
- Catalog data: `emojis` (Unicode emoji), `isolang` (ISO 639, native names), `isocountry` (ISO 3166), `iso_currency` (ISO 4217, also minor units for money).
|
||||
- Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off.
|
||||
- macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links.
|
||||
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events.
|
||||
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
|
||||
- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math.
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
|
||||
|
||||
## Commands
|
||||
@@ -110,6 +111,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
|
||||
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
|
||||
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
||||
- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width.
|
||||
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
|
||||
- A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do.
|
||||
- A terminal takes Tab and Shift-Tab through bindings under `ElyTerminal`, ahead of focus moves; other keys go through `terminal::keys` in a key listener, and typed text through its input handler.
|
||||
- A shell in the gallery starts without startup files and with a fixed prompt, so captures show no user or host.
|
||||
- gpui 0.2.2 has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`.
|
||||
|
||||
Generated
+12
@@ -1597,6 +1597,7 @@ dependencies = [
|
||||
"jiff",
|
||||
"log",
|
||||
"objc",
|
||||
"pulldown-cmark",
|
||||
"qrcode",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
@@ -4345,6 +4346,17 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"memchr",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.22.3"
|
||||
|
||||
@@ -25,6 +25,7 @@ isocountry = "0.3"
|
||||
isolang = { version = "2.4", features = ["local_names", "list_languages"] }
|
||||
jiff = "0.2"
|
||||
log = "0.4"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
regex = "1"
|
||||
rust-embed = "8.12"
|
||||
|
||||
@@ -68,7 +68,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T18 Terminal
|
||||
- [x] T19 Git
|
||||
- [x] T20 Debug
|
||||
- [ ] T21a Documents · Editing — `tasks/ch21-30.md`
|
||||
- [x] T21a Documents · Editing — `tasks/ch21-30.md`
|
||||
- [ ] T21b Documents · Reading
|
||||
- [ ] T21c Documents · Knowledge
|
||||
- [ ] T22 Collaboration
|
||||
@@ -247,3 +247,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T19 | 3 | PASS | |
|
||||
| T20 | 1 | FAIL | the demo picked past memory's end; an empty gutter had no height to press; a breakpoint's box also opened it; zoomed ticks lost their places; docs promised bound keys and batch actions |
|
||||
| T20 | 2 | PASS | |
|
||||
| T21a | 1 | — | pending review |
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-heading-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 12h8" />
|
||||
<path d="M4 18V6" />
|
||||
<path d="M12 18V6" />
|
||||
<path d="m17 12 3-2v8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-heading-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 12h8" />
|
||||
<path d="M4 18V6" />
|
||||
<path d="M12 18V6" />
|
||||
<path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-heading-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 12h8" />
|
||||
<path d="M4 18V6" />
|
||||
<path d="M12 18V6" />
|
||||
<path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" />
|
||||
<path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-merge"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m8 6 4-4 4 4" />
|
||||
<path d="M12 2v10.3a4 4 0 0 1-1.172 2.872L4 22" />
|
||||
<path d="m20 22-5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-workflow"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="8" height="8" x="3" y="3" rx="2" />
|
||||
<path d="M7 11v4a2 2 0 0 0 2 2h4" />
|
||||
<rect width="8" height="8" x="13" y="13" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 430 B |
@@ -0,0 +1,234 @@
|
||||
use ely_gpui_component::{
|
||||
documents::{
|
||||
BlockData, BlockEditor, BlockKind, Checklist, MarkdownEditor, MarkdownMode, Media,
|
||||
RichTextEditor, markdown_highlights,
|
||||
},
|
||||
forms::TextInput,
|
||||
};
|
||||
use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
const ATRIUM: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium.jpg"
|
||||
);
|
||||
|
||||
const PEOPLE: [&str; 4] = ["ada", "grace", "linus", "margaret"];
|
||||
|
||||
const NOTES: &str = "## Release notes\n\nBlend every accent **toward white** by *lift*, clamped in gamma space. See [the palette docs](https://ely.dev/palette) or ask @ada.\n\n- Tokens stay in `src/theme`\n- ~~Raw pixels~~ never\n- [x] Light and dark";
|
||||
|
||||
const ESSAY: &str = "# Lift\n\nA lift blends a color toward white. **Half** a lift sits midway, and a *full* one is white.\n\n> Tokens live in one place; components only read them.\n\n| Token | Light | Dark |\n|:--|--:|--:|\n| accent | 0.50 | 0.62 |\n| focus | 0.40 | 0.55 |\n\n```rust\nfn lift(color: Color, by: f32) -> Color {\n color.mix(WHITE, by)\n}\n```\n\n$$c' = c + (1 - c)\\,t$$";
|
||||
|
||||
/// A markdown field of several lines, styled as it is typed.
|
||||
fn markdown(
|
||||
key: &'static str,
|
||||
text: &'static str,
|
||||
rows: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
let mut field = TextInput::new(window, cx)
|
||||
.multi_line(rows, rows * 3)
|
||||
.highlighter(markdown_highlights);
|
||||
field.set_text(text, cx);
|
||||
field
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws `graph LR; a --> b --> c` as boxes in a row, as a host's renderer would.
|
||||
fn draw(source: &str) -> Result<String, String> {
|
||||
let (head, body) = source
|
||||
.split_once(';')
|
||||
.ok_or("start with `graph LR;` then name the steps")?;
|
||||
if head.trim() != "graph LR" {
|
||||
return Err(format!("only `graph LR` draws here, not `{}`", head.trim()));
|
||||
}
|
||||
let nodes: Vec<&str> = body.split("-->").map(str::trim).collect();
|
||||
if nodes.iter().any(|node| node.is_empty()) {
|
||||
return Err("each arrow needs a step on both sides".into());
|
||||
}
|
||||
let (width, gap, height) = (120, 48, 44);
|
||||
let total = nodes.len() * width + (nodes.len() - 1) * gap;
|
||||
let mut svg = format!(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='{total}' height='{height}' viewBox='0 0 {total} {height}'>"
|
||||
);
|
||||
for (ix, node) in nodes.iter().enumerate() {
|
||||
let x = ix * (width + gap);
|
||||
svg.push_str(&format!("<rect x='{x}' y='1' width='{width}' height='{}' rx='8' fill='#f4f1ec' stroke='#8a8580'/><text x='{}' y='27' font-family='Helvetica' font-size='14' text-anchor='middle' fill='#2b2926'>{node}</text>", height - 2, x + width / 2));
|
||||
if ix + 1 < nodes.len() {
|
||||
let from = x + width;
|
||||
svg.push_str(&format!("<path d='M{} 22 H{}' stroke='#8a8580' stroke-width='1.5'/><path d='M{} 17 L{} 22 L{} 27' fill='none' stroke='#8a8580' stroke-width='1.5'/>", from + 4, from + gap - 4, from + gap - 10, from + gap - 4, from + gap - 10));
|
||||
}
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn block(kind: BlockKind, texts: &[&str]) -> BlockData {
|
||||
BlockData::new(kind, texts.iter().copied())
|
||||
}
|
||||
|
||||
fn notes() -> Vec<BlockData> {
|
||||
vec![
|
||||
block(BlockKind::Heading(1), &["Project notes"]),
|
||||
block(
|
||||
BlockKind::Paragraph,
|
||||
&[
|
||||
"Type **/** on an empty line for every kind of block; `#`, `-`, `[]` and `>` at a line's start change it as you type.",
|
||||
],
|
||||
),
|
||||
block(BlockKind::Todo(true), &["Draft the palette"]),
|
||||
block(BlockKind::Todo(false), &["Check contrast in dark"]),
|
||||
block(
|
||||
BlockKind::Callout,
|
||||
&["Blocks drag by their handle; its menu turns, copies or deletes one."],
|
||||
),
|
||||
block(
|
||||
BlockKind::Toggle(true),
|
||||
&[
|
||||
"Why gamma?",
|
||||
"Blending in linear light washes out the midtones; gamma keeps them.",
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn kinds() -> Vec<BlockData> {
|
||||
vec![
|
||||
block(
|
||||
BlockKind::Code,
|
||||
&["fn lift(color: Color, by: f32) -> Color {\n color.mix(WHITE, by)\n}"],
|
||||
),
|
||||
block(BlockKind::Math, &["c' = c + (1 - c)\\,t"]),
|
||||
block(
|
||||
BlockKind::Diagram,
|
||||
&["graph LR; tokens --> components --> app"],
|
||||
),
|
||||
block(
|
||||
BlockKind::Table {
|
||||
columns: 3,
|
||||
merged: Vec::new(),
|
||||
},
|
||||
&[
|
||||
"Token", "Light", "Dark", "accent", "0.50", "0.62", "focus", "0.40", "0.55",
|
||||
],
|
||||
),
|
||||
block(
|
||||
BlockKind::Image(Media {
|
||||
width: 0.7,
|
||||
..Media::new(ATRIUM)
|
||||
}),
|
||||
&["The atrium at noon"],
|
||||
),
|
||||
block(
|
||||
BlockKind::Columns(2),
|
||||
&[
|
||||
"**Light**: paper white, ink near black.",
|
||||
"**Dark**: warm charcoal, text off-white.",
|
||||
],
|
||||
),
|
||||
block(
|
||||
BlockKind::Synced("palette-note".into()),
|
||||
&["Synced: edit either copy and both change."],
|
||||
),
|
||||
block(BlockKind::Divider, &[]),
|
||||
block(
|
||||
BlockKind::Synced("palette-note".into()),
|
||||
&["Synced: edit either copy and both change."],
|
||||
),
|
||||
block(
|
||||
BlockKind::Embed(Media::new("https://ely.dev")),
|
||||
&["The project site"],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn editing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let rich = markdown("doc-notes", NOTES, 6, window, cx);
|
||||
let essay = markdown("doc-essay", ESSAY, 24, window, cx);
|
||||
let mode = keep("doc-mode", || MarkdownMode::Visual, window, cx);
|
||||
let now_mode = *mode.read(cx);
|
||||
let notes = window.use_keyed_state("doc-blocks", cx, |window, cx| {
|
||||
BlockEditor::new(notes(), window, cx).people(PEOPLE)
|
||||
});
|
||||
let kinds = window.use_keyed_state("doc-kinds", cx, |window, cx| {
|
||||
BlockEditor::new(kinds(), window, cx)
|
||||
.people(PEOPLE)
|
||||
.diagrams(draw)
|
||||
});
|
||||
let tasks = keep(
|
||||
"doc-tasks",
|
||||
|| {
|
||||
vec![
|
||||
("Tokens in one place", true),
|
||||
("Light and dark", true),
|
||||
("Motion that settles", false),
|
||||
("A site that shows it all", false),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let now_tasks = tasks.read(cx).clone();
|
||||
div()
|
||||
.child(
|
||||
section(
|
||||
"RichTextEditor / FixedFormatToolbar / FloatingFormatToolbar / LinkEditor / MentionMenu / EmojiAutocomplete / WordCount / ReadingTime",
|
||||
"Markdown that reads as it is written: styles show as they are typed and their marks recede. The toolbar above and the one over a selection apply formats; ⌘K edits the link at the caret. @ suggests people, a colon and two letters suggest emoji. The foot counts words, characters and minutes.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("doc-rich", div().w(px(760.)).child(RichTextEditor::new("doc-rich", &rich).people(PEOPLE)))),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"MarkdownEditor",
|
||||
"One document three ways: visual, where it reads as it will and a pressed block opens as its source; source, styled as typed; and split, the source beside what it makes.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"doc-markdown",
|
||||
div().w(px(840.)).child(
|
||||
MarkdownEditor::new("doc-markdown", &essay, now_mode)
|
||||
.people(PEOPLE)
|
||||
.on_mode(move |next, _, cx| set(&mode, next, cx)),
|
||||
),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"BlockEditor / BlockHandle / SlashMenu / PlaceholderText",
|
||||
"Blocks like a notebook's. Hover one for its handle: drag it to move the block, press it to turn, copy or delete it. / on an empty line lists every kind, and an empty line says so. Undo covers text and blocks alike.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("doc-blocks", div().w(px(760.)).child(notes))),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"Block types / TableEditor / ImageEditorBlock / MathEditor / DiagramBlock / CodeBlockEditor",
|
||||
"Code colored as code, math set as it is typed, diagrams the app draws from their source, dividers, tables that grow and merge cells, pictures to resize and align, pages to open, columns, and synced copies that change together.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w(px(760.)).child(kinds)),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"TodoItem / Checklist",
|
||||
"To-dos under a title, with how many are done as a count and a bar; a done one goes quiet and struck through.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(360.)).child(
|
||||
Checklist::new("doc-checklist", "Launch", now_tasks.clone()).on_toggle(move |ix, done, _, cx| {
|
||||
let mut next = tasks.read(cx).clone();
|
||||
next[ix].1 = done;
|
||||
set(&tasks, next, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
mod editing;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 21,
|
||||
slug: "documents",
|
||||
title: "Documents",
|
||||
summary: "Markdown written and read, blocks like a notebook's, and a knowledge base around them.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::Rest,
|
||||
Step::DownAt("doc-rich", 400.0, 203.0),
|
||||
Step::UpAt("doc-rich", 400.0, 203.0),
|
||||
Step::Wait(200),
|
||||
Step::Key("cmd-shift-left"),
|
||||
Step::Type("- [x] Light and dark @gr"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("mention"),
|
||||
Step::Key("escape"),
|
||||
Step::Key("alt-shift-left"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("floating"),
|
||||
Step::Key("cmd-k"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("link"),
|
||||
Step::Key("escape"),
|
||||
Step::Wait(200),
|
||||
Step::DownAt("doc-markdown", 804.0, 16.0),
|
||||
Step::UpAt("doc-markdown", 804.0, 16.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("split"),
|
||||
Step::DownAt("doc-markdown", 696.0, 16.0),
|
||||
Step::UpAt("doc-markdown", 696.0, 16.0),
|
||||
Step::Wait(300),
|
||||
Step::DownAt("doc-markdown", 38.0, 66.0),
|
||||
Step::UpAt("doc-markdown", 38.0, 66.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("open-block"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("doc-blocks", 740.0, 60.0),
|
||||
Step::UpAt("doc-blocks", 740.0, 60.0),
|
||||
Step::Wait(200),
|
||||
Step::Key("enter"),
|
||||
Step::Type("/"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("slash"),
|
||||
Step::Key("escape"),
|
||||
Step::Key("cmd-z"),
|
||||
Step::Key("cmd-z"),
|
||||
Step::Wait(200),
|
||||
Step::HoverAt("doc-blocks", 120.0, 20.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("handle"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div().child(editing::editing(window, cx)).into_any_element()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod buttons;
|
||||
mod charts;
|
||||
mod data;
|
||||
mod debug;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod feedback;
|
||||
mod finance;
|
||||
@@ -55,6 +56,7 @@ pub const ALL: &[Page] = &[
|
||||
terminal::PAGE,
|
||||
git::PAGE,
|
||||
debug::PAGE,
|
||||
documents::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockEditor, BlockKind, media, table, view::Look};
|
||||
use crate::{
|
||||
buttons::CopyButton,
|
||||
forms::Checkbox,
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::IconSize,
|
||||
typography::Latex,
|
||||
};
|
||||
|
||||
impl BlockEditor {
|
||||
/// What a block shows, by kind.
|
||||
pub(crate) fn content(
|
||||
&self,
|
||||
block: &Block,
|
||||
number: usize,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = look.colors.clone();
|
||||
let (key, id) = (block.key, Self::id(cx));
|
||||
let marker = |text: SharedString| {
|
||||
div()
|
||||
.flex_none()
|
||||
.min_w_5()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
let row = |marker: AnyElement, field: AnyElement| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_1p5()
|
||||
.child(marker)
|
||||
.child(div().flex_1().min_w_0().child(field))
|
||||
};
|
||||
match &block.kind {
|
||||
BlockKind::Paragraph => self.field_view(block, 0, window, cx),
|
||||
BlockKind::Heading(level) => div()
|
||||
.pt_2()
|
||||
.text_size(look.headings[(*level as usize).clamp(1, 3) - 1])
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
BlockKind::Bullet => row(
|
||||
marker("•".into()).into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Numbered => row(
|
||||
marker(format!("{number}.").into()).into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Todo(done) => {
|
||||
let (entity, done) = (cx.entity(), *done);
|
||||
let check = Checkbox::new((id.clone(), format!("todo-{key}")), done).on_change(
|
||||
move |on, _, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.set_kind(key, BlockKind::Todo(on), cx)
|
||||
})
|
||||
},
|
||||
);
|
||||
row(
|
||||
check.into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.when(done, |row| row.text_color(colors.fg_subtle))
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Quote => div()
|
||||
.pl_3()
|
||||
.border_l_2()
|
||||
.border_color(colors.border_strong)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
BlockKind::Callout => div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
Icon::new(IconName::Lightbulb)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(self.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Toggle(open) => {
|
||||
let (entity, open) = (cx.entity(), *open);
|
||||
let chevron = div()
|
||||
.id((id.clone(), format!("toggle-{key}")))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, _, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.set_kind(key, BlockKind::Toggle(!open), cx)
|
||||
})
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(id.clone(), format!("disclosure-{key}")),
|
||||
open,
|
||||
));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(row(
|
||||
chevron.into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
))
|
||||
.when(open, |toggle| {
|
||||
toggle.child(div().pl_6().child(self.field_view(block, 1, window, cx)))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Code => {
|
||||
let text = block.fields[0].read(cx).text().to_string();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.sunken)
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.text_size(look.tiny)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Code")
|
||||
.child(CopyButton::new((id.clone(), format!("copy-{key}")), text)),
|
||||
)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Math => {
|
||||
let tex = block.fields[0].read(cx).text().to_string();
|
||||
let shown = match Latex::parse(&tex) {
|
||||
_ if tex.trim().is_empty() => div().into_any_element(),
|
||||
Ok(math) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(math)
|
||||
.into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.child(shown)
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Diagram => media::diagram(self, block, look, window, cx),
|
||||
BlockKind::Divider => div()
|
||||
.py_2()
|
||||
.child(div().h_0().border_t_1().border_color(colors.border))
|
||||
.into_any_element(),
|
||||
BlockKind::Table { .. } => table::table(self, block, look, window, cx),
|
||||
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
|
||||
media::media(self, block, look, window, cx)
|
||||
}
|
||||
BlockKind::Columns(_) => div()
|
||||
.flex()
|
||||
.gap_4()
|
||||
.children((0..block.fields.len()).map(|ix| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.when(ix > 0, |column| {
|
||||
column.pl_4().border_l_1().border_color(colors.border)
|
||||
})
|
||||
.child(self.field_view(block, ix, window, cx))
|
||||
}))
|
||||
.into_any_element(),
|
||||
BlockKind::Synced(name) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_size(look.tiny)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(
|
||||
Icon::new(IconName::RefreshCw)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(format!("Synced · {name}")),
|
||||
)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{Block, BlockData, BlockEditor, BlockEvent, BlockKind, Snapshot};
|
||||
|
||||
impl BlockEditor {
|
||||
/// Splits a prose block at the caret: the text after it moves to a new block of the kind that follows.
|
||||
pub(crate) fn split(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let kind = self.blocks[ix].kind.clone();
|
||||
let field = self.blocks[ix].fields[0].clone();
|
||||
if field.read(cx).is_empty()
|
||||
&& kind != BlockKind::Paragraph
|
||||
&& kind.next() != BlockKind::Paragraph
|
||||
{
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx);
|
||||
return;
|
||||
}
|
||||
let caret = field.read(cx).cursor();
|
||||
let tail = field.read(cx).text()[caret..].to_string();
|
||||
log::info!("block editor: block {key} splits at {caret}");
|
||||
self.before_change(cx);
|
||||
field.update(cx, |field, cx| {
|
||||
let end = field.text().len();
|
||||
field.select(caret..end, cx);
|
||||
field.insert("", cx);
|
||||
});
|
||||
let key = self.fresh_key();
|
||||
let next = self.build(
|
||||
BlockData {
|
||||
kind: kind.next(),
|
||||
texts: vec![tail],
|
||||
},
|
||||
key,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.blocks.insert(ix + 1, next);
|
||||
self.after_change(cx);
|
||||
self.focus_field(key, 0, Some(0), window, cx);
|
||||
}
|
||||
|
||||
/// Backspace at a prose block's start: another kind turns back into text; text joins the prose above, or an empty block goes.
|
||||
pub(crate) fn join(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
if self.blocks[ix].kind != BlockKind::Paragraph {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx);
|
||||
return;
|
||||
}
|
||||
let text = self.blocks[ix].fields[0].read(cx).text().to_string();
|
||||
let Some(above) = ix.checked_sub(1).map(|ix| &self.blocks[ix]) else {
|
||||
return;
|
||||
};
|
||||
if !above.kind.is_prose() {
|
||||
if text.is_empty() {
|
||||
self.remove(key, window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (above_key, above_field) = (above.key, above.fields[0].clone());
|
||||
log::info!("block editor: block {key} joins block {above_key}");
|
||||
self.before_change(cx);
|
||||
let joint = above_field.read(cx).text().len();
|
||||
above_field.update(cx, |field, cx| {
|
||||
field.select(joint..joint, cx);
|
||||
field.insert(&text, cx);
|
||||
});
|
||||
self.blocks.remove(ix);
|
||||
self.after_change(cx);
|
||||
self.focus_field(above_key, 0, Some(joint), window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.settle(true, cx);
|
||||
if let Some(previous) = self.history.undo(self.current.clone()) {
|
||||
log::info!("block editor: undo");
|
||||
self.restore(previous, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(next) = self.history.redo(self.current.clone()) {
|
||||
log::info!("block editor: redo");
|
||||
self.restore(next, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the document back as `snapshot` holds it, keeping fields whose blocks stay.
|
||||
fn restore(&mut self, snapshot: Snapshot, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut kept: HashMap<u64, Block> = self
|
||||
.blocks
|
||||
.drain(..)
|
||||
.map(|block| (block.key, block))
|
||||
.collect();
|
||||
for (key, data) in &snapshot.0 {
|
||||
let block = match kept.remove(key) {
|
||||
Some(mut block) if block.fields.len() == data.texts.len() => {
|
||||
block.kind = data.kind.clone();
|
||||
for (field, text) in block.fields.iter().zip(&data.texts) {
|
||||
if field.read(cx).text() != text {
|
||||
field.update(cx, |field, cx| field.set_text(text.clone(), cx));
|
||||
}
|
||||
}
|
||||
block
|
||||
}
|
||||
_ => self.build(data.clone(), *key, window, cx),
|
||||
};
|
||||
self.blocks.push(block);
|
||||
}
|
||||
self.current = snapshot;
|
||||
cx.emit(BlockEvent::Changed);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{BlockData, BlockEditor, BlockKind};
|
||||
use crate::documents::modes::at_edge;
|
||||
|
||||
/// A new empty paragraph.
|
||||
fn paragraph() -> BlockData {
|
||||
BlockData::new(BlockKind::Paragraph, [""])
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// The fields in reading order, as `(block, field)`: a closed toggle hides its body.
|
||||
fn reading_order(&self) -> Vec<(u64, usize)> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.flat_map(|block| {
|
||||
let shown = match block.kind {
|
||||
BlockKind::Toggle(false) => 1,
|
||||
_ => block.fields.len(),
|
||||
};
|
||||
(0..shown).map(move |field| (block.key, field))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enter in a field: prose splits, a toggle's title opens its body, a caption starts text below, a cell steps down a row; elsewhere Enter breaks the line.
|
||||
pub(crate) fn on_enter(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let kind = self.blocks[self.index(key)].kind.clone();
|
||||
match kind {
|
||||
kind if kind.is_prose() => self.split(key, window, cx),
|
||||
BlockKind::Toggle(open) if field == 0 => {
|
||||
if !open {
|
||||
self.set_kind(key, BlockKind::Toggle(true), cx);
|
||||
}
|
||||
self.focus_field(key, 1, None, window, cx);
|
||||
}
|
||||
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
}
|
||||
BlockKind::Table { columns, .. } => {
|
||||
let below = field + columns;
|
||||
if below >= self.blocks[self.index(key)].fields.len() {
|
||||
self.add_row(key, window, cx);
|
||||
}
|
||||
self.focus_field(key, below, None, window, cx);
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Backspace at a field's start: prose joins up or turns back into text; an empty code, math or diagram block turns into text.
|
||||
pub(crate) fn on_backspace(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let block = &self.blocks[self.index(key)];
|
||||
let input = block.fields[field].read(cx);
|
||||
if input.cursor() != 0 || !input.selection().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let empty = input.is_empty();
|
||||
match &block.kind {
|
||||
kind if kind.is_prose() => self.join(key, window, cx),
|
||||
kind if kind.is_source() && empty => {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx)
|
||||
}
|
||||
BlockKind::Toggle(_) if field == 0 && block.fields[1].read(cx).is_empty() => {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx)
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Up or Down on a field's first or last line moves to the field above or below, a table cell by a row.
|
||||
pub(crate) fn on_vertical(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
down: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let block = &self.blocks[self.index(key)];
|
||||
if !at_edge(block.fields[field].read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
if let BlockKind::Table { columns, .. } = block.kind {
|
||||
let row = if down {
|
||||
Some(field + columns)
|
||||
} else {
|
||||
field.checked_sub(columns)
|
||||
};
|
||||
if let Some(row) = row.filter(|row| *row < block.fields.len()) {
|
||||
self.focus_field(key, row, None, window, cx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let order = self.reading_order();
|
||||
let at = order
|
||||
.iter()
|
||||
.position(|place| *place == (key, field))
|
||||
.expect("a focused field is in reading order");
|
||||
let target = if down {
|
||||
order[at + 1..].iter().find(|(other, _)| *other != key)
|
||||
} else {
|
||||
order[..at].iter().rev().find(|(other, _)| *other != key)
|
||||
};
|
||||
let Some(&(target, target_field)) = target else {
|
||||
if down && !self.blocks.last().is_some_and(|last| last.kind.is_prose()) {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let end = self.blocks[self.index(target)].fields[target_field]
|
||||
.read(cx)
|
||||
.text()
|
||||
.len();
|
||||
self.focus_field(
|
||||
target,
|
||||
target_field,
|
||||
Some(if down { 0 } else { end }),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The submit key in any field leaves the block for a new paragraph below.
|
||||
pub(crate) fn on_submit(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Where a picture sits across its column.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// A picture, clip or page shown in a block: its address, and its width as a share of the column.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Media {
|
||||
pub source: SharedString,
|
||||
pub width: f32,
|
||||
pub align: Align,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new(source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
width: 1.0,
|
||||
align: Align::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block is. Each holds its text in fields: one for most, a title and a body for a toggle, one per column, a caption for media, one per cell for a table, none for a divider.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading(u8),
|
||||
Bullet,
|
||||
Numbered,
|
||||
Todo(bool),
|
||||
Quote,
|
||||
Callout,
|
||||
/// Open or closed; its body shows while open.
|
||||
Toggle(bool),
|
||||
Code,
|
||||
Math,
|
||||
/// Diagram source, such as Mermaid; the host renders the picture.
|
||||
Diagram,
|
||||
Divider,
|
||||
/// Columns across, the header row first; each `merged` cell, as row and column, folds into the cell on its left.
|
||||
Table {
|
||||
columns: usize,
|
||||
merged: Vec<(usize, usize)>,
|
||||
},
|
||||
Image(Media),
|
||||
Video(Media),
|
||||
Embed(Media),
|
||||
/// Side by side, one field each.
|
||||
Columns(usize),
|
||||
/// Text shared with every other block synced to the same source.
|
||||
Synced(SharedString),
|
||||
}
|
||||
|
||||
impl BlockKind {
|
||||
/// Its name in the slash menu.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Text",
|
||||
Self::Heading(1) => "Heading 1",
|
||||
Self::Heading(2) => "Heading 2",
|
||||
Self::Heading(_) => "Heading 3",
|
||||
Self::Bullet => "Bulleted list",
|
||||
Self::Numbered => "Numbered list",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout => "Callout",
|
||||
Self::Toggle(_) => "Toggle",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "Math",
|
||||
Self::Diagram => "Diagram",
|
||||
Self::Divider => "Divider",
|
||||
Self::Table { .. } => "Table",
|
||||
Self::Image(_) => "Image",
|
||||
Self::Video(_) => "Video",
|
||||
Self::Embed(_) => "Embed",
|
||||
Self::Columns(_) => "Columns",
|
||||
Self::Synced(_) => "Synced block",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Paragraph => IconName::Type,
|
||||
Self::Heading(1) => IconName::Heading1,
|
||||
Self::Heading(2) => IconName::Heading2,
|
||||
Self::Heading(_) => IconName::Heading3,
|
||||
Self::Bullet => IconName::List,
|
||||
Self::Numbered => IconName::ListOrdered,
|
||||
Self::Todo(_) => IconName::ListTodo,
|
||||
Self::Quote => IconName::Quote,
|
||||
Self::Callout => IconName::Info,
|
||||
Self::Toggle(_) => IconName::ChevronRight,
|
||||
Self::Code => IconName::Code,
|
||||
Self::Math => IconName::SquareFunction,
|
||||
Self::Diagram => IconName::Workflow,
|
||||
Self::Divider => IconName::Minus,
|
||||
Self::Table { .. } => IconName::Table,
|
||||
Self::Image(_) => IconName::Image,
|
||||
Self::Video(_) => IconName::Video,
|
||||
Self::Embed(_) => IconName::Globe,
|
||||
Self::Columns(_) => IconName::Columns2,
|
||||
Self::Synced(_) => IconName::RefreshCw,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an empty field of this kind says while it has focus.
|
||||
pub fn placeholder(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Type '/' for commands",
|
||||
Self::Heading(_) => self.label(),
|
||||
Self::Bullet | Self::Numbered => "List",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout | Self::Toggle(_) | Self::Columns(_) | Self::Synced(_) => "Write",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "A formula in TeX, such as e^{i\\pi} + 1 = 0",
|
||||
Self::Diagram => "A diagram in Mermaid, such as graph LR; a --> b",
|
||||
Self::Image(_) | Self::Video(_) | Self::Embed(_) => "Add a caption",
|
||||
Self::Divider | Self::Table { .. } => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fields a new block of this kind holds.
|
||||
pub fn fields(&self) -> usize {
|
||||
match self {
|
||||
Self::Divider => 0,
|
||||
Self::Toggle(_) => 2,
|
||||
Self::Columns(count) => *count,
|
||||
Self::Table { columns, .. } => columns * 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it can hold `count` fields: a table any whole rows past its header, the rest their own number.
|
||||
pub fn accepts(&self, count: usize) -> bool {
|
||||
match self {
|
||||
Self::Table { columns, .. } => {
|
||||
*columns > 0 && count >= columns * 2 && count.is_multiple_of(*columns)
|
||||
}
|
||||
kind => count == kind.fields(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prose: Enter splits it, Backspace at its start turns it back into text or joins it up.
|
||||
pub fn is_prose(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Paragraph
|
||||
| Self::Heading(_)
|
||||
| Self::Bullet
|
||||
| Self::Numbered
|
||||
| Self::Todo(_)
|
||||
| Self::Quote
|
||||
| Self::Callout
|
||||
)
|
||||
}
|
||||
|
||||
/// The kind Enter starts after this one: lists go on, the rest return to text.
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Bullet => Self::Bullet,
|
||||
Self::Numbered => Self::Numbered,
|
||||
Self::Todo(_) => Self::Todo(false),
|
||||
_ => Self::Paragraph,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of monospaced source, where Enter breaks a line.
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(self, Self::Code | Self::Math | Self::Diagram)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds the slash menu offers, in order; media and synced blocks come from their owner.
|
||||
pub(crate) fn offered() -> Vec<BlockKind> {
|
||||
vec![
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Heading(1),
|
||||
BlockKind::Heading(2),
|
||||
BlockKind::Heading(3),
|
||||
BlockKind::Bullet,
|
||||
BlockKind::Numbered,
|
||||
BlockKind::Todo(false),
|
||||
BlockKind::Quote,
|
||||
BlockKind::Callout,
|
||||
BlockKind::Toggle(true),
|
||||
BlockKind::Code,
|
||||
BlockKind::Math,
|
||||
BlockKind::Diagram,
|
||||
BlockKind::Divider,
|
||||
BlockKind::Table {
|
||||
columns: 2,
|
||||
merged: Vec::new(),
|
||||
},
|
||||
BlockKind::Columns(2),
|
||||
]
|
||||
}
|
||||
|
||||
/// The kinds whose name holds `query`, for the slash menu.
|
||||
pub(crate) fn matching(query: &str) -> Vec<BlockKind> {
|
||||
let query = query.to_lowercase();
|
||||
offered()
|
||||
.into_iter()
|
||||
.filter(|kind| kind.label().to_lowercase().contains(&query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A paragraph's leading markdown that makes it another kind, and how many bytes it takes; the space after it is typed last.
|
||||
pub(crate) fn shortcut(text: &str) -> Option<(BlockKind, usize)> {
|
||||
let prefixes: [(&str, BlockKind); 11] = [
|
||||
("# ", BlockKind::Heading(1)),
|
||||
("## ", BlockKind::Heading(2)),
|
||||
("### ", BlockKind::Heading(3)),
|
||||
("- ", BlockKind::Bullet),
|
||||
("* ", BlockKind::Bullet),
|
||||
("1. ", BlockKind::Numbered),
|
||||
("[] ", BlockKind::Todo(false)),
|
||||
("[ ] ", BlockKind::Todo(false)),
|
||||
("[x] ", BlockKind::Todo(true)),
|
||||
("> ", BlockKind::Quote),
|
||||
("``` ", BlockKind::Code),
|
||||
];
|
||||
if text == "---" {
|
||||
return Some((BlockKind::Divider, 3));
|
||||
}
|
||||
prefixes
|
||||
.into_iter()
|
||||
.find(|(prefix, _)| text.starts_with(prefix))
|
||||
.map(|(prefix, kind)| (kind, prefix.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn markdown_at_a_paragraphs_start_turns_it_into_a_kind() {
|
||||
assert_eq!(shortcut("## Lift"), Some((BlockKind::Heading(2), 3)));
|
||||
assert_eq!(shortcut("[x] done"), Some((BlockKind::Todo(true), 4)));
|
||||
assert_eq!(shortcut("---"), Some((BlockKind::Divider, 3)));
|
||||
assert_eq!(shortcut("#hashtag"), None);
|
||||
assert_eq!(shortcut("1.5 pages"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slash_menu_filters_by_name() {
|
||||
let labels: Vec<&str> = matching("head").iter().map(BlockKind::label).collect();
|
||||
assert_eq!(labels, ["Heading 1", "Heading 2", "Heading 3"]);
|
||||
assert!(matching("zzz").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_holds_whole_rows_past_its_header() {
|
||||
let table = BlockKind::Table {
|
||||
columns: 3,
|
||||
merged: Vec::new(),
|
||||
};
|
||||
assert!(table.accepts(9));
|
||||
assert!(!table.accepts(7) && !table.accepts(3));
|
||||
assert!(BlockKind::Toggle(true).accepts(2) && !BlockKind::Paragraph.accepts(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_go_on_and_the_rest_return_to_text() {
|
||||
assert_eq!(BlockKind::Todo(true).next(), BlockKind::Todo(false));
|
||||
assert_eq!(BlockKind::Heading(1).next(), BlockKind::Paragraph);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Bounds, Context, EmptyView, EntityId, Image, ImageFormat,
|
||||
ImageSource, InteractiveElement, IntoElement, ParentElement, Pixels, SharedUri,
|
||||
StatefulInteractiveElement, Styled, Window, div, img, prelude::*,
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
use super::{Align, Block, BlockEditor, BlockKind, Media, view::Look};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
primitives::{Icon, IconName, Measure},
|
||||
theme::{ControlSize, IconSize},
|
||||
};
|
||||
|
||||
/// Narrowest a picture gets, as a share of its column.
|
||||
const NARROWEST: f32 = 0.2;
|
||||
|
||||
/// A picture's edge on its way, and whose.
|
||||
struct Resize {
|
||||
owner: EntityId,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
/// A web address loads over the network; anything else is a file.
|
||||
pub(crate) fn source(text: &str) -> ImageSource {
|
||||
if text.starts_with("http://") || text.starts_with("https://") {
|
||||
SharedUri::from(text.to_string()).into()
|
||||
} else {
|
||||
Path::new(text).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of `column` a picture set by `align` spans when its edge is at `x`.
|
||||
pub(crate) fn share(align: Align, column: Bounds<Pixels>, x: Pixels) -> f32 {
|
||||
let width = f32::from(column.size.width).max(1.0);
|
||||
let reach = match align {
|
||||
Align::Left => f32::from(x - column.left()),
|
||||
Align::Right => f32::from(column.right() - x),
|
||||
Align::Center => 2.0 * f32::from(x - column.center().x).abs(),
|
||||
};
|
||||
(reach / width).clamp(NARROWEST, 1.0)
|
||||
}
|
||||
|
||||
fn media_of(kind: &mut BlockKind) -> &mut Media {
|
||||
match kind {
|
||||
BlockKind::Image(media) | BlockKind::Video(media) | BlockKind::Embed(media) => media,
|
||||
other => unreachable!("{} holds no media", other.label()),
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// Sets a picture's width while its edge is dragged; the drag's start records undo.
|
||||
fn resize(&mut self, key: u64, width: f32, first: bool, cx: &mut Context<Self>) {
|
||||
if first {
|
||||
self.before_change(cx);
|
||||
}
|
||||
let ix = self.index(key);
|
||||
media_of(&mut self.blocks[ix].kind).width = width;
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn align(&mut self, key: u64, align: Align, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let mut kind = self.blocks[ix].kind.clone();
|
||||
media_of(&mut kind).align = align;
|
||||
self.set_kind(key, kind, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture sized as a share of its column, set left, center or right, with an edge to drag and a caption below; a clip or a page shows as a card to open.
|
||||
pub(super) fn media(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let colors = look.colors.clone();
|
||||
let caption = div()
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(editor.field_view(block, 0, window, cx));
|
||||
let (media, icon) = match &block.kind {
|
||||
BlockKind::Image(media) => (media.clone(), None),
|
||||
BlockKind::Video(media) => (media.clone(), Some(IconName::Video)),
|
||||
BlockKind::Embed(media) => (media.clone(), Some(IconName::Globe)),
|
||||
other => unreachable!("{} is no media", other.label()),
|
||||
};
|
||||
if let Some(icon) = icon {
|
||||
let address = media.source.clone();
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(Icon::new(icon).size(IconSize::Md).color(colors.fg_muted))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(media.source.clone()),
|
||||
)
|
||||
.child(
|
||||
Button::new((id.clone(), format!("open-{key}")), "Open")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| cx.open_url(&address)),
|
||||
),
|
||||
)
|
||||
.child(caption)
|
||||
.into_any_element();
|
||||
}
|
||||
let column = window.use_keyed_state((id.clone(), format!("column-{key}")), cx, |_, _| {
|
||||
Bounds::<Pixels>::default()
|
||||
});
|
||||
let bounds = *column.read(cx);
|
||||
let (align, width) = (media.align, media.width);
|
||||
let owner = cx.entity_id();
|
||||
let edge = |side: &'static str, cx: &mut Context<BlockEditor>| {
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.id((id.clone(), format!("edge-{key}-{side}")))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.map(|edge| {
|
||||
if side == "left" {
|
||||
edge.left_0()
|
||||
} else {
|
||||
edge.right_0()
|
||||
}
|
||||
})
|
||||
.w_2()
|
||||
.cursor_col_resize()
|
||||
.on_drag(Resize { owner, key }, move |_, _, _, cx| {
|
||||
entity.update(cx, |editor, cx| editor.resize(key, width, true, cx));
|
||||
log::info!("image {key}: resize");
|
||||
cx.new(|_| EmptyView)
|
||||
})
|
||||
};
|
||||
let aligned = |align_to: Align, icon: IconName, cx: &mut Context<BlockEditor>| {
|
||||
IconButton::new((id.clone(), format!("align-{key}-{align_to:?}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(format!("Align {align_to:?}").to_lowercase())
|
||||
.on_click(cx.listener(move |editor, _, _, cx| editor.align(key, align_to, cx)))
|
||||
};
|
||||
let tools = div()
|
||||
.absolute()
|
||||
.top_1()
|
||||
.right_1()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.p_0p5()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.overlay)
|
||||
.opacity(0.0)
|
||||
.group_hover(format!("picture-{key}"), |tools| tools.opacity(1.0))
|
||||
.child(aligned(Align::Left, IconName::AlignLeft, cx))
|
||||
.child(aligned(Align::Center, IconName::AlignCenter, cx))
|
||||
.child(aligned(Align::Right, IconName::AlignRight, cx));
|
||||
let picture = div()
|
||||
.id((id.clone(), format!("picture-{key}")))
|
||||
.group(format!("picture-{key}"))
|
||||
.relative()
|
||||
.w(bounds.size.width * width)
|
||||
.child(
|
||||
img(source(&media.source))
|
||||
.id((id.clone(), format!("img-{key}")))
|
||||
.w(bounds.size.width * width)
|
||||
.rounded(look.radius),
|
||||
)
|
||||
.child(edge("left", cx))
|
||||
.child(edge("right", cx))
|
||||
.child(tools);
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id((id.clone(), format!("column-{key}")))
|
||||
.w_full()
|
||||
.on_drag_move(move |event: &gpui::DragMoveEvent<Resize>, _, cx| {
|
||||
if event.drag(cx).owner != owner || event.drag(cx).key != key {
|
||||
return;
|
||||
}
|
||||
let width = share(align, event.bounds, event.event.position.x);
|
||||
entity.update(cx, |editor, cx| editor.resize(key, width, false, cx));
|
||||
})
|
||||
.child(
|
||||
Measure::new(
|
||||
(id.clone(), format!("measure-{key}")),
|
||||
move |measured, _, cx| {
|
||||
column.update(cx, |column, cx| {
|
||||
*column = measured;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.flex()
|
||||
.map(|row| match align {
|
||||
Align::Left => row.justify_start(),
|
||||
Align::Center => row.justify_center(),
|
||||
Align::Right => row.justify_end(),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.w(bounds.size.width * width)
|
||||
.child(picture)
|
||||
.child(caption),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A diagram the host drew, and the width its SVG asks for.
|
||||
#[derive(Clone)]
|
||||
struct Drawn {
|
||||
image: Arc<Image>,
|
||||
width: Option<f32>,
|
||||
}
|
||||
|
||||
/// The width an SVG's root asks for, in pixels, when it gives one.
|
||||
pub(crate) fn svg_width(svg: &str) -> Option<f32> {
|
||||
let root = Regex::new(r#"<svg\b[^>]*?\swidth=['"]([0-9.]+)(px)?['"]"#)
|
||||
.expect("the SVG width pattern compiles");
|
||||
root.captures(svg)?.get(1)?.as_str().parse().ok()
|
||||
}
|
||||
|
||||
/// A diagram's source above the picture the host draws from it.
|
||||
pub(super) fn diagram(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let text = block.fields[0].read(cx).text().to_string();
|
||||
let drawn = window.use_keyed_state((id.clone(), format!("drawn-{key}")), cx, |_, _| {
|
||||
None::<(String, Result<Drawn, String>)>
|
||||
});
|
||||
let stale = drawn
|
||||
.read(cx)
|
||||
.as_ref()
|
||||
.is_none_or(|(source, _)| *source != text);
|
||||
if stale && let Some(render) = &editor.diagram {
|
||||
let picture = render(&text).map(|svg| Drawn {
|
||||
width: svg_width(&svg),
|
||||
image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())),
|
||||
});
|
||||
log::info!(
|
||||
"diagram {key}: drawn, {}",
|
||||
if picture.is_ok() {
|
||||
"fine"
|
||||
} else {
|
||||
"with an error"
|
||||
}
|
||||
);
|
||||
drawn.update(cx, |drawn, _| *drawn = Some((text.clone(), picture)));
|
||||
}
|
||||
let colors = look.colors.clone();
|
||||
let shown: AnyElement = match (editor.diagram.is_some(), drawn.read(cx).clone()) {
|
||||
_ if text.trim().is_empty() => div().into_any_element(),
|
||||
(false, _) => div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Diagrams draw once the app gives a renderer")
|
||||
.into_any_element(),
|
||||
(true, Some((_, Ok(drawn)))) => {
|
||||
let column =
|
||||
window.use_keyed_state((id.clone(), format!("diagram-width-{key}")), cx, |_, _| {
|
||||
Pixels::ZERO
|
||||
});
|
||||
let width = drawn.width.map_or(*column.read(cx), |natural| {
|
||||
Pixels::from(natural).min(*column.read(cx))
|
||||
});
|
||||
Measure::new(
|
||||
(id.clone(), format!("diagram-measure-{key}")),
|
||||
move |bounds, _, cx| {
|
||||
column.update(cx, |column, cx| {
|
||||
*column = bounds.size.width;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(
|
||||
img(drawn.image)
|
||||
.id((id.clone(), format!("diagram-{key}")))
|
||||
.w(width),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
(true, Some((_, Err(error)))) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error)
|
||||
.into_any_element(),
|
||||
(true, None) => unreachable!("a renderer draws before this shows"),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(editor.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.child(shown)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_svg_gives_its_width_when_it_has_one() {
|
||||
assert_eq!(
|
||||
svg_width("<svg xmlns='x' width='504' height='44'>"),
|
||||
Some(504.0)
|
||||
);
|
||||
assert_eq!(svg_width("<svg width=\"120px\">"), Some(120.0));
|
||||
assert_eq!(
|
||||
svg_width("<svg viewBox='0 0 10 10'><rect width='5'/></svg>"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_sets_its_share_by_how_the_picture_sits() {
|
||||
let column = Bounds::new(point(px(100.0), px(0.0)), size(px(400.0), px(10.0)));
|
||||
assert_eq!(share(Align::Left, column, px(300.0)), 0.5);
|
||||
assert_eq!(share(Align::Right, column, px(300.0)), 0.5);
|
||||
assert_eq!(share(Align::Center, column, px(400.0)), 0.5);
|
||||
assert_eq!(
|
||||
share(Align::Center, column, px(300.0)),
|
||||
NARROWEST,
|
||||
"a picture keeps some width"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
mod content;
|
||||
mod edit;
|
||||
mod keys;
|
||||
mod kind;
|
||||
mod media;
|
||||
mod table;
|
||||
mod view;
|
||||
|
||||
use std::{collections::HashMap, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, Focusable, Global, SharedString,
|
||||
Subscription, Window,
|
||||
};
|
||||
|
||||
pub use kind::{Align, BlockKind, Media};
|
||||
pub(crate) use kind::{matching, shortcut};
|
||||
|
||||
use super::markdown::markdown_highlights;
|
||||
use crate::forms::{History, InputEvent, TextInput, code_highlights};
|
||||
|
||||
/// A block as the editor gives it out and takes it in: its kind and the text of each of its fields.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockData {
|
||||
pub kind: BlockKind,
|
||||
pub texts: Vec<String>,
|
||||
}
|
||||
|
||||
impl BlockData {
|
||||
pub fn new(kind: BlockKind, texts: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
let texts: Vec<String> = texts.into_iter().map(Into::into).collect();
|
||||
assert!(
|
||||
kind.accepts(texts.len()),
|
||||
"a {} block cannot hold {} fields",
|
||||
kind.label(),
|
||||
texts.len()
|
||||
);
|
||||
Self { kind, texts }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block editor tells its owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BlockEvent {
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// Renders a diagram's source to SVG, or says what is wrong with it.
|
||||
pub(crate) type RenderDiagram = Rc<dyn Fn(&str) -> Result<String, String>>;
|
||||
|
||||
/// Text shared by synced blocks, by name, across every editor in the app.
|
||||
#[derive(Default)]
|
||||
struct SyncedTexts(HashMap<SharedString, String>);
|
||||
|
||||
impl Global for SyncedTexts {}
|
||||
|
||||
pub(crate) struct Block {
|
||||
pub key: u64,
|
||||
pub kind: BlockKind,
|
||||
pub fields: Vec<Entity<TextInput>>,
|
||||
_changes: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// The whole document, as undo keeps it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Snapshot(Vec<(u64, BlockData)>);
|
||||
|
||||
/// A document of blocks, Notion-like: prose, lists and to-dos, quotes and callouts, toggles, code, math, diagrams, dividers, tables, media, columns and synced blocks. Markdown at a paragraph's start changes its kind; `/` offers every kind; `@` and `:` suggest people and emoji; blocks drag by their handle. Undo covers text and structure alike.
|
||||
pub struct BlockEditor {
|
||||
pub(crate) blocks: Vec<Block>,
|
||||
next: u64,
|
||||
history: History<Snapshot>,
|
||||
current: Snapshot,
|
||||
pub(crate) people: Vec<SharedString>,
|
||||
pub(crate) diagram: Option<RenderDiagram>,
|
||||
_synced: Subscription,
|
||||
}
|
||||
|
||||
impl EventEmitter<BlockEvent> for BlockEditor {}
|
||||
|
||||
impl BlockEditor {
|
||||
pub fn new(blocks: Vec<BlockData>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let synced = cx.observe_global_in::<SyncedTexts>(window, |editor, _, cx| editor.pull(cx));
|
||||
let mut editor = Self {
|
||||
blocks: Vec::new(),
|
||||
next: 0,
|
||||
history: History::default(),
|
||||
current: Snapshot(Vec::new()),
|
||||
people: Vec::new(),
|
||||
diagram: None,
|
||||
_synced: synced,
|
||||
};
|
||||
for data in blocks {
|
||||
let key = editor.fresh_key();
|
||||
let block = editor.build(data, key, window, cx);
|
||||
editor.blocks.push(block);
|
||||
}
|
||||
editor.current = editor.snapshot(cx);
|
||||
editor
|
||||
}
|
||||
|
||||
/// Handles `@` suggests.
|
||||
pub fn people(mut self, handles: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.people = handles.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Draws diagram blocks: their source to SVG, or an error to show in its place.
|
||||
pub fn diagrams(mut self, render: impl Fn(&str) -> Result<String, String> + 'static) -> Self {
|
||||
self.diagram = Some(Rc::new(render));
|
||||
self
|
||||
}
|
||||
|
||||
/// The document as it stands.
|
||||
pub fn blocks(&self, cx: &App) -> Vec<BlockData> {
|
||||
self.snapshot(cx)
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(_, data)| data)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot(&self, cx: &App) -> Snapshot {
|
||||
Snapshot(
|
||||
self.blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
let texts = block
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| field.read(cx).text().to_string())
|
||||
.collect();
|
||||
(
|
||||
block.key,
|
||||
BlockData {
|
||||
kind: block.kind.clone(),
|
||||
texts,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A field for `kind`: inline markdown styled as typed, or code colored as code.
|
||||
fn field(
|
||||
kind: &BlockKind,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<TextInput> {
|
||||
let code = *kind == BlockKind::Code;
|
||||
let field = cx.new(|cx| {
|
||||
let hint = match kind {
|
||||
BlockKind::Paragraph | BlockKind::Table { .. } => "",
|
||||
kind => kind.placeholder(),
|
||||
};
|
||||
let field = TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.placeholder(hint);
|
||||
match (code, kind.is_source()) {
|
||||
(true, _) => field.highlighter(code_highlights),
|
||||
(false, true) => field,
|
||||
(false, false) => field.highlighter(markdown_highlights),
|
||||
}
|
||||
});
|
||||
field.update(cx, |field, cx| field.set_text(text, cx));
|
||||
field
|
||||
}
|
||||
|
||||
fn fresh_key(&mut self) -> u64 {
|
||||
self.next += 1;
|
||||
self.next
|
||||
}
|
||||
|
||||
fn build(
|
||||
&mut self,
|
||||
data: BlockData,
|
||||
key: u64,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Block {
|
||||
assert!(
|
||||
data.kind.accepts(data.texts.len()),
|
||||
"a {} block cannot hold {} fields",
|
||||
data.kind.label(),
|
||||
data.texts.len()
|
||||
);
|
||||
let mut texts = data.texts;
|
||||
if let BlockKind::Synced(name) = &data.kind {
|
||||
let shared = cx
|
||||
.try_global::<SyncedTexts>()
|
||||
.and_then(|synced| synced.0.get(name).cloned());
|
||||
match shared {
|
||||
Some(shared) => texts[0] = shared,
|
||||
None => {
|
||||
cx.default_global::<SyncedTexts>()
|
||||
.0
|
||||
.insert(name.clone(), texts[0].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let fields: Vec<Entity<TextInput>> = texts
|
||||
.iter()
|
||||
.map(|text| Self::field(&data.kind, text, window, cx))
|
||||
.collect();
|
||||
let changes = fields
|
||||
.iter()
|
||||
.map(|field| Self::watch(key, field, window, cx))
|
||||
.collect();
|
||||
Block {
|
||||
key,
|
||||
kind: data.kind,
|
||||
fields,
|
||||
_changes: changes,
|
||||
}
|
||||
}
|
||||
|
||||
fn watch(
|
||||
key: u64,
|
||||
field: &Entity<TextInput>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Subscription {
|
||||
cx.subscribe_in(field, window, move |editor, field, event, window, cx| {
|
||||
editor.on_field(key, field, *event, window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds an empty field to a block, at `at` or last.
|
||||
pub(crate) fn add_field(
|
||||
&mut self,
|
||||
key: u64,
|
||||
at: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let field = Self::field(&self.blocks[ix].kind, "", window, cx);
|
||||
let watched = Self::watch(key, &field, window, cx);
|
||||
let block = &mut self.blocks[ix];
|
||||
let at = at.unwrap_or(block.fields.len());
|
||||
block.fields.insert(at, field);
|
||||
block._changes.insert(at, watched);
|
||||
}
|
||||
|
||||
pub(crate) fn remove_field(&mut self, key: u64, field: usize) {
|
||||
let ix = self.index(key);
|
||||
let block = &mut self.blocks[ix];
|
||||
block.fields.remove(field);
|
||||
drop(block._changes.remove(field));
|
||||
}
|
||||
|
||||
pub(crate) fn index(&self, key: u64) -> usize {
|
||||
self.blocks
|
||||
.iter()
|
||||
.position(|block| block.key == key)
|
||||
.unwrap_or_else(|| panic!("block {key} left the editor"))
|
||||
}
|
||||
|
||||
fn on_field(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: &Entity<TextInput>,
|
||||
event: InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
match event {
|
||||
InputEvent::Changed => {
|
||||
let typed = field.focus_handle(cx).is_focused(window);
|
||||
if typed
|
||||
&& self.blocks[ix].kind == BlockKind::Paragraph
|
||||
&& let Some((kind, length)) = shortcut(field.read(cx).text())
|
||||
&& field.read(cx).cursor() == length
|
||||
{
|
||||
log::info!(
|
||||
"block editor: markdown turns block {key} into {}",
|
||||
kind.label()
|
||||
);
|
||||
field.update(cx, |field, cx| {
|
||||
field.select(0..length, cx);
|
||||
field.insert("", cx);
|
||||
});
|
||||
self.turn_into(key, kind, window, cx);
|
||||
return;
|
||||
}
|
||||
if let BlockKind::Synced(name) = &self.blocks[ix].kind {
|
||||
let text = field.read(cx).text().to_string();
|
||||
let shared = cx
|
||||
.try_global::<SyncedTexts>()
|
||||
.and_then(|synced| synced.0.get(name));
|
||||
if shared != Some(&text) {
|
||||
log::info!("block editor: synced {name} written");
|
||||
cx.default_global::<SyncedTexts>()
|
||||
.0
|
||||
.insert(name.clone(), text);
|
||||
}
|
||||
}
|
||||
self.settle(true, cx);
|
||||
}
|
||||
InputEvent::Focus if field.read(cx).is_empty() => {
|
||||
let hint = self.blocks[ix].kind.placeholder();
|
||||
field.update(cx, |field, cx| field.set_placeholder(hint, cx));
|
||||
}
|
||||
InputEvent::Blur if self.blocks[ix].kind == BlockKind::Paragraph => {
|
||||
field.update(cx, |field, cx| field.set_placeholder("", cx));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in synced text written elsewhere.
|
||||
fn pull(&mut self, cx: &mut Context<Self>) {
|
||||
let shared: Vec<(Entity<TextInput>, String)> = self
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match &block.kind {
|
||||
BlockKind::Synced(name) => cx
|
||||
.try_global::<SyncedTexts>()
|
||||
.and_then(|synced| synced.0.get(name))
|
||||
.map(|text| (block.fields[0].clone(), text.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (field, text) in shared {
|
||||
if field.read(cx).text() != text {
|
||||
log::debug!("block editor: synced text arrived");
|
||||
field.update(cx, |field, cx| field.set_text(text, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records what changed as one undo step, a burst of typing as one, and tells the owner.
|
||||
fn settle(&mut self, typing: bool, cx: &mut Context<Self>) {
|
||||
let now = self.snapshot(cx);
|
||||
if now != self.current {
|
||||
let before = std::mem::replace(&mut self.current, now);
|
||||
self.history.record(before, typing);
|
||||
cx.emit(BlockEvent::Changed);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the document before a change to its blocks.
|
||||
pub(crate) fn before_change(&mut self, cx: &mut Context<Self>) {
|
||||
self.settle(true, cx);
|
||||
self.history.record(self.current.clone(), false);
|
||||
}
|
||||
|
||||
/// After a change to the blocks: the new state is current.
|
||||
pub(crate) fn after_change(&mut self, cx: &mut Context<Self>) {
|
||||
self.current = self.snapshot(cx);
|
||||
cx.emit(BlockEvent::Changed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn focus_field(
|
||||
&self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
at: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let block = &self.blocks[self.index(key)];
|
||||
let Some(field) = block.fields.get(field) else {
|
||||
return;
|
||||
};
|
||||
if let Some(at) = at {
|
||||
field.update(cx, |field, cx| field.select(at..at, cx));
|
||||
}
|
||||
window.focus(&field.focus_handle(cx));
|
||||
}
|
||||
|
||||
/// Adds a block after `after`, or first, and puts the caret in it.
|
||||
pub fn insert(
|
||||
&mut self,
|
||||
after: Option<u64>,
|
||||
data: BlockData,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> u64 {
|
||||
self.before_change(cx);
|
||||
let key = self.fresh_key();
|
||||
let block = self.build(data, key, window, cx);
|
||||
let at = after.map_or(0, |after| self.index(after) + 1);
|
||||
log::info!("block editor: {} block at {at}", block.kind.label());
|
||||
let end = block
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().len());
|
||||
self.blocks.insert(at, block);
|
||||
self.after_change(cx);
|
||||
self.focus_field(key, 0, end, window, cx);
|
||||
key
|
||||
}
|
||||
|
||||
/// Changes a block's kind, keeping its first field's text; fields the new kind needs start empty.
|
||||
pub fn turn_into(
|
||||
&mut self,
|
||||
key: u64,
|
||||
kind: BlockKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.before_change(cx);
|
||||
let ix = self.index(key);
|
||||
let first = self.blocks[ix]
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().to_string())
|
||||
.unwrap_or_default();
|
||||
let mut texts = vec![String::new(); kind.fields()];
|
||||
if let Some(text) = texts.first_mut() {
|
||||
*text = first;
|
||||
}
|
||||
log::info!("block editor: block {key} turns into {}", kind.label());
|
||||
let block = self.build(BlockData { kind, texts }, key, window, cx);
|
||||
self.blocks[ix] = block;
|
||||
self.after_change(cx);
|
||||
self.focus_field(key, 0, None, window, cx);
|
||||
}
|
||||
|
||||
/// Changes a block's kind in place, as a to-do's box or a toggle's state does.
|
||||
pub fn set_kind(&mut self, key: u64, kind: BlockKind, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
assert!(
|
||||
kind.accepts(self.blocks[ix].fields.len()),
|
||||
"set_kind keeps the fields; turn_into changes them"
|
||||
);
|
||||
self.before_change(cx);
|
||||
self.blocks[ix].kind = kind;
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.before_change(cx);
|
||||
let ix = self.index(key);
|
||||
log::info!("block editor: block {key} removed");
|
||||
self.blocks.remove(ix);
|
||||
self.after_change(cx);
|
||||
if let Some(previous) = ix.checked_sub(1).and_then(|ix| self.blocks.get(ix)) {
|
||||
let key = previous.key;
|
||||
let end = previous
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().len());
|
||||
self.focus_field(key, 0, end, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duplicate(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let data = self.snapshot(cx).0[ix].1.clone();
|
||||
self.insert(Some(key), data, window, cx);
|
||||
}
|
||||
|
||||
/// Moves the block at `from` to place `to`.
|
||||
pub fn move_block(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
|
||||
self.before_change(cx);
|
||||
log::info!("block editor: block moved from {from} to {to}");
|
||||
let block = self.blocks.remove(from);
|
||||
self.blocks.insert(to.min(self.blocks.len()), block);
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Whether `key`'s field `field` holds focus.
|
||||
pub(crate) fn focused(&self, window: &Window, cx: &App) -> Option<(u64, usize)> {
|
||||
self.blocks.iter().find_map(|block| {
|
||||
block
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.focus_handle(cx).is_focused(window))
|
||||
.map(|ix| (block.key, ix))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockEditor, BlockKind, view::Look};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
/// A table's shape: columns, rows, and the cells that fold into their left neighbor.
|
||||
fn shape(kind: &BlockKind, fields: usize) -> (usize, usize, &[(usize, usize)]) {
|
||||
let BlockKind::Table { columns, merged } = kind else {
|
||||
unreachable!("a table block is a table")
|
||||
};
|
||||
(*columns, fields / columns, merged)
|
||||
}
|
||||
|
||||
/// How many columns the cell at `(row, column)` spans: itself and every cell folded into it.
|
||||
pub(crate) fn span(merged: &[(usize, usize)], columns: usize, row: usize, column: usize) -> usize {
|
||||
1 + (column + 1..columns)
|
||||
.take_while(|next| merged.contains(&(row, *next)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Merges after `column` is removed: its own go, the ones right of it move left.
|
||||
pub(crate) fn without_column(merged: &[(usize, usize)], column: usize) -> Vec<(usize, usize)> {
|
||||
merged
|
||||
.iter()
|
||||
.filter(|(_, at)| *at != column && *at != column + 1)
|
||||
.map(|&(row, at)| (row, if at > column { at - 1 } else { at }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merges after `row` is removed.
|
||||
pub(crate) fn without_row(merged: &[(usize, usize)], row: usize) -> Vec<(usize, usize)> {
|
||||
merged
|
||||
.iter()
|
||||
.filter(|(at, _)| *at != row)
|
||||
.map(|&(at, column)| (if at > row { at - 1 } else { at }, column))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// Adds a row of empty cells at the table's foot.
|
||||
pub(crate) fn add_row(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, _, _) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
self.before_change(cx);
|
||||
for _ in 0..columns {
|
||||
self.add_field(key, None, window, cx);
|
||||
}
|
||||
log::info!("table {key}: a row added");
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Adds a column of empty cells at the table's right.
|
||||
pub(crate) fn add_column(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let merged = merged.to_vec();
|
||||
self.before_change(cx);
|
||||
for row in (0..rows).rev() {
|
||||
self.add_field(key, Some((row + 1) * columns), window, cx);
|
||||
}
|
||||
self.blocks[ix].kind = BlockKind::Table {
|
||||
columns: columns + 1,
|
||||
merged,
|
||||
};
|
||||
log::info!("table {key}: a column added");
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Removes the row or column holding cell `cell`; a table keeps a header, a row and a column.
|
||||
pub(crate) fn remove_line(
|
||||
&mut self,
|
||||
key: u64,
|
||||
cell: usize,
|
||||
column: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let merged = merged.to_vec();
|
||||
let (row, at) = (cell / columns, cell % columns);
|
||||
if (column && columns == 1) || (!column && rows <= 2) {
|
||||
log::info!("table {key}: its last line stays");
|
||||
return;
|
||||
}
|
||||
self.before_change(cx);
|
||||
let (gone, kind): (Vec<usize>, BlockKind) = if column {
|
||||
(
|
||||
(0..rows).map(|row| row * columns + at).collect(),
|
||||
BlockKind::Table {
|
||||
columns: columns - 1,
|
||||
merged: without_column(&merged, at),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
(row * columns..(row + 1) * columns).collect(),
|
||||
BlockKind::Table {
|
||||
columns,
|
||||
merged: without_row(&merged, row),
|
||||
},
|
||||
)
|
||||
};
|
||||
for ix_field in gone.into_iter().rev() {
|
||||
self.remove_field(key, ix_field);
|
||||
}
|
||||
self.blocks[ix].kind = kind;
|
||||
log::info!(
|
||||
"table {key}: {} removed",
|
||||
if column { "a column" } else { "a row" }
|
||||
);
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Folds the cell right of `cell` into it, moving its text over; or, when already merged, parts them again.
|
||||
pub(crate) fn merge_right(&mut self, key: u64, cell: usize, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, _, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let (row, column) = (cell / columns, cell % columns);
|
||||
let spanned = span(merged, columns, row, column);
|
||||
if column + spanned >= columns && spanned == 1 {
|
||||
return;
|
||||
}
|
||||
let mut merged = merged.to_vec();
|
||||
self.before_change(cx);
|
||||
if spanned > 1 {
|
||||
merged
|
||||
.retain(|(at_row, at)| !(*at_row == row && *at > column && *at < column + spanned));
|
||||
log::info!("table {key}: cell {cell} parted");
|
||||
} else {
|
||||
let right = self.blocks[ix].fields[cell + 1].read(cx).text().to_string();
|
||||
if !right.is_empty() {
|
||||
self.blocks[ix].fields[cell].update(cx, |field, cx| {
|
||||
let end = field.text().len();
|
||||
field.select(end..end, cx);
|
||||
field.insert(&format!(" {right}"), cx);
|
||||
});
|
||||
self.blocks[ix].fields[cell + 1].update(cx, |field, cx| field.set_text("", cx));
|
||||
}
|
||||
merged.push((row, column + 1));
|
||||
log::info!("table {key}: cell {cell} merged right");
|
||||
}
|
||||
self.blocks[ix].kind = BlockKind::Table { columns, merged };
|
||||
self.after_change(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The table: a header row, then rows; cells merged across span their neighbors; tools above while a cell has focus.
|
||||
pub(super) fn table(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (columns, rows, merged) = shape(&block.kind, block.fields.len());
|
||||
let merged = merged.to_vec();
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let focused = editor
|
||||
.focused(window, cx)
|
||||
.filter(|(at, _)| *at == key)
|
||||
.map(|(_, cell)| cell);
|
||||
let colors = &look.colors;
|
||||
let tool = |name: &'static str,
|
||||
icon: IconName,
|
||||
run: fn(&mut BlockEditor, u64, usize, &mut Window, &mut Context<BlockEditor>),
|
||||
cell: usize,
|
||||
cx: &mut Context<BlockEditor>| {
|
||||
IconButton::new((id.clone(), format!("table-{key}-{name}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(name)
|
||||
.on_click(cx.listener(move |editor, _, window, cx| run(editor, key, cell, window, cx)))
|
||||
};
|
||||
let tools = focused.map(|cell| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.child(tool(
|
||||
"Add row",
|
||||
IconName::Rows2,
|
||||
|editor, key, _, window, cx| editor.add_row(key, window, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Add column",
|
||||
IconName::Columns2,
|
||||
|editor, key, _, window, cx| editor.add_column(key, window, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Remove row",
|
||||
IconName::Minus,
|
||||
|editor, key, cell, _, cx| editor.remove_line(key, cell, false, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Remove column",
|
||||
IconName::X,
|
||||
|editor, key, cell, _, cx| editor.remove_line(key, cell, true, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Merge or part cells",
|
||||
IconName::Merge,
|
||||
|editor, key, cell, _, cx| editor.merge_right(key, cell, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
});
|
||||
let mut grid = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border);
|
||||
for row in 0..rows {
|
||||
let mut line = div().flex().when(row > 0, |line| {
|
||||
line.border_t_1().border_color(colors.border)
|
||||
});
|
||||
if row == 0 {
|
||||
line = line.bg(colors.hover).font_weight(FontWeight::MEDIUM);
|
||||
}
|
||||
for column in 0..columns {
|
||||
if merged.contains(&(row, column)) {
|
||||
continue;
|
||||
}
|
||||
let cell = row * columns + column;
|
||||
let wide = span(&merged, columns, row, column);
|
||||
line = line.child(
|
||||
div()
|
||||
.flex_grow()
|
||||
.flex_basis(gpui::relative(wide as f32 / columns as f32))
|
||||
.min_w_0()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.when(column > 0, |cell| {
|
||||
cell.border_l_1().border_color(colors.border)
|
||||
})
|
||||
.child(editor.field_view(block, cell, window, cx)),
|
||||
);
|
||||
}
|
||||
grid = grid.child(line);
|
||||
}
|
||||
div()
|
||||
.id((id.clone(), format!("table-{key}")))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(tools)
|
||||
.child(grid)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_merged_cell_spans_what_folds_into_it() {
|
||||
let merged = [(1, 1), (1, 2)];
|
||||
assert_eq!(span(&merged, 4, 1, 0), 3);
|
||||
assert_eq!(span(&merged, 4, 0, 0), 1);
|
||||
assert_eq!(span(&merged, 4, 1, 3), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_line_moves_the_merges_past_it() {
|
||||
let merged = [(0, 1), (1, 3), (2, 2)];
|
||||
assert_eq!(
|
||||
without_column(&merged, 1),
|
||||
[(1, 2)],
|
||||
"a cell folded into the removed column parts"
|
||||
);
|
||||
assert_eq!(without_row(&merged, 1), [(0, 1), (1, 2)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Rems, Render, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
documents::suggest::{Offer, offers, replace_trigger},
|
||||
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
motion::Reorder,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// Theme values a block reads, taken before its fields borrow the context.
|
||||
pub(crate) struct Look {
|
||||
pub colors: Palette,
|
||||
pub mono: SharedString,
|
||||
pub radius: Rems,
|
||||
pub small: Rems,
|
||||
pub tiny: Rems,
|
||||
pub headings: [Rems; 3],
|
||||
}
|
||||
|
||||
impl Look {
|
||||
fn new(cx: &App) -> Self {
|
||||
let theme = cx.theme();
|
||||
Self {
|
||||
colors: theme.colors.clone(),
|
||||
mono: theme.mono_family.clone(),
|
||||
radius: theme.radius(Radius::Md),
|
||||
small: theme.text_size(TextSize::Sm),
|
||||
tiny: theme.text_size(TextSize::Xs),
|
||||
headings: [TextSize::Xxl, TextSize::Xl, TextSize::Lg].map(|size| theme.text_size(size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
pub(crate) fn id(cx: &Context<Self>) -> ElementId {
|
||||
("ely-blocks", cx.entity_id()).into()
|
||||
}
|
||||
|
||||
/// A slash pick: an emptied block becomes the kind; one with text gets the kind below it.
|
||||
fn pick_kind(
|
||||
&mut self,
|
||||
key: u64,
|
||||
kind: BlockKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let empty = self.blocks[ix]
|
||||
.fields
|
||||
.first()
|
||||
.is_some_and(|field| field.read(cx).is_empty());
|
||||
let data = BlockData {
|
||||
texts: vec![String::new(); kind.fields()],
|
||||
kind: kind.clone(),
|
||||
};
|
||||
let placed = if empty {
|
||||
self.turn_into(key, kind.clone(), window, cx);
|
||||
key
|
||||
} else {
|
||||
self.insert(Some(key), data, window, cx)
|
||||
};
|
||||
if kind == BlockKind::Divider {
|
||||
self.insert(
|
||||
Some(placed),
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field with its keys and suggests: `/` in prose opens the kinds.
|
||||
pub(crate) fn field_view(
|
||||
&self,
|
||||
block: &Block,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (key, field) = (block.key, block.fields[ix].clone());
|
||||
let entity = cx.entity();
|
||||
let keys = div()
|
||||
.w_full()
|
||||
.capture_action(cx.listener(move |editor, _: &Enter, window, cx| {
|
||||
if editor.on_enter(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
|
||||
if editor.on_backspace(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Up, window, cx| {
|
||||
if editor.on_vertical(key, ix, false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Down, window, cx| {
|
||||
if editor.on_vertical(key, ix, true, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Submit, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.on_submit(key, window, cx);
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(field.clone());
|
||||
let slash = move |query: &str, at: usize, caret: usize| -> (Vec<Choice>, Pick) {
|
||||
let kinds = matching(query);
|
||||
let rows = kinds
|
||||
.iter()
|
||||
.map(|kind| Choice::new(kind.label(), kind.label()).icon(kind.icon()))
|
||||
.collect();
|
||||
let (entity, field) = (entity.clone(), field.clone());
|
||||
let pick: Pick = Rc::new(move |pick: usize, window: &mut Window, cx: &mut App| {
|
||||
replace_trigger(&field, at, caret, "", cx);
|
||||
let kind = kinds[pick].clone();
|
||||
entity.update(cx, |editor, cx| editor.pick_kind(key, kind, window, cx));
|
||||
});
|
||||
(rows, pick)
|
||||
};
|
||||
let extra: Option<(char, Offer)> =
|
||||
(block.kind.is_prose() && ix == 0).then_some(('/', &slash));
|
||||
let id = (Self::id(cx), format!("offers-{key}-{ix}")).into();
|
||||
offers(id, &block.fields[ix], &self.people, extra, cx)
|
||||
.wrap(keys, window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The handle's menu: turn into another kind, duplicate or delete.
|
||||
fn menu(&self, key: u64, cx: &mut Context<Self>) -> Menu {
|
||||
let entity = cx.entity();
|
||||
let turn = super::kind::offered()
|
||||
.into_iter()
|
||||
.fold(Menu::new(), |menu, kind| {
|
||||
let entity = entity.clone();
|
||||
menu.item(MenuItem::new(kind.label()).icon(kind.icon()).on_click(
|
||||
move |window, cx| {
|
||||
let kind = kind.clone();
|
||||
entity.update(cx, |editor, cx| editor.turn_into(key, kind, window, cx));
|
||||
},
|
||||
))
|
||||
});
|
||||
let (copy, gone) = (entity.clone(), entity);
|
||||
Menu::new()
|
||||
.item(MenuItem::submenu("Turn into", turn).icon(IconName::RefreshCw))
|
||||
.item(
|
||||
MenuItem::new("Duplicate")
|
||||
.icon(IconName::Copy)
|
||||
.on_click(move |window, cx| {
|
||||
copy.update(cx, |editor, cx| editor.duplicate(key, window, cx));
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
MenuItem::new("Delete")
|
||||
.icon(IconName::Trash2)
|
||||
.on_click(move |window, cx| {
|
||||
gone.update(cx, |editor, cx| editor.remove(key, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlockEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let id = Self::id(cx);
|
||||
let look = Look::new(cx);
|
||||
let colors = look.colors.clone();
|
||||
let focused = self.focused(window, cx).map(|(key, _)| key);
|
||||
let mut number = 0;
|
||||
let mut rows: Vec<(SharedString, AnyElement)> = Vec::new();
|
||||
for ix in 0..self.blocks.len() {
|
||||
let block = &self.blocks[ix];
|
||||
number = if block.kind == BlockKind::Numbered {
|
||||
number + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let key = block.key;
|
||||
let group = SharedString::from(format!("block-{key}"));
|
||||
let entity = cx.entity();
|
||||
let plus = IconButton::new((id.clone(), format!("plus-{key}")), IconName::Plus)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Add a block below")
|
||||
.on_click(move |_, window, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.insert(
|
||||
Some(key),
|
||||
BlockData::new(BlockKind::Paragraph, ["/"]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
});
|
||||
let handle =
|
||||
OverflowMenu::new((id.clone(), format!("handle-{key}")), self.menu(key, cx))
|
||||
.icon(IconName::GripVertical)
|
||||
.tooltip("Drag to move, press for more");
|
||||
let content = self.content(&self.blocks[ix], number, &look, window, cx);
|
||||
let row = div()
|
||||
.id((id.clone(), format!("row-{key}")))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |editor, _, window, cx| {
|
||||
let end = editor.blocks[editor.index(key)]
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().len());
|
||||
if end.is_some() {
|
||||
editor.focus_field(key, 0, end, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.pl_16()
|
||||
.py_0p5()
|
||||
.child(content)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.flex()
|
||||
.opacity(if focused == Some(key) { 1.0 } else { 0.0 })
|
||||
.group_hover(group, |gutter| gutter.opacity(1.0))
|
||||
.child(plus)
|
||||
.child(handle),
|
||||
);
|
||||
rows.push((format!("{key}").into(), row.into_any_element()));
|
||||
}
|
||||
let entity = cx.entity();
|
||||
let list = rows.into_iter().fold(
|
||||
Reorder::new((id.clone(), "blocks")).on_reorder(move |from, to, _, cx| {
|
||||
entity.update(cx, |editor, cx| editor.move_block(from, to, cx))
|
||||
}),
|
||||
|list, (key, row)| list.row(key, row),
|
||||
);
|
||||
let start = cx.entity();
|
||||
div()
|
||||
.id(id.clone())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.capture_action(cx.listener(|editor, _: &Undo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.undo(window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|editor, _: &Redo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.redo(window, cx);
|
||||
}))
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.id((id, "end"))
|
||||
.min_h_8()
|
||||
.cursor_text()
|
||||
.text_color(colors.fg_subtle)
|
||||
.when(self.blocks.is_empty(), |end| end.child("Press to write"))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
start.update(cx, |editor, cx| {
|
||||
let last = editor.blocks.last().map(|block| block.key);
|
||||
editor.insert(
|
||||
last,
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::markdown::{reading_minutes, words};
|
||||
use crate::{
|
||||
theme::ActiveTheme,
|
||||
typography::{format::plural, tabular},
|
||||
};
|
||||
|
||||
/// Share of a limit past which the count warns.
|
||||
const NEAR: f32 = 0.9;
|
||||
|
||||
/// Words and characters in a text, as one quiet line; against a limit, characters warn near it and turn to danger past it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WordCount {
|
||||
text: SharedString,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl WordCount {
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts characters against `limit`.
|
||||
pub fn limit(mut self, limit: usize) -> Self {
|
||||
assert!(limit > 0, "a character limit of zero allows nothing");
|
||||
self.limit = Some(limit);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WordCount {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let characters = self.text.graphemes(true).count();
|
||||
let words = plural(words(&self.text) as u64, "word", "words");
|
||||
let (characters, color) = match self.limit {
|
||||
Some(limit) => (
|
||||
format!("{characters}/{limit}"),
|
||||
if characters > limit {
|
||||
colors.danger
|
||||
} else if characters as f32 >= limit as f32 * NEAR {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
},
|
||||
),
|
||||
None => (
|
||||
plural(characters as u64, "character", "characters"),
|
||||
colors.fg_subtle,
|
||||
),
|
||||
};
|
||||
tabular(div().flex().gap_1().text_color(colors.fg_subtle))
|
||||
.child(words)
|
||||
.child("·")
|
||||
.child(div().text_color(color).child(characters))
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a text takes to read, at an adult's pace.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReadingTime {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl ReadingTime {
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReadingTime {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let minutes = reading_minutes(words(&self.text));
|
||||
tabular(div().text_color(cx.theme().colors.fg_subtle)).child(if minutes == 0 {
|
||||
"Nothing to read yet".to_string()
|
||||
} else {
|
||||
format!("{minutes} min read")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Entity};
|
||||
|
||||
use super::markdown::{Mark, marks};
|
||||
use crate::forms::TextInput;
|
||||
|
||||
/// A format the toolbars apply to markdown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
Bullet,
|
||||
Numbered,
|
||||
Task,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The marker an inline format wraps its text in.
|
||||
fn marker(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Bold => Some("**"),
|
||||
Self::Italic => Some("*"),
|
||||
Self::Strike => Some("~~"),
|
||||
Self::Code => Some("`"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix a line format starts its lines with; numbered lines count up from one.
|
||||
fn prefix(self, nth: usize) -> String {
|
||||
match self {
|
||||
Self::Heading(level) => format!("{} ", "#".repeat(level.clamp(1, 6) as usize)),
|
||||
Self::Quote => "> ".into(),
|
||||
Self::Bullet => "- ".into(),
|
||||
Self::Numbered => format!("{}. ", nth + 1),
|
||||
Self::Task => "- [ ] ".into(),
|
||||
_ => unreachable!("an inline format has no prefix"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered line's prefix, such as `12. `, in bytes; none for other lines.
|
||||
fn number_prefix(line: &str) -> usize {
|
||||
let digits = line.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits > 0 && line[digits..].starts_with(". ") {
|
||||
digits + 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `line` already starts as `format` writes it.
|
||||
fn has(format: Format, line: &str) -> bool {
|
||||
match format {
|
||||
Format::Task => line.starts_with("- [ ] ") || line.starts_with("- [x] "),
|
||||
Format::Bullet => {
|
||||
(line.starts_with("- ") || line.starts_with("* ")) && !has(Format::Task, line)
|
||||
}
|
||||
Format::Numbered => number_prefix(line) > 0,
|
||||
_ => line.starts_with(&format.prefix(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's own prefix: heading hashes, quote, bullet, number or task box, with its space.
|
||||
fn line_prefix(line: &str) -> usize {
|
||||
let hashes = line.chars().take_while(|ch| *ch == '#').count();
|
||||
if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
|
||||
return hashes + 1;
|
||||
}
|
||||
for mark in ["- [ ] ", "- [x] ", "> ", "- ", "* "] {
|
||||
if line.starts_with(mark) {
|
||||
return mark.len();
|
||||
}
|
||||
}
|
||||
number_prefix(line)
|
||||
}
|
||||
|
||||
/// `text` with `format` applied over `selection`, and where the selection lands. An inline format wraps the selection, or unwraps it when already wrapped; an empty one leaves the caret between its markers. A line format prefixes each touched line, or takes the prefix off when every line has it.
|
||||
pub fn apply(text: &str, selection: Range<usize>, format: Format) -> (String, Range<usize>) {
|
||||
assert!(
|
||||
selection.start <= selection.end && selection.end <= text.len(),
|
||||
"the selection {selection:?} lies outside {} bytes",
|
||||
text.len()
|
||||
);
|
||||
if format == Format::Link {
|
||||
let label = &text[selection.clone()];
|
||||
let linked = format!("{}[{label}]()", &text[..selection.start]);
|
||||
let caret = linked.len() - 1;
|
||||
return (format!("{linked}{}", &text[selection.end..]), caret..caret);
|
||||
}
|
||||
if let Some(marker) = format.marker() {
|
||||
let (before, after) = (&text[..selection.start], &text[selection.end..]);
|
||||
let inner = &text[selection.clone()];
|
||||
if before.ends_with(marker) && after.starts_with(marker) {
|
||||
let start = selection.start - marker.len();
|
||||
let unwrapped = format!("{}{inner}{}", &before[..start], &after[marker.len()..]);
|
||||
return (unwrapped, start..selection.end - marker.len());
|
||||
}
|
||||
let shift = marker.len();
|
||||
let wrapped = format!("{before}{marker}{inner}{marker}{after}");
|
||||
return (wrapped, selection.start + shift..selection.end + shift);
|
||||
}
|
||||
let first = text[..selection.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let last = text[selection.end..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| selection.end + at);
|
||||
let lines: Vec<&str> = text[first..last].split('\n').collect();
|
||||
let every = lines.iter().all(|line| has(format, line));
|
||||
let changed: Vec<String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(nth, line)| {
|
||||
let bare = &line[line_prefix(line)..];
|
||||
if every {
|
||||
bare.to_string()
|
||||
} else {
|
||||
format!("{}{bare}", format.prefix(nth))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let block = changed.join("\n");
|
||||
let out = format!("{}{block}{}", &text[..first], &text[last..]);
|
||||
(out, first..first + block.len())
|
||||
}
|
||||
|
||||
/// The one stretch where `new` differs from `old`: the range it replaces and what replaces it.
|
||||
fn changed<'a>(old: &str, new: &'a str) -> (Range<usize>, &'a str) {
|
||||
let head: usize = old
|
||||
.chars()
|
||||
.zip(new.chars())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
let tail: usize = old[head..]
|
||||
.chars()
|
||||
.rev()
|
||||
.zip(new[head..].chars().rev())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
(head..old.len() - tail, &new[head..new.len() - tail])
|
||||
}
|
||||
|
||||
/// Applies `format` to the field's selection as one undo step, then selects what it formatted.
|
||||
pub fn format(field: &Entity<TextInput>, format: Format, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
let text = input.text().to_string();
|
||||
let (next, selection) = apply(&text, input.selection(), format);
|
||||
let (range, inserted) = changed(&text, &next);
|
||||
log::info!("format {format:?}: {range:?} becomes {inserted:?}");
|
||||
input.select(range, cx);
|
||||
input.insert(inserted, cx);
|
||||
input.select(selection, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The formats that hold all of `selection`, for a toolbar to show pressed.
|
||||
pub fn active(text: &str, selection: Range<usize>) -> Vec<Format> {
|
||||
let mut on: Vec<Format> = marks(text)
|
||||
.into_iter()
|
||||
.filter(|(range, _)| range.start <= selection.start && selection.end <= range.end)
|
||||
.filter_map(|(_, mark)| match mark {
|
||||
Mark::Strong => Some(Format::Bold),
|
||||
Mark::Emphasis => Some(Format::Italic),
|
||||
Mark::Strike => Some(Format::Strike),
|
||||
Mark::Code => Some(Format::Code),
|
||||
Mark::Link => Some(Format::Link),
|
||||
Mark::Heading(level) => Some(Format::Heading(level)),
|
||||
Mark::Quote => Some(Format::Quote),
|
||||
Mark::Syntax => None,
|
||||
})
|
||||
.collect();
|
||||
let line = &text[text[..selection.start].rfind('\n').map_or(0, |at| at + 1)..];
|
||||
on.extend(
|
||||
[Format::Task, Format::Bullet, Format::Numbered]
|
||||
.into_iter()
|
||||
.find(|list| has(*list, line)),
|
||||
);
|
||||
on
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inline_formats_wrap_and_unwrap() {
|
||||
let (bold, selection) = apply("a lift b", 2..6, Format::Bold);
|
||||
assert_eq!((bold.as_str(), selection.clone()), ("a **lift** b", 4..8));
|
||||
assert_eq!(
|
||||
apply(&bold, selection, Format::Bold),
|
||||
("a lift b".into(), 2..6)
|
||||
);
|
||||
assert_eq!(apply("ab", 1..1, Format::Code), ("a``b".into(), 2..2));
|
||||
assert_eq!(
|
||||
apply("see docs", 4..8, Format::Link),
|
||||
("see [docs]()".into(), 11..11)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_formats_prefix_every_touched_line_or_take_it_off() {
|
||||
let text = "one\ntwo\nthree";
|
||||
let (listed, selection) = apply(text, 1..6, Format::Numbered);
|
||||
assert_eq!(listed, "1. one\n2. two\nthree");
|
||||
assert_eq!(apply(&listed, selection, Format::Numbered).0, text);
|
||||
assert_eq!(apply("## Old", 3..3, Format::Heading(1)).0, "# Old");
|
||||
assert_eq!(apply("- item", 2..2, Format::Task).0, "- [ ] item");
|
||||
assert_eq!(apply("- [x] done", 2..2, Format::Task).0, "done");
|
||||
assert_eq!(apply("- [ ] task", 2..2, Format::Bullet).0, "- task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_is_the_stretch_between_what_stayed() {
|
||||
assert_eq!(changed("a lift b", "a **lift** b"), (2..6, "**lift**"));
|
||||
assert_eq!(changed("same", "same"), (4..4, ""));
|
||||
assert_eq!(changed("héllo", "hello"), (1..3, "e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressed_formats_hold_the_selection() {
|
||||
let text = "- [ ] a **bold** word";
|
||||
assert_eq!(active(text, 10..12), [Format::Bold, Format::Task]);
|
||||
assert_eq!(active("## Lift", 4..4), [Format::Heading(2)]);
|
||||
assert_eq!(active("12. twelve", 5..5), [Format::Numbered]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::markdown::link_at;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Enter, Input, Run, TextInput, float, is_url, surface},
|
||||
primitives::{FocusScope, IconName, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
|
||||
/// What the editor edits: a link's range and words, or the selection that becomes one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Linking {
|
||||
pub range: Range<usize>,
|
||||
pub label: String,
|
||||
/// The address, when the range is already a link.
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
impl Linking {
|
||||
/// The link around the selection, or the selection itself.
|
||||
pub fn at(text: &str, selection: Range<usize>) -> Self {
|
||||
match link_at(text, selection.start) {
|
||||
Some((range, label, address)) if selection.end <= range.end => Self {
|
||||
range,
|
||||
label,
|
||||
address: Some(address),
|
||||
},
|
||||
_ => Self {
|
||||
label: text[selection.clone()].to_string(),
|
||||
range: selection,
|
||||
address: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The link written in markdown for `address`; with no words of its own it reads as its address.
|
||||
pub fn written(&self, address: &str) -> String {
|
||||
let label = if self.label.is_empty() {
|
||||
address
|
||||
} else {
|
||||
&self.label
|
||||
};
|
||||
format!("[{label}]({address})")
|
||||
}
|
||||
}
|
||||
|
||||
/// The address field and whether it took focus yet.
|
||||
struct Editing {
|
||||
address: Entity<TextInput>,
|
||||
seeded: bool,
|
||||
}
|
||||
|
||||
/// Replaces `range` of the field's text with `text` as one undo step, and closes.
|
||||
fn write(field: &Entity<TextInput>, range: Range<usize>, text: &str, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(range, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Edits the link at the caret, or links the selection, in a panel under it: the address is checked as it is typed and applied with Enter; the link can be removed or opened from the same place. Escape or a press outside closes it, and focus returns to the text. Render it only while open.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LinkEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl LinkEditor {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for LinkEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
let linking = Linking::at(input.text(), input.selection());
|
||||
let anchor = input.bounds_for(linking.range.start);
|
||||
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
||||
let editing = window.use_keyed_state(
|
||||
(self.id.clone(), "editing"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Editing>| Editing {
|
||||
address: cx.new(|cx| TextInput::new(window, cx).placeholder("https://")),
|
||||
seeded: false,
|
||||
},
|
||||
);
|
||||
let address = editing.read(cx).address.clone();
|
||||
if !editing.read(cx).seeded {
|
||||
log::info!("link editor: open over {:?}", linking.range);
|
||||
let written = linking.address.clone().unwrap_or_default();
|
||||
address.update(cx, |field, cx| field.set_text(written, cx));
|
||||
window.focus(&address.focus_handle(cx));
|
||||
editing.update(cx, |editing, _| editing.seeded = true);
|
||||
}
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let close: Run = {
|
||||
let (takeover, on_close) = (takeover.clone(), self.on_close.clone());
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("link editor: closed");
|
||||
give_back(&takeover, window, cx);
|
||||
on_close(window, cx);
|
||||
})
|
||||
};
|
||||
let typed = address.read(cx).text().trim().to_string();
|
||||
let valid = is_url(&typed);
|
||||
let apply: Run = {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
let typed = typed.clone();
|
||||
Rc::new(move |window, cx| {
|
||||
if !is_url(&typed) {
|
||||
return;
|
||||
}
|
||||
log::info!("link editor: {typed} over {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.written(&typed), cx);
|
||||
close(window, cx);
|
||||
})
|
||||
};
|
||||
let remove: Option<Run> = linking.address.as_ref().map(|_| {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
log::info!("link editor: unlinked {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.label, cx);
|
||||
close(window, cx);
|
||||
}) as Run
|
||||
});
|
||||
let Some(anchor) = anchor else {
|
||||
return div().id(self.id);
|
||||
};
|
||||
let (enter, escape, out) = (apply.clone(), close.clone(), close.clone());
|
||||
let press = apply.clone();
|
||||
let opened = linking.address.clone();
|
||||
let panel = div()
|
||||
.id((self.id.clone(), "panel"))
|
||||
.occlude()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
enter(window, cx);
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_down_out(move |_, window, cx| out(window, cx))
|
||||
.child(
|
||||
FocusScope::new(&focus).trap().child(
|
||||
surface((self.id.clone(), "surface"), cx)
|
||||
.p_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().w(cx.theme().menu_width()).child(
|
||||
Input::new(&address)
|
||||
.size(ControlSize::Sm)
|
||||
.invalid(!typed.is_empty() && !valid),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "apply"), "Apply")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!valid)
|
||||
.on_click(move |_, window, cx| press(window, cx)),
|
||||
)
|
||||
.when_some(opened, |row, url| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "open"), IconName::ExternalLink)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Open link")
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
)
|
||||
})
|
||||
.when_some(remove, |row, remove| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "remove"), IconName::Trash2)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Remove link")
|
||||
.on_click(move |_, window, cx| remove(window, cx)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
div().id(self.id).child(float(anchor, 1, panel, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Linking;
|
||||
|
||||
#[test]
|
||||
fn a_link_is_edited_whole_and_a_selection_becomes_one() {
|
||||
let text = "see [the docs](https://ely.dev) here";
|
||||
let inside = Linking::at(text, 8..8);
|
||||
assert_eq!(
|
||||
(inside.range, inside.address.as_deref()),
|
||||
(4..31, Some("https://ely.dev"))
|
||||
);
|
||||
let plain = Linking::at(text, 32..36);
|
||||
assert_eq!(
|
||||
(
|
||||
plain.range.clone(),
|
||||
plain.label.as_str(),
|
||||
plain.address.clone()
|
||||
),
|
||||
(32..36, "here", None)
|
||||
);
|
||||
assert_eq!(plain.written("https://x.dev"), "[here](https://x.dev)");
|
||||
let bare = Linking::at(text, 0..0);
|
||||
assert_eq!(
|
||||
bare.written("https://x.dev"),
|
||||
"[https://x.dev](https://x.dev)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, FontWeight};
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{forms::Highlight, theme::ActiveTheme};
|
||||
|
||||
/// The markdown this crate reads: GitHub's tables, strikethrough and task lists, footnotes, and math between dollars.
|
||||
pub(crate) fn options() -> Options {
|
||||
Options::ENABLE_TABLES
|
||||
| Options::ENABLE_STRIKETHROUGH
|
||||
| Options::ENABLE_TASKLISTS
|
||||
| Options::ENABLE_FOOTNOTES
|
||||
| Options::ENABLE_MATH
|
||||
}
|
||||
|
||||
/// How a stretch of markdown prints while it is edited.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
/// The characters that write the marks: asterisks, backticks, brackets, hashes.
|
||||
Syntax,
|
||||
}
|
||||
|
||||
/// The run of `ch` that starts `text`, in bytes.
|
||||
fn leading(text: &str, ch: char) -> usize {
|
||||
text.chars()
|
||||
.take_while(|found| *found == ch)
|
||||
.map(char::len_utf8)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each.
|
||||
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
|
||||
let mut out = Vec::new();
|
||||
let mut links: Vec<(Range<usize>, Option<Range<usize>>)> = Vec::new();
|
||||
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
|
||||
let span = &text[range.clone()];
|
||||
match event {
|
||||
Event::Start(Tag::Strong | Tag::Emphasis | Tag::Strikethrough) => {
|
||||
let mark = match event {
|
||||
Event::Start(Tag::Strong) => Mark::Strong,
|
||||
Event::Start(Tag::Emphasis) => Mark::Emphasis,
|
||||
_ => Mark::Strike,
|
||||
};
|
||||
let first = span.chars().next().expect("a styled span has its marker");
|
||||
let edge = leading(span, first).min(span.len() / 2);
|
||||
let edge = match mark {
|
||||
Mark::Emphasis => edge.min(first.len_utf8()),
|
||||
_ => edge,
|
||||
};
|
||||
out.push((range.clone(), mark));
|
||||
out.push((range.start..range.start + edge, Mark::Syntax));
|
||||
out.push((range.end - edge..range.end, Mark::Syntax));
|
||||
}
|
||||
Event::Code(_) => {
|
||||
let ticks = leading(span, '`');
|
||||
out.push((range.clone(), Mark::Code));
|
||||
out.push((range.start..range.start + ticks, Mark::Syntax));
|
||||
out.push((range.end - ticks..range.end, Mark::Syntax));
|
||||
}
|
||||
Event::Start(Tag::Link { .. }) => links.push((range, None)),
|
||||
Event::Text(_) if !links.is_empty() => {
|
||||
let (_, inner) = links.last_mut().expect("inside a link");
|
||||
*inner = Some(match inner.take() {
|
||||
Some(inner) => inner.start..range.end,
|
||||
None => range,
|
||||
});
|
||||
}
|
||||
Event::End(TagEnd::Link) => {
|
||||
let (whole, inner) = links.pop().expect("a link ends after it starts");
|
||||
out.push((whole.clone(), Mark::Link));
|
||||
if let Some(inner) = inner {
|
||||
out.push((whole.start..inner.start, Mark::Syntax));
|
||||
out.push((inner.end..whole.end, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
let hashes = leading(span, '#');
|
||||
out.push((range.clone(), Mark::Heading(level as u8)));
|
||||
if hashes > 0 {
|
||||
let spaced = hashes + leading(&span[hashes..], ' ');
|
||||
out.push((range.start..range.start + spaced, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => {
|
||||
out.push((range.clone(), Mark::Quote));
|
||||
let mut at = range.start;
|
||||
for line in span.split_inclusive('\n') {
|
||||
let marker = line.len() - line.trim_start_matches(['>', ' ']).len();
|
||||
out.push((at..at + marker, Mark::Syntax));
|
||||
at += line.len();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.retain(|(range, _)| !range.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// Markdown styled as it is typed: strong and headings heavier, emphasis slanted, strikes struck, code washed, links in their tone; the syntax that writes them recedes.
|
||||
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let plain = Highlight::new(colors.fg);
|
||||
marks(text)
|
||||
.into_iter()
|
||||
.map(|(range, mark)| {
|
||||
let range = match mark {
|
||||
Mark::Strike => {
|
||||
let span = &text[range.clone()];
|
||||
let edge = span.len() - span.trim_start_matches('~').len();
|
||||
range.start + edge..range.end - edge
|
||||
}
|
||||
_ => range,
|
||||
};
|
||||
let highlight = match mark {
|
||||
Mark::Strong | Mark::Heading(_) => Highlight {
|
||||
weight: Some(FontWeight::SEMIBOLD),
|
||||
..plain
|
||||
},
|
||||
Mark::Emphasis => Highlight {
|
||||
italic: true,
|
||||
..plain
|
||||
},
|
||||
Mark::Strike => Highlight {
|
||||
strike: true,
|
||||
..Highlight::new(colors.fg_muted)
|
||||
},
|
||||
Mark::Code => Highlight {
|
||||
background: Some(colors.sunken),
|
||||
..plain
|
||||
},
|
||||
Mark::Link => Highlight::new(colors.link),
|
||||
Mark::Quote => Highlight::new(colors.fg_muted),
|
||||
Mark::Syntax => Highlight::new(colors.fg_subtle),
|
||||
};
|
||||
(range, highlight)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A link written `[text](address)` around byte `at`: its range, its text and its address.
|
||||
pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> {
|
||||
Parser::new_ext(text, options())
|
||||
.into_offset_iter()
|
||||
.find_map(|(event, range)| match event {
|
||||
Event::Start(Tag::Link { dest_url, .. })
|
||||
if range.start <= at && at <= range.end && text[range.clone()].starts_with('[') =>
|
||||
{
|
||||
let written = &text[range.clone()];
|
||||
let label = &written[1..written.rfind("](")?];
|
||||
Some((range, label.to_string(), dest_url.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document's top-level blocks, as ranges of its source.
|
||||
pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
|
||||
let mut depth = 0usize;
|
||||
let mut out = Vec::new();
|
||||
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(_) => {
|
||||
if depth == 0 {
|
||||
out.push(range);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => depth -= 1,
|
||||
Event::Rule if depth == 0 => out.push(range),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|range| {
|
||||
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
||||
range.start..range.start + kept
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Words in `text` as a reader counts them; each ideograph counts as one.
|
||||
pub fn words(text: &str) -> usize {
|
||||
text.unicode_words().count()
|
||||
}
|
||||
|
||||
/// Words an adult reads in a minute, on average.
|
||||
const PACE: usize = 238;
|
||||
|
||||
/// Minutes to read `words`, at least one when there are any.
|
||||
pub fn reading_minutes(words: usize) -> usize {
|
||||
words.div_ceil(PACE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn found(text: &str) -> Vec<(&str, Mark)> {
|
||||
marks(text)
|
||||
.into_iter()
|
||||
.map(|(range, mark)| (&text[range], mark))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_carry_their_markers_as_syntax() {
|
||||
use Mark::*;
|
||||
assert_eq!(
|
||||
found("a **bold** and *it* ~~no~~"),
|
||||
[
|
||||
("**bold**", Strong),
|
||||
("**", Syntax),
|
||||
("**", Syntax),
|
||||
("*it*", Emphasis),
|
||||
("*", Syntax),
|
||||
("*", Syntax),
|
||||
("~~no~~", Strike),
|
||||
("~~", Syntax),
|
||||
("~~", Syntax),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_links_and_headings_mark_their_syntax() {
|
||||
use Mark::*;
|
||||
assert_eq!(found("`x`"), [("`x`", Code), ("`", Syntax), ("`", Syntax)]);
|
||||
assert_eq!(
|
||||
found("see [the docs](https://ely.dev)"),
|
||||
[
|
||||
("[the docs](https://ely.dev)", Link),
|
||||
("[", Syntax),
|
||||
("](https://ely.dev)", Syntax)
|
||||
]
|
||||
);
|
||||
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_gives_its_range_text_and_address() {
|
||||
let text = "see [the docs](https://ely.dev) or <https://x.dev>";
|
||||
assert_eq!(
|
||||
link_at(text, 6),
|
||||
Some((4..31, "the docs".to_string(), "https://ely.dev".to_string()))
|
||||
);
|
||||
assert_eq!(link_at(text, 1), None);
|
||||
assert_eq!(link_at(text, 40), None, "an autolink has no text to edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_are_the_top_level_stretches() {
|
||||
let text = "# Lift\n\nBlend **it**.\n\n- a\n- b\n\n---\n";
|
||||
let blocks: Vec<&str> = block_ranges(text)
|
||||
.into_iter()
|
||||
.map(|range| &text[range])
|
||||
.collect();
|
||||
assert_eq!(blocks, ["# Lift", "Blend **it**.", "- a\n- b", "---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_takes_a_minute_per_pace() {
|
||||
assert_eq!(words("Blend toward white, by lift."), 5);
|
||||
assert_eq!(words("你好世界"), 4);
|
||||
assert_eq!(
|
||||
(reading_minutes(0), reading_minutes(1), reading_minutes(239)),
|
||||
(0, 1, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
mod blocks;
|
||||
mod counts;
|
||||
mod format;
|
||||
mod link;
|
||||
mod markdown;
|
||||
mod modes;
|
||||
mod render;
|
||||
mod rich;
|
||||
mod suggest;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod todo;
|
||||
mod toolbar;
|
||||
mod tree;
|
||||
|
||||
use gpui::App;
|
||||
|
||||
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
pub use format::{Format, format};
|
||||
pub use link::LinkEditor;
|
||||
pub use markdown::markdown_highlights;
|
||||
pub use modes::{MarkdownEditor, MarkdownMode};
|
||||
pub use render::MarkdownRenderer;
|
||||
pub use rich::RichTextEditor;
|
||||
pub use todo::{Checklist, TodoItem};
|
||||
pub use toolbar::FixedFormatToolbar;
|
||||
|
||||
/// Binds the documents keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
rich::bind_keys(cx);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, ElementId, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Styled, Subscription,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
counts::{ReadingTime, WordCount},
|
||||
markdown::{block_ranges, markdown_highlights},
|
||||
render::MarkdownRenderer,
|
||||
rich::RichTextEditor,
|
||||
suggest::offers,
|
||||
toolbar::FixedFormatToolbar,
|
||||
};
|
||||
use crate::{
|
||||
buttons::SegmentedControl,
|
||||
forms::{Down, InputEvent, TextInput, Up},
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// How a markdown editor shows its text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MarkdownMode {
|
||||
/// The document as it reads; a pressed block opens as its source.
|
||||
Visual,
|
||||
/// The markdown, styled as it is typed.
|
||||
Source,
|
||||
/// The source beside the document it makes.
|
||||
Split,
|
||||
}
|
||||
|
||||
impl MarkdownMode {
|
||||
const ALL: [(Self, &'static str); 3] = [
|
||||
(Self::Visual, "Visual"),
|
||||
(Self::Source, "Source"),
|
||||
(Self::Split, "Split"),
|
||||
];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|(mode, _)| *mode == self)
|
||||
.map(|(_, name)| *name)
|
||||
.expect("every mode is named")
|
||||
}
|
||||
}
|
||||
|
||||
type OnMode = Rc<dyn Fn(MarkdownMode, &mut Window, &mut App)>;
|
||||
|
||||
/// The block open as its source: where it came from, its own field, and whether it adds a block at the end.
|
||||
struct Open {
|
||||
range: Range<usize>,
|
||||
input: Entity<TextInput>,
|
||||
append: bool,
|
||||
_blur: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Visual {
|
||||
open: Option<Open>,
|
||||
}
|
||||
|
||||
/// `range` moved by an edit that wrote `written` bytes over `replaced`, when it lies after it.
|
||||
fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Range<usize> {
|
||||
if range.start < replaced.end {
|
||||
return range;
|
||||
}
|
||||
let (start, end) = (
|
||||
range.start - replaced.len() + written,
|
||||
range.end - replaced.len() + written,
|
||||
);
|
||||
start..end
|
||||
}
|
||||
|
||||
/// What joins a block added at the end to the text before it.
|
||||
fn joint(document: &str) -> &'static str {
|
||||
if document.is_empty() || document.ends_with("\n\n") {
|
||||
""
|
||||
} else if document.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
"\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the open block back over its range as one undo step, and closes it; the range replaced and the bytes written.
|
||||
fn commit(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
cx: &mut App,
|
||||
) -> Option<(Range<usize>, usize)> {
|
||||
let open = state.update(cx, |visual, cx| {
|
||||
cx.notify();
|
||||
visual.open.take()
|
||||
})?;
|
||||
let written = open.input.read(cx).text().to_string();
|
||||
let document = field.read(cx).text().to_string();
|
||||
let written = match open.append {
|
||||
true if written.is_empty() => return Some((open.range, 0)),
|
||||
true => format!("{}{written}", joint(&document)),
|
||||
false => written,
|
||||
};
|
||||
if document[open.range.clone()] != written {
|
||||
log::info!("markdown editor: block {:?} written back", open.range);
|
||||
field.update(cx, |document, cx| {
|
||||
document.select(open.range.clone(), cx);
|
||||
document.insert(&written, cx);
|
||||
});
|
||||
}
|
||||
Some((open.range, written.len()))
|
||||
}
|
||||
|
||||
/// Opens the block at `range` in a field of its own, so undo stays with it; the caret at its start or end.
|
||||
fn open(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
range: Range<usize>,
|
||||
append: bool,
|
||||
at_start: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let source = field.read(cx).text()[range.clone()].to_string();
|
||||
log::info!("markdown editor: block {range:?} opens");
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx));
|
||||
let blur = {
|
||||
let (state, field) = (state.clone(), field.clone());
|
||||
cx.subscribe(&input, move |_, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Blur {
|
||||
commit(&state, &field, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
state.update(cx, |visual, cx| {
|
||||
visual.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
_blur: blur,
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the caret sits on the field's first line, or with `last` its last.
|
||||
pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool {
|
||||
let edge = if last { input.text().len() } else { 0 };
|
||||
match (input.bounds_for(input.cursor()), input.bounds_for(edge)) {
|
||||
(Some(caret), Some(edge)) => caret.top() == edge.top(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown written three ways: visual, where the document reads as it will and a pressed block opens as its source; source, styled as it is typed; and split, the source beside the document. The toolbar, links, suggestions and counts come with each.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
mode: MarkdownMode,
|
||||
people: Vec<SharedString>,
|
||||
on_mode: Option<OnMode>,
|
||||
}
|
||||
|
||||
impl MarkdownEditor {
|
||||
/// Give the field several lines and `markdown_highlights` as its highlighter.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>, mode: MarkdownMode) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
mode,
|
||||
people: Vec::new(),
|
||||
on_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `@` suggests.
|
||||
pub fn people(mut self, handles: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.people = handles.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mode(
|
||||
mut self,
|
||||
handler: impl Fn(MarkdownMode, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_mode = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state =
|
||||
window.use_keyed_state((self.id.clone(), "visual"), cx, |_, _| Visual::default());
|
||||
let switch = {
|
||||
let (state, field, on_mode) = (state.clone(), self.field.clone(), self.on_mode.clone());
|
||||
MarkdownMode::ALL
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new((self.id.clone(), "mode"), self.mode.name())
|
||||
.size(ControlSize::Sm),
|
||||
|switch, (_, name)| switch.segment(*name, *name, None),
|
||||
)
|
||||
.on_change(move |name, window, cx| {
|
||||
let (mode, _) = MarkdownMode::ALL
|
||||
.into_iter()
|
||||
.find(|(_, named)| *named == name.as_ref())
|
||||
.expect("a segment names a mode");
|
||||
commit(&state, &field, cx);
|
||||
log::info!("markdown editor: {name}");
|
||||
if let Some(on_mode) = &on_mode {
|
||||
on_mode(mode, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
match self.mode {
|
||||
MarkdownMode::Source => RichTextEditor::new(self.id, &self.field)
|
||||
.people(self.people)
|
||||
.toolbar_end(switch)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Split => {
|
||||
let text = SharedString::from(self.field.read(cx).text().to_string());
|
||||
RichTextEditor::new(self.id.clone(), &self.field)
|
||||
.people(self.people)
|
||||
.toolbar_end(switch)
|
||||
.beside(MarkdownRenderer::new((self.id, "preview"), text))
|
||||
.into_any_element()
|
||||
}
|
||||
MarkdownMode::Visual => visual(self, state, switch, window, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The visual body: each block rendered, the open one as its source.
|
||||
fn visual(
|
||||
editor: MarkdownEditor,
|
||||
state: Entity<Visual>,
|
||||
switch: SegmentedControl,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let (colors, radius, frame, bar, tiny) = {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors.clone(),
|
||||
theme.radius(Radius::Md),
|
||||
theme.radius(Radius::Lg),
|
||||
theme.control_height(ControlSize::Lg),
|
||||
theme.text_size(TextSize::Xs),
|
||||
)
|
||||
};
|
||||
let document = editor.field.read(cx).text().to_string();
|
||||
let blocks = block_ranges(&document);
|
||||
let opened = state
|
||||
.read(cx)
|
||||
.open
|
||||
.as_ref()
|
||||
.map(|open| (open.range.clone(), open.input.clone(), open.append));
|
||||
let field = editor.field.clone();
|
||||
let source = |input: &Entity<TextInput>, window: &mut Window, cx: &mut App| {
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
offers(
|
||||
(editor.id.clone(), "offers").into(),
|
||||
input,
|
||||
&editor.people,
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.wrap(input.clone(), window, cx),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
for (ix, range) in blocks.iter().enumerate() {
|
||||
if let Some((open_range, input, false)) = &opened
|
||||
&& open_range == range
|
||||
{
|
||||
rows.push(source(input, window, cx));
|
||||
continue;
|
||||
}
|
||||
let (state, field, pressed) = (state.clone(), field.clone(), range.clone());
|
||||
rows.push(
|
||||
div()
|
||||
.id((editor.id.clone(), format!("block-{ix}")))
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.cursor_text()
|
||||
.hover(|block| block.bg(colors.hover.opacity(0.5)))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let range = match commit(&state, &field, cx) {
|
||||
Some((replaced, written)) => shifted(pressed.clone(), &replaced, written),
|
||||
None => pressed.clone(),
|
||||
};
|
||||
open(&state, &field, range, false, false, window, cx);
|
||||
})
|
||||
.child(MarkdownRenderer::new(
|
||||
(editor.id.clone(), format!("render-{ix}")),
|
||||
document[range.clone()].to_string(),
|
||||
))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
if let Some((_, input, true)) = &opened {
|
||||
rows.push(source(input, window, cx));
|
||||
}
|
||||
let (more_state, more_field) = (state.clone(), field.clone());
|
||||
let more = div()
|
||||
.id((editor.id.clone(), "more"))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.cursor_text()
|
||||
.text_color(colors.fg_subtle)
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
commit(&more_state, &more_field, cx);
|
||||
let end = more_field.read(cx).text().len();
|
||||
open(&more_state, &more_field, end..end, true, false, window, cx);
|
||||
})
|
||||
.when(document.is_empty() && opened.is_none(), |more| {
|
||||
more.child("Start writing")
|
||||
})
|
||||
.when(!document.is_empty(), |more| more.min_h_8());
|
||||
let open_input = opened.as_ref().map(|(_, input, _)| input.clone());
|
||||
let edges = |last: bool| {
|
||||
let (state, field) = (state.clone(), field.clone());
|
||||
move |window: &mut Window, cx: &mut App| -> bool {
|
||||
let Some((range, input)) = state
|
||||
.read(cx)
|
||||
.open
|
||||
.as_ref()
|
||||
.map(|open| (open.range.clone(), open.input.clone()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !at_edge(input.read(cx), last) {
|
||||
return false;
|
||||
}
|
||||
let blocks = block_ranges(field.read(cx).text());
|
||||
let target = if last {
|
||||
blocks.into_iter().find(|block| block.start >= range.end)
|
||||
} else {
|
||||
blocks
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|block| block.end <= range.start)
|
||||
};
|
||||
let Some(target) = target else { return false };
|
||||
let target = match commit(&state, &field, cx) {
|
||||
Some((replaced, written)) => shifted(target, &replaced, written),
|
||||
None => target,
|
||||
};
|
||||
open(&state, &field, target, false, last, window, cx);
|
||||
true
|
||||
}
|
||||
};
|
||||
let (up, down) = (edges(false), edges(true));
|
||||
let (escape_state, escape_field) = (state.clone(), field.clone());
|
||||
let text = SharedString::from(document);
|
||||
div()
|
||||
.id(editor.id.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(frame)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(bar)
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div().children(open_input.map(|input| {
|
||||
FixedFormatToolbar::new((editor.id.clone(), "tools"), &input)
|
||||
})),
|
||||
)
|
||||
.child(switch),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id((editor.id.clone(), "blocks"))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.capture_action(move |_: &Up, window, cx| {
|
||||
if up(window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, window, cx| {
|
||||
if down(window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if event.keystroke.key == "escape" && escape_state.read(cx).open.is_some() {
|
||||
cx.stop_propagation();
|
||||
commit(&escape_state, &escape_field, cx);
|
||||
}
|
||||
})
|
||||
.children(rows)
|
||||
.child(more),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(tiny)
|
||||
.child(WordCount::new(text.clone()))
|
||||
.child(ReadingTime::new(text)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{joint, shifted};
|
||||
|
||||
#[test]
|
||||
fn later_blocks_move_by_what_an_edit_wrote() {
|
||||
assert_eq!(shifted(10..14, &(2..6), 9), 15..19);
|
||||
assert_eq!(
|
||||
shifted(0..2, &(2..6), 9),
|
||||
0..2,
|
||||
"a block before the edit stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_at_the_end_starts_a_paragraph() {
|
||||
assert_eq!(
|
||||
(joint(""), joint("a"), joint("a\n"), joint("a\n\n")),
|
||||
("", "\n\n", "\n", "")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
|
||||
UnderlineStyle, Window, div, prelude::*,
|
||||
};
|
||||
use pulldown_cmark::Alignment;
|
||||
|
||||
use super::tree::{Inline, Node, Style, parse};
|
||||
use crate::{
|
||||
editor::code_colors,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::Latex,
|
||||
};
|
||||
|
||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
|
||||
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownRenderer {
|
||||
id: ElementId,
|
||||
source: SharedString,
|
||||
on_link: Option<OnLink>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
source: source.into(),
|
||||
on_link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes link presses instead of the browser.
|
||||
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_link = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders nodes with one set of links handlers and ids under one owner.
|
||||
struct Draw<'a> {
|
||||
id: &'a ElementId,
|
||||
on_link: &'a Option<OnLink>,
|
||||
next: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
fn key(&self, what: &str) -> ElementId {
|
||||
let at = self.next.get();
|
||||
self.next.set(at + 1);
|
||||
(self.id.clone(), format!("{what}-{at}")).into()
|
||||
}
|
||||
|
||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rule = theme.underline_thickness();
|
||||
let styles: Vec<_> = inline
|
||||
.styles
|
||||
.iter()
|
||||
.map(|(range, style)| {
|
||||
let highlight = match style {
|
||||
Style::Strong => HighlightStyle {
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Emphasis => HighlightStyle {
|
||||
font_style: Some(FontStyle::Italic),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Strike => HighlightStyle {
|
||||
strikethrough: Some(StrikethroughStyle {
|
||||
thickness: rule,
|
||||
color: Some(colors.fg_muted),
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Code | Style::Math => HighlightStyle {
|
||||
background_color: Some(colors.sunken),
|
||||
color: Some(if *style == Style::Math {
|
||||
colors.syntax.function
|
||||
} else {
|
||||
colors.fg
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Link => HighlightStyle {
|
||||
color: Some(colors.link),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: rule,
|
||||
color: Some(colors.link.opacity(0.4)),
|
||||
wavy: false,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Footnote => HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
};
|
||||
(range.clone(), highlight)
|
||||
})
|
||||
.collect();
|
||||
let styled =
|
||||
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||
if inline.links.is_empty() {
|
||||
return styled.into_any_element();
|
||||
}
|
||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
||||
let on_link = self.on_link.clone();
|
||||
InteractiveText::new(self.key("links"), styled)
|
||||
.on_click(ranges, move |ix, window, cx| {
|
||||
let url = &urls[ix];
|
||||
log::info!("markdown: open {url}");
|
||||
match &on_link {
|
||||
Some(on_link) => on_link(url, window, cx),
|
||||
None => cx.open_url(url),
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||
nodes.iter().map(|node| self.node(node, cx)).collect()
|
||||
}
|
||||
|
||||
fn node(&self, node: &Node, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
match node {
|
||||
Node::Paragraph(inline) => div()
|
||||
.text_color(colors.fg)
|
||||
.child(self.inline(inline, cx))
|
||||
.into_any_element(),
|
||||
Node::Heading(level, inline) => {
|
||||
let size = match level {
|
||||
1 => TextSize::Xxl,
|
||||
2 => TextSize::Xl,
|
||||
3 => TextSize::Lg,
|
||||
_ => TextSize::Md,
|
||||
};
|
||||
div()
|
||||
.pt_2()
|
||||
.text_size(theme.text_size(size))
|
||||
.font_weight(if *level <= 2 {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::MEDIUM
|
||||
})
|
||||
.text_color(colors.fg)
|
||||
.child(self.inline(inline, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::Quote(inner) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.pl_4()
|
||||
.border_l_2()
|
||||
.border_color(colors.border_strong)
|
||||
.text_color(colors.fg_muted)
|
||||
.children(self.nodes(inner, cx))
|
||||
.into_any_element(),
|
||||
Node::Code { language, text } => {
|
||||
let styles = code_colors(text, cx);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.syntax.variable)
|
||||
.children(language.clone().map(|name| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(name)
|
||||
}))
|
||||
.child(StyledText::new(text.clone()).with_highlights(styles))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::List { start, items } => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(items.iter().enumerate().map(|(ix, (task, body))| {
|
||||
let marker: AnyElement = match (task, start) {
|
||||
(Some(done), _) => Icon::new(if *done {
|
||||
IconName::SquareCheck
|
||||
} else {
|
||||
IconName::Square
|
||||
})
|
||||
.size(IconSize::Sm)
|
||||
.color(if *done {
|
||||
colors.focus
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
})
|
||||
.into_any_element(),
|
||||
(None, Some(first)) => div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(format!("{}.", first + ix as u64))
|
||||
.into_any_element(),
|
||||
(None, None) => div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child("•")
|
||||
.into_any_element(),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.min_w_5()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.child(marker),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.when(*task == Some(true), |body| {
|
||||
body.text_color(colors.fg_subtle)
|
||||
})
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
}))
|
||||
.into_any_element(),
|
||||
Node::Table { aligns, head, rows } => {
|
||||
let cell = |inline: &Inline, column: usize, header: bool, cx: &App| {
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.when(aligns.get(column) == Some(&Alignment::Right), |cell| {
|
||||
cell.justify_end()
|
||||
})
|
||||
.when(aligns.get(column) == Some(&Alignment::Center), |cell| {
|
||||
cell.justify_center()
|
||||
})
|
||||
.when(header, |cell| {
|
||||
cell.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_muted)
|
||||
})
|
||||
.child(self.inline(inline, cx))
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div().flex().bg(colors.hover).children(
|
||||
head.iter()
|
||||
.enumerate()
|
||||
.map(|(column, inline)| cell(inline, column, true, cx)),
|
||||
),
|
||||
)
|
||||
.children(rows.iter().map(|row| {
|
||||
div()
|
||||
.flex()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.children(
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(column, inline)| cell(inline, column, false, cx)),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
Node::Rule => div()
|
||||
.my_2()
|
||||
.h_0()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.into_any_element(),
|
||||
Node::Math(tex) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(match Latex::parse(tex) {
|
||||
Ok(math) => math.into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
})
|
||||
.into_any_element(),
|
||||
Node::Image { alt, url } => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Image)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(div().flex_1().child(alt.clone()))
|
||||
.child(div().text_color(colors.fg_subtle).child(url.clone()))
|
||||
.into_any_element(),
|
||||
Node::Footnote { label, body } => div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(div().flex_none().child(format!("[{label}]")))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownRenderer {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let nodes = parse(&self.source);
|
||||
let draw = Draw {
|
||||
id: &self.id,
|
||||
on_link: &self.on_link,
|
||||
next: std::cell::Cell::new(0),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.line_height(theme.text_size(TextSize::Base) * 1.6)
|
||||
.children(draw.nodes(&nodes, cx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, KeyBinding, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, actions, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
counts::{ReadingTime, WordCount},
|
||||
format::{Format, active, format},
|
||||
link::LinkEditor,
|
||||
suggest::offers,
|
||||
toolbar::{FixedFormatToolbar, INLINE, tool},
|
||||
};
|
||||
use crate::{
|
||||
forms::{Run, TextInput},
|
||||
overlays::FloatingToolbar,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
actions!(ely_rich_text, [ToggleBold, ToggleItalic, EditLink]);
|
||||
|
||||
const CONTEXT: &str = "ElyRichText";
|
||||
|
||||
/// Binds the rich text keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-b", ToggleBold, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-i", ToggleItalic, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-k", EditLink, Some(CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Markdown that reads as it is written: styles show as they are typed, the toolbar above and the one over a selection apply them, a link opens its editor, `@` and `:` suggest people and emoji, and the foot counts words and minutes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RichTextEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
people: Vec<SharedString>,
|
||||
end: Option<AnyElement>,
|
||||
beside: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl RichTextEditor {
|
||||
/// Give the field several lines and `markdown_highlights` as its highlighter.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
people: Vec::new(),
|
||||
end: None,
|
||||
beside: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sits at the toolbar's far end, such as a switch of views.
|
||||
pub fn toolbar_end(mut self, element: impl IntoElement) -> Self {
|
||||
self.end = Some(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sits beside the text, as a preview does.
|
||||
pub fn beside(mut self, element: impl IntoElement) -> Self {
|
||||
self.beside = Some(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Handles `@` suggests.
|
||||
pub fn people(mut self, handles: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.people = handles.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RichTextEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
assert!(
|
||||
input.is_multi_line(),
|
||||
"rich text {:?} needs a field of several lines",
|
||||
self.id
|
||||
);
|
||||
let text = SharedString::from(input.text().to_string());
|
||||
let pressed = active(input.text(), input.selection());
|
||||
let linking = window.use_keyed_state((self.id.clone(), "linking"), cx, |_, _| false);
|
||||
let set_linking = |open: bool| -> Run {
|
||||
let linking = linking.clone();
|
||||
Rc::new(move |_, cx| {
|
||||
log::info!(
|
||||
"rich text: link editor {}",
|
||||
if open { "opens" } else { "closes" }
|
||||
);
|
||||
linking.update(cx, |linking, cx| {
|
||||
*linking = open;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
};
|
||||
let (open_link, close_link) = (set_linking(true), set_linking(false));
|
||||
let float_id: ElementId = (self.id.clone(), "float").into();
|
||||
let floating = FloatingToolbar::new(float_id.clone(), &self.field).children(
|
||||
INLINE.iter().map(|tool_of| {
|
||||
let on = pressed.contains(&tool_of.0);
|
||||
tool(
|
||||
&float_id,
|
||||
&self.field,
|
||||
*tool_of,
|
||||
on,
|
||||
Some(open_link.clone()),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let suggestions = offers(
|
||||
(self.id.clone(), "offers").into(),
|
||||
&self.field,
|
||||
&self.people,
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.wrap(self.field.clone(), window, cx);
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (bold, italic, link) = (self.field.clone(), self.field.clone(), open_link.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(CONTEXT)
|
||||
.on_action(move |_: &ToggleBold, _, cx| format(&bold, Format::Bold, cx))
|
||||
.on_action(move |_: &ToggleItalic, _, cx| format(&italic, Format::Italic, cx))
|
||||
.on_action(move |_: &EditLink, window, cx| link(window, cx))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
FixedFormatToolbar::new((self.id.clone(), "tools"), &self.field)
|
||||
.on_link(move |window, cx| open_link(window, cx)),
|
||||
)
|
||||
.children(self.end),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().px_4().py_3().child(suggestions))
|
||||
.children(self.beside.map(|beside| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.child(beside)
|
||||
})),
|
||||
)
|
||||
.child(floating)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(WordCount::new(text.clone()))
|
||||
.child(ReadingTime::new(text)),
|
||||
)
|
||||
.when(*linking.read(cx), |editor| {
|
||||
editor.child(LinkEditor::new(
|
||||
(self.id.clone(), "link"),
|
||||
&self.field,
|
||||
move |window, cx| close_link(window, cx),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, Entity, SharedString, Window};
|
||||
|
||||
use crate::forms::{
|
||||
Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching,
|
||||
};
|
||||
|
||||
/// A trigger of the owner's: from the query and where it runs, the rows and what a pick does.
|
||||
pub(crate) type Offer<'a> = &'a dyn Fn(&str, usize, usize) -> (Vec<Choice>, Pick);
|
||||
|
||||
/// An emoji name needs this many letters before `:` suggests, so a colon in prose stays quiet.
|
||||
const EMOJI_QUERY: usize = 2;
|
||||
|
||||
/// Emoji shown for a `:` query.
|
||||
const EMOJI_SHOWN: usize = 6;
|
||||
|
||||
/// An offer of nothing: no rows, so no pick can come.
|
||||
fn nothing() -> (Vec<Choice>, Pick) {
|
||||
(
|
||||
Vec::new(),
|
||||
Rc::new(|_: usize, _: &mut Window, _: &mut App| unreachable!("an empty offer has no row")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
|
||||
pub(crate) fn replace_trigger(
|
||||
field: &Entity<TextInput>,
|
||||
at: usize,
|
||||
caret: usize,
|
||||
text: &str,
|
||||
cx: &mut App,
|
||||
) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(at..caret, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// What `@` and `:` offer at the field's caret: people by handle, emoji by name; `extra` adds a trigger of the owner's, such as `/` in blocks.
|
||||
pub(crate) fn offers(
|
||||
id: ElementId,
|
||||
field: &Entity<TextInput>,
|
||||
people: &[SharedString],
|
||||
extra: Option<(char, Offer)>,
|
||||
cx: &App,
|
||||
) -> Suggestions {
|
||||
let input = field.read(cx);
|
||||
let (text, caret) = (input.text().to_string(), input.cursor());
|
||||
let mut marks = vec!['@', ':'];
|
||||
marks.extend(extra.as_ref().map(|(mark, _)| *mark));
|
||||
let active = active_trigger(&text, caret, &marks);
|
||||
let query = active.map_or("", |(at, mark)| &text[at + mark.len_utf8()..caret]);
|
||||
let (rows, pick): (Vec<Choice>, Pick) = match active {
|
||||
Some((at, '@')) => {
|
||||
let found = handles_matching(people, query);
|
||||
let rows = found
|
||||
.iter()
|
||||
.map(|name| Choice::new(name.clone(), format!("@{name}")))
|
||||
.collect();
|
||||
let field = field.clone();
|
||||
(
|
||||
rows,
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
log::info!("mention: @{}", found[pick]);
|
||||
replace_trigger(&field, at, caret, &format!("@{} ", found[pick]), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some((at, ':')) if query.len() >= EMOJI_QUERY => {
|
||||
let found: Vec<&'static emojis::Emoji> = emoji_found(&query.to_lowercase())
|
||||
.into_iter()
|
||||
.take(EMOJI_SHOWN)
|
||||
.collect();
|
||||
let rows = found
|
||||
.iter()
|
||||
.map(|emoji| {
|
||||
let name = emoji.shortcode().unwrap_or(emoji.name());
|
||||
Choice::new(name, format!("{} {name}", emoji.as_str()))
|
||||
})
|
||||
.collect();
|
||||
let field = field.clone();
|
||||
(
|
||||
rows,
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
log::info!("emoji: {}", found[pick].name());
|
||||
replace_trigger(&field, at, caret, found[pick].as_str(), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some((at, mark)) => match &extra {
|
||||
Some((extra_mark, build)) if *extra_mark == mark => build(query, at, caret),
|
||||
_ => nothing(),
|
||||
},
|
||||
None => nothing(),
|
||||
};
|
||||
Suggestions {
|
||||
id,
|
||||
state: field.clone(),
|
||||
trigger: active.map(|(at, _)| at),
|
||||
rows,
|
||||
pick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{BlockData, BlockEditor, BlockKind};
|
||||
use crate::{forms, theme::Theme};
|
||||
|
||||
fn open(
|
||||
blocks: Vec<BlockData>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<BlockEditor>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
super::bind_keys(cx);
|
||||
});
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx));
|
||||
settle(cx);
|
||||
(editor, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn blocks(
|
||||
editor: &Entity<BlockEditor>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Vec<(BlockKind, Vec<String>)> {
|
||||
cx.update(|_, cx| {
|
||||
editor
|
||||
.read(cx)
|
||||
.blocks(cx)
|
||||
.into_iter()
|
||||
.map(|block| (block.kind, block.texts))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts the caret in block `ix`'s first field at byte `at`.
|
||||
fn caret(editor: &Entity<BlockEditor>, ix: usize, at: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
let field = editor.read(cx).blocks[ix].fields[0].clone();
|
||||
field.update(cx, |field, cx| field.select(at..at, cx));
|
||||
window.focus(&field.focus_handle(cx));
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec<String>) {
|
||||
(kind, vec![text.to_string()])
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx);
|
||||
caret(&editor, 0, 2, cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(
|
||||
vec![
|
||||
BlockData::new(BlockKind::Paragraph, ["tone "]),
|
||||
BlockData::new(BlockKind::Paragraph, ["lift"]),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
caret(&editor, 1, 0, cx);
|
||||
cx.simulate_keystrokes("backspace");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[text(BlockKind::Paragraph, "tone lift")]
|
||||
);
|
||||
cx.simulate_keystrokes("cmd-z");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
blocks(&editor, cx),
|
||||
[
|
||||
text(BlockKind::Paragraph, "tone "),
|
||||
text(BlockKind::Paragraph, "lift")
|
||||
],
|
||||
"undo brings the joined block back"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx);
|
||||
caret(&editor, 0, 0, cx);
|
||||
cx.simulate_input("## Lift");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx);
|
||||
caret(&editor, 0, 0, cx);
|
||||
cx.simulate_keystrokes("backspace");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(300.0), gpui::px(10.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert!(focused.is_some(), "the press focuses the block's field");
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(blocks(&editor, cx).len(), 2);
|
||||
}
|
||||
|
||||
/// The editor at a fixed width inside a row, as a page holds it.
|
||||
struct Hosted(Entity<BlockEditor>);
|
||||
|
||||
impl gpui::Render for Hosted {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement, Styled};
|
||||
gpui::div()
|
||||
.flex()
|
||||
.child(gpui::div().w(gpui::px(760.0)).child(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
super::bind_keys(cx);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|window, cx| {
|
||||
let editor = cx.new(|cx| {
|
||||
BlockEditor::new(
|
||||
vec![BlockData::new(BlockKind::Paragraph, ["lift"])],
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
Hosted(editor)
|
||||
});
|
||||
settle(cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(700.0), gpui::px(10.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let editor = cx.update(|_, cx| host.read(cx).0.clone());
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert!(focused.is_some(), "the press focuses the block's field");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(300.0), gpui::px(1.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
|
||||
assert_eq!(
|
||||
focused,
|
||||
Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Checkbox, OnFlag},
|
||||
motion::ProgressBar,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
type OnItem = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A to-do: its box and its words; done, the words go quiet and struck through.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TodoItem {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
done: bool,
|
||||
on_toggle: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl TodoItem {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, done: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
done,
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TodoItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let check = Checkbox::new(self.id, self.done);
|
||||
let check = match self.on_toggle {
|
||||
Some(on_toggle) => check.on_change(move |done, window, cx| on_toggle(done, window, cx)),
|
||||
None => check,
|
||||
};
|
||||
div().flex().items_center().gap_2().child(check).child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.when(self.done, |label| {
|
||||
label.line_through().text_color(colors.fg_subtle)
|
||||
})
|
||||
.child(self.label),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// To-dos under a title, with how many are done as a count and a bar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checklist {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
items: Vec<(SharedString, bool)>,
|
||||
on_toggle: Option<OnItem>,
|
||||
}
|
||||
|
||||
impl Checklist {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
items: impl IntoIterator<Item = (impl Into<SharedString>, bool)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|(label, done)| (label.into(), done))
|
||||
.collect(),
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells which item changed, by index, and whether it is now done.
|
||||
pub fn on_toggle(
|
||||
mut self,
|
||||
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Checklist {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let done = self.items.iter().filter(|(_, done)| *done).count();
|
||||
let total = self.items.len();
|
||||
let share = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
done as f32 / total as f32
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(div().font_weight(FontWeight::MEDIUM).child(self.title))
|
||||
.child(
|
||||
tabular(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted),
|
||||
)
|
||||
.child(format!("{done} of {total}")),
|
||||
),
|
||||
)
|
||||
.child(ProgressBar::new((self.id.clone(), "done"), share))
|
||||
.children(
|
||||
self.items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, is_done))| {
|
||||
let item =
|
||||
TodoItem::new((self.id.clone(), format!("item-{ix}")), label, is_done);
|
||||
match &self.on_toggle {
|
||||
Some(on_toggle) => {
|
||||
let on_toggle = on_toggle.clone();
|
||||
item.on_toggle(move |done, window, cx| {
|
||||
on_toggle(ix, done, window, cx)
|
||||
})
|
||||
}
|
||||
None => item,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use gpui::{App, ElementId, Entity, IntoElement, Keystroke, ParentElement, RenderOnce, Window};
|
||||
|
||||
use super::format::{Format, active, format};
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem, shortcut_text},
|
||||
forms::{Run, TextInput},
|
||||
primitives::IconName,
|
||||
shell::{Toolbar, ToolbarGroup, ToolbarSeparator},
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
/// A tool: the format it applies, its icon, its name and its key.
|
||||
type Tool = (Format, IconName, &'static str, Option<&'static str>);
|
||||
|
||||
/// The inline styles, which the floating toolbar shows too.
|
||||
pub(crate) const INLINE: &[Tool] = &[
|
||||
(Format::Bold, IconName::Bold, "Bold", Some("secondary-b")),
|
||||
(
|
||||
Format::Italic,
|
||||
IconName::Italic,
|
||||
"Italic",
|
||||
Some("secondary-i"),
|
||||
),
|
||||
(
|
||||
Format::Strike,
|
||||
IconName::Strikethrough,
|
||||
"Strikethrough",
|
||||
None,
|
||||
),
|
||||
(Format::Code, IconName::Code, "Code", None),
|
||||
(Format::Link, IconName::Link, "Link", Some("secondary-k")),
|
||||
];
|
||||
|
||||
const HEADINGS: &[Tool] = &[
|
||||
(Format::Heading(1), IconName::Heading1, "Heading 1", None),
|
||||
(Format::Heading(2), IconName::Heading2, "Heading 2", None),
|
||||
(Format::Heading(3), IconName::Heading3, "Heading 3", None),
|
||||
];
|
||||
|
||||
const BLOCKS: &[Tool] = &[
|
||||
(Format::Quote, IconName::Quote, "Quote", None),
|
||||
(Format::Bullet, IconName::List, "Bulleted list", None),
|
||||
(
|
||||
Format::Numbered,
|
||||
IconName::ListOrdered,
|
||||
"Numbered list",
|
||||
None,
|
||||
),
|
||||
(Format::Task, IconName::ListTodo, "To-do list", None),
|
||||
];
|
||||
|
||||
/// One tool as a toggle: pressed while `on`; a press applies its format, or opens the link editor.
|
||||
pub(crate) fn tool(
|
||||
id: &ElementId,
|
||||
field: &Entity<TextInput>,
|
||||
(format_of, icon, name, key): Tool,
|
||||
on: bool,
|
||||
on_link: Option<Run>,
|
||||
) -> ToggleButton {
|
||||
let tip = match key {
|
||||
Some(key) => {
|
||||
let stroke = Keystroke::parse(key).expect("a tool's key parses");
|
||||
format!("{name} {}", shortcut_text(&stroke))
|
||||
}
|
||||
None => name.to_string(),
|
||||
};
|
||||
let field = field.clone();
|
||||
ToggleButton::new(
|
||||
(id.clone(), name),
|
||||
ToggleItem::new(name).icon(icon).tooltip(tip),
|
||||
on,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, window, cx| match (&on_link, format_of) {
|
||||
(Some(open), Format::Link) => open(window, cx),
|
||||
_ => format(&field, format_of, cx),
|
||||
})
|
||||
}
|
||||
|
||||
/// The formats a markdown field takes, in a row above it: inline styles, headings, quote and lists. A tool shows pressed while the selection holds its format; a press applies it and leaves focus in the field.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FixedFormatToolbar {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_link: Option<Run>,
|
||||
}
|
||||
|
||||
impl FixedFormatToolbar {
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The link tool opens the owner's link editor instead of writing `[text]()`.
|
||||
pub fn on_link(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_link = Some(std::rc::Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FixedFormatToolbar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
let pressed = active(input.text(), input.selection());
|
||||
let group = |tools: &[Tool]| {
|
||||
ToolbarGroup::new().children(tools.iter().map(|tool_of| {
|
||||
let on = pressed.contains(&tool_of.0);
|
||||
tool(&self.id, &self.field, *tool_of, on, self.on_link.clone())
|
||||
}))
|
||||
};
|
||||
Toolbar::new()
|
||||
.child(group(INLINE))
|
||||
.child(ToolbarSeparator)
|
||||
.child(group(HEADINGS))
|
||||
.child(ToolbarSeparator)
|
||||
.child(group(BLOCKS))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
|
||||
|
||||
use super::markdown::options;
|
||||
|
||||
/// How a stretch of inline text prints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Style {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Math,
|
||||
Footnote,
|
||||
}
|
||||
|
||||
/// Inline content: its text, the styled stretches of it, and the address of each link, in order.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Inline {
|
||||
pub text: String,
|
||||
pub styles: Vec<(Range<usize>, Style)>,
|
||||
pub links: Vec<(Range<usize>, String)>,
|
||||
}
|
||||
|
||||
/// A block of a markdown document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
Paragraph(Inline),
|
||||
Heading(u8, Inline),
|
||||
Quote(Vec<Node>),
|
||||
Code {
|
||||
language: Option<String>,
|
||||
text: String,
|
||||
},
|
||||
/// Items with their blocks, numbered from `start` when ordered, each with its checkbox when a task.
|
||||
List {
|
||||
start: Option<u64>,
|
||||
items: Vec<(Option<bool>, Vec<Node>)>,
|
||||
},
|
||||
Table {
|
||||
aligns: Vec<Alignment>,
|
||||
head: Vec<Inline>,
|
||||
rows: Vec<Vec<Inline>>,
|
||||
},
|
||||
Rule,
|
||||
Math(String),
|
||||
Image {
|
||||
alt: String,
|
||||
url: String,
|
||||
},
|
||||
Footnote {
|
||||
label: String,
|
||||
body: Vec<Node>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parses into nodes as it goes: a stack of open containers and the inline text being built.
|
||||
#[derive(Default)]
|
||||
struct Builder {
|
||||
stack: Vec<(Frame, Vec<Node>)>,
|
||||
inline: Inline,
|
||||
open: Vec<(usize, Style)>,
|
||||
link: Vec<(usize, String)>,
|
||||
image: Option<(String, String)>,
|
||||
code: Option<(Option<String>, String)>,
|
||||
head: Vec<Inline>,
|
||||
row: Vec<Inline>,
|
||||
rows: Vec<Vec<Inline>>,
|
||||
}
|
||||
|
||||
enum Frame {
|
||||
Quote,
|
||||
List(Option<u64>, Vec<(Option<bool>, Vec<Node>)>),
|
||||
Item(Option<bool>),
|
||||
Table(Vec<Alignment>),
|
||||
Footnote(String),
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn take_inline(&mut self) -> Inline {
|
||||
std::mem::take(&mut self.inline)
|
||||
}
|
||||
}
|
||||
|
||||
/// `markdown` as blocks.
|
||||
pub fn parse(markdown: &str) -> Vec<Node> {
|
||||
let mut built = Builder::default();
|
||||
let mut out: Vec<Node> = Vec::new();
|
||||
for event in Parser::new_ext(markdown, options()) {
|
||||
match event {
|
||||
Event::Start(Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell) => {}
|
||||
Event::End(TagEnd::Paragraph) => {
|
||||
let text = built.take_inline();
|
||||
if !text.text.is_empty() {
|
||||
push(&mut built, &mut out, Node::Paragraph(text));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Heading(level)) => {
|
||||
let text = built.take_inline();
|
||||
push(&mut built, &mut out, Node::Heading(depth(level), text));
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => built.stack.push((Frame::Quote, Vec::new())),
|
||||
Event::Start(Tag::List(start)) => {
|
||||
flush(&mut built, &mut out);
|
||||
built
|
||||
.stack
|
||||
.push((Frame::List(start, Vec::new()), Vec::new()));
|
||||
}
|
||||
Event::Start(Tag::Item) => built.stack.push((Frame::Item(None), Vec::new())),
|
||||
Event::TaskListMarker(done) => {
|
||||
if let Some((Frame::Item(task), _)) = built.stack.last_mut() {
|
||||
*task = Some(done);
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
flush(&mut built, &mut out);
|
||||
let (frame, nodes) = built.stack.pop().expect("an item was open");
|
||||
let Frame::Item(task) = frame else {
|
||||
unreachable!("an item closes an item")
|
||||
};
|
||||
if let Some((Frame::List(_, items), _)) = built.stack.last_mut() {
|
||||
items.push((task, nodes));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
let (frame, _) = built.stack.pop().expect("a list was open");
|
||||
let Frame::List(start, items) = frame else {
|
||||
unreachable!("a list closes a list")
|
||||
};
|
||||
push(&mut built, &mut out, Node::List { start, items });
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(_)) => {
|
||||
let (_, nodes) = built.stack.pop().expect("a quote was open");
|
||||
push(&mut built, &mut out, Node::Quote(nodes));
|
||||
}
|
||||
Event::Start(Tag::FootnoteDefinition(label)) => built
|
||||
.stack
|
||||
.push((Frame::Footnote(label.to_string()), Vec::new())),
|
||||
Event::End(TagEnd::FootnoteDefinition) => {
|
||||
let (frame, body) = built.stack.pop().expect("a footnote was open");
|
||||
let Frame::Footnote(label) = frame else {
|
||||
unreachable!("a footnote closes a footnote")
|
||||
};
|
||||
push(&mut built, &mut out, Node::Footnote { label, body });
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
let language = match kind {
|
||||
CodeBlockKind::Fenced(name) if !name.is_empty() => Some(name.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
built.code = Some((language, String::new()));
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
let (language, text) = built.code.take().expect("a code block was open");
|
||||
push(
|
||||
&mut built,
|
||||
&mut out,
|
||||
Node::Code {
|
||||
language,
|
||||
text: text.trim_end_matches('\n').to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Event::Start(Tag::Table(aligns)) => {
|
||||
built.stack.push((Frame::Table(aligns), Vec::new()))
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
let cell = built.take_inline();
|
||||
built.row.push(cell);
|
||||
}
|
||||
Event::End(TagEnd::TableHead) => built.head = std::mem::take(&mut built.row),
|
||||
Event::End(TagEnd::TableRow) => {
|
||||
let row = std::mem::take(&mut built.row);
|
||||
built.rows.push(row);
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
let (frame, _) = built.stack.pop().expect("a table was open");
|
||||
let Frame::Table(aligns) = frame else {
|
||||
unreachable!("a table closes a table")
|
||||
};
|
||||
let (head, rows) = (
|
||||
std::mem::take(&mut built.head),
|
||||
std::mem::take(&mut built.rows),
|
||||
);
|
||||
push(&mut built, &mut out, Node::Table { aligns, head, rows });
|
||||
}
|
||||
Event::Rule => push(&mut built, &mut out, Node::Rule),
|
||||
Event::DisplayMath(math) => push(&mut built, &mut out, Node::Math(math.to_string())),
|
||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||
built.image = Some((String::new(), dest_url.to_string()))
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
let (alt, url) = built.image.take().expect("an image was open");
|
||||
push(&mut built, &mut out, Node::Image { alt, url });
|
||||
}
|
||||
Event::Start(Tag::Strong) => built.open.push((built.inline.text.len(), Style::Strong)),
|
||||
Event::Start(Tag::Emphasis) => {
|
||||
built.open.push((built.inline.text.len(), Style::Emphasis))
|
||||
}
|
||||
Event::Start(Tag::Strikethrough) => {
|
||||
built.open.push((built.inline.text.len(), Style::Strike))
|
||||
}
|
||||
Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) => {
|
||||
let (start, style) = built.open.pop().expect("a style closes after it opens");
|
||||
let end = built.inline.text.len();
|
||||
built.inline.styles.push((start..end, style));
|
||||
}
|
||||
Event::Start(Tag::Link { dest_url, .. }) => built
|
||||
.link
|
||||
.push((built.inline.text.len(), dest_url.to_string())),
|
||||
Event::End(TagEnd::Link) => {
|
||||
let (start, url) = built.link.pop().expect("a link closes after it opens");
|
||||
let range = start..built.inline.text.len();
|
||||
built.inline.styles.push((range.clone(), Style::Link));
|
||||
built.inline.links.push((range, url));
|
||||
}
|
||||
Event::Text(text) => match (&mut built.code, &mut built.image) {
|
||||
(Some((_, code)), _) => code.push_str(&text),
|
||||
(None, Some((alt, _))) => alt.push_str(&text),
|
||||
(None, None) => built.inline.text.push_str(&text),
|
||||
},
|
||||
Event::Code(code) => styled(&mut built.inline, &code, Style::Code),
|
||||
Event::InlineMath(math) => styled(&mut built.inline, &math, Style::Math),
|
||||
Event::FootnoteReference(label) => {
|
||||
styled(&mut built.inline, &format!("[{label}]"), Style::Footnote)
|
||||
}
|
||||
Event::SoftBreak => built.inline.text.push(' '),
|
||||
Event::HardBreak => built.inline.text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut built, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn styled(inline: &mut Inline, text: &str, style: Style) {
|
||||
let start = inline.text.len();
|
||||
inline.text.push_str(text);
|
||||
inline.styles.push((start..inline.text.len(), style));
|
||||
}
|
||||
|
||||
fn depth(level: HeadingLevel) -> u8 {
|
||||
level as u8
|
||||
}
|
||||
|
||||
/// A finished node goes into the open container, or the document.
|
||||
fn push(built: &mut Builder, out: &mut Vec<Node>, node: Node) {
|
||||
match built.stack.last_mut() {
|
||||
Some((_, nodes)) => nodes.push(node),
|
||||
None => out.push(node),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text left from a tight list item becomes its paragraph.
|
||||
fn flush(built: &mut Builder, out: &mut Vec<Node>) {
|
||||
if !built.inline.text.is_empty() {
|
||||
let text = built.take_inline();
|
||||
push(built, out, Node::Paragraph(text));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_nest_as_written() {
|
||||
let nodes = parse(
|
||||
"# Lift\n\nBlend **toward** [white](https://ely.dev).\n\n- [x] clamp\n- [ ] gamma\n\n> quoted\n\n```rust\nfn main() {}\n```\n\n---\n",
|
||||
);
|
||||
assert!(matches!(&nodes[0], Node::Heading(1, text) if text.text == "Lift"));
|
||||
let Node::Paragraph(line) = &nodes[1] else {
|
||||
panic!("a paragraph, not {:?}", nodes[1])
|
||||
};
|
||||
assert_eq!(line.text, "Blend toward white.");
|
||||
assert_eq!(line.styles, [(6..12, Style::Strong), (13..18, Style::Link)]);
|
||||
assert_eq!(line.links, [(13..18, "https://ely.dev".to_string())]);
|
||||
let Node::List { start: None, items } = &nodes[2] else {
|
||||
panic!("a list, not {:?}", nodes[2])
|
||||
};
|
||||
assert_eq!(
|
||||
items.iter().map(|(task, _)| *task).collect::<Vec<_>>(),
|
||||
[Some(true), Some(false)]
|
||||
);
|
||||
assert!(matches!(&items[0].1[0], Node::Paragraph(text) if text.text == "clamp"));
|
||||
assert!(matches!(&nodes[3], Node::Quote(inner) if inner.len() == 1));
|
||||
assert_eq!(
|
||||
nodes[4],
|
||||
Node::Code {
|
||||
language: Some("rust".into()),
|
||||
text: "fn main() {}".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes[5], Node::Rule);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_math_stands_alone() {
|
||||
assert_eq!(parse("$$c = 1$$"), [Node::Math("c = 1".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_keep_their_cells_and_alignment() {
|
||||
let nodes = parse("| Name | Lift |\n|:--|--:|\n| accent | 0.5 |\n");
|
||||
let Node::Table { aligns, head, rows } = &nodes[0] else {
|
||||
panic!("a table, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(aligns, &[Alignment::Left, Alignment::Right]);
|
||||
assert_eq!(
|
||||
head.iter()
|
||||
.map(|cell| cell.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Name", "Lift"]
|
||||
);
|
||||
assert_eq!(rows[0][1].text, "0.5");
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -83,13 +83,7 @@ pub(crate) fn lex(source: &str) -> Vec<(Range<usize>, Kind)> {
|
||||
|
||||
fn painted(spans: impl Iterator<Item = (Range<usize>, Hsla)>) -> Vec<(Range<usize>, Highlight)> {
|
||||
spans
|
||||
.map(|(range, color)| {
|
||||
let highlight = Highlight {
|
||||
color,
|
||||
background: None,
|
||||
};
|
||||
(range, highlight)
|
||||
})
|
||||
.map(|(range, color)| (range, Highlight::new(color)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -237,13 +237,7 @@ pub fn expression_highlights(source: &str, cx: &App) -> Vec<(Range<usize>, Highl
|
||||
Token::Op(_) => syntax.operator,
|
||||
Token::Open | Token::Close | Token::Comma => syntax.punctuation,
|
||||
};
|
||||
(
|
||||
range.clone(),
|
||||
Highlight {
|
||||
color,
|
||||
background: None,
|
||||
},
|
||||
)
|
||||
(range.clone(), Highlight::new(color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ impl RenderOnce for IconPicker {
|
||||
}
|
||||
|
||||
/// Emoji whose name or shortcode holds `query`, a lowercase string; all for an empty one.
|
||||
fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> {
|
||||
pub(crate) fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> {
|
||||
emojis::iter()
|
||||
.filter(|emoji| {
|
||||
query.is_empty()
|
||||
|
||||
+114
-90
@@ -1,13 +1,13 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, prelude::*,
|
||||
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Stateful, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
Highlight, Input, TextInput,
|
||||
options::{Choice, Popup},
|
||||
options::{Choice, Pick, Popup},
|
||||
text::{Down, Enter, Up},
|
||||
};
|
||||
use crate::theme::ActiveTheme;
|
||||
@@ -72,8 +72,8 @@ pub fn mention_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)
|
||||
(
|
||||
range,
|
||||
Highlight {
|
||||
color: link,
|
||||
background: Some(link.opacity(0.08)),
|
||||
..Highlight::new(link)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -86,6 +86,94 @@ struct Picking {
|
||||
dismissed: Option<usize>,
|
||||
}
|
||||
|
||||
/// Rows offered at a trigger in a field's text, and what a pick does: arrows choose, Enter or a press picks, Escape dismisses until the trigger ends.
|
||||
pub(crate) struct Suggestions {
|
||||
pub id: ElementId,
|
||||
pub state: Entity<TextInput>,
|
||||
/// Where the trigger being typed starts.
|
||||
pub trigger: Option<usize>,
|
||||
pub rows: Vec<Choice>,
|
||||
pub pick: Pick,
|
||||
}
|
||||
|
||||
impl Suggestions {
|
||||
/// Wraps the element that shows the field, with the rows under the trigger while it is typed.
|
||||
pub fn wrap(self, field: impl IntoElement, window: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default());
|
||||
let input = self.state.read(cx);
|
||||
let focused = input.focus().is_focused(window);
|
||||
let anchor = self.trigger.and_then(|ix| input.bounds_for(ix));
|
||||
let dismissed = dismissal(picking.read(cx).dismissed, self.trigger);
|
||||
if dismissed != picking.read(cx).dismissed {
|
||||
picking.update(cx, |picking, _| picking.dismissed = dismissed);
|
||||
}
|
||||
let open = focused
|
||||
&& !self.rows.is_empty()
|
||||
&& self.trigger.is_some_and(|ix| dismissed != Some(ix));
|
||||
let count = self.rows.len();
|
||||
let highlighted = picking.read(cx).highlighted.min(count.saturating_sub(1));
|
||||
log::debug!(
|
||||
"suggestions {:?}: open {open}, {count} rows, anchor {anchor:?}",
|
||||
self.id
|
||||
);
|
||||
let (up, down, escape) = (picking.clone(), picking.clone(), picking);
|
||||
let (enter, trigger) = (self.pick.clone(), self.trigger);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.w_full()
|
||||
.capture_action(move |_: &Up, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
up.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + count - 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
down.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
enter(highlighted, window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if open && event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape.update(cx, |picking, cx| {
|
||||
picking.dismissed = trigger;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(field)
|
||||
.when_some(anchor.filter(|_| open), |field, anchor| {
|
||||
field.child(
|
||||
Popup {
|
||||
id: "suggestions".into(),
|
||||
anchor,
|
||||
rows: &self.rows,
|
||||
highlighted: Some(highlighted),
|
||||
checked: None,
|
||||
pick: self.pick,
|
||||
dismiss: None,
|
||||
scroll: None,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A field where `@` or `#` opens suggestions at the caret. Arrows choose; Enter picks.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MentionInput {
|
||||
@@ -122,46 +210,34 @@ impl MentionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The handles after `trigger` that hold the typed query, a few at most.
|
||||
pub(crate) fn handles_matching(handles: &[SharedString], query: &str) -> Vec<SharedString> {
|
||||
let query = query.to_lowercase();
|
||||
handles
|
||||
.iter()
|
||||
.filter(|name| name.to_lowercase().contains(&query))
|
||||
.take(SHOWN)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl RenderOnce for MentionInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default());
|
||||
let input = self.state.read(cx);
|
||||
let (text, caret) = (input.text().to_string(), input.cursor());
|
||||
let focused = input.focus().is_focused(window);
|
||||
let marks: Vec<char> = self.triggers.iter().map(|(trigger, _)| *trigger).collect();
|
||||
let active = active_trigger(&text, caret, &marks);
|
||||
let anchor = active.and_then(|(ix, _)| input.bounds_for(ix));
|
||||
let matches: Vec<SharedString> = active
|
||||
.map(|(ix, trigger)| {
|
||||
let query = text[ix + trigger.len_utf8()..caret].to_lowercase();
|
||||
let names = &self
|
||||
.triggers
|
||||
.iter()
|
||||
.find(|(mark, _)| *mark == trigger)
|
||||
.expect("from triggers")
|
||||
.1;
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| name.to_lowercase().contains(&query))
|
||||
.take(SHOWN)
|
||||
.cloned()
|
||||
.collect()
|
||||
handles_matching(names, &text[ix + trigger.len_utf8()..caret])
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let dismissed = dismissal(picking.read(cx).dismissed, active.map(|(ix, _)| ix));
|
||||
if dismissed != picking.read(cx).dismissed {
|
||||
picking.update(cx, |picking, _| picking.dismissed = dismissed);
|
||||
}
|
||||
let open =
|
||||
focused && !matches.is_empty() && active.is_some_and(|(ix, _)| dismissed != Some(ix));
|
||||
let highlighted = picking
|
||||
.read(cx)
|
||||
.highlighted
|
||||
.min(matches.len().saturating_sub(1));
|
||||
log::debug!(
|
||||
"mention input: open {open}, {} matches, caret {anchor:?}",
|
||||
matches.len()
|
||||
);
|
||||
let rows: Vec<Choice> = matches
|
||||
.iter()
|
||||
.map(|name| {
|
||||
@@ -171,8 +247,8 @@ impl RenderOnce for MentionInput {
|
||||
Choice::new(name.clone(), label)
|
||||
})
|
||||
.collect();
|
||||
let insert = {
|
||||
let (state, matches) = (self.state.clone(), matches.clone());
|
||||
let insert: Pick = {
|
||||
let state = self.state.clone();
|
||||
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
|
||||
let Some((ix, trigger)) = active else {
|
||||
return;
|
||||
@@ -185,66 +261,14 @@ impl RenderOnce for MentionInput {
|
||||
});
|
||||
})
|
||||
};
|
||||
let count = matches.len();
|
||||
let (up, down, enter, escape) = (
|
||||
picking.clone(),
|
||||
picking.clone(),
|
||||
insert.clone(),
|
||||
picking.clone(),
|
||||
);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.w_full()
|
||||
.capture_action(move |_: &Up, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
up.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + count - 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Down, _, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
down.update(cx, |picking, cx| {
|
||||
picking.highlighted = (picking.highlighted + 1) % count;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
if open {
|
||||
cx.stop_propagation();
|
||||
enter(highlighted, window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if open && event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape.update(cx, |picking, cx| {
|
||||
picking.dismissed = active.map(|(ix, _)| ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(Input::new(&self.state))
|
||||
.when_some(anchor.filter(|_| open), |field, anchor| {
|
||||
field.child(
|
||||
Popup {
|
||||
id: "mentions".into(),
|
||||
anchor,
|
||||
rows: &rows,
|
||||
highlighted: Some(highlighted),
|
||||
checked: None,
|
||||
pick: insert,
|
||||
dismiss: None,
|
||||
scroll: None,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
})
|
||||
Suggestions {
|
||||
id: self.id,
|
||||
state: self.state.clone(),
|
||||
trigger: active.map(|(ix, _)| ix),
|
||||
rows,
|
||||
pick: insert,
|
||||
}
|
||||
.wrap(Input::new(&self.state), window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -64,6 +64,7 @@ pub use date::{
|
||||
};
|
||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||
pub use files::{DropZone, FileInput};
|
||||
pub(crate) use glyphs::emoji_found;
|
||||
pub use glyphs::{EmojiPicker, IconPicker};
|
||||
pub use group::{InputAddon, InputGroup};
|
||||
pub use hotkey::HotkeyInput;
|
||||
@@ -74,6 +75,7 @@ pub use knob::Knob;
|
||||
pub use listbox::ListBox;
|
||||
pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
|
||||
pub use mention::{MentionInput, mention_highlights};
|
||||
pub(crate) use mention::{Suggestions, active_trigger, handles_matching};
|
||||
pub use multi::MultiSelect;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
@@ -95,7 +97,9 @@ pub use structure::{
|
||||
};
|
||||
pub use switch::Switch;
|
||||
pub use tags::TagInput;
|
||||
pub(crate) use text::{Down, Enter, History, Submit, Up, bind_keys, from_utf16, to_utf16};
|
||||
pub(crate) use text::{
|
||||
Backspace, Down, Enter, History, Redo, Submit, Undo, Up, bind_keys, from_utf16, to_utf16,
|
||||
};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
|
||||
@@ -90,13 +90,7 @@ pub fn regex_highlights(pattern: &str, cx: &App) -> Vec<(Range<usize>, Highlight
|
||||
Part::Anchor => syntax.constant,
|
||||
Part::Alternation => syntax.operator,
|
||||
};
|
||||
(
|
||||
range,
|
||||
Highlight {
|
||||
color,
|
||||
background: None,
|
||||
},
|
||||
)
|
||||
(range, Highlight::new(color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+105
-49
@@ -1,11 +1,11 @@
|
||||
use gpui::{
|
||||
App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId,
|
||||
ElementInputHandler, Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId,
|
||||
MouseMoveEvent, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window,
|
||||
WrappedLine, fill, point, relative, size,
|
||||
ElementInputHandler, Entity, FontStyle, GlobalElementId, Hsla, InspectorElementId, IntoElement,
|
||||
LayoutId, MouseMoveEvent, Pixels, Point, SharedString, StrikethroughStyle, Style, TextAlign,
|
||||
TextRun, UnderlineStyle, Window, WrappedLine, fill, point, relative, size,
|
||||
};
|
||||
|
||||
use super::{Layout, TextInput};
|
||||
use super::{Highlight, Layout, TextInput};
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
pub(crate) struct TextElement {
|
||||
@@ -54,11 +54,11 @@ fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun {
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `len` bytes into runs: highlights and the marked range over a base color.
|
||||
/// Splits `len` bytes into runs: highlights, and the underlined marked range, over a base run.
|
||||
fn runs(
|
||||
len: usize,
|
||||
base: TextRun,
|
||||
spans: Vec<(std::ops::Range<usize>, Hsla, Option<Hsla>, bool)>,
|
||||
spans: Vec<(std::ops::Range<usize>, Highlight, bool)>,
|
||||
thickness: Pixels,
|
||||
) -> Vec<TextRun> {
|
||||
let mut cuts: Vec<usize> = vec![0, len];
|
||||
@@ -74,15 +74,27 @@ fn runs(
|
||||
len: end - start,
|
||||
..base.clone()
|
||||
};
|
||||
for (range, color, background, underline) in &spans {
|
||||
for (range, highlight, underline) in &spans {
|
||||
if range.start <= start && end <= range.end {
|
||||
run.color = *color;
|
||||
if background.is_some() {
|
||||
run.background_color = *background;
|
||||
run.color = highlight.color;
|
||||
if highlight.background.is_some() {
|
||||
run.background_color = highlight.background;
|
||||
}
|
||||
if let Some(weight) = highlight.weight {
|
||||
run.font.weight = weight;
|
||||
}
|
||||
if highlight.italic {
|
||||
run.font.style = FontStyle::Italic;
|
||||
}
|
||||
if highlight.strike {
|
||||
run.strikethrough = Some(StrikethroughStyle {
|
||||
thickness,
|
||||
color: Some(highlight.color),
|
||||
});
|
||||
}
|
||||
if *underline {
|
||||
run.underline = Some(UnderlineStyle {
|
||||
color: Some(*color),
|
||||
color: Some(highlight.color),
|
||||
thickness,
|
||||
wavy: false,
|
||||
});
|
||||
@@ -95,6 +107,34 @@ fn runs(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What to show, whether it is the placeholder, and its runs, the same for measure and paint.
|
||||
fn shown_runs(input: &TextInput, window: &Window, cx: &App) -> (String, bool, Vec<TextRun>) {
|
||||
let theme = cx.theme();
|
||||
let (text, placeholder) = shown(input);
|
||||
let color = window.text_style().color;
|
||||
let base = plain_run(
|
||||
text.len(),
|
||||
window,
|
||||
if placeholder {
|
||||
theme.colors.fg_subtle
|
||||
} else {
|
||||
color
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
if !placeholder {
|
||||
for (range, highlight) in input.highlights(cx) {
|
||||
spans.push((range, highlight, false));
|
||||
}
|
||||
if let Some(marked) = input.marked() {
|
||||
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
|
||||
spans.push((marked, Highlight::new(color), true));
|
||||
}
|
||||
}
|
||||
let runs = runs(text.len(), base, spans, theme.underline_thickness());
|
||||
(text, placeholder, runs)
|
||||
}
|
||||
|
||||
/// Starts of the hard lines in `text`.
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
@@ -129,9 +169,8 @@ impl Element for TextElement {
|
||||
style.size.height = line_height.into();
|
||||
return (window.request_layout(style, [], cx), ());
|
||||
};
|
||||
let (text, _) = shown(input);
|
||||
let (text, _, runs) = shown_runs(input, window, cx);
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let run = plain_run(text.len(), window, Hsla::default());
|
||||
let layout = window.request_measured_layout(style, move |known, available, window, _| {
|
||||
let width = known.width.or(match available.width {
|
||||
AvailableSpace::Definite(width) => Some(width),
|
||||
@@ -142,7 +181,7 @@ impl Element for TextElement {
|
||||
.shape_text(
|
||||
SharedString::from(text.clone()),
|
||||
font_size,
|
||||
std::slice::from_ref(&run),
|
||||
&runs,
|
||||
width,
|
||||
None,
|
||||
)
|
||||
@@ -169,47 +208,16 @@ impl Element for TextElement {
|
||||
cx: &mut App,
|
||||
) -> Prepaint {
|
||||
let theme = cx.theme();
|
||||
let (selection_color, caret_color, subtle) = (
|
||||
theme.colors.selection,
|
||||
theme.colors.focus,
|
||||
theme.colors.fg_subtle,
|
||||
);
|
||||
let (selection_color, caret_color) = (theme.colors.selection, theme.colors.focus);
|
||||
let caret_width = theme.caret_width().to_pixels(window.rem_size());
|
||||
let thickness = theme.underline_thickness();
|
||||
let input = self.input.read(cx);
|
||||
let (text, placeholder) = shown(input);
|
||||
let base = plain_run(
|
||||
text.len(),
|
||||
window,
|
||||
if placeholder {
|
||||
subtle
|
||||
} else {
|
||||
window.text_style().color
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
if !placeholder {
|
||||
for (range, highlight) in input.highlights(cx) {
|
||||
spans.push((range, highlight.color, highlight.background, false));
|
||||
}
|
||||
if let Some(marked) = input.marked() {
|
||||
let color = window.text_style().color;
|
||||
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
|
||||
spans.push((marked, color, None, true));
|
||||
}
|
||||
}
|
||||
let (text, placeholder, runs) = shown_runs(input, window, cx);
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let line_height = window.line_height();
|
||||
let wrap = input.rows().map(|_| bounds.size.width);
|
||||
let shaped = window
|
||||
.text_system()
|
||||
.shape_text(
|
||||
text.clone().into(),
|
||||
font_size,
|
||||
&runs(text.len(), base, spans, thickness),
|
||||
wrap,
|
||||
None,
|
||||
)
|
||||
.shape_text(text.clone().into(), font_size, &runs, wrap, None)
|
||||
.expect("text input shaping failed");
|
||||
let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect();
|
||||
let focused = input.focus().is_focused(window);
|
||||
@@ -400,7 +408,55 @@ fn selection_rects(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::line_starts;
|
||||
use gpui::{FontWeight, font, hsla, px};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_highlight_sets_weight_slant_and_strike_on_its_runs() {
|
||||
let base = TextRun {
|
||||
len: 3,
|
||||
font: font("Inter"),
|
||||
color: hsla(0., 0., 0., 1.),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let bold = Highlight {
|
||||
weight: Some(FontWeight::SEMIBOLD),
|
||||
..Highlight::new(hsla(0., 0., 0.5, 1.))
|
||||
};
|
||||
let slanted = Highlight {
|
||||
italic: true,
|
||||
strike: true,
|
||||
..Highlight::new(hsla(0., 0., 0.2, 1.))
|
||||
};
|
||||
let split = runs(
|
||||
3,
|
||||
base,
|
||||
vec![(0..2, bold, false), (1..3, slanted, false)],
|
||||
px(1.),
|
||||
);
|
||||
let styles: Vec<_> = split
|
||||
.iter()
|
||||
.map(|run| {
|
||||
(
|
||||
run.len,
|
||||
run.font.weight,
|
||||
run.font.style,
|
||||
run.strikethrough.is_some(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
styles,
|
||||
[
|
||||
(1, FontWeight::SEMIBOLD, FontStyle::Normal, false),
|
||||
(1, FontWeight::SEMIBOLD, FontStyle::Italic, true),
|
||||
(1, FontWeight::NORMAL, FontStyle::Italic, true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_starts_follow_newlines() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use gpui::{FontWeight, Hsla};
|
||||
|
||||
/// Styles a span of the text: its color, an optional wash behind it, and its weight, slant and strike.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Highlight {
|
||||
pub color: Hsla,
|
||||
pub background: Option<Hsla>,
|
||||
pub weight: Option<FontWeight>,
|
||||
pub italic: bool,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
/// A span in `color`, its font as the field's.
|
||||
pub fn new(color: Hsla) -> Self {
|
||||
Self {
|
||||
color,
|
||||
background: None,
|
||||
weight: None,
|
||||
italic: false,
|
||||
strike: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
mod actions;
|
||||
mod edit;
|
||||
mod element;
|
||||
mod highlight;
|
||||
mod ime;
|
||||
|
||||
use std::{ops::Range, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, Pixels, Point, SharedString,
|
||||
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString,
|
||||
Subscription, Task, Window, WrappedLine,
|
||||
};
|
||||
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys};
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys};
|
||||
use edit::Snapshot;
|
||||
pub(crate) use edit::{History, from_utf16, to_utf16};
|
||||
pub use highlight::Highlight;
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
@@ -26,13 +28,6 @@ pub enum InputEvent {
|
||||
Blur,
|
||||
}
|
||||
|
||||
/// Colors a span of the text: foreground, and an optional wash behind it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Highlight {
|
||||
pub color: Hsla,
|
||||
pub background: Option<Hsla>,
|
||||
}
|
||||
|
||||
type Highlighter = Rc<dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>>;
|
||||
type Filter = Rc<dyn Fn(char) -> bool>;
|
||||
pub(crate) type Fit = Rc<dyn Fn(&str, Range<usize>, &str) -> (String, usize)>;
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod buttons;
|
||||
pub mod charts;
|
||||
pub mod data_display;
|
||||
pub mod debug;
|
||||
pub mod documents;
|
||||
pub mod editor;
|
||||
pub mod feedback;
|
||||
pub mod finance;
|
||||
@@ -44,4 +45,5 @@ pub fn init(cx: &mut App) {
|
||||
forms::bind_keys(cx);
|
||||
editor::bind_keys(cx);
|
||||
terminal::bind_keys(cx);
|
||||
documents::bind_keys(cx);
|
||||
}
|
||||
@@ -146,6 +146,9 @@ icons! {
|
||||
HardDrive => "hard-drive",
|
||||
Hash => "hash",
|
||||
Heading => "heading",
|
||||
Heading1 => "heading-1",
|
||||
Heading2 => "heading-2",
|
||||
Heading3 => "heading-3",
|
||||
Heart => "heart",
|
||||
History => "history",
|
||||
Hourglass => "hourglass",
|
||||
@@ -180,6 +183,7 @@ icons! {
|
||||
Maximize2 => "maximize-2",
|
||||
MemoryStick => "memory-stick",
|
||||
Menu => "menu",
|
||||
Merge => "merge",
|
||||
MessageSquare => "message-square",
|
||||
MicOff => "mic-off",
|
||||
Mic => "mic",
|
||||
@@ -272,6 +276,7 @@ icons! {
|
||||
WholeWord => "whole-word",
|
||||
WifiOff => "wifi-off",
|
||||
Wifi => "wifi",
|
||||
Workflow => "workflow",
|
||||
Wrench => "wrench",
|
||||
X => "x",
|
||||
Zap => "zap",
|
||||
|
||||
+20
-20
@@ -2,26 +2,26 @@
|
||||
|
||||
## T21a Documents · Editing — `src/documents`
|
||||
|
||||
- [ ] RichTextEditor
|
||||
- [ ] MarkdownEditor — WYSIWYG, source, split
|
||||
- [ ] BlockEditor
|
||||
- [ ] Block types — paragraph, heading, list, todo, quote, code, divider, table, image, video, embed, math, callout, toggle, columns, synced
|
||||
- [ ] BlockHandle
|
||||
- [ ] SlashMenu
|
||||
- [ ] FloatingFormatToolbar → `overlays::FloatingToolbar`
|
||||
- [ ] FixedFormatToolbar
|
||||
- [ ] LinkEditor
|
||||
- [ ] MentionMenu
|
||||
- [ ] EmojiAutocomplete
|
||||
- [ ] TableEditor
|
||||
- [ ] ImageEditorBlock
|
||||
- [ ] MathEditor / LatexBlock
|
||||
- [ ] MermaidBlock / DiagramBlock — host renders the SVG
|
||||
- [ ] CodeBlockEditor
|
||||
- [ ] TodoItem / Checklist
|
||||
- [ ] PlaceholderText
|
||||
- [ ] WordCount / CharacterCount
|
||||
- [ ] ReadingTime
|
||||
- [x] RichTextEditor
|
||||
- [x] MarkdownEditor — visual, source, split
|
||||
- [x] BlockEditor
|
||||
- [x] Block types — paragraph, heading, list, todo, quote, code, divider, table, image, video, embed, math, callout, toggle, columns, synced; video and embed open as cards the app plays or shows
|
||||
- [x] BlockHandle → the block editor's handle, a `menus::OverflowMenu` that drags its block
|
||||
- [x] SlashMenu → `/` in `BlockEditor`
|
||||
- [x] FloatingFormatToolbar → `overlays::FloatingToolbar`
|
||||
- [x] FixedFormatToolbar
|
||||
- [x] LinkEditor
|
||||
- [x] MentionMenu → `@` in `RichTextEditor` and `BlockEditor`; `forms::MentionInput` in a field
|
||||
- [x] EmojiAutocomplete → a colon and two letters in `RichTextEditor` and `BlockEditor`
|
||||
- [x] TableEditor → the block editor's table: rows and columns added and removed, cells merged across
|
||||
- [x] ImageEditorBlock → the block editor's picture: resized by its edges, aligned, captioned
|
||||
- [x] MathEditor / LatexBlock → the block editor's math block over `typography::Latex`
|
||||
- [x] MermaidBlock / DiagramBlock — host renders the SVG → `BlockEditor::diagrams`
|
||||
- [x] CodeBlockEditor → the block editor's code block
|
||||
- [x] TodoItem / Checklist
|
||||
- [x] PlaceholderText → each kind's hint in `BlockEditor`
|
||||
- [x] WordCount / CharacterCount → `WordCount`, with `limit` for characters
|
||||
- [x] ReadingTime
|
||||
|
||||
## T21b Documents · Reading
|
||||
|
||||
|
||||
Reference in new issue
Block a user