Compare commits
130
Commits
2a578d3238
..
main
No files matched your search
@@ -22,7 +22,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, ubuntu-24.04-arm, windows-latest, windows-11-arm, macos-15-intel]
|
||||
# One leg per runner registered with git.zacharyzhang.com; macOS arm64 is the crate job above.
|
||||
os: [ubuntu-24.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -30,7 +31,15 @@ jobs:
|
||||
with:
|
||||
toolchain: "1.95"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --lib --features test-support --quiet
|
||||
- if: runner.os != 'Linux'
|
||||
run: cargo test --lib --features test-support --quiet
|
||||
# The Linux job container runs as root, which ignores the file permissions the tests check.
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
useradd --create-home ci
|
||||
chmod o+x "$HOME"
|
||||
chown -R ci "$GITHUB_WORKSPACE" "$HOME/.cargo" "$HOME/.rustup"
|
||||
runuser -u ci -- env HOME=/home/ci CARGO_HOME="$HOME/.cargo" RUSTUP_HOME="$HOME/.rustup" PATH="$PATH" cargo test --lib --features test-support --quiet
|
||||
- run: cargo build --example gallery
|
||||
|
||||
site:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Showcase
|
||||
|
||||
# Moves the site's showcase pin to the catalog's main once its checks pass, then deploys.
|
||||
# Moves the site's showcase pin to the Gitea catalog's main once its checks pass, then deploys.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
@@ -32,28 +32,26 @@ jobs:
|
||||
persist-credentials: false
|
||||
- id: head
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ env.CATALOG }}
|
||||
FORCE: ${{ inputs.deploy }}
|
||||
run: |
|
||||
head=$(gh api "repos/$CATALOG/commits/main" --jq .sha)
|
||||
head=$(curl -fsS "$API/branches/main" | jq -r .commit.id)
|
||||
pinned=$(tr -d '[:space:]' < frontend/showcase.lock)
|
||||
if [ "$head" = "$pinned" ]; then
|
||||
echo "showcase: pinned at ${head:0:7}, the catalog's main"
|
||||
[ "$FORCE" = "true" ] && echo "commit=$head" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
runs=$(gh api "repos/$CATALOG/commits/$head/check-runs" --jq '.check_runs')
|
||||
total=$(echo "$runs" | jq length)
|
||||
waiting=$(echo "$runs" | jq '[.[] | select(.status != "completed")] | length')
|
||||
failed=$(echo "$runs" | jq '[.[] | select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not))] | length')
|
||||
if [ "$total" -eq 0 ] || [ "$waiting" -gt 0 ]; then
|
||||
status=$(curl -fsS "$API/commits/$head/status")
|
||||
total=$(echo "$status" | jq .total_count)
|
||||
state=$(echo "$status" | jq -r .state)
|
||||
if [ "$total" -eq 0 ] || [ "$state" = "pending" ]; then
|
||||
echo "showcase: ${head:0:7}'s checks have not finished; next run looks again"
|
||||
[ "$FORCE" = "true" ] && echo "commit=$pinned" >> "$GITHUB_OUTPUT"
|
||||
[ "$FORCE" = "true" ] && echo "commit=$pinned" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
echo "::error::showcase: ${head:0:7} failed its catalog checks; the pin stays at ${pinned:0:7}"
|
||||
if [ "$state" != "success" ]; then
|
||||
echo "::error::showcase: ${head:0:7} failed its catalog checks ($state); the pin stays at ${pinned:0:7}"
|
||||
exit 1
|
||||
fi
|
||||
echo "showcase: ${pinned:0:7} -> ${head:0:7}"
|
||||
@@ -84,11 +82,8 @@ jobs:
|
||||
with:
|
||||
tool: ${{ env.WASM_BINDGEN }}
|
||||
- name: wasm-opt
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release download -R WebAssembly/binaryen -p 'binaryen-*-x86_64-linux.tar.gz' -D "$RUNNER_TEMP"
|
||||
tar -xzf "$RUNNER_TEMP"/binaryen-*-x86_64-linux.tar.gz -C "$RUNNER_TEMP"
|
||||
curl -fsSL https://github.com/WebAssembly/binaryen/releases/download/version_132/binaryen-version_132-x86_64-linux.tar.gz | tar -xz -C "$RUNNER_TEMP"
|
||||
echo "$(ls -d "$RUNNER_TEMP"/binaryen-*/)bin" >> "$GITHUB_PATH"
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -113,8 +108,8 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
- name: Commit the pin
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git config user.name 'gitea-actions'
|
||||
git config user.email 'actions@git.zacharyzhang.com'
|
||||
git add frontend/showcase.lock
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "Site · Showcase: pin Ely-GPUI-Showcases at ${COMMIT:0:7}"
|
||||
|
||||
@@ -13,11 +13,12 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
|
||||
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
|
||||
- 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; `BlockData::from_markdown` adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written.
|
||||
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, a trailing backslash doubled only when a pipe follows at once, an emptied edge cell of a row without that outer pipe written as one so its column stays, the caret read through the same escaping, a padding press on the open cell keeping its typing, and a table edit writing it back before it runs; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraLine truncated
|
||||
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
||||
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
|
||||
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
||||
- Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle.
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. The Showcase tab lists apps built with Ely from the public repo `ZacharyZhang-NY/Ely-GPUI-Showcases`: manifests, PNG previews and Markdown READMEs, checked by its `scripts/catalog.mjs` (`marked`, `pngjs`, `twitter-text`), its stars refreshed by its CI. The mark is the letter E in iso blocks, its middle arm in the accent: one geometry in `src/iso/mark.ts`, drawn by the engine the miniatures use, worn by the header in the page's iso colors and by the favicon. Below 768px the header shows the mark without its word, which stays the home link's name; below 414px it drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143).
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. The Showcase tab lists apps built with Ely from the public repo `ZacharyZhang-NY/Ely-GPUI-Showcases` on git.zacharyzhang.com (Gitea): manifests, PNG previews and Markdown READMEs, checked by its `scripts/catalog.mjs` (`marked`, `pngjs`, `twitter-text`), its stars refreshed by its CI. The mark is the letter E in iso blocks, its middle arm in the accent: one geometry in `src/iso/mark.ts`, drawn by the engine the miniatures use, worn by the header in the page's iso colors and by the favicon. Below 768px the header shows the mark without its word, which stays the home link's name; below 414px it drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' Gitea mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -46,7 +47,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
|
||||
- Colors, control and icon sizes, text sizes, radii, shadows come from `cx.theme()`. No raw `px()` literals inside components.
|
||||
- Gaps and padding use gpui's rem scale helpers (`gap_2`, `px_3`). That scale is the spacing system.
|
||||
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale.
|
||||
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale. gpui paints a border no wider than half its box, so a line as thin as its box is a fill, `w_px` or `h_px` with `bg`, as the resize handles draw theirs.
|
||||
- One component, one home. Duplicates point to it.
|
||||
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
||||
- A handler a component needs is an argument of `new`, so leaving it out fails to compile. A state live data or the outside world can reach, a key gone from a list that changed or a failed load, logs an error naming the owner and draws a visible state, nothing chosen or an error line, and the app runs on. An assert on a caller's argument or on a fact true by construction stays; one that runtime data or input can trip is fixed. Chapters move to this as `tasks/panics.md` ticks them.
|
||||
@@ -65,6 +66,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
|
||||
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
|
||||
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
||||
- A control inside a `drag_region` occludes it. On Windows gpui answers the system's hit test with the caption wherever a `Drag` hitbox lies under the pointer unoccluded, and Windows then keeps the press: no click reaches the control. `block_mouse_except_scroll` is not enough, since that answer reads every hitbox under the pointer. Window buttons occlude, which lets them answer as close, maximize and minimize, and so do a title bar's slots; a column header's item row occludes, staying the scroll container, and its title is a drag region of its own; the mini window drags by its title alone, so its buttons sit outside the region and its hover still shows the bar.
|
||||
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
||||
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
||||
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
||||
@@ -87,7 +89,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
|
||||
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
|
||||
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
|
||||
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
|
||||
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked. Panels occlude what lies under them. A submenu that does not fit on the right opens on the left of its row, sliding in from that side. A row's submenu opens once the pointer rests on it 150 ms; while a submenu is open, a row crossed inside the triangle from the last pointer place to the submenu's near corners waits 300 ms and switches only if the pointer still rests there (`menus::aim`); a row outside it marks at once. Keys and presses open at once.
|
||||
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
|
||||
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
|
||||
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
|
||||
@@ -219,13 +221,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
||||
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
||||
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
|
||||
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width yields before a header's own parts; the host's header items keep one control's width and scroll when short.
|
||||
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width is a flex basis, not a width, so it holds its header's own parts, which a width let run past it, and yields when the shell runs short; the host's header items keep one control's width and scroll when short. A resizable column drags by its border toward the filling column, its right before it and its left after it; the handle draws that border and occludes the header under it, and a drag sets off from the wider of the width drawn and the owner's, since a header's parts may hold a column wider and a short shell squeeze it narrower; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width the owner had.
|
||||
- Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body).
|
||||
- A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`).
|
||||
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency.
|
||||
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
||||
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. Copy a component draws, tooltips and default placeholders too, reads `i18n::text` (app catalog, then the builtin English and Chinese); `i18n/tests/shipped.rs` fails on a fixed English literal in a component Ely ships (labels, tooltips, button and segment text, match arms, capitalized literal arguments); a choice's value stays its key, its label the catalog's (`tables::Test::key`). A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
||||
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
||||
- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
||||
@@ -265,6 +267,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- 2026-09-29: the web gallery runs on one thread, since a static site sends no cross-origin isolation, and draws through WebGL2 where WebGPU is missing. Its pictures are served beside the wasm, not embedded: the wasm stays under Cloudflare's 25 MiB, and a story fetches only its own. `wasm-opt -Oz` takes the wasm from 23.0 to 20.0 MB; gzip and brotli stay near 7.9 and 5.5 MB.
|
||||
- 2026-10-04: the Showcase follows gpui-kit.com/apps: apps live in their own public repo and arrive by pull request. The site pins a commit of it in `frontend/showcase.lock` and moves only when someone bumps the pin, so stars refresh on the site with each bump. READMEs take Markdown and PNG pictures alone, no HTML or video, so the site needs no sanitizer. The catalog started empty.
|
||||
- 2026-10-05: the pin moves by itself: `.github/workflows/showcase.yml` looks at the catalog's main every 30 minutes and, once that commit's checks pass, builds the gallery and the site, deploys through `CLOUDFLARE_API_TOKEN` (secret) and `CLOUDFLARE_ACCOUNT_ID` (variable), then commits the pin; a run with `deploy` set redeploys at the current pin. A catalog commit that fails its checks keeps the pin. The catalog runs its checks on every pull request without approval, save from accounts new to GitHub; they get no secrets and a read-only token.
|
||||
- 2026-10-09: the repos moved from GitHub to Gitea at git.zacharyzhang.com; GitHub's copies are gone. The site links there, `pnpm showcase` downloads the catalog's archive from there, and `showcase.yml` runs on Gitea Actions, reading the catalog's head and combined commit status through Gitea's API. The catalog's stars still come from GitHub's API, signed in only with its `STARS_GITHUB_TOKEN` secret, since Gitea's own token means nothing there. TokenLedger, merged on GitHub after the copy was taken, was restored from the site's copy.
|
||||
- 2026-10-04: chapter 44, Rendering, holds `RenderSurface` alone. gpui at 1a28cff paints `RenderImage`s everywhere, `CVPixelBuffer`s (NV12) on macOS's Metal renderer alone, and shares no device or shader hook, so shader, camera and GPU views are the host's, feeding the surface, and zero-copy or Metal, OpenGL, Vulkan and Direct3D surfaces are blocked (`tasks/ch44.md`). CI tests the crate and builds the gallery on Linux, Windows and macOS, arm64 and x86_64.
|
||||
- 2026-10-05: an overlay its owner removes without calling `Close` still hands focus back: `take_focus` records each holder and what it took focus from, `give_back` forgets it, and when the focused element leaves the tree the root scope walks back past every gone holder to the focus before them all, else takes it; nested overlays removed together return to what the outer one took focus from. Before, a dialog whose action set its owner's state to closed left focus on the root, and keys reached nothing until a click.
|
||||
- 2026-10-05: `Select::label` and `TextInput::label` name the control for assistive technology without drawing anything; a text input with neither label nor placeholder had no name, and a select read only its value, so a field named by a description list beside it was anonymous in the AX tree.
|
||||
@@ -273,3 +276,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
||||
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
||||
- 2026-10-07: gpui comes from crates.io's `gpui-pre` at the exact snapshot GPUI Kit pins, not from Zed's git. Two sources build two gpui crates whose types do not mix, so Ely and Kit could not share a window. The code is still Zed's; the publisher is not. This replaces the 2026-09-28 choice of Zed's git, and the pin moves when Kit's does.
|
||||
- 2026-10-19: `ListItem::editor` draws a field in the title's place, for a row renamed in place, as ELYTerminal's session rows rename; the title stays the row's name in the AX tree, and the row's press, selection and description stay as they were.
|
||||
Generated
+255
-4
@@ -2,6 +2,22 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ab_glyph"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
|
||||
dependencies = [
|
||||
"ab_glyph_rasterizer",
|
||||
"owned_ttf_parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ab_glyph_rasterizer"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.24.1"
|
||||
@@ -1632,6 +1648,7 @@ dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"barcoders",
|
||||
"base64",
|
||||
"block",
|
||||
"cocoa 0.26.0",
|
||||
"core-foundation 0.10.0",
|
||||
@@ -1649,9 +1666,15 @@ dependencies = [
|
||||
"jiff",
|
||||
"js-sys",
|
||||
"log",
|
||||
"markdown",
|
||||
"mermaid-rs-renderer",
|
||||
"objc",
|
||||
"pulldown-cmark",
|
||||
"qrcode",
|
||||
"ratex-layout",
|
||||
"ratex-parser",
|
||||
"ratex-svg",
|
||||
"ratex-types",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"rqrr",
|
||||
@@ -1662,6 +1685,8 @@ dependencies = [
|
||||
"similar",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"usvg",
|
||||
"vte",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1856,6 +1881,17 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -3351,6 +3387,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -3611,6 +3657,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
|
||||
dependencies = [
|
||||
"unicode-id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
@@ -3666,6 +3721,23 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mermaid-rs-renderer"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb5b469222ce6b9896edf0a969cf68464f5a028ef394fd6643f00bd943df117"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fontdb",
|
||||
"json5",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metal"
|
||||
version = "0.33.0"
|
||||
@@ -4450,6 +4522,15 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
||||
dependencies = [
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -4532,6 +4613,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
@@ -4541,7 +4623,7 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
@@ -4552,10 +4634,20 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
@@ -4566,13 +4658,26 @@ dependencies = [
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5041,6 +5146,114 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||
|
||||
[[package]]
|
||||
name = "ratex-font"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
"ratex-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-font-loader"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6542b55c6273b2ca3eb66021da061dfc84bc8303b434e1332218b63728742b7e"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"ratex-font",
|
||||
"ratex-katex-fonts",
|
||||
"ratex-types",
|
||||
"ratex-unicode-font",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-katex-fonts"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c915a17fd71c41374d1ec19651a26ca352e757e1342391c2cbc5b825dacaa64b"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-layout"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-parser",
|
||||
"ratex-types",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-lexer"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e"
|
||||
dependencies = [
|
||||
"ratex-types",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-parser"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0"
|
||||
dependencies = [
|
||||
"fancy-regex",
|
||||
"ratex-font",
|
||||
"ratex-lexer",
|
||||
"ratex-types",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-svg"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a0948b4d4926858332fe04471e861f54103e655a64f74cc1b3eac0c59bf67bb"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"ratex-font",
|
||||
"ratex-font-loader",
|
||||
"ratex-katex-fonts",
|
||||
"ratex-types",
|
||||
"ratex-unicode-font",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-types"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-unicode-font"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c460464b7fba27343b2b391179ac6c86fcffac5116455158e2b08ce80866fd8"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"system-fonts",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
@@ -5239,6 +5452,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -5936,7 +6155,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6152,6 +6371,17 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-fonts"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46ba78ea149d4bb0626dacad10aa789eedc83395889890a0e6e7ec69a4577442"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"sys-locale",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.13.0"
|
||||
@@ -6535,6 +6765,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -6576,6 +6812,12 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-id"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -6588,6 +6830,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.3"
|
||||
|
||||
+10
-1
@@ -5,7 +5,7 @@ edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
description = "A restrained component library for GPUI, in light and dark."
|
||||
repository = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components"
|
||||
repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
@@ -15,6 +15,7 @@ test-support = ["gpui/test-support", "gpui/leak-detection"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
barcoders = { version = "2", default-features = false, features = ["std"] }
|
||||
emojis = "0.9"
|
||||
futures = "0.3"
|
||||
@@ -26,8 +27,14 @@ isocountry = "0.3"
|
||||
isolang = { version = "2.4", features = ["local_names", "list_languages"] }
|
||||
jiff = "0.2"
|
||||
log = "0.4"
|
||||
markdown = "=1.0.0"
|
||||
mermaid-rs-renderer = { version = "=0.3.1", default-features = false }
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
ratex-layout = "=0.1.14"
|
||||
ratex-parser = "=0.1.14"
|
||||
ratex-svg = { version = "=0.1.14", features = ["embed-fonts"] }
|
||||
ratex-types = "=0.1.14"
|
||||
regex = "1"
|
||||
rqrr = { version = "0.11", default-features = false }
|
||||
rust-embed = "8.12"
|
||||
@@ -36,6 +43,8 @@ serde_json = { version = "1", features = ["preserve_order"] }
|
||||
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
|
||||
smallvec = "1"
|
||||
unicode-segmentation = "1"
|
||||
unicode-width = "0.2"
|
||||
usvg = "0.46"
|
||||
vte = { version = "0.15", default-features = false, features = ["std", "ansi"] }
|
||||
web-time = "1.1"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Status: early. `TASKS.md` tracks every component, chapter by chapter. To help, s
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
|
||||
```
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-message-square-warning"
|
||||
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="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
|
||||
<path d="M12 15h.01" />
|
||||
<path d="M12 7v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -114,7 +114,7 @@ pub fn small_buttons(cx: &App) -> impl IntoElement + use<> {
|
||||
"share-button",
|
||||
ShareButton::new("share-page")
|
||||
.text("Ely, components for GPUI")
|
||||
.url("https://github.com/ZacharyZhang-NY/Ely-GPUI-Components"),
|
||||
.url("https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components"),
|
||||
),
|
||||
cx,
|
||||
))
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub fn codes(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let qr = QrCode::new("https://github.com/ZacharyZhang-NY/Ely-GPUI-Components")
|
||||
let qr = QrCode::new("https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components")
|
||||
.expect("a short link fits a QR code");
|
||||
let label = Barcode::code128("ELY-2419").expect("printable ASCII");
|
||||
let retail = Barcode::ean13("400638133393").expect("twelve digits");
|
||||
|
||||
@@ -211,6 +211,24 @@ pub fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let (last, caption) = ran("context-ran", window, cx);
|
||||
let menu = Menu::new()
|
||||
.buttons([
|
||||
MenuItem::new("Cut")
|
||||
.icon(IconName::Scissors)
|
||||
.keys("secondary-x")
|
||||
.on_click(run(&last, "cut")),
|
||||
MenuItem::new("Copy")
|
||||
.icon(IconName::Copy)
|
||||
.keys("secondary-c")
|
||||
.on_click(run(&last, "copy")),
|
||||
MenuItem::new("Paste")
|
||||
.icon(IconName::Clipboard)
|
||||
.keys("secondary-v")
|
||||
.on_click(run(&last, "paste")),
|
||||
MenuItem::new("Delete")
|
||||
.icon(IconName::Trash2)
|
||||
.disabled(true),
|
||||
])
|
||||
.separator()
|
||||
.item(
|
||||
MenuItem::new("Back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
@@ -242,7 +260,7 @@ pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"ContextMenu",
|
||||
"A right click opens the menu where it lands; it stays inside the window.",
|
||||
"A right click opens the menu where it lands; it stays inside the window. A row of icon buttons heads it; arrows cross the row.",
|
||||
cx,
|
||||
)
|
||||
.child(specimen(
|
||||
|
||||
@@ -26,11 +26,11 @@ fn about() -> AboutDialog {
|
||||
.description("A component library for GPUI, in light and dark.")
|
||||
.link(
|
||||
"Website",
|
||||
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components",
|
||||
"https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components",
|
||||
)
|
||||
.link(
|
||||
"License",
|
||||
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components#license",
|
||||
"https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components#license",
|
||||
)
|
||||
.copyright("© 2026 Ely contributors")
|
||||
}
|
||||
|
||||
@@ -201,26 +201,27 @@ pub fn emoji(cx: &App) -> impl IntoElement + use<> {
|
||||
}
|
||||
|
||||
pub fn latex(cx: &App) -> impl IntoElement + use<> {
|
||||
let math = |source: &str| Latex::parse(source).expect("gallery formula parses");
|
||||
let math =
|
||||
|id: &'static str, source: &str| Latex::parse(id, source).expect("gallery formula parses");
|
||||
section(
|
||||
"Latex / MathInline",
|
||||
"A TeX subset: scripts, fractions, roots, Greek.",
|
||||
"TeX typeset by RaTeX, in the text's color.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.gap_10()
|
||||
.child(specimen("euler", math(r"e^{i\pi} + 1 = 0"), cx))
|
||||
.child(specimen("euler", math("euler", r"e^{i\pi} + 1 = 0"), cx))
|
||||
.child(specimen(
|
||||
"sum",
|
||||
math(r"\sum_{k=1}^{n} k = \frac{n(n+1)}{2}"),
|
||||
math("sum", r"\sum_{k=1}^{n} k = \frac{n(n+1)}{2}"),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"root",
|
||||
math(r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"),
|
||||
math("root", r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(code("Latex::parse(r\"e^{i\\pi} + 1 = 0\")?", cx))
|
||||
.child(code("Latex::parse(\"euler\", r\"e^{i\\pi} + 1 = 0\")?", cx))
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// Writes the showcase catalog for the site: the pinned commit, or the checkout ELY_SHOWCASE names.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Marked } from "marked";
|
||||
|
||||
const REPO = "ZacharyZhang-NY/Ely-GPUI-Showcases";
|
||||
const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Showcases";
|
||||
const site = fileURLToPath(new URL("..", import.meta.url));
|
||||
const pub = join(site, "public", "showcase");
|
||||
const out = join(site, "src", "showcase.json");
|
||||
@@ -15,12 +15,14 @@ const stamp = join(pub, ".commit");
|
||||
/** The catalog at `commit`, unpacked with its validator's packages. */
|
||||
async function fetchCatalog(commit) {
|
||||
const work = mkdtempSync(join(tmpdir(), "ely-showcase-"));
|
||||
const url = `https://codeload.github.com/${REPO}/tar.gz/${commit}`;
|
||||
const url = `${REPO}/archive/${commit}.tar.gz`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`showcase: ${url} answered ${res.status}`);
|
||||
writeFileSync(join(work, "catalog.tar.gz"), Buffer.from(await res.arrayBuffer()));
|
||||
execFileSync("tar", ["-xzf", "catalog.tar.gz"], { cwd: work });
|
||||
const root = join(work, `${REPO.split("/")[1]}-${commit}`);
|
||||
const [top] = readdirSync(work, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
if (!top) throw new Error(`showcase: ${url} held no folder`);
|
||||
const root = join(work, top.name);
|
||||
const pnpm = process.env.npm_execpath;
|
||||
if (!pnpm) throw new Error("showcase: run through pnpm, which installs the catalog's validator");
|
||||
execFileSync(process.execPath, [pnpm, "install", "--frozen-lockfile", "--ignore-scripts", "--silent"], { cwd: root, stdio: "inherit" });
|
||||
|
||||
@@ -1 +1 @@
|
||||
8b5c36ae18fb3b80bdb7b4c63fef8e9042b14779
|
||||
ca872b7708d200dafb3027364122aa5895e43299
|
||||
@@ -36,8 +36,8 @@ function Header() {
|
||||
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
|
||||
<Icon name={dark ? "Sun" : "Moon"} />
|
||||
</button>
|
||||
<a className="tool" href={REPO} rel="noopener" aria-label="Ely on GitHub">
|
||||
<Icon name="GitHub" />
|
||||
<a className="tool" href={REPO} rel="noopener" aria-label="Ely on Gitea">
|
||||
<Icon name="Gitea" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Catalog {
|
||||
|
||||
export type Sort = "newest" | "stars";
|
||||
|
||||
export const SUBMIT = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Showcases#submit-an-app";
|
||||
export const SUBMIT = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Showcases#submit-an-app";
|
||||
|
||||
/** Newest first, then most stars; or most stars, then newest. */
|
||||
export function sorted(apps: App[], sort: Sort): App[] {
|
||||
|
||||
@@ -37,6 +37,6 @@ export function search(query: string): Chapter[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const REPO = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components";
|
||||
export const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components";
|
||||
|
||||
export const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -7,7 +7,7 @@ Ely runs beside Longbridge's [GPUI Kit](https://gpui-kit.com) in one app and one
|
||||
```toml
|
||||
[dependencies]
|
||||
gpui-kit = "0.7"
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
```
|
||||
|
||||
Kit brings GPUI and puts it at its root, so the app writes `use gpui_kit::*` and lists no `gpui` line of its own. Ely follows Kit's default GPUI.
|
||||
@@ -43,4 +43,4 @@ In debug builds Kit's `init` installs Kit's inspector. Call `ely_gpui_component:
|
||||
|
||||
## A whole app
|
||||
|
||||
[`compat/kit/examples/start.rs`](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/compat/kit/examples/start.rs) puts Ely's heading and button beside Kit's field and dialog, and one button turns both themes. From a clone, run it with `cargo run --manifest-path compat/kit/Cargo.toml --example start`. The crate's tests hold each claim on this page.
|
||||
[`compat/kit/examples/start.rs`](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/compat/kit/examples/start.rs) puts Ely's heading and button beside Kit's field and dialog, and one button turns both themes. From a clone, run it with `cargo run --manifest-path compat/kit/Cargo.toml --example start`. The crate's tests hold each claim on this page.
|
||||
@@ -48,7 +48,7 @@ Use `Icon::from_path("app-icons/mark.svg")` to draw one of those SVG assets with
|
||||
|
||||
Compatible single-color SVGs can come from [Lucide](https://lucide.dev/), [Hugeicons](https://github.com/hugeicons/hugeicons), [Phosphor](https://github.com/phosphor-icons/core), [Remix Icon](https://github.com/Remix-Design/RemixIcon), or [Tabler](https://github.com/tabler/tabler-icons). The app supplies its chosen files and preserves their licenses; Ely does not download or register those catalogs.
|
||||
|
||||
The example below includes its asset source. Copy [`examples/assets/mark.svg`](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/examples/assets/mark.svg) with it, or supply your own SVG.
|
||||
The example below includes its asset source. Copy [`examples/assets/mark.svg`](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/examples/assets/mark.svg) with it, or supply your own SVG.
|
||||
|
||||
## A window of icons
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Ely is not on crates.io. Depend on it by git, with GPUI at the snapshot Ely pins
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components" }
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
|
||||
```
|
||||
|
||||
@@ -25,7 +25,7 @@ GPUI's `TestAppContext` serves no assets, so `ely_gpui_component::init` returns
|
||||
|
||||
## Tests to read
|
||||
|
||||
- [Layers](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/src/primitives/tests.rs): what is raised inside a raise lies over it, and keeps its state.
|
||||
- [Feedback](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/src/feedback/tests/mod.rs): toasts and alerts, and focus handed to the root when a focused toast leaves.
|
||||
- [Tables](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/src/tables/tests/mod.rs): clicks, double presses, Tab across a grid.
|
||||
- [Internationalization](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/src/i18n/tests/mod.rs): catalogs, locales and a mirrored row.
|
||||
- [Layers](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/src/primitives/tests.rs): what is raised inside a raise lies over it, and keeps its state.
|
||||
- [Feedback](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/src/feedback/tests/mod.rs): toasts and alerts, and focus handed to the root when a focused toast leaves.
|
||||
- [Tables](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/src/tables/tests/mod.rs): clicks, double presses, Tab across a grid.
|
||||
- [Internationalization](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/src/i18n/tests/mod.rs): catalogs, locales and a mirrored row.
|
||||
@@ -13,7 +13,7 @@ RUSTC_BOOTSTRAP=1 cargo check --lib --target wasm32-unknown-unknown
|
||||
|
||||
## Start an app in the browser
|
||||
|
||||
The gallery's [web entry](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/examples/gallery/web.rs) shows the steps:
|
||||
The gallery's [web entry](https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/src/branch/main/examples/gallery/web.rs) shows the steps:
|
||||
|
||||
| Step | Why |
|
||||
| --- | --- |
|
||||
|
||||
@@ -9,14 +9,14 @@ import VolumeCross from "reicon/icons/VolumeCross";
|
||||
|
||||
const set = { ArrowRightUp, Maximize, Minimize, Moon, Refresh, Sound, Sun, VolumeCross };
|
||||
|
||||
// simple-icons' GitHub mark (CC0), padded to sit like Reicon's.
|
||||
const GITHUB =
|
||||
'<svg viewBox="-3 -3 30 30" width="24" height="24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
|
||||
// simple-icons' Gitea mark (CC0), padded to sit like Reicon's.
|
||||
const GITEA =
|
||||
'<svg viewBox="-3 -3 30 30" width="24" height="24" fill="currentColor"><path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/></svg>';
|
||||
|
||||
export type IconName = keyof typeof set | "GitHub";
|
||||
export type IconName = keyof typeof set | "Gitea";
|
||||
|
||||
/** An 18px glyph at 1.5px in a 24px box. */
|
||||
export function Icon({ name }: { name: IconName }) {
|
||||
const svg = name === "GitHub" ? GITHUB : set[name].toSvg({ size: 24 });
|
||||
const svg = name === "Gitea" ? GITEA : set[name].toSvg({ size: 24 });
|
||||
return <span className="icon" aria-hidden="true" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ impl RenderOnce for DescriptionList {
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group of a `PropertyGrid`, open unless `folded`.
|
||||
/// A `PropertyGrid` group; its unique title keys its fold.
|
||||
pub struct PropertyGroup {
|
||||
title: SharedString,
|
||||
open: bool,
|
||||
@@ -157,10 +157,9 @@ impl RenderOnce for PropertyGrid {
|
||||
let folds: Vec<_> = self
|
||||
.groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, group)| {
|
||||
.map(|group| {
|
||||
use_seeded(
|
||||
(self.id.clone(), format!("fold-{ix}")),
|
||||
(self.id.clone(), format!("fold-{}", group.title)),
|
||||
group.open,
|
||||
window,
|
||||
cx,
|
||||
@@ -180,14 +179,14 @@ impl RenderOnce for PropertyGrid {
|
||||
|(ix, PropertyGroup { title, rows, .. })| {
|
||||
let shown = folds[ix].read(cx).value;
|
||||
let (toggle, key) = (folds[ix].clone(), title.clone());
|
||||
let chevron = Disclosure::new((id.clone(), format!("chevron-{ix}")), shown);
|
||||
let chevron = Disclosure::new((id.clone(), format!("chevron-{title}")), shown);
|
||||
div()
|
||||
.when(ix > 0, |group| {
|
||||
group.border_t_1().border_color(colors.border)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id((id.clone(), format!("group-{ix}")))
|
||||
.id((id.clone(), format!("group-{title}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
@@ -208,10 +207,10 @@ impl RenderOnce for PropertyGrid {
|
||||
})
|
||||
})
|
||||
.child(chevron)
|
||||
.child(title),
|
||||
.child(title.clone()),
|
||||
)
|
||||
.child(
|
||||
Collapsible::new((id.clone(), format!("rows-{ix}")), shown).child(
|
||||
Collapsible::new((id.clone(), format!("rows-{title}")), shown).child(
|
||||
div()
|
||||
.pb_1()
|
||||
.children(rows.into_iter().map(|(name, editor)| {
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::{
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
AnyElement, Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels,
|
||||
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -75,6 +75,46 @@ fn a_property_group_folds_from_its_header_and_stays_folded(cx: &mut TestAppConte
|
||||
assert_eq!(height.get(), open, "a second press opens it");
|
||||
}
|
||||
|
||||
/// A grid that may put a Table group first.
|
||||
struct Reorder(bool);
|
||||
|
||||
impl Render for Reorder {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
let row = |name: &'static str| div().h(px(20.0)).debug_selector(move || name.into());
|
||||
let grid = PropertyGrid::new("grid");
|
||||
let grid = if self.0 {
|
||||
grid.group(PropertyGroup::new("Table").row("Rows", row("table")))
|
||||
} else {
|
||||
grid
|
||||
};
|
||||
div().w(px(320.0)).child(
|
||||
grid.group(PropertyGroup::new("Layout").row("Width", row("layout")))
|
||||
.group(PropertyGroup::new("Export").row("Scale", row("export"))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_fold_stays_with_its_group_when_a_group_is_put_first(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, _| Reorder(false));
|
||||
settle(cx);
|
||||
cx.simulate_click(point(px(4.0), px(12.0)), Modifiers::none());
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("layout").is_none(), "Layout folds");
|
||||
view.update(cx, |view, cx| {
|
||||
view.0 = true;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("table").is_some(), "the new group opens");
|
||||
assert!(cx.debug_bounds("layout").is_none(), "Layout stays folded");
|
||||
assert!(cx.debug_bounds("export").is_some(), "Export stays open");
|
||||
}
|
||||
|
||||
/// A one-row description list, this wide, that reports where the list and its value sit.
|
||||
struct Facts(Pixels, fn() -> AnyElement, Rc<Cell<[Bounds<Pixels>; 2]>>);
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ impl BlockEditor {
|
||||
}
|
||||
BlockKind::Math => {
|
||||
let tex = block.fields[0].read(cx).text().to_string();
|
||||
let shown = match Latex::parse(&tex) {
|
||||
let shown = match Latex::parse((id.clone(), format!("math-{key}")), &tex) {
|
||||
_ if tex.trim().is_empty() => div().into_any_element(),
|
||||
Ok(math) => div()
|
||||
.flex()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::tree::{Node, parse};
|
||||
|
||||
/// The anchor of the document's `ordinal`th heading.
|
||||
pub(crate) fn anchor(ordinal: usize) -> SharedString {
|
||||
format!("heading-{ordinal}").into()
|
||||
}
|
||||
|
||||
/// Headings in order for a contents list: anchor, text, depth.
|
||||
pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> {
|
||||
fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Heading(level, inline) => {
|
||||
let ordinal = out.len();
|
||||
out.push((
|
||||
anchor(ordinal),
|
||||
inline.text.clone().into(),
|
||||
*level as usize - 1,
|
||||
));
|
||||
}
|
||||
Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out),
|
||||
Node::List { items, .. } => {
|
||||
for (_, body) in items {
|
||||
walk(body, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&parse(markdown), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::outline;
|
||||
|
||||
#[test]
|
||||
fn the_outline_numbers_headings_in_order_with_their_depth() {
|
||||
let found =
|
||||
outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark");
|
||||
let shape: Vec<(&str, &str, usize)> = found
|
||||
.iter()
|
||||
.map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("heading-0", "Lift", 0),
|
||||
("heading-1", "In light", 1),
|
||||
("heading-2", "Quoted", 2),
|
||||
("heading-3", "Listed", 3),
|
||||
("heading-4", "In dark", 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use std::{
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Asset, ElementId, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce,
|
||||
SharedString, Size, Styled, Window, div, img, px, size,
|
||||
};
|
||||
use mermaid_rs_renderer::{LayoutConfig, RenderOptions, Theme, render_strict};
|
||||
|
||||
use crate::{
|
||||
primitives::hold,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A Mermaid diagram; a failure shows why and the source.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Diagram {
|
||||
id: ElementId,
|
||||
source: SharedString,
|
||||
}
|
||||
|
||||
impl Diagram {
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawn diagram depends on.
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
struct Chart {
|
||||
source: SharedString,
|
||||
dark: bool,
|
||||
/// Background and text, as `#rrggbb`.
|
||||
ground: SharedString,
|
||||
ink: SharedString,
|
||||
/// Window scale, in hundredths.
|
||||
scale: u32,
|
||||
}
|
||||
|
||||
type Drawn = Result<(Arc<RenderImage>, Size<Pixels>), SharedString>;
|
||||
|
||||
enum Drawer {}
|
||||
|
||||
impl Asset for Drawer {
|
||||
type Source = Chart;
|
||||
type Output = Drawn;
|
||||
|
||||
fn load(chart: Chart, cx: &mut App) -> impl Future<Output = Drawn> + Send + 'static {
|
||||
let renderer = cx.svg_renderer();
|
||||
async move {
|
||||
let svg = svg(&chart)?;
|
||||
let tree = usvg::Tree::from_data(svg.as_bytes(), &options())
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
let shape = size(px(tree.size().width()), px(tree.size().height()));
|
||||
let picture = renderer
|
||||
.render_single_frame(svg.as_bytes(), chart.scale as f32 / 100.0)
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
Ok((picture, shape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagram as SVG in Mermaid's theme for the mode.
|
||||
fn svg(chart: &Chart) -> Result<String, SharedString> {
|
||||
let mut theme = match chart.dark {
|
||||
true => Theme::dark(),
|
||||
false => Theme::modern(),
|
||||
};
|
||||
let (ground, ink) = (chart.ground.to_string(), chart.ink.to_string());
|
||||
theme.background = ground.clone();
|
||||
theme.edge_label_background = ground;
|
||||
theme.primary_text_color = ink.clone();
|
||||
theme.text_color = ink.clone();
|
||||
theme.pie_title_text_color = ink.clone();
|
||||
theme.pie_section_text_color = ink.clone();
|
||||
theme.pie_legend_text_color = ink;
|
||||
let options = RenderOptions {
|
||||
theme,
|
||||
layout: LayoutConfig::default(),
|
||||
};
|
||||
// The renderer may panic on input it parsed.
|
||||
let drawn = catch_unwind(AssertUnwindSafe(|| render_strict(&chart.source, options)));
|
||||
match drawn {
|
||||
Ok(Ok(svg)) => Ok(svg),
|
||||
Ok(Err(error)) => Err(error.to_string().into()),
|
||||
Err(_) => {
|
||||
log::error!("diagram: the renderer failed on a parsed diagram");
|
||||
Err("the Mermaid renderer could not draw this diagram".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `source` as light SVG of plain paths, for export.
|
||||
pub fn diagram_svg(source: &str) -> Result<String, SharedString> {
|
||||
let chart = Chart {
|
||||
source: source.to_owned().into(),
|
||||
dark: false,
|
||||
ground: "#ffffff".into(),
|
||||
ink: "#000000".into(),
|
||||
scale: 100,
|
||||
};
|
||||
flatten(&svg(&chart)?)
|
||||
}
|
||||
|
||||
/// usvg reading with the system's fonts, chosen as gpui draws.
|
||||
pub(crate) fn options() -> usvg::Options<'static> {
|
||||
let named = usvg::FontResolver::default_font_selector();
|
||||
let select_font = Box::new(
|
||||
move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
|
||||
// A missing family falls to sans-serif, then any face.
|
||||
let sans = usvg::fontdb::Query {
|
||||
families: &[usvg::fontdb::Family::SansSerif],
|
||||
..Default::default()
|
||||
};
|
||||
named(font, db)
|
||||
.or_else(|| db.query(&sans))
|
||||
.or_else(|| db.faces().next().map(|face| face.id))
|
||||
},
|
||||
);
|
||||
usvg::Options {
|
||||
fontdb: fonts(),
|
||||
font_resolver: usvg::FontResolver {
|
||||
select_font,
|
||||
select_fallback: usvg::FontResolver::default_fallback_selector(),
|
||||
},
|
||||
..usvg::Options::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `svg` with markers and text as paths any reader draws.
|
||||
pub(crate) fn flatten(svg: &str) -> Result<String, SharedString> {
|
||||
let tree = usvg::Tree::from_data(svg.as_bytes(), &options())
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
// A letter without a font vanishes: say so.
|
||||
let (mut kept, mut lost) = (0, 0);
|
||||
texts(tree.root(), &mut kept, &mut lost);
|
||||
let written = labels(svg)?;
|
||||
if kept < written || lost > 0 {
|
||||
log::error!("svg: {kept} of {written} texts set, {lost} letters without a font");
|
||||
return Err("no installed font has every letter here".into());
|
||||
}
|
||||
Ok(tree.to_string(&usvg::WriteOptions::default()))
|
||||
}
|
||||
|
||||
/// The `<text>` elements holding words.
|
||||
fn labels(svg: &str) -> Result<usize, SharedString> {
|
||||
let doc = usvg::roxmltree::Document::parse(svg)
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
let worded = |node: &usvg::roxmltree::Node| {
|
||||
node.descendants()
|
||||
.any(|word| word.text().is_some_and(|text| !text.trim().is_empty()))
|
||||
};
|
||||
Ok(doc
|
||||
.descendants()
|
||||
.filter(|node| node.has_tag_name("text") && worded(node))
|
||||
.count())
|
||||
}
|
||||
|
||||
/// Text nodes usvg set; letters it found no glyph for.
|
||||
fn texts(group: &usvg::Group, kept: &mut usize, lost: &mut usize) {
|
||||
for node in group.children() {
|
||||
match node {
|
||||
usvg::Node::Group(inner) => texts(inner, kept, lost),
|
||||
usvg::Node::Text(text) => {
|
||||
*kept += 1;
|
||||
let glyphs = text
|
||||
.layouted()
|
||||
.iter()
|
||||
.flat_map(|span| &span.positioned_glyphs);
|
||||
// Glyph zero is a font's blank for a missing letter.
|
||||
*lost += glyphs
|
||||
.filter(|glyph| glyph.id.0 == 0 && !glyph.text.trim().is_empty())
|
||||
.count();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's fonts and gpui's bundled ones, read once.
|
||||
fn fonts() -> Arc<usvg::fontdb::Database> {
|
||||
static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
|
||||
FONTS
|
||||
.get_or_init(|| {
|
||||
use gpui::AssetSource as _;
|
||||
let mut fonts = usvg::fontdb::Database::new();
|
||||
fonts.load_system_fonts();
|
||||
// The faces gpui's SVG renderer adds, from the same assets.
|
||||
for path in [
|
||||
"fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
|
||||
"fonts/lilex/Lilex-Regular.ttf",
|
||||
] {
|
||||
match crate::Assets.load(path) {
|
||||
Ok(Some(data)) => fonts.load_font_data(data.into_owned()),
|
||||
Ok(None) => log::debug!("diagram: no bundled {path}"),
|
||||
Err(error) => log::error!("diagram: bundled {path} did not load: {error}"),
|
||||
}
|
||||
}
|
||||
// As gpui: unanswered generic families take bundled faces.
|
||||
use usvg::fontdb::{Family, Query};
|
||||
for (family, bundled) in [
|
||||
(Family::SansSerif, "IBM Plex Sans"),
|
||||
(Family::Serif, "IBM Plex Sans"),
|
||||
(Family::Monospace, "Lilex"),
|
||||
(Family::Cursive, "IBM Plex Sans"),
|
||||
(Family::Fantasy, "IBM Plex Sans"),
|
||||
] {
|
||||
let query = Query {
|
||||
families: &[family],
|
||||
..Default::default()
|
||||
};
|
||||
if fonts.query(&query).is_some() {
|
||||
continue;
|
||||
}
|
||||
match family {
|
||||
Family::SansSerif => fonts.set_sans_serif_family(bundled),
|
||||
Family::Serif => fonts.set_serif_family(bundled),
|
||||
Family::Monospace => fonts.set_monospace_family(bundled),
|
||||
Family::Cursive => fonts.set_cursive_family(bundled),
|
||||
_ => fonts.set_fantasy_family(bundled),
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"diagram: {} font faces for measuring and export",
|
||||
fonts.len()
|
||||
);
|
||||
Arc::new(fonts)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// `#rrggbb` of a theme color.
|
||||
fn hex(color: Hsla) -> SharedString {
|
||||
let rgb = color.to_rgb();
|
||||
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
channel(rgb.r),
|
||||
channel(rgb.g),
|
||||
channel(rgb.b)
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
impl RenderOnce for Diagram {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let chart = Chart {
|
||||
source: self.source.clone(),
|
||||
dark: theme.is_dark(),
|
||||
ground: hex(colors.surface),
|
||||
ink: hex(colors.fg),
|
||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||
};
|
||||
let (radius, small, mono) = (
|
||||
theme.radius(Radius::Md),
|
||||
theme.text_size(TextSize::Sm),
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
let picture = |drawn: &Drawn| drawn.as_ref().ok().map(|(image, _)| image.clone());
|
||||
match hold::<Drawer>(self.id, &chart, picture, window, cx) {
|
||||
Some(Ok((picture, shape))) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(img(picture).w(shape.width).h(shape.height).max_w_full())
|
||||
.into_any_element(),
|
||||
Some(Err(why)) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.text_size(small)
|
||||
.child(div().text_color(colors.danger).child(why))
|
||||
.child(
|
||||
div()
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.source),
|
||||
)
|
||||
.into_any_element(),
|
||||
None => div().h_12().into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chart(source: &str, dark: bool) -> Chart {
|
||||
Chart {
|
||||
source: source.to_string().into(),
|
||||
dark,
|
||||
ground: "#2c2c2c".into(),
|
||||
ink: "#eeeeee".into(),
|
||||
scale: 100,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flowchart_draws_with_the_ground_and_ink() {
|
||||
let svg = svg(&chart("flowchart LR\n A[Start] --> B", true)).expect("draws");
|
||||
let svg = svg.to_lowercase();
|
||||
assert!(svg.contains("#2c2c2c"), "the ground: {svg}");
|
||||
let label = svg.find(">start<").expect("the label");
|
||||
let tag = &svg[svg[..label].rfind("<text").expect("its tag")..label];
|
||||
assert!(tag.contains("fill=\"#eeeeee\""), "the label's ink: {tag}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_diagram_says_why() {
|
||||
let why = svg(&chart("flowchart LR\n A -->", false)).expect_err("an error");
|
||||
assert!(!why.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exported_diagram_is_plain_paths() {
|
||||
let svg = diagram_svg(
|
||||
"flowchart LR
|
||||
A[Start] --> B",
|
||||
)
|
||||
.expect("draws");
|
||||
assert!(svg.contains("<path"), "{svg}");
|
||||
assert!(!svg.contains("<marker") && !svg.contains("<text"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_letter_no_font_has_is_an_error() {
|
||||
let why = diagram_svg("flowchart LR\n A[ABC \u{F0000}] --> B").expect_err("no glyph");
|
||||
assert!(why.contains("font"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_write_as_hex() {
|
||||
assert_eq!(hex(gpui::rgb(0x2c2c2c).into()), "#2c2c2c");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Edits read off the UI thread: formats and block commands.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{AppContext as _, Context};
|
||||
|
||||
use super::{
|
||||
DocumentEvent, LARGEST_READ, MarkdownDocument,
|
||||
block::{self, BlockCommand, settled},
|
||||
cells,
|
||||
format::{Change, EditRefusal, InlineFormat, drop_image, toggle},
|
||||
tables::{self, TableEdit},
|
||||
};
|
||||
use crate::documents::{MarkdownMode, modes::joint};
|
||||
|
||||
/// The text read, the open block's offset, the selection.
|
||||
type Snapshot = (String, usize, Range<usize>);
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// `format` at the selection, read apart; refusals by event.
|
||||
pub fn format(
|
||||
&mut self,
|
||||
format: InlineFormat,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), EditRefusal> {
|
||||
self.apart(
|
||||
format!("format {format:?}"),
|
||||
move |full, selection| toggle(full, selection, format),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes the picture at source byte `at`, read apart.
|
||||
pub fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
|
||||
let drop = move |full: &str, _| drop_image(full, at);
|
||||
self.apart("delete image".to_string(), drop, cx)
|
||||
}
|
||||
|
||||
/// `command` at the caret, read apart; refusals by event.
|
||||
pub fn block(
|
||||
&mut self,
|
||||
command: BlockCommand,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), EditRefusal> {
|
||||
self.apart(
|
||||
format!("block {command:?}"),
|
||||
move |full, selection| block::run(full, selection, command),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// `edit` on the table at the caret, read apart.
|
||||
pub fn edit_table(
|
||||
&mut self,
|
||||
edit: TableEdit,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), EditRefusal> {
|
||||
self.apart(
|
||||
format!("table edit {edit:?}"),
|
||||
move |full, selection| match edit {
|
||||
TableEdit::Delete => tables::delete(full, selection.start),
|
||||
_ => tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads `read` off the UI thread, then writes its change.
|
||||
fn apart(
|
||||
&mut self,
|
||||
name: String,
|
||||
read: impl FnOnce(&str, Range<usize>) -> Result<Change, EditRefusal> + Send + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), EditRefusal> {
|
||||
if self.mode == MarkdownMode::Preview {
|
||||
return Err(EditRefusal::Preview);
|
||||
}
|
||||
let (full, offset, selection) = self.snapshot(cx);
|
||||
if full.len() > LARGEST_READ {
|
||||
log::info!("markdown document: {name} refused: {} bytes", full.len());
|
||||
return Err(EditRefusal::TooLong);
|
||||
}
|
||||
if self.editing {
|
||||
log::info!("markdown document: {name} refused: one runs");
|
||||
return Err(EditRefusal::Busy);
|
||||
}
|
||||
log::info!("markdown document: {name} reads {} bytes apart", full.len());
|
||||
let taken = (full.clone(), offset, selection.clone());
|
||||
self.editing = true;
|
||||
cx.spawn(async move |document, cx| {
|
||||
let change = cx
|
||||
.background_spawn(async move { read(&full, selection) })
|
||||
.await;
|
||||
document
|
||||
.update(cx, |document, cx| {
|
||||
document.editing = false;
|
||||
let done = if document.mode == MarkdownMode::Preview {
|
||||
Err(EditRefusal::Preview)
|
||||
} else if document.snapshot(cx) != taken {
|
||||
Err(EditRefusal::Changed)
|
||||
} else {
|
||||
change
|
||||
.and_then(|change| document.apply(&name, change, &taken.0, taken.1, cx))
|
||||
};
|
||||
if let Err(why) = done {
|
||||
log::info!("markdown document: {name} refused: {why:?}");
|
||||
cx.emit(DocumentEvent::EditRefused(why));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text an edit reads: the open block's in place.
|
||||
pub(super) fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
|
||||
let text = self.editor.read(cx).text();
|
||||
let Some(open) = &self.open else {
|
||||
let picked = self.editor.read(cx).primary().range();
|
||||
return settle((text.to_string(), 0, picked));
|
||||
};
|
||||
let input = open.input.read(cx);
|
||||
let (before, after) = match open.append {
|
||||
true => (format!("{text}{}", joint(text)), ""),
|
||||
false => (
|
||||
text[..open.range.start].to_string(),
|
||||
&text[open.range.end..],
|
||||
),
|
||||
};
|
||||
let (typed, mut picked) = (input.text(), input.selection());
|
||||
// A cell reads as it writes back.
|
||||
let typed = match open.cell {
|
||||
Some(_) => {
|
||||
let edges = cells::edges(text, open.range.clone());
|
||||
let shift = |at: usize| cells::kept_at(typed, at, edges);
|
||||
picked = shift(picked.start)..shift(picked.end);
|
||||
cells::kept(typed, edges)
|
||||
}
|
||||
None => typed.to_string(),
|
||||
};
|
||||
let offset = before.len();
|
||||
let full = format!("{before}{typed}{after}");
|
||||
settle((full, offset, offset + picked.start..offset + picked.end))
|
||||
}
|
||||
|
||||
/// Writes `change`: into the open block while it stays inside.
|
||||
fn apply(
|
||||
&mut self,
|
||||
name: &str,
|
||||
(edits, selected): Change,
|
||||
full: &str,
|
||||
offset: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), EditRefusal> {
|
||||
log::info!("markdown document: {name}");
|
||||
if let Some(open) = &self.open {
|
||||
let typed = open.input.read(cx).text();
|
||||
// Offsets read the escaped cell; its field holds it raw.
|
||||
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||
let raw = open.cell.is_some() && cells::kept(typed, edges) != typed;
|
||||
let end = offset + typed.len();
|
||||
let grown = (edits.iter().map(|(_, with)| with.len()).sum::<usize>() + end)
|
||||
.checked_sub(edits.iter().map(|(range, _)| range.len()).sum::<usize>());
|
||||
let inside = !raw
|
||||
&& edits
|
||||
.iter()
|
||||
.all(|(range, _)| offset <= range.start && range.end <= end)
|
||||
&& grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown);
|
||||
if inside {
|
||||
let (Some(from), Some(to)) = (
|
||||
edits.iter().map(|(range, _)| range.start).min(),
|
||||
edits.iter().map(|(range, _)| range.end).max(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut written = String::new();
|
||||
let mut at = from;
|
||||
for (range, with) in &edits {
|
||||
written.push_str(&full[at..range.start]);
|
||||
written.push_str(with);
|
||||
at = range.end;
|
||||
}
|
||||
written.push_str(&full[at..to]);
|
||||
open.input.update(cx, |input, cx| {
|
||||
input.select(from - offset..to - offset, cx);
|
||||
input.insert(&written, cx);
|
||||
input.select(selected.start - offset..selected.end - offset, cx);
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("markdown document: {name} reaches past the open block");
|
||||
self.commit(cx);
|
||||
if self.editor.read(cx).text() != full {
|
||||
log::error!("markdown document: {name}: the write-back is not the text read");
|
||||
return Err(EditRefusal::Changed);
|
||||
}
|
||||
}
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.edit(edits, cx);
|
||||
editor.select(std::iter::once(selected), cx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor splits lines on LF only; reads start off a CRLF.
|
||||
fn settle((full, offset, picked): Snapshot) -> Snapshot {
|
||||
let picked = settled(&full, picked.start)..settled(&full, picked.end);
|
||||
(full, offset, picked)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Dropped files and pasted pictures go to the owner.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::{ClipboardEntry, Context, EventEmitter, Image};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
/// What was dropped on or pasted into the document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Attachment {
|
||||
/// Files dropped, as they lie on disk.
|
||||
Files(Vec<PathBuf>),
|
||||
/// A picture pasted with no text beside it.
|
||||
Picture(Image),
|
||||
}
|
||||
|
||||
impl EventEmitter<Attachment> for MarkdownDocument {}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// A pasted picture goes to the owner; false leaves it.
|
||||
pub(super) fn paste_picture(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(item) = cx.read_from_clipboard() else {
|
||||
return false;
|
||||
};
|
||||
let entries = item.entries();
|
||||
if entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, ClipboardEntry::String(_)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(picture) = entries.iter().find_map(|entry| match entry {
|
||||
ClipboardEntry::Image(image) => Some(image.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
log::info!("markdown document: a picture pasted");
|
||||
cx.emit(Attachment::Picture(picture));
|
||||
true
|
||||
}
|
||||
|
||||
/// `text` at the caret, as typed; false in preview.
|
||||
pub fn insert(&mut self, text: &str, cx: &mut Context<Self>) -> bool {
|
||||
if self.mode == MarkdownMode::Preview {
|
||||
return false;
|
||||
}
|
||||
if let Some(open) = &self.open {
|
||||
open.input.update(cx, |input, cx| input.insert(text, cx));
|
||||
return true;
|
||||
}
|
||||
let caret = self.editor.read(cx).primary().range();
|
||||
let after = caret.start + text.len();
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(caret, text.to_string())], cx);
|
||||
editor.select(std::iter::once(after..after), cx);
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! Block commands at the caret, as Typora's Paragraph menu (R4.7).
|
||||
|
||||
mod check;
|
||||
mod heading;
|
||||
mod insert;
|
||||
mod list;
|
||||
mod quote;
|
||||
mod split;
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::format::{Change, EditRefusal};
|
||||
use crate::documents::markdown::{file_tree, span, walk};
|
||||
|
||||
/// A command of Typora's Paragraph menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BlockCommand {
|
||||
/// Levels 1 to 6.
|
||||
Heading(u8),
|
||||
Paragraph,
|
||||
/// A level up; a paragraph turns level 6.
|
||||
Promote,
|
||||
/// A level down; level 6 turns a paragraph.
|
||||
Demote,
|
||||
Quote,
|
||||
OrderedList,
|
||||
BulletList,
|
||||
TaskList,
|
||||
Rule,
|
||||
CodeBlock,
|
||||
MathBlock,
|
||||
Toc,
|
||||
FrontMatter,
|
||||
Footnote,
|
||||
LinkDefinition,
|
||||
ParagraphBefore,
|
||||
ParagraphAfter,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// `command` at `selection` in `text`, or why not.
|
||||
pub(super) fn run(
|
||||
text: &str,
|
||||
selection: Range<usize>,
|
||||
command: BlockCommand,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
let root = file_tree(text);
|
||||
let here = Here::new(text, &root, selection);
|
||||
match command {
|
||||
BlockCommand::Heading(_)
|
||||
| BlockCommand::Paragraph
|
||||
| BlockCommand::Promote
|
||||
| BlockCommand::Demote => heading::run(&here, command),
|
||||
BlockCommand::Quote => quote::run(&here),
|
||||
BlockCommand::OrderedList | BlockCommand::BulletList | BlockCommand::TaskList => {
|
||||
list::run(&here, command)
|
||||
}
|
||||
BlockCommand::FrontMatter => insert::front_matter(&here),
|
||||
BlockCommand::ParagraphBefore => insert::paragraph(&here, true),
|
||||
BlockCommand::ParagraphAfter => insert::paragraph(&here, false),
|
||||
BlockCommand::Delete => insert::delete(&here),
|
||||
_ => insert::run(&here, command),
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's place in the parsed text.
|
||||
pub(super) struct Here<'a> {
|
||||
pub(super) text: &'a str,
|
||||
pub(super) selection: Range<usize>,
|
||||
/// Root down to the deepest block at the selection's start.
|
||||
pub(super) path: Vec<&'a Node>,
|
||||
pub(super) root: &'a Node,
|
||||
/// Every node, in document order.
|
||||
pub(super) nodes: Vec<&'a Node>,
|
||||
}
|
||||
|
||||
impl<'a> Here<'a> {
|
||||
fn new(text: &'a str, root: &'a Node, selection: Range<usize>) -> Self {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
Here {
|
||||
text,
|
||||
path: path(text, root, selection.start),
|
||||
selection,
|
||||
root,
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's block, unless the path ends on a container.
|
||||
pub(super) fn leaf(&self) -> Option<&'a Node> {
|
||||
let last = *self.path.last()?;
|
||||
(!holds(last)).then_some(last)
|
||||
}
|
||||
|
||||
/// The containers around `path[..upto]`.
|
||||
pub(super) fn containers(&self, upto: usize) -> Vec<&'a Node> {
|
||||
self.path[..upto]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|node| container(node))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the caret's line holds nothing past its prefix.
|
||||
pub(super) fn blank(&self) -> bool {
|
||||
let line = line_start(self.text, self.selection.start);
|
||||
let containers = self.containers(self.path.len());
|
||||
inside(self.text, line, &containers)
|
||||
.is_some_and(|at| self.text[at..line_end(self.text, line)].trim().is_empty())
|
||||
}
|
||||
|
||||
/// Root down to the deepest block at `at`.
|
||||
pub(super) fn path_at(&self, at: usize) -> Vec<&'a Node> {
|
||||
path(self.text, self.root, at)
|
||||
}
|
||||
|
||||
/// The common holder's index and the blocks the selection touches.
|
||||
pub(super) fn blocks(&self) -> Option<(usize, Vec<&'a Node>)> {
|
||||
let end = self.path_at(self.selection.end);
|
||||
let mut k = 0;
|
||||
while k + 1 < self.path.len()
|
||||
&& k + 1 < end.len()
|
||||
&& std::ptr::eq(self.path[k + 1], end[k + 1])
|
||||
{
|
||||
k += 1;
|
||||
}
|
||||
if !holds(self.path[k]) {
|
||||
return Some((k - 1, vec![self.path[k]]));
|
||||
}
|
||||
let (s, e) = (self.selection.start, self.selection.end);
|
||||
let kids: Vec<&Node> = (self.path[k].children().map(Vec::as_slice))
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|kid| span(kid).start <= e && s <= span(kid).end)
|
||||
.collect();
|
||||
(!kids.is_empty()).then_some((k, kids))
|
||||
}
|
||||
|
||||
/// Whether a paragraph ending on the line before joins `block`.
|
||||
pub(super) fn joins_before(&self, block: &Range<usize>, gone: &[&Node]) -> bool {
|
||||
let line = line_start(self.text, block.start);
|
||||
let Some(previous) = prev_line(self.text, line) else {
|
||||
return false;
|
||||
};
|
||||
let ended = (self.nodes.iter()).any(|node| {
|
||||
matches!(node, Node::Paragraph(_)) && line_start(self.text, span(node).end) == previous
|
||||
});
|
||||
ended && !self.opens(line, gone)
|
||||
}
|
||||
|
||||
/// Whether the line after `block` would join it as text.
|
||||
pub(super) fn joins_after(&self, block: &Range<usize>) -> bool {
|
||||
let Some(next) = next_line(self.text, block.end) else {
|
||||
return false;
|
||||
};
|
||||
let starts = (self.nodes.iter()).any(|node| {
|
||||
let at = span(node).start;
|
||||
line_start(self.text, at) == next
|
||||
&& match node {
|
||||
Node::Paragraph(_) => true,
|
||||
Node::ThematicBreak(_) => self.text[at..].starts_with('-'),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
starts && !self.opens(next, &[])
|
||||
}
|
||||
|
||||
/// Whether `line` opens a container that stays and interrupts.
|
||||
fn opens(&self, line: usize, gone: &[&Node]) -> bool {
|
||||
let on = |node: &Node| line_start(self.text, span(node).start) == line;
|
||||
let left = |node: &Node| !gone.iter().any(|gone| std::ptr::eq(*gone, node));
|
||||
(self.nodes.iter()).any(|node| match node {
|
||||
Node::List(list) if left(node) => list
|
||||
.children
|
||||
.iter()
|
||||
.any(|item| on(item) && (!list.ordered || list.start == Some(1))),
|
||||
Node::Blockquote(_) => on(node) && left(node),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Blockquotes, list items and footnote definitions.
|
||||
pub(super) fn container(node: &Node) -> bool {
|
||||
matches!(
|
||||
node,
|
||||
Node::Blockquote(_) | Node::ListItem(_) | Node::FootnoteDefinition(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `node` is task item `holder`'s first text.
|
||||
pub(super) fn task_head(holder: &Node, node: &Node) -> bool {
|
||||
matches!(holder, Node::ListItem(item)
|
||||
if item.checked.is_some()
|
||||
&& item.children.first().is_some_and(|kid| std::ptr::eq(kid, node)))
|
||||
}
|
||||
|
||||
/// Where a heading-closing `#` run starts in `line`.
|
||||
pub(super) fn closing(line: &str) -> Option<usize> {
|
||||
let line = line.trim_end_matches([' ', '\t']);
|
||||
let kept = line.trim_end_matches('#');
|
||||
(kept.len() < line.len() && kept.ends_with([' ', '\t'])).then_some(kept.len())
|
||||
}
|
||||
|
||||
/// The byte to escape so `line` reads as prose.
|
||||
pub(super) fn unblocked(line: &str) -> Result<Option<usize>, EditRefusal> {
|
||||
let prose = |line: &str| {
|
||||
let root = file_tree(line);
|
||||
matches!(
|
||||
root.children().and_then(|kids| kids.first()),
|
||||
Some(Node::Paragraph(_))
|
||||
)
|
||||
};
|
||||
if prose(line) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mark = line.find(|ch: char| ch.is_ascii_punctuation());
|
||||
match mark.filter(|mark| prose(&format!("{}\\{}", &line[..*mark], &line[*mark..]))) {
|
||||
Some(mark) => Ok(Some(mark)),
|
||||
None => Err(check::misread("a line stays a block")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the path goes on below `node`.
|
||||
fn holds(node: &Node) -> bool {
|
||||
container(node) || matches!(node, Node::Root(_) | Node::List(_))
|
||||
}
|
||||
|
||||
/// Root down to the deepest block at `at`.
|
||||
fn path<'a>(text: &str, root: &'a Node, at: usize) -> Vec<&'a Node> {
|
||||
let mut path = vec![root];
|
||||
while let Some(node) = path.last().copied().filter(|node| holds(node)) {
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let found = (kids.iter())
|
||||
.find(|kid| span(kid).contains(&at) || span(kid).end == at)
|
||||
.or_else(|| {
|
||||
// A caret before a block on its line is inside.
|
||||
(kids.iter()).find(|kid| {
|
||||
let start = span(kid).start;
|
||||
at < start && !text[at..start].contains(['\n', '\r'])
|
||||
})
|
||||
});
|
||||
let Some(kid) = found else {
|
||||
break;
|
||||
};
|
||||
path.push(kid);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Whether `at` falls between a CR and its LF.
|
||||
fn splits(text: &str, at: usize) -> bool {
|
||||
text[..at].ends_with('\r') && text[at..].starts_with('\n')
|
||||
}
|
||||
|
||||
/// `at`, before the CR when it splits a CRLF.
|
||||
pub(super) fn settled(text: &str, at: usize) -> usize {
|
||||
if splits(text, at) { at - 1 } else { at }
|
||||
}
|
||||
|
||||
pub(super) fn line_start(text: &str, at: usize) -> usize {
|
||||
debug_assert!(!splits(text, at), "line_start inside a CRLF");
|
||||
text[..at].rfind(['\n', '\r']).map_or(0, |at| at + 1)
|
||||
}
|
||||
|
||||
pub(super) fn line_end(text: &str, at: usize) -> usize {
|
||||
debug_assert!(!splits(text, at), "line_end inside a CRLF");
|
||||
at + text[at..].find(['\n', '\r']).unwrap_or(text.len() - at)
|
||||
}
|
||||
|
||||
/// The start of the line after `at`'s.
|
||||
pub(super) fn next_line(text: &str, at: usize) -> Option<usize> {
|
||||
let end = line_end(text, at);
|
||||
let rest = &text[end..];
|
||||
let ending = if rest.starts_with("\r\n") { 2 } else { 1 };
|
||||
(end < text.len()).then_some(end + ending)
|
||||
}
|
||||
|
||||
/// The start of the line before `at`'s.
|
||||
pub(super) fn prev_line(text: &str, at: usize) -> Option<usize> {
|
||||
let start = line_start(text, at);
|
||||
let ending = if text[..start].ends_with("\r\n") {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(start > 0).then(|| line_start(text, start - ending))
|
||||
}
|
||||
|
||||
/// The text's own line ending.
|
||||
pub(super) fn eol(text: &str) -> &'static str {
|
||||
match (text.contains("\r\n"), text.contains('\r')) {
|
||||
(true, _) => "\r\n",
|
||||
(false, true) => "\r",
|
||||
_ => "\n",
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns before `at` on its line, tabs stopping at four.
|
||||
pub(super) fn column(text: &str, at: usize) -> usize {
|
||||
advance(0, &text[line_start(text, at)..at])
|
||||
}
|
||||
|
||||
fn advance(col: usize, run: &str) -> usize {
|
||||
run.chars().fold(col, |col, ch| match ch {
|
||||
'\t' => col / 4 * 4 + 4,
|
||||
_ => col + 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// A list marker's or footnote label's bytes, then its spaces.
|
||||
pub(super) fn marker(text: &str, node: &Node) -> (usize, usize) {
|
||||
let start = span(node).start;
|
||||
let line = &text[start..line_end(text, start)];
|
||||
let mark = match node {
|
||||
Node::FootnoteDefinition(_) => line.find("]:").map_or(line.len(), |at| at + 2),
|
||||
_ if line.starts_with(['-', '*', '+']) => 1,
|
||||
_ => line.find(['.', ')']).map_or(line.len(), |at| at + 1),
|
||||
};
|
||||
let after = &line[mark..];
|
||||
let spaces = after.len() - after.trim_start_matches([' ', '\t']).len();
|
||||
(mark, spaces)
|
||||
}
|
||||
|
||||
/// The column a container's continuation lines start at.
|
||||
pub(super) fn indent(text: &str, node: &Node) -> usize {
|
||||
let start = span(node).start;
|
||||
let base = column(text, start);
|
||||
let (mark, spaces) = marker(text, node);
|
||||
if matches!(node, Node::FootnoteDefinition(_)) {
|
||||
return base + 4;
|
||||
}
|
||||
let after = advance(base + mark, &text[start + mark..start + mark + spaces]) - base - mark;
|
||||
let blank = text[start + mark..line_end(text, start)].trim().is_empty();
|
||||
match blank || !(1..=4).contains(&after) {
|
||||
true => base + mark + 1,
|
||||
false => base + mark + after,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a container's text starts on its first line.
|
||||
pub(super) fn opener(text: &str, node: &Node) -> usize {
|
||||
let start = span(node).start;
|
||||
let (mark, spaces) = marker(text, node);
|
||||
let width = advance(
|
||||
column(text, start + mark),
|
||||
&text[start + mark..start + mark + spaces],
|
||||
) - column(text, start + mark);
|
||||
match (1..=4).contains(&width) {
|
||||
true => start + mark + spaces,
|
||||
false => start + mark + spaces.min(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `line`'s text starts inside `containers`; none when lazy.
|
||||
pub(super) fn inside(text: &str, line: usize, containers: &[&Node]) -> Option<usize> {
|
||||
let end = line_end(text, line);
|
||||
let mut at = line;
|
||||
for node in containers {
|
||||
if text[at..end].trim().is_empty() {
|
||||
return Some(end);
|
||||
}
|
||||
if matches!(node, Node::Blockquote(_)) {
|
||||
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||
if lead > 3 || !text[at + lead..end].starts_with('>') {
|
||||
return None;
|
||||
}
|
||||
at += lead + 1;
|
||||
at += usize::from(text[at..end].starts_with([' ', '\t']));
|
||||
continue;
|
||||
}
|
||||
let start = span(node).start;
|
||||
if line <= start && start < end {
|
||||
at = opener(text, node);
|
||||
continue;
|
||||
}
|
||||
let want = indent(text, node);
|
||||
let mut col = column(text, at);
|
||||
while col < want {
|
||||
let ch = text[at..end]
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|ch| matches!(ch, ' ' | '\t'))?;
|
||||
col = advance(col, &text[at..at + 1]);
|
||||
at += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// The prefix a new line takes inside `containers`.
|
||||
pub(super) fn prefix(text: &str, containers: &[&Node]) -> String {
|
||||
let mut out = String::new();
|
||||
for (ix, node) in containers.iter().enumerate() {
|
||||
if matches!(node, Node::Blockquote(_)) {
|
||||
out.push_str("> ");
|
||||
continue;
|
||||
}
|
||||
let start = span(node).start;
|
||||
let outer = inside(text, line_start(text, start), &containers[..ix]).unwrap_or(start);
|
||||
let width = indent(text, node).saturating_sub(column(text, outer.min(start)));
|
||||
out.push_str(&" ".repeat(width));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where byte `at` lies once `edits`, sorted, are written.
|
||||
pub(super) fn shifted(at: usize, edits: &[(Range<usize>, String)]) -> usize {
|
||||
let mut moved = at;
|
||||
for (range, with) in edits {
|
||||
if range.end <= at {
|
||||
moved = moved + with.len() - range.len();
|
||||
} else if range.start < at {
|
||||
moved -= at - range.start;
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// `edits`, sorted, and the caret mapped through them.
|
||||
pub(super) fn change(mut edits: Vec<(Range<usize>, String)>, caret: Range<usize>) -> Change {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
assert!(
|
||||
edits
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].0.end <= pair[1].0.start),
|
||||
"block edits overlap"
|
||||
);
|
||||
let caret = shifted(caret.start, &edits)..shifted(caret.end, &edits);
|
||||
(edits, caret)
|
||||
}
|
||||
|
||||
/// `edits`, sorted, with the caret already where it lands.
|
||||
pub(super) fn placed(mut edits: Vec<(Range<usize>, String)>, caret: usize) -> Change {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
assert!(
|
||||
edits
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].0.end <= pair[1].0.start),
|
||||
"block edits overlap"
|
||||
);
|
||||
(edits, caret..caret)
|
||||
}
|
||||
|
||||
/// Why `node` takes no block command at the caret.
|
||||
pub(super) fn refusal(node: &Node) -> EditRefusal {
|
||||
match node {
|
||||
Node::Code(_) => EditRefusal::Code,
|
||||
Node::Math(_) => EditRefusal::Math,
|
||||
Node::Html(_) => EditRefusal::Html,
|
||||
Node::Table(_) => EditRefusal::Table,
|
||||
Node::Yaml(_) | Node::Toml(_) => EditRefusal::FrontMatter,
|
||||
_ => EditRefusal::Outside,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! A command's result read back: its text and formats kept.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use crate::documents::{
|
||||
MarkdownReader, Style,
|
||||
document::format::EditRefusal,
|
||||
markdown::{file_tree, span, walk},
|
||||
};
|
||||
|
||||
const KEPT: u16 = bit(Style::Code) | bit(Style::Math);
|
||||
const CODE: u16 = 1 << 9;
|
||||
const MATH: u16 = 1 << 10;
|
||||
const HTML: u16 = 1 << 11;
|
||||
|
||||
/// Refuses a result that reads as something else.
|
||||
pub(super) fn misread(why: &str) -> EditRefusal {
|
||||
log::warn!("block command: {why}");
|
||||
EditRefusal::Misread
|
||||
}
|
||||
|
||||
/// `text` with sorted `edits` written, and its parse.
|
||||
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> (String, Node) {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut at = 0;
|
||||
for (range, with) in edits {
|
||||
out.push_str(&text[at..range.start]);
|
||||
out.push_str(with);
|
||||
at = range.end;
|
||||
}
|
||||
out.push_str(&text[at..]);
|
||||
let root = file_tree(&out);
|
||||
(out, root)
|
||||
}
|
||||
|
||||
const fn bit(style: Style) -> u16 {
|
||||
1 << style as u16
|
||||
}
|
||||
|
||||
/// A parsed document read as the drawing reads it.
|
||||
pub(super) struct Shown<'a> {
|
||||
root: &'a Node,
|
||||
reader: MarkdownReader<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Shown<'a> {
|
||||
pub(super) fn new(source: &'a str, root: &'a Node) -> Self {
|
||||
let reader = MarkdownReader::new(source, root);
|
||||
Self { root, reader }
|
||||
}
|
||||
|
||||
/// Each shown character, with its formats.
|
||||
pub(super) fn all(&self) -> Vec<(char, u16)> {
|
||||
let mut out = Vec::new();
|
||||
self.read(self.root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn read(&self, node: &Node, out: &mut Vec<(char, u16)>) {
|
||||
let (value, format) = match node {
|
||||
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_) => {
|
||||
return out.extend(self.inline(node));
|
||||
}
|
||||
Node::Code(code) => (code.value.as_str(), CODE),
|
||||
Node::Math(math) => (math.value.as_str(), MATH),
|
||||
Node::Html(html) => (html.value.as_str(), HTML),
|
||||
_ => {
|
||||
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
self.read(kid, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
out.extend(value.chars().map(|ch| (ch, format)));
|
||||
}
|
||||
|
||||
/// `node`'s drawn text; white space counts only in code.
|
||||
pub(super) fn inline(&self, node: &Node) -> Vec<(char, u16)> {
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let inline = self.reader.inline(kids);
|
||||
let mut formats = vec![0u16; inline.text.len()];
|
||||
for (range, style) in &inline.styles {
|
||||
for format in &mut formats[range.clone()] {
|
||||
*format |= bit(*style);
|
||||
}
|
||||
}
|
||||
(inline.text.char_indices())
|
||||
.map(|(at, ch)| (ch, formats[at]))
|
||||
.filter(|(ch, format)| !ch.is_whitespace() || format & KEPT != 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The level (0 a paragraph) and characters at `at`.
|
||||
pub(super) fn at(&self, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
|
||||
let mut nodes = Vec::new();
|
||||
walk(self.root, &mut nodes);
|
||||
nodes.into_iter().find_map(|node| {
|
||||
let level = match node {
|
||||
Node::Paragraph(_) => 0,
|
||||
Node::Heading(heading) => heading.depth,
|
||||
_ => return None,
|
||||
};
|
||||
let whole = span(node);
|
||||
(whole.start <= at && at <= whole.end).then(|| (level, self.inline(node)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Heading levels and paragraphs, as Typora's `changeBlock`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{
|
||||
BlockCommand, Here, change, check, closing, eol, insert, inside, line_end, line_start,
|
||||
next_line, prefix, prev_line, refusal, unblocked,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
/// The level `command` asks at `level`; 0 is a paragraph.
|
||||
fn target(level: u8, command: BlockCommand) -> u8 {
|
||||
match command {
|
||||
BlockCommand::Heading(n) => {
|
||||
assert!((1..=6).contains(&n), "a heading level is 1 to 6");
|
||||
n
|
||||
}
|
||||
BlockCommand::Promote if level == 0 => 6,
|
||||
BlockCommand::Promote => (level - 1).max(1),
|
||||
BlockCommand::Demote if level == 0 || level == 6 => 0,
|
||||
BlockCommand::Demote => level + 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let caret = here.selection.clone();
|
||||
let leaf = here.leaf();
|
||||
let level = match leaf {
|
||||
Some(Node::Paragraph(_)) => 0,
|
||||
Some(Node::Heading(heading)) => heading.depth,
|
||||
Some(node @ (Node::Math(_) | Node::Html(_) | Node::Table(_)))
|
||||
if !matches!(command, BlockCommand::Promote | BlockCommand::Demote) =>
|
||||
{
|
||||
// Typora gives these an empty paragraph after.
|
||||
log::info!("block command: {:?} takes a paragraph after", refusal(node));
|
||||
return insert::paragraph(here, false);
|
||||
}
|
||||
Some(node) => return Err(refusal(node)),
|
||||
None if here.blank() => 0,
|
||||
None => return Err(EditRefusal::Outside),
|
||||
};
|
||||
let goal = target(level, command);
|
||||
if goal == level {
|
||||
return Ok((Vec::new(), caret));
|
||||
}
|
||||
let containers = here.containers(here.path.len());
|
||||
let hashes = "#".repeat(usize::from(goal)) + " ";
|
||||
let Some(node) = leaf else {
|
||||
let line = line_start(text, caret.start);
|
||||
let at = inside(text, line, &containers).expect("a blank line holds its prefix");
|
||||
return Ok(change(vec![(at..at, hashes)], caret));
|
||||
};
|
||||
let whole = span(node);
|
||||
let edits = match (level, goal) {
|
||||
(0, _) if task_head(here, &whole) => return Err(EditRefusal::Outside),
|
||||
(0, _) => to_heading(here, &containers, &whole, hashes),
|
||||
(_, 0) => to_paragraph(here, node, &containers)?,
|
||||
_ => relevel(here, node, &containers, goal),
|
||||
};
|
||||
let change = change(edits, caret);
|
||||
// Every shown character stays; the caret's block is `goal`.
|
||||
let (written, after) = check::reread(text, &change.0);
|
||||
let before = check::Shown::new(text, here.root);
|
||||
let now = check::Shown::new(&written, &after);
|
||||
let placed = match now.at(change.1.start) {
|
||||
Some((level, _)) => level == goal,
|
||||
// An empty paragraph has no node.
|
||||
None => goal == 0 && before.inline(node).is_empty(),
|
||||
};
|
||||
let kept = placed && now.all() == before.all();
|
||||
match kept {
|
||||
true => Ok(change),
|
||||
false => Err(check::misread("a heading change would reread otherwise")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `node` is an ATX heading, `#` led.
|
||||
fn atx(text: &str, node: &Node) -> bool {
|
||||
text[span(node)].trim_start().starts_with('#')
|
||||
}
|
||||
|
||||
/// Whether the caret is on a task item's first line.
|
||||
fn task_head(here: &Here, whole: &Range<usize>) -> bool {
|
||||
let first = line_start(here.text, whole.start) == line_start(here.text, here.selection.start);
|
||||
let leaf = here.path[here.path.len() - 1];
|
||||
first && super::task_head(here.path[here.path.len() - 2], leaf)
|
||||
}
|
||||
|
||||
/// The caret's line led by `hashes`; lazy lines kept in.
|
||||
fn to_heading(
|
||||
here: &Here,
|
||||
containers: &[&Node],
|
||||
whole: &Range<usize>,
|
||||
hashes: String,
|
||||
) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
let lead = prefix(text, containers);
|
||||
let first = line_start(text, here.selection.start);
|
||||
let mut edits = Vec::new();
|
||||
let mut line = first;
|
||||
loop {
|
||||
let at = match line_start(text, whole.start) == line {
|
||||
true => Some(whole.start),
|
||||
false => inside(text, line, containers),
|
||||
};
|
||||
let content = |from: usize| {
|
||||
from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len()
|
||||
};
|
||||
let (at, lazy) = match at {
|
||||
Some(at) => (content(at), ""),
|
||||
None => (content(line), lead.as_str()),
|
||||
};
|
||||
if line == first {
|
||||
edits.push((at..at, format!("{lazy}{hashes}")));
|
||||
if let Some(run) = closing(&text[at..line_end(text, at)]) {
|
||||
edits.push((at + run..at + run, "\\".to_string()));
|
||||
}
|
||||
} else if !lazy.is_empty() {
|
||||
edits.push((at..at, lazy.to_string()));
|
||||
}
|
||||
match next_line(text, line) {
|
||||
Some(next) if next < whole.end => line = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
/// A heading's marks off; a blank line where text joins.
|
||||
fn to_paragraph(
|
||||
here: &Here,
|
||||
node: &Node,
|
||||
containers: &[&Node],
|
||||
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text));
|
||||
let mut edits = Vec::new();
|
||||
if here.joins_before(&whole, &[]) {
|
||||
let line = line_start(text, whole.start);
|
||||
edits.push((line..line, blank.clone()));
|
||||
}
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
match atx(text, node) {
|
||||
true => match (kids.first(), kids.last()) {
|
||||
(Some(first), Some(last)) => {
|
||||
let start = span(first).start;
|
||||
edits.push((whole.start..start, String::new()));
|
||||
// The body kept: closing hashes go.
|
||||
let end = line_end(text, start).min(span(last).end);
|
||||
if let Some(mark) = unblocked(&text[start..end])? {
|
||||
edits.push((start + mark..start + mark, "\\".to_string()));
|
||||
}
|
||||
edits.push((span(last).end..whole.end, String::new()));
|
||||
}
|
||||
_ => edits.push((whole.clone(), String::new())),
|
||||
},
|
||||
false => {
|
||||
// The underline line goes with the break before it.
|
||||
let under = line_start(text, whole.end);
|
||||
let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len();
|
||||
edits.push((under - ending.min(2)..whole.end, String::new()));
|
||||
}
|
||||
}
|
||||
if here.joins_after(&whole) {
|
||||
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||
edits.push((next..next, blank.clone()));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// A heading moved to level `goal`.
|
||||
fn relevel(
|
||||
here: &Here,
|
||||
node: &Node,
|
||||
containers: &[&Node],
|
||||
goal: u8,
|
||||
) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
if atx(text, node) {
|
||||
let at = whole.start + text[whole.clone()].find('#').expect("an ATX heading has #");
|
||||
let run = text[at..].len() - text[at..].trim_start_matches('#').len();
|
||||
return vec![(at..at + run, "#".repeat(usize::from(goal)))];
|
||||
}
|
||||
let under = line_start(text, whole.end);
|
||||
let marks = &text[under..whole.end];
|
||||
let at = under + marks.find(['=', '-']).expect("a setext underline");
|
||||
let run = marks.trim_end().len() - (at - under);
|
||||
if goal <= 2 {
|
||||
let mark = if goal == 1 { "=" } else { "-" };
|
||||
return vec![(at..at + run, mark.repeat(run))];
|
||||
}
|
||||
// Levels past 2 are ATX: one line, the underline gone.
|
||||
let mut edits = vec![(
|
||||
whole.start..whole.start,
|
||||
"#".repeat(usize::from(goal)) + " ",
|
||||
)];
|
||||
let mut line = line_start(text, whole.start);
|
||||
while let Some(next) = next_line(text, line).filter(|next| *next < under) {
|
||||
let end = line_end(text, line);
|
||||
let from = inside(text, next, containers).unwrap_or(next);
|
||||
let from = from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len();
|
||||
edits.push((end..from, " ".to_string()));
|
||||
line = next;
|
||||
}
|
||||
let last = prev_line(text, under).expect("a setext heading has text");
|
||||
if let Some(run) = closing(&text[last..line_end(text, last)]) {
|
||||
edits.push((last + run..last + run, "\\".to_string()));
|
||||
}
|
||||
let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len();
|
||||
edits.push((under - ending.min(2)..whole.end, String::new()));
|
||||
edits
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Blocks put in and taken out, as Typora's `insertBlock`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{
|
||||
BlockCommand, Here, container, eol, inside, line_end, line_start, next_line, placed, prefix,
|
||||
prev_line, refusal,
|
||||
split::{Room, content, split},
|
||||
task_head,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
/// Whether `line` holds text past `containers`' prefix.
|
||||
fn text_on(text: &str, line: usize, containers: &[&Node]) -> bool {
|
||||
let at = content(text, line, containers);
|
||||
at < line_end(text, line)
|
||||
}
|
||||
|
||||
/// The selection's text, line by line, prefixes cut.
|
||||
fn picked(here: &Here, containers: &[&Node]) -> Vec<String> {
|
||||
let text = here.text;
|
||||
let (s, e) = (here.selection.start, here.selection.end);
|
||||
let mut out = Vec::new();
|
||||
let mut at = s;
|
||||
while at < e {
|
||||
out.push(text[at..line_end(text, at).min(e)].to_string());
|
||||
match next_line(text, at).filter(|next| *next < e) {
|
||||
Some(next) => at = inside(text, next, containers).unwrap_or(next).min(e),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest footnote number not taken.
|
||||
fn free_note(here: &Here) -> usize {
|
||||
let used: HashSet<&str> = (here.nodes.iter())
|
||||
.filter_map(|node| match node {
|
||||
Node::FootnoteDefinition(note) => Some(note.identifier.as_str()),
|
||||
Node::FootnoteReference(note) => Some(note.identifier.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(1..)
|
||||
.find(|n: &usize| !used.contains(n.to_string().as_str()))
|
||||
.expect("a free number")
|
||||
}
|
||||
|
||||
/// `command`'s lines and the caret's line and byte.
|
||||
fn lines(
|
||||
here: &Here,
|
||||
command: BlockCommand,
|
||||
body: Vec<String>,
|
||||
) -> (Vec<String>, Option<(usize, usize)>) {
|
||||
let fenced = |fence: String, body: Vec<String>| {
|
||||
let mut lines = vec![fence.clone()];
|
||||
lines.extend(if body.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
body
|
||||
});
|
||||
lines.push(fence);
|
||||
(lines, Some((1, 0)))
|
||||
};
|
||||
match command {
|
||||
BlockCommand::CodeBlock => {
|
||||
let longest = (body.iter())
|
||||
.flat_map(|line| line.split(|ch| ch != '`'))
|
||||
.map(str::len)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
fenced("`".repeat(longest.max(2) + 1), body)
|
||||
}
|
||||
BlockCommand::MathBlock => fenced("$$".to_string(), body),
|
||||
BlockCommand::Toc => (vec!["[toc]".to_string()], Some((0, 5))),
|
||||
BlockCommand::Footnote => {
|
||||
let line = format!("[^{}]: ", free_note(here));
|
||||
let end = line.len();
|
||||
(vec![line], Some((0, end)))
|
||||
}
|
||||
BlockCommand::LinkDefinition => {
|
||||
let first = body.first().map_or("", |line| line.trim());
|
||||
if first.contains(['/', '\\']) {
|
||||
let address: String = first.chars().take(160).collect();
|
||||
return (vec![format!("[]: {address}")], Some((0, 1)));
|
||||
}
|
||||
let label: String = (first.split_whitespace().next().unwrap_or_default())
|
||||
.chars()
|
||||
.take(20)
|
||||
.collect();
|
||||
let line = format!("[{label}]: ");
|
||||
let caret = if label.is_empty() { 1 } else { line.len() };
|
||||
(vec![line], Some((0, caret)))
|
||||
}
|
||||
_ => (vec!["---".to_string()], None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `lines` joined inside `lead`, and the caret's byte in them.
|
||||
fn joined(
|
||||
lines: &[String],
|
||||
caret: Option<(usize, usize)>,
|
||||
nl: &str,
|
||||
lead: &str,
|
||||
) -> (String, Option<usize>) {
|
||||
let mut out = String::new();
|
||||
let mut at = None;
|
||||
for (ix, line) in lines.iter().enumerate() {
|
||||
if ix > 0 {
|
||||
out.push_str(nl);
|
||||
out.push_str(lead);
|
||||
}
|
||||
if let Some((row, byte)) = caret
|
||||
&& row == ix
|
||||
{
|
||||
at = Some(out.len() + byte);
|
||||
}
|
||||
out.push_str(line);
|
||||
}
|
||||
(out, at)
|
||||
}
|
||||
|
||||
/// Whether a paragraph or ATX heading splits at the caret.
|
||||
fn splits(text: &str, node: &Node) -> bool {
|
||||
match node {
|
||||
Node::Paragraph(_) => true,
|
||||
Node::Heading(_) => text[span(node)].trim_start().starts_with('#'),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let leaf = here.leaf();
|
||||
let containers = here.containers(here.path.len() - usize::from(leaf.is_some()));
|
||||
let lead = prefix(text, &containers);
|
||||
let room = Room {
|
||||
nl: eol(text),
|
||||
blank: lead.trim_end().to_string(),
|
||||
lead,
|
||||
containers,
|
||||
};
|
||||
let (lines, caret) = lines(here, command, picked(here, &room.containers));
|
||||
let (block, inner) = joined(&lines, caret, room.nl, &room.lead);
|
||||
let rule = inner.is_none();
|
||||
match leaf {
|
||||
Some(node) if splits(text, node) => {
|
||||
if here.selection.end > span(node).end {
|
||||
return Err(EditRefusal::Across);
|
||||
}
|
||||
split(here, node, &room, &block, inner)
|
||||
}
|
||||
Some(node) if !here.selection.is_empty() => Err(refusal(node)),
|
||||
Some(node) => {
|
||||
let at = line_end(text, span(node).end);
|
||||
let next =
|
||||
next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers));
|
||||
let gap = room.gap();
|
||||
let tail = match rule {
|
||||
true => gap.clone(),
|
||||
false => String::new(),
|
||||
};
|
||||
let sep = if next {
|
||||
format!("{}{}", room.nl, room.blank)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let caret = at + gap.len() + inner.unwrap_or(block.len() + tail.len());
|
||||
Ok(placed(
|
||||
vec![(at..at, format!("{gap}{block}{tail}{sep}"))],
|
||||
caret,
|
||||
))
|
||||
}
|
||||
None if here.blank() && here.selection.is_empty() => {
|
||||
let line = line_start(text, here.selection.start);
|
||||
let at = inside(text, line, &room.containers).expect("a blank line holds its prefix");
|
||||
let end = line_end(text, line);
|
||||
let prev =
|
||||
prev_line(text, line).is_some_and(|prev| text_on(text, prev, &room.containers));
|
||||
let next =
|
||||
next_line(text, line).is_some_and(|next| text_on(text, next, &room.containers));
|
||||
let head = if prev {
|
||||
format!("{}{}", room.nl, room.lead)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let tail = if rule { room.gap() } else { String::new() };
|
||||
let sep = if next {
|
||||
format!("{}{}", room.nl, room.blank)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let caret = at + head.len() + inner.unwrap_or(block.len() + tail.len());
|
||||
Ok(placed(
|
||||
vec![(at..end, format!("{head}{block}{tail}{sep}"))],
|
||||
caret,
|
||||
))
|
||||
}
|
||||
None => Err(EditRefusal::Outside),
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty line before or after the caret's block.
|
||||
pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let leaf = here.leaf().ok_or(EditRefusal::Outside)?;
|
||||
let whole = span(leaf);
|
||||
let containers = here.containers(here.path.len() - 1);
|
||||
let lead = prefix(text, &containers);
|
||||
let room = Room {
|
||||
nl: eol(text),
|
||||
blank: lead.trim_end().to_string(),
|
||||
lead,
|
||||
containers,
|
||||
};
|
||||
let gap = room.gap();
|
||||
let holder = here.path[here.path.len() - 2];
|
||||
if before && task_head(holder, leaf) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
// A holder's first line keeps its opener: text moves down.
|
||||
if before
|
||||
&& container(holder)
|
||||
&& !matches!(holder, Node::Blockquote(_))
|
||||
&& line_start(text, span(holder).start) == line_start(text, whole.start)
|
||||
{
|
||||
let down = format!("{}{}", room.nl, room.lead);
|
||||
return Ok(placed(vec![(whole.start..whole.start, down)], whole.start));
|
||||
}
|
||||
if before {
|
||||
let mut edits = Vec::new();
|
||||
let mut shift = 0;
|
||||
if here.joins_before(&whole, &[]) {
|
||||
let line = line_start(text, whole.start);
|
||||
let blank = format!("{}{}", room.blank, room.nl);
|
||||
shift = blank.len();
|
||||
edits.push((line..line, blank));
|
||||
}
|
||||
edits.push((whole.start..whole.start, gap));
|
||||
return Ok(placed(edits, whole.start + shift));
|
||||
}
|
||||
let at = line_end(text, whole.end);
|
||||
let next = next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers));
|
||||
let sep = if next {
|
||||
format!("{}{}", room.nl, room.blank)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(placed(
|
||||
vec![(at..at, format!("{gap}{sep}"))],
|
||||
at + gap.len(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Front matter at the top, unless there is some.
|
||||
pub(super) fn front_matter(here: &Here) -> Result<Change, EditRefusal> {
|
||||
let first = here.root.children().and_then(|kids| kids.first());
|
||||
if matches!(first, Some(Node::Yaml(_) | Node::Toml(_))) {
|
||||
return Err(EditRefusal::Present);
|
||||
}
|
||||
let nl = eol(here.text);
|
||||
let tail = match here.text.is_empty() || here.text.starts_with(['\n', '\r']) {
|
||||
true => "",
|
||||
false => nl,
|
||||
};
|
||||
Ok(placed(
|
||||
vec![(0..0, format!("---{nl}{nl}---{nl}{tail}"))],
|
||||
3 + nl.len(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The caret's block gone; a lone paragraph takes its holder.
|
||||
pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let leaf = here.leaf().ok_or(EditRefusal::Outside)?;
|
||||
let mut ix = here.path.len() - 1;
|
||||
if matches!(leaf, Node::Paragraph(_))
|
||||
&& let Some(parent) = here.path.get(ix - 1)
|
||||
&& container(parent)
|
||||
&& parent.children().is_some_and(|kids| kids.len() == 1)
|
||||
{
|
||||
ix -= 1;
|
||||
}
|
||||
let whole = span(here.path[ix]);
|
||||
let line = line_start(text, whole.start);
|
||||
if !text[line..whole.start]
|
||||
.chars()
|
||||
.all(|ch| matches!(ch, ' ' | '\t' | '>'))
|
||||
{
|
||||
return Ok(placed(vec![(whole.clone(), String::new())], whole.start));
|
||||
}
|
||||
// Containers wholly on the lines go too.
|
||||
let last = line_end(text, whole.end);
|
||||
let top = (1..=ix)
|
||||
.find(|j| {
|
||||
let held = span(here.path[*j]);
|
||||
line <= held.start && held.end <= last
|
||||
})
|
||||
.expect("the block lies on its lines");
|
||||
let gone = &here.path[top..=ix];
|
||||
let outer = here.containers(top);
|
||||
let mut end = next_line(text, whole.end).unwrap_or(text.len());
|
||||
let blank_above = prev_line(text, line).is_none_or(|prev| !text_on(text, prev, &outer));
|
||||
if blank_above && end < text.len() && !text_on(text, end, &outer) {
|
||||
end = next_line(text, end).unwrap_or(text.len());
|
||||
}
|
||||
// Neighbors that would join keep a blank line between.
|
||||
let joins = here.joins_before(&whole, gone) && here.joins_after(&whole);
|
||||
let with = match joins {
|
||||
true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)),
|
||||
false => String::new(),
|
||||
};
|
||||
Ok(placed(vec![(line..end, with)], line))
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Lists on, off and retyped, as Typora's `toggleIndent`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::{Code, List, Node};
|
||||
|
||||
use super::{
|
||||
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
|
||||
opener, prefix, refusal, unblocked,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
fn kind(list: &List) -> BlockCommand {
|
||||
match (list.ordered, list.children.first()) {
|
||||
(true, _) => BlockCommand::OrderedList,
|
||||
(false, Some(Node::ListItem(item))) if item.checked.is_some() => BlockCommand::TaskList,
|
||||
_ => BlockCommand::BulletList,
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker an item takes: number `n` or the bullet.
|
||||
fn marker(kind: BlockCommand, n: u32, bullet: char) -> String {
|
||||
match kind {
|
||||
BlockCommand::OrderedList => format!("{n}. "),
|
||||
BlockCommand::TaskList => format!("{bullet} [ ] "),
|
||||
_ => format!("{bullet} "),
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker's width for continuation lines; boxes add none.
|
||||
fn width(kind: BlockCommand, n: u32) -> usize {
|
||||
match kind {
|
||||
BlockCommand::OrderedList => format!("{n}. ").len(),
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||
let caret = here.selection.clone();
|
||||
let path = &here.path;
|
||||
let item = match here.leaf() {
|
||||
Some(leaf) => path.len().checked_sub(2).filter(|ix| match path[*ix] {
|
||||
Node::ListItem(item) => {
|
||||
!caret.is_empty()
|
||||
|| item
|
||||
.children
|
||||
.first()
|
||||
.is_some_and(|kid| std::ptr::eq(kid, leaf))
|
||||
}
|
||||
_ => false,
|
||||
}),
|
||||
None => Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))),
|
||||
};
|
||||
// A selection past the list takes every touched sibling.
|
||||
if let Some(ix) = item
|
||||
&& let Node::List(list) = path[ix - 1]
|
||||
&& (here.path_at(caret.end).iter()).any(|node| std::ptr::eq(*node, path[ix - 1]))
|
||||
{
|
||||
let edits = match kind(list) == command {
|
||||
true => unwrap(here, ix - 1, list)?,
|
||||
false => {
|
||||
let start = if list.ordered {
|
||||
list.start.unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let outer = here.containers(ix - 1);
|
||||
retype(here.text, path[ix - 1], list, &outer, command, start)?
|
||||
}
|
||||
};
|
||||
return Ok(change(edits, caret));
|
||||
}
|
||||
if here.leaf().is_none() && caret.is_empty() {
|
||||
if !here.blank() {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
let line = line_start(here.text, caret.start);
|
||||
let at = inside(here.text, line, &here.containers(path.len()))
|
||||
.expect("a blank line holds its prefix");
|
||||
return Ok(change(vec![(at..at, marker(command, 1, '-'))], caret));
|
||||
}
|
||||
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
|
||||
Ok(change(wrap(here, k, &blocks, command)?, caret))
|
||||
}
|
||||
|
||||
/// Indent from `at` to column `want`: its end, columns past.
|
||||
fn indented(text: &str, at: usize, want: usize) -> Option<(usize, usize)> {
|
||||
let end = line_end(text, at);
|
||||
let mut cut = at;
|
||||
let mut col = column(text, at);
|
||||
while col < want {
|
||||
let ch = text[cut..end]
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|ch| matches!(ch, ' ' | '\t'))?;
|
||||
col = if ch == '\t' { col / 4 * 4 + 4 } else { col + 1 };
|
||||
cut += 1;
|
||||
}
|
||||
Some((cut, col - want))
|
||||
}
|
||||
|
||||
/// Each non-lazy line of `item` after its first, with indent.
|
||||
fn continued<'a>(
|
||||
text: &'a str,
|
||||
item: &Node,
|
||||
outer: &'a [&Node],
|
||||
) -> impl Iterator<Item = (usize, usize, usize)> + 'a {
|
||||
let whole = span(item);
|
||||
let want = indent(text, item);
|
||||
std::iter::successors(next_line(text, whole.start), move |line| {
|
||||
next_line(text, *line)
|
||||
})
|
||||
.take_while(move |line| *line < whole.end)
|
||||
.filter_map(move |line| {
|
||||
let at = inside(text, line, outer)?;
|
||||
if text[at..line_end(text, line)].trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (cut, past) = indented(text, at, want)?;
|
||||
Some((at, cut, past))
|
||||
})
|
||||
}
|
||||
|
||||
/// Every item's marker off, its lines out one level.
|
||||
fn unwrap(here: &Here, ix: usize, list: &List) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(ix);
|
||||
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
|
||||
let whole = span(here.path[ix]);
|
||||
let mut edits = Vec::new();
|
||||
for (n, item) in list.children.iter().enumerate() {
|
||||
let start = span(item).start;
|
||||
let line = line_start(text, start);
|
||||
let joins = match n {
|
||||
0 => here.joins_before(&whole, &[here.path[ix]]),
|
||||
_ => prev_text(text, line, &outer),
|
||||
};
|
||||
if joins {
|
||||
edits.push((line..line, blank.clone()));
|
||||
}
|
||||
let first = item.children().and_then(|kids| kids.first()).map(span);
|
||||
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||
match &first {
|
||||
Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => {
|
||||
edits.push((start..kid.start, String::new()));
|
||||
}
|
||||
Some(kid) => {
|
||||
let next = next_line(text, start).expect("an item's text lies below");
|
||||
edits.push((line..next, String::new()));
|
||||
if boxed {
|
||||
edits.push((unbox(text, kid.start), String::new()));
|
||||
}
|
||||
}
|
||||
None => edits.push((start..line_end(text, start), String::new())),
|
||||
}
|
||||
if boxed && let Some(kid) = first {
|
||||
edits.extend(escaped(text, kid.start)?);
|
||||
}
|
||||
for (at, cut, past) in continued(text, item, &outer) {
|
||||
edits.push((at..cut, " ".repeat(past)));
|
||||
}
|
||||
}
|
||||
if here.joins_after(&whole) {
|
||||
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||
edits.push((next..next, blank.clone()));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// The task box before the text at `kid`.
|
||||
fn unbox(text: &str, kid: usize) -> Range<usize> {
|
||||
let line = line_start(text, kid);
|
||||
line + text[line..kid].rfind('[').expect("a task's box")..kid
|
||||
}
|
||||
|
||||
/// A backslash where text at `at`, unboxed, opens a block.
|
||||
fn escaped(text: &str, at: usize) -> Result<Option<(Range<usize>, String)>, EditRefusal> {
|
||||
let mark = unblocked(&text[at..line_end(text, at)])?;
|
||||
Ok(mark.map(|mark| (at + mark..at + mark, "\\".to_string())))
|
||||
}
|
||||
|
||||
/// Whether `node`, holding `code`, is fenced in the source.
|
||||
fn fenced(text: &str, node: &Node, code: &Code) -> bool {
|
||||
code.lang.is_some()
|
||||
|| code.value.is_empty()
|
||||
|| text[span(node)].lines().count() > code.value.lines().count()
|
||||
}
|
||||
|
||||
/// Whether the line before `line` holds text inside `outer`.
|
||||
fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
|
||||
super::prev_line(text, line).is_some_and(|prev| {
|
||||
inside(text, prev, outer).is_none_or(|at| !text[at..line_end(text, prev)].trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// Each marker as `kind`'s from `start`; lines follow.
|
||||
fn retype(
|
||||
text: &str,
|
||||
node: &Node,
|
||||
list: &List,
|
||||
outer: &[&Node],
|
||||
kind: BlockCommand,
|
||||
start: u32,
|
||||
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let bullet = match list.ordered {
|
||||
true => '-',
|
||||
false => text[span(node).start..].chars().next().unwrap_or('-'),
|
||||
};
|
||||
let mut edits = Vec::new();
|
||||
let task = kind == BlockCommand::TaskList;
|
||||
for (n, item) in list.children.iter().enumerate() {
|
||||
let number = start + n as u32;
|
||||
let at = span(item).start;
|
||||
let (mark, spaces) = super::marker(text, item);
|
||||
let first = item.children().and_then(|kids| kids.first());
|
||||
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||
let body = at + mark + spaces;
|
||||
let blank = body == line_end(text, at);
|
||||
if boxed {
|
||||
// A box stays when the kind keeps boxes.
|
||||
let kind = if task { BlockCommand::BulletList } else { kind };
|
||||
let end = if blank { opener(text, item) } else { body };
|
||||
edits.push((at..end, marker(kind, number, bullet)));
|
||||
if !task {
|
||||
let kid = span(first.expect("a boxed item has text")).start;
|
||||
edits.push((unbox(text, kid), String::new()));
|
||||
edits.extend(escaped(text, kid)?);
|
||||
}
|
||||
} else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) {
|
||||
// GFM boxes only a paragraph.
|
||||
return Err(refusal(kid));
|
||||
} else if blank {
|
||||
edits.push((at..opener(text, item), marker(kind, number, bullet)));
|
||||
} else {
|
||||
// Indented code is written again from its value.
|
||||
let pad = match first {
|
||||
Some(kid @ Node::Code(code)) if !fenced(text, kid, code) => {
|
||||
let line = code.value.lines().next().unwrap_or_default();
|
||||
4 + line.len() - line.trim_start_matches(' ').len()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
edits.push((at..body, marker(kind, number, bullet) + &" ".repeat(pad)));
|
||||
}
|
||||
let want = column(text, at) + width(kind, number);
|
||||
for (from, cut, past) in continued(text, item, outer) {
|
||||
let pad = want.saturating_sub(column(text, from)) + past;
|
||||
edits.push((from..cut, " ".repeat(pad)));
|
||||
}
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// Each paragraph line, and each other block, made an item.
|
||||
fn wrap(
|
||||
here: &Here,
|
||||
k: usize,
|
||||
blocks: &[&Node],
|
||||
command: BlockCommand,
|
||||
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(k + 1);
|
||||
let lead = prefix(text, &outer);
|
||||
let mut edits = Vec::new();
|
||||
let mut n = 1;
|
||||
for block in blocks {
|
||||
if let Node::List(list) = block {
|
||||
if kind(list) != command {
|
||||
edits.extend(retype(text, block, list, &outer, command, n)?);
|
||||
}
|
||||
n += list.children.len() as u32;
|
||||
continue;
|
||||
}
|
||||
if command == BlockCommand::TaskList && !matches!(block, Node::Paragraph(_)) {
|
||||
return Err(refusal(block));
|
||||
}
|
||||
let whole = span(block);
|
||||
let mut line = line_start(text, whole.start);
|
||||
while line <= whole.end {
|
||||
let first = line == line_start(text, whole.start);
|
||||
let (at, lazy) = match first {
|
||||
true => (Some(whole.start), ""),
|
||||
false => match inside(text, line, &outer) {
|
||||
Some(at) => (Some(at), ""),
|
||||
None => (None, lead.as_str()),
|
||||
},
|
||||
};
|
||||
let at = at.unwrap_or(line);
|
||||
let content = at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len();
|
||||
if !text[at..line_end(text, line)].trim().is_empty() {
|
||||
let mark = match first || matches!(block, Node::Paragraph(_)) {
|
||||
true => {
|
||||
n += 1;
|
||||
marker(command, n - 1, '-')
|
||||
}
|
||||
false => " ".repeat(width(command, n - 1)),
|
||||
};
|
||||
let at = if matches!(block, Node::Paragraph(_)) {
|
||||
content
|
||||
} else {
|
||||
at
|
||||
};
|
||||
edits.push((at..at, format!("{lazy}{mark}")));
|
||||
}
|
||||
match next_line(text, line) {
|
||||
Some(next) => line = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Quotes on and off, as Typora's `toggleIndent`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{
|
||||
Here, change, check, eol, inside, line_end, line_start, next_line, prefix, split::content,
|
||||
task_head,
|
||||
};
|
||||
use crate::documents::{
|
||||
alert,
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::{file_tree, span},
|
||||
};
|
||||
|
||||
pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
|
||||
let caret = here.selection.clone();
|
||||
let ix = match here.leaf() {
|
||||
Some(_) => here.path.len() - 2,
|
||||
None => here.path.len() - 1,
|
||||
};
|
||||
if let Node::Blockquote(_) = here.path[ix]
|
||||
&& whole(here, ix)
|
||||
{
|
||||
return unwrap(here, ix).map(|edits| change(edits, caret));
|
||||
}
|
||||
if here.leaf().is_none() && here.selection.is_empty() {
|
||||
if !here.blank() {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
let line = line_start(here.text, caret.start);
|
||||
let at = inside(here.text, line, &here.containers(here.path.len()))
|
||||
.expect("a blank line holds its prefix");
|
||||
return Ok(change(vec![(at..at, "> ".to_string())], caret));
|
||||
}
|
||||
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
|
||||
// GFM reads a task's first line as text.
|
||||
if task_head(here.path[k], blocks[0]) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
Ok(change(wrap(here, k, &blocks), caret))
|
||||
}
|
||||
|
||||
/// Whether the selection takes in the whole quote at `ix`.
|
||||
fn whole(here: &Here, ix: usize) -> bool {
|
||||
if here.selection.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let quote = here.path[ix];
|
||||
let end = here.path_at(here.selection.end);
|
||||
let kids = quote.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let first = here.path.get(ix + 1).copied();
|
||||
let last = end.get(ix + 1).copied();
|
||||
end.get(ix).is_some_and(|node| std::ptr::eq(*node, quote))
|
||||
&& first.is_some_and(|first| kids.first().is_some_and(|kid| std::ptr::eq(kid, first)))
|
||||
&& last.is_some_and(|last| kids.last().is_some_and(|kid| std::ptr::eq(kid, last)))
|
||||
}
|
||||
|
||||
/// One `>` off each line of the quote at `ix`.
|
||||
fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let quote = here.path[ix];
|
||||
let whole = span(quote);
|
||||
let outer = here.containers(ix);
|
||||
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
|
||||
let mut edits = Vec::new();
|
||||
let mut line = line_start(text, whole.start);
|
||||
if here.joins_before(&whole, &[quote]) {
|
||||
edits.push((line..line, blank.clone()));
|
||||
}
|
||||
if let Node::Blockquote(inner) = quote
|
||||
&& alert(inner).is_some()
|
||||
{
|
||||
if !marker_only(&text[whole.start..line_end(text, line)]) {
|
||||
return Err(check::misread("an alert marker shares its line"));
|
||||
}
|
||||
// A body block keeps its indent; text sheds it.
|
||||
let continues = inner
|
||||
.children
|
||||
.first()
|
||||
.is_some_and(|head| span(head).end > line_end(text, line));
|
||||
// The marker goes with blank lines; an outer opener stays.
|
||||
let (mut cut, mut rest) = (whole.end, whole.end);
|
||||
let mut next = next_line(text, line);
|
||||
while let Some(at) = next.filter(|at| *at < whole.end) {
|
||||
let lead = match mark(text, at, &outer) {
|
||||
Some(mark) => mark.end,
|
||||
None => content(text, at, &outer),
|
||||
};
|
||||
let body =
|
||||
lead + text[lead..].len() - text[lead..].trim_start_matches([' ', '\t']).len();
|
||||
if body < line_end(text, at) {
|
||||
cut = if continues { body } else { lead };
|
||||
rest = next_line(text, at).unwrap_or(text.len());
|
||||
break;
|
||||
}
|
||||
next = next_line(text, at);
|
||||
}
|
||||
edits.push((whole.start..cut, String::new()));
|
||||
line = rest;
|
||||
}
|
||||
while line < whole.end {
|
||||
if let Some(mark) = mark(text, line, &outer) {
|
||||
edits.push((mark, String::new()));
|
||||
}
|
||||
match next_line(text, line) {
|
||||
Some(next) => line = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if here.joins_after(&whole) {
|
||||
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||
edits.push((next..next, blank.clone()));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// Whether `line`, a quote's first, holds only an alert marker.
|
||||
fn marker_only(line: &str) -> bool {
|
||||
let root = file_tree(&format!("{line}\n> x"));
|
||||
let Some(Node::Blockquote(quote)) = root.children().and_then(|kids| kids.first()) else {
|
||||
return false;
|
||||
};
|
||||
matches!(alert(quote), Some((_, body)) if matches!(&body[..],
|
||||
[Node::Paragraph(head)] if matches!(&head.children[..],
|
||||
[Node::Text(text)] if text.value == "x")))
|
||||
}
|
||||
|
||||
/// `line`'s `>` and one space after, inside `outer`.
|
||||
fn mark(text: &str, line: usize, outer: &[&Node]) -> Option<Range<usize>> {
|
||||
let at = inside(text, line, outer)?;
|
||||
let end = line_end(text, line);
|
||||
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||
let mark = at + lead;
|
||||
(lead <= 3 && text[mark..end].starts_with('>')).then(|| {
|
||||
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
|
||||
mark..mark + 1 + space
|
||||
})
|
||||
}
|
||||
|
||||
/// `> ` before each line of `blocks`, held by `path[k]`.
|
||||
fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range<usize>, String)> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(k + 1);
|
||||
let first = span(blocks[0]).start;
|
||||
let last = span(blocks[blocks.len() - 1]).end;
|
||||
let mut edits = Vec::new();
|
||||
let mut line = line_start(text, first);
|
||||
while line <= last {
|
||||
let at = match line == line_start(text, first) {
|
||||
true => Some(first),
|
||||
false => inside(text, line, &outer),
|
||||
};
|
||||
// A lazy line stays in its paragraph.
|
||||
if let Some(at) = at {
|
||||
let blank = text[at..line_end(text, line)].trim().is_empty();
|
||||
edits.push((at..at, if blank { ">" } else { "> " }.to_string()));
|
||||
}
|
||||
match next_line(text, line) {
|
||||
Some(next) => line = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
edits
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! A paragraph or heading split, as Typora's `splitAt`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{
|
||||
Here, check, closing, inside, line_end, line_start, next_line, placed, prev_line, shifted,
|
||||
task_head, unblocked,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal, marks},
|
||||
markdown::span,
|
||||
};
|
||||
|
||||
/// Where the new block's text goes, ending the edit.
|
||||
pub(super) struct Room<'a> {
|
||||
pub(super) nl: &'static str,
|
||||
pub(super) lead: String,
|
||||
pub(super) blank: String,
|
||||
pub(super) containers: Vec<&'a Node>,
|
||||
}
|
||||
|
||||
impl Room<'_> {
|
||||
/// A blank line, then a new line's prefix.
|
||||
pub(super) fn gap(&self) -> String {
|
||||
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `line`'s text starts inside `containers`; a lazy line's own.
|
||||
pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
|
||||
let at = inside(text, line, containers).unwrap_or(line);
|
||||
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
|
||||
}
|
||||
|
||||
/// Back from `at` over blanks and prefixes, to `floor`.
|
||||
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let line = line_start(text, at).max(floor);
|
||||
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
|
||||
if line == floor || at > content(text, line, containers) {
|
||||
return at;
|
||||
}
|
||||
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
|
||||
}
|
||||
}
|
||||
|
||||
/// On from `at` over blanks and prefixes, to `ceil`.
|
||||
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
|
||||
loop {
|
||||
let end = line_end(text, at).min(ceil);
|
||||
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
|
||||
if at < end {
|
||||
return at;
|
||||
}
|
||||
match next_line(text, at) {
|
||||
Some(next) if next < ceil => at = content(text, next, containers),
|
||||
_ => return ceil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline nodes with no text to cut.
|
||||
fn sealed(node: &Node) -> bool {
|
||||
matches!(
|
||||
node,
|
||||
Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// `at` off inline edges; marks to close, then reopen.
|
||||
fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> {
|
||||
let text = here.text;
|
||||
let marked: Vec<(&Node, [Range<usize>; 2])> = (here.nodes.iter())
|
||||
.filter_map(|node| Some((*node, marks(node, text)?)))
|
||||
.collect();
|
||||
while let Some([open, _]) = (marked.iter())
|
||||
.map(|(_, holes)| holes)
|
||||
.find(|[open, _]| open.start < at && open.end == at)
|
||||
{
|
||||
at = open.start;
|
||||
}
|
||||
while let Some([_, close]) = (marked.iter())
|
||||
.map(|(_, holes)| holes)
|
||||
.find(|[_, close]| close.start == at && at < close.end)
|
||||
{
|
||||
at = close.end;
|
||||
}
|
||||
let strictly = |range: Range<usize>| range.start < at && at < range.end;
|
||||
let mut cut: Vec<&(&Node, [Range<usize>; 2])> = (marked.iter())
|
||||
.filter(|(node, _)| strictly(span(node)))
|
||||
.collect();
|
||||
let paired =
|
||||
|node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_));
|
||||
let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node)))
|
||||
|| (cut.iter()).any(|(node, [open, close])| {
|
||||
!paired(node) || strictly(open.clone()) || strictly(close.clone())
|
||||
});
|
||||
if unsafe_cut {
|
||||
return Err(EditRefusal::Across);
|
||||
}
|
||||
cut.sort_by_key(|(node, _)| span(node).start);
|
||||
let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]);
|
||||
let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]);
|
||||
Ok((at, close.collect(), open.collect()))
|
||||
}
|
||||
|
||||
/// `block` put where the selection splits `node`.
|
||||
pub(super) fn split(
|
||||
here: &Here,
|
||||
node: &Node,
|
||||
room: &Room,
|
||||
block: &str,
|
||||
inner: Option<usize>,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
let text = here.text;
|
||||
let whole = span(node);
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let (marks, body) = match node {
|
||||
Node::Heading(heading) => (
|
||||
"#".repeat(usize::from(heading.depth)) + " ",
|
||||
kids.first().map_or(whole.end, |kid| span(kid).start)
|
||||
..kids.last().map_or(whole.end, |kid| span(kid).end),
|
||||
),
|
||||
_ => (String::new(), whole.clone()),
|
||||
};
|
||||
// A caret in heading marks takes the text's edge.
|
||||
let s = here.selection.start.clamp(body.start, body.end);
|
||||
let e = here.selection.end.clamp(body.start, body.end);
|
||||
let (s, close, _) = seam(here, s)?;
|
||||
let (e, _, open) = seam(here, e)?;
|
||||
let containers = &room.containers;
|
||||
let mut from = back(text, s, body.start, containers);
|
||||
let after = forth(text, e, body.end, containers);
|
||||
let before = from > body.start;
|
||||
let follows = after < body.end;
|
||||
// GFM reads a task's first line as text.
|
||||
if !before && task_head(here.path[here.path.len() - 2], node) {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
if !before {
|
||||
from = whole.start;
|
||||
}
|
||||
let to = if follows { after } else { whole.end };
|
||||
let gap = room.gap();
|
||||
let mut written = String::new();
|
||||
if before {
|
||||
written.push_str(&close);
|
||||
written.push_str(&gap);
|
||||
}
|
||||
let start = written.len();
|
||||
written.push_str(block);
|
||||
let lead = if follows { marks.len() + open.len() } else { 0 };
|
||||
let caret = match inner {
|
||||
Some(inner) => start + inner,
|
||||
None => {
|
||||
written.push_str(&gap);
|
||||
written.len() + lead
|
||||
}
|
||||
};
|
||||
if follows {
|
||||
if inner.is_some() {
|
||||
written.push_str(&gap);
|
||||
}
|
||||
written.push_str(&marks);
|
||||
written.push_str(&open);
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
let mut shift = 0;
|
||||
if !before && here.joins_before(&whole, &[]) {
|
||||
let line = line_start(text, whole.start);
|
||||
let blank = format!("{}{}", room.blank, room.nl);
|
||||
shift = blank.len();
|
||||
edits.push((line..line, blank));
|
||||
}
|
||||
edits.push((from..to, written));
|
||||
// A heading's cut end must not close it.
|
||||
let line = line_start(text, from);
|
||||
if before
|
||||
&& close.is_empty()
|
||||
&& matches!(node, Node::Heading(_))
|
||||
&& let Some(run) = closing(&text[line..from])
|
||||
{
|
||||
edits.push((line + run..line + run, "\\".to_string()));
|
||||
shift += 1;
|
||||
}
|
||||
// The rest must not open a block.
|
||||
if follows
|
||||
&& marks.is_empty()
|
||||
&& open.is_empty()
|
||||
&& let Some(mark) = unblocked(&text[after..line_end(text, after)])?
|
||||
{
|
||||
edits.push((after + mark..after + mark, "\\".to_string()));
|
||||
}
|
||||
let change = placed(edits, from + shift + caret);
|
||||
let edits = &change.0;
|
||||
let (written, after_root) = check::reread(text, edits);
|
||||
let now = check::Shown::new(&written, &after_root);
|
||||
let level = match node {
|
||||
Node::Heading(heading) => heading.depth,
|
||||
_ => 0,
|
||||
};
|
||||
let piece = |at: usize| {
|
||||
now.at(shifted(at, edits))
|
||||
.and_then(|(got, chars)| (got == level).then_some(chars))
|
||||
};
|
||||
let head = if before {
|
||||
piece(from - 1)
|
||||
} else {
|
||||
Some(Vec::new())
|
||||
};
|
||||
let tail = if follows { piece(to) } else { Some(Vec::new()) };
|
||||
let want = check::Shown::new(text, here.root).inline(node);
|
||||
let kept = head
|
||||
.zip(tail)
|
||||
.is_some_and(|(head, tail)| match here.selection.is_empty() {
|
||||
true => [head, tail].concat() == want,
|
||||
false => {
|
||||
want.starts_with(&head)
|
||||
&& want.ends_with(&tail)
|
||||
&& head.len() + tail.len() <= want.len()
|
||||
}
|
||||
});
|
||||
match kept {
|
||||
true => Ok(change),
|
||||
false => Err(check::misread("a split would reread otherwise")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! A table cell opens alone, as Typora edits tables.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use crate::documents::{
|
||||
markdown::{file_tree, span, walk},
|
||||
render::CellAt,
|
||||
};
|
||||
|
||||
/// A cell: which, its source, its caret.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Cell {
|
||||
pub at: CellAt,
|
||||
pub range: Range<usize>,
|
||||
pub caret: usize,
|
||||
}
|
||||
|
||||
/// What names a cell in a block.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Aim {
|
||||
/// The cell holding this byte of the text.
|
||||
Byte(usize),
|
||||
/// This cell, its caret at the end.
|
||||
At(CellAt),
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
/// A way out of a cell.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Move {
|
||||
Next,
|
||||
Previous,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// The rows of each table in `root`, in drawing order.
|
||||
fn tables(root: &Node) -> Vec<Vec<&Node>> {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
nodes
|
||||
.into_iter()
|
||||
.filter(|node| matches!(node, Node::Table(_)))
|
||||
.map(|table| {
|
||||
table
|
||||
.children()
|
||||
.expect("a table holds rows")
|
||||
.iter()
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cells(row: &Node) -> &[Node] {
|
||||
row.children().expect("a row holds cells")
|
||||
}
|
||||
|
||||
/// The cell `aim` names in `block` of `text`.
|
||||
pub(super) fn find(text: &str, block: Range<usize>, aim: Aim) -> Option<Cell> {
|
||||
let root = file_tree(&text[block.clone()]);
|
||||
let tables = tables(&root);
|
||||
let at = match aim {
|
||||
Aim::At(at) => at,
|
||||
Aim::First if !tables.is_empty() => CellAt {
|
||||
table: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
},
|
||||
Aim::Last => {
|
||||
let table = tables.len().checked_sub(1)?;
|
||||
let row = tables[table].len() - 1;
|
||||
let column = cells(tables[table][row]).len().checked_sub(1)?;
|
||||
CellAt { table, row, column }
|
||||
}
|
||||
Aim::First => return None,
|
||||
Aim::Byte(byte) => {
|
||||
let byte = byte.checked_sub(block.start)?;
|
||||
let table = (tables.iter()).position(|rows| {
|
||||
let (first, last) = (rows[0], rows[rows.len() - 1]);
|
||||
(span(first).start..=span(last).end).contains(&byte)
|
||||
})?;
|
||||
let rows = &tables[table];
|
||||
let row = rows.iter().rposition(|row| span(row).start <= byte)?;
|
||||
let column = (cells(rows[row]).iter()).rposition(|cell| span(cell).start <= byte)?;
|
||||
CellAt { table, row, column }
|
||||
}
|
||||
};
|
||||
let cell = cells(tables.get(at.table)?.get(at.row)?).get(at.column)?;
|
||||
let range = written(&text[block.clone()], cell);
|
||||
let caret = match aim {
|
||||
Aim::Byte(byte) => (byte - block.start).clamp(range.start, range.end) - range.start,
|
||||
_ => range.len(),
|
||||
};
|
||||
Some(Cell {
|
||||
at,
|
||||
range: block.start + range.start..block.start + range.end,
|
||||
caret,
|
||||
})
|
||||
}
|
||||
|
||||
/// The cell `by` reaches from `at`; none past the edge.
|
||||
pub(super) fn beside(text: &str, block: Range<usize>, at: CellAt, by: Move) -> Option<CellAt> {
|
||||
let root = file_tree(&text[block]);
|
||||
let tables = tables(&root);
|
||||
let rows = tables.get(at.table)?;
|
||||
let width = |row: usize| rows.get(row).map_or(0, |row| cells(row).len());
|
||||
let (row, column) = match by {
|
||||
Move::Next if at.column + 1 < width(at.row) => (at.row, at.column + 1),
|
||||
Move::Next => (at.row + 1, 0),
|
||||
Move::Previous if at.column > 0 => (at.row, at.column - 1),
|
||||
Move::Previous => {
|
||||
let row = at.row.checked_sub(1)?;
|
||||
(row, width(row).checked_sub(1)?)
|
||||
}
|
||||
Move::Up => (at.row.checked_sub(1)?, at.column),
|
||||
Move::Down => (at.row + 1, at.column),
|
||||
};
|
||||
let column = column.min(width(row).checked_sub(1)?);
|
||||
Some(CellAt {
|
||||
table: at.table,
|
||||
row,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
/// A cell's source; empty, just past its opening pipe.
|
||||
fn written(text: &str, cell: &Node) -> Range<usize> {
|
||||
let inside = cell.children().expect("a cell holds inlines");
|
||||
if let (Some(first), Some(last)) = (inside.first(), inside.last()) {
|
||||
return span(first).start..span(last).end;
|
||||
}
|
||||
let span = span(cell);
|
||||
let mut at = span.start;
|
||||
if text[at..span.end].starts_with('|') {
|
||||
at += 1;
|
||||
}
|
||||
if text[at..span.end].starts_with([' ', '\t']) {
|
||||
at += 1;
|
||||
}
|
||||
at..at
|
||||
}
|
||||
|
||||
/// `typed` with each bare `|` escaped.
|
||||
fn guarded(typed: &str) -> String {
|
||||
let mut out = String::with_capacity(typed.len());
|
||||
let mut slashes = 0;
|
||||
for ch in typed.chars() {
|
||||
if ch == '|' && slashes % 2 == 0 {
|
||||
out.push('\\');
|
||||
}
|
||||
slashes = if ch == '\\' { slashes + 1 } else { 0 };
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What borders a cell's source in its row.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Edges {
|
||||
/// A pipe follows at once.
|
||||
closed: bool,
|
||||
/// No pipe before it on its line.
|
||||
first: bool,
|
||||
/// No pipe after it on its line.
|
||||
last: bool,
|
||||
}
|
||||
|
||||
/// The borders of the cell source at `range` of `text`.
|
||||
pub(super) fn edges(text: &str, range: Range<usize>) -> Edges {
|
||||
let line = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let end = (text[range.end..].find('\n')).map_or(text.len(), |at| range.end + at);
|
||||
let (before, after) = (&text[line..range.start], &text[range.end..end]);
|
||||
Edges {
|
||||
closed: after.starts_with('|'),
|
||||
first: !before.contains('|'),
|
||||
last: !after.contains('|'),
|
||||
}
|
||||
}
|
||||
|
||||
/// `typed` as a cell keeps it, its row's pipes intact.
|
||||
pub(super) fn kept(typed: &str, edges: Edges) -> String {
|
||||
let mut out = guarded(typed);
|
||||
let slashes = out.len() - out.trim_end_matches('\\').len();
|
||||
if edges.closed && slashes % 2 == 1 {
|
||||
out.push('\\');
|
||||
}
|
||||
// An emptied bare edge cell keeps its column.
|
||||
if out.is_empty() && (edges.first || edges.last) {
|
||||
out.push('|');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte `at` of `typed` within `kept(typed, edges)`.
|
||||
pub(super) fn kept_at(typed: &str, at: usize, edges: Edges) -> usize {
|
||||
match at == typed.len() {
|
||||
true if typed.is_empty() && !edges.first => 0,
|
||||
true => kept(typed, edges).len(),
|
||||
false => guarded(&typed[..at]).len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Aim, CellAt, Edges, Move, beside, edges, find, guarded, kept, kept_at};
|
||||
|
||||
const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n";
|
||||
|
||||
fn block() -> std::ops::Range<usize> {
|
||||
7..TABLE.len() - 1
|
||||
}
|
||||
|
||||
fn at(row: usize, column: usize) -> CellAt {
|
||||
CellAt {
|
||||
table: 0,
|
||||
row,
|
||||
column,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_byte_opens_its_cell_with_the_caret_there() {
|
||||
let byte = TABLE.find("**b**").expect("in the table") + 2;
|
||||
let cell = find(TABLE, block(), Aim::Byte(byte)).expect("a cell");
|
||||
assert_eq!(cell.at, at(0, 1));
|
||||
assert_eq!(&TABLE[cell.range.clone()], "**b**");
|
||||
assert_eq!(cell.caret, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_cell_opens_between_its_pipes() {
|
||||
let cell = find(TABLE, block(), Aim::At(at(1, 1))).expect("a cell");
|
||||
assert!(cell.range.is_empty());
|
||||
let written = [&TABLE[..cell.range.start], "x", &TABLE[cell.range.end..]].concat();
|
||||
assert!(written.contains("| c | x |"), "{written:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_open_the_first_and_last_cells() {
|
||||
let first = find(TABLE, block(), Aim::First).expect("a cell");
|
||||
assert_eq!((first.at, &TABLE[first.range]), (at(0, 0), "a"));
|
||||
let last = find(TABLE, block(), Aim::Last).expect("a cell");
|
||||
assert_eq!((last.at, &TABLE[last.range]), (at(2, 1), "e"));
|
||||
assert_eq!(find("plain\n", 0..5, Aim::First), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_wrap_rows_and_stop_at_edges() {
|
||||
let to = |from, by| beside(TABLE, block(), from, by);
|
||||
assert_eq!(to(at(0, 1), Move::Next), Some(at(1, 0)));
|
||||
assert_eq!(to(at(1, 0), Move::Previous), Some(at(0, 1)));
|
||||
assert_eq!(to(at(0, 0), Move::Previous), None);
|
||||
assert_eq!(to(at(2, 1), Move::Next), None);
|
||||
assert_eq!(to(at(1, 1), Move::Up), Some(at(0, 1)));
|
||||
assert_eq!(to(at(2, 0), Move::Down), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_pipes_escape_and_escaped_stay() {
|
||||
assert_eq!(guarded("a|b"), "a\\|b");
|
||||
assert_eq!(guarded("a\\|b"), "a\\|b");
|
||||
assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b");
|
||||
}
|
||||
|
||||
/// Cell `(row, column)` of `text`'s one table, written as `typed`.
|
||||
fn write(text: &str, row: usize, column: usize, typed: &str) -> String {
|
||||
let block = 0..text.len() - 1;
|
||||
let cell = find(text, block, Aim::At(at(row, column))).expect("a cell");
|
||||
let kept = kept(typed, edges(text, cell.range.clone()));
|
||||
[&text[..cell.range.start], &kept, &text[cell.range.end..]].concat()
|
||||
}
|
||||
|
||||
/// The source of each cell of `text`'s one table.
|
||||
fn grid(text: &str) -> Vec<Vec<String>> {
|
||||
let block = 0..text.len() - 1;
|
||||
(0..)
|
||||
.map_while(|row| {
|
||||
let cells: Vec<String> = (0..)
|
||||
.map_while(|column| {
|
||||
let cell = find(text, block.clone(), Aim::At(at(row, column)))?;
|
||||
Some(text[cell.range].to_string())
|
||||
})
|
||||
.collect();
|
||||
(!cells.is_empty()).then_some(cells)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_backslash_keeps_the_next_pipe() {
|
||||
let shut = Edges {
|
||||
closed: true,
|
||||
first: false,
|
||||
last: false,
|
||||
};
|
||||
assert_eq!(kept("a\\", shut), "a\\\\");
|
||||
assert_eq!(kept("a\\\\", shut), "a\\\\");
|
||||
assert_eq!(kept_at("a\\", 2, shut), 3);
|
||||
assert_eq!(kept_at("a\\b", 2, shut), 2);
|
||||
let written = write("|a|b|\n|---|---|\n|c|d|\n", 0, 0, "a\\");
|
||||
assert_eq!(grid(&written)[0], ["a\\\\", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backslash_before_padding_stays_single() {
|
||||
let text = "| a\\ | b |\n| --- | --- |\n";
|
||||
assert_eq!(write(text, 0, 0, "a\\"), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emptied_bare_edge_cell_keeps_its_column() {
|
||||
let text = "alpha | beta\n--- | ---\nc | d\n";
|
||||
let rows = |written: &str| grid(written).iter().map(Vec::len).collect::<Vec<_>>();
|
||||
for (row, column) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
let written = write(text, row, column, "");
|
||||
assert_eq!(rows(&written), [2, 2], "{written:?}");
|
||||
assert_eq!(grid(&written)[row][column], "", "{written:?}");
|
||||
}
|
||||
assert_eq!(grid(&write(text, 1, 0, ""))[1], ["", "d"]);
|
||||
let empty = edges(text, 0..5);
|
||||
assert_eq!(kept_at("", 0, empty), 1, "past its new pipe");
|
||||
let last = edges(text, 8..12);
|
||||
assert_eq!(kept_at("", 0, last), 0, "before its new pipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_pipes_shift_later_bytes() {
|
||||
let open = Edges {
|
||||
closed: false,
|
||||
first: false,
|
||||
last: false,
|
||||
};
|
||||
assert_eq!(kept_at("||x", 2, open), 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
||||
|
||||
use super::{
|
||||
DocumentEvent, MarkdownDocument, Open, TargetBlock,
|
||||
cells::{self, Aim, Move},
|
||||
};
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
markdown::markdown_highlights,
|
||||
modes::{at_edge, joint},
|
||||
},
|
||||
forms::{InputEvent, TextInput},
|
||||
primitives::lent,
|
||||
};
|
||||
|
||||
/// Where an opened block's caret lands.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Landing {
|
||||
Start,
|
||||
End,
|
||||
/// A source byte of the drawn read.
|
||||
At(usize),
|
||||
/// A table cell, by byte or place.
|
||||
Cell(Aim),
|
||||
}
|
||||
|
||||
impl Landing {
|
||||
/// The same landing after `edit`, if it survives.
|
||||
fn moved(self, edit: &Option<(Range<usize>, usize)>) -> Option<Self> {
|
||||
Some(match self {
|
||||
Self::At(at) => Self::At(moved(at, edit)?),
|
||||
Self::Cell(Aim::Byte(at)) => Self::Cell(Aim::Byte(moved(at, edit)?)),
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where byte `at` lies after `edit`, if it survives.
|
||||
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
||||
match edit {
|
||||
None => Some(at),
|
||||
Some((replaced, _)) if at <= replaced.start => Some(at),
|
||||
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// An empty, read visual document takes typing at once.
|
||||
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if std::mem::take(&mut self.pointing) {
|
||||
return;
|
||||
}
|
||||
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
|
||||
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
|
||||
self.open(None, Landing::End, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo or redo in the document's one history.
|
||||
pub(super) fn history(&mut self, redo: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
self.editor.update(cx, |editor, cx| match redo {
|
||||
true => editor.redo(cx),
|
||||
false => editor.undo(cx),
|
||||
});
|
||||
window.focus(&self.focus, cx);
|
||||
// Once the change is read, an empty end opens.
|
||||
cx.defer_in(window, |document, window, cx| document.focused(window, cx));
|
||||
}
|
||||
|
||||
/// Opens block `ix` as shown, or one past the end.
|
||||
pub(super) fn open(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
landing: Landing,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.waiting = None;
|
||||
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||
pressed => pressed.flatten().map(|block| block.range.start),
|
||||
};
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) && written.is_some() {
|
||||
// A long write-back reads first; it opens after.
|
||||
let start = match pressed {
|
||||
None => Some(None),
|
||||
Some(start) => moved(start, &written).map(Some),
|
||||
};
|
||||
let (Some(start), Some(landing)) = (start, landing.moved(&written)) else {
|
||||
return log::warn!("markdown document: the pressed block was written over");
|
||||
};
|
||||
log::info!("markdown document: a block opens once its text is read");
|
||||
self.waiting = Some((start, landing, self.edits + self.writes as u64));
|
||||
return;
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let text = self.editor.read(cx).text();
|
||||
let (range, append, cell) = match pressed {
|
||||
None => (text.len()..text.len(), true, None),
|
||||
Some(start) => {
|
||||
let found = moved(start, &written).and_then(|start| {
|
||||
self.parsed
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| block.range.start == start)
|
||||
});
|
||||
let Some(block) = found else {
|
||||
return log::warn!("markdown document: the pressed block changed as one wrote");
|
||||
};
|
||||
let table = block.kind == TargetBlock::Table;
|
||||
let aim = match landing {
|
||||
Landing::Cell(Aim::Byte(at)) => match moved(at, &written) {
|
||||
Some(at) => Some(Aim::Byte(at)),
|
||||
None => {
|
||||
return log::warn!(
|
||||
"markdown document: the pressed cell was written over"
|
||||
);
|
||||
}
|
||||
},
|
||||
Landing::Cell(aim) => Some(aim),
|
||||
Landing::Start if table => Some(Aim::First),
|
||||
Landing::End if table => Some(Aim::Last),
|
||||
_ => None,
|
||||
};
|
||||
match aim {
|
||||
None => (block.range.clone(), false, None),
|
||||
Some(aim) => match cells::find(text, block.range.clone(), aim) {
|
||||
Some(cell) => (cell.range, false, Some((cell.at, cell.caret))),
|
||||
None => {
|
||||
return log::warn!("markdown document: no cell {aim:?} in the block");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
let caret = match landing {
|
||||
_ if cell.is_some() => cell.map(|(_, caret)| caret),
|
||||
Landing::Start => Some(0),
|
||||
Landing::End | Landing::Cell(_) => None,
|
||||
Landing::At(at) => match moved(at, &written) {
|
||||
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
|
||||
_ => {
|
||||
log::warn!("markdown document: the pressed byte left its block");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let source = text[range.clone()].to_string();
|
||||
let input = cx.new(|cx| {
|
||||
let input = TextInput::new(window, cx).highlighter(markdown_highlights);
|
||||
match cell {
|
||||
Some(_) => input,
|
||||
None => input.multi_line(1, usize::MAX),
|
||||
}
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if let Some(caret) = caret {
|
||||
input.select(caret..caret, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
let subscriptions = vec![cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
|document, _, event: &InputEvent, window, cx| {
|
||||
match event {
|
||||
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
|
||||
InputEvent::Blur => {
|
||||
document.commit(cx);
|
||||
}
|
||||
InputEvent::Changed => {
|
||||
document.typed += 1;
|
||||
cx.emit(DocumentEvent::Changed)
|
||||
}
|
||||
// Enter in a cell writes it back.
|
||||
InputEvent::Submit
|
||||
if document
|
||||
.open
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.cell.is_some()) =>
|
||||
{
|
||||
document.commit(cx);
|
||||
window.focus(&document.focus, cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
)];
|
||||
match cell {
|
||||
Some((at, _)) => log::info!("markdown document: cell {at:?} at {range:?} opens"),
|
||||
None => log::info!("markdown document: block {range:?} opens"),
|
||||
}
|
||||
self.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
cell: cell.map(|(at, _)| at),
|
||||
lent: None,
|
||||
_subscriptions: subscriptions,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A block asked open before its text was read, now opened.
|
||||
pub(super) fn open_waiting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.waiting.is_none() || !self.read_now(cx) {
|
||||
return;
|
||||
}
|
||||
let (start, landing, expected) = self.waiting.take().expect("checked above");
|
||||
if self.edits != expected {
|
||||
return log::info!("markdown document: a waiting block lapsed with its text");
|
||||
}
|
||||
let ix = match start {
|
||||
None => None,
|
||||
Some(start) => match (self.parsed.blocks.iter()).position(|b| b.range.start == start) {
|
||||
Some(ix) => Some(ix),
|
||||
None => return log::warn!("markdown document: the waiting block was read away"),
|
||||
},
|
||||
};
|
||||
self.open(ix, landing, window, cx);
|
||||
}
|
||||
|
||||
/// Takes the focus a dropped block still holds.
|
||||
pub(super) fn take_orphan(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(orphan) = self.orphan.take() else {
|
||||
return;
|
||||
};
|
||||
if self.open.is_none() && orphan.is_focused(window) {
|
||||
log::info!("markdown document: a dropped block's focus passes to the document");
|
||||
window.focus(&self.focus_handle(cx), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the block open while an overlay holds focus.
|
||||
fn lend(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let (Some(open), Some(holder)) = (self.open.as_mut(), window.focused(cx)) else {
|
||||
return;
|
||||
};
|
||||
log::info!("markdown document: an overlay borrows the open block's focus");
|
||||
let watch = cx.on_focus_out(&holder, window, |document, _, window, cx| {
|
||||
let back = (document.open.as_ref())
|
||||
.is_some_and(|open| open.input.focus_handle(cx).is_focused(window));
|
||||
match (back, lent(window, cx)) {
|
||||
(true, _) => {
|
||||
if let Some(open) = document.open.as_mut() {
|
||||
open.lent = None;
|
||||
}
|
||||
}
|
||||
(false, true) => document.lend(window, cx),
|
||||
(false, false) => {
|
||||
document.commit(cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
open.lent = Some(watch);
|
||||
}
|
||||
|
||||
/// Writes the open block as one step; the edit made.
|
||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
||||
let open = self.open.take()?;
|
||||
self.orphan = Some(open.input.focus_handle(cx));
|
||||
cx.notify();
|
||||
let typed = open.input.read(cx).text().to_string();
|
||||
let caret = open.input.read(cx).cursor();
|
||||
let text = self.editor.read(cx).text();
|
||||
let edges = cells::edges(text, open.range.clone());
|
||||
let (lead, written) = match open.append {
|
||||
true if typed.is_empty() => return None,
|
||||
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
||||
false if open.cell.is_some() => (0, cells::kept(&typed, edges)),
|
||||
false => (0, typed.clone()),
|
||||
};
|
||||
let caret = match open.cell {
|
||||
Some(_) => cells::kept_at(&typed, caret, edges),
|
||||
None => caret,
|
||||
};
|
||||
let at = open.range.start + lead + caret;
|
||||
let edit = (text[open.range.clone()] != written).then(|| {
|
||||
log::info!("markdown document: block {:?} written back", open.range);
|
||||
(open.range.clone(), written.len())
|
||||
});
|
||||
if edit.is_some() {
|
||||
self.writes += 1;
|
||||
let start = open.range.start;
|
||||
let landed = self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(open.range, written.clone())], cx);
|
||||
editor.text().get(start..start + written.len()) == Some(written.as_str())
|
||||
});
|
||||
if !landed {
|
||||
self.writes -= 1;
|
||||
log::error!("markdown document: the editor refused a write-back");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// The one caret follows the block's.
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
edit
|
||||
}
|
||||
|
||||
/// Up past the open block's top, or down past it.
|
||||
pub(super) fn step(&mut self, down: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let Some(open) = &self.open else {
|
||||
return false;
|
||||
};
|
||||
if open.append || !at_edge(open.input.read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
let here = self.block_at(open.range.start);
|
||||
if let Some(at) = open.cell {
|
||||
let block = self.parsed.blocks[here].range.clone();
|
||||
let by = if down { Move::Down } else { Move::Up };
|
||||
let text = self.editor.read(cx).text();
|
||||
if let Some(to) = cells::beside(text, block, at, by) {
|
||||
self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let next = match down {
|
||||
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
||||
false => here.checked_sub(1),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return false;
|
||||
};
|
||||
let landing = if down { Landing::Start } else { Landing::End };
|
||||
self.open(Some(next), landing, window, cx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_place_moves_with_an_edit_before_it() {
|
||||
let edit = Some((2..6, 9));
|
||||
assert_eq!(super::moved(10, &edit), Some(15));
|
||||
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
||||
assert_eq!(super::moved(2, &edit), Some(2), "its start stays");
|
||||
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
||||
assert_eq!(super::moved(7, &None), Some(7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Matches shown in any mode; replacements in one step.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{
|
||||
MarkdownDocument,
|
||||
cells::{self, Aim},
|
||||
edit::Landing,
|
||||
};
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// `range` in view: selected, or in its opened block.
|
||||
pub fn show_match(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let line = |document: &Self, cx: &Context<Self>| {
|
||||
document.editor.read(cx).text()[..range.start]
|
||||
.matches('\n')
|
||||
.count()
|
||||
};
|
||||
// A newer request takes the place of one waiting.
|
||||
self.showing = None;
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => {
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(range), cx));
|
||||
}
|
||||
MarkdownMode::Preview => self.reveal(line(self, cx), window, cx),
|
||||
MarkdownMode::Visual => {
|
||||
if self.commit(cx).is_some() || !self.read_now(cx) {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
// Shown once read, if nothing else changed it.
|
||||
let expected = self.edits + self.writes as u64;
|
||||
self.showing = Some((range, expected));
|
||||
return cx.notify();
|
||||
}
|
||||
let ix = self.block_at(range.start);
|
||||
let held = self.parsed.blocks.get(ix).map(|block| block.range.clone());
|
||||
let Some(block) =
|
||||
held.filter(|block| block.start <= range.start && range.end <= block.end)
|
||||
else {
|
||||
// Outside one block: its line.
|
||||
return self.reveal(line(self, cx), window, cx);
|
||||
};
|
||||
// A match in a table opens its cell.
|
||||
let text = self.editor.read(cx).text();
|
||||
let landing = match cells::find(text, block.clone(), Aim::Byte(range.start)) {
|
||||
Some(cell)
|
||||
if cell.range.start <= range.start && range.end <= cell.range.end =>
|
||||
{
|
||||
Landing::Cell(Aim::At(cell.at))
|
||||
}
|
||||
Some(_) => return self.reveal(line(self, cx), window, cx),
|
||||
None => Landing::End,
|
||||
};
|
||||
self.open(Some(ix), landing, window, cx);
|
||||
self.list.scroll_to_reveal_item(ix);
|
||||
if let Some(open) = &self.open {
|
||||
let input = open.input.clone();
|
||||
let inside = range.start - open.range.start..range.end - open.range.start;
|
||||
input.update(cx, |input, cx| input.select(inside, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A match asked before its text was read, now shown.
|
||||
pub(super) fn show_waiting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.showing.is_none() || !self.read_now(cx) {
|
||||
return;
|
||||
}
|
||||
let (range, expected) = self.showing.take().expect("checked above");
|
||||
if self.edits != expected {
|
||||
return log::info!("markdown document: a waiting match lapsed with its text");
|
||||
}
|
||||
self.show_match(range, window, cx);
|
||||
}
|
||||
|
||||
/// `edits` as one undo step; false in preview.
|
||||
pub fn replace(&mut self, edits: Vec<(Range<usize>, String)>, cx: &mut Context<Self>) -> bool {
|
||||
if self.mode == MarkdownMode::Preview {
|
||||
return false;
|
||||
}
|
||||
self.commit(cx);
|
||||
let text = self.editor.read(cx).text();
|
||||
if edits
|
||||
.iter()
|
||||
.all(|(range, with)| text[range.clone()] == *with)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
log::info!("markdown document: {} replaced", edits.len());
|
||||
// Its own change: an open block outlives it.
|
||||
self.writes += 1;
|
||||
self.editor.update(cx, |editor, cx| editor.edit(edits, cx));
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Inline formats toggle at the selection, as Typora's (R4.6).
|
||||
|
||||
mod clear;
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::TableRefusal;
|
||||
|
||||
use crate::documents::{
|
||||
markdown::{file_tree, span, walk},
|
||||
tree::inside,
|
||||
};
|
||||
|
||||
/// An inline format of Typora's Format menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InlineFormat {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Code,
|
||||
Strike,
|
||||
Underline,
|
||||
Link,
|
||||
Image,
|
||||
/// Every inline mark in the touched blocks.
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Why a format or block command did not happen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditRefusal {
|
||||
/// Preview takes no edits.
|
||||
Preview,
|
||||
Code,
|
||||
Math,
|
||||
Html,
|
||||
FrontMatter,
|
||||
/// No paragraph, heading or cell at the caret.
|
||||
Outside,
|
||||
/// A table holds the caret.
|
||||
Table,
|
||||
/// No picture holds the caret.
|
||||
NoImage,
|
||||
/// Front matter is there already.
|
||||
Present,
|
||||
/// The selection crosses a block or another mark.
|
||||
Across,
|
||||
/// Past the largest read: source alone.
|
||||
TooLong,
|
||||
/// Another edit is still read.
|
||||
Busy,
|
||||
/// The text changed before the edit was read.
|
||||
Changed,
|
||||
/// The result would read as other text or blocks.
|
||||
Misread,
|
||||
/// A table edit refused, and why.
|
||||
Cells(TableRefusal),
|
||||
}
|
||||
|
||||
/// The edits and the selection after them.
|
||||
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
|
||||
|
||||
/// Removes the picture at the caret, as Typora's Delete Image.
|
||||
pub(super) fn drop_image(text: &str, at: usize) -> Result<Change, EditRefusal> {
|
||||
let root = file_tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let picture = (nodes.iter())
|
||||
.filter(|node| matches!(node, Node::Image(_) | Node::ImageReference(_)))
|
||||
.map(|node| span(node))
|
||||
.find(|range| range.contains(&at))
|
||||
.ok_or(EditRefusal::NoImage)?;
|
||||
Ok((
|
||||
vec![(picture.clone(), String::new())],
|
||||
picture.start..picture.start,
|
||||
))
|
||||
}
|
||||
|
||||
/// Typora's word stops: punctuation and space.
|
||||
fn stop(ch: char) -> bool {
|
||||
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
|
||||
}
|
||||
|
||||
/// The text a block holds: its children, past heading marks.
|
||||
fn content(node: &Node) -> Range<usize> {
|
||||
let whole = span(node);
|
||||
match node.children().map(|kids| (kids.first(), kids.last())) {
|
||||
Some((Some(first), Some(last))) => span(first).start..span(last).end,
|
||||
_ => whole.end..whole.end,
|
||||
}
|
||||
}
|
||||
|
||||
/// Room for text: inside a cell's pipes, trailing spaces too.
|
||||
fn room(node: &Node, text: &str) -> Range<usize> {
|
||||
let inner = match node {
|
||||
Node::TableCell(_) => {
|
||||
let whole = span(node);
|
||||
let cell = &text[whole.clone()];
|
||||
whole.start + usize::from(cell.starts_with('|'))
|
||||
..whole.end - usize::from(cell.len() > 1 && cell.ends_with('|'))
|
||||
}
|
||||
_ => content(node),
|
||||
};
|
||||
let rest = &text[inner.end..];
|
||||
let spaces = rest.len() - rest.trim_start_matches([' ', '\t']).len();
|
||||
inner.start..inner.end + spaces
|
||||
}
|
||||
|
||||
/// Which of `sorted`, disjoint spans, holds `inner`.
|
||||
pub(super) fn holder(sorted: &[Range<usize>], inner: &Range<usize>) -> Option<usize> {
|
||||
let at = sorted.partition_point(|span| span.start <= inner.start);
|
||||
(at > 0 && inner.end <= sorted[at - 1].end).then(|| at - 1)
|
||||
}
|
||||
|
||||
fn block(node: &Node) -> bool {
|
||||
matches!(
|
||||
node,
|
||||
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// A styled node's two marks, kept text between.
|
||||
pub(super) fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
||||
let whole = span(node);
|
||||
let inner = match node {
|
||||
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
|
||||
Node::Link(_) | Node::LinkReference(_) => content(node),
|
||||
Node::InlineCode(_) => inside(text, whole.clone(), '`'),
|
||||
Node::Image(_) | Node::ImageReference(_) => {
|
||||
// Typora keeps the alt up to its first `]`.
|
||||
let end = whole.start + text[whole.clone()].find(']')?;
|
||||
whole.start + 2..end
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some([whole.start..inner.start, inner.end..whole.end])
|
||||
}
|
||||
|
||||
/// Whether `at` lies strictly inside one of `node`'s marks.
|
||||
fn in_marks(node: &Node, text: &str, at: usize) -> bool {
|
||||
marks(node, text).is_some_and(|holes| holes.iter().any(|hole| hole.start < at && at < hole.end))
|
||||
}
|
||||
|
||||
fn styled(format: InlineFormat, node: &Node) -> bool {
|
||||
match format {
|
||||
InlineFormat::Strong => matches!(node, Node::Strong(_)),
|
||||
InlineFormat::Emphasis => matches!(node, Node::Emphasis(_)),
|
||||
InlineFormat::Code => matches!(node, Node::InlineCode(_)),
|
||||
InlineFormat::Strike => matches!(node, Node::Delete(_)),
|
||||
InlineFormat::Link => matches!(node, Node::Link(_) | Node::LinkReference(_)),
|
||||
InlineFormat::Image => matches!(node, Node::Image(_) | Node::ImageReference(_)),
|
||||
InlineFormat::Underline | InlineFormat::Clear => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn merges(format: InlineFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
InlineFormat::Strong | InlineFormat::Emphasis | InlineFormat::Strike
|
||||
)
|
||||
}
|
||||
|
||||
/// `text[range]` without `holes`, sorted and inside it.
|
||||
fn cut(text: &str, range: Range<usize>, holes: &[Range<usize>]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut at = range.start;
|
||||
for hole in holes {
|
||||
out.push_str(&text[at..hole.start]);
|
||||
at = hole.end;
|
||||
}
|
||||
out.push_str(&text[at..range.end]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte `at` once `holes` are cut; inside one, its start.
|
||||
fn after(at: usize, holes: &[Range<usize>]) -> usize {
|
||||
let gone: usize = holes
|
||||
.iter()
|
||||
.map(|hole| at.clamp(hole.start, hole.end) - hole.start)
|
||||
.sum();
|
||||
at - gone
|
||||
}
|
||||
|
||||
/// Refuses an end inside code, math, HTML or front matter.
|
||||
fn verbatim(
|
||||
nodes: &[&Node],
|
||||
selection: &Range<usize>,
|
||||
format: InlineFormat,
|
||||
) -> Result<(), EditRefusal> {
|
||||
let mut blocks: Vec<Range<usize>> = nodes
|
||||
.iter()
|
||||
.filter(|node| block(node))
|
||||
.map(|node| span(node))
|
||||
.collect();
|
||||
blocks.sort_by_key(|block| block.start);
|
||||
for node in nodes {
|
||||
let whole = span(node);
|
||||
let touches = |edge: bool| {
|
||||
[selection.start, selection.end]
|
||||
.iter()
|
||||
.any(|&at| match edge {
|
||||
true => whole.start <= at && at <= whole.end,
|
||||
false => whole.start < at && at < whole.end,
|
||||
})
|
||||
};
|
||||
// Clear takes a block's inline code and tags too.
|
||||
let held = format != InlineFormat::Clear && touches(false);
|
||||
let why = match node {
|
||||
Node::Code(_) if touches(true) => EditRefusal::Code,
|
||||
Node::Math(_) if touches(true) => EditRefusal::Math,
|
||||
Node::Yaml(_) | Node::Toml(_) if touches(true) => EditRefusal::FrontMatter,
|
||||
Node::InlineCode(_) if format != InlineFormat::Code && held => EditRefusal::Code,
|
||||
Node::InlineMath(_) if held => EditRefusal::Math,
|
||||
Node::Html(_) => match holder(&blocks, &whole).is_some() {
|
||||
true if held => EditRefusal::Html,
|
||||
false if touches(true) => EditRefusal::Html,
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
return Err(why);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The nearest `<u>` and `</u>` pair around `s..e` in `within`.
|
||||
fn underline(
|
||||
nodes: &[&Node],
|
||||
within: &Range<usize>,
|
||||
s: usize,
|
||||
e: usize,
|
||||
) -> Option<[Range<usize>; 2]> {
|
||||
let mut tags: Vec<(&str, Range<usize>)> = (nodes.iter())
|
||||
.filter_map(|node| match node {
|
||||
Node::Html(html) => Some((html.value.trim(), span(node))),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(_, at)| within.start <= at.start && at.end <= within.end)
|
||||
.collect();
|
||||
tags.sort_by_key(|(_, at)| at.start);
|
||||
let mut opened = Vec::new();
|
||||
for (tag, at) in tags {
|
||||
if tag.eq_ignore_ascii_case("<u>") {
|
||||
opened.push(at);
|
||||
} else if tag.eq_ignore_ascii_case("</u>")
|
||||
&& let Some(open) = opened.pop()
|
||||
&& open.end <= s
|
||||
&& e <= at.start
|
||||
{
|
||||
// Inner pairs close first.
|
||||
return Some([open, at]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `format` toggled at `selection` in `text`, or why not.
|
||||
pub(super) fn toggle(
|
||||
text: &str,
|
||||
selection: Range<usize>,
|
||||
format: InlineFormat,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
toggle_tree(text, &file_tree(text), selection, format)
|
||||
}
|
||||
|
||||
/// `toggle` on `root`, the tree `text` parses to.
|
||||
pub(super) fn toggle_tree(
|
||||
text: &str,
|
||||
root: &Node,
|
||||
selection: Range<usize>,
|
||||
format: InlineFormat,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
verbatim(&nodes, &selection, format)?;
|
||||
if format == InlineFormat::Clear {
|
||||
return clear::clear(text, &nodes, selection);
|
||||
}
|
||||
let picked = &text[selection.clone()];
|
||||
let s = selection.start + (picked.len() - picked.trim_start().len());
|
||||
let e = (selection.end - (picked.len() - picked.trim_end().len())).max(s);
|
||||
let holds = |node: &Node| {
|
||||
let whole = span(node);
|
||||
let inside = match s == e {
|
||||
true => whole.start < s && s < whole.end,
|
||||
false => whole.start <= s && e <= whole.end,
|
||||
};
|
||||
inside && !in_marks(node, text, s) && !in_marks(node, text, e)
|
||||
};
|
||||
let unwrap = |range: Range<usize>, holes: [Range<usize>; 2]| -> Change {
|
||||
let written = cut(text, range.clone(), &holes);
|
||||
(vec![(range, written)], after(s, &holes)..after(e, &holes))
|
||||
};
|
||||
let found = (nodes.iter())
|
||||
.filter(|node| styled(format, node) && holds(node))
|
||||
.min_by_key(|node| span(node).len());
|
||||
if let Some(node) = found {
|
||||
crossed(text, &nodes, &span(node), s, e, format)?;
|
||||
return Ok(unwrap(
|
||||
span(node),
|
||||
marks(node, text).expect("a styled node has marks"),
|
||||
));
|
||||
}
|
||||
let rooms: Vec<Range<usize>> = (nodes.iter())
|
||||
.filter(|node| block(node))
|
||||
.map(|node| room(node, text))
|
||||
.collect();
|
||||
let within = (rooms.iter())
|
||||
.filter(|range| range.start <= s && e <= range.end)
|
||||
.min_by_key(|range| range.len())
|
||||
.cloned();
|
||||
let Some(within) = within else {
|
||||
if s != e {
|
||||
let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end);
|
||||
return Err(match touched {
|
||||
true => EditRefusal::Across,
|
||||
false => EditRefusal::Outside,
|
||||
});
|
||||
}
|
||||
let line = text[..s]
|
||||
.rsplit(['\r', '\n'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
+ text[s..].split(['\r', '\n']).next().unwrap_or_default();
|
||||
return match line.trim().is_empty() {
|
||||
true => Ok(pair(s, format)),
|
||||
false => Err(EditRefusal::Outside),
|
||||
};
|
||||
};
|
||||
if format == InlineFormat::Underline
|
||||
&& let Some(tags) = underline(&nodes, &within, s, e)
|
||||
{
|
||||
crossed(text, &nodes, &(tags[0].start..tags[1].end), s, e, format)?;
|
||||
return Ok(unwrap(tags[0].start..tags[1].end, tags));
|
||||
}
|
||||
if s != e {
|
||||
return wrap(text, &nodes, &within, s, e, format);
|
||||
}
|
||||
let prev = text[within.start..s].chars().next_back();
|
||||
let next = text[s..within.end].chars().next();
|
||||
if prev.is_none_or(stop) && next.is_none_or(stop) {
|
||||
crossed(text, &nodes, &within, s, s, format)?;
|
||||
return Ok(pair(s, format));
|
||||
}
|
||||
let word = (nodes.iter())
|
||||
.filter(|node| matches!(node, Node::Text(_)))
|
||||
.map(|node| span(node))
|
||||
.find(|run| run.start <= s && s <= run.end)
|
||||
.map(|run| {
|
||||
let start = (text[run.start..s].char_indices().rev())
|
||||
.find(|(_, ch)| stop(*ch))
|
||||
.map_or(run.start, |(at, ch)| run.start + at + ch.len_utf8());
|
||||
let end = s + text[s..run.end].find(stop).unwrap_or(run.end - s);
|
||||
start..end
|
||||
})
|
||||
.filter(|word| !word.is_empty());
|
||||
match word {
|
||||
Some(word) => wrap(text, &nodes, &within, word.start, word.end, format),
|
||||
None => {
|
||||
crossed(text, &nodes, &within, s, s, format)?;
|
||||
Ok(pair(s, format))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks `format` writes around `inner`.
|
||||
fn written(format: InlineFormat, inner: &str) -> (String, String) {
|
||||
let (open, close) = match format {
|
||||
InlineFormat::Strong => ("**", "**"),
|
||||
InlineFormat::Emphasis => ("*", "*"),
|
||||
InlineFormat::Strike => ("~~", "~~"),
|
||||
InlineFormat::Underline => ("<u>", "</u>"),
|
||||
InlineFormat::Link => ("[", "]()"),
|
||||
InlineFormat::Image => ("![", "]()"),
|
||||
InlineFormat::Code => {
|
||||
let longest = inner.split(|ch| ch != '`').map(str::len).max().unwrap_or(0);
|
||||
let fence = "`".repeat(longest + 1);
|
||||
let pad = if inner.starts_with('`') || inner.ends_with('`') {
|
||||
" "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return (format!("{fence}{pad}"), format!("{pad}{fence}"));
|
||||
}
|
||||
InlineFormat::Clear => unreachable!("clear writes no marks"),
|
||||
};
|
||||
(open.into(), close.into())
|
||||
}
|
||||
|
||||
/// An empty pair at `at`, the caret inside it.
|
||||
fn pair(at: usize, format: InlineFormat) -> Change {
|
||||
let (open, close) = written(format, "");
|
||||
let caret = at + open.len();
|
||||
(vec![(at..at, format!("{open}{close}"))], caret..caret)
|
||||
}
|
||||
|
||||
/// Refuses an end in marks, or a mark split off.
|
||||
fn crossed(
|
||||
text: &str,
|
||||
nodes: &[&Node],
|
||||
within: &Range<usize>,
|
||||
s: usize,
|
||||
e: usize,
|
||||
format: InlineFormat,
|
||||
) -> Result<(), EditRefusal> {
|
||||
for node in nodes {
|
||||
let whole = span(node);
|
||||
if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end {
|
||||
continue;
|
||||
}
|
||||
let holes = match node {
|
||||
Node::InlineMath(_) | Node::Html(_) | Node::Break(_) | Node::FootnoteReference(_) => {
|
||||
vec![whole.clone()]
|
||||
}
|
||||
_ => match marks(node, text) {
|
||||
Some(marks) => marks.to_vec(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let strictly = |at: usize| holes.iter().any(|hole| hole.start < at && at < hole.end);
|
||||
let overlaps = holes.iter().any(|hole| s < hole.end && hole.start < e);
|
||||
let holds_all = s <= whole.start && whole.end <= e;
|
||||
// A same style cut is taken in whole.
|
||||
let same = merges(format) && styled(format, node);
|
||||
if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) {
|
||||
return Err(EditRefusal::Across);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `s..e` in `format`, same marks inside taken in.
|
||||
fn wrap(
|
||||
text: &str,
|
||||
nodes: &[&Node],
|
||||
within: &Range<usize>,
|
||||
mut s: usize,
|
||||
mut e: usize,
|
||||
format: InlineFormat,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
crossed(text, nodes, within, s, e, format)?;
|
||||
if merges(format) {
|
||||
for node in nodes.iter().filter(|node| styled(format, node)) {
|
||||
let whole = span(node);
|
||||
if whole.start < e
|
||||
&& s < whole.end
|
||||
&& within.start <= whole.start
|
||||
&& whole.end <= within.end
|
||||
{
|
||||
(s, e) = (s.min(whole.start), e.max(whole.end));
|
||||
}
|
||||
}
|
||||
crossed(text, nodes, within, s, e, format)?;
|
||||
}
|
||||
let mut holes: Vec<Range<usize>> = (nodes.iter())
|
||||
.filter(|node| styled(format, node) && s <= span(node).start && span(node).end <= e)
|
||||
.flat_map(|node| marks(node, text).expect("a styled node has marks"))
|
||||
.filter(|hole| !hole.is_empty())
|
||||
.collect();
|
||||
holes.sort_by_key(|hole| hole.start);
|
||||
let inner = cut(text, s..e, &holes);
|
||||
let (open, close) = written(format, &inner);
|
||||
let at = s + open.len();
|
||||
let selection = match format {
|
||||
InlineFormat::Link | InlineFormat::Image => at + inner.len() + 2..at + inner.len() + 2,
|
||||
_ => at..at + inner.len(),
|
||||
};
|
||||
Ok((vec![(s..e, format!("{open}{inner}{close}"))], selection))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Clear Format: every inline mark off the touched blocks.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
|
||||
use crate::documents::markdown::{math_inside, span};
|
||||
|
||||
/// The backslashes of escapes in a text node's span.
|
||||
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text[whole.clone()].char_indices().peekable();
|
||||
while let Some((at, ch)) = chars.next() {
|
||||
if ch == '\\'
|
||||
&& chars
|
||||
.peek()
|
||||
.is_some_and(|(_, next)| next.is_ascii_punctuation())
|
||||
{
|
||||
out.push(whole.start + at..whole.start + at + 1);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A node's inline marks, as Typora's `.md-meta`.
|
||||
fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>> {
|
||||
let whole = span(node);
|
||||
match node {
|
||||
Node::Html(_) => vec![whole],
|
||||
// Autolink text keeps its backslashes.
|
||||
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
||||
Node::Text(_) => escapes(text, whole),
|
||||
Node::InlineMath(_) => {
|
||||
let inner = math_inside(text, whole.clone());
|
||||
vec![whole.start..inner.start, inner.end..whole.end]
|
||||
}
|
||||
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
||||
_ => marks(node, text).map(Vec::from).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every inline mark in the blocks `selection` touches.
|
||||
pub(super) fn clear(
|
||||
text: &str,
|
||||
nodes: &[&Node],
|
||||
selection: Range<usize>,
|
||||
) -> Result<Change, EditRefusal> {
|
||||
let mut blocks: Vec<Range<usize>> = (nodes.iter())
|
||||
.filter(|node| block(node))
|
||||
.map(|node| room(node, text))
|
||||
.filter(|range| range.start <= selection.end && selection.start <= range.end)
|
||||
.collect();
|
||||
if blocks.is_empty() {
|
||||
return Err(EditRefusal::Outside);
|
||||
}
|
||||
blocks.sort_by_key(|block| block.start);
|
||||
let mut autolinks: Vec<Range<usize>> = (nodes.iter())
|
||||
.filter(|node| matches!(node, Node::Link(_)))
|
||||
.map(|node| span(node))
|
||||
.filter(|whole| !text[whole.clone()].starts_with('['))
|
||||
.collect();
|
||||
autolinks.sort_by_key(|link| link.start);
|
||||
let mut inside: Vec<Vec<Range<usize>>> = vec![Vec::new(); blocks.len()];
|
||||
for hole in (nodes.iter()).flat_map(|node| meta(node, text, &autolinks)) {
|
||||
if let Some(at) = holder(&blocks, &hole).filter(|_| !hole.is_empty()) {
|
||||
inside[at].push(hole);
|
||||
}
|
||||
}
|
||||
let mut holes = Vec::new();
|
||||
let mut edits = Vec::new();
|
||||
for (block, mut cuts) in blocks.into_iter().zip(inside) {
|
||||
if cuts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
cuts.sort_by_key(|hole| hole.start);
|
||||
edits.push((block.clone(), cut(text, block, &cuts)));
|
||||
holes.extend(cuts);
|
||||
}
|
||||
Ok((
|
||||
edits,
|
||||
after(selection.start, &holes)..after(selection.end, &holes),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Lists go on as typed; lines shift in and out.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{Context, Window};
|
||||
use markdown::mdast;
|
||||
|
||||
use super::{
|
||||
MarkdownDocument,
|
||||
cells::{Aim, Move, beside},
|
||||
edit::Landing,
|
||||
};
|
||||
use crate::documents::markdown::{span, tree, walk};
|
||||
|
||||
/// Spaces one indent step adds.
|
||||
const STEP: usize = 4;
|
||||
|
||||
/// Enter at `at`: the edit, none outside list items.
|
||||
pub(super) fn enter(source: &str, at: usize) -> Option<(Range<usize>, String)> {
|
||||
let root = tree(source);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let held: Vec<&mdast::Node> = nodes
|
||||
.into_iter()
|
||||
.filter(|node| span(node).start <= at && at <= span(node).end)
|
||||
.collect();
|
||||
let raw = |node: &&&mdast::Node| matches!(node, mdast::Node::Code(_) | mdast::Node::Math(_));
|
||||
if held.iter().any(|node| raw(&node)) {
|
||||
return None;
|
||||
}
|
||||
let item = held
|
||||
.iter()
|
||||
.filter(|node| matches!(node, mdast::Node::ListItem(_)))
|
||||
.min_by_key(|node| span(node).len())?;
|
||||
// The item may start at its indent; the marker follows.
|
||||
let lead = &source[span(item).start..];
|
||||
let start = span(item).start + lead.len() - lead.trim_start_matches([' ', '\t']).len();
|
||||
let first = source[..start].rfind('\n').map_or(0, |found| found + 1);
|
||||
// Quote marks stay; a parent's marker becomes spaces.
|
||||
let container: String = source[first..start]
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == '>' || ch.is_whitespace() {
|
||||
ch
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rest = &source[start..];
|
||||
let (marker, after) = bullet(rest).or_else(|| ordered(rest))?;
|
||||
let gap = rest[after..].len() - rest[after..].trim_start_matches([' ', '\t']).len();
|
||||
let gap = match &rest[after..after + gap] {
|
||||
"" => " ",
|
||||
written => written,
|
||||
};
|
||||
let body = source[start + after..span(item).end].trim_start();
|
||||
// A bare box lacks `checked` yet is a task.
|
||||
let boxed = ["[ ]", "[x]", "[X]"]
|
||||
.iter()
|
||||
.any(|task| body.starts_with(task));
|
||||
let task = boxed || matches!(item, mdast::Node::ListItem(item) if item.checked.is_some());
|
||||
let body = match boxed {
|
||||
true => &body[3..],
|
||||
false => body,
|
||||
};
|
||||
let end = source[at..]
|
||||
.find('\n')
|
||||
.map_or(source.len(), |found| at + found);
|
||||
if body.trim().is_empty() {
|
||||
// An empty item ends the list.
|
||||
return (at == end).then(|| (start..end, String::new()));
|
||||
}
|
||||
let task = if task { "[ ] " } else { "" };
|
||||
Some((at..at, format!("\n{container}{marker}{gap}{task}")))
|
||||
}
|
||||
|
||||
/// A bullet marker and its length.
|
||||
fn bullet(rest: &str) -> Option<(String, usize)> {
|
||||
let mark = rest.chars().next().filter(|ch| "-*+".contains(*ch))?;
|
||||
Some((mark.to_string(), 1))
|
||||
}
|
||||
|
||||
/// A number marker, counted on, and its length.
|
||||
fn ordered(rest: &str) -> Option<(String, usize)> {
|
||||
let digits = rest.chars().take_while(char::is_ascii_digit).count();
|
||||
if !(1..=9).contains(&digits) {
|
||||
return None;
|
||||
}
|
||||
let close = rest[digits..]
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|ch| ".)".contains(*ch))?;
|
||||
let number: u64 = rest[..digits].parse().expect("ascii digits");
|
||||
Some((format!("{}{close}", number + 1), digits + 1))
|
||||
}
|
||||
|
||||
/// Leading bytes making one step; tabs stop every four.
|
||||
fn outdent(line: &str) -> usize {
|
||||
let mut columns = 0;
|
||||
for (bytes, ch) in line.char_indices() {
|
||||
if columns >= STEP {
|
||||
return bytes;
|
||||
}
|
||||
columns = match ch {
|
||||
' ' => columns + 1,
|
||||
'\t' => (columns / STEP + 1) * STEP,
|
||||
_ => return bytes,
|
||||
};
|
||||
}
|
||||
line.len()
|
||||
}
|
||||
|
||||
/// Lines `range` touches, a step in or out.
|
||||
pub(super) fn shift(text: &str, range: Range<usize>, deeper: bool) -> (String, Range<usize>) {
|
||||
let first = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let last = text[range.end..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| range.end + at);
|
||||
// Each change: where, and bytes added or removed.
|
||||
let mut changes: Vec<(usize, isize)> = Vec::new();
|
||||
let mut at = first;
|
||||
loop {
|
||||
let line = &text[at..text[at..].find('\n').map_or(text.len(), |end| at + end)];
|
||||
match deeper {
|
||||
true => changes.push((at, STEP as isize)),
|
||||
false => match outdent(line) {
|
||||
0 => {}
|
||||
bytes => changes.push((at, -(bytes as isize))),
|
||||
},
|
||||
}
|
||||
match text[at..].find('\n') {
|
||||
Some(end) if at + end < last => at += end + 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut out = String::with_capacity(text.len() + changes.len() * STEP);
|
||||
let mut from = 0;
|
||||
for (at, change) in &changes {
|
||||
out.push_str(&text[from..*at]);
|
||||
from = *at;
|
||||
match *change > 0 {
|
||||
true => out.push_str(&" ".repeat(STEP)),
|
||||
false => from += change.unsigned_abs(),
|
||||
}
|
||||
}
|
||||
out.push_str(&text[from..]);
|
||||
let moved = |pos: usize| {
|
||||
let mut shifted = pos as isize;
|
||||
for (at, change) in &changes {
|
||||
match *change > 0 {
|
||||
true if *at <= pos => shifted += change,
|
||||
false if *at < pos => shifted -= (pos - at).min(change.unsigned_abs()) as isize,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
shifted as usize
|
||||
};
|
||||
(out, moved(range.start)..moved(range.end))
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// Enter on a list line in source; false leaves it.
|
||||
pub(super) fn source_newline(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let editor = self.editor.read(cx);
|
||||
let caret = editor.primary().range();
|
||||
if editor.selections().len() != 1 || !caret.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
log::info!("markdown document: Enter while a long text is read");
|
||||
return false;
|
||||
}
|
||||
let editor = self.editor.read(cx);
|
||||
let text = editor.text();
|
||||
let ix = self.block_at(caret.start);
|
||||
let block = self.parsed.blocks.get(ix).map(|block| block.range.clone());
|
||||
let Some(block) =
|
||||
block.filter(|block| block.start <= caret.start && caret.start <= block.end)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let at = caret.start - block.start;
|
||||
let Some((range, typed)) = enter(&text[block.clone()], at) else {
|
||||
return false;
|
||||
};
|
||||
let range = range.start + block.start..range.end + block.start;
|
||||
let after = range.start + typed.len();
|
||||
let edit = (range, typed);
|
||||
log::info!("markdown document: a list goes on in source");
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([edit], cx);
|
||||
editor.select(std::iter::once(after..after), cx);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Enter on a block list line; false leaves it.
|
||||
pub(super) fn block_newline(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(open) = self.open.as_ref().filter(|open| open.cell.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
let input = open.input.clone();
|
||||
let (text, caret) = {
|
||||
let input = input.read(cx);
|
||||
(input.text().to_string(), input.selection())
|
||||
};
|
||||
if !caret.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((range, typed)) = enter(&text, caret.start) else {
|
||||
return false;
|
||||
};
|
||||
log::info!("markdown document: a list goes on in a block");
|
||||
input.update(cx, |input, cx| {
|
||||
input.select(range, cx);
|
||||
input.insert(&typed, cx);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Tab or Shift-Tab; in a cell, moves between cells.
|
||||
pub(super) fn block_shift(
|
||||
&mut self,
|
||||
deeper: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(open) = &self.open else {
|
||||
return;
|
||||
};
|
||||
if let Some(at) = open.cell {
|
||||
let here = self.block_at(open.range.start);
|
||||
let block = self.parsed.blocks[here].range.clone();
|
||||
let by = if deeper { Move::Next } else { Move::Previous };
|
||||
let text = self.editor.read(cx).text();
|
||||
match beside(text, block, at, by) {
|
||||
Some(to) => self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx),
|
||||
None => log::info!("markdown document: no cell {by:?} of {at:?}"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let input = open.input.clone();
|
||||
let (text, selection) = {
|
||||
let input = input.read(cx);
|
||||
(input.text().to_string(), input.selection())
|
||||
};
|
||||
let (shifted, selection) = shift(&text, selection, deeper);
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(shifted, cx);
|
||||
input.select(selection, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{enter, shift};
|
||||
|
||||
/// Enter at the end of `source`'s line holding `at`.
|
||||
fn entered(source: &str, at: &str) -> Option<String> {
|
||||
let at = source.find(at).expect("in the text") + at.len();
|
||||
let (range, typed) = enter(source, at)?;
|
||||
Some(format!(
|
||||
"{}{typed}{}",
|
||||
&source[..range.start],
|
||||
&source[range.end..]
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_go_on_with_their_marker() {
|
||||
assert_eq!(entered("- a", "a").as_deref(), Some("- a\n- "));
|
||||
assert_eq!(entered(" * a", "a").as_deref(), Some(" * a\n * "));
|
||||
assert_eq!(entered("9. a", "a").as_deref(), Some("9. a\n10. "));
|
||||
assert_eq!(entered("3) a", "a").as_deref(), Some("3) a\n4) "));
|
||||
assert_eq!(
|
||||
entered("- [x] done", "done").as_deref(),
|
||||
Some("- [x] done\n- [ ] ")
|
||||
);
|
||||
assert_eq!(entered("- a\n- ", "- a\n- ").as_deref(), Some("- a\n"));
|
||||
let plain = [
|
||||
("text", "text"),
|
||||
("-a", "-a"),
|
||||
("* * *", "* * *"),
|
||||
("```\n- a\n```", "- a"),
|
||||
("1.a", "1.a"),
|
||||
];
|
||||
for (plain, at) in plain {
|
||||
assert_eq!(entered(plain, at), None, "{plain:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_tabs_and_nesting_carry_on() {
|
||||
assert_eq!(entered("> - a", "a").as_deref(), Some("> - a\n> - "));
|
||||
assert_eq!(entered("-\ta", "a").as_deref(), Some("-\ta\n-\t"));
|
||||
assert_eq!(
|
||||
entered("- a\n\t- b", "b").as_deref(),
|
||||
Some("- a\n\t- b\n\t- ")
|
||||
);
|
||||
assert_eq!(entered("- - a", "a").as_deref(), Some("- - a\n - "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_shift_in_and_out_with_the_selection() {
|
||||
assert_eq!(shift("a\nb", 0..0, true), (" a\nb".into(), 4..4));
|
||||
assert_eq!(shift("a\nb", 1..3, true), (" a\n b".into(), 5..11));
|
||||
assert_eq!(shift(" a", 7..7, false), (" a".into(), 3..3));
|
||||
assert_eq!(shift(" a\nb", 1..1, false), ("a\nb".into(), 0..0));
|
||||
// Spaces and tabs mixed: four columns go.
|
||||
assert_eq!(shift(" \t- b", 6..6, false), ("- b".into(), 3..3));
|
||||
assert_eq!(shift("\t - b", 6..6, false), (" - b".into(), 5..5));
|
||||
assert_eq!(shift(" \t\t- b", 6..6, false), ("\t- b".into(), 4..4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! A writing view: how text shows, not what it says.
|
||||
|
||||
use gpui::{Context, Rems, SharedString};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
/// How the document shows its text (MARK-09).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DocumentLook {
|
||||
/// All but the current block or line fade.
|
||||
pub focus: bool,
|
||||
/// The current block or line stays mid-view.
|
||||
pub typewriter: bool,
|
||||
/// The widest the text column grows.
|
||||
pub measure: Option<Rems>,
|
||||
/// The prose font; none keeps the theme's.
|
||||
pub font: Option<SharedString>,
|
||||
/// Text size against the theme's.
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for DocumentLook {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
focus: false,
|
||||
typewriter: false,
|
||||
measure: None,
|
||||
font: None,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
pub fn look(&self) -> &DocumentLook {
|
||||
&self.look
|
||||
}
|
||||
|
||||
pub fn set_look(&mut self, look: DocumentLook, cx: &mut Context<Self>) {
|
||||
log::info!("markdown document: look {look:?}");
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_scale(look.scale, cx);
|
||||
editor.set_focus_line(look.focus, cx);
|
||||
editor.set_typewriter(look.typewriter, cx);
|
||||
});
|
||||
self.look = look;
|
||||
self.centered = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The reader's block: the open one, or the caret's.
|
||||
pub(super) fn current_block(&self, cx: &gpui::App) -> usize {
|
||||
match &self.open {
|
||||
Some(open) if open.append => self.parsed.blocks.len(),
|
||||
Some(open) => self.block_at(open.range.start),
|
||||
None => self.block_at(self.editor.read(cx).primary().head),
|
||||
}
|
||||
}
|
||||
|
||||
/// Half a view of room at each end, for typewriter.
|
||||
pub(super) fn make_room(&mut self) {
|
||||
let room = match self.look.typewriter && self.mode == MarkdownMode::Visual {
|
||||
true => self.list.viewport_bounds().size.height / 2.0,
|
||||
false => gpui::Pixels::ZERO,
|
||||
};
|
||||
if room != self.room {
|
||||
self.room = room;
|
||||
let last = self.parsed.blocks.len();
|
||||
self.list.remeasure_items(0..1);
|
||||
self.list.remeasure_items(last..last + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current block mid-view once it or its text moves.
|
||||
pub(super) fn center_current(&mut self, cx: &gpui::App) {
|
||||
if !self.look.typewriter || self.mode != MarkdownMode::Visual {
|
||||
return;
|
||||
}
|
||||
let ix = self.current_block(cx);
|
||||
let Some(mut block) = self.list.bounds_for_item(ix) else {
|
||||
return;
|
||||
};
|
||||
// The ends' room is no block.
|
||||
if ix == 0 {
|
||||
block.origin.y += self.room;
|
||||
block.size.height -= self.room;
|
||||
}
|
||||
if ix == self.parsed.blocks.len() {
|
||||
block.size.height -= self.room;
|
||||
}
|
||||
let view = self.list.viewport_bounds();
|
||||
// New layout, resize or end room moves it.
|
||||
let heights = [block.size.height, view.size.height, self.room]
|
||||
.map(|height| f32::from(height).round() as u64);
|
||||
let moved = (ix, self.edits, self.typed, heights);
|
||||
if self.centered == Some(moved) {
|
||||
return;
|
||||
}
|
||||
self.list.scroll_by(block.center().y - view.center().y);
|
||||
self.centered = Some(moved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
mod apart;
|
||||
mod attach;
|
||||
mod block;
|
||||
mod cells;
|
||||
mod edit;
|
||||
mod finding;
|
||||
mod format;
|
||||
mod lists;
|
||||
mod look;
|
||||
mod parse;
|
||||
mod place;
|
||||
mod point;
|
||||
mod tables;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod visual;
|
||||
|
||||
pub(crate) use visual::bind_keys;
|
||||
|
||||
use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Pixels, Subscription, Task, Window,
|
||||
};
|
||||
|
||||
pub use crate::documents::tree::DocumentHeading;
|
||||
pub use attach::Attachment;
|
||||
pub use block::BlockCommand;
|
||||
pub use format::{EditRefusal, InlineFormat};
|
||||
pub use look::DocumentLook;
|
||||
use parse::{Parsed, alike, parsed};
|
||||
pub use point::{DocumentTarget, TargetBlock};
|
||||
pub use tables::{TableEdit, TableRefusal};
|
||||
|
||||
use super::{
|
||||
MarkdownMode,
|
||||
markdown::markdown_highlights,
|
||||
modes::joint,
|
||||
render::{CellAt, OnHeading, OnLink},
|
||||
};
|
||||
use crate::{
|
||||
editor::{CodeEditor, EditorEvent, LineNumbers},
|
||||
forms::TextInput,
|
||||
};
|
||||
|
||||
/// Texts this long parse at once; longer ones elsewhere.
|
||||
const AT_ONCE: usize = 128 << 10;
|
||||
|
||||
/// Longer texts are never read: source alone.
|
||||
pub const LARGEST_READ: usize = 2 << 20;
|
||||
|
||||
/// Typing waits this long for a pause before a read.
|
||||
const PAUSE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// What the document tells its owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DocumentEvent {
|
||||
/// The text changed, typed in any mode or replaced.
|
||||
Changed,
|
||||
/// A new read of the text: blocks and headings.
|
||||
Parsed,
|
||||
/// A secondary press moved the caret onto this.
|
||||
Pointed(DocumentTarget),
|
||||
/// A long format, accepted, did not happen.
|
||||
EditRefused(EditRefusal),
|
||||
}
|
||||
|
||||
/// A top line asked, numbered so late callbacks know theirs.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Placement {
|
||||
line: usize,
|
||||
ask: u64,
|
||||
/// Scrolled; its frame callback finishes it.
|
||||
placing: bool,
|
||||
}
|
||||
|
||||
/// The block open as its source, in its own field.
|
||||
struct Open {
|
||||
range: Range<usize>,
|
||||
input: Entity<TextInput>,
|
||||
/// Opened past the end, to add a block.
|
||||
append: bool,
|
||||
/// A table cell alone; its bare pipes escape.
|
||||
cell: Option<CellAt>,
|
||||
/// Watches an overlay that borrowed focus.
|
||||
lent: Option<Subscription>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A Markdown file: one text, one history, three views.
|
||||
pub struct MarkdownDocument {
|
||||
editor: Entity<CodeEditor>,
|
||||
mode: MarkdownMode,
|
||||
base: Option<Arc<Path>>,
|
||||
parsed: Arc<Parsed>,
|
||||
/// Text changes counted; a read of an older one lapses.
|
||||
edits: u64,
|
||||
_parsing: Option<Task<()>>,
|
||||
editing: bool,
|
||||
list: ListState,
|
||||
open: Option<Open>,
|
||||
/// A dropped block's focus, for the document to take.
|
||||
orphan: Option<FocusHandle>,
|
||||
/// A top line asked for, until the view holds it.
|
||||
top: Option<Placement>,
|
||||
asks: u64,
|
||||
/// Its own write-backs whose changes are still to come.
|
||||
writes: usize,
|
||||
/// A match asked before its text was read; its version.
|
||||
showing: Option<(Range<usize>, u64)>,
|
||||
/// A block asked open before its text was read.
|
||||
waiting: Option<(Option<usize>, edit::Landing, u64)>,
|
||||
look: DocumentLook,
|
||||
/// Typing in the open block, counted.
|
||||
typed: u64,
|
||||
/// Typewriter room past each end of the list.
|
||||
room: Pixels,
|
||||
/// The block, text, typing and heights last centered.
|
||||
centered: Option<(usize, u64, u64, [u64; 3])>,
|
||||
focus: FocusHandle,
|
||||
/// A secondary press took focus.
|
||||
pointing: bool,
|
||||
/// The latest press; older lookups lapse.
|
||||
pressed: u64,
|
||||
on_link: Option<OnLink>,
|
||||
on_heading: Option<OnHeading>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DocumentEvent> for MarkdownDocument {}
|
||||
|
||||
impl Focusable for MarkdownDocument {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => self.editor.focus_handle(cx),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => self.focus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
pub fn new(
|
||||
text: impl Into<String>,
|
||||
mode: MarkdownMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
CodeEditor::new(text, window, cx)
|
||||
.language("Markdown")
|
||||
.line_numbers(LineNumbers::Hidden)
|
||||
.hide_indent_guides()
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
let focus = cx.focus_handle();
|
||||
let subscriptions = vec![
|
||||
cx.subscribe(&editor, |document, _, event, cx| {
|
||||
if *event == EditorEvent::Changed {
|
||||
document.changed(cx);
|
||||
}
|
||||
}),
|
||||
cx.on_focus(&focus, window, Self::focused),
|
||||
];
|
||||
let overdraw = window.rem_size() * 40.0;
|
||||
let this = cx.entity().downgrade();
|
||||
let on_heading: OnHeading = Rc::new(move |at, window, cx| {
|
||||
this.update(cx, |document, cx| document.reveal_at(at, window, cx))
|
||||
.expect("a heading list lives in its document");
|
||||
});
|
||||
let mut document = Self {
|
||||
editor,
|
||||
mode,
|
||||
base: None,
|
||||
parsed: Arc::default(),
|
||||
edits: 0,
|
||||
_parsing: None,
|
||||
editing: false,
|
||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||
open: None,
|
||||
orphan: None,
|
||||
top: None,
|
||||
asks: 0,
|
||||
writes: 0,
|
||||
showing: None,
|
||||
waiting: None,
|
||||
look: DocumentLook::default(),
|
||||
typed: 0,
|
||||
centered: None,
|
||||
room: Pixels::ZERO,
|
||||
focus,
|
||||
pointing: false,
|
||||
pressed: 0,
|
||||
on_link: None,
|
||||
on_heading: Some(on_heading),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
document.reparse(true, cx);
|
||||
document
|
||||
}
|
||||
|
||||
/// The folder relative pictures load from.
|
||||
pub fn set_base(&mut self, folder: impl Into<Arc<Path>>, cx: &mut Context<Self>) {
|
||||
self.base = Some(folder.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Takes link presses; without it the system opens them.
|
||||
pub fn on_link(&mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) {
|
||||
self.on_link = Some(Rc::new(handler));
|
||||
}
|
||||
|
||||
pub fn editor(&self) -> &Entity<CodeEditor> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> MarkdownMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Shows `mode` at the same line; an open block writes.
|
||||
pub fn set_mode(&mut self, mode: MarkdownMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
log::info!("markdown document: {:?} to {mode:?}", self.mode);
|
||||
if !self.read_now(cx) {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
let listed = |mode| matches!(mode, MarkdownMode::Visual | MarkdownMode::Preview);
|
||||
// Visual and preview share one list and its scroll.
|
||||
let top = (!(listed(self.mode) && listed(mode))).then(|| self.top_line(cx));
|
||||
self.mode = mode;
|
||||
if let Some(top) = top {
|
||||
self.set_top_line(top, window, cx);
|
||||
}
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
// Preview to visual keeps one handle: no focus event.
|
||||
self.focused(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The text now, an open block's typing included.
|
||||
pub fn text(&self, cx: &App) -> String {
|
||||
let text = self.editor.read(cx).text();
|
||||
let Some(open) = &self.open else {
|
||||
return text.to_string();
|
||||
};
|
||||
let typed = open.input.read(cx).text();
|
||||
let written = match open.append {
|
||||
true if typed.is_empty() => String::new(),
|
||||
true => format!("{}{typed}", joint(text)),
|
||||
false if open.cell.is_some() => {
|
||||
cells::kept(typed, cells::edges(text, open.range.clone()))
|
||||
}
|
||||
false => typed.to_string(),
|
||||
};
|
||||
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
||||
}
|
||||
|
||||
/// Replaces the text in one step; open typing drops.
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.drop_open(cx);
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.set_text(text, cx));
|
||||
}
|
||||
|
||||
/// New text, undo afresh; caret and top line stay.
|
||||
pub fn reset(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
let ((line, column), top) = (self.caret(cx), self.top_line(cx));
|
||||
self.drop_open(cx);
|
||||
self.editor.update(cx, |editor, cx| editor.reset(text, cx));
|
||||
self.set_caret(line, column, cx);
|
||||
// Placed once the new text is read.
|
||||
self.asks += 1;
|
||||
self.top = Some(Placement {
|
||||
line: top,
|
||||
ask: self.asks,
|
||||
placing: false,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The headings as last read, with lines from zero.
|
||||
pub fn outline(&self, cx: &App) -> Vec<(DocumentHeading, usize)> {
|
||||
let editor = self.editor.read(cx);
|
||||
self.parsed
|
||||
.headings
|
||||
.iter()
|
||||
.filter(|heading| heading.at <= editor.text().len())
|
||||
.map(|heading| (heading.clone(), editor.buffer.line_of(heading.at)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the blocks shown read the text now.
|
||||
pub fn read_now(&self, cx: &App) -> bool {
|
||||
self.parsed.text == self.editor.read(cx).text()
|
||||
}
|
||||
|
||||
fn changed(&mut self, cx: &mut Context<Self>) {
|
||||
// Only the document writes while a block is open.
|
||||
if self.writes > 0 {
|
||||
self.writes -= 1;
|
||||
} else if self.open.is_some() {
|
||||
log::error!("markdown document: the text changed under the open block");
|
||||
self.drop_open(cx);
|
||||
}
|
||||
self.replace_top();
|
||||
self.edits += 1;
|
||||
cx.emit(DocumentEvent::Changed);
|
||||
// Typed source reads after a pause.
|
||||
let typed = matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split);
|
||||
self.reparse(!typed, cx);
|
||||
}
|
||||
|
||||
/// Reads the text: short ones now, else after a pause.
|
||||
fn reparse(&mut self, now: bool, cx: &mut Context<Self>) {
|
||||
let (text, edits) = (self.editor.read(cx).text().to_string(), self.edits);
|
||||
if text.len() > LARGEST_READ {
|
||||
self._parsing = None;
|
||||
log::info!("markdown document: {} bytes, too long to read", text.len());
|
||||
// Nothing read: no blocks, no headings.
|
||||
let unread = Parsed {
|
||||
text,
|
||||
..Parsed::default()
|
||||
};
|
||||
return self.adopt(unread, edits, cx);
|
||||
}
|
||||
if now && text.len() <= AT_ONCE {
|
||||
self._parsing = None;
|
||||
return self.adopt(parsed(&text), edits, cx);
|
||||
}
|
||||
self._parsing = Some(cx.spawn(async move |document, cx| {
|
||||
if !now {
|
||||
cx.background_executor().timer(PAUSE).await;
|
||||
}
|
||||
let read = cx.background_spawn(async move { parsed(&text) }).await;
|
||||
document
|
||||
.update(cx, |document, cx| document.adopt(read, edits, cx))
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Takes the read of edit `edits`; older reads drop.
|
||||
fn adopt(&mut self, read: Parsed, edits: u64, cx: &mut Context<Self>) {
|
||||
if edits != self.edits {
|
||||
return log::debug!("markdown document: read {edits} lapsed");
|
||||
}
|
||||
let (head, tail) = alike(&self.parsed, &read);
|
||||
let old = self.parsed.blocks.len();
|
||||
self.list
|
||||
.splice(head..old - tail, read.blocks.len() - head - tail);
|
||||
let headings: Vec<usize> = read.headings.iter().map(|heading| heading.at).collect();
|
||||
self.editor
|
||||
.update(cx, |editor, _| editor.set_anchors(headings));
|
||||
self.parsed = Arc::new(read);
|
||||
cx.emit(DocumentEvent::Parsed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Closes the open block without writing it.
|
||||
fn drop_open(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(open) = self.open.take() {
|
||||
self.orphan = Some(open.input.focus_handle(cx));
|
||||
log::info!("markdown document: the open block closed unwritten");
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast;
|
||||
|
||||
use crate::documents::{
|
||||
markdown::{file_tree, span},
|
||||
tree::{DocumentHeading, Node, Reader},
|
||||
};
|
||||
|
||||
use super::TargetBlock;
|
||||
|
||||
/// A top-level block: its source, and what it draws.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Block {
|
||||
pub range: Range<usize>,
|
||||
pub kind: TargetBlock,
|
||||
/// None shows a link definition as written.
|
||||
pub nodes: Option<Vec<Node>>,
|
||||
}
|
||||
|
||||
/// A file read as blocks and headings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Parsed {
|
||||
/// The text read, to compare blocks across reads.
|
||||
pub text: String,
|
||||
pub blocks: Vec<Block>,
|
||||
pub headings: Vec<DocumentHeading>,
|
||||
}
|
||||
|
||||
/// `text` read whole; every byte is in or between blocks.
|
||||
pub(crate) fn parsed(text: &str) -> Parsed {
|
||||
let root = file_tree(text);
|
||||
let reader = Reader::new(text, &root);
|
||||
let blocks = root
|
||||
.children()
|
||||
.expect("the root holds the blocks")
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let range = span(node);
|
||||
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
||||
let raw = matches!(node, mdast::Node::Definition(_));
|
||||
let nodes = (!raw).then(|| {
|
||||
let mut out = Vec::new();
|
||||
reader.top(node, &mut out);
|
||||
out.iter_mut().for_each(|drawn| drawn.rebase(range.start));
|
||||
out
|
||||
});
|
||||
Block {
|
||||
range: range.start..range.start + kept,
|
||||
kind: TargetBlock::of(node, text),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Parsed {
|
||||
text: text.to_string(),
|
||||
blocks,
|
||||
headings: reader.headings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks alike at both ends: `(head, tail)` counts to keep.
|
||||
pub(crate) fn alike(old: &Parsed, new: &Parsed) -> (usize, usize) {
|
||||
let same = |a: &Block, b: &Block| {
|
||||
a.nodes == b.nodes && old.text[a.range.clone()] == new.text[b.range.clone()]
|
||||
};
|
||||
let head = old
|
||||
.blocks
|
||||
.iter()
|
||||
.zip(&new.blocks)
|
||||
.take_while(|(a, b)| same(a, b))
|
||||
.count();
|
||||
let room = old.blocks.len().min(new.blocks.len()) - head;
|
||||
let tail = old
|
||||
.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(new.blocks.iter().rev())
|
||||
.take(room)
|
||||
.take_while(|(a, b)| same(a, b))
|
||||
.count();
|
||||
(head, tail)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::documents::tree::Style;
|
||||
|
||||
#[test]
|
||||
fn every_block_is_shown_definitions_as_written() {
|
||||
let text = "---\ntitle: a\n---\n\n# Lift\n\n[site]: https://ely.dev\n\n<div>x</div>\n\n> ## Quoted\n";
|
||||
let found = parsed(text);
|
||||
let shown: Vec<(&str, bool)> = found
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| (&text[block.range.clone()], block.nodes.is_some()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("---\ntitle: a\n---", true),
|
||||
("# Lift", true),
|
||||
("[site]: https://ely.dev", false),
|
||||
("<div>x</div>", true),
|
||||
("> ## Quoted", true),
|
||||
]
|
||||
);
|
||||
let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn");
|
||||
let text = "title: a".into();
|
||||
assert_eq!(drawn(0), [Node::FrontMatter { text, from: 4 }]);
|
||||
assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x"));
|
||||
let headings: Vec<(u8, &str, usize)> = found
|
||||
.headings
|
||||
.iter()
|
||||
.map(|heading| (heading.depth, heading.title.as_str(), heading.at))
|
||||
.collect();
|
||||
assert_eq!(headings, [(1, "Lift", 18), (2, "Quoted", 67)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_extended_syntax_draws_as_content() {
|
||||
let text = "| a |\n| - |\n| 1 |\n\n- [x] done\n\n~~gone~~\n\n$$\nx^2\n$$\n\n```mermaid\ngraph TD\nA-->B\n```\n\nNote[^1]\n\n[^1]: foot\n";
|
||||
let found = parsed(text);
|
||||
let drawn: Vec<&Node> = found
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match &block.nodes {
|
||||
Some(nodes) => &nodes[0],
|
||||
None => panic!("{:?} shows raw", &text[block.range.clone()]),
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(drawn[0], Node::Table { .. }));
|
||||
assert!(matches!(drawn[1], Node::List { items, .. } if items[0].0 == Some(true)));
|
||||
assert!(
|
||||
matches!(drawn[2], Node::Paragraph(line) if line.styles.iter().any(|(_, style)| *style == Style::Strike))
|
||||
);
|
||||
assert!(matches!(drawn[3], Node::Math(formula) if formula == "x^2"));
|
||||
assert!(
|
||||
matches!(drawn[4], Node::Code { language: Some(language), .. } if language == "mermaid")
|
||||
);
|
||||
assert!(
|
||||
matches!(drawn[5], Node::Paragraph(line) if line.styles.iter().any(|(_, style)| *style == Style::Footnote))
|
||||
);
|
||||
assert!(matches!(drawn[6], Node::Footnote { label, .. } if label == "1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_the_blocks_around_it() {
|
||||
let old = parsed("# A\n\nb\n\nc\n\nd\n");
|
||||
let new = parsed("# A\n\nb and more\n\nc\n\nd\n");
|
||||
assert_eq!(alike(&old, &new), (1, 2));
|
||||
assert_eq!(alike(&old, &old), (4, 0), "all kept from the head");
|
||||
let shorter = parsed("# A\n");
|
||||
assert_eq!(alike(&old, &shorter), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_and_code_count_from_their_block() {
|
||||
let old = parsed(
|
||||
"a
|
||||
|
||||

|
||||
|
||||
```
|
||||
x
|
||||
```
|
||||
",
|
||||
);
|
||||
let new = parsed(
|
||||
"a and more
|
||||
|
||||

|
||||
|
||||
```
|
||||
x
|
||||
```
|
||||
",
|
||||
);
|
||||
assert_eq!(alike(&old, &new), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_resolves_across_blocks() {
|
||||
let found = parsed("[site]\n\n[site]: https://ely.dev\n");
|
||||
let Some(nodes) = &found.blocks[0].nodes else {
|
||||
panic!("the paragraph draws")
|
||||
};
|
||||
let Node::Paragraph(text) = &nodes[0] else {
|
||||
panic!("a paragraph, not {nodes:?}")
|
||||
};
|
||||
assert_eq!(text.links, [(0..4, "https://ely.dev".to_string())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
||||
|
||||
use super::{MarkdownDocument, Placement, cells};
|
||||
use crate::documents::{MarkdownMode, modes::joint};
|
||||
|
||||
/// Line and column, from zero, of byte `at` in `text`.
|
||||
fn point(text: &str, at: usize) -> (usize, usize) {
|
||||
let before = &text[..at];
|
||||
let line_start = before.rfind('\n').map_or(0, |newline| newline + 1);
|
||||
(
|
||||
before.matches('\n').count(),
|
||||
before[line_start..].chars().count(),
|
||||
)
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// The caret's line and column from zero, every mode.
|
||||
pub fn caret(&self, cx: &App) -> (usize, usize) {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(open) = &self.open else {
|
||||
return point(editor.text(), editor.primary().head);
|
||||
};
|
||||
let input = open.input.read(cx);
|
||||
let joined = match open.append && !input.text().is_empty() {
|
||||
true => joint(editor.text()).len(),
|
||||
false => 0,
|
||||
};
|
||||
// A cell's caret reads its escaped write-back.
|
||||
let cursor = match open.cell {
|
||||
Some(_) => {
|
||||
let edges = cells::edges(editor.text(), open.range.clone());
|
||||
cells::kept_at(input.text(), input.cursor(), edges)
|
||||
}
|
||||
None => input.cursor(),
|
||||
};
|
||||
point(&self.text(cx), open.range.start + joined + cursor)
|
||||
}
|
||||
|
||||
/// The selected Markdown: the source's, or the open block's.
|
||||
pub fn selected(&self, cx: &App) -> String {
|
||||
let Some(open) = &self.open else {
|
||||
// Rendered modes show no source selection.
|
||||
if !matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split) {
|
||||
return String::new();
|
||||
}
|
||||
let editor = self.editor.read(cx);
|
||||
return editor.text()[editor.primary().range()].to_string();
|
||||
};
|
||||
let input = open.input.read(cx);
|
||||
input.text()[input.selection()].to_string()
|
||||
}
|
||||
|
||||
/// The caret to `line` and `column`, clamped to text.
|
||||
pub fn set_caret(&mut self, line: usize, column: usize, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
let editor = self.editor.read(cx);
|
||||
let range = editor
|
||||
.buffer
|
||||
.line_range(line.min(editor.buffer.lines() - 1));
|
||||
let text = &editor.text()[range.clone()];
|
||||
let within = text
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(text.len(), |(at, _)| at);
|
||||
let at = range.start + within;
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The first line in view; block lines share its height.
|
||||
pub fn top_line(&self, cx: &App) -> usize {
|
||||
let editor = self.editor.read(cx);
|
||||
if let Some(top) = self.top {
|
||||
return top.line.min(editor.buffer.lines() - 1);
|
||||
}
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => editor.top_line().unwrap_or(0),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let ListOffset {
|
||||
item_ix,
|
||||
offset_in_item,
|
||||
} = self.list.logical_scroll_top();
|
||||
let Some(block) = self.parsed.blocks.get(item_ix) else {
|
||||
return editor.buffer.lines() - 1;
|
||||
};
|
||||
let first = editor.buffer.line_of(block.range.start);
|
||||
let lines = editor.buffer.line_of(block.range.end) + 1 - first;
|
||||
let into = self.list.bounds_for_item(item_ix).map_or(0.0, |bounds| {
|
||||
offset_in_item.as_f32() / bounds.size.height.as_f32().max(1.0)
|
||||
});
|
||||
first + ((into * lines as f32).round() as usize).min(lines - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls `line` to the top; the caret stays.
|
||||
pub fn set_top_line(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.asks += 1;
|
||||
self.top = Some(Placement {
|
||||
line,
|
||||
ask: self.asks,
|
||||
placing: false,
|
||||
});
|
||||
self.place_top(window, cx);
|
||||
}
|
||||
|
||||
/// A placement in flight goes stale; it is placed again.
|
||||
pub(super) fn replace_top(&mut self) {
|
||||
if let Some(top) = &mut self.top {
|
||||
self.asks += 1;
|
||||
(top.ask, top.placing) = (self.asks, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the view to the asked top once read.
|
||||
pub(super) fn place_top(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(Placement {
|
||||
line,
|
||||
ask,
|
||||
placing: false,
|
||||
}) = self.top
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let editor = self.editor.read(cx);
|
||||
let line = line.min(editor.buffer.lines() - 1);
|
||||
if matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split) {
|
||||
editor
|
||||
.scroll
|
||||
.scroll_to_item_strict(line + editor.pad, ScrollStrategy::Top);
|
||||
self.placing(ask);
|
||||
// The editor's offset holds it from the next frame.
|
||||
return cx.on_next_frame(window, move |document, _, cx| {
|
||||
document.placed(ask, None, cx)
|
||||
});
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return;
|
||||
}
|
||||
let editor = self.editor.read(cx);
|
||||
let at = editor.buffer.line_range(line).start;
|
||||
let item_ix = self.block_at(at);
|
||||
let Some(block) = self.parsed.blocks.get(item_ix) else {
|
||||
self.top = None;
|
||||
return;
|
||||
};
|
||||
let first = editor.buffer.line_of(block.range.start);
|
||||
let lines = editor.buffer.line_of(block.range.end) + 1 - first;
|
||||
let into = line.saturating_sub(first) as f32 / lines as f32;
|
||||
self.list.scroll_to(ListOffset {
|
||||
item_ix,
|
||||
offset_in_item: px(0.),
|
||||
});
|
||||
self.placing(ask);
|
||||
cx.notify();
|
||||
// A block's height is known once drawn.
|
||||
cx.on_next_frame(window, move |document, _, cx| {
|
||||
document.placed(ask, Some((item_ix, into)), cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn placing(&mut self, ask: u64) {
|
||||
if let Some(top) = self.top.as_mut().filter(|top| top.ask == ask) {
|
||||
top.placing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame after placing ask `ask`; stale asks do nothing.
|
||||
fn placed(&mut self, ask: u64, within: Option<(usize, f32)>, cx: &mut Context<Self>) {
|
||||
if self.top.is_none_or(|top| top.ask != ask) {
|
||||
return;
|
||||
}
|
||||
if let Some((item_ix, into)) = within
|
||||
&& let Some(bounds) = self.list.bounds_for_item(item_ix)
|
||||
{
|
||||
self.list.scroll_to(ListOffset {
|
||||
item_ix,
|
||||
offset_in_item: bounds.size.height * into,
|
||||
});
|
||||
}
|
||||
self.top = None;
|
||||
// The view moved: center again.
|
||||
self.centered = None;
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.centered = None;
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The block holding byte `at`, or the one before it.
|
||||
pub(super) fn block_at(&self, at: usize) -> usize {
|
||||
self.parsed
|
||||
.blocks
|
||||
.partition_point(|block| block.range.start <= at)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Reveals the pressed heading where the edits since carried it.
|
||||
pub(super) fn reveal_at(&mut self, at: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let headings = &self.parsed.headings;
|
||||
let Some(index) = headings.iter().position(|heading| heading.at == at) else {
|
||||
return log::warn!("markdown document: a heading read away");
|
||||
};
|
||||
self.commit(cx);
|
||||
let anchors = self.editor.read(cx).anchors();
|
||||
let Some(found) = *anchors.get(index).expect("an anchor per heading read") else {
|
||||
return log::warn!("markdown document: the pressed heading changed as one wrote");
|
||||
};
|
||||
if !self.read_now(cx) {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return log::warn!("markdown document: a heading pressed while the text is read again");
|
||||
}
|
||||
if !self
|
||||
.parsed
|
||||
.headings
|
||||
.iter()
|
||||
.any(|heading| heading.at == found)
|
||||
{
|
||||
return log::warn!("markdown document: the pressed heading reads as no heading now");
|
||||
}
|
||||
let line = self.editor.read(cx).buffer.line_of(found);
|
||||
self.reveal(line, window, cx);
|
||||
}
|
||||
|
||||
/// Line `line` at top and focused, caret at start.
|
||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_caret(line, 0, cx);
|
||||
self.set_top_line(line, window, cx);
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn points_count_lines_and_characters() {
|
||||
assert_eq!(super::point("ab\nçd\n", 6), (1, 2));
|
||||
assert_eq!(super::point("", 0), (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{AppContext as _, Context, Focusable as _, MouseButton, Pixels, Point, Window};
|
||||
use markdown::mdast;
|
||||
|
||||
use super::{
|
||||
DocumentEvent, MarkdownDocument,
|
||||
cells::{self, Aim},
|
||||
edit::Landing,
|
||||
edit::moved,
|
||||
parse::Block,
|
||||
};
|
||||
use crate::documents::{
|
||||
markdown::{file_tree, span, walk},
|
||||
render::{CellAt, OnPoint, Place, Pointed},
|
||||
tree::{Node, Reader, alert, is_toc, lines_at},
|
||||
};
|
||||
|
||||
/// A top-level block's kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TargetBlock {
|
||||
Paragraph,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
Alert,
|
||||
List,
|
||||
Code,
|
||||
Math,
|
||||
Table,
|
||||
Rule,
|
||||
Html,
|
||||
Footnote,
|
||||
Definition,
|
||||
FrontMatter,
|
||||
Toc,
|
||||
}
|
||||
|
||||
impl TargetBlock {
|
||||
/// `node`'s kind, read from `source`.
|
||||
pub(super) fn of(node: &mdast::Node, source: &str) -> Self {
|
||||
match node {
|
||||
mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc,
|
||||
mdast::Node::Paragraph(_) => Self::Paragraph,
|
||||
mdast::Node::Heading(heading) => Self::Heading(heading.depth),
|
||||
mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert,
|
||||
mdast::Node::Blockquote(_) => Self::Quote,
|
||||
mdast::Node::List(_) => Self::List,
|
||||
mdast::Node::Code(_) => Self::Code,
|
||||
mdast::Node::Math(_) => Self::Math,
|
||||
mdast::Node::Table(_) => Self::Table,
|
||||
mdast::Node::ThematicBreak(_) => Self::Rule,
|
||||
mdast::Node::Html(_) => Self::Html,
|
||||
mdast::Node::FootnoteDefinition(_) => Self::Footnote,
|
||||
mdast::Node::Definition(_) => Self::Definition,
|
||||
mdast::Node::Yaml(_) | mdast::Node::Toml(_) => Self::FrontMatter,
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a secondary press landed on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DocumentTarget {
|
||||
/// The pressed source byte; an open block's as unwritten.
|
||||
pub at: usize,
|
||||
/// None past the last block.
|
||||
pub block: Option<TargetBlock>,
|
||||
pub link: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub in_table: bool,
|
||||
/// The code block's text, pressed in one.
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// Presses on block `ix`'s drawing, read as it was drawn.
|
||||
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
|
||||
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
|
||||
let block = parsed.blocks[ix].range.clone();
|
||||
let start = block.start;
|
||||
Rc::new(move |pointed, button, window, cx| {
|
||||
// The drawing counts from its block's start.
|
||||
let at = match &pointed.place {
|
||||
Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn),
|
||||
// Unreadable lines land at their start.
|
||||
Place::Lines { text, from, at } => {
|
||||
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
||||
}
|
||||
Place::At(at) => Some(*at),
|
||||
// A cell's padding lands at its end.
|
||||
Place::Cell(cell) => cells::find(&parsed.text, block.clone(), Aim::At(*cell))
|
||||
.map(|found| found.range.end - start),
|
||||
Place::Block => None,
|
||||
}
|
||||
.map(|at| start + at);
|
||||
// A press in a table opens only its cell.
|
||||
let landing = match (&pointed.place, at) {
|
||||
(Place::Cell(cell), _) => Landing::Cell(Aim::At(*cell)),
|
||||
(_, Some(at)) if pointed.table => Landing::Cell(Aim::Byte(at)),
|
||||
(_, Some(at)) => Landing::At(at),
|
||||
(_, None) => Landing::End,
|
||||
};
|
||||
let ix = Some(ix);
|
||||
document
|
||||
.update(cx, |document, cx| {
|
||||
// The open cell's padding keeps its typing.
|
||||
let open = match pointed.place {
|
||||
Place::Cell(cell) => document.open_cell(&block) == Some(cell),
|
||||
_ => false,
|
||||
};
|
||||
match (button, open) {
|
||||
(MouseButton::Left, true) => document.cell_end(window, cx),
|
||||
(MouseButton::Right, true) => document.point_open(None, cx),
|
||||
(MouseButton::Left, false) => document.open(ix, landing, window, cx),
|
||||
(MouseButton::Right, false) => document.point(ix, at, pointed, window, cx),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.expect("a block's drawing lives in its document");
|
||||
})
|
||||
}
|
||||
|
||||
/// The cell open in `block`, if any.
|
||||
fn open_cell(&self, block: &Range<usize>) -> Option<CellAt> {
|
||||
let open = self.open.as_ref()?;
|
||||
let inside = block.start <= open.range.start && open.range.end <= block.end;
|
||||
open.cell.filter(|_| inside)
|
||||
}
|
||||
|
||||
/// Caret to the open cell's end.
|
||||
fn cell_end(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(open) = &self.open else {
|
||||
return;
|
||||
};
|
||||
let input = open.input.clone();
|
||||
input.update(cx, |input, cx| {
|
||||
let end = input.text().len();
|
||||
input.select(end..end, cx);
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
}
|
||||
|
||||
/// A block's kind, text and what it draws.
|
||||
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
|
||||
let text = self.parsed.text[block.range.clone()].to_string();
|
||||
(block.kind, text, block.nodes.clone())
|
||||
}
|
||||
|
||||
/// Caret to `at`, else block `ix`; the owner hears.
|
||||
pub(super) fn point(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
at: Option<usize>,
|
||||
pointed: Pointed,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pressed += 1;
|
||||
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||
block => block.flatten(),
|
||||
};
|
||||
let kind = block.map(|block| block.kind);
|
||||
let pressed = block.map(|block| self.drawn(block));
|
||||
let code = (pointed.code.as_deref().map(str::to_string)).or_else(|| {
|
||||
match block.and_then(|block| block.nodes.as_deref()) {
|
||||
Some([Node::Code { text, .. }]) => Some(text.clone()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let at = at.or(block.map(|block| block.range.start));
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let at = match at {
|
||||
None => self.editor.read(cx).text().len(),
|
||||
Some(at) => match moved(at, &written) {
|
||||
Some(at) => at,
|
||||
None => return log::warn!("markdown document: the pressed text was written over"),
|
||||
},
|
||||
};
|
||||
// A write-back may change the pressed block.
|
||||
let now = (self.parsed.blocks.iter())
|
||||
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||
.map(|block| self.drawn(block));
|
||||
// A cell beside the written one keeps its place.
|
||||
let beside = pointed.table && now.as_ref().is_some_and(|now| now.0 == TargetBlock::Table);
|
||||
if written.is_some() && pressed.is_some() && now != pressed && !beside {
|
||||
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||
}
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
// Its focus opens no empty block.
|
||||
self.pointing = !self.focus.is_focused(window);
|
||||
window.focus(&self.focus, cx);
|
||||
log::info!("markdown document: a secondary press puts the caret at byte {at}");
|
||||
cx.emit(DocumentEvent::Pointed(DocumentTarget {
|
||||
at,
|
||||
block: kind,
|
||||
link: pointed.link,
|
||||
image: pointed.image,
|
||||
in_table: pointed.table || kind == Some(TargetBlock::Table),
|
||||
code,
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A right press at `position`, else at the field's end.
|
||||
pub(super) fn point_open(&mut self, position: Option<Point<Pixels>>, cx: &mut Context<Self>) {
|
||||
let Some(open) = &self.open else {
|
||||
return log::warn!("markdown document: a press on a block closed since");
|
||||
};
|
||||
let input = open.input.read(cx);
|
||||
let pressed = match position {
|
||||
Some(position) => input.offset_for_point(position),
|
||||
None => input.text().len(),
|
||||
};
|
||||
// A cell's typed pipes shift its bytes.
|
||||
let pressed = match open.cell {
|
||||
Some(_) => {
|
||||
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||
cells::kept_at(input.text(), pressed, edges)
|
||||
}
|
||||
None => pressed,
|
||||
};
|
||||
let taken = self.snapshot(cx);
|
||||
let (full, offset, _) = taken.clone();
|
||||
self.pressed += 1;
|
||||
let press = self.pressed;
|
||||
cx.spawn(async move |document, cx| {
|
||||
let target = cx
|
||||
.background_spawn(async move { target_at(&full, offset + pressed) })
|
||||
.await;
|
||||
document
|
||||
.update(cx, |document, cx| {
|
||||
if document.pressed != press {
|
||||
return log::info!("markdown document: a newer press came first");
|
||||
}
|
||||
if document.open.is_none() || document.snapshot(cx) != taken {
|
||||
return log::info!("markdown document: the pressed block changed first");
|
||||
}
|
||||
log::info!("markdown document: a secondary press in the open block");
|
||||
cx.emit(DocumentEvent::Pointed(target));
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// What byte `at` of `text` holds, read as drawn.
|
||||
fn target_at(text: &str, at: usize) -> DocumentTarget {
|
||||
let root = file_tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let top = (root
|
||||
.children()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter())
|
||||
.find(|node| span(node).start <= at && at <= span(node).end);
|
||||
let block = top.map(|node| TargetBlock::of(node, text));
|
||||
let mut drawn = Vec::new();
|
||||
if let Some(node) = top.filter(|node| !matches!(node, mdast::Node::Definition(_))) {
|
||||
Reader::new(text, &root).top(node, &mut drawn);
|
||||
}
|
||||
let mut flat = Vec::new();
|
||||
flatten(&drawn, &mut flat);
|
||||
let around: Vec<&mdast::Node> = (nodes.iter().copied())
|
||||
.filter(|node| span(node).contains(&at))
|
||||
.collect();
|
||||
let defined = |identifier: &str| {
|
||||
nodes.iter().find_map(|node| match node {
|
||||
mdast::Node::Definition(definition) if definition.identifier == identifier => {
|
||||
Some(definition.url.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let written = || {
|
||||
around.iter().rev().find_map(|node| match node {
|
||||
mdast::Node::Link(link) => Some(link.url.clone()),
|
||||
mdast::Node::LinkReference(link) => defined(&link.identifier),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let picture = around
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|node| matches!(node, mdast::Node::Image(_) | mdast::Node::ImageReference(_)));
|
||||
let shown = picture.and_then(|node| {
|
||||
flat.iter().find_map(|kid| match kid {
|
||||
Node::Image { at, url, link, .. } if *at == span(node).start => {
|
||||
Some((url.clone(), link.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let image = match &shown {
|
||||
Some((url, _)) => Some(url.clone()),
|
||||
None => picture.and_then(|node| match node {
|
||||
mdast::Node::Image(picture) => Some(picture.url.clone()),
|
||||
mdast::Node::ImageReference(picture) => defined(&picture.identifier),
|
||||
_ => None,
|
||||
}),
|
||||
};
|
||||
let link = match shown {
|
||||
Some((_, link)) => link,
|
||||
None => drawn_link(&flat, text, at).unwrap_or_else(written),
|
||||
};
|
||||
let code = around.iter().find_map(|node| match node {
|
||||
mdast::Node::Code(code) => Some(code.value.clone()),
|
||||
_ => None,
|
||||
});
|
||||
DocumentTarget {
|
||||
at,
|
||||
block,
|
||||
link,
|
||||
image,
|
||||
in_table: around
|
||||
.iter()
|
||||
.any(|node| matches!(node, mdast::Node::Table(_))),
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
/// `nodes` and all they hold, depth first.
|
||||
fn flatten<'a>(nodes: &'a [Node], out: &mut Vec<&'a Node>) {
|
||||
for node in nodes {
|
||||
out.push(node);
|
||||
match node {
|
||||
Node::Quote(body) | Node::Footnote { body, .. } | Node::Alert { body, .. } => {
|
||||
flatten(body, out)
|
||||
}
|
||||
Node::List { items, .. } => items.iter().for_each(|(_, body)| flatten(body, out)),
|
||||
Node::Table { head, rows, .. } => {
|
||||
(head.iter().chain(rows.iter().flatten())).for_each(|cell| flatten(cell, out))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The link drawn at source byte `at`; none if undrawn.
|
||||
fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option<Option<String>> {
|
||||
let inline = nodes.iter().find_map(|node| match node {
|
||||
Node::Paragraph(inline) | Node::Heading(_, inline)
|
||||
if (inline.sources.iter()).any(|(_, span, _)| span.contains(&at)) =>
|
||||
{
|
||||
Some(inline)
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
let drawn = inline.drawn_at(text, at)?;
|
||||
let link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn));
|
||||
Some(link.map(|(_, url)| url.clone()))
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Tables change by rows and columns (MARK-06).
|
||||
|
||||
use gpui::Context;
|
||||
use markdown::mdast::{self, AlignKind};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::{
|
||||
MarkdownDocument,
|
||||
block::{self, BlockCommand, eol, line_end, line_start, next_line, prev_line, settled},
|
||||
format::{Change, EditRefusal},
|
||||
};
|
||||
use crate::documents::{
|
||||
MarkdownMode,
|
||||
markdown::{file_tree, span, walk},
|
||||
};
|
||||
|
||||
/// What a table edit does at the caret.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TableEdit {
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
DeleteRow,
|
||||
DeleteColumn,
|
||||
MoveRowUp,
|
||||
MoveRowDown,
|
||||
MoveColumnLeft,
|
||||
MoveColumnRight,
|
||||
/// Every cell padded to its column, as Typora tidies.
|
||||
Tidy,
|
||||
/// The whole table.
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Why a table edit did not happen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TableRefusal {
|
||||
/// No table at the caret.
|
||||
Outside,
|
||||
/// The head row and the last column stay.
|
||||
Kept,
|
||||
/// The caret is in a cell past the head's columns.
|
||||
Beyond,
|
||||
/// Its lines carry different quote or list marks.
|
||||
Mixed,
|
||||
/// Nothing lies past the caret's row or column that way.
|
||||
Edge,
|
||||
}
|
||||
|
||||
/// The table around `at` after `edit`, and the caret.
|
||||
pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Change, TableRefusal> {
|
||||
let root = file_tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let (range, table) = nodes
|
||||
.into_iter()
|
||||
.find_map(|node| match node {
|
||||
mdast::Node::Table(table) if around(node, at) => Some((span(node), table)),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or(TableRefusal::Outside)?;
|
||||
let prefix = &text[line_start(text, range.start)..range.start];
|
||||
let mut line = range.start;
|
||||
while let Some(next) = next_line(text, line).filter(|next| *next < range.end) {
|
||||
if !text[next..].starts_with(prefix) {
|
||||
return Err(TableRefusal::Mixed);
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
let eol = eol(&text[range.clone()]);
|
||||
let rows: Vec<&mdast::Node> = table.children.iter().collect();
|
||||
let row = rows
|
||||
.iter()
|
||||
.rposition(|row| span(row).start <= at)
|
||||
.unwrap_or(0);
|
||||
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
|
||||
let cells = cells_of(rows[row]);
|
||||
let column = cells
|
||||
.iter()
|
||||
.rposition(|cell| span(cell).start <= at)
|
||||
.unwrap_or(0);
|
||||
let offset = cells.get(column).map_or(0, |cell| within(cell, at));
|
||||
let width = table.align.len();
|
||||
// Cells past the head stay as written.
|
||||
let mut grid: Vec<Vec<String>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut cells: Vec<String> = cells_of(row)
|
||||
.iter()
|
||||
.map(|cell| written(text, cell))
|
||||
.collect();
|
||||
if cells.len() < width {
|
||||
cells.resize(width, String::new());
|
||||
}
|
||||
cells
|
||||
})
|
||||
.collect();
|
||||
let mut aligns = table.align.clone();
|
||||
let column_edit = matches!(
|
||||
edit,
|
||||
TableEdit::ColumnLeft
|
||||
| TableEdit::ColumnRight
|
||||
| TableEdit::DeleteColumn
|
||||
| TableEdit::MoveColumnLeft
|
||||
| TableEdit::MoveColumnRight
|
||||
);
|
||||
if column_edit && column >= width {
|
||||
return Err(TableRefusal::Beyond);
|
||||
}
|
||||
let caret = match edit {
|
||||
TableEdit::RowAbove | TableEdit::RowBelow => {
|
||||
let below = edit == TableEdit::RowBelow || row == 0;
|
||||
let at = if below { row + 1 } else { row };
|
||||
grid.insert(at, vec![String::new(); width]);
|
||||
(at, column.min(width - 1), 0)
|
||||
}
|
||||
TableEdit::DeleteRow if row > 0 => {
|
||||
grid.remove(row);
|
||||
(row.min(grid.len() - 1), column.min(width - 1), 0)
|
||||
}
|
||||
TableEdit::ColumnLeft | TableEdit::ColumnRight => {
|
||||
let at = column + usize::from(edit == TableEdit::ColumnRight);
|
||||
grid.iter_mut()
|
||||
.for_each(|cells| cells.insert(at, String::new()));
|
||||
aligns.insert(at, AlignKind::None);
|
||||
(row, at, 0)
|
||||
}
|
||||
TableEdit::DeleteColumn if width > 1 => {
|
||||
grid.iter_mut().for_each(|cells| {
|
||||
cells.remove(column);
|
||||
});
|
||||
aligns.remove(column);
|
||||
(row, column.min(width - 2), 0)
|
||||
}
|
||||
TableEdit::MoveRowUp | TableEdit::MoveRowDown => {
|
||||
let to = match edit {
|
||||
TableEdit::MoveRowUp => row.checked_sub(1),
|
||||
_ => Some(row + 1).filter(|to| *to < grid.len()),
|
||||
}
|
||||
.ok_or(TableRefusal::Edge)?;
|
||||
// GFM reads a head of other width as no table.
|
||||
if row.min(to) == 0 && grid[row.max(to)].len() > width {
|
||||
return Err(TableRefusal::Beyond);
|
||||
}
|
||||
grid.swap(row, to);
|
||||
(to, column, offset)
|
||||
}
|
||||
TableEdit::MoveColumnLeft | TableEdit::MoveColumnRight => {
|
||||
let to = match edit {
|
||||
TableEdit::MoveColumnLeft => column.checked_sub(1),
|
||||
_ => Some(column + 1).filter(|to| *to < width),
|
||||
}
|
||||
.ok_or(TableRefusal::Edge)?;
|
||||
grid.iter_mut().for_each(|cells| cells.swap(column, to));
|
||||
aligns.swap(column, to);
|
||||
(row, to, offset)
|
||||
}
|
||||
TableEdit::Tidy => (row, column, offset),
|
||||
_ => return Err(TableRefusal::Kept),
|
||||
};
|
||||
let (out, caret) = table_text(&grid, &aligns, caret, prefix, eol);
|
||||
let caret = range.start + caret;
|
||||
Ok((vec![(range, out)], caret..caret))
|
||||
}
|
||||
|
||||
/// Removes the table at the caret, as Delete does.
|
||||
pub(super) fn delete(text: &str, at: usize) -> Result<Change, EditRefusal> {
|
||||
let root = file_tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
if !nodes.iter().any(|node| around(node, at)) {
|
||||
return Err(EditRefusal::Cells(TableRefusal::Outside));
|
||||
}
|
||||
block::run(text, at..at, BlockCommand::Delete)
|
||||
}
|
||||
|
||||
/// Whether `node` is a table holding `at`.
|
||||
fn around(node: &mdast::Node, at: usize) -> bool {
|
||||
matches!(node, mdast::Node::Table(_)) && (span(node).start..=span(node).end).contains(&at)
|
||||
}
|
||||
|
||||
/// A cell's source between its pipes.
|
||||
fn written(text: &str, cell: &mdast::Node) -> String {
|
||||
let inside = cell.children().expect("a cell holds inlines");
|
||||
match (inside.first(), inside.last()) {
|
||||
(Some(first), Some(last)) => text[span(first).start..span(last).end].to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `at`'s byte in `cell`'s written text.
|
||||
fn within(cell: &mdast::Node, at: usize) -> usize {
|
||||
let inside = cell.children().expect("a cell holds inlines");
|
||||
match (inside.first(), inside.last()) {
|
||||
(Some(first), Some(last)) => {
|
||||
let start = span(first).start;
|
||||
at.clamp(start, span(last).end) - start
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Display columns, a tab four and controls none, as Typora's.
|
||||
fn width(cell: &str) -> usize {
|
||||
let tabs = cell.matches('\t').count();
|
||||
let shown: usize = cell
|
||||
.split(char::is_control)
|
||||
.map(UnicodeWidthStr::width)
|
||||
.sum();
|
||||
shown + 4 * tabs
|
||||
}
|
||||
|
||||
/// The table's text and the caret at row, column, byte.
|
||||
fn table_text(
|
||||
grid: &[Vec<String>],
|
||||
aligns: &[AlignKind],
|
||||
at: (usize, usize, usize),
|
||||
prefix: &str,
|
||||
eol: &str,
|
||||
) -> (String, usize) {
|
||||
// Typora pads columns 4 to 60 display columns wide.
|
||||
let widths: Vec<usize> = (0..aligns.len())
|
||||
.map(|column| {
|
||||
(grid.iter())
|
||||
.map(|cells| width(&cells[column]))
|
||||
.fold(4, usize::max)
|
||||
.min(60)
|
||||
})
|
||||
.collect();
|
||||
let mut out = String::new();
|
||||
let mut caret = 0;
|
||||
for (row, cells) in grid.iter().enumerate() {
|
||||
out.push('|');
|
||||
for (column, cell) in cells.iter().enumerate() {
|
||||
let room = widths
|
||||
.get(column)
|
||||
.map_or(0, |widest| widest.saturating_sub(width(cell)));
|
||||
let before = match aligns.get(column) {
|
||||
Some(AlignKind::Right) => room,
|
||||
Some(AlignKind::Center) => room / 2,
|
||||
_ => 0,
|
||||
};
|
||||
out.push(' ');
|
||||
out.push_str(&" ".repeat(before));
|
||||
if (row, column) == (at.0, at.1) {
|
||||
caret = out.len() + at.2;
|
||||
}
|
||||
out.push_str(cell);
|
||||
out.push_str(&" ".repeat(room - before));
|
||||
out.push_str(" |");
|
||||
}
|
||||
if row == 0 {
|
||||
out.push_str(eol);
|
||||
out.push_str(prefix);
|
||||
out.push('|');
|
||||
for (align, width) in aligns.iter().zip(&widths) {
|
||||
let dashes = match align {
|
||||
AlignKind::Left => format!(":{}", "-".repeat(width - 1)),
|
||||
AlignKind::Right => format!("{}:", "-".repeat(width - 1)),
|
||||
AlignKind::Center => format!(":{}:", "-".repeat(width - 2)),
|
||||
AlignKind::None => "-".repeat(*width),
|
||||
};
|
||||
out.push_str(&format!(" {dashes} |"));
|
||||
}
|
||||
}
|
||||
if row + 1 < grid.len() {
|
||||
out.push_str(eol);
|
||||
out.push_str(prefix);
|
||||
}
|
||||
}
|
||||
(out, caret)
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// A new table after the caret's line, unless in preview.
|
||||
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
|
||||
if self.mode == MarkdownMode::Preview {
|
||||
return Err(EditRefusal::Preview);
|
||||
}
|
||||
self.commit(cx);
|
||||
let text = self.editor.read(cx).text().to_string();
|
||||
let at = settled(&text, self.editor.read(cx).primary().range().start);
|
||||
let (start, end, eol) = (line_start(&text, at), line_end(&text, at), eol(&text));
|
||||
// A blank line gives way; a written one keeps.
|
||||
let blank = text[start..end].trim().is_empty();
|
||||
let above = prev_line(&text, start).map_or("", |line| &text[line..line_end(&text, line)]);
|
||||
let (from, before) = match (blank, above.trim().is_empty()) {
|
||||
(true, true) => (start, String::new()),
|
||||
(true, false) => (start, eol.to_string()),
|
||||
(false, _) => (end, eol.repeat(2)),
|
||||
};
|
||||
let after = if text[end..].starts_with(&eol.repeat(2)) || end == text.len() {
|
||||
""
|
||||
} else {
|
||||
eol
|
||||
};
|
||||
let grid = vec![vec![String::new(); 3]; 3];
|
||||
let (table, caret) = table_text(&grid, &[AlignKind::None; 3], (0, 0, 0), "", eol);
|
||||
let caret = from + before.len() + caret;
|
||||
log::info!("markdown document: a table goes in");
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(from..end, format!("{before}{table}{after}"))], cx);
|
||||
editor.select(std::iter::once(caret..caret), cx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TableEdit, TableRefusal, edit_table};
|
||||
|
||||
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n";
|
||||
|
||||
/// `text` after `edit` at `at`, the caret as `‸`.
|
||||
fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
||||
let at = text.find(at).expect("in the table");
|
||||
let (edits, caret) = edit_table(text, at, edit)?;
|
||||
let (range, table) = &edits[0];
|
||||
let out = format!("{}{table}{}", &text[..range.start], &text[range.end..]);
|
||||
Ok(format!("{}‸{}", &out[..caret.start], &out[caret.start..]))
|
||||
}
|
||||
|
||||
fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
||||
edited_in(TABLE, at, edit)
|
||||
}
|
||||
|
||||
fn around(table: &str) -> Result<String, TableRefusal> {
|
||||
Ok(format!("Intro\n\n{table}\n\nAfter\n"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_columns_come_and_go() {
|
||||
assert_eq!(
|
||||
edited("3", TableEdit::RowAbove),
|
||||
around(
|
||||
"| A | B |\n| :--- | ---: |\n| 1 | 2 |\n| ‸ | |\n| 3 | 4 |"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
edited("A", TableEdit::RowBelow),
|
||||
around(
|
||||
"| A | B |\n| :--- | ---: |\n| ‸ | |\n| 1 | 2 |\n| 3 | 4 |"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
edited("2", TableEdit::ColumnLeft),
|
||||
around(
|
||||
"| A | | B |\n| :--- | ---- | ---: |\n| 1 | ‸ | 2 |\n| 3 | | 4 |"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
edited("1", TableEdit::DeleteRow),
|
||||
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |")
|
||||
);
|
||||
assert_eq!(
|
||||
edited("B", TableEdit::DeleteColumn),
|
||||
around("| ‸A |\n| :--- |\n| 1 |\n| 3 |")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_columns_move_with_the_caret() {
|
||||
assert_eq!(
|
||||
edited("3", TableEdit::MoveRowUp),
|
||||
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |\n| 1 | 2 |")
|
||||
);
|
||||
assert_eq!(
|
||||
edited("A", TableEdit::MoveRowDown),
|
||||
around("| 1 | 2 |\n| :--- | ---: |\n| ‸A | B |\n| 3 | 4 |")
|
||||
);
|
||||
assert_eq!(
|
||||
edited("2", TableEdit::MoveColumnLeft),
|
||||
around("| B | A |\n| ---: | :--- |\n| ‸2 | 1 |\n| 4 | 3 |")
|
||||
);
|
||||
assert_eq!(edited("A", TableEdit::MoveRowUp), Err(TableRefusal::Edge));
|
||||
assert_eq!(edited("3", TableEdit::MoveRowDown), Err(TableRefusal::Edge));
|
||||
assert_eq!(
|
||||
edited("1", TableEdit::MoveColumnLeft),
|
||||
Err(TableRefusal::Edge)
|
||||
);
|
||||
assert_eq!(
|
||||
edited("4", TableEdit::MoveColumnRight),
|
||||
Err(TableRefusal::Edge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tidy_pads_by_display_width_and_keeps_the_caret() {
|
||||
let text = "| 名前 | x |\n|:-:|-|\n| 長い名前です | yy‸z |\n";
|
||||
let at = text.find('‸').unwrap();
|
||||
let text = text.replace('‸', "");
|
||||
let (edits, caret) = edit_table(&text, at, TableEdit::Tidy).unwrap();
|
||||
let out = format!(
|
||||
"{}‸{}",
|
||||
&edits[0].1[..caret.start],
|
||||
&edits[0].1[caret.start..]
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
"| 名前 | x |\n| :----------: | ---- |\n| 長い名前です | yy‸z |"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewrite_keeps_the_tables_line_ending() {
|
||||
let crlf = "Before\r\n\r\n| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n\r\nAfter\r\n";
|
||||
assert_eq!(
|
||||
edited_in(crlf, "1", TableEdit::Tidy),
|
||||
Ok("Before\r\n\r\n| A | B |\r\n| ---- | ---- |\r\n| ‸1 | 2 |\r\n\r\nAfter\r\n".into())
|
||||
);
|
||||
let cr = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
|
||||
assert_eq!(
|
||||
edited_in(cr, "1", TableEdit::Tidy),
|
||||
Ok("Before\r\r| A | B |\r| ---- | ---- |\r| ‸1 | 2 |\r\rAfter\r".into())
|
||||
);
|
||||
let moved = "| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n";
|
||||
assert_eq!(
|
||||
edited_in(moved, "1", TableEdit::MoveRowUp),
|
||||
Ok("| ‸1 | 2 |\r\n| ---- | ---- |\r\n| A | B |\r\n".into())
|
||||
);
|
||||
let quoted = "> | A |\r> | - |\r> | 1 |\r";
|
||||
assert_eq!(
|
||||
edited_in(quoted, "1", TableEdit::RowBelow),
|
||||
Ok("> | A |\r> | ---- |\r> | 1 |\r> | ‸ |\r".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_counts_four_columns_as_in_typora() {
|
||||
let text = "| a\tb | x |\n| - | - |\n| q | r |\n";
|
||||
assert_eq!(
|
||||
edited_in(text, "q", TableEdit::Tidy),
|
||||
Ok("| a\tb | x |\n| ------ | ---- |\n| ‸q | r |\n".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_head_stays_and_outside_is_no_table() {
|
||||
assert_eq!(edited("A", TableEdit::DeleteRow), Err(TableRefusal::Kept));
|
||||
assert_eq!(
|
||||
edit_table(TABLE, 1, TableEdit::RowBelow).err(),
|
||||
Some(TableRefusal::Outside)
|
||||
);
|
||||
let single = "| A |\n| - |\n| 1 |\n";
|
||||
assert_eq!(
|
||||
edit_table(single, 2, TableEdit::DeleteColumn).err(),
|
||||
Some(TableRefusal::Kept)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_past_the_head_stay_and_take_no_column_edit() {
|
||||
let text = "| A | B |\n| - | - |\n| 1 | 2 | 3 |\n";
|
||||
assert_eq!(
|
||||
edited_in(text, "3", TableEdit::DeleteColumn),
|
||||
Err(TableRefusal::Beyond)
|
||||
);
|
||||
assert_eq!(
|
||||
edited_in(text, "3", TableEdit::MoveColumnLeft),
|
||||
Err(TableRefusal::Beyond)
|
||||
);
|
||||
assert_eq!(
|
||||
edited_in(text, "1", TableEdit::MoveRowUp),
|
||||
Err(TableRefusal::Beyond),
|
||||
"a head wider than its delimiter is no table"
|
||||
);
|
||||
assert_eq!(
|
||||
edited_in(text, "1", TableEdit::RowBelow),
|
||||
Ok("| A | B |\n| ---- | ---- |\n| 1 | 2 | 3 |\n| ‸ | |\n".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_table_keeps_its_marks() {
|
||||
let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n";
|
||||
assert_eq!(
|
||||
edited_in(text, "1", TableEdit::RowBelow),
|
||||
Ok(
|
||||
"> | A | B |\n> | ---- | ---- |\n> | 1 | 2 |\n> | ‸ | |\n"
|
||||
.into()
|
||||
)
|
||||
);
|
||||
let mixed = "- | A |\n | - |\n | 1 |\n";
|
||||
assert_eq!(
|
||||
edited_in(mixed, "1", TableEdit::RowBelow),
|
||||
Err(TableRefusal::Mixed)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use std::{cell::RefCell, path::PathBuf, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, ExternalPaths, FileDropEvent, Image, ImageFormat, Modifiers,
|
||||
TestAppContext, VisualTestContext, point, px,
|
||||
};
|
||||
|
||||
use super::{MarkdownDocument, MarkdownMode, open, opened, press, text};
|
||||
use crate::documents::Attachment;
|
||||
|
||||
/// Attachments the document hands its owner.
|
||||
fn heard(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Rc<RefCell<Vec<Attachment>>> {
|
||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||
let kept = heard.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(document, move |_, attached: &Attachment, _| {
|
||||
kept.borrow_mut().push(attached.clone())
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
heard
|
||||
}
|
||||
|
||||
fn picture() -> Image {
|
||||
Image::from_bytes(ImageFormat::Png, vec![1, 2, 3])
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn dropped_files_go_to_the_owner(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a\n", MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
let at = point(px(100.0), px(100.0));
|
||||
let paths = ExternalPaths(vec![PathBuf::from("shot.png")].into());
|
||||
cx.simulate_event(FileDropEvent::Entered {
|
||||
position: at,
|
||||
paths,
|
||||
});
|
||||
cx.simulate_event(FileDropEvent::Submit { position: at });
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
*heard.borrow(),
|
||||
[Attachment::Files(vec![PathBuf::from("shot.png")])]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_pasted_picture_goes_to_the_owner_and_text_pastes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a\n", MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.write_to_clipboard(ClipboardItem::new_image(&picture()));
|
||||
cx.simulate_keystrokes("secondary-v");
|
||||
assert_eq!(*heard.borrow(), [Attachment::Picture(picture())]);
|
||||
assert_eq!(opened(&document, cx), "a", "the picture typed nothing");
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("b".into()));
|
||||
cx.simulate_keystrokes("secondary-v");
|
||||
assert_eq!(opened(&document, cx), "ab", "text pastes as text");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn text_goes_in_at_the_caret_but_not_in_preview(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("ab\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_caret(0, 1, cx);
|
||||
assert!(document.insert("X", cx));
|
||||
});
|
||||
assert_eq!(text(&document, cx), "aXb\n");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
assert!(!document.update(cx, |document, cx| document.insert("Y", cx)));
|
||||
assert_eq!(text(&document, cx), "aXb\n");
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Block commands as Typora's Paragraph menu (R4.7).
|
||||
|
||||
use gpui::{Modifiers, TestAppContext};
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{open, opened, press, text};
|
||||
use crate::documents::{
|
||||
BlockCommand, EditRefusal, MarkdownMode, document::block::run, markdown::file_tree,
|
||||
};
|
||||
|
||||
/// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection.
|
||||
pub(super) fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
|
||||
let caret = marked.find('‸');
|
||||
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let selection = match caret {
|
||||
Some(at) => at..at,
|
||||
None => {
|
||||
let start = marked.find('⟨').expect("a selection");
|
||||
start..marked.find('⟩').expect("its end") - '⟨'.len_utf8()
|
||||
}
|
||||
};
|
||||
let (edits, selected) = run(&text, selection, command)?;
|
||||
let mut out = text.clone();
|
||||
for (range, with) in edits.into_iter().rev() {
|
||||
out.replace_range(range, &with);
|
||||
}
|
||||
Ok(match selected.is_empty() {
|
||||
true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]),
|
||||
false => format!(
|
||||
"{}⟨{}⟩{}",
|
||||
&out[..selected.start],
|
||||
&out[selected.clone()],
|
||||
&out[selected.end..]
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ok(marked: &str, command: BlockCommand) -> String {
|
||||
ran(marked, command).expect("ran")
|
||||
}
|
||||
|
||||
/// The top-level blocks `text` reads as.
|
||||
pub(super) fn kinds(text: &str) -> Vec<&'static str> {
|
||||
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let root = file_tree(&text);
|
||||
let kids = root.children().map(Vec::as_slice).unwrap_or_default();
|
||||
(kids.iter())
|
||||
.map(|kid| match kid {
|
||||
Node::Paragraph(_) => "p",
|
||||
Node::Heading(_) => "h",
|
||||
Node::Blockquote(_) => "quote",
|
||||
Node::List(_) => "list",
|
||||
Node::Code(_) => "code",
|
||||
Node::Math(_) => "math",
|
||||
Node::ThematicBreak(_) => "rule",
|
||||
Node::Yaml(_) => "yaml",
|
||||
Node::Definition(_) => "definition",
|
||||
Node::FootnoteDefinition(_) => "note",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_set_as_typoras_menu() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸b", Heading(2)), "## a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(2)), "## a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(3)), "### a‸b");
|
||||
assert_eq!(ok("a‸", Promote), "###### a‸");
|
||||
assert_eq!(ok("# a‸", Promote), "# a‸");
|
||||
assert_eq!(ok("## a‸", Promote), "# a‸");
|
||||
assert_eq!(ok("###### a‸", Demote), "a‸");
|
||||
assert_eq!(ok("a‸", Demote), "a‸");
|
||||
assert_eq!(ok("a\n\n‸\n\nb", Heading(2)), "a\n\n## ‸\n\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_line_becomes_the_heading_alone() {
|
||||
let out = ok("a\nb‸\nc", BlockCommand::Heading(1));
|
||||
assert_eq!(out, "a\n# b‸\nc");
|
||||
assert_eq!(kinds(&out), ["p", "h", "p"]);
|
||||
assert_eq!(ok("> a\nb‸", BlockCommand::Heading(1)), "> a\n> # b‸");
|
||||
assert_eq!(ok("> a‸\nb", BlockCommand::Heading(1)), "> # a‸\n> b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setext_headings_relevel_and_go_atx_past_two() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸\n===", Heading(2)), "a‸\n---");
|
||||
assert_eq!(ok("a‸\n===", Heading(3)), "### a‸");
|
||||
assert_eq!(ok("a\nb‸\n---", Heading(4)), "#### a b‸");
|
||||
assert_eq!(ok("a‸\n===", Paragraph), "a‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_made_text_keeps_apart_from_its_neighbors() {
|
||||
use BlockCommand::Paragraph;
|
||||
assert_eq!(ok("a\n# h‸", Paragraph), "a\n\nh‸");
|
||||
assert_eq!(ok("# h‸\nb", Paragraph), "h‸\n\nb");
|
||||
let out = ok("# h‸\n---", Paragraph);
|
||||
assert_eq!(kinds(&out), ["p", "rule"]);
|
||||
assert_eq!(ok("- a\n- # h‸", Paragraph), "- a\n- h‸");
|
||||
let out = ok("> # h‸\nb", Paragraph);
|
||||
assert_eq!(kinds(&out), ["quote", "p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_commands_refuse_or_add_a_paragraph_as_typora() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ran("```\na‸\n```", Heading(1)), Err(EditRefusal::Code));
|
||||
assert_eq!(ran("$$\na‸\n$$", Promote), Err(EditRefusal::Math));
|
||||
assert_eq!(ok("$$\na‸\n$$", Heading(1)), "$$\na\n$$\n\n‸");
|
||||
assert_eq!(
|
||||
ran("---\nt: a‸\n---\n", Heading(1)),
|
||||
Err(EditRefusal::FrontMatter)
|
||||
);
|
||||
assert_eq!(ran("- [ ] a‸", Heading(1)), Err(EditRefusal::Outside));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_wrap_and_unwrap() {
|
||||
use BlockCommand::Quote;
|
||||
assert_eq!(ok("a‸", Quote), "> a‸");
|
||||
assert_eq!(ok("> a‸", Quote), "a‸");
|
||||
assert_eq!(ok("> a\n> b‸", Quote), "a\nb‸");
|
||||
assert_eq!(ok("> a\nb‸", Quote), "a\nb‸");
|
||||
assert_eq!(ok("⟨a\n\nb⟩", Quote), "> ⟨a\n>\n> b⟩");
|
||||
assert_eq!(ok("x\n> a‸", Quote), "x\n\na‸");
|
||||
assert_eq!(ok("> [!NOTE]\n> a‸", Quote), "a‸");
|
||||
assert_eq!(ok("- a‸", Quote), "- > a‸");
|
||||
assert_eq!(ok("> a\n>\n> ⟨b⟩", Quote), "> a\n>\n> > ⟨b⟩");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_wrap_each_line() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸", BulletList), "- a‸");
|
||||
assert_eq!(ok("a‸", OrderedList), "1. a‸");
|
||||
assert_eq!(ok("a‸", TaskList), "- [ ] a‸");
|
||||
assert_eq!(ok("⟨a\nb⟩", OrderedList), "1. ⟨a\n2. b⟩");
|
||||
assert_eq!(ok("> a‸", BulletList), "> - a‸");
|
||||
assert_eq!(ok("- a\n\n b‸", BulletList), "- a\n\n - b‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_of_the_kind_unwraps_and_another_retypes() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("- a\n- b‸", BulletList);
|
||||
assert_eq!(out, "a\n\nb‸");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
assert_eq!(ok("- a\n- b‸", OrderedList), "1. a\n2. b‸");
|
||||
assert_eq!(ok("- a\n b‸", OrderedList), "1. a\n b‸");
|
||||
assert_eq!(ok("- [ ] a‸", BulletList), "- a‸");
|
||||
assert_eq!(ok("* a‸", TaskList), "* [ ] a‸");
|
||||
assert_eq!(ok("3. a\n4. b‸", BulletList), "- a\n- b‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_split_the_paragraph_at_the_caret() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("ab‸cd", Rule), "ab\n\n---\n\n‸cd");
|
||||
assert_eq!(ok("ab‸", CodeBlock), "ab\n\n```\n‸\n```");
|
||||
assert_eq!(ok("a ⟨b⟩", CodeBlock), "a\n\n```\n‸b\n```");
|
||||
assert_eq!(ok("‸ab", Toc), "[toc]‸\n\nab");
|
||||
assert_eq!(ok("# ab‸cd", Rule), "# ab\n\n---\n\n# ‸cd");
|
||||
assert_eq!(ok("- ab‸", Rule), "- ab\n\n ---\n\n ‸");
|
||||
assert_eq!(ok("```\na‸\n```", Rule), "```\na\n```\n\n---\n\n‸");
|
||||
assert_eq!(ok("a\n\n‸\n\nb", Rule), "a\n\n---\n\n‸\n\nb");
|
||||
assert_eq!(ran("⟨a\n\nb⟩", Rule), Err(EditRefusal::Across));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_and_definitions_take_free_names() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("x[^1]\n\n[^1]: y\n\nab‸", Footnote);
|
||||
assert_eq!(out, "x[^1]\n\n[^1]: y\n\nab\n\n[^2]: ‸");
|
||||
assert_eq!(ok("‸", LinkDefinition), "[‸]: ");
|
||||
assert_eq!(ok("⟨see⟩", LinkDefinition), "[see]: ‸");
|
||||
assert_eq!(ok("⟨a/b.md⟩", LinkDefinition), "[‸]: a/b.md");
|
||||
let out = ok("ab‸", MathBlock);
|
||||
assert_eq!(kinds(&out), ["p", "math"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_paragraphs_come_before_and_after() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸", ParagraphAfter), "a\n\n‸");
|
||||
assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h");
|
||||
let out = ok("- a‸", ParagraphBefore);
|
||||
assert_eq!(out, "- ‸\n a");
|
||||
assert_eq!(kinds(&out), ["list"]);
|
||||
let out = ok("x[^1]\n\n[^1]: a‸", ParagraphBefore);
|
||||
assert_eq!(out, "x[^1]\n\n[^1]: ‸\n a");
|
||||
assert_eq!(kinds(&out), ["p", "note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_goes_on_top_once() {
|
||||
let out = ok("a‸", BlockCommand::FrontMatter);
|
||||
assert_eq!(out, "---\n‸\n---\n\na");
|
||||
assert_eq!(kinds(&out), ["yaml", "p"]);
|
||||
assert_eq!(
|
||||
ran("---\nt: 1\n---\n\na‸", BlockCommand::FrontMatter),
|
||||
Err(EditRefusal::Present)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_takes_the_block_or_its_lone_holder() {
|
||||
use BlockCommand::Delete;
|
||||
assert_eq!(ok("a\n\nb‸\n\nc", Delete), "a\n\n‸c");
|
||||
assert_eq!(ok("x\n\n> b‸\n\nc", Delete), "x\n\n‸c");
|
||||
assert_eq!(ok("- a\n- b‸\n- c", Delete), "- a\n‸- c");
|
||||
assert_eq!(ok("- a‸\n\n b", Delete), "- ‸\n\n b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_follow_the_text() {
|
||||
assert_eq!(
|
||||
ok("ab‸\r\n", BlockCommand::ParagraphAfter),
|
||||
"ab\r\n\r\n‸\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_source_command_is_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||
document.block(BlockCommand::Heading(2), cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "## alpha\n");
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
});
|
||||
assert_eq!(text(&document, cx), "alpha\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_takes_a_command_and_stays_open(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.block(BlockCommand::Quote, cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(opened(&document, cx), "> alpha", "still open to type in");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("first\n\nalpha\n", MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("opened");
|
||||
open.input.update(cx, |input, cx| input.insert("x", cx));
|
||||
document
|
||||
.block(BlockCommand::FrontMatter, cx)
|
||||
.expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let written = text(&document, cx);
|
||||
assert!(written.starts_with("---\n\n---\n"), "{written:?}");
|
||||
assert!(
|
||||
written.contains("alpha") && written.contains('x'),
|
||||
"{written:?}"
|
||||
);
|
||||
document.read_with(cx, |document, _| assert!(document.open.is_none()));
|
||||
}
|
||||
|
||||
/// The one list's items, each as its blocks' kinds.
|
||||
fn items(text: &str) -> Vec<Vec<&'static str>> {
|
||||
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let root = file_tree(&text);
|
||||
let Some(Node::List(list)) = root.children().and_then(|kids| kids.first()) else {
|
||||
panic!("a list first");
|
||||
};
|
||||
(list.children.iter())
|
||||
.map(|item| {
|
||||
(item
|
||||
.children()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter())
|
||||
.map(|kid| match kid {
|
||||
Node::Paragraph(_) => "p",
|
||||
Node::Code(_) => "code",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caret_in_heading_marks_splits_at_the_text() {
|
||||
use BlockCommand::Rule;
|
||||
assert_eq!(ok("‸# alpha", Rule), "---\n\n# ‸alpha");
|
||||
let out = ok("# alpha #‸##", Rule);
|
||||
assert_eq!(kinds(&out), ["h", "rule"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_list_keeps_checked_boxes() {
|
||||
assert_eq!(
|
||||
ok("- a\n- [x] b‸", BlockCommand::TaskList),
|
||||
"- [ ] a\n- [x] b‸"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_in_an_item_keeps_the_item() {
|
||||
let out = ok("- > [!NOTE]\n > a‸\n- b", BlockCommand::Quote);
|
||||
assert_eq!(out, "- a‸\n- b");
|
||||
assert_eq!(items(&out), [vec!["p"], vec!["p"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_closes_and_reopens_inline_marks() {
|
||||
use BlockCommand::Rule;
|
||||
let out = ok("**ab‸cd**", Rule);
|
||||
assert_eq!(out, "**ab**\n\n---\n\n**‸cd**");
|
||||
assert_eq!(kinds(&out), ["p", "rule", "p"]);
|
||||
assert_eq!(ran("[a‸b](u)", Rule), Err(EditRefusal::Across));
|
||||
assert_eq!(ran("`a‸b`", Rule), Err(EditRefusal::Across));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retyped_item_keeps_its_code_indent() {
|
||||
let out = ok("- code‸", BlockCommand::OrderedList);
|
||||
assert_eq!(out, "1. code‸");
|
||||
assert_eq!(items(&out), [vec!["code"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_keeps_neighbors_apart() {
|
||||
let out = ok("p\n# h‸\nq", BlockCommand::Delete);
|
||||
assert_eq!(out, "p\n‸\nq");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_marks_keep_their_text() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("alpha #‸", Heading(1));
|
||||
assert_eq!(out, "# alpha \\#‸");
|
||||
assert_eq!(kinds(&out), ["h"]);
|
||||
let out = ok("# - alpha‸", Paragraph);
|
||||
assert_eq!(out, "\\- alpha‸");
|
||||
assert_eq!(kinds(&out), ["p"]);
|
||||
assert_eq!(kinds(&ok("# 1. a‸", Paragraph)), ["p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tasks_first_text_takes_no_block() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ran("- [ ] a‸", Quote), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] ‸ab", Rule), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] ‸ab", CodeBlock), Err(EditRefusal::Outside));
|
||||
assert_eq!(ran("- [ ] a‸", ParagraphBefore), Err(EditRefusal::Outside));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_past_a_list_takes_its_siblings() {
|
||||
let out = ok("- ⟨a\n\nb⟩", BlockCommand::OrderedList);
|
||||
assert_eq!(out, "1. ⟨a\n\n2. b⟩");
|
||||
assert_eq!(kinds(&out), ["list"]);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Block commands whose results reread as written.
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::block::{kinds, ok, ran};
|
||||
use crate::documents::{BlockCommand, EditRefusal, markdown::file_tree};
|
||||
|
||||
fn plain(marked: &str) -> String {
|
||||
marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect()
|
||||
}
|
||||
|
||||
/// Every node of `text`, in document order.
|
||||
fn nodes(text: &str, keep: &mut dyn FnMut(&Node)) {
|
||||
fn walk(node: &Node, keep: &mut dyn FnMut(&Node)) {
|
||||
keep(node);
|
||||
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
walk(kid, keep);
|
||||
}
|
||||
}
|
||||
walk(&file_tree(text), keep);
|
||||
}
|
||||
|
||||
fn checks(text: &str) -> Vec<Option<bool>> {
|
||||
let mut out = Vec::new();
|
||||
nodes(text, &mut |node| {
|
||||
if let Node::ListItem(item) = node {
|
||||
out.push(item.checked);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn codes(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
nodes(text, &mut |node| {
|
||||
if let Node::Code(code) = node {
|
||||
out.push(code.value.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn texts(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
nodes(text, &mut |node| {
|
||||
if let Node::Text(text) = node {
|
||||
out.push(text.value.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_that_would_reread_otherwise_refuses() {
|
||||
assert_eq!(
|
||||
ran("~~ab‸~cd~~", BlockCommand::Rule),
|
||||
Err(EditRefusal::Misread)
|
||||
);
|
||||
assert_eq!(
|
||||
ran("__ab‸_cd__", BlockCommand::Rule),
|
||||
Err(EditRefusal::Misread)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_rest_that_would_open_a_block_is_escaped() {
|
||||
let out = plain(&ok("a ‸- b", BlockCommand::Rule));
|
||||
assert_eq!(out, "a\n\n---\n\n\\- b");
|
||||
assert_eq!(kinds(&out), ["p", "rule", "p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_hashes_stay_text_wherever_a_heading_line_ends() {
|
||||
let out = plain(&ok("alpha #‸\n===", BlockCommand::Heading(3)));
|
||||
assert_eq!(out, "### alpha \\#");
|
||||
assert_eq!(texts(&out), ["alpha #"]);
|
||||
let out = plain(&ok("# a #‸ b", BlockCommand::Rule));
|
||||
assert_eq!(kinds(&out), ["h", "rule", "h"]);
|
||||
assert_eq!(texts(&out), ["a #", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_reads_the_heading_body_without_closing_hashes() {
|
||||
let out = plain(&ok("# ---‸ ###", BlockCommand::Paragraph));
|
||||
assert_eq!(out, "\\---");
|
||||
assert_eq!(kinds(&out), ["p"]);
|
||||
let out = plain(&ok("# [x]: /u‸ ###", BlockCommand::Paragraph));
|
||||
assert_eq!(kinds(&out), ["p"]);
|
||||
assert_eq!(texts(&out).concat(), "[x]: /u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_box_below_its_marker_stays_checked() {
|
||||
let out = plain(&ok("1.‸\n [x] a", BlockCommand::TaskList));
|
||||
assert_eq!(checks(&out), [Some(true)]);
|
||||
assert_eq!(texts(&out), ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_keeps_code_columns_past_tabs() {
|
||||
let source = "-\t\t‸code";
|
||||
let out = plain(&ok(source, BlockCommand::OrderedList));
|
||||
assert_eq!(codes(&out), codes(&plain(source)));
|
||||
assert_eq!(codes(&out).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_cannot_box_code() {
|
||||
assert_eq!(
|
||||
ran("- ‸a", BlockCommand::TaskList),
|
||||
Err(EditRefusal::Code)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_unwraps_into_its_holder() {
|
||||
assert_eq!(
|
||||
plain(&ok("- > [!NOTE]‸\n a", BlockCommand::Quote)),
|
||||
"- a"
|
||||
);
|
||||
assert_eq!(
|
||||
plain(&ok("> > [!NOTE]‸\n> a", BlockCommand::Quote)),
|
||||
"> a"
|
||||
);
|
||||
let out = plain(&ok("- > [!NOTE]‸\n >\n >\n > a", BlockCommand::Quote));
|
||||
assert_eq!(out, "- a");
|
||||
assert_eq!(kinds(&out), ["list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_marker_sharing_its_line_refuses() {
|
||||
assert_eq!(
|
||||
ran("> [!NOTE] a‸", BlockCommand::Quote),
|
||||
Err(EditRefusal::Misread)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keeps_neighbors_apart_past_gone_openers() {
|
||||
let out = plain(&ok("p\n> # ‸h\nq", BlockCommand::Delete));
|
||||
assert_eq!(out, "p\n\nq");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
}
|
||||
|
||||
fn inline_codes(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
nodes(text, &mut |node| {
|
||||
if let Node::InlineCode(code) = node {
|
||||
out.push(code.value.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_join_keeps_white_space_in_code() {
|
||||
let source = "a `x\n y`‸\n===";
|
||||
match ran(source, BlockCommand::Heading(3)) {
|
||||
Err(EditRefusal::Misread) => {}
|
||||
Ok(out) => assert_eq!(inline_codes(&plain(&out)), inline_codes(&plain(source))),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_that_draws_otherwise_refuses() {
|
||||
for (source, command) in [
|
||||
("<u>ab‸cd</u>", BlockCommand::Rule),
|
||||
("<u>a‸\nb</u>", BlockCommand::Heading(1)),
|
||||
("x:smile: ‸:smile:", BlockCommand::Rule),
|
||||
] {
|
||||
assert_eq!(ran(source, command), Err(EditRefusal::Misread), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_body_block_keeps_its_indent() {
|
||||
let out = plain(&ok("> [!NOTE]\n>\n> co‸de", BlockCommand::Quote));
|
||||
assert_eq!(
|
||||
(kinds(&out), codes(&out)),
|
||||
(vec!["code"], vec!["code".to_string()])
|
||||
);
|
||||
let out = plain(&ok(
|
||||
"> [!NOTE]\n>\n> ```\n> co‸de\n> ```",
|
||||
BlockCommand::Quote,
|
||||
));
|
||||
assert_eq!(
|
||||
(kinds(&out), codes(&out)),
|
||||
(vec!["code"], vec!["code".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_reads_a_fence_from_its_source() {
|
||||
let out = plain(&ok("- ```lang‸", BlockCommand::OrderedList));
|
||||
assert_eq!(codes(&out), vec!["```lang".to_string()]);
|
||||
let out = plain(&ok("- \u{a0}code‸", BlockCommand::OrderedList));
|
||||
assert_eq!(codes(&out), vec!["\u{a0}code".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_box_leaves_text() {
|
||||
assert_eq!(
|
||||
plain(&ok("- [x] ---‸", BlockCommand::BulletList)),
|
||||
"- \\---"
|
||||
);
|
||||
assert_eq!(
|
||||
plain(&ok("- [x] # h‸", BlockCommand::BulletList)),
|
||||
"- \\# h"
|
||||
);
|
||||
assert_eq!(plain(&ok("-\n [x] a‸", BlockCommand::TaskList)), "a");
|
||||
assert_eq!(plain(&ok("- [x] # h‸", BlockCommand::TaskList)), "\\# h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_heading_becomes_an_empty_paragraph() {
|
||||
assert!(ran("# ‸", BlockCommand::Paragraph).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_alert_marker_on_its_own_line_unwraps() {
|
||||
assert_eq!(plain(&ok("> \\[!NOTE]\n> a‸", BlockCommand::Quote)), "a");
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! A press in a table opens only its cell (R6.1).
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{open, opened, point::heard, text};
|
||||
use crate::documents::{MarkdownDocument, MarkdownMode};
|
||||
|
||||
const TABLE: &str = "| a | **bold** |\n| --- | --- |\n| c | |\n";
|
||||
|
||||
/// Presses cell `row`, `column` of the first table.
|
||||
fn press_cell(row: usize, column: usize, cx: &mut VisualTestContext) {
|
||||
press_cell_with(row, column, MouseButton::Left, cx);
|
||||
}
|
||||
|
||||
fn press_cell_with(row: usize, column: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let name = format!("markdown-cell-0-{row}-{column}");
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(name.into_boxed_str()))
|
||||
.expect("the cell is drawn");
|
||||
let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0));
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Presses the open cell's field `x` past its left.
|
||||
fn press_field(x: f32, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let field = cx
|
||||
.debug_bounds("markdown-open-cell")
|
||||
.expect("a cell is open");
|
||||
let at = point(field.left() + px(x), field.top() + px(10.0));
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
|
||||
document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("open")
|
||||
.input
|
||||
.read(cx)
|
||||
.cursor()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where byte `k` of the open cell lies.
|
||||
fn x_of(document: &Entity<MarkdownDocument>, k: usize, cx: &mut VisualTestContext) -> f32 {
|
||||
(0..400)
|
||||
.map(|step| step as f32)
|
||||
.find(|&x| {
|
||||
press_field(x, MouseButton::Left, cx);
|
||||
caret(document, cx) == k
|
||||
})
|
||||
.expect("the byte is drawn")
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_opens_its_cell_and_writes_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "**bold**");
|
||||
assert!(
|
||||
cx.debug_bounds("markdown-cell-0-0-0").is_some(),
|
||||
"the rest stays drawn"
|
||||
);
|
||||
press_cell(1, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "");
|
||||
cx.simulate_input("x|y");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| a | **bold** |\n| --- | --- |\n| c | x\\|y |\n"
|
||||
);
|
||||
cx.simulate_keystrokes("escape");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), TABLE);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tab_and_arrows_walk_the_cells(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
assert_eq!(opened(&document, cx), "a");
|
||||
cx.simulate_keystrokes("tab");
|
||||
assert_eq!(opened(&document, cx), "**bold**");
|
||||
cx.simulate_keystrokes("shift-tab");
|
||||
assert_eq!(opened(&document, cx), "a");
|
||||
cx.simulate_keystrokes("down");
|
||||
assert_eq!(opened(&document, cx), "c");
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
assert_eq!(text(&document, cx), TABLE);
|
||||
}
|
||||
|
||||
const WORDS: &str = "| alpha | beta |\n| --- | --- |\n| c | d |\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_the_open_cell_moves_its_caret_and_keeps_one_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
assert_eq!(caret(&document, cx), 5);
|
||||
cx.simulate_input("X");
|
||||
let x = x_of(&document, 1, cx);
|
||||
press_field(x, MouseButton::Left, cx);
|
||||
assert_eq!(caret(&document, cx), 1, "the cell stays open");
|
||||
cx.simulate_input("Y");
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| aYlphaX | beta |\n| --- | --- |\n| c | d |\n"
|
||||
);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), WORDS);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_match_in_a_table_opens_its_cell_selected(cx: &mut TestAppContext) {
|
||||
let table = "| needle | B |\n| --- | --- |\n| C | tail |\n";
|
||||
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||
let at = table.find("needle").expect("the word");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.show_match(at..at + 6, window, cx)
|
||||
});
|
||||
assert_eq!(opened(&document, cx), "needle");
|
||||
let picked = document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("open")
|
||||
.input
|
||||
.read(cx)
|
||||
.selection()
|
||||
});
|
||||
assert_eq!(picked, 0..6);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_closing_backslash_keeps_the_table(cx: &mut TestAppContext) {
|
||||
let table = "|a|b|\n|---|---|\n|c|d|\n";
|
||||
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("\\");
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(text(&document, cx), "|a\\\\|b|\n|---|---|\n|c|d|\n");
|
||||
press_cell(0, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "b", "still its own cell");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_after_typed_pipes_finds_the_link(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_cell(1, 1, cx);
|
||||
cx.simulate_keystrokes("backspace");
|
||||
cx.simulate_input("||[go](https://ely.dev)");
|
||||
let x = x_of(&document, 3, cx);
|
||||
press_field(x, MouseButton::Right, cx);
|
||||
let target = heard.borrow().last().cloned().expect("heard");
|
||||
assert_eq!(target.link.as_deref(), Some("https://ely.dev"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_on_a_cell_points_at_it(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_cell_with(1, 1, MouseButton::Right, cx);
|
||||
let at = heard.borrow().last().expect("heard").at;
|
||||
assert_eq!(at, WORDS.rfind(" d").expect("the cell") + 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_untouched_backslash_cell_keeps_its_bytes(cx: &mut TestAppContext) {
|
||||
let table = "| a\\ | b |\n| --- | --- |\n| c | d |\n";
|
||||
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
assert_eq!(opened(&document, cx), "a\\");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Source, window, cx)
|
||||
});
|
||||
assert_eq!(text(&document, cx), table);
|
||||
}
|
||||
|
||||
const BARE: &str = "alpha | beta\n--- | ---\nc | d\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn tab_leaves_an_edited_first_cell_without_outer_pipes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
cx.simulate_keystrokes("tab");
|
||||
assert_eq!(opened(&document, cx), "beta");
|
||||
assert_eq!(text(&document, cx), "alphaX | beta\n--- | ---\nc | d\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_neighbour_press_leaves_an_edited_first_cell(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
press_cell(0, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "beta");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_open_cells_padding_lands_at_its_end_in_one_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
let x = x_of(&document, 1, cx);
|
||||
press_field(x, MouseButton::Left, cx);
|
||||
assert_eq!(caret(&document, cx), 1);
|
||||
press_cell(0, 0, cx);
|
||||
assert_eq!(caret(&document, cx), 6, "the padding lands at the end");
|
||||
cx.simulate_input("Y");
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| alphaXY | beta |\n| --- | --- |\n| c | d |\n"
|
||||
);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), WORDS);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_on_the_open_cells_padding_points_at_its_end(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("||");
|
||||
press_cell_with(0, 0, MouseButton::Right, cx);
|
||||
let target = heard.borrow().last().cloned().expect("heard");
|
||||
assert!(target.in_table);
|
||||
assert_eq!(
|
||||
target.at,
|
||||
WORDS.find("alpha").expect("the cell") + "alpha\\|\\|".len()
|
||||
);
|
||||
assert_eq!(opened(&document, cx), "alpha||", "the cell stays open");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_caret_after_a_typed_pipe_reads_the_escaped_cell(cx: &mut TestAppContext) {
|
||||
for (typed, column) in [("|中", 5), ("|x", 5)] {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_keystrokes("backspace backspace backspace backspace backspace");
|
||||
cx.simulate_input(typed);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (0, column), "{typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn emptied_bare_edge_cells_keep_the_table(cx: &mut TestAppContext) {
|
||||
let cases = [
|
||||
(0, 0, 5, "| | beta\n--- | ---\nc | d\n"),
|
||||
(0, 1, 4, "alpha | |\n--- | ---\nc | d\n"),
|
||||
(1, 0, 1, "alpha | beta\n--- | ---\n| | d\n"),
|
||||
];
|
||||
for (row, column, chars, written) in cases {
|
||||
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||
press_cell(row, column, cx);
|
||||
for _ in 0..chars {
|
||||
cx.simulate_keystrokes("backspace");
|
||||
}
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(text(&document, cx), written);
|
||||
press_cell(1, 1, cx);
|
||||
assert_eq!(opened(&document, cx), "d", "{written:?} is still a table");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_beside_an_edited_cell_points(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
press_cell_with(0, 1, MouseButton::Right, cx);
|
||||
let at = heard.borrow().last().expect("heard").at;
|
||||
let written = "| alphaX | beta |\n| --- | --- |\n| c | d |\n";
|
||||
assert_eq!(text(&document, cx), written);
|
||||
assert_eq!(at, written.find("beta").expect("the cell") + 4);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tab_in_a_long_document_opens_the_next_cell_once_read(cx: &mut TestAppContext) {
|
||||
let long = format!("{WORDS}\n```\n{}\n```\n", "x".repeat(super::super::AT_ONCE));
|
||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| document.reveal(0, window, cx));
|
||||
super::frames(cx);
|
||||
press_cell(0, 0, cx);
|
||||
cx.simulate_input("X");
|
||||
cx.simulate_keystrokes("tab");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(opened(&document, cx), "beta");
|
||||
assert!(text(&document, cx).starts_with("| alphaX | beta |"));
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Inline formats toggle as Typora's (R4.6).
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
|
||||
use markdown::mdast::Node;
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{open, opened, press, text};
|
||||
use crate::documents::{
|
||||
DocumentEvent, EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
||||
document::{
|
||||
format::{toggle, toggle_tree},
|
||||
visual,
|
||||
},
|
||||
markdown::file_tree,
|
||||
};
|
||||
|
||||
/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection.
|
||||
fn toggled(marked: &str, format: InlineFormat) -> Result<String, EditRefusal> {
|
||||
let caret = marked.find('‸');
|
||||
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let selection = match caret {
|
||||
Some(at) => at..at,
|
||||
None => {
|
||||
let start = marked.find('⟨').expect("a selection");
|
||||
start..marked.find('⟩').expect("its end") - '⟨'.len_utf8()
|
||||
}
|
||||
};
|
||||
let (edits, selected) = toggle(&text, selection, format)?;
|
||||
let mut out = text.clone();
|
||||
for (range, with) in edits.into_iter().rev() {
|
||||
out.replace_range(range, &with);
|
||||
}
|
||||
Ok(match selected.is_empty() {
|
||||
true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]),
|
||||
false => format!(
|
||||
"{}⟨{}⟩{}",
|
||||
&out[..selected.start],
|
||||
&out[selected.clone()],
|
||||
&out[selected.end..]
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn ok(marked: &str, format: InlineFormat) -> String {
|
||||
toggled(marked, format).expect("toggled")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_wraps_and_a_styled_one_unwraps() {
|
||||
use InlineFormat::*;
|
||||
assert_eq!(ok("a ⟨bold⟩ z", Strong), "a **⟨bold⟩** z");
|
||||
assert_eq!(
|
||||
ok("a ⟨word ⟩z", Strong),
|
||||
"a **⟨word⟩** z",
|
||||
"end space stays out"
|
||||
);
|
||||
assert_eq!(ok("a **⟨bold⟩** z", Strong), "a ⟨bold⟩ z");
|
||||
assert_eq!(ok("⟨a⟩", Emphasis), "*⟨a⟩*");
|
||||
assert_eq!(ok("⟨a⟩", Strike), "~~⟨a⟩~~");
|
||||
assert_eq!(ok("⟨a⟩", Underline), "<u>⟨a⟩</u>");
|
||||
assert_eq!(ok("x ⟨a`b⟩ y", Code), "x ``⟨a`b⟩`` y");
|
||||
assert_eq!(ok("⟨site⟩", Link), "[site](‸)");
|
||||
assert_eq!(ok("⟨cat⟩", Image), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caret_takes_its_word_or_an_empty_pair() {
|
||||
use InlineFormat::*;
|
||||
assert_eq!(ok("a wo‸rd z", Strong), "a **⟨word⟩** z");
|
||||
assert_eq!(ok("a ‸ z", Strong), "a **‸** z");
|
||||
assert_eq!(ok("a\n\n‸", Emphasis), "a\n\n*‸*", "a blank line");
|
||||
assert_eq!(ok("a ‸ z", Link), "a [‸]() z");
|
||||
assert_eq!(
|
||||
ok("x wo‸rd", Strong),
|
||||
"x **⟨word⟩**",
|
||||
"a wide space stops it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caret_in_a_style_takes_it_off() {
|
||||
use InlineFormat::*;
|
||||
assert_eq!(ok("a **b‸old** z", Strong), "a b‸old z");
|
||||
assert_eq!(ok("~~a‸~~", Strike), "a‸");
|
||||
assert_eq!(ok("x `co‸de` y", Code), "x co‸de y");
|
||||
assert_eq!(ok("<u>a‸b</u>", Underline), "a‸b");
|
||||
assert_eq!(ok("[si‸te](https://x)", Link), "si‸te");
|
||||
assert_eq!(ok("", Image), "c‸at");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_bold_selection_takes_the_bold_in() {
|
||||
assert_eq!(ok("a **b** ⟨c⟩", InlineFormat::Strong), "a **b** **⟨c⟩**");
|
||||
assert_eq!(ok("a **⟨b** c⟩", InlineFormat::Strong), "a **⟨b c⟩**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_takes_every_mark_off_the_touched_blocks() {
|
||||
let cleared = ok(
|
||||
"**a** ‸`b` [c](u) <u>d</u> ",
|
||||
InlineFormat::Clear,
|
||||
);
|
||||
assert_eq!(cleared, "a ‸b c d e");
|
||||
assert_eq!(ok("**a‸**\n\n*b*", InlineFormat::Clear), "a‸\n\n*b*");
|
||||
assert_eq!(ok("⟨**a**\n\n*b*⟩", InlineFormat::Clear), "⟨a\n\nb⟩");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_math_html_and_front_matter_refuse() {
|
||||
use EditRefusal as Why;
|
||||
use InlineFormat::*;
|
||||
assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code));
|
||||
assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code));
|
||||
assert_eq!(toggled("$$\nx‸\n$$", Strong), Err(Why::Math));
|
||||
assert_eq!(toggled("a $x‸$ b", Emphasis), Err(Why::Math));
|
||||
assert_eq!(toggled("<div>\nx‸\n</div>", Strong), Err(Why::Html));
|
||||
assert_eq!(
|
||||
toggled("---\nti‸tle: a\n---\n\nbody", Strong),
|
||||
Err(Why::FrontMatter)
|
||||
);
|
||||
assert_eq!(toggled("```\nco‸de\n```", Clear), Err(Why::Code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_across_a_link_or_into_its_address_refuses() {
|
||||
use InlineFormat::*;
|
||||
assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(EditRefusal::Across));
|
||||
assert_eq!(toggled("[a](ht‸tp://x)", Strong), Err(EditRefusal::Across));
|
||||
assert_eq!(toggled("a\n\n> ‸", Strong), Err(EditRefusal::Outside));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_stays_open_and_undoes_as_one_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha beta\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("opened");
|
||||
open.input.update(cx, |input, cx| input.select(6..10, cx));
|
||||
document
|
||||
.format(InlineFormat::Strong, cx)
|
||||
.expect("formatted");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
opened(&document, cx),
|
||||
"alpha **beta**",
|
||||
"still open to type in"
|
||||
);
|
||||
document.update(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("opened");
|
||||
assert_eq!(
|
||||
open.input.read(cx).selection(),
|
||||
8..12,
|
||||
"the word stays picked"
|
||||
);
|
||||
});
|
||||
cx.dispatch_action(visual::Undo);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "alpha beta\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_source_takes_a_format_as_one_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||
document.format(InlineFormat::Code, cx).expect("formatted");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "`alpha` beta\n");
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
});
|
||||
assert_eq!(text(&document, cx), "alpha beta\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_refuses_a_format(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx);
|
||||
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||
assert_eq!(refused, Err(EditRefusal::Preview));
|
||||
assert_eq!(text(&document, cx), "alpha\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_in_marks_or_an_address_refuses() {
|
||||
use InlineFormat::*;
|
||||
let across = Err(EditRefusal::Across);
|
||||
assert_eq!(toggled("a *‸*bold** z", Strong), across);
|
||||
assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across);
|
||||
assert_eq!(toggled("a *⟨*b** c⟩", Strong), across);
|
||||
assert_eq!(toggled("*a⟨b*⟩", Strong), across);
|
||||
assert_eq!(toggled("⟨alpha\n\nbeta⟩", Strong), across);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_spaces_cells_and_cr_lines_take_a_pair() {
|
||||
use InlineFormat::*;
|
||||
assert_eq!(ok("alpha ‸", Strong), "alpha **‸**");
|
||||
assert_eq!(ok("| h |\n| - |\n| ‸ |", Strong), "| h |\n| - |\n| **‸** |");
|
||||
assert_eq!(ok("a\r‸\rb", Strong), "a\r**‸**\rb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_takes_math_footnote_and_escape_marks() {
|
||||
use InlineFormat::Clear;
|
||||
assert_eq!(ok("**a** $x‸+y$", Clear), "a x‸+y");
|
||||
assert_eq!(ok("a‸ [^n]\n\n[^n]: note", Clear), "a‸ n\n\n[^n]: note");
|
||||
assert_eq!(ok(r"a‸ \*b\*", Clear), "a‸ *b*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underline_comes_off_past_other_tags() {
|
||||
use InlineFormat::Underline;
|
||||
assert_eq!(ok("<u>a <br>b‸c</u>", Underline), "a <br>b‸c");
|
||||
assert_eq!(ok("<u>a <em>b‸c</em> d</u>", Underline), "a <em>b‸c</em> d");
|
||||
}
|
||||
|
||||
/// A source document of one word, caret at 1.
|
||||
fn word(cx: &mut TestAppContext, len: usize) -> (Entity<MarkdownDocument>, &mut VisualTestContext) {
|
||||
let (document, cx) = open(&"x".repeat(len), MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
|
||||
});
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_format_reads_apart(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, 64);
|
||||
document.update(cx, |document, cx| {
|
||||
document.format(InlineFormat::Strong, cx).expect("taken")
|
||||
});
|
||||
assert!(
|
||||
!text(&document, cx).starts_with("**"),
|
||||
"not on the UI thread"
|
||||
);
|
||||
cx.run_until_parked();
|
||||
assert!(text(&document, cx).starts_with("**x"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn many_short_paragraphs_format_apart(cx: &mut TestAppContext) {
|
||||
let source = "a\n\n".repeat(100);
|
||||
let (document, cx) = open(&source, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(0..1), cx));
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
});
|
||||
assert_eq!(text(&document, cx), source, "not on the UI thread");
|
||||
cx.run_until_parked();
|
||||
assert!(text(&document, cx).starts_with("**a**\n\na"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, 64);
|
||||
document.update(cx, |document, cx| {
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
text(&document, cx).starts_with("yx"),
|
||||
"the typing stays, unformatted"
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusals a document sends by event.
|
||||
pub(super) fn refusals(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Rc<RefCell<Vec<EditRefusal>>> {
|
||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||
let kept = heard.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
||||
if let DocumentEvent::EditRefused(why) = event {
|
||||
kept.borrow_mut().push(*why);
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
heard
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_lapse_comes_by_event(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, 64);
|
||||
let heard = refusals(&document, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(*heard.borrow(), [EditRefusal::Changed]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwrap_refuses_an_end_in_nested_marks() {
|
||||
use InlineFormat::*;
|
||||
let across = Err(EditRefusal::Across);
|
||||
assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across);
|
||||
assert_eq!(toggled("**~~a~‸~**", Strong), across);
|
||||
assert_eq!(toggled("<u>[a](ht‸tps://x)</u>", Underline), across);
|
||||
assert_eq!(toggled("**a *b⟨* c⟩**", Strong), across);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_keeps_an_autolink_backslash() {
|
||||
assert_eq!(
|
||||
ok(r"a‸ <https://x/\*>", InlineFormat::Clear),
|
||||
r"a‸ https://x/\*"
|
||||
);
|
||||
}
|
||||
|
||||
/// The walk past the parse, on `n` then `4n` repeats.
|
||||
fn linear(unit: &str, run: impl Fn(&str, &Node)) {
|
||||
let time = |n: usize| {
|
||||
let text = unit.repeat(n);
|
||||
let root = file_tree(&text);
|
||||
let start = Instant::now();
|
||||
run(&text, &root);
|
||||
start.elapsed()
|
||||
};
|
||||
let (small, large) = (time(4000), time(16000));
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paragraphs_format_in_linear_time() {
|
||||
linear("a\n\n", |text, root| {
|
||||
toggle_tree(text, root, 0..1, InlineFormat::Strong).expect("toggled");
|
||||
});
|
||||
linear("**a**\n\n", |text, root| {
|
||||
toggle_tree(text, root, 0..text.len(), InlineFormat::Clear).expect("cleared");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_second_format_refuses_while_one_runs(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, 64);
|
||||
let second = document.update(cx, |document, cx| {
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
document.format(InlineFormat::Emphasis, cx)
|
||||
});
|
||||
assert_eq!(second, Err(EditRefusal::Busy));
|
||||
cx.run_until_parked();
|
||||
document.update(cx, |document, cx| {
|
||||
document.format(InlineFormat::Emphasis, cx).expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(text(&document, cx).starts_with("***x"));
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
});
|
||||
assert!(text(&document, cx).starts_with("**x"));
|
||||
assert!(!text(&document, cx).starts_with("***"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_refusal_comes_by_event(cx: &mut TestAppContext) {
|
||||
let source = format!("`code` {}", "x".repeat(64));
|
||||
let (document, cx) = open(&source, MarkdownMode::Source, cx);
|
||||
let heard = refusals(&document, cx);
|
||||
let taken = document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||
document.format(InlineFormat::Strong, cx)
|
||||
});
|
||||
assert_eq!(taken, Ok(()));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(*heard.borrow(), [EditRefusal::Code]);
|
||||
assert_eq!(text(&document, cx), source);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_format_drops_once_preview_shows(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, 64);
|
||||
let heard = refusals(&document, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
document.set_mode(MarkdownMode::Preview, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(text(&document, cx).starts_with("xx"));
|
||||
assert_eq!(*heard.borrow(), [EditRefusal::Preview]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) {
|
||||
let (document, cx) = word(cx, LARGEST_READ + 1);
|
||||
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||
assert_eq!(refused, Err(EditRefusal::TooLong));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_visual_format_reads_apart(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("word\n\nmore\n", MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
|
||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"word\n\nmore\n",
|
||||
"not on the UI thread"
|
||||
);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "**word**\n\nmore\n");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{MarkdownDocument, MarkdownMode, TEXT, open, press, text};
|
||||
|
||||
/// Focus on `document`'s own handle, as an owner gives it.
|
||||
fn focus(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) {
|
||||
let handle = document.read_with(cx, |document, cx| {
|
||||
gpui::Focusable::focus_handle(document, cx)
|
||||
});
|
||||
cx.update(|window, cx| window.focus(&handle, cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_empty_visual_document_takes_typing_on_focus(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_input("Hi");
|
||||
assert_eq!(text(&document, cx), "Hi", "typing lands with no press");
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "Hi", "escape keeps it");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn focus_opens_no_block_in_a_written_document(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_input("x");
|
||||
assert_eq!(text(&document, cx), TEXT);
|
||||
}
|
||||
|
||||
fn mode(document: &Entity<MarkdownDocument>, mode: MarkdownMode, cx: &mut VisualTestContext) {
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(mode, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_and_back_still_take_typing_in_an_empty_document(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_keystrokes("escape");
|
||||
mode(&document, MarkdownMode::Preview, cx);
|
||||
mode(&document, MarkdownMode::Visual, cx);
|
||||
cx.simulate_input("Hi");
|
||||
assert_eq!(text(&document, cx), "Hi");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn undo_in_an_untouched_block_steps_the_document(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Seed", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_text("", cx));
|
||||
mode(&document, MarkdownMode::Visual, cx);
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "Seed", "the source edit is undone");
|
||||
cx.simulate_keystrokes("secondary-shift-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "", "and redone");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn undo_takes_a_typed_block_back_as_one_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Seed", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_text(TEXT, cx));
|
||||
mode(&document, MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
cx.simulate_input(" x");
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), TEXT, "the edit before stays");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn redo_survives_a_switch_to_source(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_input("Hi");
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "");
|
||||
mode(&document, MarkdownMode::Source, cx);
|
||||
cx.simulate_keystrokes("secondary-shift-z");
|
||||
assert_eq!(text(&document, cx), "Hi");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn redo_survives_preview_and_back(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Seed", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_input("!");
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "Seed");
|
||||
mode(&document, MarkdownMode::Preview, cx);
|
||||
mode(&document, MarkdownMode::Visual, cx);
|
||||
cx.simulate_keystrokes("secondary-shift-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "Seed!");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn redo_survives_a_second_undo(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_input("Hi");
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("secondary-shift-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "Hi");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typing_lands_after_undo_empties_the_document(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
focus(&document, cx);
|
||||
cx.simulate_input("Hi");
|
||||
mode(&document, MarkdownMode::Source, cx);
|
||||
mode(&document, MarkdownMode::Visual, cx);
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "");
|
||||
cx.simulate_input("Next");
|
||||
assert_eq!(text(&document, cx), "Next");
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||
KeyUpEvent, Keystroke, Menu, MenuItem, Modifiers, ParentElement as _, Render, Styled as _,
|
||||
TestAppContext, VisualTestContext, Window, actions, div, point, px,
|
||||
};
|
||||
|
||||
use super::{MarkdownMode, opened, press, text};
|
||||
use crate::{
|
||||
documents::{InlineFormat, MarkdownDocument},
|
||||
menus::MenuBar,
|
||||
};
|
||||
|
||||
actions!(lend_tests, [Bold, Away]);
|
||||
|
||||
/// A menu bar over a document.
|
||||
struct Host {
|
||||
document: Entity<MarkdownDocument>,
|
||||
away: FocusHandle,
|
||||
}
|
||||
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.on_action(cx.listener(|host, _: &Bold, _, cx| {
|
||||
(host
|
||||
.document
|
||||
.update(cx, |document, cx| document.format(InlineFormat::Strong, cx)))
|
||||
.expect("formatted");
|
||||
}))
|
||||
.on_action(cx.listener(|host, _: &Away, window, cx| window.focus(&host.away, cx)))
|
||||
.child(MenuBar::new("bar", cx.get_menus().expect("menus")))
|
||||
.child(div().track_focus(&self.away).h(px(20.0)))
|
||||
.child(self.document.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn host(cx: &mut TestAppContext) -> (Entity<MarkdownDocument>, &mut VisualTestContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Format".into(),
|
||||
items: vec![
|
||||
MenuItem::action("Strong", Bold),
|
||||
MenuItem::action("Away", Away),
|
||||
],
|
||||
disabled: false,
|
||||
}])
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|window, cx| Host {
|
||||
document: cx
|
||||
.new(|cx| MarkdownDocument::new("alpha beta\n", MarkdownMode::Visual, window, cx)),
|
||||
away: cx.focus_handle(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
let document = host.read_with(cx, |host, _| host.document.clone());
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("opened").input.clone();
|
||||
input.update(cx, |input, cx| input.select(0..5, cx));
|
||||
});
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
/// Opens the bar's only menu and runs row `ix`.
|
||||
fn choose(ix: usize, cx: &mut VisualTestContext) {
|
||||
cx.simulate_click(point(px(12.0), px(10.0)), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
for _ in 0..=ix {
|
||||
cx.simulate_keystrokes("down");
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse("enter").expect("a key"),
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_acts_on_the_open_block_selection(cx: &mut TestAppContext) {
|
||||
let (document, cx) = host(cx);
|
||||
choose(0, cx);
|
||||
assert_eq!(opened(&document, cx), "**alpha** beta");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_that_moves_focus_away_closes_the_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = host(cx);
|
||||
choose(1, cx);
|
||||
let open = document.read_with(cx, |document, _| document.open.is_some());
|
||||
assert!(!open, "the block wrote back");
|
||||
assert_eq!(text(&document, cx), "alpha beta\n");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{MarkdownDocument, MarkdownMode, open};
|
||||
|
||||
const LINKED: &str = "[a long linked line of text](https://ely.dev)\n";
|
||||
|
||||
/// Links pressed so far, as the owner hears them.
|
||||
fn heard(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Rc<RefCell<Vec<String>>> {
|
||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||
let kept = heard.clone();
|
||||
document.update(cx, |document, _| {
|
||||
document.on_link(move |url, _, _| kept.borrow_mut().push(url.to_string()))
|
||||
});
|
||||
heard
|
||||
}
|
||||
|
||||
/// Presses the text of block 0, past its padding.
|
||||
fn press_text(modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds("markdown-block-0")
|
||||
.expect("the block is drawn");
|
||||
let at = point(bounds.left() + px(16.0), bounds.top() + px(10.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Left, modifiers);
|
||||
cx.simulate_mouse_up(at, MouseButton::Left, modifiers);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn is_open(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> bool {
|
||||
document.read_with(cx, |document, _| document.open.is_some())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_click_in_preview_hands_the_link_over(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Preview, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_text(Modifiers::none(), cx);
|
||||
assert_eq!(*heard.borrow(), ["https://ely.dev"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_plain_press_in_visual_edits_the_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_text(Modifiers::none(), cx);
|
||||
assert!(heard.borrow().is_empty() && is_open(&document, cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_visual_hands_the_link_over(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_text(Modifiers::secondary_key(), cx);
|
||||
assert_eq!(*heard.borrow(), ["https://ely.dev"]);
|
||||
assert!(!is_open(&document, cx), "no block opens");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use gpui::{TestAppContext, px, rems};
|
||||
|
||||
use super::{MarkdownMode, frames, open};
|
||||
use crate::documents::DocumentLook;
|
||||
use crate::documents::document::edit::Landing;
|
||||
|
||||
/// Sixty short paragraphs, each its own block.
|
||||
fn long() -> String {
|
||||
(0..60).map(|n| format!("Paragraph {n}.\n\n")).collect()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_measure_narrows_the_column(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
|
||||
let look = DocumentLook {
|
||||
measure: Some(rems(20.)),
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_look(look, cx));
|
||||
frames(cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
assert!(block.size.width <= px(320.0), "{:?}", block.size);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
let look = DocumentLook {
|
||||
typewriter: true,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_look(look, cx);
|
||||
document.open(Some(40), Landing::End, window, cx);
|
||||
document.list.scroll_to_reveal_item(40);
|
||||
});
|
||||
frames(cx);
|
||||
let (block, view) = document.read_with(cx, |document, _| {
|
||||
(
|
||||
document.list.bounds_for_item(40),
|
||||
document.list.viewport_bounds(),
|
||||
)
|
||||
});
|
||||
let block = block.expect("block 40 is laid out");
|
||||
let off = (block.center().y - view.center().y).abs();
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typewriter_keeps_the_source_caret_mid_view(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Source, cx);
|
||||
let look = DocumentLook {
|
||||
typewriter: true,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(look, cx);
|
||||
document.set_caret(90, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
let before = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_input(
|
||||
"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
",
|
||||
);
|
||||
frames(cx);
|
||||
let after = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
assert_eq!(after, before + 10, "the view follows the caret");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_scale_sizes_source_lines(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a\n", MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
frames(cx);
|
||||
let plain = editor.read_with(cx, |editor, _| editor.metrics.line);
|
||||
let look = DocumentLook {
|
||||
scale: 2.0,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_look(look, cx));
|
||||
frames(cx);
|
||||
let large = editor.read_with(cx, |editor, _| editor.metrics.line);
|
||||
assert!(large > plain * 1.8, "{plain:?} to {large:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(Some(3), Landing::End, window, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.current_block(cx)),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
fn typewriter() -> DocumentLook {
|
||||
DocumentLook {
|
||||
typewriter: true,
|
||||
..DocumentLook::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far block `ix` sits from mid-view.
|
||||
fn off_middle(
|
||||
document: &gpui::Entity<super::MarkdownDocument>,
|
||||
ix: usize,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) -> gpui::Pixels {
|
||||
document.read_with(cx, |document, _| {
|
||||
let mut block = document.list.bounds_for_item(ix).expect("laid out");
|
||||
if ix == 0 {
|
||||
block.origin.y += document.room;
|
||||
block.size.height -= document.room;
|
||||
}
|
||||
if ix == document.parsed.blocks.len() {
|
||||
block.size.height -= document.room;
|
||||
}
|
||||
(block.center().y - document.list.viewport_bounds().center().y).abs()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_takes_the_scale(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(Some(0), Landing::End, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let plain = document
|
||||
.read_with(cx, |document, _| document.list.bounds_for_item(0))
|
||||
.expect("laid out");
|
||||
let large = DocumentLook {
|
||||
scale: 2.0,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_look(large, cx));
|
||||
frames(cx);
|
||||
let scaled = document
|
||||
.read_with(cx, |document, _| document.list.bounds_for_item(0))
|
||||
.expect("laid out");
|
||||
assert!(
|
||||
scaled.size.height > plain.size.height * 1.5,
|
||||
"{plain:?} to {scaled:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_resize_centers_the_block_again(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.open(Some(40), Landing::End, window, cx);
|
||||
document.list.scroll_to_reveal_item(40);
|
||||
});
|
||||
frames(cx);
|
||||
cx.simulate_resize(gpui::size(px(1000.0), px(400.0)));
|
||||
frames(cx);
|
||||
assert!(off_middle(&document, 40, cx) < px(4.0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_first_and_last_blocks_center(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.open(Some(0), Landing::End, window, cx);
|
||||
});
|
||||
frames(cx);
|
||||
let first = off_middle(&document, 0, cx);
|
||||
assert!(first < px(4.0), "the first is {first:?} off");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(Some(59), Landing::End, window, cx);
|
||||
document.list.scroll_to_reveal_item(59);
|
||||
});
|
||||
frames(cx);
|
||||
assert!(off_middle(&document, 59, cx) < px(4.0), "the last");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_last_source_line_centers_and_a_resize_follows(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.set_caret(119, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
let top = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
assert!(
|
||||
top > 80,
|
||||
"top {top}: the last line is mid-view, not at the bottom"
|
||||
);
|
||||
cx.simulate_resize(gpui::size(px(1000.0), px(400.0)));
|
||||
frames(cx);
|
||||
let resized = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let (line, height) =
|
||||
editor.read_with(cx, |editor, _| (editor.metrics.line, editor.metrics.height));
|
||||
let half = (height / line / 2.0).floor() as usize;
|
||||
let middle = 119 - half;
|
||||
assert!(
|
||||
resized.abs_diff(middle) <= 1,
|
||||
"top {top} to {resized}; mid-view is {middle}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Typewriter source, caret on line 60 of 120.
|
||||
fn centered_source(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
gpui::Entity<super::MarkdownDocument>,
|
||||
&mut gpui::VisualTestContext,
|
||||
) {
|
||||
let text: String = (0..120).map(|n| format!("Line {n}.\n")).collect();
|
||||
let (document, cx) = open(&text, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.set_caret(60, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn source_lines_below_the_center_draw(cx: &mut TestAppContext) {
|
||||
let (_, cx) = centered_source(cx);
|
||||
let caret = cx.debug_bounds("editor-line-60").expect("caret line");
|
||||
let next = cx.debug_bounds("editor-line-61").expect("the line below");
|
||||
assert!(next.top() >= caret.bottom() - px(1.0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn platform_text_queries_meet_the_drawn_line(cx: &mut TestAppContext) {
|
||||
use gpui::EntityInputHandler as _;
|
||||
let (document, cx) = centered_source(cx);
|
||||
let drawn = cx.debug_bounds("editor-line-60").expect("caret line");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let (anchor, line) = editor.update_in(cx, |editor, window, cx| {
|
||||
let at = editor.text()[..editor.primary().head]
|
||||
.encode_utf16()
|
||||
.count();
|
||||
let anchor = editor.bounds_for_range(at..at, gpui::Bounds::default(), window, cx);
|
||||
let point = gpui::point(editor.metrics.left, drawn.center().y);
|
||||
let index = editor.character_index_for_point(point, window, cx);
|
||||
let line = index.map(|at| editor.buffer.line_of(editor.byte_range(&(at..at)).start));
|
||||
(anchor.expect("anchor"), line)
|
||||
});
|
||||
assert!(
|
||||
(anchor.top() - drawn.top()).abs() < px(2.0),
|
||||
"{anchor:?} vs {drawn:?}"
|
||||
);
|
||||
assert_eq!(line, Some(60));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn switching_to_visual_centers_the_caret_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.set_caret(100, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let off = off_middle(&document, 50, cx);
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
|
||||
/// How far line 100 sits from mid-view.
|
||||
fn source_off(
|
||||
document: &gpui::Entity<super::MarkdownDocument>,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) -> gpui::Pixels {
|
||||
let drawn = cx.debug_bounds("editor-line-100").expect("line drawn");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let middle = editor.read_with(cx, |editor, _| {
|
||||
editor.metrics.top + editor.metrics.height / 2.0
|
||||
});
|
||||
(drawn.center().y - middle).abs()
|
||||
}
|
||||
|
||||
/// Typewriter source, caret on line 100.
|
||||
fn source_at_100(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
gpui::Entity<super::MarkdownDocument>,
|
||||
&mut gpui::VisualTestContext,
|
||||
) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(typewriter(), cx);
|
||||
document.set_caret(100, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_mode_round_trip_centers_the_source_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = source_at_100(cx);
|
||||
for mode in [MarkdownMode::Visual, MarkdownMode::Source] {
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(mode, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
}
|
||||
let off = source_off(&document, cx);
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn revealing_the_caret_line_keeps_it_centered(cx: &mut TestAppContext) {
|
||||
let (document, cx) = source_at_100(cx);
|
||||
document.update_in(cx, |document, window, cx| document.reveal(100, window, cx));
|
||||
frames(cx);
|
||||
let off = source_off(&document, cx);
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_small_resize_centers_the_source_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = source_at_100(cx);
|
||||
cx.simulate_resize(gpui::size(px(1000.0), px(1070.0)));
|
||||
frames(cx);
|
||||
let off = source_off(&document, cx);
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, edit::Landing, visual};
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
|
||||
|
||||
pub(super) fn open<'a>(
|
||||
text: &str,
|
||||
mode: MarkdownMode,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<MarkdownDocument>, &'a mut VisualTestContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
let text = text.to_string();
|
||||
let (document, cx) =
|
||||
cx.add_window_view(move |window, cx| MarkdownDocument::new(text, mode, window, cx));
|
||||
cx.update(|window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
/// Presses block `ix` where it is drawn.
|
||||
pub(super) fn press(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
let at = point(bounds.left() + px(4.0), bounds.top() + px(4.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Left, modifiers);
|
||||
cx.simulate_mouse_up(at, MouseButton::Left, modifiers);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Frames with their next-frame callbacks, so scrolls land.
|
||||
pub(super) fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..4 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.simulate_next_frame(cx));
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub(super) fn text(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> String {
|
||||
document.read_with(cx, |document, cx| document.text(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_block_typed_in_is_the_text_at_once_and_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
let changes = Rc::new(RefCell::new(0));
|
||||
let counted = changes.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(&document, move |_, event: &DocumentEvent, _| {
|
||||
if *event == DocumentEvent::Changed {
|
||||
*counted.borrow_mut() += 1;
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
press(1, Modifiers::none(), cx);
|
||||
cx.simulate_input(" Again.");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"# Lift\n\nBlend toward white. Again.\n\n- one\n- two\n",
|
||||
"typing shows in the text before it is written back"
|
||||
);
|
||||
assert!(*changes.borrow() > 0, "each key tells the owner");
|
||||
cx.simulate_keystrokes("escape");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
assert_eq!(
|
||||
editor.read_with(cx, |editor, _| editor.text().to_string()),
|
||||
text(&document, cx),
|
||||
"written back"
|
||||
);
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), TEXT, "one step undoes the block");
|
||||
cx.dispatch_action(visual::Redo);
|
||||
assert!(text(&document, cx).contains("Again."));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_same_text_and_history_cross_modes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.edit([(0..0, "Intro\n\n".to_string())], cx)
|
||||
});
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), format!("Intro\n\n{TEXT}"));
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
TEXT,
|
||||
"the source edit undoes in visual"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_mode_switch_writes_the_open_block_and_changes_nothing_else(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
TEXT,
|
||||
"an unchanged block writes nothing"
|
||||
);
|
||||
let undone = document.read_with(cx, |document, cx| {
|
||||
document.editor().read(cx).history.can_undo()
|
||||
});
|
||||
assert!(!undone, "no step was recorded");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_and_a_secondary_press_open_no_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Preview, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
press(1, Modifiers::secondary_key(), cx);
|
||||
assert!(
|
||||
document.read_with(cx, |document, _| document.open.is_none()),
|
||||
"Cmd or Ctrl leaves the press to links"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn down_at_a_blocks_last_line_opens_the_next(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("down");
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("a block is open");
|
||||
document.text(cx)[open.range.clone()].to_string()
|
||||
});
|
||||
assert_eq!(opened, "Blend toward white.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn reset_starts_undo_afresh(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| document.set_text("changed", cx));
|
||||
document.update(cx, |document, cx| document.reset("from disk", cx));
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "from disk");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_long_text_reads_off_the_main_thread(cx: &mut TestAppContext) {
|
||||
let long = "A paragraph of prose.\n\n".repeat(AT_ONCE / 20);
|
||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||
let (read, blocks) = document.read_with(cx, |document, cx| {
|
||||
(document.read_now(cx), document.parsed.blocks.len())
|
||||
});
|
||||
assert!(read, "read once parked");
|
||||
assert_eq!(blocks, AT_ONCE / 20);
|
||||
document.update(cx, |document, cx| document.set_text("# Short\n", cx));
|
||||
assert!(
|
||||
document.read_with(cx, |document, cx| document.read_now(cx)),
|
||||
"a short text reads at once"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_past_the_end_adds_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# Lift", MarkdownMode::Visual, cx);
|
||||
let end = cx.debug_bounds("markdown-block-0").expect("drawn").bottom() + px(12.0);
|
||||
cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_input("More.");
|
||||
assert_eq!(text(&document, cx), "# Lift\n\nMore.");
|
||||
cx.simulate_keystrokes("escape");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let written = editor.read_with(cx, |editor, _| editor.text().to_string());
|
||||
assert_eq!(written, "# Lift\n\nMore.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn front_matter_opens_as_written(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("---\ntitle: a\n---\n\nBody\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("open")
|
||||
.input
|
||||
.read(cx)
|
||||
.text()
|
||||
.to_string()
|
||||
});
|
||||
assert_eq!(opened, "---\ntitle: a\n---");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_source_colors_markdown(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
let colors = document.read_with(cx, |document, cx| {
|
||||
document.editor().read(cx).line_colors("a **b**", cx)
|
||||
});
|
||||
assert!(
|
||||
colors
|
||||
.iter()
|
||||
.any(|(range, style)| *range == (2..7)
|
||||
&& style.font_weight == Some(gpui::FontWeight::BOLD)),
|
||||
"{colors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn down_past_the_last_block_keeps_it_open(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# Lift\n\nLast.", MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("down");
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("still open");
|
||||
document.text(cx)[open.range.clone()].to_string()
|
||||
});
|
||||
assert_eq!(opened, "Last.");
|
||||
}
|
||||
|
||||
/// Opens block `ix` as shown, its field set to `typed`.
|
||||
pub(super) fn type_in(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
ix: Option<usize>,
|
||||
typed: Option<&str>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(ix, Landing::End, window, cx);
|
||||
if let (Some(typed), Some(open)) = (typed, &document.open) {
|
||||
open.input.update(cx, |input, cx| input.set_text(typed, cx));
|
||||
}
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub(super) fn opened(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> String {
|
||||
document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("a block is open");
|
||||
open.input.read(cx).text().to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_deleting_a_block_opens_the_one_pressed(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(
|
||||
"A
|
||||
|
||||
B
|
||||
|
||||
C",
|
||||
MarkdownMode::Visual,
|
||||
cx,
|
||||
);
|
||||
type_in(&document, Some(0), Some(""), cx);
|
||||
type_in(&document, Some(2), None, cx);
|
||||
assert_eq!(opened(&document, cx), "C");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_splitting_a_block_opens_the_one_pressed(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(
|
||||
"A
|
||||
|
||||
B
|
||||
|
||||
C",
|
||||
MarkdownMode::Visual,
|
||||
cx,
|
||||
);
|
||||
type_in(
|
||||
&document,
|
||||
Some(0),
|
||||
Some(
|
||||
"A
|
||||
|
||||
New",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
type_in(&document, Some(1), None, cx);
|
||||
assert_eq!(opened(&document, cx), "B");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_caret_survives_an_undo_inside_a_character(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("éabcd", MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(0), Some("x"), cx);
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "éabcd");
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (0, 0), "the restored caret");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_takes_no_undo_or_redo(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| document.set_text("Changed", cx));
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "Changed");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_added_block_reports_its_caret_after_the_joint(cx: &mut TestAppContext) {
|
||||
for start in ["A", "A\n", "A\n\n"] {
|
||||
let (document, cx) = open(start, MarkdownMode::Visual, cx);
|
||||
type_in(&document, None, Some("B"), cx);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (2, 1), "after {start:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_keyboard_reaches_and_opens_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
cx.update(|window, cx| window.focus_next(cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
frames(cx);
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").expect("a key"),
|
||||
});
|
||||
frames(cx);
|
||||
assert_eq!(opened(&document, cx), "# Lift");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_change_under_an_open_block_closes_it(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(2), Some("- three"), cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.edit([(0..0, "x".to_string())], cx));
|
||||
cx.run_until_parked();
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
assert_eq!(text(&document, cx), format!("x{TEXT}"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
|
||||
let long = format!("A\n\nB\n\nCCCC\n\n{}", "Prose.\n\n".repeat(AT_ONCE / 8 + 1));
|
||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(Some(0), Landing::End, window, cx);
|
||||
let input = document.open.as_ref().expect("open").input.clone();
|
||||
input.update(cx, |input, cx| input.set_text("AAAA", cx));
|
||||
// B is pressed before the new read lands.
|
||||
document.open(Some(1), Landing::End, window, cx);
|
||||
assert!(document.open.is_none(), "a stale range opens nothing");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
type_in(&document, Some(1), None, cx);
|
||||
assert_eq!(opened(&document, cx), "B", "once read, B opens whole");
|
||||
}
|
||||
|
||||
mod attach;
|
||||
mod block;
|
||||
mod block_edges;
|
||||
mod cells;
|
||||
mod format;
|
||||
mod history;
|
||||
mod lend;
|
||||
mod links;
|
||||
mod look;
|
||||
mod open_look;
|
||||
mod place;
|
||||
mod point;
|
||||
mod presses;
|
||||
mod reading;
|
||||
mod secondary;
|
||||
mod toc;
|
||||
mod tools;
|
||||
|
||||
#[gpui::test]
|
||||
fn typed_source_reads_after_a_pause(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.edit([(0..0, "x".to_string())], cx));
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
!document.read_with(cx, |document, cx| document.read_now(cx)),
|
||||
"no read per key"
|
||||
);
|
||||
cx.executor().advance_clock(super::PAUSE);
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
document.read_with(cx, |document, cx| document.read_now(cx)),
|
||||
"read after the pause"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_text_past_the_largest_read_is_never_read(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# Old\n", MarkdownMode::Source, cx);
|
||||
let long = "a\n".repeat(super::LARGEST_READ / 2 + 1);
|
||||
document.update(cx, |document, cx| document.set_text(long, cx));
|
||||
cx.executor().advance_clock(super::PAUSE);
|
||||
cx.run_until_parked();
|
||||
let (blocks, outline) = document.read_with(cx, |document, cx| {
|
||||
(document.parsed.blocks.len(), document.outline(cx).len())
|
||||
});
|
||||
assert_eq!((blocks, outline), (0, 0), "the old read is gone");
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! An open block keeps its place and size (R7.5).
|
||||
|
||||
use gpui::{
|
||||
Bounds, Fill, Modifiers, Pixels, StyleRefinement, Styled, TestAppContext, VisualTestContext,
|
||||
rems,
|
||||
};
|
||||
|
||||
use super::{open, press};
|
||||
use crate::documents::{TargetBlock, document::visual::open_text};
|
||||
use crate::{
|
||||
documents::{MarkdownMode, markdown_highlights, render::HEAVY},
|
||||
forms::Highlight,
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
|
||||
/// Block 0's bounds closed, then open.
|
||||
fn closed_then_open<'a>(
|
||||
source: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Bounds<Pixels>, Bounds<Pixels>, &'a mut VisualTestContext) {
|
||||
let (_, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let closed = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
press(0, Modifiers::none(), cx);
|
||||
let open = cx.debug_bounds("markdown-open").expect("the block opens");
|
||||
(closed, open, cx)
|
||||
}
|
||||
|
||||
/// Within a pixel: a field rounds its line height.
|
||||
fn near(a: Pixels, b: Pixels) -> bool {
|
||||
(a - b).abs() < gpui::px(1.0)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_paragraph_keeps_its_place_and_height(cx: &mut TestAppContext) {
|
||||
let (closed, open, _) = closed_then_open("Plain words here.\n", cx);
|
||||
assert!(
|
||||
near(closed.top(), open.top()) && near(closed.left(), open.left()),
|
||||
"{closed:?} {open:?}"
|
||||
);
|
||||
assert!(
|
||||
near(closed.size.height, open.size.height),
|
||||
"{closed:?} {open:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_heading_keeps_its_size(cx: &mut TestAppContext) {
|
||||
let (closed, open, _) = closed_then_open("## Plain heading\n", cx);
|
||||
assert!(
|
||||
near(closed.top(), open.top()) && near(closed.left(), open.left()),
|
||||
"{closed:?} {open:?}"
|
||||
);
|
||||
assert!(
|
||||
near(closed.size.height, open.size.height),
|
||||
"{closed:?} {open:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_code_block_keeps_its_panel(cx: &mut TestAppContext) {
|
||||
keeps_its_panel("```\nlet x = 1;\n```\n", cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn open_front_matter_keeps_its_panel(cx: &mut TestAppContext) {
|
||||
keeps_its_panel("---\nkey: value\n---\n\nText.\n", cx);
|
||||
}
|
||||
|
||||
/// Block 0 opens with its panel at the closed inset.
|
||||
fn keeps_its_panel(source: &str, cx: &mut TestAppContext) {
|
||||
let (closed, open, cx) = closed_then_open(source, cx);
|
||||
assert!(
|
||||
near(closed.top(), open.top()) && near(closed.left(), open.left()),
|
||||
"{closed:?} {open:?}"
|
||||
);
|
||||
// The panel sits inside the frame's border and padding.
|
||||
let (x, y) = cx.update(|window, _| {
|
||||
let rem = window.rem_size();
|
||||
(rems(0.75).to_pixels(rem), rems(0.25).to_pixels(rem))
|
||||
});
|
||||
let panel = cx.debug_bounds("markdown-open-text").expect("drawn");
|
||||
let border = gpui::px(1.0);
|
||||
assert!(
|
||||
near(panel.left(), closed.left() + border + x),
|
||||
"{panel:?} {closed:?}"
|
||||
);
|
||||
assert!(
|
||||
near(panel.top(), closed.top() + border + y),
|
||||
"{panel:?} {closed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The highlight on the run that holds `word`.
|
||||
fn run(source: &str, word: &str, cx: &mut TestAppContext) -> Highlight {
|
||||
cx.update(crate::init_for_tests);
|
||||
let at = source.find(word).expect("the word");
|
||||
cx.update(|cx| {
|
||||
markdown_highlights(source, cx)
|
||||
.into_iter()
|
||||
.rfind(|(range, _)| range.start <= at && at < range.end)
|
||||
.expect("a run")
|
||||
.1
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_heading_keeps_its_weight(cx: &mut TestAppContext) {
|
||||
for source in [
|
||||
"# Heavy words
|
||||
",
|
||||
"a **Heavy** b
|
||||
",
|
||||
] {
|
||||
assert_eq!(run(source, "Heavy", cx).weight, Some(HEAVY), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_sixth_heading_stays_muted(cx: &mut TestAppContext) {
|
||||
let muted = cx.update(|cx| {
|
||||
crate::init_for_tests(cx);
|
||||
cx.theme().colors.fg_muted
|
||||
});
|
||||
for word in ["Quiet", "Loud"] {
|
||||
let source = "###### Quiet **Loud**
|
||||
";
|
||||
assert_eq!(run(source, word, cx).color, muted, "{word}");
|
||||
}
|
||||
let fg = cx.update(|cx| cx.theme().colors.fg);
|
||||
assert_eq!(
|
||||
run(
|
||||
"##### Plain
|
||||
",
|
||||
"Plain",
|
||||
cx
|
||||
)
|
||||
.color,
|
||||
fg
|
||||
);
|
||||
let code = run("###### Quiet `Code`\n", "Code", cx);
|
||||
assert_eq!(code.color, fg, "code keeps its own color");
|
||||
}
|
||||
|
||||
/// The open text box's style for `kind`.
|
||||
fn styled(kind: TargetBlock, cx: &mut TestAppContext) -> StyleRefinement {
|
||||
cx.update(crate::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
open_text(Some((kind, true)), 1.0, cx.theme())
|
||||
.style()
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn open_prose_has_no_panel(cx: &mut TestAppContext) {
|
||||
for kind in [
|
||||
TargetBlock::Paragraph,
|
||||
TargetBlock::Heading(2),
|
||||
TargetBlock::List,
|
||||
TargetBlock::Quote,
|
||||
] {
|
||||
assert_eq!(styled(kind, cx).background, None, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn open_code_math_and_front_matter_keep_their_panel(cx: &mut TestAppContext) {
|
||||
let (sunken, muted, mono) = cx.update(|cx| {
|
||||
crate::init_for_tests(cx);
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors.sunken,
|
||||
theme.colors.fg_muted,
|
||||
theme.mono_family.clone(),
|
||||
)
|
||||
});
|
||||
for kind in [
|
||||
TargetBlock::Code,
|
||||
TargetBlock::Math,
|
||||
TargetBlock::FrontMatter,
|
||||
] {
|
||||
assert_eq!(
|
||||
styled(kind, cx).background,
|
||||
Some(Fill::from(sunken)),
|
||||
"{kind:?}"
|
||||
);
|
||||
}
|
||||
let front = styled(TargetBlock::FrontMatter, cx).text;
|
||||
assert_eq!((front.color, front.font_family), (Some(muted), Some(mono)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn open_front_matter_has_no_runs(cx: &mut TestAppContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
let runs = cx.update(|cx| markdown_highlights("---\ntitle: R75\n---", cx));
|
||||
assert!(runs.is_empty(), "{runs:?}");
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext, px};
|
||||
|
||||
use super::{AT_ONCE, MarkdownDocument, MarkdownMode, TEXT, frames, open, text};
|
||||
|
||||
/// The view's top line once no ask waits.
|
||||
fn landed(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
|
||||
frames(cx);
|
||||
document.read_with(cx, |document, cx| {
|
||||
assert!(document.top.is_none(), "the ask finished");
|
||||
document.top_line(cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// A hundred paragraphs: line `2n` is the `n`th.
|
||||
fn paragraphs() -> String {
|
||||
(0..100).map(|n| format!("Paragraph {n}\n\n")).collect()
|
||||
}
|
||||
|
||||
/// A fenced block of `lines` lines, then a paragraph.
|
||||
fn fenced(lines: usize) -> String {
|
||||
let body: String = (0..lines).map(|n| format!("line {n}\n")).collect();
|
||||
format!("```\n{body}```\n\nAfter.\n")
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn caret_and_view_set_apart_and_carry_across_modes(cx: &mut TestAppContext) {
|
||||
let long = (0..60).map(|n| format!("Line {n}\n\n")).collect::<String>();
|
||||
let (document, cx) = open(&long, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(4, 3, cx));
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_top_line(40, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let source = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
assert_eq!(source, 40, "the source scrolled");
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (4, 3), "the view moved alone");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let (caret, top) = document.read_with(cx, |document, cx| {
|
||||
(document.caret(cx), document.top_line(cx))
|
||||
});
|
||||
assert_eq!(caret, (4, 3), "one caret for every mode");
|
||||
assert_eq!(top, 40, "the view stays at its line");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_top_line_inside_a_tall_block_survives_modes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&fenced(100), MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_top_line(40, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 40);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn visual_to_preview_keeps_the_offset_in_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&fenced(100), MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.list.scroll_to(gpui::ListOffset {
|
||||
item_ix: 0,
|
||||
offset_in_item: px(500.0),
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
frames(cx);
|
||||
let before = document.read_with(cx, |document, _| document.list.logical_scroll_top());
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let after = document.read_with(cx, |document, _| document.list.logical_scroll_top());
|
||||
assert_eq!(
|
||||
(after.item_ix, after.offset_in_item),
|
||||
(before.item_ix, before.offset_in_item)
|
||||
);
|
||||
assert_eq!(after.offset_in_item, px(500.0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_top_line_asked_with_new_text_lands_once_read(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("old", MarkdownMode::Visual, cx);
|
||||
let fresh: String = (0..100).map(|n| format!("Paragraph {n}\n\n")).collect();
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.reset(fresh, cx);
|
||||
document.set_top_line(40, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 40);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_second_top_line_asked_while_placing_wins(cx: &mut TestAppContext) {
|
||||
let fresh: String = (0..100).map(|n| format!("Paragraph {n}\n\n")).collect();
|
||||
let (document, cx) = open(&fresh, MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx);
|
||||
document.set_top_line(60, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 60);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_switch_before_the_frame_keeps_the_ask(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(¶graphs(), MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_top_line(40, window, cx);
|
||||
document.set_mode(MarkdownMode::Visual, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 40, "source to visual");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_top_line(60, window, cx);
|
||||
document.set_mode(MarkdownMode::Source, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 60, "visual to source");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn new_text_under_an_ask_places_it_again(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(¶graphs(), MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_top_line(40, window, cx);
|
||||
document.reset(fenced(100), cx);
|
||||
document.set_top_line(40, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 40);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_ask_past_a_shorter_text_lands_on_its_last_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("old", MarkdownMode::Visual, cx);
|
||||
let long = "Prose.\n\n".repeat(AT_ONCE / 8 + 1);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.reset(long, cx);
|
||||
document.set_top_line(40, window, cx);
|
||||
document.reset("short", cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 0);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn reveal_after_new_text_lands_once_read(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("old", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.reset(paragraphs(), cx);
|
||||
document.reveal(80, window, cx);
|
||||
});
|
||||
assert_eq!(landed(&document, cx), 80);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (80, 0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn reveal_puts_the_caret_on_the_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| document.reveal(4, window, cx));
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(4, 0)
|
||||
);
|
||||
cx.simulate_input("x");
|
||||
assert!(
|
||||
text(&document, cx).contains(
|
||||
"
|
||||
x- one"
|
||||
),
|
||||
"keys land on the line"
|
||||
);
|
||||
let outline: Vec<(String, usize)> = document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.outline(cx)
|
||||
.into_iter()
|
||||
.map(|(heading, line)| (heading.title, line))
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(outline, [("Lift".to_string(), 0)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_reset_keeps_the_caret_and_the_top_line(cx: &mut TestAppContext) {
|
||||
let lines: String = (0..60).map(|n| format!("Line {n}\n\n")).collect();
|
||||
let (document, cx) = open(&lines, MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_caret(40, 2, cx);
|
||||
document.set_top_line(30, window, cx);
|
||||
});
|
||||
frames(cx);
|
||||
let changed: String = (0..60).map(|n| format!("Row {n}\n\n")).collect();
|
||||
document.update(cx, |document, cx| document.reset(changed, cx));
|
||||
frames(cx);
|
||||
let (caret, top) = document.read_with(cx, |document, cx| {
|
||||
(document.caret(cx), document.top_line(cx))
|
||||
});
|
||||
assert_eq!((caret, top), ((40, 2), 30));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_selection_reads_in_source_and_in_an_open_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# One\n\nTwo words.\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(2..5), cx))
|
||||
});
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.selected(cx)),
|
||||
"One"
|
||||
);
|
||||
for mode in [MarkdownMode::Preview, MarkdownMode::Visual] {
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(mode, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let selected = document.read_with(cx, |document, cx| document.selected(cx));
|
||||
assert_eq!(selected, "", "{mode:?} shows no source selection");
|
||||
}
|
||||
super::type_in(&document, Some(1), None, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("a block is open");
|
||||
open.input.update(cx, |input, cx| input.select(4..9, cx));
|
||||
});
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.selected(cx)),
|
||||
"words"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_visual_opening_starts_at_the_top(cx: &mut TestAppContext) {
|
||||
let source = format!("alpha beta\n\n{}", "x".repeat(20000));
|
||||
let (document, cx) = open(&source, MarkdownMode::Visual, cx);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(landed(&document, cx), 0);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! A press lands on the pressed character (R4.5).
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{open, type_in};
|
||||
use crate::{
|
||||
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// Presses block `ix` on its first line's character `k`.
|
||||
pub(super) fn press_on(ix: usize, k: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
// The test text system sets glyphs 0.6 em apart.
|
||||
let size = cx.update(|window, cx| {
|
||||
cx.theme()
|
||||
.text_size(TextSize::Base)
|
||||
.to_pixels(window.rem_size())
|
||||
});
|
||||
let em = size * 0.6;
|
||||
let x = bounds.left() + px(13.0) + em * (k as f32 + 0.25);
|
||||
let at = point(x, bounds.top() + px(10.0));
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub(super) fn heard(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Rc<RefCell<Vec<DocumentTarget>>> {
|
||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||
let kept = heard.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
||||
if let DocumentEvent::Pointed(target) = event {
|
||||
kept.borrow_mut().push(target.clone());
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
heard
|
||||
}
|
||||
|
||||
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
|
||||
document.read_with(cx, |document, cx| document.editor.read(cx).primary().head)
|
||||
}
|
||||
|
||||
pub(super) fn opened_caret(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> usize {
|
||||
document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("open")
|
||||
.input
|
||||
.read(cx)
|
||||
.cursor()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn target(at: usize, block: TargetBlock) -> DocumentTarget {
|
||||
DocumentTarget {
|
||||
at,
|
||||
block: Some(block),
|
||||
link: None,
|
||||
image: None,
|
||||
in_table: false,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT: &str = "First.\n\nSay **bold** and more.\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
press_on(1, 4, MouseButton::Left, cx);
|
||||
assert_eq!(
|
||||
opened_caret(&document, cx),
|
||||
"Say **".len(),
|
||||
"on the b of bold"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_past_a_crlf_line_types_before_its_end(cx: &mut TestAppContext) {
|
||||
let source = "one\r\ntwo\r\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
press_on(0, 3, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), "one".len());
|
||||
cx.simulate_input("X");
|
||||
cx.simulate_keystrokes("escape");
|
||||
assert_eq!(super::text(&document, cx), "oneX\r\ntwo\r\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_code_keeping_a_crlf_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "`one\r\ntwo`\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
press_on(0, 2, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), "`on".len());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(1, 4, MouseButton::Right, cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
let at = TEXT.find("bold").unwrap();
|
||||
assert_eq!(caret(&document, cx), at);
|
||||
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_on_a_link_names_its_address(cx: &mut TestAppContext) {
|
||||
let text = "Go [there](https://ely.dev) now\n";
|
||||
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 4, MouseButton::Right, cx);
|
||||
let expected = DocumentTarget {
|
||||
link: Some("https://ely.dev".into()),
|
||||
..target(text.find("here").unwrap(), TargetBlock::Paragraph)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) {
|
||||
let text = "```rust\nlet x = 1;\n```\n";
|
||||
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 0, MouseButton::Right, cx);
|
||||
let expected = DocumentTarget {
|
||||
code: Some("let x = 1;".into()),
|
||||
..target("```rust\n".len(), TargetBlock::Code)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_on_a_picture_names_it(cx: &mut TestAppContext) {
|
||||
let text = "Lead.\n\n\n";
|
||||
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(1, 1, MouseButton::Right, cx);
|
||||
let expected = DocumentTarget {
|
||||
image: Some("missing.png".into()),
|
||||
..target(text.find("![").unwrap(), TargetBlock::Paragraph)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_writes_the_open_block_first(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
type_in(&document, Some(0), Some("First, longer."), cx);
|
||||
press_on(1, 4, MouseButton::Right, cx);
|
||||
let written = "First, longer.\n\nSay **bold** and more.\n";
|
||||
assert_eq!(super::text(&document, cx), written);
|
||||
let at = written.find("bold").unwrap();
|
||||
assert_eq!(caret(&document, cx), at, "the byte moved with the write");
|
||||
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
let end = cx.debug_bounds("markdown-block-1").expect("drawn").bottom() + px(12.0);
|
||||
cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
let expected = DocumentTarget {
|
||||
block: None,
|
||||
..target(TEXT.len(), TargetBlock::Paragraph)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
/// Base and small text sizes, in pixels.
|
||||
pub(super) fn sizes(cx: &mut VisualTestContext) -> (Pixels, Pixels) {
|
||||
cx.update(|window, cx| {
|
||||
let size = |size| cx.theme().text_size(size).to_pixels(window.rem_size());
|
||||
(size(TextSize::Base), size(TextSize::Sm))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn press_at(x: Pixels, y: Pixels, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
cx.simulate_mouse_down(point(x, y), button, Modifiers::none());
|
||||
cx.simulate_mouse_up(point(x, y), button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Presses block 0's code glyph `k` on `line`.
|
||||
pub(super) fn press_lines(k: usize, line: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, small) = sizes(cx);
|
||||
let x = bounds.left() + px(25.0) + small * 0.6 * (k as f32 + 0.25);
|
||||
let y = bounds.top() + px(17.0) + body * 1.6 * (line as f32 + 0.25);
|
||||
press_at(x, y, button, cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
|
||||
let source = "> alpha\n> beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
// "beta" draws on its own line.
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 1.25;
|
||||
press_at(
|
||||
x,
|
||||
bounds.top() + px(10.0) + body * 1.6,
|
||||
MouseButton::Right,
|
||||
cx,
|
||||
);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_an_alert_lands_past_its_marker(cx: &mut TestAppContext) {
|
||||
let source = "> [!NOTE]\n> alpha beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 6.25;
|
||||
let y = bounds.top() + px(15.0) + body * 1.6;
|
||||
press_at(x, y, MouseButton::Right, cx);
|
||||
let at = source.find("beta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Alert)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_html_block_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "<div>alpha beta</div>\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 7, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||
press_on(0, 7, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_definition_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://ely.dev\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 10, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
|
||||
for source in [" alpha\n beta\n", "```\r\nalpha\r\nbeta\r\n```\r\n"] {
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(1, 1, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
let eol = if source.contains('\r') { "\r\n" } else { "\n" };
|
||||
let expected = DocumentTarget {
|
||||
code: Some(format!("alpha{eol}beta")),
|
||||
..target(at, TargetBlock::Code)
|
||||
};
|
||||
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
||||
press_lines(1, 1, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at, "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_front_matter_press_lands_past_its_fence(cx: &mut TestAppContext) {
|
||||
let source = "---\ntitle: value\n---\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(7, 0, MouseButton::Right, cx);
|
||||
let at = source.find("value").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||
press_lines(7, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_on_an_empty_document_opens_nothing(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
let events = heard(&document, cx);
|
||||
press_at(px(25.0), px(25.0), MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow().len(), 1);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_write_back_that_changes_the_pressed_block_points_nowhere(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("First.\n\nalpha beta\n", MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
type_in(&document, Some(0), Some("```"), cx);
|
||||
press_on(1, 7, MouseButton::Right, cx);
|
||||
let written = "```\n\nalpha beta\n";
|
||||
assert_eq!(super::text(&document, cx), written);
|
||||
assert!(
|
||||
events.borrow().is_empty(),
|
||||
"the pressed paragraph became code"
|
||||
);
|
||||
assert_ne!(caret(&document, cx), written.find("eta").unwrap());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn crlf_lines_press_where_they_were_written(cx: &mut TestAppContext) {
|
||||
let source = "```
|
||||
alpha
|
||||
beta
|
||||
```
|
||||
";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(2, 0, MouseButton::Right, cx);
|
||||
let at = source.find("pha").unwrap();
|
||||
let expected = DocumentTarget {
|
||||
code: Some("alpha\nbeta".into()),
|
||||
..target(at, TargetBlock::Code)
|
||||
};
|
||||
assert_eq!(*events.borrow(), [expected]);
|
||||
press_lines(2, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn crlf_front_matter_presses_where_written(cx: &mut TestAppContext) {
|
||||
let source = "---
|
||||
title: value
|
||||
author: person
|
||||
---
|
||||
";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(7, 0, MouseButton::Right, cx);
|
||||
let at = source.find("value").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||
press_lines(7, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_crlf_definition_presses_where_written(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://ely.dev
|
||||
\"title\"
|
||||
";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 10, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_changed_definition_points_nowhere(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://old.dev
|
||||
|
||||
Go [there][ref]
|
||||
";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
type_in(&document, Some(0), Some("[ref]: https://new.dev"), cx);
|
||||
press_on(1, 4, MouseButton::Right, cx);
|
||||
assert_eq!(
|
||||
super::text(&document, cx),
|
||||
source.replace("old.dev", "new.dev")
|
||||
);
|
||||
assert!(
|
||||
events.borrow().is_empty(),
|
||||
"the link changed under the press"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_quoted_greater_than_presses_on_itself(cx: &mut TestAppContext) {
|
||||
let source = "> <div>
|
||||
> >alpha beta</div>";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 0.25;
|
||||
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||
let at = source.find(">alpha").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_escaped_backslash_presses_on_itself(cx: &mut TestAppContext) {
|
||||
let source = r"a \\ z";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 2, MouseButton::Right, cx);
|
||||
let at = source.find(r"\ z").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) {
|
||||
let source = "<div>alpha <b> beta</b></div>";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 8, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_beside_the_code_still_names_it(cx: &mut TestAppContext) {
|
||||
let source = "```\nlet x;\n```\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
press_at(
|
||||
bounds.left() + px(4.0),
|
||||
bounds.top() + px(4.0),
|
||||
MouseButton::Right,
|
||||
cx,
|
||||
);
|
||||
let code = events
|
||||
.borrow()
|
||||
.first()
|
||||
.and_then(|target| target.code.clone());
|
||||
assert_eq!(code.as_deref(), Some("let x;"));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Presses on text the parser rewrote (R4.5).
|
||||
|
||||
use gpui::{MouseButton, TestAppContext, px};
|
||||
|
||||
use super::{
|
||||
open,
|
||||
point::{heard, opened_caret, press_at, press_lines, press_on, sizes, target},
|
||||
};
|
||||
use crate::documents::{MarkdownMode, TargetBlock};
|
||||
|
||||
#[gpui::test]
|
||||
fn inline_code_kept_indent_press(cx: &mut TestAppContext) {
|
||||
let source = "a `fox\n y` z";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 3, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(4, TargetBlock::Paragraph)]);
|
||||
press_on(0, 3, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), 4);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn quoted_html_kept_mark_press(cx: &mut TestAppContext) {
|
||||
let source = "> <div>\n> > alpha beta</div>";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 0.25;
|
||||
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Quote)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn decoded_line_end_press(cx: &mut TestAppContext) {
|
||||
let source = "ab y";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn numeric_control_press(cx: &mut TestAppContext) {
|
||||
let source = "ab € y";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn html_decoded_space_press(cx: &mut TestAppContext) {
|
||||
let source = "<div>ab </div>";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(6, TargetBlock::Html)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn inline_code_kept_indent_left_press(cx: &mut TestAppContext) {
|
||||
let source = "a `fox\n y` z";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
press_on(0, 3, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), 4);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn autolink_literal_reference_press(cx: &mut TestAppContext) {
|
||||
let source = "<https://example.com/?a=&b=2>";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 9, MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow().len(), 1);
|
||||
assert_eq!(events.borrow()[0].at, source.find('x').unwrap());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn table_escaped_pipe_press(cx: &mut TestAppContext) {
|
||||
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(26.0) + body * 0.6 * 1.25;
|
||||
press_at(x, bounds.top() + px(17.0), MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow().len(), 1);
|
||||
assert!(events.borrow()[0].in_table);
|
||||
assert_eq!(events.borrow()[0].at, source.find('b').unwrap());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn prior_crlf_code_press(cx: &mut TestAppContext) {
|
||||
let source = "```\r\nalpha\r\nbeta\r\n```\r\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(2, 0, MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow()[0].at, source.find("pha").unwrap());
|
||||
press_lines(2, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), source.find("pha").unwrap());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn prior_crlf_front_matter_press(cx: &mut TestAppContext) {
|
||||
let source = "---\r\ntitle: value\r\nauthor: person\r\n---\r\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(7, 0, MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow()[0].at, source.find("value").unwrap());
|
||||
press_lines(7, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), source.find("value").unwrap());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn prior_crlf_definition_press(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://ely.dev\r\n \"title\"\r\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 10, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Drawn text reads back to its source byte (R4.5).
|
||||
|
||||
use crate::documents::{
|
||||
TargetBlock,
|
||||
document::parse::parsed,
|
||||
tree::{Inline, Node, lines_at},
|
||||
};
|
||||
|
||||
/// The source byte under drawn `shown` in block 0.
|
||||
fn read_back(source: &str, shown: &str) -> Option<usize> {
|
||||
fn find<'a>(nodes: &'a [Node], shown: &str) -> Option<&'a Inline> {
|
||||
nodes.iter().find_map(|node| match node {
|
||||
Node::Paragraph(inline) | Node::Heading(_, inline) if inline.text.contains(shown) => {
|
||||
Some(inline)
|
||||
}
|
||||
Node::Quote(body) | Node::Alert { body, .. } | Node::Footnote { body, .. } => {
|
||||
find(body, shown)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
let parsed = parsed(source);
|
||||
let block = &parsed.blocks[0];
|
||||
let inline = find(block.nodes.as_ref().expect("drawn"), shown).expect("shown");
|
||||
let at = inline.text.find(shown).expect("shown");
|
||||
let written = inline.source_at(&source[block.range.start..], at)?;
|
||||
Some(block.range.start + written)
|
||||
}
|
||||
|
||||
/// The source byte under drawn `word` of block 0's code.
|
||||
fn code_back(source: &str, word: &str) -> Option<usize> {
|
||||
fn code(nodes: &[Node]) -> (&str, usize) {
|
||||
match &nodes[0] {
|
||||
Node::Code { text, from, .. } => (text, *from),
|
||||
Node::Quote(body) => code(body),
|
||||
other => panic!("not code: {other:?}"),
|
||||
}
|
||||
}
|
||||
let parsed = parsed(source);
|
||||
let block = &parsed.blocks[0];
|
||||
let (drawn, from) = code(block.nodes.as_ref().expect("drawn"));
|
||||
let at = drawn.find(word).expect("drawn");
|
||||
let written = lines_at(&source[block.range.start..], from, drawn, at)?;
|
||||
Some(block.range.start + written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_reads_back_past_marks_escapes_trims_and_markers() {
|
||||
let cases = [
|
||||
("> alpha\n> beta", "eta"),
|
||||
("> [!NOTE]\n> alpha beta", "beta"),
|
||||
("> [!NOTE] \n> alpha beta", "beta"),
|
||||
("> [!NOTE]\\\n> alpha beta", "beta"),
|
||||
("> [!NOTE]\n> \\*beta\\*", "*beta"),
|
||||
("> [!NOTE] alpha beta", "beta"),
|
||||
("a \\*star\\* z", "*star"),
|
||||
("a \\\\ z", "\\ z"),
|
||||
("a ` xy ` z", "xy"),
|
||||
("a ` xy ` z", " xy "),
|
||||
("a ` >xy ` z", ">xy"),
|
||||
("a `\\\\xy` z", "xy"),
|
||||
("<div>alpha beta</div>\n", "eta"),
|
||||
("> <div>\n> alpha beta</div>\n", "beta"),
|
||||
("> <div>\n> >alpha beta</div>", ">alpha"),
|
||||
("> <div>\n> >alpha beta</div>", "beta"),
|
||||
("<div>alpha <b>beta</b> gamma</div>", "gamma"),
|
||||
("<div>alpha <b>beta</b> gamma</div>", "beta"),
|
||||
("<div>alpha <b> beta</b></div>", "eta"),
|
||||
("<div>one</div>\n<div>alpha beta</div>", "beta"),
|
||||
];
|
||||
for (source, shown) in cases {
|
||||
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_reads_back_to_its_ampersand() {
|
||||
let source = "a & b";
|
||||
assert_eq!(read_back(source, "& b"), source.find('&'));
|
||||
assert_eq!(read_back(source, "b"), source.find('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_an_emoji_reads_back_and_before_it_lands_at_its_start() {
|
||||
let source = "ab :smile: y";
|
||||
assert_eq!(read_back(source, "y"), source.find('y'));
|
||||
assert_eq!(read_back(source, "b"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_reads_back_line_for_line_whatever_its_line_ends() {
|
||||
let cases = [
|
||||
"```\nalpha\nbeta\n```\n",
|
||||
"```\r\nalpha\r\nbeta\r\n```\r\n",
|
||||
"```\ralpha\rbeta\r```\r",
|
||||
" alpha\n beta\n",
|
||||
"> ```\n> alpha\n> beta\n> ```\n",
|
||||
];
|
||||
for source in cases {
|
||||
for word in ["pha", "eta"] {
|
||||
assert_eq!(code_back(source, word), source.find(word), "{source:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_cut_from_a_tab_reads_back_past_it() {
|
||||
for spaces in 0..=3 {
|
||||
for prefix in ["\t", " \t", " \t", " \t", "\t\t"] {
|
||||
let indent = " ".repeat(spaces);
|
||||
let source = format!("{indent}```\n{indent}alpha\n{prefix}beta\n{indent}```\n");
|
||||
assert_eq!(code_back(&source, "eta"), source.find("eta"), "{source:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decoded_line_end_reads_without_a_written_one() {
|
||||
let quote = parsed("> alpha beta");
|
||||
assert_eq!(quote.blocks[0].kind, TargetBlock::Quote);
|
||||
let alert = parsed("> [!NOTE] alpha beta");
|
||||
assert_eq!(alert.blocks[0].kind, TargetBlock::Alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_leading_marks_still_map() {
|
||||
let source = "a `fox\n y` z";
|
||||
assert_eq!(read_back(source, "ox"), source.find("ox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_line_end_maps_nearby_text() {
|
||||
for (source, shown) in [
|
||||
("ab y", "b"),
|
||||
("ab y", "b"),
|
||||
("ab 
 y", "b"),
|
||||
("<div>ab y</div>", "b"),
|
||||
("<div>ab 	 y</div>", "b"),
|
||||
] {
|
||||
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_references_match_the_parser() {
|
||||
for source in ["ab € y", "ab  y", "ab  y"] {
|
||||
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_trailing_encoded_space_maps_previous_word() {
|
||||
for source in [
|
||||
"<div>ab </div>",
|
||||
"<div>ab <b>word</b></div>",
|
||||
"<div>ab   word</div>",
|
||||
] {
|
||||
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_pipe_in_a_table_maps_previous_text() {
|
||||
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
|
||||
let parsed = parsed(source);
|
||||
let nodes = parsed.blocks[0].nodes.as_ref().unwrap();
|
||||
let Node::Table { head, .. } = &nodes[0] else {
|
||||
panic!("table")
|
||||
};
|
||||
let Node::Paragraph(inline) = &head[0][0] else {
|
||||
panic!("text")
|
||||
};
|
||||
assert_eq!(inline.text, "ab|cd");
|
||||
assert_eq!(inline.source_at(source, 1), source.find('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autolink_does_not_decode_literal_reference() {
|
||||
let source = "<https://example.com/?a=&b=2>";
|
||||
assert_eq!(read_back(source, "xample"), source.find("xample"));
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Right presses that keep a selection; Delete Image (R4.10).
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{MarkdownMode, open, opened, point::heard, point::press_on, text};
|
||||
use crate::documents::{DocumentTarget, EditRefusal, MarkdownDocument, TargetBlock};
|
||||
|
||||
/// A right press near the open block's left edge.
|
||||
fn press_open(cx: &mut VisualTestContext) {
|
||||
let field = cx.debug_bounds("markdown-open").expect("a block is open");
|
||||
let at = point(field.left() + px(20.0), field.top() + px(14.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn open_selection(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
document.read_with(cx, |document, cx| {
|
||||
(document.open.as_ref()).map(|open| open.input.read(cx).selection())
|
||||
})
|
||||
}
|
||||
|
||||
fn select_open(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
range: std::ops::Range<usize>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
document.update(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("open").input.clone();
|
||||
input.update(cx, |input, cx| input.select(range, cx));
|
||||
});
|
||||
}
|
||||
|
||||
const LINKED: &str = "[docs](https://x.test)\n\nNext.\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..22, cx);
|
||||
press_open(cx);
|
||||
assert_eq!(
|
||||
open_selection(&document, cx),
|
||||
Some(0..22),
|
||||
"kept open and selected"
|
||||
);
|
||||
let at = heard.borrow().first().expect("heard").at;
|
||||
assert!(at < 6, "the pressed byte, not the selection's: {at}");
|
||||
let target = DocumentTarget {
|
||||
link: Some("https://x.test".into()),
|
||||
..super::point::target(at, TargetBlock::Paragraph)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [target]);
|
||||
assert_eq!(text(&document, cx), LINKED, "nothing written");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_outside_the_selection_moves_the_caret(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 22..22, cx);
|
||||
press_open(cx);
|
||||
let at = open_selection(&document, cx).expect("still open");
|
||||
assert!(
|
||||
at.is_empty() && at.start < 5,
|
||||
"the caret went to the press: {at:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_reference_in_the_open_block_reads_its_definition_outside(cx: &mut TestAppContext) {
|
||||
let source = "[docs][d]\n\n[d]: https://x.test\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..9, cx);
|
||||
press_open(cx);
|
||||
let link = heard
|
||||
.borrow()
|
||||
.first()
|
||||
.and_then(|target| target.link.clone());
|
||||
assert_eq!(link.as_deref(), Some("https://x.test"));
|
||||
assert_eq!(opened(&document, cx), "[docs][d]");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_in_source_keeps_the_selection(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..10), cx));
|
||||
let line = cx.debug_bounds("editor-line-0").expect("drawn");
|
||||
let at = point(line.left() + px(60.0), line.top() + px(6.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
let kept = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||
assert_eq!(kept, 0..10);
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(10..10), cx));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
let moved = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||
assert!(moved.is_empty() && moved.start < 10, "{moved:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) {
|
||||
let source = "A  B\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||
let heard = super::format::refusals(&document, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.delete_image(0, cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(*heard.borrow(), [EditRefusal::NoImage]);
|
||||
document.update(cx, |document, cx| {
|
||||
document.delete_image(4, cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "A B\n");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), source);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn text_pastes_at_the_caret_with_no_block_open(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("First.\n", MarkdownMode::Visual, cx);
|
||||
press_on(0, 0, MouseButton::Right, cx);
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("New ".into()));
|
||||
cx.simulate_keystrokes("secondary-v");
|
||||
assert_eq!(text(&document, cx), "New First.\n");
|
||||
}
|
||||
|
||||
/// A right press on byte `at` of the open block.
|
||||
fn press_open_at(document: &Entity<MarkdownDocument>, at: usize, cx: &mut VisualTestContext) {
|
||||
let spot = document.read_with(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("open").input.read(cx);
|
||||
input.bounds_for_range(at..at + 1)[0].center()
|
||||
});
|
||||
cx.simulate_mouse_down(spot, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(spot, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
const TWO: &str = "[one](https://a.test) [two](https://b.test)\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_a_selection_reads_the_pressed_link(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TWO, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..43, cx);
|
||||
press_open_at(&document, TWO.find("two").expect("two") + 1, cx);
|
||||
assert_eq!(open_selection(&document, cx), Some(0..43), "kept");
|
||||
let link = heard
|
||||
.borrow()
|
||||
.first()
|
||||
.and_then(|target| target.link.clone());
|
||||
assert_eq!(link.as_deref(), Some("https://b.test"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn only_the_latest_press_in_an_open_block_is_heard(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TWO, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..43, cx);
|
||||
let spots = document.read_with(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("open").input.read(cx);
|
||||
["one", "two"].map(|word| {
|
||||
let at = TWO.find(word).expect("word") + 1;
|
||||
input.bounds_for_range(at..at + 1)[0].center()
|
||||
})
|
||||
});
|
||||
document.update(cx, |document, cx| {
|
||||
document.point_open(Some(spots[0]), cx);
|
||||
document.point_open(Some(spots[1]), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let links: Vec<_> = (heard.borrow().iter())
|
||||
.map(|target| target.link.clone())
|
||||
.collect();
|
||||
assert_eq!(links, [Some("https://b.test".to_string())]);
|
||||
}
|
||||
|
||||
const PICTURES: &str = "x  \n";
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_image_takes_the_pressed_picture(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(PICTURES, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 0, MouseButton::Left, cx);
|
||||
select_open(&document, 0..25, cx);
|
||||
press_open_at(&document, PICTURES.find("b.png").expect("b"), cx);
|
||||
let target = heard.borrow().first().cloned().expect("heard");
|
||||
assert_eq!(target.image.as_deref(), Some("b.png"));
|
||||
document.update(cx, |document, cx| {
|
||||
document.delete_image(target.at, cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(opened(&document, cx), "x  ");
|
||||
}
|
||||
|
||||
const ANCHORED: &str = "[<a href=\"https://inner.test\">inside</a>](https://outer.test)\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_reads_an_anchor_as_drawn(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(ANCHORED, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..0, cx);
|
||||
press_open_at(&document, ANCHORED.find("inside").expect("word") + 2, cx);
|
||||
let link = heard
|
||||
.borrow()
|
||||
.first()
|
||||
.and_then(|target| target.link.clone());
|
||||
assert_eq!(link.as_deref(), Some("https://inner.test"));
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{MarkdownDocument, MarkdownMode, frames, open, type_in};
|
||||
|
||||
const LISTED: &str = "[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||
|
||||
/// Presses entry `ix` of the drawn heading list.
|
||||
fn press_entry(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-toc-{ix}").into_boxed_str()))
|
||||
.expect("the entry is drawn");
|
||||
let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Left, modifiers);
|
||||
frames(cx);
|
||||
cx.simulate_mouse_up(at, MouseButton::Left, modifiers);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_heading_written_above_with_the_same_title_is_not_the_pressed_one(cx: &mut TestAppContext) {
|
||||
let text = "before\n\n[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(0), Some("## B"), cx);
|
||||
frames(cx);
|
||||
press_entry(1, Modifiers::secondary_key(), cx);
|
||||
let (caret, now) =
|
||||
document.read_with(cx, |document, cx| (document.caret(cx), document.text(cx)));
|
||||
assert_eq!(now, "## B\n\n[toc]\n\n# A\n\ntext\n\n## B\n");
|
||||
assert_eq!(caret, (8, 0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_twin_typed_above_while_the_text_waits_is_not_the_pressed_one(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LISTED, MarkdownMode::Split, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(0..0, "## B\n\n".to_string())], cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||
press_entry(1, Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(8, 0)
|
||||
);
|
||||
}
|
||||
|
||||
const TWINS: &str = "[toc]\n\n## B\n\ntext\n\n## B\n";
|
||||
|
||||
/// Deletes the first twin while the text waits.
|
||||
fn delete_first_twin(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) {
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(7..13, String::new())], cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_deleted_twin_leaves_the_other_found(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TWINS, MarkdownMode::Split, cx);
|
||||
delete_first_twin(&document, cx);
|
||||
press_entry(1, Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(4, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_a_deleted_twin_moves_nothing(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TWINS, MarkdownMode::Split, cx);
|
||||
delete_first_twin(&document, cx);
|
||||
let before = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
press_entry(0, Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_in_preview_reveals_the_heading(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LISTED, MarkdownMode::Preview, cx);
|
||||
press_entry(1, Modifiers::none(), cx);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (6, 0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn visual_reveals_on_a_secondary_press_and_edits_on_a_plain_one(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LISTED, MarkdownMode::Visual, cx);
|
||||
press_entry(1, Modifiers::secondary_key(), cx);
|
||||
let (caret, open) = document.read_with(cx, |document, cx| {
|
||||
(document.caret(cx), document.open.is_some())
|
||||
});
|
||||
assert_eq!((caret, open), ((6, 0), false));
|
||||
press_entry(0, Modifiers::none(), cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_some()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_an_earlier_block_grew_finds_the_heading(cx: &mut TestAppContext) {
|
||||
let text = "before\n\n[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(0), Some("before\nextra\nextra"), cx);
|
||||
frames(cx);
|
||||
press_entry(1, Modifiers::secondary_key(), cx);
|
||||
frames(cx);
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(10, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_while_the_text_waits_to_be_read_finds_the_heading(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LISTED, MarkdownMode::Split, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(0..0, "inserted\n".repeat(20))], cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||
press_entry(1, Modifiers::none(), cx);
|
||||
frames(cx);
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(26, 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Makes `edits` while the text waits, caret at the top.
|
||||
fn edit_waiting<'a>(
|
||||
text: &str,
|
||||
edits: Vec<(std::ops::Range<usize>, String)>,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<MarkdownDocument>, &'a mut VisualTestContext) {
|
||||
let (document, cx) = open(text, MarkdownMode::Split, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.edit(edits, cx));
|
||||
document.set_caret(0, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> (usize, usize) {
|
||||
document.read_with(cx, |document, cx| document.caret(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_twin_written_right_at_the_heading_goes_before_it(cx: &mut TestAppContext) {
|
||||
let edits = vec![(7..7, "## B\n\n".to_string())];
|
||||
let (document, cx) = edit_waiting("[toc]\n\n## B\n\ntext\n", edits, cx);
|
||||
press_entry(0, Modifiers::none(), cx);
|
||||
assert_eq!(caret(&document, cx), (4, 0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn adjacent_twins_keep_who_was_deleted(cx: &mut TestAppContext) {
|
||||
let twins = "[toc]\n\n## B\n\n## B\n\ntext\n";
|
||||
let (document, cx) = edit_waiting(twins, vec![(7..13, String::new())], cx);
|
||||
press_entry(0, Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
caret(&document, cx),
|
||||
(0, 0),
|
||||
"the deleted one moves nothing"
|
||||
);
|
||||
press_entry(1, Modifiers::none(), cx);
|
||||
assert_eq!(caret(&document, cx), (2, 0), "the kept one moved up");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_heading_between_two_edits_stays_found(cx: &mut TestAppContext) {
|
||||
let text = "before\n\n[toc]\n\n# A\n\nafter\n";
|
||||
let edits = vec![
|
||||
(0..0, "start\n".to_string()),
|
||||
(text.len()..text.len(), "end\n".to_string()),
|
||||
];
|
||||
let (document, cx) = edit_waiting(text, edits, cx);
|
||||
press_entry(0, Modifiers::none(), cx);
|
||||
assert_eq!(caret(&document, cx), (5, 0));
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use gpui::{Modifiers, TestAppContext};
|
||||
|
||||
use super::{MarkdownMode, open, opened, press, text};
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_in_source_goes_on_with_the_list(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("- a", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(0, 3, cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.simulate_input("b");
|
||||
assert_eq!(text(&document, cx), "- a\n- b");
|
||||
cx.simulate_keystrokes("enter enter");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"- a\n- b\n",
|
||||
"an empty item ends the list"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_in_a_block_goes_on_with_the_list(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("1. a\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.simulate_input("b");
|
||||
assert_eq!(opened(&document, cx), "1. a\n2. b");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tab_shifts_a_block_line_in_and_out(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("- a\n- b\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("tab");
|
||||
assert_eq!(opened(&document, cx), "- a\n - b");
|
||||
cx.simulate_keystrokes("shift-tab");
|
||||
assert_eq!(opened(&document, cx), "- a\n- b");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("| A |\n| - |\n| 1 |\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||
let edited = document.update(cx, |document, cx| {
|
||||
document.edit_table(crate::documents::TableEdit::RowBelow, cx)
|
||||
});
|
||||
assert_eq!(edited, Ok(()));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| A |\n| ---- |\n| 1 |\n| |\n"
|
||||
);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Intro\nNext\n", MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.insert_table(cx).expect("inserted")
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"Intro\n\n| | | |\n| ---- | ---- | ---- |\n| | | |\n| | | |\n\nNext\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_table_after_end_keeps_crlf(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Intro\r\nNext\r\n", MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_keystrokes("end");
|
||||
let caret = editor.read_with(cx, |editor, _| editor.primary().range().start);
|
||||
assert_eq!(caret, 6, "End lands between CR and LF");
|
||||
document.update(cx, |document, cx| {
|
||||
document.insert_table(cx).expect("inserted")
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"Intro\r\n\r\n| | | |\r\n| ---- | ---- | ---- |\r\n| | | |\r\n| | | |\r\n\r\nNext\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_match_in_visual_opens_its_block_selected(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# A\n\nfind me here\n", MarkdownMode::Visual, cx);
|
||||
let at = "# A\n\nfind me here\n".find("me").expect("the word");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.show_match(at..at + 2, window, cx)
|
||||
});
|
||||
let selected = document.read_with(cx, |document, cx| {
|
||||
let input = document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("a block opens")
|
||||
.input
|
||||
.read(cx);
|
||||
input.text()[input.selection()].to_string()
|
||||
});
|
||||
assert_eq!(selected, "me");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn every_replacement_is_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a b a", MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.replace(vec![(0..1, "x".into()), (4..5, "x".into())], cx)
|
||||
});
|
||||
assert_eq!(text(&document, cx), "x b x");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), "a b a");
|
||||
}
|
||||
|
||||
const FENCED: &str = "```text\n- a\n```\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_in_source_code_is_a_plain_line_break(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(FENCED, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(1, 3, cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(text(&document, cx), "```text\n- a\n\n```\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_in_an_open_code_block_is_a_plain_line_break(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(FENCED, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("open").input.clone();
|
||||
input.update(cx, |input, cx| input.select(11..11, cx));
|
||||
});
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(opened(&document, cx), "```text\n- a\n\n```");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_match_shown_after_a_replacement_opens_selected(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a b a\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.replace(vec![(0..1, "x".into())], cx);
|
||||
document.show_match(4..5, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.simulate_input("z");
|
||||
assert_eq!(text(&document, cx), "x b z\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_takes_no_edits(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("| A |\n| - |\n| 1 |\n", MarkdownMode::Preview, cx);
|
||||
let refused = document.update(cx, |document, cx| {
|
||||
(
|
||||
document.replace(vec![(2..3, "B".into())], cx),
|
||||
document.edit_table(crate::documents::TableEdit::RowBelow, cx),
|
||||
document.insert_table(cx),
|
||||
)
|
||||
});
|
||||
let preview = Err(crate::documents::EditRefusal::Preview);
|
||||
assert_eq!(refused, (false, preview, preview));
|
||||
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_quoted_list_goes_on_in_source_and_in_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("> - a\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(opened(&document, cx), "> - a\n> - ");
|
||||
cx.simulate_keystrokes("escape");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Source, window, cx);
|
||||
document.set_text("> - a", cx);
|
||||
document.set_caret(0, 5, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(text(&document, cx), "> - a\n> - ");
|
||||
}
|
||||
|
||||
/// A text long enough to be read off the window.
|
||||
fn long() -> String {
|
||||
"para\n\n".repeat(super::super::AT_ONCE / 6 + 1)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_waiting_match_lapses_when_the_text_changes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("x\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_text(long(), cx);
|
||||
document.show_match(0..1, window, cx);
|
||||
});
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_text(format!("y{}", long()), cx)
|
||||
});
|
||||
super::frames(cx);
|
||||
let opened = document.read_with(cx, |document, _| document.open.is_some());
|
||||
assert!(!opened, "the old match does not land on the new text");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_newer_match_takes_the_place_of_a_waiting_one(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("x\n", MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_text(long(), cx);
|
||||
document.show_match(0..1, window, cx);
|
||||
});
|
||||
document.update(cx, |document, cx| document.set_text("a b a\n", cx));
|
||||
cx.run_until_parked();
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.show_match(4..5, window, cx)
|
||||
});
|
||||
super::frames(cx);
|
||||
cx.simulate_input("z");
|
||||
assert_eq!(text(&document, cx), "a b z\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_empty_task_ends_the_list(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("- [x] done", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(0, 10, cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(text(&document, cx), "- [x] done\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn shift_tab_takes_a_tab_indent_out(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("- a\n\t- b\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("shift-tab");
|
||||
assert_eq!(opened(&document, cx), "- a\n- b");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn shift_tab_takes_spaces_then_a_tab_out(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("- a\n \t- b\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("shift-tab");
|
||||
assert_eq!(opened(&document, cx), "- a\n- b");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_table_on_an_indented_blank_line_is_a_table(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(
|
||||
"
|
||||
",
|
||||
MarkdownMode::Source,
|
||||
cx,
|
||||
);
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_caret(0, 4, cx);
|
||||
document.insert_table(cx).expect("inserted");
|
||||
});
|
||||
let column = document.update(cx, |document, cx| {
|
||||
document.edit_table(crate::documents::TableEdit::ColumnRight, cx)
|
||||
});
|
||||
assert_eq!(column, Ok(()), "{}", text(&document, cx));
|
||||
cx.run_until_parked();
|
||||
assert!(text(&document, cx).starts_with("| "));
|
||||
}
|
||||
|
||||
const SMALL: &str = "| A | B |\n| - | - |\n| 1 | 2 |\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_table_move_reads_apart_as_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||
let moved = document.update(cx, |document, cx| {
|
||||
document.edit_table(crate::documents::TableEdit::MoveRowUp, cx)
|
||||
});
|
||||
assert_eq!(moved, Ok(()));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
|
||||
);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), SMALL);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_table_edit_at_an_edge_refuses_by_event(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
|
||||
let heard = super::format::refusals(&document, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.edit_table(crate::documents::TableEdit::MoveRowDown, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let edge = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Edge);
|
||||
assert_eq!(*heard.borrow(), [edge]);
|
||||
assert_eq!(text(&document, cx), SMALL);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tidy_in_cr_text_keeps_what_comes_before(cx: &mut TestAppContext) {
|
||||
let source = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
|
||||
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||
let at = source.find('1').expect("a cell");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.edit_table(crate::documents::TableEdit::Tidy, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"Before\r\r| A | B |\r| ---- | ---- |\r| 1 | 2 |\r\rAfter\r"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("Intro\rNext\r", MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.insert_table(cx).expect("inserted")
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"Intro\r\r| | | |\r| ---- | ---- | ---- |\r| | | |\r| | | |\r\rNext\r"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn tidy_in_an_open_cell_writes_it_and_tidies(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(SMALL, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
assert_eq!(opened(&document, cx), "2");
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.edit_table(crate::documents::TableEdit::Tidy, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"| A | B |\n| ---- | ---- |\n| 1 | 2 |\n"
|
||||
);
|
||||
}
|
||||
|
||||
const CRLF: &str = "Intro\r\nNext\r\n";
|
||||
|
||||
/// A code block in CRLF text after `keys`.
|
||||
fn code_block_after(keys: &str, cx: &mut TestAppContext) -> String {
|
||||
let (document, cx) = open(CRLF, MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_keystrokes(keys);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.block(crate::documents::BlockCommand::CodeBlock, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
text(&document, cx)
|
||||
}
|
||||
|
||||
const FENCE_AFTER_INTRO: &str = "Intro\r\n\r\n```\r\n\r\n```\r\n\r\nNext\r\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_block_after_end_in_crlf_text_reads_the_line(cx: &mut TestAppContext) {
|
||||
assert_eq!(code_block_after("end", cx), FENCE_AFTER_INTRO);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_selection_from_inside_a_crlf_reads_the_line(cx: &mut TestAppContext) {
|
||||
assert_eq!(code_block_after("end shift-right", cx), FENCE_AFTER_INTRO);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_table_takes_the_table_or_refuses_outside(cx: &mut TestAppContext) {
|
||||
let source = "before\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||
let heard = super::format::refusals(&document, cx);
|
||||
let delete = |cx: &mut gpui::VisualTestContext| {
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.edit_table(crate::documents::TableEdit::Delete, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
|
||||
delete(cx);
|
||||
assert_eq!(text(&document, cx), "before\n\nafter\n");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), source);
|
||||
document.update(cx, |document, cx| document.set_caret(0, 2, cx));
|
||||
delete(cx);
|
||||
let outside = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Outside);
|
||||
assert_eq!(*heard.borrow(), [outside]);
|
||||
assert_eq!(text(&document, cx), source);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn undo_follows_a_table_edit_that_closes_the_block(cx: &mut TestAppContext) {
|
||||
let source = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.edit_table(crate::documents::TableEdit::Delete, cx)
|
||||
.expect("taken")
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "before\n\nafter\n");
|
||||
let held = cx.update(|window, cx| {
|
||||
let handle = gpui::Focusable::focus_handle(document.read(cx), cx);
|
||||
handle.contains_focused(window, cx)
|
||||
});
|
||||
assert!(held, "focus stays in the document");
|
||||
cx.simulate_keystrokes("secondary-z");
|
||||
assert_eq!(text(&document, cx), source);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, Div, ExternalPaths, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
||||
prelude::FluentBuilder as _, relative,
|
||||
};
|
||||
|
||||
use super::{MarkdownDocument, edit::Landing, parse::Block};
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode, TargetBlock,
|
||||
render::{HEAVY, Hooks, OpenCell, Pointed, draw_nodes, heading_step, point_lines},
|
||||
},
|
||||
forms::{self, Down, Up},
|
||||
primitives::FocusRing as _,
|
||||
theme::{ActiveTheme, Radius, TextSize, Theme},
|
||||
};
|
||||
|
||||
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
||||
|
||||
const CONTEXT: &str = "MarkdownDocument";
|
||||
|
||||
/// How far a faded block recedes.
|
||||
const FADED: f32 = 0.35;
|
||||
|
||||
/// The open block's text box: its closed font and panel.
|
||||
pub(super) fn open_text(kind: Option<(TargetBlock, bool)>, scale: f32, theme: &Theme) -> Div {
|
||||
let colors = &theme.colors;
|
||||
let body = theme.text_size(TextSize::Base) * scale;
|
||||
let mono = theme.text_size(TextSize::Sm) * scale;
|
||||
let text = div()
|
||||
.debug_selector(|| "markdown-open-text".to_string())
|
||||
.text_size(body)
|
||||
.line_height(body * 1.6)
|
||||
.text_color(colors.fg);
|
||||
match kind {
|
||||
Some((TargetBlock::Heading(level), true)) => text
|
||||
.pt_2()
|
||||
.text_size(body * heading_step(level))
|
||||
.line_height(relative(1.3))
|
||||
.font_weight(HEAVY)
|
||||
.when(level == 6, |text| text.text_color(colors.fg_muted)),
|
||||
// Code, math and front matter keep their panel.
|
||||
Some((kind @ (TargetBlock::Code | TargetBlock::Math | TargetBlock::FrontMatter), true)) => {
|
||||
text.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.when(kind != TargetBlock::Math, |text| {
|
||||
text.font_family(theme.mono_family.clone()).text_size(mono)
|
||||
})
|
||||
.when(kind == TargetBlock::FrontMatter, |text| {
|
||||
text.text_color(colors.fg_muted)
|
||||
})
|
||||
}
|
||||
// Written as is: mono and muted.
|
||||
Some((_, false)) => text
|
||||
.font_family(theme.mono_family.clone())
|
||||
.line_height(gpui::phi())
|
||||
.text_color(colors.fg_muted),
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modes with a reader's block to keep.
|
||||
fn editing_mode(mode: MarkdownMode) -> bool {
|
||||
mode == MarkdownMode::Visual
|
||||
}
|
||||
|
||||
/// Undo and redo; Tab shifts an open block's lines.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let context = Some(CONTEXT);
|
||||
let block = Some("MarkdownDocument > ElyInput");
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("secondary-z", Undo, context),
|
||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||
KeyBinding::new("secondary-v", Paste, context),
|
||||
KeyBinding::new("tab", Indent, block),
|
||||
KeyBinding::new("shift-tab", Outdent, block),
|
||||
];
|
||||
if !cfg!(target_os = "macos") {
|
||||
bindings.push(KeyBinding::new("ctrl-y", Redo, context));
|
||||
}
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// List item `ix`, with typewriter room at the ends.
|
||||
fn item(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let (first, last) = (ix == 0, ix == self.parsed.blocks.len());
|
||||
let room = self.room;
|
||||
div()
|
||||
.when(first, |item| item.pt(room))
|
||||
.when(last, |item| item.pb(room))
|
||||
.child(self.block_item(ix, window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A block, or the end.
|
||||
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let editing = self.mode == MarkdownMode::Visual;
|
||||
let drawn = editing && ix < self.parsed.blocks.len();
|
||||
let on_point = drawn.then(|| self.on_point(ix, cx));
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (radius, body, mono) = (
|
||||
theme.radius(Radius::Md),
|
||||
theme.text_size(TextSize::Base) * self.look.scale,
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
// Focus fades all but the reader's block.
|
||||
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
||||
let open = self.open.as_ref();
|
||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||
return match (open, editing) {
|
||||
(Some(open), _) if open.append => self.field(open, None, cx),
|
||||
(_, true) => self
|
||||
.pressable(div().id("more").min_h_8(), None, cx)
|
||||
.into_any_element(),
|
||||
(_, false) => div().into_any_element(),
|
||||
};
|
||||
};
|
||||
if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) {
|
||||
return self.field(open, Some(block), cx);
|
||||
}
|
||||
// A cell open in this block, typed in place.
|
||||
let cell = open.and_then(|open| {
|
||||
let inside = block.range.start <= open.range.start && open.range.end <= block.range.end;
|
||||
Some(OpenCell {
|
||||
at: open.cell.filter(|_| inside)?,
|
||||
field: std::cell::RefCell::new(Some(
|
||||
div()
|
||||
.debug_selector(|| "markdown-open-cell".to_string())
|
||||
.on_mouse_down(MouseButton::Right, Self::point_in_open(cx))
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(open.input.clone())
|
||||
.into_any_element(),
|
||||
)),
|
||||
})
|
||||
});
|
||||
let shown = match &block.nodes {
|
||||
Some(nodes) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(body)
|
||||
.line_height(body * 1.6)
|
||||
.text_color(colors.fg)
|
||||
.children(draw_nodes(
|
||||
&("render", ix).into(),
|
||||
nodes,
|
||||
Hooks {
|
||||
base: &self.base,
|
||||
on_link: &self.on_link,
|
||||
on_heading: &self.on_heading,
|
||||
on_point: &on_point,
|
||||
cell: cell.as_ref(),
|
||||
},
|
||||
self.look.scale,
|
||||
cx,
|
||||
)),
|
||||
None => {
|
||||
let raw = &self.parsed.text[block.range.clone()];
|
||||
let drawn = StyledText::new(raw.to_string());
|
||||
let layout = drawn.layout().clone();
|
||||
let shown = div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(drawn)
|
||||
.into_any_element();
|
||||
div().child(point_lines(
|
||||
shown,
|
||||
layout,
|
||||
raw,
|
||||
0,
|
||||
&on_point,
|
||||
(false, false),
|
||||
))
|
||||
}
|
||||
};
|
||||
let block = div()
|
||||
.id(("block", ix))
|
||||
.debug_selector(move || format!("markdown-block-{ix}"))
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.when(faded, |block| block.opacity(FADED))
|
||||
.child(shown);
|
||||
match editing {
|
||||
true => self.pressable(block, Some(ix), cx).into_any_element(),
|
||||
false => block.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The open block in its closed frame, size and weight.
|
||||
fn field(
|
||||
&self,
|
||||
open: &super::Open,
|
||||
block: Option<&Block>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let point = Self::point_in_open(cx);
|
||||
let kind = block.map(|block| (block.kind, block.nodes.is_some()));
|
||||
let text = open_text(kind, self.look.scale, cx.theme());
|
||||
div()
|
||||
.debug_selector(|| "markdown-open".to_string())
|
||||
.on_mouse_down(MouseButton::Right, point)
|
||||
.px_3()
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.child(text.child(open.input.clone()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A right press in the open field points there.
|
||||
fn point_in_open(
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static {
|
||||
cx.listener(|document, event: &MouseDownEvent, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.point_open(Some(event.position), cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// `element` opens block `ix`; a right press points.
|
||||
fn pressable(
|
||||
&self,
|
||||
element: gpui::Stateful<gpui::Div>,
|
||||
ix: Option<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::Stateful<gpui::Div> {
|
||||
let hover = cx.theme().colors.hover.opacity(0.5);
|
||||
element
|
||||
.tab_index(0)
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.focus_ring(cx)
|
||||
.cursor_text()
|
||||
.hover(move |element| element.bg(hover))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |document, event: &MouseDownEvent, window, cx| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.open(ix, Landing::End, window, cx);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |document, _: &MouseDownEvent, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.point(ix, None, Pointed::block(), window, cx);
|
||||
}),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
||||
if event.is_keyboard() {
|
||||
document.open(ix, Landing::End, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MarkdownDocument {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.take_orphan(window, cx);
|
||||
// A top line asked before its text was read.
|
||||
self.place_top(window, cx);
|
||||
self.show_waiting(window, cx);
|
||||
self.open_waiting(window, cx);
|
||||
let colors = cx.theme().colors.clone();
|
||||
let root = div()
|
||||
.size_full()
|
||||
.bg(colors.surface)
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.on_drop(cx.listener(|_, dropped: &ExternalPaths, _, cx| {
|
||||
log::info!("markdown document: {} files dropped", dropped.paths().len());
|
||||
cx.emit(super::Attachment::Files(dropped.paths().to_vec()));
|
||||
}));
|
||||
self.center_current(cx);
|
||||
// Typewriter: half a view past each end.
|
||||
self.make_room();
|
||||
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
||||
// The text column: its measure, font, centered.
|
||||
let column = |content: gpui::AnyElement, look: &super::DocumentLook| {
|
||||
div().size_full().flex().justify_center().child(
|
||||
div()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.when_some(look.measure, |column, measure| column.max_w(measure))
|
||||
.when_some(look.font.clone(), |column, font| column.font_family(font))
|
||||
.child(content),
|
||||
)
|
||||
};
|
||||
let blocks = column(blocks.into_any_element(), &self.look);
|
||||
let source = column(self.editor.clone().into_any_element(), &self.look);
|
||||
match self.mode {
|
||||
MarkdownMode::Source => root
|
||||
.capture_action(cx.listener(|document, _: &crate::editor::Paste, _, cx| {
|
||||
if document.paste_picture(cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &crate::editor::Newline, _, cx| {
|
||||
if document.source_newline(cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.child(source)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Split => root
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
||||
.child(div().flex_1().min_w_0().h_full().child(blocks))
|
||||
.into_any_element(),
|
||||
MarkdownMode::Preview => root
|
||||
.track_focus(&self.focus)
|
||||
.px_2()
|
||||
.py_3()
|
||||
.child(blocks)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Visual => root
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(cx.listener(|document, _: &Undo, window, cx| {
|
||||
document.history(false, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|document, _: &Redo, window, cx| {
|
||||
document.history(true, window, cx);
|
||||
}))
|
||||
// One history: a block's own never runs.
|
||||
.capture_action(cx.listener(|document, _: &forms::Undo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
document.history(false, window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &forms::Redo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
document.history(true, window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &forms::Paste, _, cx| {
|
||||
if document.paste_picture(cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|document, _: &Paste, _, cx| {
|
||||
if document.paste_picture(cx) {
|
||||
return;
|
||||
}
|
||||
match cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||
Some(text) => _ = document.insert(&text, cx),
|
||||
None => log::info!("markdown document: nothing to paste"),
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
||||
if document.block_newline(cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|document, _: &Indent, window, cx| {
|
||||
document.block_shift(true, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|document, _: &Outdent, window, cx| {
|
||||
document.block_shift(false, window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &Up, window, cx| {
|
||||
if document.step(false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &Down, window, cx| {
|
||||
if document.step(true, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_key_down(
|
||||
cx.listener(|document, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key == "escape" && document.open.is_some() {
|
||||
cx.stop_propagation();
|
||||
document.commit(cx);
|
||||
window.focus(&document.focus, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.px_2()
|
||||
.py_3()
|
||||
.child(blocks)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
-90
@@ -1,18 +1,62 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, FontWeight};
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
mod tex;
|
||||
|
||||
use gpui::App;
|
||||
use markdown::{Constructs, ParseOptions, mdast};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{forms::Highlight, theme::ActiveTheme};
|
||||
use crate::{documents::render::HEAVY, 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
|
||||
/// GitHub's flavor, and math between dollars.
|
||||
pub(crate) fn options() -> ParseOptions {
|
||||
ParseOptions {
|
||||
constructs: Constructs {
|
||||
math_flow: true,
|
||||
math_text: true,
|
||||
..Constructs::gfm()
|
||||
},
|
||||
..ParseOptions::gfm()
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` as a syntax tree.
|
||||
pub(crate) fn tree(text: &str) -> mdast::Node {
|
||||
parsed(text, &options())
|
||||
}
|
||||
|
||||
fn parsed(text: &str, options: &ParseOptions) -> mdast::Node {
|
||||
let swapped = tex::swapped(text, options);
|
||||
let mut root =
|
||||
markdown::to_mdast(&swapped.text, options).expect("markdown without MDX always parses");
|
||||
tex::mend(&mut root, &swapped);
|
||||
root
|
||||
}
|
||||
|
||||
/// An inline formula's TeX: inside `$` runs or `\(` pairs.
|
||||
pub(crate) fn math_inside(source: &str, whole: Range<usize>) -> Range<usize> {
|
||||
match source[whole.clone()].starts_with('$') {
|
||||
true => crate::documents::tree::inside(source, whole, '$'),
|
||||
false => whole.start + 2..whole.end - 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a whole file parses: front matter too.
|
||||
pub(crate) fn file_options() -> ParseOptions {
|
||||
let mut options = options();
|
||||
options.constructs.frontmatter = true;
|
||||
options
|
||||
}
|
||||
|
||||
/// A whole file as a syntax tree.
|
||||
pub fn file_tree(text: &str) -> mdast::Node {
|
||||
parsed(text, &file_options())
|
||||
}
|
||||
|
||||
/// The bytes of the source a parsed node spans.
|
||||
pub(crate) fn span(node: &mdast::Node) -> Range<usize> {
|
||||
let at = node.position().expect("a parsed node has its position");
|
||||
at.start.offset..at.end.offset
|
||||
}
|
||||
|
||||
/// How a stretch of markdown prints while it is edited.
|
||||
@@ -37,7 +81,7 @@ fn leading(text: &str, ch: char) -> usize {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// How many bytes at each end of `span` write `mark`: its asterisks, tildes or backticks.
|
||||
/// Bytes at each end of `span` that write `mark`.
|
||||
pub(crate) fn edge(span: &str, mark: Mark) -> usize {
|
||||
let first = span.chars().next().expect("a styled span has its marker");
|
||||
let run = leading(span, first).min(span.len() / 2);
|
||||
@@ -47,58 +91,59 @@ pub(crate) fn edge(span: &str, mark: Mark) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each.
|
||||
/// Every node under `node`, itself first, in source order.
|
||||
pub(crate) fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) {
|
||||
out.push(node);
|
||||
for child in node.children().into_iter().flatten() {
|
||||
walk(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks over `text`, with the syntax writing each.
|
||||
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
|
||||
let root = file_tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
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 edge = edge(span, mark);
|
||||
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 = edge(span, Mark::Code);
|
||||
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));
|
||||
for node in nodes {
|
||||
let range = span(node);
|
||||
let written = &text[range.clone()];
|
||||
let styled = match node {
|
||||
mdast::Node::Strong(_) => Some(Mark::Strong),
|
||||
mdast::Node::Emphasis(_) => Some(Mark::Emphasis),
|
||||
mdast::Node::Delete(_) => Some(Mark::Strike),
|
||||
mdast::Node::InlineCode(_) => Some(Mark::Code),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mark) = styled {
|
||||
let edge = edge(written, mark);
|
||||
out.push((range.clone(), mark));
|
||||
out.push((range.start..range.start + edge, Mark::Syntax));
|
||||
out.push((range.end - edge..range.end, Mark::Syntax));
|
||||
continue;
|
||||
}
|
||||
match node {
|
||||
mdast::Node::Link(mdast::Link { children, .. })
|
||||
| mdast::Node::LinkReference(mdast::LinkReference { children, .. }) => {
|
||||
out.push((range.clone(), Mark::Link));
|
||||
if let (Some(first), Some(last)) = (children.first(), children.last()) {
|
||||
let inner = span(first).start..span(last).end;
|
||||
out.push((range.start..inner.start, Mark::Syntax));
|
||||
out.push((inner.end..range.end, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
let hashes = leading(span, '#');
|
||||
out.push((range.clone(), Mark::Heading(level as u8)));
|
||||
mdast::Node::Heading(heading) => {
|
||||
let hashes = leading(written, '#');
|
||||
out.push((range.clone(), Mark::Heading(heading.depth)));
|
||||
if hashes > 0 {
|
||||
let spaced = hashes + leading(&span[hashes..], ' ');
|
||||
let spaced = hashes + leading(&written[hashes..], ' ');
|
||||
out.push((range.start..range.start + spaced, Mark::Syntax));
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => {
|
||||
mdast::Node::Blockquote(_) => {
|
||||
out.push((range.clone(), Mark::Quote));
|
||||
let mut at = range.start;
|
||||
for line in span.split_inclusive('\n') {
|
||||
for line in written.split_inclusive('\n') {
|
||||
let marker = line.len() - line.trim_start_matches(['>', ' ']).len();
|
||||
out.push((at..at + marker, Mark::Syntax));
|
||||
at += line.len();
|
||||
@@ -111,13 +156,25 @@ pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
|
||||
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.
|
||||
/// Markdown styled as typed; its syntax 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)
|
||||
let marks = marks(text);
|
||||
// A sixth-level heading reads muted, as drawn.
|
||||
let muted: Vec<Range<usize>> = marks
|
||||
.iter()
|
||||
.filter(|(_, mark)| *mark == Mark::Heading(6))
|
||||
.map(|(range, _)| range.clone())
|
||||
.collect();
|
||||
marks
|
||||
.into_iter()
|
||||
.map(|(range, mark)| {
|
||||
let within =
|
||||
|outer: &Range<usize>| outer.start <= range.start && range.end <= outer.end;
|
||||
let plain = Highlight::new(match muted.iter().any(within) {
|
||||
true => colors.fg_muted,
|
||||
false => colors.fg,
|
||||
});
|
||||
let range = match mark {
|
||||
Mark::Strike => {
|
||||
let span = &text[range.clone()];
|
||||
@@ -128,7 +185,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
|
||||
};
|
||||
let highlight = match mark {
|
||||
Mark::Strong | Mark::Heading(_) => Highlight {
|
||||
weight: Some(FontWeight::SEMIBOLD),
|
||||
weight: Some(HEAVY),
|
||||
..plain
|
||||
},
|
||||
Mark::Emphasis => Highlight {
|
||||
@@ -141,7 +198,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
|
||||
},
|
||||
Mark::Code => Highlight {
|
||||
background: Some(colors.sunken),
|
||||
..plain
|
||||
..Highlight::new(colors.fg)
|
||||
},
|
||||
Mark::Link => Highlight::new(colors.link),
|
||||
Mark::Quote => Highlight::new(colors.fg_muted),
|
||||
@@ -152,48 +209,41 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A link written `[text](address)` around byte `at`: its range, its text and its address.
|
||||
/// The `[text](address)` link around byte `at`, if any.
|
||||
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()))
|
||||
let root = tree(text);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
nodes.into_iter().find_map(|node| match node {
|
||||
mdast::Node::Link(link) => {
|
||||
let range = span(node);
|
||||
let written = &text[range.clone()];
|
||||
if !(range.start <= at && at <= range.end && written.starts_with('[')) {
|
||||
return None;
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
let label = &written[1..written.rfind("](")?];
|
||||
Some((range, label.to_string(), link.url.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document's top-level blocks, as ranges of its source.
|
||||
/// Top-level blocks as source ranges; definitions excluded.
|
||||
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 root = tree(text);
|
||||
root.children()
|
||||
.expect("the root holds the blocks")
|
||||
.iter()
|
||||
.filter(|block| !matches!(block, mdast::Node::Definition(_)))
|
||||
.map(|block| {
|
||||
let range = span(block);
|
||||
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.
|
||||
/// Words in `text`; each ideograph counts as one.
|
||||
pub fn words(text: &str) -> usize {
|
||||
text.unicode_words().count()
|
||||
}
|
||||
@@ -201,7 +251,7 @@ pub fn words(text: &str) -> usize {
|
||||
/// Words an adult reads in a minute, on average.
|
||||
const PACE: usize = 238;
|
||||
|
||||
/// Minutes to read `words`, at least one when there are any.
|
||||
/// Minutes to read `words`; at least one if any.
|
||||
pub fn reading_minutes(words: usize) -> usize {
|
||||
words.div_ceil(PACE)
|
||||
}
|
||||
@@ -251,6 +301,24 @@ mod tests {
|
||||
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_links_mark_their_syntax() {
|
||||
use Mark::*;
|
||||
let defined = "\n\n[site]: https://ely.dev\n";
|
||||
for (link, open, close) in [
|
||||
("[the site][site]", "[", "][site]"),
|
||||
("[site][]", "[", "][]"),
|
||||
("[site]", "[", "]"),
|
||||
] {
|
||||
let text = format!("{link}{defined}");
|
||||
assert_eq!(
|
||||
found(&text),
|
||||
[(link, Link), (open, Syntax), (close, Syntax)],
|
||||
"{link}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_gives_its_range_text_and_address() {
|
||||
let text = "see [the docs](https://ely.dev) or <https://x.dev>";
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! `\(…\)` and `\[…\]` read as math between dollars.
|
||||
|
||||
use std::{borrow::Cow, ops::Range};
|
||||
|
||||
use markdown::{ParseOptions, mdast, unist::Position};
|
||||
|
||||
use super::{span, walk};
|
||||
use crate::documents::tree::{Style, html};
|
||||
|
||||
/// Bytes a literal dollar may stand in as while parsing.
|
||||
const STAND_INS: std::ops::RangeInclusive<u8> = 0x01..=0x08;
|
||||
|
||||
/// One pair's delimiters as written.
|
||||
pub(super) struct Pair {
|
||||
open: Range<usize>,
|
||||
close: Range<usize>,
|
||||
}
|
||||
|
||||
/// The swapped text and what to put back after parsing.
|
||||
pub(super) struct Swapped<'a> {
|
||||
pub text: Cow<'a, str>,
|
||||
pairs: Vec<Pair>,
|
||||
/// Spaces that only part two dollars.
|
||||
gaps: Vec<usize>,
|
||||
/// The byte every other dollar was written as.
|
||||
stand_in: u8,
|
||||
}
|
||||
|
||||
/// `text` with each pair's delimiters as dollars, byte for byte.
|
||||
pub(super) fn swapped<'a>(text: &'a str, options: &ParseOptions) -> Swapped<'a> {
|
||||
let stand_in = STAND_INS
|
||||
.clone()
|
||||
.find(|byte| !text.as_bytes().contains(byte));
|
||||
let none = || Swapped {
|
||||
text: Cow::Borrowed(text),
|
||||
pairs: Vec::new(),
|
||||
gaps: Vec::new(),
|
||||
stand_in: 0,
|
||||
};
|
||||
if !text.contains("\\(") && !text.contains("\\[") {
|
||||
return none();
|
||||
}
|
||||
let Some(stand_in) = stand_in else {
|
||||
log::warn!("markdown: \\( reads as text; every stand-in byte is used");
|
||||
return none();
|
||||
};
|
||||
let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses");
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let mut kept = vec![false; text.len()];
|
||||
let (mut pairs, mut dollars) = (Vec::new(), Vec::new());
|
||||
for node in nodes {
|
||||
use mdast::Node::{Heading, Paragraph, TableCell};
|
||||
if matches!(node, Paragraph(_) | Heading(_) | TableCell(_)) {
|
||||
keep(node, text, &mut kept);
|
||||
paired(text, span(node), &kept, &mut pairs, &mut dollars);
|
||||
}
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
return none();
|
||||
}
|
||||
let (out, gaps) = written(text, &pairs, &dollars, stand_in);
|
||||
Swapped {
|
||||
text: Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")),
|
||||
pairs,
|
||||
gaps,
|
||||
stand_in,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks what in `container` is not prose: code, tags, addresses.
|
||||
fn keep(container: &mdast::Node, text: &str, kept: &mut [bool]) {
|
||||
let mut nodes = Vec::new();
|
||||
walk(container, &mut nodes);
|
||||
// Open styled tags, and where code text last started.
|
||||
let (mut open, mut from) = (Vec::new(), 0);
|
||||
for node in &nodes[1..] {
|
||||
use mdast::Node::*;
|
||||
let whole = span(node);
|
||||
match node {
|
||||
InlineCode(_) | InlineMath(_) | Image(_) | ImageReference(_) | FootnoteReference(_) => {
|
||||
kept[whole].fill(true)
|
||||
}
|
||||
Link(_) | LinkReference(_) => {
|
||||
kept[whole.clone()].fill(true);
|
||||
// Bracketed text is prose; its address is not.
|
||||
let last = node.children().and_then(|children| children.last());
|
||||
if text[whole.clone()].starts_with('[')
|
||||
&& let Some(last) = last
|
||||
{
|
||||
// A text's span skips a leading escape: start at `[`.
|
||||
kept[whole.start + 1..span(last).end].fill(false);
|
||||
}
|
||||
}
|
||||
Html(_) => {
|
||||
kept[whole.clone()].fill(true);
|
||||
let Some(tag) = html::tag(&text[whole.clone()]) else {
|
||||
continue;
|
||||
};
|
||||
if html::inline_style(&tag.name).is_none() {
|
||||
continue;
|
||||
}
|
||||
let was = coded(&open);
|
||||
match tag.closing {
|
||||
true => end(&mut open, &tag.name),
|
||||
false => {
|
||||
if tag.name == "a" {
|
||||
end(&mut open, "a");
|
||||
}
|
||||
open.push(tag.name);
|
||||
}
|
||||
}
|
||||
match (was, coded(&open)) {
|
||||
(false, true) => from = whole.end,
|
||||
(true, false) => kept[from..whole.start].fill(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// An unclosed `<code>` runs to its block's end.
|
||||
if coded(&open) {
|
||||
kept[from..span(container).end].fill(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an open tag shows its text as code.
|
||||
fn coded(open: &[String]) -> bool {
|
||||
(open.iter()).any(|name| html::inline_style(name) == Some(Style::Code))
|
||||
}
|
||||
|
||||
/// Ends the innermost `name` as the reader does: inner tags close.
|
||||
fn end(open: &mut Vec<String>, name: &str) {
|
||||
let Some(at) = open.iter().rposition(|opened| opened == name) else {
|
||||
return;
|
||||
};
|
||||
let closed = open.split_off(at);
|
||||
open.extend(
|
||||
closed
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.filter(|name| html::reopens(name)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pairs and bare dollars in `within`; pairs cross nothing kept.
|
||||
fn paired(
|
||||
text: &str,
|
||||
within: Range<usize>,
|
||||
kept: &[bool],
|
||||
pairs: &mut Vec<Pair>,
|
||||
dollars: &mut Vec<usize>,
|
||||
) {
|
||||
let bytes = text.as_bytes();
|
||||
let mut open: Option<(usize, u8)> = None;
|
||||
let mut at = within.start;
|
||||
while at < within.end {
|
||||
if kept[at] {
|
||||
open = None;
|
||||
} else if bytes[at] == b'$' {
|
||||
dollars.push(at);
|
||||
} else if bytes[at] == b'\\' && at + 1 < within.end {
|
||||
match (bytes[at + 1], open) {
|
||||
(b'(', None) => open = Some((at, b')')),
|
||||
(b'[', None) => open = Some((at, b']')),
|
||||
(close, Some((start, wanted))) if close == wanted && !kept[at + 1] => {
|
||||
if !bytes[start + 2..at].iter().all(u8::is_ascii_whitespace) {
|
||||
pairs.push(Pair {
|
||||
open: start..start + 2,
|
||||
close: at..at + 2,
|
||||
});
|
||||
}
|
||||
open = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// An escaped byte escapes nothing after it.
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The swapped bytes; a space parts dollars that would touch.
|
||||
fn written(text: &str, pairs: &[Pair], dollars: &[usize], stand_in: u8) -> (Vec<u8>, Vec<usize>) {
|
||||
let mut out = text.as_bytes().to_vec();
|
||||
for at in dollars {
|
||||
out[*at] = stand_in;
|
||||
}
|
||||
let mut gaps = Vec::new();
|
||||
for (ix, pair) in pairs.iter().enumerate() {
|
||||
for byte in &mut out[pair.open.end..pair.close.start] {
|
||||
if *byte == b'$' {
|
||||
*byte = stand_in;
|
||||
}
|
||||
}
|
||||
let open = match pair.open.start.checked_sub(1).map(|before| out[before]) {
|
||||
Some(b'$') => {
|
||||
gaps.push(pair.open.start);
|
||||
*b" $"
|
||||
}
|
||||
_ => *b"$ ",
|
||||
};
|
||||
out[pair.open.clone()].copy_from_slice(&open);
|
||||
let after = pair.close.end;
|
||||
let opens_next = pairs
|
||||
.get(ix + 1)
|
||||
.is_some_and(|next| next.open.start == after);
|
||||
let close = match opens_next || out.get(after) == Some(&b'$') {
|
||||
true => {
|
||||
gaps.push(pair.close.start + 1);
|
||||
*b"$ "
|
||||
}
|
||||
false => *b" $",
|
||||
};
|
||||
out[pair.close.clone()].copy_from_slice(&close);
|
||||
}
|
||||
(out, gaps)
|
||||
}
|
||||
|
||||
/// Puts TeX, spans and dollars back; drops parting spaces.
|
||||
pub(super) fn mend(root: &mut mdast::Node, swapped: &Swapped) {
|
||||
if swapped.pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let mdast::Node::InlineMath(math) = root
|
||||
&& let Some(position) = &mut math.position
|
||||
&& let Some(pair) =
|
||||
(swapped.pairs.iter()).find(|pair| pair.open.contains(&position.start.offset))
|
||||
{
|
||||
math.value = written_tex(&math.value, pair, swapped, position);
|
||||
position.start.column -= position.start.offset - pair.open.start;
|
||||
position.start.offset = pair.open.start;
|
||||
position.end.column += pair.close.end - position.end.offset;
|
||||
position.end.offset = pair.close.end;
|
||||
}
|
||||
if let Some(children) = root.children_mut() {
|
||||
children.iter_mut().for_each(|child| trim(child, swapped));
|
||||
children.retain(|child| !matches!(child, mdast::Node::Text(text) if text.value.is_empty()));
|
||||
children.iter_mut().for_each(|child| mend(child, swapped));
|
||||
}
|
||||
}
|
||||
|
||||
/// The TeX as written, from the value markdown-rs read.
|
||||
fn written_tex(value: &str, pair: &Pair, swapped: &Swapped, at: &Position) -> String {
|
||||
let bytes = swapped.text.as_bytes();
|
||||
// A pad: the space a delimiter writes inside its dollar.
|
||||
let pads = (
|
||||
at.start.offset == pair.open.start,
|
||||
at.end.offset == pair.close.end,
|
||||
);
|
||||
let ends = (
|
||||
pads.0 || bytes[pair.open.end] == b' ',
|
||||
pads.1 || bytes[pair.close.start - 1] == b' ',
|
||||
);
|
||||
// With spaces at both ends, markdown-rs trimmed one each.
|
||||
let mut tex = match ends {
|
||||
(true, true) => format!(" {value} "),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
if pads.1 {
|
||||
tex.pop();
|
||||
}
|
||||
if pads.0 {
|
||||
tex.remove(0);
|
||||
}
|
||||
tex.replace(char::from(swapped.stand_in), "$")
|
||||
}
|
||||
|
||||
/// Restores a text's dollars; drops parting spaces.
|
||||
fn trim(node: &mut mdast::Node, swapped: &Swapped) {
|
||||
let mdast::Node::Text(text) = node else {
|
||||
return;
|
||||
};
|
||||
text.value = text.value.replace(char::from(swapped.stand_in), "$");
|
||||
let Some(position) = &mut text.position else {
|
||||
return;
|
||||
};
|
||||
if swapped.gaps.contains(&position.start.offset) && text.value.starts_with(' ') {
|
||||
text.value.remove(0);
|
||||
position.start.offset += 1;
|
||||
position.start.column += 1;
|
||||
}
|
||||
if swapped.gaps.contains(&(position.end.offset - 1)) && text.value.ends_with(' ') {
|
||||
text.value.pop();
|
||||
position.end.offset -= 1;
|
||||
position.end.column -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,123 @@
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use crate::documents::markdown::{options, span, tree, walk};
|
||||
|
||||
/// Each formula's TeX and span, and the text left.
|
||||
fn read(source: &str) -> (Vec<(String, std::ops::Range<usize>)>, String) {
|
||||
let root = tree(source);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let mut maths = Vec::new();
|
||||
let mut text = String::new();
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::InlineMath(math) => maths.push((math.value.clone(), span(node))),
|
||||
Node::Text(written) => text.push_str(&written.value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(maths, text)
|
||||
}
|
||||
|
||||
fn tex(source: &str) -> Vec<String> {
|
||||
read(source).0.into_iter().map(|(tex, _)| tex).collect()
|
||||
}
|
||||
|
||||
fn untouched(source: &str) {
|
||||
let plain = markdown::to_mdast(source, &options()).unwrap();
|
||||
assert_eq!(tree(source), plain, "{source:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_spans_its_delimiters_and_keeps_its_tex() {
|
||||
assert_eq!(
|
||||
read(r"a \(x\) b"),
|
||||
(vec![("x".into(), 2..7)], "a b".into())
|
||||
);
|
||||
assert_eq!(tex(r"\( x \)"), [" x "]);
|
||||
assert_eq!(tex(r"\(x \)\(y\)"), ["x ", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_formulas_stay_two() {
|
||||
for (source, maths) in [
|
||||
(r"\(x\)\(y\)", vec!["x", "y"]),
|
||||
(r"$x$\(y\)", vec!["x", "y"]),
|
||||
(r"\(y\)$x$", vec!["y", "x"]),
|
||||
(r"\[x\]\(y\)", vec!["x", "y"]),
|
||||
] {
|
||||
let (found, text) = read(source);
|
||||
let found: Vec<_> = found.into_iter().map(|(tex, _)| tex).collect();
|
||||
assert_eq!(found, maths, "{source:?}");
|
||||
assert_eq!(text, "", "no parting space shows: {source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollars_stay_dollars() {
|
||||
assert_eq!(tex(r"\(\$5\)"), [r"\$5"]);
|
||||
assert_eq!(tex(r"\[a \$ b\]"), [r"a \$ b"]);
|
||||
assert_eq!(tex(r"\(a$b\)"), ["a$b"]);
|
||||
assert_eq!(
|
||||
read(r"costs $5 and \(x\)"),
|
||||
(vec![("x".into(), 13..18)], "costs $5 and ".into()),
|
||||
"a stray dollar does not pair with a formula"
|
||||
);
|
||||
assert_eq!(read(r"\$\(x\)").1, "$", "an escaped dollar before");
|
||||
assert_eq!(read(r"\(x\)\$").1, "$", "an escaped dollar after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_keep_their_parentheses() {
|
||||
let link = r"[go](https://e.test/\(x\))";
|
||||
untouched(link);
|
||||
untouched(r".png)");
|
||||
untouched("[go][a\\(x\\)]\n\n[a\\(x\\)]: /u");
|
||||
untouched(r"<https://e.test/\(x\)>");
|
||||
assert_eq!(tex(r"[\(x\)](https://e.test)"), ["x"], "link text is prose");
|
||||
untouched(r"[a\(b](c)\)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_and_tags_stay_literal() {
|
||||
untouched(r"a <code>\(x\)</code> b");
|
||||
untouched(r"a <kbd>\(x\)</kbd> b");
|
||||
untouched(r"a <code>\(x\) b");
|
||||
untouched(r"a <code><code>\(x\)</code> \(y\)</code> b");
|
||||
untouched(r"a <kbd><code>\(x\)</code> \(y\)</kbd> b");
|
||||
assert_eq!(tex(r"<code>a</code> \(x\)"), ["x"], "after code, prose");
|
||||
for crossed in [
|
||||
r"a <code><kbd>x</code> \(y\) b",
|
||||
r"a <b><kbd>x</b> \(y\) b",
|
||||
r"a <code><b>x</code> \(y\)</b> b",
|
||||
] {
|
||||
assert_eq!(
|
||||
tex(crossed),
|
||||
["y"],
|
||||
"a crossed close ends code: {crossed:?}"
|
||||
);
|
||||
}
|
||||
untouched(r"a <b><code>x</b> \(y\) b");
|
||||
let reopened = crate::documents::tree::parse(r"a <b><code>x</b> \(y\) b");
|
||||
let crate::documents::tree::Node::Paragraph(inline) = &reopened[0] else {
|
||||
panic!("a paragraph: {reopened:?}");
|
||||
};
|
||||
let code = crate::documents::tree::Style::Code;
|
||||
assert!(
|
||||
(inline.styles.iter()).any(|(range, style)| *style == code && range.contains(&6)),
|
||||
"the reader reopens code over (y): {inline:?}"
|
||||
);
|
||||
untouched(r"`\(x\)`");
|
||||
untouched(r"\(a `b` c\)");
|
||||
assert_eq!(tex(r"<b>\(x\)</b>"), ["x"], "other tags hold prose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_stays_in_one_block() {
|
||||
untouched("- \\(a\n- b\\)");
|
||||
untouched("\\(a\r\rb\\)");
|
||||
untouched("\\(a\r\n\r\nb\\)");
|
||||
untouched("# \\(a\nb\\)");
|
||||
untouched("| \\(a | b\\) |\n| - | - |");
|
||||
assert_eq!(tex("> \\(a\n> b\\)"), ["a\nb"], "a quote's lines");
|
||||
}
|
||||
+18
-1
@@ -1,5 +1,8 @@
|
||||
mod blocks;
|
||||
mod contents;
|
||||
mod counts;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod epub;
|
||||
mod find;
|
||||
mod format;
|
||||
@@ -9,6 +12,7 @@ mod link;
|
||||
mod markdown;
|
||||
mod modes;
|
||||
mod pages;
|
||||
mod picture;
|
||||
mod print;
|
||||
mod reading;
|
||||
mod render;
|
||||
@@ -27,28 +31,41 @@ use gpui::App;
|
||||
|
||||
pub(crate) use blocks::source;
|
||||
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use contents::outline;
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
pub use diagram::{Diagram, diagram_svg};
|
||||
pub(crate) use diagram::{flatten, options as svg_options};
|
||||
pub use document::{
|
||||
Attachment, BlockCommand, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget,
|
||||
EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal,
|
||||
TargetBlock,
|
||||
};
|
||||
pub use epub::{Chapter, EpubReader};
|
||||
pub use format::{Format, format};
|
||||
pub(crate) use history::Change;
|
||||
pub use history::{PageHistoryDiff, Version, VersionHistory};
|
||||
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
||||
pub use link::LinkEditor;
|
||||
pub use markdown::file_tree as markdown_tree;
|
||||
pub use markdown::markdown_highlights;
|
||||
pub use modes::{MarkdownEditor, MarkdownMode};
|
||||
pub use pages::{PageCover, PageIcon};
|
||||
pub use print::{Margins, Paper, PrintPreview, PrintSettings, sheet};
|
||||
pub use reading::{Footnote, Glossary, ReadingProgress, TooltipTerm, ZenMode};
|
||||
pub use render::{MarkdownRenderer, outline};
|
||||
pub use render::MarkdownRenderer;
|
||||
pub use rich::RichTextEditor;
|
||||
pub use templates::{Template, TemplatePicker};
|
||||
pub use thumbs::PageThumbnailList;
|
||||
pub use todo::{Checklist, TodoItem};
|
||||
pub use toolbar::FixedFormatToolbar;
|
||||
pub use tree::{
|
||||
AlertKind, Inline, Node as MarkdownBlock, Reader as MarkdownReader, Style, alert, is_toc,
|
||||
};
|
||||
pub(crate) use viewer::step as zoom_step;
|
||||
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
|
||||
|
||||
/// Binds the documents keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
rich::bind_keys(cx);
|
||||
document::bind_keys(cx);
|
||||
}
|
||||
+24
-11
@@ -23,19 +23,22 @@ use crate::{
|
||||
/// 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.
|
||||
/// The document as read; a pressed block opens as source.
|
||||
Visual,
|
||||
/// The markdown, styled as it is typed.
|
||||
Source,
|
||||
/// The source beside the document it makes.
|
||||
Split,
|
||||
/// The document alone, read only.
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl MarkdownMode {
|
||||
const ALL: [(Self, &'static str); 3] = [
|
||||
const ALL: [(Self, &'static str); 4] = [
|
||||
(Self::Visual, "Visual"),
|
||||
(Self::Source, "Source"),
|
||||
(Self::Split, "Split"),
|
||||
(Self::Preview, "Preview"),
|
||||
];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
@@ -49,7 +52,7 @@ impl MarkdownMode {
|
||||
|
||||
type OnMode = Rc<dyn Fn(MarkdownMode, &mut Window, &mut App)>;
|
||||
|
||||
/// The block open as its source: the document it opened in, where, its own field, and whether it adds a block at the end.
|
||||
/// The open block: its document, range, field, and appending.
|
||||
struct Open {
|
||||
document: String,
|
||||
range: Range<usize>,
|
||||
@@ -63,7 +66,7 @@ struct Visual {
|
||||
open: Option<Open>,
|
||||
}
|
||||
|
||||
/// `range` moved by an edit that wrote `written` bytes over `replaced`, when it lies after it.
|
||||
/// `range` shifted by `written` bytes written over `replaced`.
|
||||
fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Range<usize> {
|
||||
if range.start < replaced.end {
|
||||
return range;
|
||||
@@ -75,8 +78,8 @@ fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Rang
|
||||
start..end
|
||||
}
|
||||
|
||||
/// What joins a block added at the end to the text before it.
|
||||
fn joint(document: &str) -> &'static str {
|
||||
/// What joins an appended block to the text before.
|
||||
pub(crate) fn joint(document: &str) -> &'static str {
|
||||
if document.is_empty() || document.ends_with("\n\n") {
|
||||
""
|
||||
} else if document.ends_with('\n') {
|
||||
@@ -86,7 +89,7 @@ fn joint(document: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the open block back over its range as one undo step, and closes it; the range replaced and the bytes written, none when the text left the range.
|
||||
/// Writes the open block back as one step; the edit.
|
||||
fn commit(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
@@ -120,7 +123,7 @@ fn commit(
|
||||
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.
|
||||
/// Opens the block at `range` in its own field.
|
||||
fn open(
|
||||
state: &Entity<Visual>,
|
||||
field: &Entity<TextInput>,
|
||||
@@ -165,7 +168,7 @@ fn open(
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the caret sits on the field's first line, or with `last` its last.
|
||||
/// The caret on the first line, or with `last` 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)) {
|
||||
@@ -174,7 +177,7 @@ pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Markdown written as visual, source, or split views.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownEditor {
|
||||
id: ElementId,
|
||||
@@ -250,11 +253,21 @@ impl RenderOnce for MarkdownEditor {
|
||||
.into_any_element()
|
||||
}
|
||||
MarkdownMode::Visual => visual(self, state, switch, window, cx),
|
||||
MarkdownMode::Preview => {
|
||||
let text = SharedString::from(self.field.read(cx).text().to_string());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().flex().justify_end().child(switch))
|
||||
.child(MarkdownRenderer::new((self.id, "preview"), text))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The visual body: each block rendered, the open one as its source.
|
||||
/// The visual body: blocks drawn, the open one as source.
|
||||
fn visual(
|
||||
editor: MarkdownEditor,
|
||||
state: Entity<Visual>,
|
||||
|
||||
@@ -110,3 +110,41 @@ fn a_press_on_a_block_of_a_replaced_text_opens_nothing(cx: &mut gpui::TestAppCon
|
||||
"x"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
struct Previewed(gpui::Entity<TextInput>);
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
impl gpui::Render for Previewed {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
MarkdownEditor::new("preview", &self.0, MarkdownMode::Preview)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[gpui::test]
|
||||
fn preview_draws_the_document_and_takes_no_press(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
Previewed(cx.new(|cx| {
|
||||
let mut field = TextInput::new(window, cx).multi_line(1, usize::MAX);
|
||||
field.set_text("# Lift", cx);
|
||||
field
|
||||
}))
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let field = view.read_with(cx, |view, _| view.0.clone());
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(20.0), gpui::px(60.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
assert_eq!(
|
||||
field.read_with(cx, |field, _| field.text().to_string()),
|
||||
"# Lift"
|
||||
);
|
||||
assert!(!cx.update(|window, cx| field.focus_handle(cx).is_focused(window)));
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use base64::Engine as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Asset, ElementId, Hsla, Image, ImageAssetLoader, ImageFormat,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderImage, RenderOnce, Resource,
|
||||
SMOOTH_SVG_SCALE_FACTOR, SharedString, Styled, Window, div, img, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
i18n,
|
||||
primitives::{Icon, IconName, hold},
|
||||
theme::{ActiveTheme, IconSize, Radius},
|
||||
};
|
||||
|
||||
/// Where a picture's bytes come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Origin {
|
||||
File(PathBuf),
|
||||
/// Bytes inline in the address, and their type.
|
||||
Data(ImageFormat, Arc<[u8]>),
|
||||
Web,
|
||||
/// A relative path with no folder to read it in.
|
||||
Loose,
|
||||
/// An address that names no picture, and why.
|
||||
Bad(SharedString),
|
||||
}
|
||||
|
||||
/// `%XX` decoded; a broken escape stays as written.
|
||||
fn decoded(text: &str) -> Vec<u8> {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
|
||||
while at < bytes.len() {
|
||||
let pair = bytes.get(at + 1..at + 3);
|
||||
let hex =
|
||||
pair.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(byte)) => (out.push(byte), at += 3),
|
||||
(byte, _) => (out.push(byte), at += 1),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A path written in a link, `%XX` decoded.
|
||||
fn path(written: &str) -> Result<PathBuf, SharedString> {
|
||||
String::from_utf8(decoded(written))
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| "the path is not UTF-8".into())
|
||||
}
|
||||
|
||||
/// Where `url` points, read from the folder `base`.
|
||||
pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin {
|
||||
let url = url.trim();
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || url.starts_with("//") {
|
||||
return Origin::Web;
|
||||
}
|
||||
if lower.starts_with("data:") {
|
||||
return inline(url);
|
||||
}
|
||||
if lower.starts_with("file:") {
|
||||
return file(&url[5..]);
|
||||
}
|
||||
let scheme = url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.len() > 1
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch))
|
||||
});
|
||||
if scheme {
|
||||
return Origin::Bad(format!("{url} is no picture address").into());
|
||||
}
|
||||
let written = url.split(['?', '#']).next().unwrap_or_default();
|
||||
match path(written) {
|
||||
Ok(path) if path.is_absolute() => Origin::File(path),
|
||||
Ok(path) => base.map_or(Origin::Loose, |base| Origin::File(base.join(path))),
|
||||
Err(why) => Origin::Bad(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `file:` address after its scheme: host, path, no query.
|
||||
fn file(rest: &str) -> Origin {
|
||||
let rest = rest.split(['?', '#']).next().unwrap_or_default();
|
||||
let (host, written) = match rest.strip_prefix("//") {
|
||||
Some(after) => after.split_at(after.find('/').unwrap_or(after.len())),
|
||||
None => ("", rest),
|
||||
};
|
||||
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
|
||||
let written = match (local, written.as_bytes()) {
|
||||
// Windows writes file:///C:/…
|
||||
(true, [b'/', _, b':', ..]) if cfg!(windows) => written[1..].to_string(),
|
||||
(true, _) => written.to_string(),
|
||||
(false, _) if cfg!(windows) => format!("//{host}{written}"),
|
||||
(false, _) => return Origin::Bad(format!("files on {host} are not read").into()),
|
||||
};
|
||||
path(&written).map_or_else(Origin::Bad, Origin::File)
|
||||
}
|
||||
|
||||
/// A `data:` address: base64 bytes of an image type.
|
||||
fn inline(url: &str) -> Origin {
|
||||
let Some((head, payload)) = url[5..].split_once(',') else {
|
||||
return Origin::Bad("the data address has no comma".into());
|
||||
};
|
||||
let Some(mime) = head.strip_suffix(";base64") else {
|
||||
return Origin::Bad("only base64 data addresses draw".into());
|
||||
};
|
||||
let Some(format) = ImageFormat::from_mime_type(&mime.to_ascii_lowercase()) else {
|
||||
return Origin::Bad(format!("{mime} is no image type").into());
|
||||
};
|
||||
match base64::engine::general_purpose::STANDARD.decode(payload.trim()) {
|
||||
Ok(bytes) => Origin::Data(format, bytes.into()),
|
||||
Err(error) => Origin::Bad(format!("{error}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline bytes decoded as gpui decodes files.
|
||||
enum Unpacker {}
|
||||
|
||||
impl Asset for Unpacker {
|
||||
type Source = (ImageFormat, Arc<[u8]>);
|
||||
type Output = Result<Arc<RenderImage>, SharedString>;
|
||||
|
||||
fn load(
|
||||
(format, bytes): Self::Source,
|
||||
cx: &mut App,
|
||||
) -> impl Future<Output = Self::Output> + Send + 'static {
|
||||
let renderer = cx.svg_renderer();
|
||||
async move {
|
||||
Image::from_bytes(format, bytes.to_vec())
|
||||
.to_image_data(renderer)
|
||||
.map_err(|error| format!("{error:#}").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture at `url`, natural size up to the column.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct Picture {
|
||||
pub id: ElementId,
|
||||
pub alt: String,
|
||||
pub url: String,
|
||||
/// The folder relative addresses start from.
|
||||
pub base: Option<Arc<Path>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for Picture {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
picture(self, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let Picture { id, alt, url, base } = shown;
|
||||
let (alt, url) = (alt.as_str(), url.as_str());
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (loaded, svg) = match origin(url, base.as_deref()) {
|
||||
Origin::File(path) => {
|
||||
let svg = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"));
|
||||
let resource = Resource::Path(path.into());
|
||||
let image = |loaded: &Result<Arc<RenderImage>, _>| loaded.as_ref().ok().cloned();
|
||||
let loaded = hold::<ImageAssetLoader>(id, &resource, image, window, cx)
|
||||
.map(|loaded| loaded.map_err(|error| SharedString::from(error.to_string())));
|
||||
(loaded, svg)
|
||||
}
|
||||
Origin::Data(format, bytes) => {
|
||||
let svg = format == ImageFormat::Svg;
|
||||
let image = |loaded: &Result<Arc<RenderImage>, _>| loaded.as_ref().ok().cloned();
|
||||
(
|
||||
hold::<Unpacker>(id, &(format, bytes), image, window, cx),
|
||||
svg,
|
||||
)
|
||||
}
|
||||
Origin::Web => {
|
||||
let said = i18n::text(cx, "markdown.picture.web", &[]);
|
||||
return note(alt, said, colors.fg_subtle, cx);
|
||||
}
|
||||
Origin::Loose => {
|
||||
let said = i18n::text(cx, "markdown.picture.folder", &[]);
|
||||
return note(alt, said, colors.fg_subtle, cx);
|
||||
}
|
||||
Origin::Bad(why) => return note(alt, why, colors.danger, cx),
|
||||
};
|
||||
match loaded {
|
||||
Some(Ok(image)) => {
|
||||
let natural = image.size(0);
|
||||
// The picture's own height follows its ratio.
|
||||
let smooth = if svg { SMOOTH_SVG_SCALE_FACTOR } else { 1.0 };
|
||||
let width = i32::from(natural.width) as f32 / smooth;
|
||||
div()
|
||||
.debug_selector(|| "markdown-picture".into())
|
||||
.w(px(width))
|
||||
.max_w_full()
|
||||
.child(img(image).size_full())
|
||||
.into_any_element()
|
||||
}
|
||||
Some(Err(why)) => {
|
||||
log::warn!("markdown: a picture did not load");
|
||||
note(alt, why, colors.danger, cx)
|
||||
}
|
||||
None => note(alt, url.to_string().into(), colors.fg_subtle, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture not drawn: its alt text and why.
|
||||
fn note(alt: &str, said: SharedString, tone: Hsla, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
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.to_string()))
|
||||
.child(div().text_color(tone).child(said))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn addresses_resolve_against_the_folder() {
|
||||
let base = Path::new("/notes");
|
||||
assert_eq!(
|
||||
origin("assets/a%20b.png?x#y", Some(base)),
|
||||
Origin::File(base.join("assets/a b.png"))
|
||||
);
|
||||
assert_eq!(origin("assets/a.png", None), Origin::Loose);
|
||||
assert_eq!(origin("https://e.x/a.png", Some(base)), Origin::Web);
|
||||
assert_eq!(origin("//e.x/a.png", Some(base)), Origin::Web);
|
||||
assert!(matches!(origin("mailto:a@b", Some(base)), Origin::Bad(_)));
|
||||
let absolute = std::env::current_dir().expect("a dir").join("a.png");
|
||||
assert_eq!(
|
||||
origin(&absolute.display().to_string(), Some(base)),
|
||||
Origin::File(absolute)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_addresses_decode_without_query_or_fragment() {
|
||||
for url in [
|
||||
"file:///tmp/a%23b.png",
|
||||
"file://localhost/tmp/a%23b.png#x",
|
||||
"FILE:/tmp/a%23b.png?y",
|
||||
] {
|
||||
assert_eq!(
|
||||
origin(url, None),
|
||||
Origin::File(PathBuf::from("/tmp/a#b.png")),
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
assert_eq!(
|
||||
origin("file:///C:/notes/a.png#preview", None),
|
||||
Origin::File(PathBuf::from("C:/notes/a.png"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_on_another_host_is_its_share_or_refused() {
|
||||
let found = origin("file://server/share/a.png", Some(Path::new("/notes")));
|
||||
match cfg!(windows) {
|
||||
true => assert_eq!(found, Origin::File(PathBuf::from("//server/share/a.png"))),
|
||||
false => assert!(matches!(found, Origin::Bad(_)), "{found:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_addresses_carry_their_bytes() {
|
||||
assert_eq!(
|
||||
origin("data:image/png;base64,cG5n", None),
|
||||
Origin::Data(ImageFormat::Png, Arc::from(&b"png"[..]))
|
||||
);
|
||||
assert!(matches!(origin("data:image/png,raw", None), Origin::Bad(_)));
|
||||
assert!(matches!(
|
||||
origin("data:text/plain;base64,eA==", None),
|
||||
Origin::Bad(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
origin("data:image/png;base64,***", None),
|
||||
Origin::Bad(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
+298
-280
@@ -1,25 +1,39 @@
|
||||
use std::rc::Rc;
|
||||
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
|
||||
UnderlineStyle, Window, div, prelude::*,
|
||||
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
||||
StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
|
||||
};
|
||||
use pulldown_cmark::Alignment;
|
||||
|
||||
use super::tree::{Inline, Node, Style, parse};
|
||||
use super::{
|
||||
contents::anchor,
|
||||
diagram::Diagram,
|
||||
picture::Picture,
|
||||
tree::{Inline, Node, Style, parse},
|
||||
};
|
||||
use crate::{
|
||||
editor::code_colors,
|
||||
editor::{code_colors, code_colors_with},
|
||||
navigation::{Anchor, Sections},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::Latex,
|
||||
primitives::FocusRing as _,
|
||||
theme::{ActiveTheme, Palette, Radius, TextSize},
|
||||
typography::{Latex, failed},
|
||||
};
|
||||
|
||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
mod blocks;
|
||||
mod math_text;
|
||||
mod point;
|
||||
mod table;
|
||||
|
||||
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
|
||||
pub(crate) use table::{CellAt, OpenCell};
|
||||
|
||||
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
/// Reveals the heading whose source starts at a byte.
|
||||
pub(crate) type OnHeading = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
|
||||
|
||||
/// 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.
|
||||
/// Markdown drawn as a document; links go to the host.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MarkdownRenderer {
|
||||
id: ElementId,
|
||||
@@ -28,6 +42,7 @@ pub struct MarkdownRenderer {
|
||||
size: TextSize,
|
||||
sections: Option<Entity<Sections>>,
|
||||
code: Option<DrawCode>,
|
||||
base: Option<Arc<Path>>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
@@ -39,22 +54,29 @@ impl MarkdownRenderer {
|
||||
size: TextSize::Base,
|
||||
sections: None,
|
||||
code: None,
|
||||
base: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder relative picture addresses start from.
|
||||
pub fn base(mut self, folder: impl Into<Arc<Path>>) -> Self {
|
||||
self.base = Some(folder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The body's size; headings keep theirs.
|
||||
pub fn text_size(mut self, size: TextSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks each heading for a table of contents over `sections`; `outline` gives its entries.
|
||||
/// Marks headings for `sections`; `outline` lists them.
|
||||
pub fn sections(mut self, sections: &Entity<Sections>) -> Self {
|
||||
self.sections = Some(sections.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Draws code blocks with the owner's element, given an id, the language and the code.
|
||||
/// Code blocks drawn by the owner: id, language, code.
|
||||
pub fn code(
|
||||
mut self,
|
||||
draw: impl Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement + 'static,
|
||||
@@ -70,14 +92,139 @@ impl MarkdownRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders nodes with one set of links handlers and ids under one owner.
|
||||
/// What an owner hands its drawing: pictures' folder and presses.
|
||||
pub(crate) struct Hooks<'a> {
|
||||
pub base: &'a Option<Arc<Path>>,
|
||||
pub on_link: &'a Option<OnLink>,
|
||||
pub on_heading: &'a Option<OnHeading>,
|
||||
pub on_point: &'a Option<OnPoint>,
|
||||
/// The cell typed in, drawn in its place.
|
||||
pub cell: Option<&'a OpenCell>,
|
||||
}
|
||||
|
||||
/// Headings and strong text share one weight.
|
||||
pub(crate) const HEAVY: FontWeight = FontWeight::BOLD;
|
||||
|
||||
/// Typora's heading steps over the body.
|
||||
pub(crate) fn heading_step(level: u8) -> f32 {
|
||||
match level {
|
||||
1 => 2.25,
|
||||
2 => 1.75,
|
||||
3 => 1.5,
|
||||
4 => 1.25,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How one inline style draws.
|
||||
fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
|
||||
match style {
|
||||
Style::Strong => HighlightStyle {
|
||||
font_weight: Some(HEAVY),
|
||||
..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()
|
||||
},
|
||||
Style::Underline => HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: rule,
|
||||
color: None,
|
||||
wavy: false,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
Style::Mark => HighlightStyle {
|
||||
background_color: Some(colors.warning_subtle),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlights laid over a text's own.
|
||||
type Over = Vec<(Range<usize>, HighlightStyle)>;
|
||||
|
||||
/// `inline` as text; hidden ranges lose highlight, `over` adds some.
|
||||
fn drawn_text(
|
||||
inline: &Inline,
|
||||
(hidden, over): (&[Range<usize>], Over),
|
||||
links: Option<ElementId>,
|
||||
on_link: &Option<OnLink>,
|
||||
cx: &App,
|
||||
) -> (AnyElement, TextLayout) {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rule = theme.underline_thickness();
|
||||
let styles: Vec<_> = (inline.styles.iter())
|
||||
.filter(|(range, _)| !hidden.contains(range))
|
||||
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
||||
.chain(over)
|
||||
.collect();
|
||||
let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||
let layout = styled.layout().clone();
|
||||
let drawn = match links {
|
||||
None => styled.into_any_element(),
|
||||
Some(id) => {
|
||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
||||
let on_link = on_link.clone();
|
||||
InteractiveText::new(id, styled)
|
||||
.on_click(ranges, move |ix, window, cx| {
|
||||
open_link(&on_link, &urls[ix], window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
(drawn, layout)
|
||||
}
|
||||
|
||||
/// Draws nodes under one owner's ids and hooks.
|
||||
struct Draw<'a> {
|
||||
id: &'a ElementId,
|
||||
on_link: &'a Option<OnLink>,
|
||||
hooks: Hooks<'a>,
|
||||
next: std::cell::Cell<usize>,
|
||||
sections: &'a Option<Entity<Sections>>,
|
||||
headings: std::cell::Cell<usize>,
|
||||
code: &'a Option<DrawCode>,
|
||||
/// The body's size; headings step up from it.
|
||||
body: TextSize,
|
||||
/// Text size against the theme's.
|
||||
scale: f32,
|
||||
/// Lists open around the node drawn.
|
||||
lists: std::cell::Cell<usize>,
|
||||
/// Drawing a table's cells.
|
||||
table: std::cell::Cell<bool>,
|
||||
/// Tables drawn so far.
|
||||
tables: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
@@ -88,72 +235,21 @@ impl Draw<'_> {
|
||||
}
|
||||
|
||||
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 links = (!inline.links.is_empty()).then(|| self.key("links"));
|
||||
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
||||
let (drawn, layout) =
|
||||
drawn_text(inline, (&[], Vec::new()), links, self.hooks.on_link, cx);
|
||||
let at = move |position| {
|
||||
layout
|
||||
.index_for_position(position)
|
||||
.unwrap_or_else(|near| near)
|
||||
};
|
||||
return self.point_text(drawn, at, inline);
|
||||
}
|
||||
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()
|
||||
let on_link = self.hooks.on_link.clone();
|
||||
let (drawn, at) =
|
||||
math_text::MathText::new(self.key("math"), inline.clone(), links, on_link);
|
||||
self.point_text(drawn.into_any_element(), at, inline)
|
||||
}
|
||||
|
||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||
@@ -161,6 +257,14 @@ impl Draw<'_> {
|
||||
}
|
||||
|
||||
fn node(&self, node: &Node, cx: &App) -> AnyElement {
|
||||
let table = self.table.get();
|
||||
self.table.set(table || matches!(node, Node::Table { .. }));
|
||||
let drawn = self.drawn(node, cx);
|
||||
self.table.set(table);
|
||||
drawn
|
||||
}
|
||||
|
||||
fn drawn(&self, node: &Node, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
match node {
|
||||
@@ -169,24 +273,22 @@ impl Draw<'_> {
|
||||
.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,
|
||||
};
|
||||
let heading = 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));
|
||||
// Every level bold.
|
||||
let size = theme.text_size(self.body) * heading_step(*level) * self.scale;
|
||||
let ordinal = self.headings.get();
|
||||
self.headings.set(ordinal + 1);
|
||||
let heading = div()
|
||||
.debug_selector(move || format!("markdown-heading-{ordinal}"))
|
||||
.pt_2()
|
||||
.text_size(size)
|
||||
.line_height(relative(1.3))
|
||||
.font_weight(HEAVY)
|
||||
.text_color(if *level == 6 {
|
||||
colors.fg_muted
|
||||
} else {
|
||||
colors.fg
|
||||
})
|
||||
.child(self.inline(inline, cx));
|
||||
match self.sections {
|
||||
Some(sections) => Anchor::new(sections, anchor(ordinal))
|
||||
.child(heading)
|
||||
@@ -204,7 +306,15 @@ impl Draw<'_> {
|
||||
.text_color(colors.fg_muted)
|
||||
.children(self.nodes(inner, cx))
|
||||
.into_any_element(),
|
||||
Node::Code { language, text } => {
|
||||
Node::Code {
|
||||
language,
|
||||
text,
|
||||
from,
|
||||
} => {
|
||||
if language.as_deref() == Some("mermaid") {
|
||||
let diagram = Diagram::new(self.key("diagram"), text.clone());
|
||||
return self.point_code(diagram.into_any_element(), None, text, *from);
|
||||
}
|
||||
if let Some(draw) = self.code {
|
||||
return draw(
|
||||
self.key("code"),
|
||||
@@ -212,8 +322,15 @@ impl Draw<'_> {
|
||||
text.clone().into(),
|
||||
);
|
||||
}
|
||||
let styles = code_colors(text, cx);
|
||||
div()
|
||||
// A named fence reads as its language; prose stays plain.
|
||||
let styles = match language.as_deref() {
|
||||
Some(name) => crate::forms::syntax_fenced(name)
|
||||
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
|
||||
None => code_colors(text, cx),
|
||||
};
|
||||
let code = StyledText::new(text.clone()).with_highlights(styles);
|
||||
let layout = code.layout().clone();
|
||||
let block = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
@@ -221,150 +338,58 @@ impl Draw<'_> {
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_size(theme.text_size(TextSize::Sm) * self.scale)
|
||||
.text_color(colors.syntax.variable)
|
||||
.children(language.clone().map(|name| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_size(theme.text_size(TextSize::Xs) * self.scale)
|
||||
.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()
|
||||
.child(code)
|
||||
.into_any_element();
|
||||
self.point_code(block, Some(layout), text, *from)
|
||||
}
|
||||
Node::List { start, items } => self.list(start, items, cx),
|
||||
Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
|
||||
Node::Rule => div()
|
||||
.debug_selector(|| "markdown-rule".into())
|
||||
.my_2()
|
||||
.h_0()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.border_t_2()
|
||||
.border_color(colors.border_strong)
|
||||
.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(),
|
||||
.child(match Latex::parse(self.key("math"), tex) {
|
||||
Ok(math) => math.display().into_any_element(),
|
||||
Err(error) => failed(&error, tex, cx),
|
||||
})
|
||||
.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::Image { alt, url, link, at } => {
|
||||
let picture = div().id(self.key("image")).child(Picture {
|
||||
id: self.key("picture"),
|
||||
alt: alt.clone(),
|
||||
url: url.clone(),
|
||||
base: self.hooks.base.clone(),
|
||||
});
|
||||
let drawn = match link.clone() {
|
||||
None => picture.into_any_element(),
|
||||
Some(address) => {
|
||||
let on_link = self.hooks.on_link.clone();
|
||||
picture
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.on_click(move |_, window, cx| {
|
||||
open_link(&on_link, &address, window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
self.point_image(drawn, *at, url, link)
|
||||
}
|
||||
Node::Footnote { label, body } => div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
@@ -379,41 +404,45 @@ impl Draw<'_> {
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
Node::Alert { kind, body } => self.alert(*kind, body, cx),
|
||||
Node::Toc(headings) => self.toc(headings, cx),
|
||||
Node::FrontMatter { text, from } => self.front_matter(text, *from, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor of the document's `ordinal`th heading.
|
||||
fn anchor(ordinal: usize) -> SharedString {
|
||||
format!("heading-{ordinal}").into()
|
||||
/// Opens `url` through the owner, else the system.
|
||||
fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut App) {
|
||||
// The address is the document's: never logged.
|
||||
log::info!("markdown: a link opens");
|
||||
match on_link {
|
||||
Some(on_link) => on_link(url, window, cx),
|
||||
None => cx.open_url(url),
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings of `markdown` in order, as a table of contents takes them: anchor, text and depth from 0.
|
||||
pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> {
|
||||
fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Heading(level, inline) => {
|
||||
let ordinal = out.len();
|
||||
out.push((
|
||||
anchor(ordinal),
|
||||
inline.text.clone().into(),
|
||||
*level as usize - 1,
|
||||
));
|
||||
}
|
||||
Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out),
|
||||
Node::List { items, .. } => {
|
||||
for (_, body) in items {
|
||||
walk(body, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&parse(markdown), &mut out);
|
||||
out
|
||||
/// Parsed `nodes` drawn under `id`, with the owner's hooks.
|
||||
pub(crate) fn draw_nodes(
|
||||
id: &ElementId,
|
||||
nodes: &[Node],
|
||||
hooks: Hooks,
|
||||
scale: f32,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let draw = Draw {
|
||||
id,
|
||||
hooks,
|
||||
next: std::cell::Cell::new(0),
|
||||
sections: &None,
|
||||
headings: std::cell::Cell::new(0),
|
||||
code: &None,
|
||||
body: TextSize::Base,
|
||||
scale,
|
||||
lists: std::cell::Cell::new(0),
|
||||
table: std::cell::Cell::new(false),
|
||||
tables: std::cell::Cell::new(0),
|
||||
};
|
||||
draw.nodes(nodes, cx)
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownRenderer {
|
||||
@@ -422,11 +451,22 @@ impl RenderOnce for MarkdownRenderer {
|
||||
let nodes = parse(&self.source);
|
||||
let draw = Draw {
|
||||
id: &self.id,
|
||||
on_link: &self.on_link,
|
||||
hooks: Hooks {
|
||||
base: &self.base,
|
||||
on_link: &self.on_link,
|
||||
on_heading: &None,
|
||||
on_point: &None,
|
||||
cell: None,
|
||||
},
|
||||
next: std::cell::Cell::new(0),
|
||||
sections: &self.sections,
|
||||
headings: std::cell::Cell::new(0),
|
||||
code: &self.code,
|
||||
body: self.size,
|
||||
scale: 1.0,
|
||||
lists: std::cell::Cell::new(0),
|
||||
table: std::cell::Cell::new(false),
|
||||
tables: std::cell::Cell::new(0),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
@@ -439,26 +479,4 @@ impl RenderOnce for MarkdownRenderer {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::outline;
|
||||
|
||||
#[test]
|
||||
fn the_outline_numbers_headings_in_order_with_their_depth() {
|
||||
let found =
|
||||
outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark");
|
||||
let shape: Vec<(&str, &str, usize)> = found
|
||||
.iter()
|
||||
.map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("heading-0", "Lift", 0),
|
||||
("heading-1", "In light", 1),
|
||||
("heading-2", "Quoted", 2),
|
||||
("heading-3", "Listed", 3),
|
||||
("heading-4", "In dark", 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,197 @@
|
||||
use gpui::{
|
||||
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, StyledText, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::{Draw, point::point_lines};
|
||||
use crate::{
|
||||
documents::tree::{AlertKind, DocumentHeading, Node},
|
||||
primitives::{FocusRing as _, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
impl Draw<'_> {
|
||||
/// A list; bullets turn with depth, as browsers draw them.
|
||||
pub(super) fn list(
|
||||
&self,
|
||||
start: &Option<u64>,
|
||||
items: &[(Option<bool>, Vec<Node>)],
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let depth = self.lists.get();
|
||||
self.lists.set(depth + 1);
|
||||
let bullet = match depth {
|
||||
0 => "•",
|
||||
1 => "◦",
|
||||
_ => "▪",
|
||||
};
|
||||
let list = 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()
|
||||
.debug_selector(move || format!("markdown-bullet-{depth}"))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(bullet)
|
||||
.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();
|
||||
self.lists.set(depth);
|
||||
list
|
||||
}
|
||||
|
||||
/// A GitHub alert: a colored rule, icon, title and body.
|
||||
pub(super) fn alert(&self, kind: AlertKind, body: &[Node], cx: &App) -> AnyElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let (tone, icon) = match kind {
|
||||
AlertKind::Note => (colors.info, IconName::Info),
|
||||
AlertKind::Tip => (colors.success, IconName::Lightbulb),
|
||||
// The palette's violet, read at 4.5:1.
|
||||
AlertKind::Important => (colors.syntax.keyword, IconName::MessageSquareWarning),
|
||||
AlertKind::Warning => (colors.warning, IconName::TriangleAlert),
|
||||
AlertKind::Caution => (colors.danger, IconName::OctagonAlert),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.py_1()
|
||||
.pl_4()
|
||||
.border_l_2()
|
||||
.border_color(tone)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(tone)
|
||||
.child(Icon::new(icon).size(IconSize::Sm).color(tone))
|
||||
.child(kind.title()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.text_color(colors.fg)
|
||||
.children(self.nodes(body, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// `[toc]`: each heading by depth; a press reveals it.
|
||||
pub(super) fn toc(&self, headings: &[DocumentHeading], cx: &App) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let top = headings.iter().map(|heading| heading.depth).min();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(headings.iter().enumerate().map(|(ix, heading)| {
|
||||
let entry = div()
|
||||
.id(self.key("toc"))
|
||||
.debug_selector(move || format!("markdown-toc-{ix}"))
|
||||
.child(heading.title.clone());
|
||||
let entry = match heading.depth - top.expect("a heading is listed") {
|
||||
0 => entry,
|
||||
1 => entry.pl_4(),
|
||||
2 => entry.pl_8(),
|
||||
3 => entry.pl_12(),
|
||||
4 => entry.pl_16(),
|
||||
_ => entry.pl_20(),
|
||||
};
|
||||
let Some(reveal) = self.hooks.on_heading.clone() else {
|
||||
return entry.text_color(colors.fg_muted).into_any_element();
|
||||
};
|
||||
let (at, pressed) = (heading.at, reveal.clone());
|
||||
entry
|
||||
.text_color(colors.link)
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
// On press: the layout may move before release.
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
if event.modifiers.secondary() {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
pressed(at, window, cx);
|
||||
}
|
||||
})
|
||||
.on_click(move |event, window, cx| {
|
||||
if !event.modifiers().secondary() {
|
||||
reveal(at, window, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Front matter as written, inside its box.
|
||||
pub(super) fn front_matter(&self, text: &str, from: usize, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let drawn = StyledText::new(text.to_string());
|
||||
let layout = drawn.layout().clone();
|
||||
let block = div()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm) * self.scale)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(drawn)
|
||||
.into_any_element();
|
||||
point_lines(
|
||||
block,
|
||||
layout,
|
||||
text,
|
||||
from,
|
||||
self.hooks.on_point,
|
||||
(false, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Inline formulas typeset among a paragraph's words.
|
||||
|
||||
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, HighlightStyle,
|
||||
InspectorElementId, IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size,
|
||||
TextLayout, TextStyleRefinement, Window, point,
|
||||
};
|
||||
|
||||
use super::{OnLink, drawn_text};
|
||||
use crate::{
|
||||
documents::tree::{Inline, Style},
|
||||
theme::ActiveTheme,
|
||||
typography::typeset,
|
||||
};
|
||||
|
||||
/// Glue that reserves a formula's width and never breaks.
|
||||
const GLUE: char = '\u{00A0}';
|
||||
|
||||
/// An original span and the drawn span standing for it.
|
||||
type Swap = (Range<usize>, Range<usize>);
|
||||
|
||||
/// The drawn text and how its bytes map back.
|
||||
struct Shown {
|
||||
layout: TextLayout,
|
||||
swaps: Vec<Swap>,
|
||||
}
|
||||
|
||||
/// Paragraph text whose formulas draw typeset over reserved glue.
|
||||
pub(super) struct MathText {
|
||||
id: ElementId,
|
||||
inline: Inline,
|
||||
links: Option<ElementId>,
|
||||
on_link: Option<OnLink>,
|
||||
shown: Rc<RefCell<Option<Shown>>>,
|
||||
text: Option<AnyElement>,
|
||||
/// Each picture, its size, and the drawn span it covers.
|
||||
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
|
||||
/// One glue character's width.
|
||||
glue: Pixels,
|
||||
}
|
||||
|
||||
impl MathText {
|
||||
/// The element and how a point finds its original byte.
|
||||
pub(super) fn new(
|
||||
id: ElementId,
|
||||
inline: Inline,
|
||||
links: Option<ElementId>,
|
||||
on_link: Option<OnLink>,
|
||||
) -> (Self, impl Fn(Point<Pixels>) -> usize + 'static) {
|
||||
let shown = Rc::new(RefCell::new(None));
|
||||
let read = shown.clone();
|
||||
let at = move |position| match &*read.borrow() {
|
||||
Some(Shown { layout, swaps }) => back(
|
||||
swaps,
|
||||
layout
|
||||
.index_for_position(position)
|
||||
.unwrap_or_else(|near| near),
|
||||
),
|
||||
None => 0,
|
||||
};
|
||||
let text = Self {
|
||||
id,
|
||||
inline,
|
||||
links,
|
||||
on_link,
|
||||
shown,
|
||||
text: None,
|
||||
pictures: Vec::new(),
|
||||
glue: Pixels::ZERO,
|
||||
};
|
||||
(text, at)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where original byte `at` is drawn.
|
||||
fn forward(swaps: &[Swap], at: usize) -> usize {
|
||||
let mut shift = 0isize;
|
||||
for (written, drawn) in swaps {
|
||||
if at < written.start {
|
||||
break;
|
||||
}
|
||||
if at < written.end {
|
||||
return drawn.start;
|
||||
}
|
||||
shift = drawn.end as isize - written.end as isize;
|
||||
}
|
||||
(at as isize + shift) as usize
|
||||
}
|
||||
|
||||
/// The original byte drawn byte `at` stands for.
|
||||
fn back(swaps: &[Swap], at: usize) -> usize {
|
||||
let mut shift = 0isize;
|
||||
for (written, drawn) in swaps {
|
||||
if at < drawn.start {
|
||||
break;
|
||||
}
|
||||
if at < drawn.end {
|
||||
return written.start;
|
||||
}
|
||||
shift = written.end as isize - drawn.end as isize;
|
||||
}
|
||||
(at as isize + shift) as usize
|
||||
}
|
||||
|
||||
/// `inline` with each swapped span drawn as `drawn_as[ix]`.
|
||||
fn shown(inline: &Inline, swaps: &mut [Swap], drawn_as: &[String]) -> Inline {
|
||||
let mut text = String::new();
|
||||
let mut from = 0;
|
||||
for ((written, drawn), with) in swaps.iter_mut().zip(drawn_as) {
|
||||
text.push_str(&inline.text[from..written.start]);
|
||||
let start = text.len();
|
||||
text.push_str(with);
|
||||
*drawn = start..text.len();
|
||||
from = written.end;
|
||||
}
|
||||
text.push_str(&inline.text[from..]);
|
||||
let span = |range: &Range<usize>| forward(swaps, range.start)..forward(swaps, range.end);
|
||||
Inline {
|
||||
text,
|
||||
styles: (inline.styles.iter())
|
||||
.map(|(range, style)| (span(range), *style))
|
||||
.collect(),
|
||||
links: (inline.links.iter())
|
||||
.map(|(range, url)| (span(range), url.clone()))
|
||||
.collect(),
|
||||
sources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for MathText {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for MathText {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, ()) {
|
||||
let style = window.text_style();
|
||||
let rem = window.rem_size();
|
||||
let font = style.font_size.to_pixels(rem);
|
||||
let glue = String::from(GLUE);
|
||||
let run = style.to_run(glue.len());
|
||||
let width = window
|
||||
.text_system()
|
||||
.shape_line(glue.into(), font, &[run], None)
|
||||
.width;
|
||||
let mut maths: Vec<Range<usize>> = (self.inline.styles.iter())
|
||||
.filter(|(_, style)| *style == Style::Math)
|
||||
.map(|(range, _)| range.clone())
|
||||
.collect();
|
||||
maths.sort_by_key(|range| range.start);
|
||||
let (mut swaps, mut drawn_as) = (Vec::new(), Vec::new());
|
||||
let (mut pictures, mut errors) = (Vec::new(), Vec::new());
|
||||
for (ix, range) in maths.into_iter().enumerate() {
|
||||
let source = SharedString::from(self.inline.text[range.clone()].to_string());
|
||||
match typeset(
|
||||
(self.id.clone(), SharedString::from(format!("formula-{ix}"))),
|
||||
(&source, false),
|
||||
font,
|
||||
window,
|
||||
cx,
|
||||
) {
|
||||
Some(Ok((image, size))) => {
|
||||
let count = (size.width / width).ceil().max(1.0) as usize;
|
||||
drawn_as.push(GLUE.to_string().repeat(count));
|
||||
pictures.push((image, size, swaps.len()));
|
||||
swaps.push((range, 0..0));
|
||||
}
|
||||
// The formula is the user's text: not logged.
|
||||
Some(Err(error)) => {
|
||||
log::warn!("markdown: an inline formula shows as TeX, unset");
|
||||
drawn_as.push(format!(" ({error})"));
|
||||
errors.push(swaps.len());
|
||||
swaps.push((range.end..range.end, 0..0));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let inline = shown(&self.inline, &mut swaps, &drawn_as);
|
||||
let hidden: Vec<Range<usize>> = (pictures.iter())
|
||||
.map(|(_, _, ix)| swaps[*ix].1.clone())
|
||||
.collect();
|
||||
let danger = HighlightStyle {
|
||||
color: Some(cx.theme().colors.danger),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let said = (errors.iter())
|
||||
.map(|ix| (swaps[*ix].1.clone(), danger))
|
||||
.collect();
|
||||
let (mut text, layout) = drawn_text(
|
||||
&inline,
|
||||
(&hidden, said),
|
||||
self.links.clone(),
|
||||
&self.on_link,
|
||||
cx,
|
||||
);
|
||||
let tallest = pictures
|
||||
.iter()
|
||||
.map(|(_, size, _)| size.height)
|
||||
.fold(Pixels::ZERO, Pixels::max);
|
||||
// A tall formula opens the paragraph's lines.
|
||||
let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement {
|
||||
line_height: Some(tallest.into()),
|
||||
..TextStyleRefinement::default()
|
||||
});
|
||||
let id = window.with_text_style(taller, |window| text.request_layout(window, cx));
|
||||
self.pictures = (pictures.into_iter().zip(hidden))
|
||||
.map(|((image, size, _), drawn)| (image, size, drawn))
|
||||
.collect();
|
||||
self.glue = width;
|
||||
*self.shown.borrow_mut() = Some(Shown { layout, swaps });
|
||||
self.text = Some(text);
|
||||
(id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(text) = &mut self.text {
|
||||
text.prepaint(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
_: &mut (),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(text) = &mut self.text else {
|
||||
return;
|
||||
};
|
||||
text.paint(window, cx);
|
||||
let shown = self.shown.borrow();
|
||||
let Some(Shown { layout, .. }) = &*shown else {
|
||||
return;
|
||||
};
|
||||
let line = layout.line_height();
|
||||
for (image, size, drawn) in &self.pictures {
|
||||
let Some((start, room)) = place(layout, drawn, self.glue) else {
|
||||
continue;
|
||||
};
|
||||
let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0);
|
||||
let bounds = Bounds {
|
||||
origin,
|
||||
size: *size,
|
||||
};
|
||||
if let Err(error) =
|
||||
window.paint_image(bounds, bounds, Corners::default(), image.clone(), 0, false)
|
||||
{
|
||||
log::error!("markdown: an inline formula did not paint: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where glue `drawn` starts, and how wide it runs.
|
||||
fn place(
|
||||
layout: &TextLayout,
|
||||
drawn: &Range<usize>,
|
||||
glue: Pixels,
|
||||
) -> Option<(Point<Pixels>, Pixels)> {
|
||||
let last = layout.position_for_index(drawn.end - GLUE.len_utf8())?;
|
||||
let first = layout.position_for_index(drawn.start)?;
|
||||
// A wrap's index reads as the previous line's end.
|
||||
let start = match first.y == last.y {
|
||||
true => first,
|
||||
false => point(layout.bounds().origin.x, last.y),
|
||||
};
|
||||
Some((start, last.x + glue - start.x))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bytes_map_both_ways_around_a_formula() {
|
||||
let inline = Inline {
|
||||
text: "a x^2 b".into(),
|
||||
styles: vec![(2..5, Style::Math), (6..7, Style::Strong)],
|
||||
..Inline::default()
|
||||
};
|
||||
let mut swaps = vec![(2..5, 0..0)];
|
||||
let drawn = shown(&inline, &mut swaps, &[GLUE.to_string().repeat(4)]);
|
||||
assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4)));
|
||||
let glue = GLUE.len_utf8() * 4;
|
||||
assert_eq!(swaps[0].1, 2..2 + glue);
|
||||
assert_eq!(
|
||||
drawn.styles[1].0,
|
||||
3 + glue..4 + glue,
|
||||
"the bold b moves past the glue"
|
||||
);
|
||||
assert_eq!(back(&swaps, 3), 2, "glue reads back to its formula");
|
||||
assert_eq!(back(&swaps, 3 + glue), 6);
|
||||
assert_eq!(forward(&swaps, 6), 3 + glue);
|
||||
}
|
||||
|
||||
type Read = Box<dyn Fn(Point<Pixels>) -> usize>;
|
||||
type Found = Rc<RefCell<Option<(Rc<RefCell<Option<Shown>>>, Read)>>>;
|
||||
|
||||
/// Draws `text` with `math` a formula, `width` wide.
|
||||
struct Probe {
|
||||
text: &'static str,
|
||||
math: Range<usize>,
|
||||
width: f32,
|
||||
found: Found,
|
||||
}
|
||||
|
||||
impl gpui::Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let inline = Inline {
|
||||
text: self.text.into(),
|
||||
styles: vec![(self.math.clone(), Style::Math)],
|
||||
..Inline::default()
|
||||
};
|
||||
let (text, at) = MathText::new("probe".into(), inline, None, None);
|
||||
*self.found.borrow_mut() = Some((text.shown.clone(), Box::new(at)));
|
||||
let shell = gpui::div()
|
||||
.w(gpui::px(self.width))
|
||||
.text_size(gpui::px(16.0));
|
||||
shell.child(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn laid(
|
||||
text: &'static str,
|
||||
math: Range<usize>,
|
||||
width: f32,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) -> Found {
|
||||
cx.update(crate::theme::Theme::init);
|
||||
let found = Found::default();
|
||||
let held = found.clone();
|
||||
let (_, cx) = cx.add_window_view(move |_, _| Probe {
|
||||
text,
|
||||
math,
|
||||
width,
|
||||
found: held,
|
||||
});
|
||||
for _ in 0..4 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) {
|
||||
let found = laid("a x^2 b", 2..5, 400.0, cx);
|
||||
let found = found.borrow();
|
||||
let (shown, at) = found.as_ref().expect("drawn");
|
||||
let shown = shown.borrow();
|
||||
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||
assert_eq!(swaps.len(), 1, "the formula typeset in place");
|
||||
let drawn = &swaps[0].1;
|
||||
let at_x = |ix| layout.position_for_index(ix).expect("on its line").x;
|
||||
let glue = at_x(drawn.end) - at_x(drawn.end - GLUE.len_utf8());
|
||||
let (start, room) = place(layout, drawn, glue).expect("placed");
|
||||
assert!(room > gpui::px(10.0), "glue holds the picture's width");
|
||||
let middle = point(start.x + room / 2.0, start.y + layout.line_height() / 2.0);
|
||||
assert_eq!(at(middle), 2, "a press on the picture is the formula");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_formula_wrapped_whole_draws_on_its_line(cx: &mut gpui::TestAppContext) {
|
||||
let text = "aaaaaaaaaa x^2+y^2=r^2 tail";
|
||||
let found = laid(text, 11..22, 160.0, cx);
|
||||
let found = found.borrow();
|
||||
let (shown, at) = found.as_ref().expect("drawn");
|
||||
let shown = shown.borrow();
|
||||
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||
let drawn = &swaps[0].1;
|
||||
let line = layout.line_height();
|
||||
let last = layout.position_for_index(drawn.end - GLUE.len_utf8());
|
||||
assert_eq!(last.expect("its glue").y, layout.bounds().top() + line);
|
||||
let (start, _) = place(layout, drawn, gpui::px(0.0)).expect("placed");
|
||||
assert_eq!(
|
||||
start,
|
||||
point(layout.bounds().left(), layout.bounds().top() + line),
|
||||
"the picture starts the second line"
|
||||
);
|
||||
assert_eq!(at(start + point(gpui::px(2.0), line / 2.0)), 11);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_failed_formula_says_why_after_its_tex(cx: &mut gpui::TestAppContext) {
|
||||
let found = laid("a x^2^3 b", 2..7, 400.0, cx);
|
||||
let found = found.borrow();
|
||||
let (shown, _) = found.as_ref().expect("drawn");
|
||||
let shown = shown.borrow();
|
||||
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||
assert_eq!(swaps[0].0, 7..7, "the TeX stays; the reason follows");
|
||||
let text = layout.wrapped_text();
|
||||
assert!(
|
||||
text.starts_with("a x^2^3 (") && text.ends_with(") b"),
|
||||
"{text:?}"
|
||||
);
|
||||
assert!(text.contains(" at "), "where RaTeX stopped: {text:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, TextLayout, Window, div,
|
||||
};
|
||||
|
||||
use super::{Draw, table::CellAt};
|
||||
use crate::documents::tree::Inline;
|
||||
|
||||
/// Where a press fell, as the drawing knows it.
|
||||
pub(crate) enum Place {
|
||||
/// A drawn byte, read back through its text's sources.
|
||||
Text(Rc<Inline>, usize),
|
||||
/// A source byte.
|
||||
At(usize),
|
||||
/// Drawn byte `at` of `text`, its lines written from `from`.
|
||||
Lines {
|
||||
text: Rc<str>,
|
||||
from: usize,
|
||||
at: usize,
|
||||
},
|
||||
/// A table cell, pressed past its text.
|
||||
Cell(CellAt),
|
||||
/// Somewhere the block holds no byte for.
|
||||
Block,
|
||||
}
|
||||
|
||||
/// A press on drawn content.
|
||||
pub(crate) struct Pointed {
|
||||
pub place: Place,
|
||||
pub link: Option<String>,
|
||||
pub image: Option<String>,
|
||||
/// The code's text, pressed in a code block.
|
||||
pub code: Option<Rc<str>>,
|
||||
pub table: bool,
|
||||
}
|
||||
|
||||
impl Pointed {
|
||||
/// A press past any drawn text.
|
||||
pub(crate) fn block() -> Self {
|
||||
Self::at(Place::Block)
|
||||
}
|
||||
|
||||
fn at(place: Place) -> Self {
|
||||
Self {
|
||||
place,
|
||||
link: None,
|
||||
image: None,
|
||||
code: None,
|
||||
table: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a press's button and what it fell on.
|
||||
pub(crate) type OnPoint = Rc<dyn Fn(Pointed, MouseButton, &mut Window, &mut App)>;
|
||||
|
||||
/// `element` hands its left and right presses to `on_point`.
|
||||
fn pointable(
|
||||
element: AnyElement,
|
||||
on_point: &Option<OnPoint>,
|
||||
table: bool,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> AnyElement {
|
||||
match on_point {
|
||||
Some(_) => pressed(div().child(element), on_point, table, point).into_any_element(),
|
||||
None => element,
|
||||
}
|
||||
}
|
||||
|
||||
/// `element` itself takes the presses `pointable` hands on.
|
||||
fn pressed<E: InteractiveElement>(
|
||||
element: E,
|
||||
on_point: &Option<OnPoint>,
|
||||
table: bool,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> E {
|
||||
let Some(on_point) = on_point.clone() else {
|
||||
return element;
|
||||
};
|
||||
let point = Rc::new(point);
|
||||
let press = move |button: MouseButton| {
|
||||
let (on_point, point) = (on_point.clone(), point.clone());
|
||||
move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if button == MouseButton::Left && event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let mut pointed = point(event);
|
||||
pointed.table |= table;
|
||||
on_point(pointed, button, window, cx);
|
||||
}
|
||||
};
|
||||
element
|
||||
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||
}
|
||||
|
||||
/// Drawn `text`, its lines written from source byte `from`.
|
||||
pub(crate) fn point_lines(
|
||||
element: AnyElement,
|
||||
layout: TextLayout,
|
||||
text: &str,
|
||||
from: usize,
|
||||
on_point: &Option<OnPoint>,
|
||||
(table, code): (bool, bool),
|
||||
) -> AnyElement {
|
||||
let text: Rc<str> = Rc::from(text);
|
||||
pointable(element, on_point, table, move |event| {
|
||||
let at = layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near);
|
||||
Pointed {
|
||||
code: code.then(|| text.clone()),
|
||||
..Pointed::at(Place::Lines {
|
||||
text: text.clone(),
|
||||
from,
|
||||
at,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
fn pointable(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> AnyElement {
|
||||
pointable(element, self.hooks.on_point, self.table.get(), point)
|
||||
}
|
||||
|
||||
/// A table cell taking its presses, past its text too.
|
||||
pub(super) fn pressed_cell(&self, cell: Div, at: CellAt) -> Div {
|
||||
pressed(cell, self.hooks.on_point, true, move |_| {
|
||||
Pointed::at(Place::Cell(at))
|
||||
})
|
||||
}
|
||||
|
||||
/// Drawn `inline`; a press finds its byte through `at`.
|
||||
pub(super) fn point_text(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
|
||||
inline: &Inline,
|
||||
) -> AnyElement {
|
||||
if self.hooks.on_point.is_none() {
|
||||
return element;
|
||||
}
|
||||
let inline = Rc::new(inline.clone());
|
||||
self.pointable(element, move |event| {
|
||||
let at = at(event.position);
|
||||
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||
Pointed {
|
||||
link: link.map(|(_, url)| url.clone()),
|
||||
..Pointed::at(Place::Text(inline.clone(), at))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A code block; its lines start at source byte `from`.
|
||||
pub(super) fn point_code(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
layout: Option<TextLayout>,
|
||||
text: &str,
|
||||
from: usize,
|
||||
) -> AnyElement {
|
||||
let table = self.table.get();
|
||||
match layout {
|
||||
Some(layout) => point_lines(
|
||||
element,
|
||||
layout,
|
||||
text,
|
||||
from,
|
||||
self.hooks.on_point,
|
||||
(table, true),
|
||||
),
|
||||
None => {
|
||||
let text: Rc<str> = Rc::from(text);
|
||||
self.pointable(element, move |_| Pointed {
|
||||
code: Some(text.clone()),
|
||||
..Pointed::block()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture written at source byte `at`.
|
||||
pub(super) fn point_image(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
at: usize,
|
||||
url: &str,
|
||||
link: &Option<String>,
|
||||
) -> AnyElement {
|
||||
let (url, link) = (url.to_string(), link.clone());
|
||||
self.pointable(element, move |_| Pointed {
|
||||
image: Some(url.clone()),
|
||||
link: link.clone(),
|
||||
..Pointed::at(Place::At(at))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, div, prelude::*,
|
||||
};
|
||||
use markdown::mdast::AlignKind;
|
||||
|
||||
use super::Draw;
|
||||
use crate::{
|
||||
documents::tree::Node,
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
|
||||
/// A cell: table in drawing order, row, column.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CellAt {
|
||||
pub table: usize,
|
||||
pub row: usize,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
/// The cell open for typing and its field, drawn once.
|
||||
pub(crate) struct OpenCell {
|
||||
pub at: CellAt,
|
||||
pub field: RefCell<Option<AnyElement>>,
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
pub(super) fn table(
|
||||
&self,
|
||||
aligns: &[AlignKind],
|
||||
head: &[Vec<Node>],
|
||||
rows: &[Vec<Vec<Node>>],
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let table = self.tables.get();
|
||||
self.tables.set(table + 1);
|
||||
let cell = |blocks: &[Node], row: usize, column: usize, cx: &App| {
|
||||
let at = CellAt { table, row, column };
|
||||
let open = (self.hooks.cell)
|
||||
.filter(|open| open.at == at)
|
||||
.and_then(|open| open.field.borrow_mut().take());
|
||||
let drawn = div()
|
||||
.debug_selector(move || format!("markdown-cell-{table}-{row}-{column}"))
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
|
||||
cell.justify_end()
|
||||
})
|
||||
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
|
||||
cell.justify_center()
|
||||
})
|
||||
.when(row == 0, |cell| {
|
||||
cell.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_muted)
|
||||
});
|
||||
let inside = match open {
|
||||
// Presses on the open field stay with it.
|
||||
Some(field) => div()
|
||||
.w_full()
|
||||
.child(field)
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()),
|
||||
None => div()
|
||||
.max_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(self.nodes(blocks, cx)),
|
||||
};
|
||||
self.pressed_cell(drawn.child(inside), at)
|
||||
};
|
||||
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, blocks)| cell(blocks, 0, column, cx)),
|
||||
),
|
||||
)
|
||||
.children(rows.iter().enumerate().map(|(row, cells)| {
|
||||
div()
|
||||
.flex()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.children(
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(column, blocks)| cell(blocks, row + 1, column, cx)),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px, rems};
|
||||
|
||||
use super::{HEAVY, MarkdownRenderer, Style, highlight};
|
||||
use crate::theme::{ActiveTheme, TextSize, Theme};
|
||||
|
||||
/// `source` drawn in a 400-point column.
|
||||
struct Page(&'static str);
|
||||
|
||||
impl Render for Page {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(400.0))
|
||||
.child(MarkdownRenderer::new("page", self.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_rule_draws_a_line_two_pixels_high(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page("above\n\n---\n\nbelow\n"));
|
||||
cx.run_until_parked();
|
||||
let rule = cx.debug_bounds("markdown-rule").expect("the rule is drawn");
|
||||
assert_eq!(rule.size.height, px(2.0), "{rule:?}");
|
||||
assert!(rule.size.width > px(300.0), "{rule:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn bullets_turn_with_depth(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page("- a\n - b\n 1. c\n - d\n"));
|
||||
cx.run_until_parked();
|
||||
for depth in [0, 1, 3] {
|
||||
let selector = Box::leak(format!("markdown-bullet-{depth}").into_boxed_str());
|
||||
assert!(cx.debug_bounds(selector).is_some(), "depth {depth}");
|
||||
}
|
||||
assert!(
|
||||
cx.debug_bounds("markdown-bullet-2").is_none(),
|
||||
"an ordered list"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn alerts_lists_of_headings_and_front_matter_draw(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let source = "> [!IMPORTANT]\n> read\n\n[toc]\n\n# One\n\n## Two\n";
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page(source));
|
||||
cx.run_until_parked();
|
||||
let (one, two) = (
|
||||
cx.debug_bounds("markdown-toc-0").expect("drawn"),
|
||||
cx.debug_bounds("markdown-toc-1").expect("drawn"),
|
||||
);
|
||||
assert!(two.top() > one.top(), "{one:?} {two:?}");
|
||||
}
|
||||
|
||||
/// One heading of every level.
|
||||
const HEADINGS: &str = "# a
|
||||
|
||||
## a
|
||||
|
||||
### a
|
||||
|
||||
#### a
|
||||
|
||||
##### a
|
||||
|
||||
###### a
|
||||
";
|
||||
|
||||
/// `source` in a 400-point column, body at `size`.
|
||||
struct Sized(&'static str, TextSize);
|
||||
|
||||
impl Render for Sized {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(400.0))
|
||||
.child(MarkdownRenderer::new("page", self.0).text_size(self.1))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn headings_step_up_from_the_body_by_typoras_ratios(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
for body in [TextSize::Base, TextSize::Lg] {
|
||||
let (_, cx) = cx.add_window_view(move |_, _| Sized(HEADINGS, body));
|
||||
cx.run_until_parked();
|
||||
let (base, pad) = cx.update(|window, cx| {
|
||||
let rem = window.rem_size();
|
||||
(
|
||||
cx.theme().text_size(body).to_pixels(rem),
|
||||
rems(0.5).to_pixels(rem),
|
||||
)
|
||||
});
|
||||
for (ordinal, step) in [2.25, 1.75, 1.5, 1.25, 1.0, 1.0].into_iter().enumerate() {
|
||||
let selector = Box::leak(format!("markdown-heading-{ordinal}").into_boxed_str());
|
||||
let high = cx.debug_bounds(selector).expect("drawn").size.height;
|
||||
let off = high - pad - base * step * 1.3;
|
||||
assert!(
|
||||
off.abs() < px(0.5),
|
||||
"{body:?} level {}: {high:?}",
|
||||
ordinal + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn strong_text_in_a_heading_keeps_its_weight(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| {
|
||||
Page(
|
||||
"### Plain **Strong** plain
|
||||
",
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.debug_bounds("markdown-heading-0").is_some(), "drawn");
|
||||
let strong = cx.update(|_, cx| {
|
||||
let theme = cx.theme();
|
||||
highlight(&Style::Strong, &theme.colors, theme.underline_thickness()).font_weight
|
||||
});
|
||||
assert_eq!(strong, Some(HEAVY), "strong never lightens a heading");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_zoomed_document_scales_its_headings(cx: &mut TestAppContext) {
|
||||
use crate::documents::{DocumentLook, MarkdownDocument, MarkdownMode};
|
||||
cx.update(crate::init_for_tests);
|
||||
let (document, cx) = cx.add_window_view(|window, cx| {
|
||||
MarkdownDocument::new("### a\n", MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
document.update(cx, |document, cx| {
|
||||
let look = DocumentLook {
|
||||
scale: 1.5,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.set_look(look, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let (base, pad) = cx.update(|window, cx| {
|
||||
let rem = window.rem_size();
|
||||
(
|
||||
cx.theme().text_size(TextSize::Base).to_pixels(rem),
|
||||
rems(0.5).to_pixels(rem),
|
||||
)
|
||||
});
|
||||
let high = cx
|
||||
.debug_bounds("markdown-heading-0")
|
||||
.expect("drawn")
|
||||
.size
|
||||
.height;
|
||||
let off = high - pad - base * 1.5 * 1.5 * 1.3;
|
||||
assert!(off.abs() < px(0.5), "{high:?}");
|
||||
}
|
||||
+102
-6
@@ -23,7 +23,7 @@ fn hit(page: usize) -> PageHit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three tall pages and the hits found; a press while pinning records the note asked.
|
||||
/// Three tall pages and hits; a pinning press records notes.
|
||||
struct Reader {
|
||||
query: Entity<TextInput>,
|
||||
width: f32,
|
||||
@@ -60,7 +60,7 @@ fn open(cx: &mut TestAppContext) -> (Entity<Reader>, Noted, &mut VisualTestConte
|
||||
open_with(400.0, None, cx)
|
||||
}
|
||||
|
||||
/// Pages `width` points wide, with a hit on the second, current from the first frame when `current` says.
|
||||
/// Pages `width` wide, a hit on page two, maybe current.
|
||||
fn open_with(
|
||||
width: f32,
|
||||
current: Option<usize>,
|
||||
@@ -86,7 +86,7 @@ fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A point on the pages, below the header and the find bar, at the view's middle.
|
||||
/// A point on the pages, below the bars, mid-view.
|
||||
fn on_pages(cx: &mut VisualTestContext) -> Point<Pixels> {
|
||||
cx.update(|window, _| point(window.viewport_size().width / 2.0, px(300.0)))
|
||||
}
|
||||
@@ -102,7 +102,7 @@ fn wheel(down: f32, cx: &mut VisualTestContext) {
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Turns pinning on with the header's last button, then presses the pages at their middle; returns the note asked.
|
||||
/// Turns pinning on, presses mid-page; the note asked.
|
||||
fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point<f32>) {
|
||||
let at = on_pages(cx);
|
||||
pin_at(at, cx, noted)
|
||||
@@ -143,7 +143,7 @@ fn a_note_lands_where_the_press_is_on_a_scrolled_page(cx: &mut TestAppContext) {
|
||||
assert_eq!(scrolled.y, top.y + 150.0, "the press counts the scroll");
|
||||
}
|
||||
|
||||
/// Scrolls `down`, makes the hit current, then pins a note at the same window point.
|
||||
/// Scrolls `down`, makes the hit current, pins a note.
|
||||
fn revealed(down: f32, cx: &mut TestAppContext) -> (usize, Point<f32>) {
|
||||
let (reader, noted, cx) = open(cx);
|
||||
if down > 0.0 {
|
||||
@@ -201,7 +201,7 @@ fn a_hit_current_from_the_first_frame_comes_into_view(cx: &mut TestAppContext) {
|
||||
assert_eq!(at_once, later);
|
||||
}
|
||||
|
||||
/// A field outside a zen view, and how often Escape left zen.
|
||||
/// A field outside zen, and the Escapes that left zen.
|
||||
struct Zen {
|
||||
outside: Entity<TextInput>,
|
||||
on: bool,
|
||||
@@ -301,3 +301,99 @@ fn a_selected_version_past_the_history_selects_none(cx: &mut TestAppContext) {
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Markdown drawn `width` points wide, its pictures read in `base`.
|
||||
struct Page {
|
||||
source: &'static str,
|
||||
base: std::path::PathBuf,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
impl Render for Page {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(self.width))
|
||||
.child(super::MarkdownRenderer::new("page", self.source).base(self.base.as_path()))
|
||||
}
|
||||
}
|
||||
|
||||
const SHOT: &str = "Before
|
||||
|
||||

|
||||
";
|
||||
|
||||
/// A 200×100 picture in `source`, drawn in a `width` column.
|
||||
fn drawn_picture(source: &'static str, width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
|
||||
let named = format!(
|
||||
"ely-picture-{}-{width}-{}",
|
||||
std::process::id(),
|
||||
source.len()
|
||||
);
|
||||
let folder = std::env::temp_dir().join(named);
|
||||
std::fs::create_dir_all(folder.join("assets")).expect("a folder");
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(200, 100)
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("a png");
|
||||
std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture");
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page {
|
||||
source,
|
||||
base: folder.clone(),
|
||||
width,
|
||||
});
|
||||
let mut drawn = None;
|
||||
// The decode runs on the background executor.
|
||||
for _ in 0..50 {
|
||||
settle(cx);
|
||||
drawn = cx.debug_bounds("markdown-picture");
|
||||
if drawn.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(&folder).expect("cleaned");
|
||||
drawn.expect("the picture drew").size
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestAppContext) {
|
||||
let shrunk = drawn_picture(SHOT, 120.0, cx);
|
||||
assert_eq!(shrunk, size(px(120.0), px(60.0)), "shrunk at 2:1");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) {
|
||||
assert_eq!(drawn_picture(SHOT, 400.0, cx), size(px(200.0), px(100.0)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_in_a_table_cell_draws(cx: &mut TestAppContext) {
|
||||
let table = "| Picture |
|
||||
| --- |
|
||||
|  |
|
||||
";
|
||||
assert_eq!(drawn_picture(table, 400.0, cx), size(px(200.0), px(100.0)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_in_a_narrow_cell_shrinks_within_it(cx: &mut TestAppContext) {
|
||||
let table = "| A | B |
|
||||
| - | - |
|
||||
|  | b |
|
||||
";
|
||||
let drawn = drawn_picture(table, 160.0, cx);
|
||||
assert!(drawn.width < px(80.0), "within its half: {drawn:?}");
|
||||
assert_eq!(drawn.height, drawn.width / 2.0, "at its ratio");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_formula_that_fails_shows_why_and_its_source(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page {
|
||||
source: "$$x^2^3$$\n",
|
||||
base: std::env::temp_dir(),
|
||||
width: 300.0,
|
||||
});
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("latex-error").is_some(), "the failure drew");
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
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: Vec<(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.push((String::new(), dest_url.to_string()))
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
let (alt, url) = built.image.pop().expect("an image closes after it opens");
|
||||
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, built.image.last_mut()) {
|
||||
(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 an_image_inside_an_image_keeps_both() {
|
||||
let nodes = parse("](outer.png)");
|
||||
let urls: Vec<&str> = nodes
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
Node::Image { url, .. } => Some(url.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(urls, ["inner.png", "outer.png"], "{nodes:?}");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use markdown::mdast;
|
||||
|
||||
use super::{Node, Reader};
|
||||
use crate::documents::markdown::span;
|
||||
|
||||
/// A heading anywhere in the file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DocumentHeading {
|
||||
pub depth: u8,
|
||||
pub title: String,
|
||||
/// Where its source starts, in bytes.
|
||||
pub at: usize,
|
||||
}
|
||||
|
||||
/// A GitHub alert's kind, named by its marker.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AlertKind {
|
||||
Note,
|
||||
Tip,
|
||||
Important,
|
||||
Warning,
|
||||
Caution,
|
||||
}
|
||||
|
||||
impl AlertKind {
|
||||
/// The title GitHub shows.
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Note => "Note",
|
||||
Self::Tip => "Tip",
|
||||
Self::Important => "Important",
|
||||
Self::Warning => "Warning",
|
||||
Self::Caution => "Caution",
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind `line` marks, as `[!NOTE]` in any case.
|
||||
fn marked(line: &str) -> Option<Self> {
|
||||
let name = line
|
||||
.trim_matches([' ', '\t'])
|
||||
.strip_prefix("[!")?
|
||||
.strip_suffix(']')?;
|
||||
[
|
||||
Self::Note,
|
||||
Self::Tip,
|
||||
Self::Important,
|
||||
Self::Warning,
|
||||
Self::Caution,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|kind| kind.title().eq_ignore_ascii_case(name))
|
||||
}
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// A root block; alerts and `[toc]` live only here.
|
||||
pub(crate) fn top(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||
if let Some(alert) = self.alert(node) {
|
||||
return out.push(alert);
|
||||
}
|
||||
match is_toc(node, self.source) {
|
||||
true => out.push(Node::Toc(self.headings.clone())),
|
||||
false => self.block(node, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote whose first line marks it, with more after.
|
||||
fn alert(&self, node: &mdast::Node) -> Option<Node> {
|
||||
let mdast::Node::Blockquote(quote) = node else {
|
||||
return None;
|
||||
};
|
||||
let (kind, parts) = alert(quote)?;
|
||||
let mut body = Vec::new();
|
||||
self.blocks(&parts, &mut body);
|
||||
Some(Node::Alert { kind, body })
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote's alert kind and its blocks after the marker.
|
||||
pub fn alert(quote: &mdast::Blockquote) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else {
|
||||
return None;
|
||||
};
|
||||
let (kind, after) = marker(¶graph.children)?;
|
||||
if after.is_empty() && rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let head = (!after.is_empty()).then(|| {
|
||||
mdast::Node::Paragraph(mdast::Paragraph {
|
||||
children: after,
|
||||
position: paragraph.position.clone(),
|
||||
})
|
||||
});
|
||||
Some((kind, head.into_iter().chain(rest.iter().cloned()).collect()))
|
||||
}
|
||||
|
||||
/// A paragraph that is `[toc]` alone, as Typora reads it.
|
||||
pub fn is_toc(node: &mdast::Node, source: &str) -> bool {
|
||||
matches!(node, mdast::Node::Paragraph(_))
|
||||
&& source[span(node)].trim().eq_ignore_ascii_case("[toc]")
|
||||
}
|
||||
|
||||
/// A plain first line naming a kind, and what follows.
|
||||
fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||
let mut line = String::new();
|
||||
for (at, child) in children.iter().enumerate() {
|
||||
let head = match child {
|
||||
mdast::Node::Text(text) => match text.value.find(['\n', '\r']) {
|
||||
None => {
|
||||
line.push_str(&text.value);
|
||||
continue;
|
||||
}
|
||||
Some(end) => {
|
||||
line.push_str(&text.value[..end]);
|
||||
let after = &text.value[end..];
|
||||
let after = after.strip_prefix("\r\n").unwrap_or(&after[1..]);
|
||||
let mut kept = text.clone();
|
||||
kept.value = after.to_string();
|
||||
(!after.is_empty()).then_some(mdast::Node::Text(kept))
|
||||
}
|
||||
},
|
||||
mdast::Node::Break(_) => None,
|
||||
_ => return None,
|
||||
};
|
||||
let after = head.into_iter().chain(children[at + 1..].iter().cloned());
|
||||
return Some((AlertKind::marked(&line)?, after.collect()));
|
||||
}
|
||||
Some((AlertKind::marked(&line)?, Vec::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::documents::tree::{Style, parse};
|
||||
|
||||
fn alert(source: &str) -> Option<(AlertKind, Vec<Node>)> {
|
||||
match parse(source).into_iter().next() {
|
||||
Some(Node::Alert { kind, body }) => Some((kind, body)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(nodes: &[Node]) -> Vec<String> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| match node {
|
||||
Node::Paragraph(line) => line.text.clone(),
|
||||
Node::Heading(_, line) => format!("# {}", line.text),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alerts_read_as_github_reads_them() {
|
||||
let note = alert("> [!NOTE]\n> one").expect("an alert");
|
||||
assert_eq!(
|
||||
(note.0, text(¬e.1)),
|
||||
(AlertKind::Note, vec!["one".into()])
|
||||
);
|
||||
let cases = [
|
||||
("> [!tip]\n> two", Some(AlertKind::Tip)),
|
||||
("> [!NOTE] \n> trailing", Some(AlertKind::Note)),
|
||||
("> [!TIP]\n> leading", Some(AlertKind::Tip)),
|
||||
("> [!Warning]\n>\n> after a blank", Some(AlertKind::Warning)),
|
||||
("> [!WARNING] same line", None),
|
||||
("> [!IMPORTANT]", None),
|
||||
("> [!FOO]\n> five", None),
|
||||
("> text\n> [!NOTE]", None),
|
||||
("> \\[!NOTE]\n> escaped", Some(AlertKind::Note)),
|
||||
("> [!NOTE]\n> referenced", Some(AlertKind::Note)),
|
||||
("> [!NOTE] \n> hard line", Some(AlertKind::Note)),
|
||||
("> [!NOTE]\rcarriage", Some(AlertKind::Note)),
|
||||
("> [!NOTE]\nlazy", Some(AlertKind::Note)),
|
||||
("> [!NOTE]\u{a0}\n> no-break space", None),
|
||||
("> **[!TIP]**\n> strong", None),
|
||||
("> <span>[!WARNING]</span>\n> tag", None),
|
||||
("[!NOTE]: https://example.com\n\n> [!NOTE]\n> a link", None),
|
||||
];
|
||||
for (source, kind) in cases {
|
||||
assert_eq!(alert(source).map(|found| found.0), kind, "{source:?}");
|
||||
}
|
||||
let nested = parse("- > [!CAUTION]\n > in a list");
|
||||
assert!(matches!(&nested[0], Node::List { .. }), "{nested:?}");
|
||||
let blocks = alert("> [!NOTE]\n> # head\n> - item").expect("an alert");
|
||||
assert!(matches!(
|
||||
&blocks.1[..],
|
||||
[Node::Heading(1, _), Node::List { .. }]
|
||||
));
|
||||
let styled = alert("> [!CAUTION]\n> a **b**\n> c").expect("an alert");
|
||||
let Node::Paragraph(line) = &styled.1[0] else {
|
||||
panic!("a paragraph, not {:?}", styled.1)
|
||||
};
|
||||
let bold = 2..3;
|
||||
assert_eq!(
|
||||
(line.text.as_str(), &line.styles[..]),
|
||||
("a b\nc", &[(bold, Style::Strong)][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toc_paragraph_lists_every_heading() {
|
||||
let nodes = parse("[toc]\n\n# A\n\n> ## B :smile:\n");
|
||||
let Node::Toc(headings) = &nodes[0] else {
|
||||
panic!("a toc, not {nodes:?}")
|
||||
};
|
||||
let listed: Vec<(u8, &str, usize)> = headings
|
||||
.iter()
|
||||
.map(|heading| (heading.depth, heading.title.as_str(), heading.at))
|
||||
.collect();
|
||||
assert_eq!(listed, [(1, "A", 7), (2, "B 😄", 14)]);
|
||||
assert!(matches!(&parse(" [TOC] ")[0], Node::Toc(_)));
|
||||
assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_)));
|
||||
assert!(matches!(&parse("> [toc]")[0], Node::Quote(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// `text` with each known `:shortcode:` as its emoji.
|
||||
fn emoji(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find(':') {
|
||||
out.push_str(&rest[..at]);
|
||||
let after = &rest[at + 1..];
|
||||
let len = after
|
||||
.find(|ch: char| !shortcode_char(ch))
|
||||
.unwrap_or(after.len());
|
||||
let found = after[len..]
|
||||
.starts_with(':')
|
||||
.then(|| emojis::get_by_shortcode(&after[..len]))
|
||||
.flatten();
|
||||
match found {
|
||||
Some(found) => {
|
||||
out.push_str(found.as_str());
|
||||
rest = &after[len + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push(':');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn shortcode_char(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-')
|
||||
}
|
||||
|
||||
/// Where the first `:name:` starts.
|
||||
fn candidate(text: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(at) = text[from..].find(':').map(|at| from + at) {
|
||||
let after = &text[at + 1..];
|
||||
let len = after
|
||||
.find(|ch: char| !shortcode_char(ch))
|
||||
.unwrap_or(after.len());
|
||||
if len > 0 && after[len..].starts_with(':') {
|
||||
return Some(at);
|
||||
}
|
||||
from = at + 1 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One DOM text node's shortcodes, as GitHub reads them.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shortcodes {
|
||||
/// Settled by the node's first `:name:`.
|
||||
reads: Option<bool>,
|
||||
/// Text before it held more than white space.
|
||||
inked: bool,
|
||||
last: Option<char>,
|
||||
}
|
||||
|
||||
impl Shortcodes {
|
||||
/// A new DOM text node starts.
|
||||
pub(super) fn split(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// Blank or spaced text before the first reads them all.
|
||||
pub(super) fn read(&mut self, text: &str) -> String {
|
||||
if self.reads.is_none() {
|
||||
let at = candidate(text);
|
||||
let lead = &text[..at.unwrap_or(text.len())];
|
||||
self.inked |= !lead.chars().all(|ch| ch.is_ascii_whitespace());
|
||||
self.last = lead.chars().next_back().or(self.last);
|
||||
self.reads = at.map(|_| !self.inked || self.last == Some(' '));
|
||||
}
|
||||
match self.reads {
|
||||
Some(true) => emoji(text),
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_shortcodes_become_emoji() {
|
||||
assert_eq!(emoji("hi :smile: :+1:"), "hi 😄 👍");
|
||||
assert_eq!(emoji(":nope: at 10:30 and ::"), ":nope: at 10:30 and ::");
|
||||
assert_eq!(emoji("a:smile:b :x:smile:"), "a😄b ❌smile:");
|
||||
assert_eq!(emoji("é :smile: 中"), "é 😄 中");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_runs_scan_in_linear_time() {
|
||||
let colons = ":zq".repeat(1 << 16);
|
||||
let started = web_time::Instant::now();
|
||||
assert_eq!(emoji(&colons).len(), colons.len());
|
||||
assert_eq!(Shortcodes::default().read(&colons).len(), colons.len());
|
||||
let spent = started.elapsed();
|
||||
assert!(spent.as_secs() < 2, "192 KiB took {spent:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
use markdown::decode_named;
|
||||
|
||||
use super::Style;
|
||||
|
||||
/// One HTML tag as written: its lowercase name and attributes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Tag {
|
||||
pub name: String,
|
||||
pub closing: bool,
|
||||
pub attributes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn attribute(&self, name: &str) -> Option<&str> {
|
||||
self.attributes
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// How Typora shows an inline tag; others show text.
|
||||
pub(crate) fn inline_style(name: &str) -> Option<Style> {
|
||||
match name {
|
||||
"u" => Some(Style::Underline),
|
||||
"b" | "strong" => Some(Style::Strong),
|
||||
"i" | "em" => Some(Style::Emphasis),
|
||||
"s" | "del" | "strike" => Some(Style::Strike),
|
||||
"code" | "kbd" => Some(Style::Code),
|
||||
"mark" => Some(Style::Mark),
|
||||
"a" => Some(Style::Link),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML5's formatting elements reopen past a crossed close.
|
||||
pub(crate) fn reopens(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"a" | "b" | "code" | "em" | "i" | "s" | "strike" | "strong" | "u"
|
||||
)
|
||||
}
|
||||
|
||||
/// GFM's tag filter: these tags show as text.
|
||||
pub(crate) fn filtered(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"title"
|
||||
| "textarea"
|
||||
| "style"
|
||||
| "xmp"
|
||||
| "iframe"
|
||||
| "noembed"
|
||||
| "noframes"
|
||||
| "script"
|
||||
| "plaintext"
|
||||
)
|
||||
}
|
||||
|
||||
/// Elements a browser lays as blocks: a line each side.
|
||||
pub(crate) fn breaks(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"address"
|
||||
| "article"
|
||||
| "aside"
|
||||
| "blockquote"
|
||||
| "center"
|
||||
| "details"
|
||||
| "div"
|
||||
| "dl"
|
||||
| "dd"
|
||||
| "dt"
|
||||
| "figcaption"
|
||||
| "figure"
|
||||
| "footer"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "header"
|
||||
| "hr"
|
||||
| "li"
|
||||
| "main"
|
||||
| "nav"
|
||||
| "ol"
|
||||
| "p"
|
||||
| "pre"
|
||||
| "section"
|
||||
| "summary"
|
||||
| "table"
|
||||
| "tr"
|
||||
| "ul"
|
||||
)
|
||||
}
|
||||
|
||||
/// What one inline HTML tag does, as GitHub shows it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum InlineTag {
|
||||
/// Opens `name`; an anchor styles only with an address.
|
||||
Open {
|
||||
name: String,
|
||||
style: Option<Style>,
|
||||
href: Option<String>,
|
||||
},
|
||||
Close(String),
|
||||
Break,
|
||||
/// A filtered tag, shown as its text.
|
||||
Text(String),
|
||||
/// Gone; the text around it stays.
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// What the inline HTML `written` does.
|
||||
pub(super) fn inline_tag(written: &str) -> InlineTag {
|
||||
let Some(tag) = tag(written) else {
|
||||
return InlineTag::Gone;
|
||||
};
|
||||
if filtered(&tag.name) {
|
||||
return InlineTag::Text(text(written));
|
||||
}
|
||||
if tag.name == "br" {
|
||||
return InlineTag::Break;
|
||||
}
|
||||
let Some(style) = inline_style(&tag.name) else {
|
||||
return InlineTag::Gone;
|
||||
};
|
||||
if tag.closing {
|
||||
return InlineTag::Close(tag.name);
|
||||
}
|
||||
let href = match style {
|
||||
Style::Link => tag.attribute("href").map(str::to_string),
|
||||
_ => None,
|
||||
};
|
||||
let style = (style != Style::Link || href.is_some()).then_some(style);
|
||||
InlineTag::Open {
|
||||
name: tag.name,
|
||||
style,
|
||||
href,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start tags that end an open `p`, as HTML5 lists them.
|
||||
pub(crate) fn closes_p(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"address"
|
||||
| "article"
|
||||
| "aside"
|
||||
| "blockquote"
|
||||
| "center"
|
||||
| "details"
|
||||
| "dialog"
|
||||
| "dir"
|
||||
| "div"
|
||||
| "dl"
|
||||
| "dd"
|
||||
| "dt"
|
||||
| "fieldset"
|
||||
| "figcaption"
|
||||
| "figure"
|
||||
| "footer"
|
||||
| "form"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "header"
|
||||
| "hgroup"
|
||||
| "hr"
|
||||
| "li"
|
||||
| "listing"
|
||||
| "main"
|
||||
| "menu"
|
||||
| "nav"
|
||||
| "ol"
|
||||
| "p"
|
||||
| "pre"
|
||||
| "search"
|
||||
| "section"
|
||||
| "summary"
|
||||
| "table"
|
||||
| "ul"
|
||||
)
|
||||
}
|
||||
|
||||
/// Bytes through the `>` ending the tag at `html`'s start.
|
||||
pub(crate) fn tag_end(html: &str) -> Option<usize> {
|
||||
let (mut quote, mut last) = (None, '<');
|
||||
for (at, ch) in html.char_indices().skip(1) {
|
||||
match quote {
|
||||
Some(open) if ch == open => quote = None,
|
||||
Some(_) => {}
|
||||
None if ch == '>' => return Some(at + 1),
|
||||
None if matches!(ch, '"' | '\'') && last == '=' => quote = Some(ch),
|
||||
None => {}
|
||||
}
|
||||
if !spacing(ch) {
|
||||
last = ch;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `html` as one tag; comments and the rest are none.
|
||||
pub(crate) fn tag(html: &str) -> Option<Tag> {
|
||||
let inner = html.trim().strip_prefix('<')?.strip_suffix('>')?;
|
||||
let (closing, inner) = match inner.strip_prefix('/') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, inner),
|
||||
};
|
||||
if !inner.starts_with(|ch: char| ch.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let named = inner
|
||||
.find(|ch: char| spacing(ch) || ch == '/')
|
||||
.unwrap_or(inner.len());
|
||||
Some(Tag {
|
||||
name: inner[..named].to_ascii_lowercase(),
|
||||
closing,
|
||||
attributes: attributes(&inner[named..]),
|
||||
})
|
||||
}
|
||||
|
||||
/// `name="value"`, `name='value'`, `name=value` and bare names.
|
||||
fn attributes(mut rest: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
rest = rest.trim_start_matches(spacing);
|
||||
// A slash between attributes closes the tag itself.
|
||||
if let Some(after) = rest.strip_prefix('/') {
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let named = rest
|
||||
.find(|ch: char| spacing(ch) || ch == '=' || ch == '/')
|
||||
.unwrap_or(rest.len());
|
||||
if named == 0 {
|
||||
return out;
|
||||
}
|
||||
let name = rest[..named].to_ascii_lowercase();
|
||||
rest = rest[named..].trim_start_matches(spacing);
|
||||
let Some(after) = rest.strip_prefix('=') else {
|
||||
out.push((name, String::new()));
|
||||
continue;
|
||||
};
|
||||
let after = after.trim_start_matches(spacing);
|
||||
let (value, left) = match after.chars().next() {
|
||||
Some(quote @ ('"' | '\'')) => match after[1..].find(quote) {
|
||||
Some(end) => (&after[1..1 + end], &after[2 + end..]),
|
||||
None => (&after[1..], ""),
|
||||
},
|
||||
_ => {
|
||||
let end = after.find(spacing).unwrap_or(after.len());
|
||||
(&after[..end], &after[end..])
|
||||
}
|
||||
};
|
||||
out.push((name, decoded(value, true)));
|
||||
rest = left;
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML's whitespace, which is ASCII alone.
|
||||
pub(crate) fn spacing(ch: char) -> bool {
|
||||
matches!(ch, ' ' | '\t' | '\n' | '\r' | '\x0C')
|
||||
}
|
||||
|
||||
/// Text between tags, references decoded as HTML does.
|
||||
pub(crate) fn text(written: &str) -> String {
|
||||
decoded(written, false)
|
||||
}
|
||||
|
||||
/// References decoded as HTML does, in a value or text.
|
||||
fn decoded(text: &str, attribute: bool) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
match reference(rest, attribute) {
|
||||
Some((value, len)) => {
|
||||
out.push_str(&value);
|
||||
rest = &rest[len..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Longest reference name HTML knows, in bytes.
|
||||
const LONGEST: usize = 32;
|
||||
|
||||
/// A reference at `text`'s start, as HTML reads it.
|
||||
pub(super) fn reference(text: &str, attribute: bool) -> Option<(String, usize)> {
|
||||
let body = text.strip_prefix('&')?;
|
||||
if let Some(number) = body.strip_prefix('#') {
|
||||
let (digits, radix) = match number.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => (hex, 16),
|
||||
None => (number, 10),
|
||||
};
|
||||
let len = digits
|
||||
.find(|ch: char| !ch.is_digit(radix))
|
||||
.unwrap_or(digits.len());
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let code = digits[..len].chars().try_fold(0u32, |code, ch| {
|
||||
code.checked_mul(radix)?.checked_add(ch.to_digit(radix)?)
|
||||
});
|
||||
let ended = usize::from(digits[len..].starts_with(';'));
|
||||
let used = 2 + (number.len() - digits.len()) + len + ended;
|
||||
return Some((numeric(code).to_string(), used));
|
||||
}
|
||||
let len = body
|
||||
.find(|ch: char| !ch.is_ascii_alphanumeric())
|
||||
.unwrap_or(body.len());
|
||||
let name = &body[..len];
|
||||
if (1..=LONGEST).contains(&len)
|
||||
&& body[len..].starts_with(';')
|
||||
&& let Some(value) = decode_named(name, true)
|
||||
{
|
||||
return Some((value, len + 2));
|
||||
}
|
||||
// Text takes a legacy prefix; a value, the whole name.
|
||||
let shortest = match attribute {
|
||||
true if body[len..].starts_with(['=', ';']) => return None,
|
||||
true => len,
|
||||
false => 1,
|
||||
};
|
||||
let name = (shortest..=len.min(LONGEST))
|
||||
.rev()
|
||||
.map(|end| &name[..end])
|
||||
.find(|name| legacy(name))?;
|
||||
let value = decode_named(name, true).expect("a legacy name");
|
||||
Some((value, name.len() + 1))
|
||||
}
|
||||
|
||||
/// A name HTML decodes without its semicolon.
|
||||
fn legacy(name: &str) -> bool {
|
||||
if matches!(name, "AMP" | "LT" | "GT" | "QUOT" | "COPY" | "REG") {
|
||||
return true;
|
||||
}
|
||||
decode_named(name, false).is_some_and(|value| {
|
||||
let mut chars = value.chars();
|
||||
let one = chars.next().filter(|_| chars.next().is_none());
|
||||
one.is_some_and(|ch| matches!(ch, '&' | '<' | '>' | '"' | '\u{A0}'..='\u{FF}'))
|
||||
})
|
||||
}
|
||||
|
||||
/// The character a numeric reference names, as HTML maps it.
|
||||
fn numeric(code: Option<u32>) -> char {
|
||||
// Windows-1252 for the C1 block, as browsers do.
|
||||
const C1: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
|
||||
0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013,
|
||||
0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
match code {
|
||||
Some(code @ 0x80..=0x9F) => char::from_u32(C1[(code - 0x80) as usize]),
|
||||
Some(0) | None => None,
|
||||
Some(code) => char::from_u32(code),
|
||||
}
|
||||
.unwrap_or(char::REPLACEMENT_CHARACTER)
|
||||
}
|
||||
|
||||
/// `n` in superscript digits.
|
||||
pub(crate) fn superscript(n: usize) -> String {
|
||||
const DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
||||
n.to_string()
|
||||
.bytes()
|
||||
.map(|digit| DIGITS[usize::from(digit - b'0')])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_tag_reads_its_name_and_attributes() {
|
||||
let link = tag("<A HREF=\"https://ely.dev\" title='x' hidden>").expect("a tag");
|
||||
assert_eq!((link.name.as_str(), link.closing), ("a", false));
|
||||
assert_eq!(link.attribute("href"), Some("https://ely.dev"));
|
||||
assert_eq!(link.attribute("title"), Some("x"));
|
||||
assert_eq!(link.attribute("hidden"), Some(""));
|
||||
assert_eq!(
|
||||
tag("</u>").map(|t| (t.name, t.closing)),
|
||||
Some(("u".into(), true))
|
||||
);
|
||||
assert_eq!(tag("<br/>").map(|t| t.name), Some("br".into()));
|
||||
assert_eq!(tag("<u-box>").map(|t| t.name), Some("u-box".into()));
|
||||
assert_eq!(tag("<!-- note -->"), None);
|
||||
assert_eq!(tag("<1>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_values_keep_slashes_and_decode_references() {
|
||||
let bare = tag("<a href=/path/>").expect("a tag");
|
||||
assert_eq!(bare.attribute("href"), Some("/path/"));
|
||||
let closed = tag("<img src=\"a.png\" />").expect("a tag");
|
||||
assert_eq!(closed.attribute("src"), Some("a.png"));
|
||||
let query = tag("<a href=\"https://x.test/?q=1&p=2&r=3&nope;\">");
|
||||
let query = query.expect("a tag");
|
||||
assert_eq!(
|
||||
query.attribute("href"),
|
||||
Some("https://x.test/?q=1&p=2&r=3&nope;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_decode_as_a_browser_decodes_them() {
|
||||
let href = |html: &str| {
|
||||
tag(html)
|
||||
.expect("a tag")
|
||||
.attribute("href")
|
||||
.map(str::to_owned)
|
||||
};
|
||||
let cases = [
|
||||
("<a href=\"?q=1&page=2\">", "?q=1&page=2"),
|
||||
("<a href=\"/€Ÿ\">", "/€Ÿ\u{81}"),
|
||||
(
|
||||
"<a href=\"���\">",
|
||||
"\u{FFFD}\u{FFFD}\u{FFFD}",
|
||||
),
|
||||
(
|
||||
"<a href=\"& page&x&=∉&\">",
|
||||
"& page&x&=∉&",
|
||||
),
|
||||
("<a href=/a\u{A0}b>", "/a\u{A0}b"),
|
||||
];
|
||||
for (html, want) in cases {
|
||||
assert_eq!(href(html).as_deref(), Some(want), "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_runs_scan_in_linear_time() {
|
||||
let amps = "&".repeat(1 << 18);
|
||||
let started = web_time::Instant::now();
|
||||
assert_eq!(decoded(&s, true).len(), amps.len());
|
||||
assert_eq!(text(&s).len(), amps.len());
|
||||
let spent = started.elapsed();
|
||||
assert!(spent.as_secs() < 2, "256 KiB took {spent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_takes_the_longest_legacy_name() {
|
||||
assert_eq!(
|
||||
text("¬it; & &x <3 &nope;"),
|
||||
"¬it; & &x <3 &nope;"
|
||||
);
|
||||
assert_eq!(decoded("¬it;&x", true), "¬it;&x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_ends_outside_its_quotes() {
|
||||
assert_eq!(tag_end("<a href=\"x>y\">z"), Some(14));
|
||||
assert_eq!(tag_end("<b it's>r"), Some(8));
|
||||
assert_eq!(tag_end("<b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_go_up() {
|
||||
assert_eq!(superscript(1), "¹");
|
||||
assert_eq!(superscript(120), "¹²⁰");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::{Escapes, Inline, Run, html, sources::LineMap};
|
||||
|
||||
/// An HTML block's text, as a browser shows it.
|
||||
pub(super) fn html_block_text(written: &str, source: &str, span: Range<usize>) -> Inline {
|
||||
let (mut run, map) = (Run::default(), LineMap::new(source, span.start, written));
|
||||
let mut rest = written;
|
||||
// Each text run keeps where it was written.
|
||||
let words = |run: &mut Run, rest: &str, text: &str| {
|
||||
let from = written.len() - rest.len();
|
||||
let at = |value| map.as_ref()?.at(value);
|
||||
// Blank text draws nothing where it stands.
|
||||
if text.chars().all(html::spacing) {
|
||||
return run.words(text);
|
||||
}
|
||||
match at(from).zip(at(from + text.len())) {
|
||||
Some((start, end)) => {
|
||||
(run.text.sources).push((run.text.text.len(), start..end, Escapes::Html))
|
||||
}
|
||||
None => log::warn!("markdown: an HTML block's text lost its source"),
|
||||
}
|
||||
run.words(text);
|
||||
};
|
||||
while let Some(at) = rest.find('<') {
|
||||
words(&mut run, rest, &rest[..at]);
|
||||
rest = &rest[at..];
|
||||
if let Some(comment) = rest.strip_prefix("<!--") {
|
||||
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||
run.shortcodes.split();
|
||||
continue;
|
||||
}
|
||||
let opens =
|
||||
rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch));
|
||||
if !opens {
|
||||
words(&mut run, rest, "<");
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
}
|
||||
// An unclosed tag drops the rest, as HTML5 does.
|
||||
let Some(end) = html::tag_end(rest) else {
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let (tag_text, after) = rest.split_at(end);
|
||||
match html::tag(tag_text) {
|
||||
Some(tag) if html::filtered(&tag.name) => words(&mut run, rest, tag_text),
|
||||
// A block closes what opened inside it.
|
||||
Some(tag) if html::breaks(&tag.name) => {
|
||||
run.line();
|
||||
match tag.closing {
|
||||
true => run.end(&tag.name),
|
||||
false => {
|
||||
run.implied(&tag.name);
|
||||
match tag.name == "hr" {
|
||||
true => run.shortcodes.split(),
|
||||
false => run.start(&tag.name, None, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// It ends a `p` but lays no block.
|
||||
Some(tag) if !tag.closing && html::closes_p(&tag.name) => {
|
||||
run.implied(&tag.name);
|
||||
run.html(tag_text);
|
||||
}
|
||||
// A line's end drops its space.
|
||||
Some(tag) if tag.closing || tag.name == "br" => {
|
||||
run.owed = run.owed.filter(|_| tag.name != "br");
|
||||
run.html(tag_text);
|
||||
}
|
||||
Some(_) => {
|
||||
run.pay();
|
||||
run.html(tag_text);
|
||||
}
|
||||
None => run.shortcodes.split(),
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
words(&mut run, rest, rest);
|
||||
run.cut()
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// HTML text: references decoded, white space collapsed.
|
||||
fn words(&mut self, written: &str) {
|
||||
let shown = self.shown(&html::text(written));
|
||||
self.spaced(&shown);
|
||||
}
|
||||
|
||||
/// Text with its white space collapsed.
|
||||
fn spaced(&mut self, shown: &str) {
|
||||
for ch in shown.chars() {
|
||||
if !html::spacing(ch) {
|
||||
self.pay();
|
||||
self.text.text.push(ch);
|
||||
} else if self.owed.is_none() && !self.text.text.ends_with('\n') {
|
||||
let open = self.open.len();
|
||||
self.owed = (!self.text.text.is_empty()).then_some((' ', open));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line owed where a block element starts or ends.
|
||||
fn line(&mut self) {
|
||||
let from = self.owed.map_or(self.open.len(), |(_, from)| from);
|
||||
let text = &self.text.text;
|
||||
self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some(('\n', from));
|
||||
}
|
||||
|
||||
/// Writes the white space owed, before a word or tag.
|
||||
fn pay(&mut self) {
|
||||
let Some((owed, from)) = self.owed.take() else {
|
||||
return;
|
||||
};
|
||||
self.text.text.push(owed);
|
||||
let start = self.text.text.len();
|
||||
// What opened after it starts after it.
|
||||
for opened in &mut self.open[from..] {
|
||||
opened.start = start;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends what a block's start tag closes, as HTML5 does.
|
||||
fn implied(&mut self, name: &str) {
|
||||
let items: &[&str] = match name {
|
||||
"li" => &["li"],
|
||||
"dd" | "dt" => &["dd", "dt"],
|
||||
_ => &[],
|
||||
};
|
||||
// The search stops at a special element.
|
||||
let stops = |name: &str| html::breaks(name) && !matches!(name, "address" | "div" | "p");
|
||||
let found = items
|
||||
.iter()
|
||||
.any(|item| self.count(item) > 0)
|
||||
.then(|| {
|
||||
let mut names = self.open.iter().rev().map(|opened| opened.name.as_str());
|
||||
names.find(|name| items.contains(name) || stops(name))
|
||||
})
|
||||
.flatten()
|
||||
.filter(|name| items.contains(name))
|
||||
.map(str::to_string);
|
||||
if let Some(item) = found {
|
||||
self.end(&item);
|
||||
}
|
||||
if html::closes_p(name) && self.count("p") > 0 {
|
||||
self.line();
|
||||
self.end("p");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::documents::tree::{Inline, Node, Style, parse};
|
||||
|
||||
fn html(source: &str) -> Option<Inline> {
|
||||
match parse(source).into_iter().next() {
|
||||
Some(Node::Paragraph(line)) => Some(line),
|
||||
None => None,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text_as_a_browser_does() {
|
||||
let centered = html(
|
||||
"<div align=\"center\">\n <b>Bold</b> and\n :smile: & <code>:smile:</code>\n</div>",
|
||||
)
|
||||
.expect("text");
|
||||
assert_eq!(centered.text, "Bold and 😄 & :smile:");
|
||||
let (bold, code) = (0..4, 16..23);
|
||||
assert_eq!(
|
||||
centered.styles,
|
||||
[(bold, Style::Strong), (code, Style::Code)]
|
||||
);
|
||||
let parts = html("<p>one <b>two </b></p>\n<p>three <b></p>").expect("text");
|
||||
assert_eq!(parts.text, "one two\nthree <b>");
|
||||
assert_eq!(
|
||||
html("<div>\na <br> b</br>c</div>").expect("text").text,
|
||||
"a\nb\nc"
|
||||
);
|
||||
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
|
||||
assert_eq!(
|
||||
html(filtered).expect("text").text,
|
||||
"<script>x</script> shown<style>p{}</style>"
|
||||
);
|
||||
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
|
||||
assert_eq!(
|
||||
html(crossed).expect("text").text,
|
||||
"before<script>ab</script>after"
|
||||
);
|
||||
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
|
||||
let lines: Vec<_> = (split.into_iter())
|
||||
.map(|node| match node {
|
||||
Node::Paragraph(line) => line.text,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines, ["<script> first", "second\n</script>"]);
|
||||
let inline = html("a <script>b</script> c").expect("text");
|
||||
assert_eq!(inline.text, "a <script>b</script> c");
|
||||
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_block_ends_what_opened_inside_it() {
|
||||
let marked = html("<div><mark>x</div>y").expect("text");
|
||||
assert_eq!(
|
||||
(marked.text.as_str(), &marked.styles[..]),
|
||||
("x\ny", &[(0..1, Style::Mark)][..])
|
||||
);
|
||||
let bold = html("<div><b>x</div>y").expect("text");
|
||||
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
|
||||
let kept = html("<div><mark><p>x</p>y</mark></div>").expect("text");
|
||||
assert_eq!(kept.styles, [(0..3, Style::Mark)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_start_ends_what_html5_ends() {
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
for source in [
|
||||
"<ul><li><mark>x<li>y</ul>z",
|
||||
"<div><p><mark>x<div>y</div>z</div>",
|
||||
"<dl><dt><mark>x<dd>y</dl>z",
|
||||
] {
|
||||
let line = html(source).expect("text");
|
||||
assert_eq!(
|
||||
(line.text.as_str(), &line.styles[..]),
|
||||
("x\ny\nz", &mark[..])
|
||||
);
|
||||
}
|
||||
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
|
||||
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
|
||||
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").expect("text");
|
||||
assert_eq!(
|
||||
nested.styles,
|
||||
[(0..3, Style::Mark)],
|
||||
"a list stops the search"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_tags_are_text_and_names_end_as_html5_ends_them() {
|
||||
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||
for (source, shown) in [
|
||||
(
|
||||
"<div><script>:smile:</script> :smile:</div>",
|
||||
"<script>:smile:</script> :smile:",
|
||||
),
|
||||
(
|
||||
"<div><script><b>:smile:</b></script> :smile:</div>",
|
||||
"<script>😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script> :smile:</script> :smile:</div>",
|
||||
"<script> 😄</script> 😄",
|
||||
),
|
||||
(
|
||||
"<div><script title=\" :smile:\"> x:smile:</div>",
|
||||
"<script title=\" 😄\"> x😄",
|
||||
),
|
||||
(
|
||||
"<div><iframe title=\":smile:\"> :smile:</div>",
|
||||
"<iframe title=\":smile:\"> :smile:",
|
||||
),
|
||||
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
|
||||
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
|
||||
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
|
||||
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
|
||||
("<div>:smile:</div>", "😄"),
|
||||
] {
|
||||
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
|
||||
}
|
||||
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
|
||||
assert_eq!(html(named).expect("text").text, "axbyc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_html5_p_closer_ends_a_p() {
|
||||
for name in ["fieldset", "menu"] {
|
||||
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
|
||||
let mark = [(0..1, Style::Mark)];
|
||||
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_html_reads_in_linear_time() {
|
||||
let sources = |n: usize| {
|
||||
[
|
||||
"<".repeat(2 * n),
|
||||
"<a".repeat(n),
|
||||
"<!--".repeat(n),
|
||||
"<b>".repeat(n),
|
||||
"<b>".repeat(n / 2) + &"</i>".repeat(n / 2),
|
||||
"<script></script>".repeat(n / 8),
|
||||
"<div>a ".repeat(n),
|
||||
"<b> ".repeat(n),
|
||||
"<li><b>x ".repeat(n / 2),
|
||||
"<p><i>x ".repeat(n / 2),
|
||||
]
|
||||
};
|
||||
let time = |n| {
|
||||
let started = web_time::Instant::now();
|
||||
for source in sources(n) {
|
||||
parse(&format!(
|
||||
"<div>
|
||||
{source}"
|
||||
));
|
||||
}
|
||||
started.elapsed()
|
||||
};
|
||||
let (small, large) = (time(1 << 14), time(1 << 16));
|
||||
// Four times the input, at most eight the time.
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
use std::{collections::HashMap, ops::Range};
|
||||
|
||||
use markdown::mdast::{self, AlignKind};
|
||||
|
||||
use super::markdown::{span, tree, walk};
|
||||
|
||||
mod blocks;
|
||||
mod emoji;
|
||||
pub(crate) mod html;
|
||||
mod html_block;
|
||||
mod sources;
|
||||
mod stack;
|
||||
|
||||
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
|
||||
use html_block::html_block_text;
|
||||
pub use sources::Escapes;
|
||||
pub(crate) use sources::{inside, lines_at};
|
||||
use stack::close;
|
||||
|
||||
/// How a stretch of inline text prints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Style {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Math,
|
||||
Footnote,
|
||||
Underline,
|
||||
/// A highlighted run, as `<mark>`.
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// Inline text, its styled stretches and link addresses.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Inline {
|
||||
pub text: String,
|
||||
pub styles: Vec<(Range<usize>, Style)>,
|
||||
pub links: Vec<(Range<usize>, String)>,
|
||||
/// Where each piece was written: drawn start, source span.
|
||||
pub sources: Vec<(usize, Range<usize>, Escapes)>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Where its lines start in the source.
|
||||
from: usize,
|
||||
},
|
||||
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
|
||||
List {
|
||||
start: Option<u64>,
|
||||
items: Vec<(Option<bool>, Vec<Node>)>,
|
||||
},
|
||||
/// Each cell's blocks: its text, split at pictures.
|
||||
Table {
|
||||
aligns: Vec<AlignKind>,
|
||||
head: Vec<Vec<Node>>,
|
||||
rows: Vec<Vec<Vec<Node>>>,
|
||||
},
|
||||
Rule,
|
||||
Math(String),
|
||||
/// A picture, and the link around it.
|
||||
Image {
|
||||
alt: String,
|
||||
url: String,
|
||||
link: Option<String>,
|
||||
/// Its source start.
|
||||
at: usize,
|
||||
},
|
||||
Footnote {
|
||||
label: String,
|
||||
body: Vec<Node>,
|
||||
},
|
||||
/// A GitHub alert: a quote under its kind's title.
|
||||
Alert {
|
||||
kind: AlertKind,
|
||||
body: Vec<Node>,
|
||||
},
|
||||
/// `[toc]`: the file's headings.
|
||||
Toc(Vec<DocumentHeading>),
|
||||
/// Front matter, its fences cut; lines start at `from`.
|
||||
FrontMatter {
|
||||
text: String,
|
||||
from: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tree read against its source and its link definitions.
|
||||
pub struct Reader<'a> {
|
||||
source: &'a str,
|
||||
urls: HashMap<String, String>,
|
||||
/// Footnotes numbered by their first reference.
|
||||
notes: HashMap<String, usize>,
|
||||
/// Every heading, as `[toc]` lists them.
|
||||
pub headings: Vec<DocumentHeading>,
|
||||
/// Soft breaks as lines, as the screen draws them.
|
||||
lines: bool,
|
||||
}
|
||||
|
||||
/// An open element, as HTML names it.
|
||||
struct Opened {
|
||||
name: String,
|
||||
start: usize,
|
||||
style: Option<Style>,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
/// Inline text being built, and the containers open around it.
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
text: Inline,
|
||||
open: Vec<Opened>,
|
||||
/// How many of each name are open.
|
||||
named: HashMap<String, usize>,
|
||||
/// Inside an autolink: its text stays as written.
|
||||
verbatim: bool,
|
||||
/// In a table cell, whose code reads `\|` as `|`.
|
||||
table: bool,
|
||||
/// Soft breaks as lines, not spaces.
|
||||
lines: bool,
|
||||
/// The current DOM text node's shortcodes.
|
||||
shortcodes: emoji::Shortcodes,
|
||||
/// White space owed, and the open elements before it.
|
||||
owed: Option<(char, usize)>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Text so far; open styles close here, reopen at zero.
|
||||
fn cut(&mut self) -> Inline {
|
||||
let mut done = std::mem::take(&mut self.text);
|
||||
let end = done.text.len();
|
||||
for opened in self.open.iter_mut().rev() {
|
||||
close(&mut done, opened, end);
|
||||
opened.start = 0;
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
fn count(&self, name: &str) -> usize {
|
||||
self.named.get(name).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Text with its shortcodes read, outside autolinks and `<code>`.
|
||||
fn shown(&mut self, written: &str) -> String {
|
||||
match !self.verbatim && self.count("code") == 0 {
|
||||
true => self.shortcodes.read(written),
|
||||
false => written.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text as shown: shortcodes read, lines joined.
|
||||
fn push_text(&mut self, written: &str) {
|
||||
let shown = self.shown(written);
|
||||
self.push_plain(&shown);
|
||||
}
|
||||
|
||||
fn push_plain(&mut self, shown: &str) {
|
||||
let soft = if self.lines { "\n" } else { " " };
|
||||
let shown = shown.replace("\r\n", soft).replace(['\r', '\n'], soft);
|
||||
self.text.text.push_str(&shown);
|
||||
}
|
||||
}
|
||||
|
||||
/// `markdown` as blocks.
|
||||
pub fn parse(markdown: &str) -> Vec<Node> {
|
||||
let root = tree(markdown);
|
||||
let (reader, mut out) = (Reader::new(markdown, &root), Vec::new());
|
||||
for node in root.children().expect("the root holds the blocks") {
|
||||
reader.top(node, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// Reads `root`, parsed from `source`, as drawn on screen.
|
||||
pub fn new(source: &'a str, root: &mdast::Node) -> Self {
|
||||
Self::reading(source, root, true)
|
||||
}
|
||||
|
||||
/// The same, soft breaks joined as GFM output joins them.
|
||||
pub fn joined(source: &'a str, root: &mdast::Node) -> Self {
|
||||
Self::reading(source, root, false)
|
||||
}
|
||||
|
||||
fn reading(source: &'a str, root: &mdast::Node, lines: bool) -> Self {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
|
||||
for node in nodes {
|
||||
match node {
|
||||
mdast::Node::Definition(definition) => {
|
||||
urls.entry(definition.identifier.clone())
|
||||
.or_insert_with(|| definition.url.clone());
|
||||
}
|
||||
mdast::Node::FootnoteReference(note) => {
|
||||
let next = notes.len() + 1;
|
||||
notes.entry(note.identifier.clone()).or_insert(next);
|
||||
}
|
||||
mdast::Node::Heading(heading) => headings.push((heading, span(node).start)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut reader = Self {
|
||||
source,
|
||||
urls,
|
||||
notes,
|
||||
headings: Vec::new(),
|
||||
lines: false,
|
||||
};
|
||||
reader.headings = headings
|
||||
.into_iter()
|
||||
.map(|(heading, at)| DocumentHeading {
|
||||
depth: heading.depth,
|
||||
title: reader.inline(&heading.children).text,
|
||||
at,
|
||||
})
|
||||
.collect();
|
||||
// Titles read on one line; the body as asked.
|
||||
reader.lines = lines;
|
||||
reader
|
||||
}
|
||||
|
||||
fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec<Node>) {
|
||||
for node in nodes {
|
||||
self.block(node, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn nested(&self, nodes: &[mdast::Node]) -> Vec<Node> {
|
||||
let mut out = Vec::new();
|
||||
self.blocks(nodes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A reference's address; the parser checked its definition.
|
||||
fn url(&self, identifier: &str) -> String {
|
||||
self.urls
|
||||
.get(identifier)
|
||||
.cloned()
|
||||
.expect("a reference names a definition")
|
||||
}
|
||||
|
||||
/// `node`'s blocks, as the drawing reads them.
|
||||
pub fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||
match node {
|
||||
mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶graph.children, out),
|
||||
mdast::Node::Heading(heading) => {
|
||||
let (mut run, mut after) = (self.run(), Vec::new());
|
||||
for child in &heading.children {
|
||||
self.inline_into(child, &mut run, &mut Images::After(&mut after));
|
||||
}
|
||||
out.push(Node::Heading(heading.depth, run.cut()));
|
||||
out.extend(after);
|
||||
}
|
||||
mdast::Node::Blockquote(quote) => out.push(Node::Quote(self.nested("e.children))),
|
||||
mdast::Node::Code(code) => out.push(Node::Code {
|
||||
language: code.lang.clone(),
|
||||
text: code.value.clone(),
|
||||
from: sources::body(self.source, node),
|
||||
}),
|
||||
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
|
||||
mdast::Node::List(list) => {
|
||||
let items = list
|
||||
.children
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
mdast::Node::ListItem(item) => (item.checked, self.nested(&item.children)),
|
||||
other => unreachable!("a list holds items, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
let start = list.ordered.then(|| u64::from(list.start.unwrap_or(1)));
|
||||
out.push(Node::List { start, items });
|
||||
}
|
||||
mdast::Node::Table(table) => {
|
||||
let mut rows = table.children.iter().map(|row| match row {
|
||||
mdast::Node::TableRow(row) => row
|
||||
.children
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
let mut blocks = Vec::new();
|
||||
let inlines = cell.children().expect("a cell holds inlines");
|
||||
self.paragraph(cell, inlines, &mut blocks);
|
||||
blocks
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
other => unreachable!("a table holds rows, not {other:?}"),
|
||||
});
|
||||
let head = rows.next().expect("a table has its head row");
|
||||
out.push(Node::Table {
|
||||
aligns: table.align.clone(),
|
||||
head,
|
||||
rows: rows.collect(),
|
||||
});
|
||||
}
|
||||
mdast::Node::ThematicBreak(_) => out.push(Node::Rule),
|
||||
mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote {
|
||||
label: self.label(note),
|
||||
body: self.nested(¬e.children),
|
||||
}),
|
||||
mdast::Node::Html(html) => {
|
||||
text_block(html_block_text(&html.value, self.source, span(node)), out)
|
||||
}
|
||||
mdast::Node::Yaml(mdast::Yaml { value, .. })
|
||||
| mdast::Node::Toml(mdast::Toml { value, .. }) => out.push(Node::FrontMatter {
|
||||
text: value.clone(),
|
||||
from: sources::body(self.source, node),
|
||||
}),
|
||||
mdast::Node::Definition(_) => {}
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text splits at images; a lone formula is block.
|
||||
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
||||
if let [mdast::Node::InlineMath(math)] = children
|
||||
&& ["$$", "\\["]
|
||||
.iter()
|
||||
.any(|open| self.source[span(&children[0])].starts_with(open))
|
||||
{
|
||||
return out.push(Node::Math(math.value.trim().to_string()));
|
||||
}
|
||||
let mut run = Run {
|
||||
table: matches!(node, mdast::Node::TableCell(_)),
|
||||
..self.run()
|
||||
};
|
||||
for child in children {
|
||||
self.inline_into(child, &mut run, &mut Images::Split(out));
|
||||
}
|
||||
text_block(run.cut(), out);
|
||||
}
|
||||
|
||||
fn run(&self) -> Run {
|
||||
Run {
|
||||
lines: self.lines,
|
||||
..Run::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A footnote's number, by its first reference.
|
||||
pub fn note(&self, identifier: &str) -> Option<usize> {
|
||||
self.notes.get(identifier).copied()
|
||||
}
|
||||
|
||||
/// A note's label: its number, once referenced.
|
||||
pub fn label(&self, note: &mdast::FootnoteDefinition) -> String {
|
||||
match self.note(¬e.identifier) {
|
||||
Some(number) => number.to_string(),
|
||||
None => note
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| note.identifier.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline text where images draw as their alt text.
|
||||
pub fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
||||
let mut run = self.run();
|
||||
for node in nodes {
|
||||
self.inline_into(node, &mut run, &mut Images::Alt);
|
||||
}
|
||||
run.cut()
|
||||
}
|
||||
|
||||
fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
|
||||
// Any element ends the DOM text node.
|
||||
if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) {
|
||||
run.shortcodes.split();
|
||||
}
|
||||
if let Some((written, escapes)) = sources::written(node, self.source) {
|
||||
let escapes = match (escapes, node) {
|
||||
(Escapes::Markdown, _) if run.verbatim => Escapes::Plain,
|
||||
(_, mdast::Node::InlineCode(_)) if run.table => Escapes::Pipes,
|
||||
_ => escapes,
|
||||
};
|
||||
let at = run.text.text.len();
|
||||
run.text.sources.push((at, written, escapes));
|
||||
}
|
||||
let text = &mut run.text;
|
||||
let (children, (name, style), url) = match node {
|
||||
mdast::Node::Text(words) => return run.push_text(&words.value),
|
||||
mdast::Node::Break(_) => return text.text.push('\n'),
|
||||
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
|
||||
mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math),
|
||||
mdast::Node::FootnoteReference(note) => {
|
||||
let number = self.notes[¬e.identifier];
|
||||
return styled(text, &html::superscript(number), Style::Footnote);
|
||||
}
|
||||
mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
|
||||
let (alt, url) = match node {
|
||||
mdast::Node::Image(image) => (image.alt.clone(), image.url.clone()),
|
||||
mdast::Node::ImageReference(image) => {
|
||||
(image.alt.clone(), self.url(&image.identifier))
|
||||
}
|
||||
_ => unreachable!("matched as an image"),
|
||||
};
|
||||
let (link, at) = (run.link(), span(node).start);
|
||||
return match images {
|
||||
Images::Alt => run.text.text.push_str(&alt),
|
||||
Images::Split(out) => {
|
||||
text_block(run.cut(), out);
|
||||
out.push(Node::Image { alt, url, link, at })
|
||||
}
|
||||
Images::After(out) => out.push(Node::Image { alt, url, link, at }),
|
||||
};
|
||||
}
|
||||
mdast::Node::Html(written) => return run.html(&written.value),
|
||||
mdast::Node::Strong(strong) => (&strong.children, ("strong", Style::Strong), None),
|
||||
mdast::Node::Emphasis(emphasis) => (&emphasis.children, ("em", Style::Emphasis), None),
|
||||
mdast::Node::Delete(delete) => (&delete.children, ("del", Style::Strike), None),
|
||||
mdast::Node::Link(link) => (&link.children, ("a", Style::Link), Some(link.url.clone())),
|
||||
mdast::Node::LinkReference(link) => (
|
||||
&link.children,
|
||||
("a", Style::Link),
|
||||
Some(self.url(&link.identifier)),
|
||||
),
|
||||
other => unreachable!("{other:?} is no inline outside MDX"),
|
||||
};
|
||||
// A container is the element GitHub writes.
|
||||
run.start(name, Some(style), url);
|
||||
let autolink =
|
||||
matches!(node, mdast::Node::Link(_)) && !self.source[span(node)].starts_with('[');
|
||||
let outer = run.verbatim;
|
||||
run.verbatim |= autolink;
|
||||
for child in children {
|
||||
self.inline_into(child, run, images);
|
||||
}
|
||||
run.verbatim = outer;
|
||||
run.end(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an inline image goes.
|
||||
enum Images<'a> {
|
||||
/// Its alt text stays in the line.
|
||||
Alt,
|
||||
/// It splits the text into blocks.
|
||||
Split(&'a mut Vec<Node>),
|
||||
/// It follows the text, as a heading's.
|
||||
After(&'a mut Vec<Node>),
|
||||
}
|
||||
|
||||
/// Text left between images, unless it is blank.
|
||||
fn text_block(text: Inline, out: &mut Vec<Node>) {
|
||||
if !text.text.trim().is_empty() {
|
||||
out.push(Node::Paragraph(text));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,432 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::{decode_named, mdast::Node};
|
||||
|
||||
use super::{Inline, Node as Drawn, html, span};
|
||||
|
||||
/// How a piece's source turns into its text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Escapes {
|
||||
Plain,
|
||||
/// Backslash escapes and references, as Markdown reads them.
|
||||
Markdown,
|
||||
/// References, as HTML reads them.
|
||||
Html,
|
||||
/// Escaped pipes, as a table cell's code reads them.
|
||||
Pipes,
|
||||
}
|
||||
|
||||
/// A leaf's written span and how it reads.
|
||||
pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escapes)> {
|
||||
let whole = match node {
|
||||
Node::Text(_)
|
||||
| Node::Break(_)
|
||||
| Node::Html(_)
|
||||
| Node::FootnoteReference(_)
|
||||
| Node::Image(_)
|
||||
| Node::ImageReference(_)
|
||||
| Node::InlineCode(_)
|
||||
| Node::InlineMath(_) => span(node),
|
||||
_ => return None,
|
||||
};
|
||||
Some(match node {
|
||||
Node::Text(_) => (whole, Escapes::Markdown),
|
||||
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
||||
Node::InlineMath(_) => (
|
||||
crate::documents::markdown::math_inside(source, whole),
|
||||
Escapes::Plain,
|
||||
),
|
||||
_ => (whole, Escapes::Plain),
|
||||
})
|
||||
}
|
||||
|
||||
/// A code span's text, one end space cut each side.
|
||||
pub(crate) fn inside(source: &str, whole: Range<usize>, mark: char) -> Range<usize> {
|
||||
let written = &source[whole.clone()];
|
||||
let open = written.len() - written.trim_start_matches(mark).len();
|
||||
let close = written.len() - written.trim_end_matches(mark).len();
|
||||
let text = &written[open..written.len().saturating_sub(close).max(open)];
|
||||
let blank = |ch: char| matches!(ch, ' ' | '\n' | '\r');
|
||||
let cut = text.starts_with(blank) && text.ends_with(blank) && !text.chars().all(blank);
|
||||
let width = |crlf: bool| if crlf { 2 } else { 1 };
|
||||
let (head, tail) = match cut {
|
||||
true => (
|
||||
width(text.starts_with("\r\n")),
|
||||
width(text.ends_with("\r\n")),
|
||||
),
|
||||
false => (0, 0),
|
||||
};
|
||||
whole.start + open + head..whole.start + open + text.len() - tail
|
||||
}
|
||||
|
||||
/// Where a block's text lines start: past a fence line.
|
||||
pub(super) fn body(source: &str, node: &Node) -> usize {
|
||||
let whole = span(node);
|
||||
let written = &source[whole.clone()];
|
||||
let fenced = matches!(node, Node::Yaml(_) | Node::Toml(_))
|
||||
|| ["```", "~~~"]
|
||||
.iter()
|
||||
.any(|fence| written.starts_with(fence));
|
||||
match fenced {
|
||||
true => lines(written)
|
||||
.nth(1)
|
||||
.map_or(whole.end, |line| whole.start + line.start),
|
||||
false => whole.start,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text`'s lines without their endings, split as CommonMark splits.
|
||||
fn lines(text: &str) -> impl Iterator<Item = Range<usize>> + '_ {
|
||||
let mut next = Some(0);
|
||||
std::iter::from_fn(move || {
|
||||
let start = next?;
|
||||
let rest = &text[start..];
|
||||
let Some(end) = rest.find(['\n', '\r']) else {
|
||||
next = None;
|
||||
return Some(start..text.len());
|
||||
};
|
||||
let width = if rest[end..].starts_with("\r\n") {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
next = Some(start + end + width);
|
||||
Some(start..start + end)
|
||||
})
|
||||
}
|
||||
|
||||
/// Source byte under drawn byte `at`; lines start at `from`.
|
||||
pub(crate) fn lines_at(source: &str, from: usize, drawn: &str, at: usize) -> Option<usize> {
|
||||
LineMap::new(source, from, drawn)?.at(at)
|
||||
}
|
||||
|
||||
/// Each drawn line set against the written line it ends.
|
||||
pub(super) struct LineMap(Vec<Line>);
|
||||
|
||||
struct Line {
|
||||
drawn: Range<usize>,
|
||||
/// Where its written line starts, and its shared end.
|
||||
written: usize,
|
||||
common: usize,
|
||||
kept: usize,
|
||||
/// The char before the shared end, as a cut tab.
|
||||
before: usize,
|
||||
}
|
||||
|
||||
impl LineMap {
|
||||
pub(super) fn new(source: &str, from: usize, drawn: &str) -> Option<Self> {
|
||||
let written = source.get(from..)?;
|
||||
let lines = lines(drawn).zip(lines(written)).map(|(line, place)| {
|
||||
let (shown, text) = (&drawn[line.clone()], &written[place.clone()]);
|
||||
let kept: usize = match text.ends_with(shown) {
|
||||
true => shown.len(),
|
||||
false => (shown.chars().rev())
|
||||
.zip(text.chars().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(a, _)| a.len_utf8())
|
||||
.sum(),
|
||||
};
|
||||
let common = text.len() - kept;
|
||||
let before =
|
||||
(text[..common].chars().next_back()).map_or(0, |ch| common - ch.len_utf8());
|
||||
Line {
|
||||
drawn: line,
|
||||
written: from + place.start,
|
||||
common,
|
||||
kept,
|
||||
before,
|
||||
}
|
||||
});
|
||||
Some(Self(lines.collect()))
|
||||
}
|
||||
|
||||
/// Source byte under drawn byte `at`.
|
||||
pub(super) fn at(&self, at: usize) -> Option<usize> {
|
||||
let line = self
|
||||
.0
|
||||
.get(self.0.partition_point(|line| line.drawn.end < at))?;
|
||||
let column = at.saturating_sub(line.drawn.start);
|
||||
let shared = line.drawn.len() - line.kept;
|
||||
Some(
|
||||
line.written
|
||||
+ match column.checked_sub(shared) {
|
||||
Some(into) => line.common + into,
|
||||
None => line.before,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawn {
|
||||
/// Its source bytes counted from `start`, its block's.
|
||||
pub(crate) fn rebase(&mut self, start: usize) {
|
||||
match self {
|
||||
Drawn::Paragraph(inline) | Drawn::Heading(_, inline) => {
|
||||
for (_, span, _) in &mut inline.sources {
|
||||
*span = span.start - start..span.end - start;
|
||||
}
|
||||
}
|
||||
Drawn::Quote(body) | Drawn::Alert { body, .. } | Drawn::Footnote { body, .. } => {
|
||||
body.iter_mut().for_each(|node| node.rebase(start))
|
||||
}
|
||||
Drawn::List { items, .. } => items
|
||||
.iter_mut()
|
||||
.flat_map(|(_, body)| body)
|
||||
.for_each(|node| node.rebase(start)),
|
||||
Drawn::Table { head, rows, .. } => head
|
||||
.iter_mut()
|
||||
.chain(rows.iter_mut().flatten())
|
||||
.flatten()
|
||||
.for_each(|node| node.rebase(start)),
|
||||
Drawn::Code { from, .. } | Drawn::FrontMatter { from, .. } => *from -= start,
|
||||
Drawn::Image { at, .. } => *at -= start,
|
||||
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Inline {
|
||||
/// Source byte under drawn byte `at`, in some piece.
|
||||
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||
let piece = self.sources.partition_point(|(start, ..)| *start <= at);
|
||||
let (start, span, escapes) = self.sources.get(piece.checked_sub(1)?)?;
|
||||
let end = self
|
||||
.sources
|
||||
.get(piece)
|
||||
.map_or(self.text.len(), |(next, ..)| *next);
|
||||
let drawn = self.text.get(*start..end.max(*start))?;
|
||||
let written = source.get(span.clone())?;
|
||||
// Unreadable text lands at its start.
|
||||
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The last drawn byte written by source byte `at`.
|
||||
pub(crate) fn drawn_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||
(0..self.sources.len()).rev().find_map(|piece| {
|
||||
let (start, span, escapes) = &self.sources[piece];
|
||||
if span.start > at {
|
||||
return None;
|
||||
}
|
||||
let end = (self.sources.get(piece + 1)).map_or(self.text.len(), |(next, ..)| *next);
|
||||
let drawn = self.text.get(*start..end.max(*start))?;
|
||||
let written = source.get(span.clone())?;
|
||||
// Unreadable text lands at its start.
|
||||
let mut places = aligned(drawn, written, *escapes).into_iter();
|
||||
(drawn.char_indices().rev())
|
||||
.map(|(byte, _)| (byte, places.next().map_or(0, |(_, place)| place)))
|
||||
.find(|(_, place)| span.start + place <= at)
|
||||
.map(|(byte, _)| start + byte)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A source char as read, and where it was written.
|
||||
struct Read {
|
||||
ch: char,
|
||||
at: usize,
|
||||
/// Written as itself, not escaped or referenced.
|
||||
raw: bool,
|
||||
/// A container mark or indent at a line's start.
|
||||
lead: bool,
|
||||
}
|
||||
|
||||
impl Read {
|
||||
fn ends_line(&self) -> bool {
|
||||
matches!(self.ch, '\n' | '\r')
|
||||
}
|
||||
}
|
||||
|
||||
/// `written`'s chars, escapes and references read.
|
||||
fn read(written: &str, escapes: Escapes) -> Vec<Read> {
|
||||
let mut out = Vec::with_capacity(written.len());
|
||||
let (mut at, mut line_start) = (0, false);
|
||||
while let Some(ch) = written[at..].chars().next() {
|
||||
let rest = &written[at..];
|
||||
let decoded = match (escapes, ch) {
|
||||
(Escapes::Markdown, '\\') => (rest[1..].chars().next())
|
||||
.filter(char::is_ascii_punctuation)
|
||||
.map(|punct| (punct.to_string(), 2, at + 1)),
|
||||
(Escapes::Markdown, '&') => reference(rest).map(|(value, used)| (value, used, at)),
|
||||
(Escapes::Html, '&') => {
|
||||
html::reference(rest, false).map(|(value, used)| (value, used, at))
|
||||
}
|
||||
(Escapes::Pipes, '\\') => {
|
||||
(rest[1..].starts_with('|')).then(|| ("|".to_string(), 2, at + 1))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((value, used, place)) = decoded {
|
||||
out.extend(value.chars().map(|ch| Read {
|
||||
ch,
|
||||
at: place,
|
||||
raw: false,
|
||||
lead: false,
|
||||
}));
|
||||
(at, line_start) = (at + used, false);
|
||||
continue;
|
||||
}
|
||||
let ends = matches!(ch, '\n' | '\r');
|
||||
let lead = line_start && !ends && matches!(ch, ' ' | '\t' | '>');
|
||||
out.push(Read {
|
||||
ch,
|
||||
at,
|
||||
raw: true,
|
||||
lead,
|
||||
});
|
||||
(at, line_start) = (at + ch.len_utf8(), ends || lead);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A reference at `text`'s start, as Markdown reads it.
|
||||
fn reference(text: &str) -> Option<(String, usize)> {
|
||||
let body = text.strip_prefix('&')?;
|
||||
let (end, _) = body.char_indices().take(34).find(|(_, ch)| *ch == ';')?;
|
||||
let name = &body[..end];
|
||||
let code = |digits: &str, radix: u32, most: usize| {
|
||||
let fits =
|
||||
(1..=most).contains(&digits.len()) && digits.chars().all(|ch| ch.is_digit(radix));
|
||||
let code = u32::from_str_radix(digits, radix).ok().filter(|_| fits)?;
|
||||
// Controls become U+FFFD, as markdown-rs reads them.
|
||||
let ch = char::from_u32(code).filter(|ch| {
|
||||
!matches!(ch, '\0'..='\u{08}' | '\u{0B}' | '\u{0E}'..='\u{1F}' | '\u{7F}'..='\u{9F}')
|
||||
});
|
||||
Some(ch.unwrap_or(char::REPLACEMENT_CHARACTER).to_string())
|
||||
};
|
||||
let value = match name.strip_prefix('#') {
|
||||
Some(hex) if hex.starts_with(['x', 'X']) => code(&hex[1..], 16, 6)?,
|
||||
Some(digits) => code(digits, 10, 7)?,
|
||||
None if name.chars().all(|ch| ch.is_ascii_alphanumeric()) => decode_named(name, true)?,
|
||||
None => return None,
|
||||
};
|
||||
Some((value, end + 2))
|
||||
}
|
||||
|
||||
/// Where drawn byte `at` was written, matched from the end.
|
||||
fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usize> {
|
||||
if at >= drawn.len() {
|
||||
return Some(written.len());
|
||||
}
|
||||
(aligned(drawn, written, escapes).into_iter())
|
||||
.find(|(byte, _)| *byte == at)
|
||||
.map(|(_, place)| place)
|
||||
}
|
||||
|
||||
/// Drawn chars from the end and where each was written.
|
||||
fn aligned(drawn: &str, written: &str, escapes: Escapes) -> Vec<(usize, usize)> {
|
||||
let source = read(written, escapes);
|
||||
let mut j = source.len();
|
||||
let mut out = Vec::new();
|
||||
for (byte, d) in drawn.char_indices().rev() {
|
||||
let found = loop {
|
||||
let Some(s) = source[..j].last() else {
|
||||
return out;
|
||||
};
|
||||
if d == s.ch || (d.is_whitespace() && s.ends_line()) {
|
||||
j -= 1;
|
||||
// A CRLF drawn as one char is one end.
|
||||
let collapsed = !drawn[..byte].ends_with('\r');
|
||||
if collapsed
|
||||
&& s.ch == '\n'
|
||||
&& j > 0
|
||||
&& source[j - 1].ch == '\r'
|
||||
&& source[j - 1].raw == s.raw
|
||||
{
|
||||
j -= 1;
|
||||
}
|
||||
break source[j].at;
|
||||
}
|
||||
// White space, line ends and marks the text dropped.
|
||||
if s.lead || matches!(s.ch, ' ' | '\t') || s.ends_line() {
|
||||
j -= 1;
|
||||
continue;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
out.push((byte, found));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Inline, Reader, tree};
|
||||
|
||||
/// The first paragraph, as drawn.
|
||||
fn first(source: &str) -> Inline {
|
||||
let root = tree(source);
|
||||
let reader = Reader::new(source, &root);
|
||||
let paragraph = &root.children().expect("blocks")[0];
|
||||
reader.inline(paragraph.children().expect("inlines"))
|
||||
}
|
||||
|
||||
/// The source byte under drawn text in the first paragraph.
|
||||
fn under(source: &str, drawn: &str) -> usize {
|
||||
let inline = first(source);
|
||||
let at = inline.text.find(drawn).expect("drawn");
|
||||
inline.source_at(source, at).expect("a piece")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_byte_reads_back_to_its_drawn_byte() {
|
||||
let source = "Say **bold** and \\*x";
|
||||
let inline = first(source);
|
||||
let bold = source.find("bold").unwrap();
|
||||
assert_eq!(inline.drawn_at(source, bold), inline.text.find("bold"));
|
||||
let mark = source.find("**").unwrap();
|
||||
assert_eq!(inline.drawn_at(source, mark), inline.text.find(' '));
|
||||
let star = source.find("*x").unwrap();
|
||||
assert_eq!(inline.drawn_at(source, star), inline.text.find('*'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_paragraph_reads_back_in_one_pass() {
|
||||
let source = "x".repeat(40_000);
|
||||
let inline = first(&source);
|
||||
let started = web_time::Instant::now();
|
||||
assert_eq!(inline.drawn_at(&source, 5), Some(5));
|
||||
let took = started.elapsed();
|
||||
assert!(took.as_millis() < 500, "took {took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_lands_where_it_was_written() {
|
||||
let source = "Say **bold** and `code` here\nnext line";
|
||||
assert_eq!(under(source, "bold"), source.find("bold").unwrap());
|
||||
assert_eq!(under(source, "code"), source.find("code").unwrap());
|
||||
assert_eq!(under(source, "here"), source.find("here").unwrap());
|
||||
assert_eq!(under(source, "next"), source.find("next").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_crlf_reads_back_to_its_start() {
|
||||
let source = "one\r\ntwo";
|
||||
let inline = first(source);
|
||||
let end = inline.text.find('\n').expect("a drawn line end");
|
||||
assert_eq!(inline.source_at(source, end), Some(3));
|
||||
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_crlf_reads_back_char_for_char() {
|
||||
let source = "`one\r\ntwo`\n";
|
||||
assert_eq!(under(source, "ne"), source.find("ne").unwrap());
|
||||
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_escapes_and_indents_read_back() {
|
||||
let source = "a \\*star\\* x\n y";
|
||||
assert_eq!(under(source, "x"), source.find('x').unwrap());
|
||||
assert_eq!(under(source, "y"), source.find('y').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_read_back_and_emoji_end_the_reading() {
|
||||
let source = "a & b";
|
||||
assert_eq!(under(source, "b"), source.find('b').unwrap());
|
||||
let source = "ab :smile: y";
|
||||
assert_eq!(under(source, "y"), source.find('y').unwrap());
|
||||
assert_eq!(under(source, "b"), 0, "before the emoji: its start");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use super::{Inline, Opened, Run, Style, html, html::InlineTag};
|
||||
|
||||
/// The elements open around inline text.
|
||||
impl Run {
|
||||
pub(super) fn push(&mut self, opened: Opened) {
|
||||
*self.named.entry(opened.name.clone()).or_default() += 1;
|
||||
self.open.push(opened);
|
||||
}
|
||||
|
||||
/// The innermost open link's address.
|
||||
pub(super) fn link(&self) -> Option<String> {
|
||||
self.open.iter().rev().find_map(|opened| opened.url.clone())
|
||||
}
|
||||
|
||||
/// Opens or closes an element for an inline HTML tag.
|
||||
pub(super) fn html(&mut self, written: &str) {
|
||||
match html::inline_tag(written) {
|
||||
InlineTag::Text(shown) => self.push_text(&shown),
|
||||
InlineTag::Open { name, style, href } => self.start(&name, style, href),
|
||||
InlineTag::Close(name) => {
|
||||
self.shortcodes.split();
|
||||
self.end(&name);
|
||||
}
|
||||
InlineTag::Break => {
|
||||
self.shortcodes.split();
|
||||
self.text.text.push('\n');
|
||||
}
|
||||
InlineTag::Gone => self.shortcodes.split(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `name`; a new `a` ends the open one.
|
||||
pub(super) fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
|
||||
if name == "a" {
|
||||
self.end("a");
|
||||
}
|
||||
self.shortcodes.split();
|
||||
self.push(Opened {
|
||||
name: name.to_string(),
|
||||
start: self.text.text.len(),
|
||||
style,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends the innermost `name`, as HTML5 repairs nesting.
|
||||
pub(super) fn end(&mut self, name: &str) {
|
||||
if self.count(name) == 0 {
|
||||
return;
|
||||
}
|
||||
self.shortcodes.split();
|
||||
let at = self
|
||||
.open
|
||||
.iter()
|
||||
.rposition(|opened| opened.name == name)
|
||||
.expect("a counted name is open");
|
||||
let end = self.text.text.len();
|
||||
if let Some((_, from)) = &mut self.owed {
|
||||
*from = (*from).min(at);
|
||||
}
|
||||
let closed = self.open.split_off(at);
|
||||
for opened in &closed {
|
||||
close(&mut self.text, opened, end);
|
||||
*self.named.get_mut(&opened.name).expect("counted") -= 1;
|
||||
}
|
||||
for opened in closed.into_iter().skip(1) {
|
||||
if html::reopens(&opened.name) {
|
||||
self.push(Opened {
|
||||
start: end,
|
||||
..opened
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `opened` as styled up to `end`.
|
||||
pub(super) fn close(text: &mut Inline, opened: &Opened, end: usize) {
|
||||
if opened.start >= end {
|
||||
return;
|
||||
}
|
||||
if let Some(style) = opened.style {
|
||||
text.styles.push((opened.start..end, style));
|
||||
}
|
||||
if let Some(url) = &opened.url {
|
||||
text.links.push((opened.start..end, url.clone()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
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(),
|
||||
from: 95
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes[5], Node::Rule);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_inside_an_image_is_the_outer_one() {
|
||||
// markdown-rs drops the inner image from the alt.
|
||||
assert_eq!(
|
||||
parse("](outer.png)"),
|
||||
[Node::Image {
|
||||
alt: "outer ".into(),
|
||||
url: "outer.png".into(),
|
||||
link: None,
|
||||
at: 0
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_splits_its_paragraph_and_references_resolve() {
|
||||
let nodes = parse("Before ![a][pic] after [site].\n\n[pic]: a.png\n[site]: https://ely.dev\n");
|
||||
assert!(matches!(&nodes[0], Node::Paragraph(text) if text.text == "Before "));
|
||||
assert_eq!(
|
||||
nodes[1],
|
||||
Node::Image {
|
||||
alt: "a".into(),
|
||||
url: "a.png".into(),
|
||||
link: None,
|
||||
at: 7
|
||||
}
|
||||
);
|
||||
let Node::Paragraph(after) = &nodes[2] else {
|
||||
panic!("a paragraph, not {:?}", nodes[2])
|
||||
};
|
||||
assert_eq!(after.text, " after site.");
|
||||
assert_eq!(after.links, [(7..11, "https://ely.dev".to_string())]);
|
||||
assert_eq!(nodes.len(), 3, "definitions draw nothing: {nodes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_break_draws_as_a_line_and_joins_for_output() {
|
||||
for source in [
|
||||
"one\ntwo \nthree",
|
||||
"one\r\ntwo \r\nthree",
|
||||
"one\rtwo \rthree",
|
||||
] {
|
||||
let nodes = parse(source);
|
||||
assert!(
|
||||
matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"),
|
||||
"{source:?} draws {nodes:?}"
|
||||
);
|
||||
let root = super::super::markdown::tree(source);
|
||||
let paragraph = &root.children().expect("blocks")[0];
|
||||
let joined = Reader::joined(source, &root).inline(paragraph.children().expect("inlines"));
|
||||
assert_eq!(joined.text, "one two\nthree", "{source:?} joins");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_title_reads_on_one_line() {
|
||||
let source = "one\ntwo\n===\n";
|
||||
let root = super::super::markdown::tree(source);
|
||||
assert_eq!(Reader::new(source, &root).headings[0].title, "one two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definitions_nested_in_quotes_and_lists_resolve() {
|
||||
for source in [
|
||||
"[site] ![pic]\n\n> [site]: https://ely.dev\n> [pic]: a.png\n",
|
||||
"[site] ![pic]\n\n- [site]: https://ely.dev\n- [pic]: a.png\n",
|
||||
] {
|
||||
let nodes = parse(source);
|
||||
let Node::Paragraph(text) = &nodes[0] else {
|
||||
panic!("a paragraph, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(text.links, [(0..4, "https://ely.dev".to_string())]);
|
||||
assert_eq!(
|
||||
nodes[1],
|
||||
Node::Image {
|
||||
alt: "pic".into(),
|
||||
url: "a.png".into(),
|
||||
link: None,
|
||||
at: 7
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_inside_styles_and_links_stays_an_image() {
|
||||
let nodes = parse("*a  b* [](https://ely.dev)");
|
||||
let Node::Paragraph(before) = &nodes[0] else {
|
||||
panic!("a paragraph, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(
|
||||
(before.text.as_str(), &before.styles[..]),
|
||||
("a ", &[(0..2, Style::Emphasis)][..])
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[1],
|
||||
Node::Image {
|
||||
alt: "x".into(),
|
||||
url: "x.png".into(),
|
||||
link: None,
|
||||
at: 3
|
||||
}
|
||||
);
|
||||
let Node::Paragraph(after) = &nodes[2] else {
|
||||
panic!("a paragraph, not {:?}", nodes[2])
|
||||
};
|
||||
assert_eq!(
|
||||
(after.text.as_str(), &after.styles[..]),
|
||||
(" b ", &[(0..2, Style::Emphasis)][..])
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[3],
|
||||
Node::Image {
|
||||
alt: "y".into(),
|
||||
url: "y.png".into(),
|
||||
link: Some("https://ely.dev".into()),
|
||||
at: 19
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes.len(), 4, "{nodes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_keeps_its_images_after_it() {
|
||||
let nodes = parse("# Lift ");
|
||||
assert!(matches!(&nodes[0], Node::Heading(1, text) if text.text == "Lift "));
|
||||
assert!(matches!(&nodes[1], Node::Image { url, .. } if url == "x.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_math_stands_alone() {
|
||||
assert_eq!(parse("$$c = 1$$"), [Node::Math("c = 1".into())]);
|
||||
assert_eq!(parse("$$\nc = 1\n$$"), [Node::Math("c = 1".into())]);
|
||||
let Node::Paragraph(inline) = &parse("so $c$ holds")[0] else {
|
||||
panic!("inline math stays in its paragraph")
|
||||
};
|
||||
assert_eq!(inline.styles, [(3..4, Style::Math)]);
|
||||
}
|
||||
|
||||
#[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, &[AlignKind::Left, AlignKind::Right]);
|
||||
let text = |cell: &[Node]| match cell {
|
||||
[Node::Paragraph(line)] => line.text.clone(),
|
||||
other => panic!("one line, not {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
head.iter().map(|cell| text(cell)).collect::<Vec<_>>(),
|
||||
["Name", "Lift"]
|
||||
);
|
||||
assert_eq!(text(&rows[0][1]), "0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_in_a_cell_stays_a_picture() {
|
||||
let nodes = parse("| Picture |\n| --- |\n|  |\n");
|
||||
let Node::Table { rows, .. } = &nodes[0] else {
|
||||
panic!("a table, not {:?}", nodes[0])
|
||||
};
|
||||
assert!(
|
||||
matches!(&rows[0][0][..], [Node::Image { alt, url, .. }] if alt == "shot" && url == "assets/shot.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_number_their_references_and_hold_their_body() {
|
||||
let nodes = parse("Lift[^a].\n\n[^a]: Toward white.\n");
|
||||
let Node::Paragraph(line) = &nodes[0] else {
|
||||
panic!("a paragraph, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(line.text, "Lift¹.");
|
||||
assert_eq!(line.styles, [(4..6, Style::Footnote)]);
|
||||
assert!(matches!(&nodes[1], Node::Footnote { label, body } if label == "1" && body.len() == 1));
|
||||
let alone = parse(
|
||||
"[^z]: Unread.
|
||||
",
|
||||
);
|
||||
assert!(matches!(&alone[0], Node::Footnote { label, .. } if label == "z"));
|
||||
}
|
||||
|
||||
fn line(source: &str) -> Inline {
|
||||
match parse(source).into_iter().next() {
|
||||
Some(Node::Paragraph(text)) => text,
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_html_shows_as_typora_shows_it() {
|
||||
let text = line(
|
||||
"a <u>un</u> <b>bo</b> <kbd>K</kbd> <mark>hi</mark> <a href=\"https://ely.dev\">x</a><br>end <span>sp</span> <a name=\"n\">no</a>",
|
||||
);
|
||||
assert_eq!(text.text, "a un bo K hi x\nend sp no");
|
||||
assert_eq!(
|
||||
text.styles,
|
||||
[
|
||||
(2..4, Style::Underline),
|
||||
(5..7, Style::Strong),
|
||||
(8..9, Style::Code),
|
||||
(10..12, Style::Mark),
|
||||
(13..14, Style::Link)
|
||||
]
|
||||
);
|
||||
assert_eq!(text.links, [(13..14, "https://ely.dev".to_string())]);
|
||||
}
|
||||
|
||||
/// Where `style` holds, touching stretches joined.
|
||||
fn marked(inline: &Inline, style: Style) -> Vec<(usize, usize)> {
|
||||
let mut ranges: Vec<Range<usize>> = inline
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|(_, held)| *held == style)
|
||||
.map(|(range, _)| range.clone())
|
||||
.collect();
|
||||
ranges.sort_by_key(|range| range.start);
|
||||
let mut out: Vec<Range<usize>> = Vec::new();
|
||||
for range in ranges {
|
||||
match out.last_mut() {
|
||||
Some(last) if last.end >= range.start => last.end = last.end.max(range.end),
|
||||
_ => out.push(range),
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|range| (range.start, range.end))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_and_emphasis_cross_as_a_browser_repairs_them() {
|
||||
let opened_inside = line("**a <u>b** c</u> d");
|
||||
assert_eq!(opened_inside.text, "a b c d");
|
||||
assert_eq!(marked(&opened_inside, Style::Strong), [(0, 3)]);
|
||||
assert_eq!(marked(&opened_inside, Style::Underline), [(2, 5)]);
|
||||
let closed_inside = line("a <u>*text</u> outside* end");
|
||||
assert_eq!(closed_inside.text, "a text outside end");
|
||||
assert_eq!(marked(&closed_inside, Style::Underline), [(2, 6)]);
|
||||
assert_eq!(marked(&closed_inside, Style::Emphasis), [(2, 14)]);
|
||||
assert!(line("a </u> b").styles.is_empty(), "a stray close");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_close_ends_the_markdown_element() {
|
||||
let em = line("a *inside</em> outside* end");
|
||||
assert_eq!(em.text, "a inside outside end");
|
||||
assert_eq!(marked(&em, Style::Emphasis), [(2, 8)]);
|
||||
let strong = line("a **inside</strong> outside** end");
|
||||
assert_eq!(marked(&strong, Style::Strong), [(2, 8)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_formatting_elements_reopen_past_a_close() {
|
||||
for (tag, style) in [
|
||||
("mark", Style::Mark),
|
||||
("kbd", Style::Code),
|
||||
("del", Style::Strike),
|
||||
] {
|
||||
let text = line(&format!("a **bold <{tag}>x** outside</{tag}> end"));
|
||||
assert_eq!(text.text, "a bold x outside end", "{tag}");
|
||||
assert_eq!(marked(&text, style), [(7, 8)], "{tag}");
|
||||
}
|
||||
let reopened = line("<b>1<i>2</b>3</i>4");
|
||||
assert_eq!(marked(&reopened, Style::Strong), [(0, 2)]);
|
||||
assert_eq!(marked(&reopened, Style::Emphasis), [(1, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_anchor_ends_the_open_one() {
|
||||
let text = line("[one <a href='/inner'>two</a> three](/outer)");
|
||||
assert_eq!(text.text, "one two three");
|
||||
assert_eq!(
|
||||
text.links,
|
||||
[(0..4, "/outer".to_string()), (4..7, "/inner".to_string())]
|
||||
);
|
||||
assert_eq!(marked(&text, Style::Link), [(0, 7)]);
|
||||
let named = line("[x <a name=n>y</a> z](/u)");
|
||||
assert_eq!(named.links, [(0..2, "/u".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closing_br_breaks_as_html_reads_it() {
|
||||
assert_eq!(line("before<br></br>after").text, "before\n\nafter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_mean_what_github_means() {
|
||||
assert_eq!(line(":cricket: :cricket_game:").text, "🦗 🏏");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_draw_as_emoji_outside_code() {
|
||||
let text = line("go :rocket: `:rocket:`");
|
||||
assert_eq!(text.text, "go 🚀 :rocket:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_number_by_their_first_reference() {
|
||||
let nodes = parse("x[^b] y[^a] z[^b]\n\n[^a]: A\n\n[^b]: B\n");
|
||||
let Node::Paragraph(text) = &nodes[0] else {
|
||||
panic!("a paragraph, not {:?}", nodes[0])
|
||||
};
|
||||
assert_eq!(text.text, "x¹ y² z¹");
|
||||
let labels: Vec<&str> = nodes[1..]
|
||||
.iter()
|
||||
.map(|node| match node {
|
||||
Node::Footnote { label, .. } => label.as_str(),
|
||||
other => panic!("a footnote, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels, ["2", "1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tilde_still_strikes() {
|
||||
assert_eq!(line("H~2~O").styles, [(1..2, Style::Strike)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_read_as_github_reads_them() {
|
||||
let decoded = line(r"Show \:smile: and :smile: and :smile:.");
|
||||
assert_eq!(decoded.text, "Show 😄 and 😄 and 😄.");
|
||||
let tags = line("a <code>:smile:</code> <kbd>:smile:</kbd> <tt>:smile:</tt> :smile:");
|
||||
assert_eq!(tags.text, "a :smile: 😄 😄 😄");
|
||||
assert_eq!(
|
||||
line("<https://x.test/:smile:>").text,
|
||||
"https://x.test/:smile:"
|
||||
);
|
||||
assert_eq!(line("[go :smile:](https://x.test)").text, "go 😄");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tags_show_text_and_only_anchors_link() {
|
||||
let custom = line("a <u-box>word</u-box> <br-box>in</br-box> end");
|
||||
assert_eq!(
|
||||
(custom.text.as_str(), custom.styles.len()),
|
||||
("a word in end", 0)
|
||||
);
|
||||
let bold = line("See <b href=\"https://x.test/\">bold</b>.");
|
||||
assert_eq!(bold.styles, [(4..8, Style::Strong)]);
|
||||
assert!(bold.links.is_empty(), "{bold:?}");
|
||||
let query = line("<a href=\"https://x.test/?q=1&p=2\">r</a> <a href=/path/>s</a>");
|
||||
let urls: Vec<&str> = query.links.iter().map(|(_, url)| url.as_str()).collect();
|
||||
assert_eq!(urls, ["https://x.test/?q=1&p=2", "/path/"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_shortcode_decides_its_text_node() {
|
||||
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||
for (source, shown) in [
|
||||
("a>:smile: :smile:", "a>:smile: :smile:"),
|
||||
("a> :smile: :smile:", "a> 😄 😄"),
|
||||
("x:smile: :smile:", "x:smile: :smile:"),
|
||||
("a;:smile: :smile:", "a;:smile: :smile:"),
|
||||
("a :smile: b>:smile:", "a 😄 b>😄"),
|
||||
("a :zzzq: x:smile: :smile:", "a :zzzq: x😄 😄"),
|
||||
("ax:zzzq: :smile:", "ax:zzzq: :smile:"),
|
||||
("a :x-y: z:smile:", "a :x-y: z😄"),
|
||||
("a :x.y: z:smile:", "a :x.y: z:smile:"),
|
||||
("a.:x.y: :smile:", "a.:x.y: 😄"),
|
||||
("x\t:smile: y:smile:", "x\t:smile: y:smile:"),
|
||||
("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"),
|
||||
("x\n:smile: y:smile:", "x\n:smile: y:smile:"),
|
||||
("*x*:smile: :smile:", "x😄 😄"),
|
||||
("*x*\n:smile: y:smile:", "x\n😄 y😄"),
|
||||
("x:smile: `c` :smile:", "x:smile: c 😄"),
|
||||
("x:smile:<!-- c --> :smile:", "x:smile: 😄"),
|
||||
("x:smile:<span>s</span> :smile:", "x:smile:s 😄"),
|
||||
("x&:smile: :smile:", "x&:smile: :smile:"),
|
||||
("x:smile: \n:smile:", "x:smile:\n😄"),
|
||||
(
|
||||
"a <script>x</style>:smile:</script> :smile:",
|
||||
"a <script>x</style>:smile:</script> :smile:",
|
||||
),
|
||||
(
|
||||
"a <script><b>:smile:</b></script> :smile:",
|
||||
"a <script>😄</script> 😄",
|
||||
),
|
||||
] {
|
||||
assert_eq!(line(source).text, shown, "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_delimiters_read_as_math() {
|
||||
assert_eq!(parse(r"\[c = 1\]"), [Node::Math("c = 1".into())]);
|
||||
assert_eq!(parse("\\[\nc = 1\n\\]"), [Node::Math("c = 1".into())]);
|
||||
for indent in [" ", " ", " "] {
|
||||
let source = format!(r"{indent}\[c = 1\]");
|
||||
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||
let source = format!("{indent}$$c = 1$$");
|
||||
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||
}
|
||||
let Node::Paragraph(inline) = &parse(r"so \(c\) holds")[0] else {
|
||||
panic!("inline math stays in its paragraph")
|
||||
};
|
||||
assert_eq!(inline.text, "so c holds");
|
||||
assert_eq!(inline.styles, [(3..4, Style::Math)]);
|
||||
let Node::Paragraph(alone) = &parse(r"\(c\)")[0] else {
|
||||
panic!("a lone formula stays inline")
|
||||
};
|
||||
assert_eq!(alone.styles, [(0..1, Style::Math)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_delimiters_leave_code_escapes_and_open_pairs() {
|
||||
let text = |source: &str| match &parse(source)[0] {
|
||||
Node::Paragraph(inline) => (inline.text.clone(), inline.styles.clone()),
|
||||
other => panic!("a paragraph, not {other:?}"),
|
||||
};
|
||||
assert_eq!(text(r"`\(c\)`").1, [(0..5, Style::Code)], "code stays code");
|
||||
assert_eq!(
|
||||
text(r"\\(c\\)"),
|
||||
(r"\(c\)".into(), Vec::new()),
|
||||
"escaped backslashes"
|
||||
);
|
||||
assert_eq!(text(r"a \(c").0, "a (c", "no closing, no formula");
|
||||
let split = parse("a \\(c\n\nd\\) e");
|
||||
assert!(
|
||||
split.iter().all(|node| match node {
|
||||
Node::Paragraph(inline) => inline.styles.is_empty(),
|
||||
_ => false,
|
||||
}),
|
||||
"a blank line ends the formula: {split:?}"
|
||||
);
|
||||
}
|
||||
+50
-2
@@ -1,6 +1,6 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::state::Marks;
|
||||
use super::state::{CodeEditor, Marks};
|
||||
|
||||
/// Where `offset` lands after `edits`, sorted and apart, each a replaced range and the length written; none when an edit replaced it.
|
||||
fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||
@@ -17,6 +17,21 @@ fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||
Some(at as usize)
|
||||
}
|
||||
|
||||
/// Where the character at `offset` lands; text written at it goes before.
|
||||
fn carried(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||
let mut at = offset as isize;
|
||||
for (range, written) in edits {
|
||||
if offset < range.start {
|
||||
break;
|
||||
}
|
||||
if offset < range.end {
|
||||
return None;
|
||||
}
|
||||
at += *written as isize - range.len() as isize;
|
||||
}
|
||||
Some(at as usize)
|
||||
}
|
||||
|
||||
/// A range through `edits`; none when an edit cut into it.
|
||||
fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
|
||||
Some(moved(range.start, edits)?..moved(range.end, edits)?)
|
||||
@@ -43,6 +58,9 @@ pub(crate) fn shift(marks: &mut Marks, edits: &[(Range<usize>, usize)]) {
|
||||
ghost.offset = moved(ghost.offset, edits)?;
|
||||
Some(ghost)
|
||||
});
|
||||
for anchor in &mut marks.anchors {
|
||||
*anchor = anchor.and_then(|at| carried(at, edits));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the owner's offsets when the whole text changes; they named the old one.
|
||||
@@ -51,6 +69,27 @@ pub(crate) fn clear(marks: &mut Marks) {
|
||||
marks.backgrounds.clear();
|
||||
marks.hints.clear();
|
||||
marks.ghost = None;
|
||||
marks.anchors.fill(None);
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Offsets the editor carries through each edit.
|
||||
pub fn set_anchors(&mut self, offsets: impl IntoIterator<Item = usize>) {
|
||||
let text = self.buffer.text();
|
||||
let whole = |at: usize| {
|
||||
let fits = text.is_char_boundary(at);
|
||||
if !fits {
|
||||
log::error!("code editor: anchor {at} splits a character");
|
||||
}
|
||||
fits.then_some(at)
|
||||
};
|
||||
self.marks.anchors = offsets.into_iter().map(whole).collect();
|
||||
}
|
||||
|
||||
/// Where each anchor is now; none once an edit took it.
|
||||
pub fn anchors(&self) -> &[Option<usize>] {
|
||||
&self.marks.anchors
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `range` runs forward between whole characters of `text`.
|
||||
@@ -62,7 +101,7 @@ pub(crate) fn fits(text: &str, range: &Range<usize>) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fits, moved};
|
||||
use super::{carried, fits, moved};
|
||||
|
||||
#[test]
|
||||
fn offsets_ride_their_edits() {
|
||||
@@ -80,4 +119,13 @@ mod tests {
|
||||
assert_eq!(moved(8, &edits), Some(10));
|
||||
assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_go_after_text_written_at_them() {
|
||||
let edits = [(2..4, 1), (6..6, 3)];
|
||||
assert_eq!(carried(2, &edits), None, "its character replaced");
|
||||
assert_eq!(carried(4, &edits), Some(3), "just past a replace");
|
||||
assert_eq!(carried(6, &edits), Some(8), "after an insertion at it");
|
||||
assert_eq!(carried(1, &edits), Some(1));
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -1,10 +1,28 @@
|
||||
use std::ops::Range;
|
||||
use std::{
|
||||
ops::Range,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
/// Source text and where each line starts, in bytes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Buffer {
|
||||
text: String,
|
||||
starts: Vec<usize>,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
impl PartialEq for Buffer {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.text == other.text
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Buffer {}
|
||||
|
||||
/// A fresh stamp: no two texts share one.
|
||||
fn stamp() -> u64 {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn starts(text: &str) -> Vec<usize> {
|
||||
@@ -17,7 +35,16 @@ impl Buffer {
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
let starts = starts(&text);
|
||||
Self { text, starts }
|
||||
Self {
|
||||
text,
|
||||
starts,
|
||||
stamp: stamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names this text; every edit gives a new one.
|
||||
pub(crate) fn stamp(&self) -> u64 {
|
||||
self.stamp
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
@@ -107,6 +134,7 @@ impl Buffer {
|
||||
);
|
||||
self.text.replace_range(range, text);
|
||||
self.starts = starts(&self.text);
|
||||
self.stamp = stamp();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ impl CodeEditor {
|
||||
}
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +346,7 @@ impl CodeEditor {
|
||||
}
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-29
@@ -5,6 +5,7 @@ use gpui::{
|
||||
div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
@@ -148,6 +149,26 @@ impl FindWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The count shown, and whether it reads as a miss.
|
||||
pub(crate) fn count(
|
||||
matches: &Result<(Option<usize>, usize), SharedString>,
|
||||
cx: &App,
|
||||
) -> (SharedString, bool) {
|
||||
match matches {
|
||||
Ok((_, 0)) => (i18n::text(cx, "find.none", &[]), true),
|
||||
Ok((Some(current), total)) => {
|
||||
let (at, total) = ((current + 1).to_string(), total.to_string());
|
||||
let args = [("at", at.as_str()), ("total", total.as_str())];
|
||||
(i18n::text(cx, "find.at", &args), false)
|
||||
}
|
||||
Ok((None, total)) => {
|
||||
let total = total.to_string();
|
||||
(i18n::text(cx, "find.found", &[("total", &total)]), false)
|
||||
}
|
||||
Err(message) => (message.clone(), true),
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FindWidget {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
@@ -155,7 +176,7 @@ impl RenderOnce for FindWidget {
|
||||
let options = self.options;
|
||||
let toggle = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
words: SharedString,
|
||||
on: bool,
|
||||
set: fn(&mut FindOptions, bool)| {
|
||||
let on_options = self.on_options.clone();
|
||||
@@ -174,16 +195,11 @@ impl RenderOnce for FindWidget {
|
||||
}
|
||||
})
|
||||
};
|
||||
let (count, missing) = match &self.matches {
|
||||
Ok((_, 0)) => ("No results".to_string(), true),
|
||||
Ok((Some(current), total)) => (format!("{} of {total}", current + 1), false),
|
||||
Ok((None, total)) => (format!("{total} found"), false),
|
||||
Err(message) => (message.to_string(), true),
|
||||
};
|
||||
let (count, missing) = count(&self.matches, cx);
|
||||
let found = matches!(self.matches, Ok((_, total)) if total > 0);
|
||||
let icon_button = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
words: SharedString,
|
||||
enabled: bool,
|
||||
action: Option<OnBool>,
|
||||
value: bool| {
|
||||
@@ -229,7 +245,7 @@ impl RenderOnce for FindWidget {
|
||||
} else {
|
||||
IconName::ChevronRight
|
||||
},
|
||||
"Toggle replace",
|
||||
i18n::text(cx, "find.fold", &[]),
|
||||
true,
|
||||
Some(fold),
|
||||
!showing,
|
||||
@@ -245,21 +261,21 @@ impl RenderOnce for FindWidget {
|
||||
row.child(toggle(
|
||||
"case",
|
||||
IconName::CaseSensitive,
|
||||
"Match case",
|
||||
i18n::text(cx, "find.case", &[]),
|
||||
options.case,
|
||||
|next, on| next.case = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"word",
|
||||
IconName::WholeWord,
|
||||
"Whole word",
|
||||
i18n::text(cx, "find.word", &[]),
|
||||
options.word,
|
||||
|next, on| next.word = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"regex",
|
||||
IconName::Regex,
|
||||
"Regular expression",
|
||||
i18n::text(cx, "find.regex", &[]),
|
||||
options.regex,
|
||||
|next, on| next.regex = on,
|
||||
))
|
||||
@@ -278,7 +294,7 @@ impl RenderOnce for FindWidget {
|
||||
.child(icon_button(
|
||||
"previous",
|
||||
IconName::ArrowUp,
|
||||
"Previous match",
|
||||
i18n::text(cx, "find.previous", &[]),
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
false,
|
||||
@@ -286,7 +302,7 @@ impl RenderOnce for FindWidget {
|
||||
.child(icon_button(
|
||||
"next",
|
||||
IconName::ArrowDown,
|
||||
"Next match",
|
||||
i18n::text(cx, "find.next", &[]),
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
true,
|
||||
@@ -295,7 +311,7 @@ impl RenderOnce for FindWidget {
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.tooltip(i18n::text(cx, "dialog.close", &[]))
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})),
|
||||
)
|
||||
@@ -312,22 +328,28 @@ impl RenderOnce for FindWidget {
|
||||
.child(Input::new(field).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "replace"), "Replace")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(one, |button, one| {
|
||||
button.on_click(move |_, window, cx| one(false, window, cx))
|
||||
}),
|
||||
Button::new(
|
||||
(self.id.clone(), "replace"),
|
||||
i18n::text(cx, "find.replace", &[]),
|
||||
)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(one, |button, one| {
|
||||
button.on_click(move |_, window, cx| one(false, window, cx))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "all"), "Replace all")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(all, |button, all| {
|
||||
button.on_click(move |_, window, cx| all(true, window, cx))
|
||||
}),
|
||||
Button::new(
|
||||
(self.id.clone(), "all"),
|
||||
i18n::text(cx, "find.replace_all", &[]),
|
||||
)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(all, |button, all| {
|
||||
button.on_click(move |_, window, cx| all(true, window, cx))
|
||||
}),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use super::{symbols::SymbolKind, syntax};
|
||||
use crate::i18n;
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
navigation::{fuzzy, marked},
|
||||
@@ -185,7 +186,7 @@ impl RenderOnce for CompletionMenu {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("No suggestions"),
|
||||
.child(i18n::text(cx, "completion.empty", &[])),
|
||||
)
|
||||
})
|
||||
.children(rows),
|
||||
|
||||
+44
-18
@@ -8,6 +8,7 @@ use gpui::{
|
||||
use super::{
|
||||
cursor::Selection,
|
||||
layout::{Row, Shown},
|
||||
measure::Measured,
|
||||
state::CodeEditor,
|
||||
};
|
||||
use crate::{
|
||||
@@ -67,11 +68,17 @@ impl CodeEditor {
|
||||
&& start.text != self.buffer.text()
|
||||
{
|
||||
self.history.record(start, false);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset under a window point, when a row of code is there.
|
||||
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> {
|
||||
pub(crate) fn offset_at(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let metrics = self.metrics;
|
||||
if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO {
|
||||
return None;
|
||||
@@ -82,20 +89,41 @@ impl CodeEditor {
|
||||
let Row::Line(line) = *row else {
|
||||
return None;
|
||||
};
|
||||
self.offset_in(line, position.x, cx)
|
||||
self.offset_in(line, position.x, window, cx)
|
||||
}
|
||||
|
||||
/// The offset nearest window x on a line; none for a line the text has since lost.
|
||||
pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> Option<usize> {
|
||||
pub(crate) fn offset_in(
|
||||
&self,
|
||||
line: usize,
|
||||
x: Pixels,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Option<usize> {
|
||||
if line >= self.buffer.lines() {
|
||||
return None;
|
||||
}
|
||||
let (shown, measured) = self.measured(line, window, cx);
|
||||
let column = measured.column(x - self.metrics.left);
|
||||
Some(
|
||||
self.buffer
|
||||
.offset(line, shown.back(self.row_text(line), column)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A line as drawn and laid out.
|
||||
fn measured(&self, line: usize, window: &Window, cx: &Context<Self>) -> (Shown, Measured) {
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = ((x - self.metrics.left) / self.metrics.advance)
|
||||
.round()
|
||||
.max(0.0) as usize;
|
||||
Some(self.buffer.offset(line, shown.back(text, column)))
|
||||
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
|
||||
let family = cx.theme().mono_family.clone();
|
||||
let measured = Measured::new(
|
||||
&shown,
|
||||
family,
|
||||
self.metrics.size,
|
||||
self.metrics.advance,
|
||||
window,
|
||||
);
|
||||
(shown, measured)
|
||||
}
|
||||
|
||||
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
@@ -103,7 +131,7 @@ impl CodeEditor {
|
||||
to_utf16(text, range.start)..to_utf16(text, range.end)
|
||||
}
|
||||
|
||||
fn byte_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
pub(crate) fn byte_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
let text = self.buffer.text();
|
||||
from_utf16(text, range.start)..from_utf16(text, range.end)
|
||||
}
|
||||
@@ -205,7 +233,7 @@ impl EntityInputHandler for CodeEditor {
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let range = self.byte_range(&range_utf16);
|
||||
@@ -216,27 +244,25 @@ impl EntityInputHandler for CodeEditor {
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))?;
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let (shown, measured) = self.measured(line, window, cx);
|
||||
let column = shown.column(text, self.buffer.point(range.start).1.min(text.len()));
|
||||
let metrics = self.metrics;
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let origin = point(
|
||||
metrics.left + metrics.advance * column as f32,
|
||||
metrics.left + measured.x(column),
|
||||
metrics.top + metrics.line * row as f32 - scrolled,
|
||||
);
|
||||
Some(Bounds::new(
|
||||
origin,
|
||||
gpui::size(metrics.advance, metrics.line),
|
||||
))
|
||||
let wide = measured.x(column + 1) - measured.x(column);
|
||||
Some(Bounds::new(origin, gpui::size(wide, metrics.line)))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let offset = self.offset_at(position, cx)?;
|
||||
let offset = self.offset_at(position, window, cx)?;
|
||||
Some(to_utf16(self.buffer.text(), offset))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub(crate) enum Row {
|
||||
Ghost(usize),
|
||||
/// The inline chat's rows; the first draws it.
|
||||
Chat(usize),
|
||||
/// Typewriter room past an end.
|
||||
Room,
|
||||
}
|
||||
|
||||
/// Which lines folds hide.
|
||||
|
||||
+97
-31
@@ -10,8 +10,8 @@ use super::{
|
||||
cursor::Selection,
|
||||
gutter::Cell,
|
||||
layout::{Shown, stack},
|
||||
measure::Measured,
|
||||
state::{CodeEditor, CursorShape},
|
||||
syntax,
|
||||
};
|
||||
use crate::{
|
||||
charts::tint,
|
||||
@@ -20,7 +20,11 @@ use crate::{
|
||||
|
||||
impl CodeEditor {
|
||||
/// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair.
|
||||
fn styles(&self, line: usize, cx: &Context<Self>) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
pub(super) fn styles(
|
||||
&self,
|
||||
line: usize,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.row_range(line);
|
||||
let local = |span: &Range<usize>| {
|
||||
@@ -29,12 +33,14 @@ impl CodeEditor {
|
||||
let touches = |span: &Range<usize>| {
|
||||
span.start < range.end.max(range.start + 1) && range.start < span.end
|
||||
};
|
||||
let mut spans = syntax::colors(self.row_text(line), cx);
|
||||
let mut spans = self.line_colors(self.row_text(line), cx);
|
||||
// Offsets found in an older text are left out.
|
||||
let fresh = self.frame.stamp == self.buffer.stamp();
|
||||
let brackets = self
|
||||
.frame
|
||||
.brackets
|
||||
.iter()
|
||||
.filter(|bracket| range.contains(&bracket.offset));
|
||||
.filter(|bracket| fresh && range.contains(&bracket.offset));
|
||||
for bracket in brackets {
|
||||
let at = bracket.offset - range.start;
|
||||
let color = if self.options.rainbow {
|
||||
@@ -45,7 +51,12 @@ impl CodeEditor {
|
||||
spans.retain(|(span, _)| *span != (at..at + 1));
|
||||
spans.push((at..at + 1, color.into()));
|
||||
}
|
||||
for span in self.frame.occurrences.iter().filter(|span| touches(span)) {
|
||||
for span in self
|
||||
.frame
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|span| fresh && touches(span))
|
||||
{
|
||||
spans.push((
|
||||
local(span),
|
||||
HighlightStyle {
|
||||
@@ -86,7 +97,7 @@ impl CodeEditor {
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some((open, close)) = self.frame.matched {
|
||||
if let Some((open, close)) = self.frame.matched.filter(|_| fresh) {
|
||||
for at in [open, close].into_iter().filter(|at| range.contains(at)) {
|
||||
let at = at - range.start;
|
||||
spans.push((
|
||||
@@ -110,24 +121,34 @@ impl CodeEditor {
|
||||
(0..indent).step_by(self.options.tab).collect()
|
||||
}
|
||||
|
||||
/// Dots where the line has spaces and arrows for tabs, spaces elsewhere, so they fall on the text's columns.
|
||||
fn whitespace(&self, line: usize, shown: &Shown, cx: &Context<Self>) -> String {
|
||||
/// Dots for spaces, arrows for tabs; other characters hidden.
|
||||
fn whitespace(&self, line: usize, cx: &Context<Self>) -> StyledText {
|
||||
let text = self.row_text(line);
|
||||
let notes = self.notes(line, cx);
|
||||
let mut out = String::new();
|
||||
let hidden = HighlightStyle {
|
||||
color: Some(gpui::transparent_black()),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let (mut out, mut hide) = (String::new(), Vec::new());
|
||||
let mut note = notes.iter().peekable();
|
||||
for (at, ch) in text.char_indices() {
|
||||
let start = out.len();
|
||||
while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) {
|
||||
out.extend(std::iter::repeat_n(' ', words.chars().count()));
|
||||
out.push_str(words);
|
||||
}
|
||||
match ch {
|
||||
' ' | '\t' => {
|
||||
hide.push((start..out.len(), hidden));
|
||||
out.push(if ch == ' ' { '·' } else { '→' });
|
||||
}
|
||||
_ => {
|
||||
out.push(ch);
|
||||
hide.push((start..out.len(), hidden));
|
||||
}
|
||||
}
|
||||
out.push(match ch {
|
||||
' ' => '·',
|
||||
'\t' => '→',
|
||||
_ => ' ',
|
||||
});
|
||||
}
|
||||
debug_assert!(out.chars().count() <= shown.text.chars().count());
|
||||
out
|
||||
hide.retain(|(range, _)| !range.is_empty());
|
||||
StyledText::new(out).with_highlights(hide)
|
||||
}
|
||||
|
||||
pub(crate) fn line_row(
|
||||
@@ -144,7 +165,16 @@ impl CodeEditor {
|
||||
let text = self.row_text(line);
|
||||
let range = self.row_range(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
|
||||
let x = |column: usize| advance * (column + 1) as f32;
|
||||
let measured = Measured::new(
|
||||
&shown,
|
||||
theme.mono_family.clone(),
|
||||
self.metrics.size,
|
||||
advance,
|
||||
window,
|
||||
);
|
||||
// Text where laid out; rulers and guides on grid.
|
||||
let x = |column: usize| advance + measured.x(column);
|
||||
let grid = |column: usize| advance * (column + 1) as f32;
|
||||
let focused = self.focus.is_focused(window);
|
||||
let primary = self.primary();
|
||||
let current = primary.is_empty() && self.buffer.line_of(primary.head) == line;
|
||||
@@ -171,7 +201,7 @@ impl CodeEditor {
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(start))
|
||||
.w(advance * (end - start) as f32)
|
||||
.w(x(end) - x(start))
|
||||
.h(height)
|
||||
.bg(if focused {
|
||||
colors.selection
|
||||
@@ -199,7 +229,7 @@ impl CodeEditor {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(*column))
|
||||
.left(grid(*column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
@@ -210,7 +240,7 @@ impl CodeEditor {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(column))
|
||||
.left(grid(column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
@@ -226,19 +256,20 @@ impl CodeEditor {
|
||||
.filter(|selection| self.buffer.line_of(selection.head) == line)
|
||||
.filter(|_| focused && self.caret_on)
|
||||
.map(|selection| {
|
||||
let left = x(column_of(selection.head));
|
||||
let column = column_of(selection.head);
|
||||
let (left, wide) = (x(column), x(column + 1) - x(column));
|
||||
let caret = theme.caret_width().to_pixels(window.rem_size());
|
||||
let mark = div().absolute().left(left);
|
||||
match self.options.cursor {
|
||||
CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus),
|
||||
CursorShape::Block => mark
|
||||
.top_0()
|
||||
.w(advance)
|
||||
.w(wide)
|
||||
.h(height)
|
||||
.bg(colors.focus.opacity(0.35)),
|
||||
CursorShape::Underline => mark
|
||||
.top(height - caret * 1.5)
|
||||
.w(advance)
|
||||
.w(wide)
|
||||
.h(caret * 1.5)
|
||||
.bg(colors.focus),
|
||||
}
|
||||
@@ -252,7 +283,7 @@ impl CodeEditor {
|
||||
.line_height(height)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.fg_subtle.opacity(0.6))
|
||||
.child(self.whitespace(line, &shown, cx))
|
||||
.child(self.whitespace(line, cx))
|
||||
});
|
||||
let folded = self.folded.contains(&line).then(|| {
|
||||
let entity = cx.entity();
|
||||
@@ -336,9 +367,17 @@ impl CodeEditor {
|
||||
editor.press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| {
|
||||
editor.drag(line, event, cx)
|
||||
}));
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
|
||||
editor.secondary_press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(
|
||||
cx.listener(move |editor, event: &MouseMoveEvent, window, cx| {
|
||||
editor.drag(line, event, window, cx)
|
||||
}),
|
||||
);
|
||||
base.child(self.gutter(Cell::Line(line), row, cx))
|
||||
.child(code)
|
||||
.into_any_element()
|
||||
@@ -353,7 +392,7 @@ impl CodeEditor {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus, cx);
|
||||
let Some(at) = self.offset_in(line, event.position.x, cx) else {
|
||||
let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
|
||||
return;
|
||||
};
|
||||
let chosen = match event.click_count {
|
||||
@@ -393,12 +432,39 @@ impl CodeEditor {
|
||||
self.set_selections(chosen, cx);
|
||||
}
|
||||
|
||||
/// A right press keeps a selection it lands in.
|
||||
pub(crate) fn secondary_press(
|
||||
&mut self,
|
||||
line: usize,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus, cx);
|
||||
let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
|
||||
return;
|
||||
};
|
||||
let held = (self.selections.iter()).any(|selection| {
|
||||
let range = selection.range();
|
||||
!range.is_empty() && (range.start..=range.end).contains(&at)
|
||||
});
|
||||
if !held {
|
||||
self.set_selections(vec![Selection::caret(at)], cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag over a line stretches the primary selection to where the pointer is.
|
||||
pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context<Self>) {
|
||||
pub(crate) fn drag(
|
||||
&mut self,
|
||||
line: usize,
|
||||
event: &MouseMoveEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.dragging || !event.dragging() {
|
||||
return;
|
||||
}
|
||||
let Some(at) = self.offset_in(line, event.position.x, cx) else {
|
||||
let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
|
||||
return;
|
||||
};
|
||||
let mut all = self.selections.clone();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Where drawn columns fall, as gpui lays them out.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{Hsla, LineLayout, Pixels, SharedString, TextRun, Window, font};
|
||||
|
||||
use super::layout::Shown;
|
||||
|
||||
/// A laid-out line: wide characters keep their own width.
|
||||
pub(crate) struct Measured {
|
||||
text: SharedString,
|
||||
layout: Arc<LineLayout>,
|
||||
advance: Pixels,
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
/// `shown` in the code font `family` at `size`.
|
||||
pub fn new(
|
||||
shown: &Shown,
|
||||
family: SharedString,
|
||||
size: Pixels,
|
||||
advance: Pixels,
|
||||
window: &Window,
|
||||
) -> Self {
|
||||
let base = font(family);
|
||||
let run = |len: usize, style: Option<&gpui::HighlightStyle>| {
|
||||
let mut font = base.clone();
|
||||
if let Some(style) = style {
|
||||
font.weight = style.font_weight.unwrap_or(font.weight);
|
||||
font.style = style.font_style.unwrap_or(font.style);
|
||||
}
|
||||
TextRun {
|
||||
len,
|
||||
font,
|
||||
color: Hsla::default(),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}
|
||||
};
|
||||
let (text, mut runs, mut at) = (shown.text.clone(), Vec::new(), 0);
|
||||
for (range, style) in shown.styles.iter().filter(|(range, _)| !range.is_empty()) {
|
||||
if range.start > at {
|
||||
runs.push(run(range.start - at, None));
|
||||
}
|
||||
runs.push(run(range.len(), Some(style)));
|
||||
at = range.end;
|
||||
}
|
||||
if at < text.len() {
|
||||
runs.push(run(text.len() - at, None));
|
||||
}
|
||||
let layout = window.text_system().layout_line(&text, size, &runs, None);
|
||||
Self {
|
||||
text,
|
||||
layout,
|
||||
advance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `column` starts; past the end in whole advances.
|
||||
pub fn x(&self, column: usize) -> Pixels {
|
||||
match self.text.char_indices().nth(column) {
|
||||
Some((at, _)) => self.layout.x_for_index(at),
|
||||
None => {
|
||||
let past = column - self.text.chars().count();
|
||||
self.layout.width + self.advance * past as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column starting nearest `x`, line end included.
|
||||
pub fn column(&self, x: Pixels) -> usize {
|
||||
let starts = self.layout.runs.iter().flat_map(|run| {
|
||||
run.glyphs
|
||||
.iter()
|
||||
.map(|glyph| (glyph.index, glyph.position.x))
|
||||
});
|
||||
let off = |edge: Pixels| f32::from((edge - x).abs());
|
||||
let at = starts
|
||||
.chain([(self.text.len(), self.layout.width)])
|
||||
.min_by(|a, b| off(a.1).total_cmp(&off(b.1)))
|
||||
.map(|(at, _)| at.min(self.text.len()))
|
||||
.expect("the line end is always a stop");
|
||||
self.text[..at].chars().count()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user