Compare commits

...
79 Commits
Author SHA1 Message Date
ZacharyZhang-NY 7f7a715d79 Lists: ListItem::editor draws a field in the title's place, for a row renamed in place; the title stays the row's name
CI / site (push) Successful in 29s
CI / platforms (windows-latest) (push) Successful in 4m52s
CI / crate (push) Failing after 15m34s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
2026-10-19 19:10:43 -04:00
ZacharyZhang-NYandClaude de961eb164 Documents: code protection closes and reopens tags as the reader does
CI / site (push) Successful in 33s
CI / platforms (windows-latest) (push) Successful in 5m1s
CI / crate (push) Failing after 11m3s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:24:40 -04:00
ZacharyZhang-NYandClaude 5fba7b3dfb Documents: nested code tags stay literal; an indented \[ formula is block
CI / site (push) Successful in 33s
CI / platforms (windows-latest) (push) Successful in 4m35s
CI / crate (push) Failing after 12m30s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:10:16 -04:00
ZacharyZhang-NYandClaude a20e712e29 Documents: \( pairs stay in one paragraph and off addresses; formulas place at wraps
CI / site (push) Successful in 36s
CI / platforms (windows-latest) (push) Successful in 4m49s
CI / crate (push) Failing after 11m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
- Pairs pair inside one paragraph, heading or cell; links' addresses,
  images, code and <code>/<kbd> text stay literal.
- Adjacent formulas stay two; a parting space is dropped after parsing.
- Stray and escaped dollars, in or out of a formula, stay dollars.
- A formula's node spans its delimiters; its value is the written TeX.
- A formula wrapped whole draws at its own line's start.
- A failed inline formula shows its reason after the TeX.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 23:54:39 -04:00
ZacharyZhang-NY e095f06689 Typography: a formula picture is as wide as its formula (px, not pt)
CI / platforms (windows-latest) (push) Successful in 4m59s
CI / crate (push) Failing after 12m49s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 36s
2026-10-18 23:00:24 -04:00
ZacharyZhang-NY dceca9f3c6 Documents: inline formulas typeset in text; \( and \[ read as math
CI / site (push) Successful in 35s
CI / crate (push) Failing after 35s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Successful in 5m41s
2026-10-18 22:42:07 -04:00
ZacharyZhang-NYandClaude 7d704d97f4 Navigation: a long tab fits its strip and cuts its label
CI / crate (push) Failing after 28s
CI / site (push) Successful in 35s
CI / platforms (windows-latest) (push) Successful in 5m21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 22:10:52 -04:00
ZacharyZhang-NYandClaude 3a68d1bb29 Navigation: the chosen tab follows a strip that changes width
CI / site (push) Successful in 35s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 11m12s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
The strip scrolled its chosen tab in once, at its first width; a
panel opening beside it then narrowed it and pushed the tab out
of view (ElyTable on a 30-sheet workbook). The strip now follows
its chosen tab whenever the choice or its width changes, checked
at paint with the width just laid out; a manual scroll stays
until either changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 21:52:16 -04:00
ZacharyZhang-NYandClaude e56a34301d Navigation: a wide tab strip scrolls and shows its chosen tab
CI / site (push) Successful in 35s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 12m21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
A horizontal strip with more tabs than room clipped the rest out
of reach (ElyTable's sheets, Gitea #8). The strip now scrolls
sideways, the wheel included, and scrolls the chosen tab fully
into view whenever the choice changes; a manual scroll stays
until then. The tabs may shrink below their natural width.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 21:39:42 -04:00
ZacharyZhang-NY da3fdcaf39 Column shell: a drag that comes back where it set off gives the width the owner had, where the last width it reported stayed; the width a drag sets off from lives with its press, so each press starts afresh
CI / crate (push) Failing after 13m11s
CI / site (push) Successful in 31s
CI / platforms (windows-latest) (push) Successful in 4m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
2026-10-16 21:17:36 -04:00
ZacharyZhang-NY 0b4848bb2a Column shell: a drag sets off from the wider of the width drawn and the owner's, so a column a short shell squeezes moves with the pointer, where it jumped back against it, and a drag with no sideways move changes no width, where it wrote the squeezed one
CI / platforms (windows-latest) (push) Successful in 5m2s
CI / crate (push) Failing after 13m0s
CI / site (push) Successful in 31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
2026-10-16 20:57:32 -04:00
ZacharyZhang-NY 005faefd5d Resize handles: the line is a filled hairline, since gpui paints a border no wider than half its box: a split's divider, a resizable box's edge and a column's border drew half a pixel, and none at a scale of one
CI / site (push) Successful in 25s
CI / platforms (windows-latest) (push) Successful in 4m36s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / crate (push) Failing after 12m30s
2026-10-16 20:10:03 -04:00
ZacharyZhang-NY 5c2822cfb6 Column shell: a resizable column drags by its border toward the filling column, whose handle draws that border and occludes the header under it, a drag setting off from the width drawn; a set width is a flex basis, so a column holds its header's own parts, where Windows' caption buttons ran out of the window
CI / site (push) Successful in 30s
CI / platforms (windows-latest) (push) Successful in 4m47s
CI / crate (push) Failing after 12m32s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
2026-10-16 19:56:21 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2e65b05a65 Documents: inline code in a sixth-level heading keeps its own color
CI / platforms (windows-latest) (push) Successful in 5m29s
CI / crate (push) Failing after 12m24s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 14:48:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 962964e7da Documents: the field highlighter reads front matter as front matter
CI / site (push) Successful in 30s
CI / crate (push) Failing after 13m19s
CI / platforms (windows-latest) (push) Successful in 4m41s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Without the front matter construct, an open front matter block parsed
as a rule and a setext heading, so its keys drew bright and bold. The
highlighter now parses as the file does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 14:34:16 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1ff993dcb1 Documents: an open heading keeps its weight and color; front matter keeps its panel
CI / site (push) Successful in 31s
CI / crate (push) Failing after 12m51s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Successful in 4m42s
The field's highlighter gave headings and bold semibold over the drawn
bold, and its plain color hid a sixth-level heading's muted color. It
now uses the drawn weight, and runs inside a sixth-level heading stay
muted. Front matter opens in its mono, muted panel. The open text box
is built by one function, so tests read its background directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 14:27:26 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bff7ebcd64 Documents: an open block keeps its look
CI / crate (push) Failing after 2s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 2s
CI / site (push) Successful in 24s
The open field sits in the closed block's frame at its size, line
height and weight; headings keep their step. Code and math keep their
panel. Typewriter centering also keys on the ends' room, so a block
opened at the same height still centers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 14:01:58 -04:00
ZacharyZhang-NY cdc5ce8247 Shell: a control in a drag region occludes it, so it takes its press on Windows, where gpui answers the hit test with the caption over any Drag hitbox left uncovered: window buttons and title bar slots occlude, and a column header's item row does while staying the scroll container, its title a drag region of its own; the mini window drags by its title alone, keeping its hover
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 29s
CI / platforms (windows-latest) (push) Successful in 4m19s
CI / crate (push) Failing after 6m18s
2026-10-15 18:11:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 90bd37b550 Documents: strong text keeps a heading's weight; sizes are tested
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 32s
CI / platforms (windows-latest) (push) Successful in 4m33s
CI / crate (push) Failing after 6m26s
Headings and strong text share one bold weight, so strong text in a
heading no longer draws lighter. A heading's line height is relative
to its own size, and tests check every level's size against the body,
a larger body and a zoomed document.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:55:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 acbbe1dbe5 Documents: a heading test comment fits ten words
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 27s
CI / crate (push) Failing after 4m33s
CI / platforms (windows-latest) (push) Successful in 4m41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:33:31 -04:00
ZacharyZhang-NYandClaude Opus 5.5 4a8f940ea2 Code editor: two measure comments fit ten words
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 26s
CI / crate (push) Failing after 3m24s
CI / platforms (windows-latest) (push) Successful in 4m47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:19:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cd61653ff1 Code editor: measure with the current text; the line end is a stop
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 31s
CI / crate (push) Failing after 4m9s
CI / platforms (windows-latest) (push) Successful in 4m34s
Bracket and match offsets from the last draw belong to that text's
stamp, so an input-method edit before the next draw no longer splits
a character into font runs. A press past the last glyph's start picks
the nearer of that start and the line end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:01:12 -04:00
ZacharyZhang-NYandClaude Opus 5.5 26af4be04f Documents: a CRLF drawn as two chars reads back char for char
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 31s
CI / platforms (windows-latest) (push) Successful in 4m36s
CI / crate (push) Failing after 9m28s
Only a CRLF the drawn text collapses is one end; inline code keeps both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 13:26:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c31d092583 Documents: a drawn CRLF reads back to its start
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 10m55s
A press at a CRLF line's end typed between CR and LF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 13:04:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5954132e76 Documents: headings step up from the body, all bold
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 10m33s
CI / platforms (windows-latest) (push) Failing after 10s
Sizes follow Typora's steps over the body (2.25, 1.75, 1.5, 1.25, 1, 1)
so levels 3 and 4 stand out; level 6 is muted; line height fits the size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:25:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9fb607beb4 Code editor: columns sit where gpui lays them out
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 10s
CI / crate (push) Failing after 11m53s
CI / site (push) Successful in 22s
Carets, selections, presses and the input method box measured the line
as columns times one 'm', so a wide character (CJK, emoji) put the caret
mid-glyph. editor::measure lays out the drawn line; rulers and indent
guides stay on the column grid; whitespace marks hide the rest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:21:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 93efa7f70b Documents: soft breaks draw as lines; Reader::joined keeps GFM spaces
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 27s
The screen keeps a single line break (Typora's default when writing);
outputs read with Reader::joined, which joins them as GFM does. Heading
titles read on one line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:08:22 -04:00
ZacharyZhang-NY a2cb531e06 Text input: Copy, Cut, Paste and SelectAll are public, so an app's Edit menu reaches a focused field
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m28s
2026-10-15 10:07:08 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5a53c08c84 Menus: a row exit drops its delayed mark; icon rows keep the aim
CI / platforms (windows-latest) (push) Failing after 8s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:54:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d4fdb694ef Menus: hover intent for submenus; panels occlude; flip left
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Panels occlude the rows under them. A submenu without room on the
right opens on the left of its row. A submenu opens after the pointer
rests 150 ms; a row crossed toward an open submenu waits 300 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:25:43 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f1771b3880 Documents: cells keep their row's pipes; long reads finish cell moves
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m22s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
An emptied bare edge cell writes a pipe so its column stays. The caret
reads the escaped write-back. A right press beside an edited cell keeps
its place. A move that waits on a long read opens once the read lands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:09:43 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2b6bd1d369 Documents: untouched cells keep their bytes; open cells take padding presses
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m35s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
A trailing backslash doubles only before a closing pipe. A write at a
block's start keeps the block, so Tab leaves a first cell without outer
pipes. Padding of the open cell moves its caret to the end, and a right
press there points at the end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:39:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6a3f3486fd Documents: open cells keep presses; finds open their cell
CI / platforms (windows-latest) (push) Failing after 9s
CI / crate (push) Failing after 13m33s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 23s
A press inside the open cell stays with its field. Find opens the
cell holding a match. A closing backslash no longer escapes the
next pipe. A right press after typed pipes reads the right byte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:16:08 -04:00
ZacharyZhang-NY 5dc4dd1805 Resize border: glass leaves a see-through window's pane to its content, without ground or shadow
CI / crate (push) Failing after 11m28s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
2026-10-15 01:01:26 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2594b3fd0f Documents: a press in a table opens its cell alone
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 11m56s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Visual mode opened a pressed table whole, as Markdown source. A press
in a table now opens only that cell, in place, as its source in a
one-line field; the rest of the table stays drawn. Tab and Shift-Tab
walk the cells, Up and Down move a row or leave the table, Enter and
Escape write back in one undo step, and a bare pipe typed in a cell is
escaped on write-back. The table drawing moves to render/table.rs and
presses on a cell's padding name the cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:50:20 -04:00
ZacharyZhang-NY 991ca7b548 Column shell: glass grounds the columns in the palette's glass, so a blurred window shows through them
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 11m45s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
2026-10-14 23:23:30 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e07fd34fa3 Documents: time the drawn-lookup test with web_time
CI / site (push) Successful in 25s
CI / crate (push) Failing after 11m23s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 9s
The component's check bans std Instant; the R4.10 test used it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:12:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2b8bd9fcf3 Right press: one alignment pass, latest press wins
CI / site (push) Successful in 22s
CI / crate (push) Failing after 12m14s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 10s
drawn_at aligns each source piece once instead of re-reading the span
for every drawn byte, so a right press near a long paragraph's start
stays linear. A newer press drops an older pending open-block lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:40:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a4bf6965f9 i18n: the find box, sort and filter builders and password toggle in Chinese
CI / platforms (windows-latest) (push) Failing after 18s
CI / site (push) Successful in 23s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / crate (push) Failing after 13m36s
The find counts, toggles, replace buttons, the builders' add, join and
direction labels, the filter tests and the password tooltip read the
builtin catalog. Test::label becomes Test::key, the choice's value; its
label is the catalog's. The shipped-copy scan checks button, segment and
match-arm text on every match, and zh-CN render tests cover both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 20:41:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 79e94f7e24 documents: a right press in an open block targets the pressed byte
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 11m46s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
The open block's target was read at the selection's start and from
the syntax tree only, so a press on the second of two selected links
named the first, and an inline anchor read as its outer link.
point_open now reads the byte under the press and resolves links as
drawing does; delete_image takes that byte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 20:18:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6df9ab0aa6 AGENTS: name the i18n call and the shipped-copy test
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m5s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 9s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:53:15 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d05b1b78ca i18n: shipped components read their copy from the catalog
CI / platforms (windows-latest) (push) Failing after 16s
CI / site (push) Successful in 25s
CI / crate (push) Failing after 10m59s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Select's default placeholder, the clear, close, remove and unpin tooltips,
the completion menu's empty line and the size sample showed English under
zh-CN. A source test now fails on any fixed English literal in them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:53:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1b5fb07cda documents: Ctrl+V with no block open pastes text at the caret
CI / platforms (windows-latest) (push) Failing after 8s
CI / crate (push) Failing after 11m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 22s
It pasted only pictures; text was dropped without a word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 18:20:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 013f14216f documents: right presses keep a selection; Delete Image
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 12m8s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
A right press in a text field or the code editor keeps a selection it
lands in, else moves the caret. In visual mode a right press in the open
block keeps it open and emits Pointed, read apart from the whole text.
MarkdownDocument::delete_image removes the picture at the caret.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 18:15:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6c2d28c56e documents: a dropped block hands its focus to the document
CI / platforms (windows-latest) (push) Failing after 8s
CI / crate (push) Failing after 12m5s
CI / site (push) Successful in 21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
An edit past an open block wrote it back and dropped its input while
it held focus, so focus fell to the window root and Undo reached
nothing. The next render passes such focus to the document.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:39:24 -04:00
ZacharyZhang-NYandClaude Opus 5.5 24ffc45c6b documents: TableEdit::Delete removes the table at the caret
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 12m17s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Typora's Paragraph > Table > Delete Table. It removes the table as the
block command does and refuses outside a table as TableRefusal::Outside.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:04:26 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6d90351423 documents: an open block stays open while an overlay borrows focus
CI / platforms (windows-latest) (push) Failing after 12s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 11m41s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
A menu or dialog took focus, the open block wrote back and closed, and
the overlay handed focus back to a closed input: its command reached
nothing and the selection was gone. The block now waits for the overlay
to let focus go, and writes back only when focus lands elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:01:42 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6b033f3eb0 documents: edits read the caret off a CRLF split
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m47s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
The editor splits lines on LF only, so End can leave the caret
between CR and LF. The edit runner and table insert now settle the
selection before the CR; the line helpers assert they never see a
split instead of guessing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:56:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ce015e7b7b documents: a caret inside CRLF reads as its line's end
The code editor splits lines on LF only, so End and Right can leave the
caret between CR and LF. line_start and line_end now read that spot as
the end of its line, so insert_table keeps CRLF around a new table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:44:59 -04:00
ZacharyZhang-NYandClaude Opus 5.5 07fee552c3 documents: tables keep their line ending; tabs count four
Table rewrites find their prefix and rows by CR, LF and CRLF alike and
write the table's own line ending; insert_table takes the text's. A
cell's width counts a tab as 4 and a control character as 0, as
Typora's wcswidth does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:33:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bcee87c08f documents: table moves and tidy read apart; a code press names its text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:16:42 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0558f0c890 documents: block checks read as drawn; alert, fence and box edges kept
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 12m1s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:03:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 892e095025 documents: block commands reread their result
Fixes R4.7 round 2: a heading change or split is parsed again and
refused as Misread when its text, formats or kind would change;
closing hashes and block openers are escaped at split ends; task
boxes below a marker stay; indented code is written from its value;
alerts unwrap past blank and lazy lines; delete counts every gone
opener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 14:36:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b37031b00b documents: block commands keep text, tasks and list items whole
Round-1 fixes for R4.7: a caret in heading marks splits at the text; a
split closes and reopens bold, italic and strike, refusing inside links,
code, math and HTML; a task's first text takes no block; retype keeps
task boxes and code indent and takes siblings past the list; an alert
in an item keeps the item's opener; paragraph-before moves a holder's
first text below its opener; delete keeps neighbors from joining; a
heading's text keeps closing hashes and block openers escaped; a heading
level is idempotent, as Typora's menu calls it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 14:08:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 58e212d4ec documents: block commands as Typora's Paragraph menu
MarkdownDocument::block runs a BlockCommand at the caret: heading
levels, paragraph, promote and demote; quote and list toggles;
rule, code, math, toc, footnote, link definition and front matter
inserts; empty paragraphs before and after; delete. Formats and
block commands share one runner off the UI thread; FormatRefusal
is now EditRefusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:44:42 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1d0354592b documents: format always reads apart; read cutoff back to 128 KiB
CI / site (push) Successful in 23s
CI / crate (push) Failing after 12m9s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 9s
The format command never parses on the UI thread; refusals past the
size checks come by event. The document's at-once read returns to
128 KiB, so a visual opening between the cutoffs starts at the top.
Timing test takes web_time::Instant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:14:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0289fdaa19 documents: format checks nested marks, reports late refusals, reads small texts at once
Unwrapping a style now runs the crossing check on the found node, so an
end in a nested link address or strike mark refuses. Clear keeps an
autolink's backslashes. Block lookups use a sorted search, so the walk
grows linearly. A long format refuses a second one as Busy and reports
refusals, a changed text and a shown preview through FormatRefused.
markdown-rs grows with the square of its nodes, so texts read at once
only up to 16 KiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 12:47:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d238e3b4f0 documents: format refuses ends in marks, clears more, reads long texts apart
Unwrap and merge check the original ends against marks and link
addresses. Blocks take text up to their end spaces; a cell between its
pipes. Clear also takes inline math's dollars, footnote brackets and
escape backslashes. Underline finds its <u> pair past other tags. Blank
lines split on CR too; a selection across blocks says Across. Past
128 KiB the toggle runs off the UI thread and lands only if text and
selection are unchanged; past the largest read it refuses (TooLong).
Clear and the document command move into format/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 12:16:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 68d33b4c54 documents: inline formats toggle as Typora's
MarkdownDocument::format toggles bold, italic, code, strike, underline,
link and image at the selection, or clears every inline mark from the
blocks it touches, following Typora's toggleStyle: off inside the style,
wrap a selection, take the caret's word or write an empty pair. Code,
math, HTML and front matter refuse, as does a selection crossing a
block, a link or another mark. An open block keeps its field open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:51:24 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c35fdac2e3 documents: read presses as the parser decoded them
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m39s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Round 3 of R4.5 found five ways the reverse reading drifted from
markdown-rs: kept indents and marks were eaten as joins, autolink text
was decoded, table code kept its escaped pipes, decoded white space
could not join or drop, and C0/C1 numeric references kept their
controls. Matching now tries an equal character first, a drawn space
against any line end next, and drops white space decoded or not;
autolinks read as written, table cell code reads \| as |, and numeric
references follow markdown-rs. The reviewer's probes are regressions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:34:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d4c614cb7f documents: drawn text reads back from its end, decoded as written
Each piece records how its source decodes: Markdown's escapes and
references for text, HTML's for an HTML block's runs, none for code
spans, cut as CommonMark cuts their end spaces. Matching runs from the
piece's end, so quote marks and indents at a line's start are never
taken for text, and an alert's text needs no moved position: alert()
takes no source again.

Lines split on LF, CRLF and CR alike; LineMap matches each drawn line
to the written line it ends once, so an HTML block maps in linear time
and a tab cut to spaces lands on the tab. A write-back refuses the
press when the pressed block's kind, text or drawing changed.

The hostile HTML test measures growth: a fixed 2 s budget failed under
the full suite's load on the old code too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:07:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 83d747d87e documents: presses read back through quotes, code, HTML and front matter
Each drawn character finds its written one, skipping only white
space, escapes and quote marks, so a press lands on the character
itself. Code, front matter and link definitions read back line for
line. An HTML block keeps each text run's source byte, and an alert
keeps its text past the marker; alert() now takes the source.

A secondary press refuses a target its write-back changed, and its
focus no longer opens an empty document. Run's element stack moves to
tree/stack.rs and the HTML-block reader to tree/html_block.rs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:11:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5af11fe489 documents: a press opens its block on the pressed character; a secondary press points
Each drawn text piece keeps its written span, so a press maps back to a
source byte. A secondary press opens nothing: it moves the caret there and
emits DocumentEvent::Pointed with the block's kind, link, picture and
whether it lies in a table or code. Block nodes count from their own start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:38:08 -04:00
ZacharyZhang-NYandClaude Opus 5.5 12512217bd documents: exports read through MarkdownReader, one fresh stack per area; reading helpers close again
CI / platforms (windows-latest) (push) Failing after 13s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 28s
The writer in ElyMark kept its own copy of the inline reading, which drifted
from the drawing. Reader goes public as MarkdownReader with block, inline,
note and label, and its blocks as MarkdownBlock. Shortcodes, InlineTag,
inline_tag and html_block_text close back to the tree; headings(text) goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:36:21 -04:00
ZacharyZhang-NYandClaude Opus 5.5 97fa047d18 documents: the drawing's reading rules are public, for exports to share
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 10m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Shortcodes, inline_tag, html_block_text, alert, is_toc and headings are what
the drawing itself calls, so an export that calls them shows the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:59:03 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8e5dad8177 documents: shortcodes read per DOM text node as GitHub reads them; HTML5's p closers end a p; a TOC press follows editor anchors
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 10m54s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
- The first `:name:`-shaped run in a text node decides for the whole node:
  blank text or a space before it turns every shortcode, else none. Filtered
  tags are text and do not split the node. Probed on GitHub's markdown API.
- `fieldset`, `menu` and the rest of HTML5's list end an open `p`.
- `CodeEditor` carries owner anchors through each edit; the document anchors
  every heading it reads, and a TOC press goes where its anchor went.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:48:26 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a744316274 documents: a TOC press follows the edit since its read; filtered tags keep shortcodes; tag names end as HTML5 ends them; block starts end p, li, dd and dt; owed space shifts only what opened after it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:11:57 -04:00
ZacharyZhang-NYandClaude Opus 5.5 98f19a8aac documents: alerts read their marker from parsed text; GFM's filtered tags show as text; block elements close what opened inside them; a TOC press relocates its heading after a write-back or a pending read and acts on press
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:42:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2eec25140d documents: GitHub alerts, a [toc] list of headings, front matter in a box, HTML blocks as text, a visible rule and bullets by depth
At the root a quote marked [!NOTE] and the rest, as GitHub's API reads it,
is an alert; a [toc] paragraph lists the headings and a press reveals one;
an HTML block shows its text with the inline tags, white space as a browser
lays it, scripts and styles dropped. The rule is 2px; bullets turn by depth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:08:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9ff403df31 Property grid: the group doc fits one line
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 25s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / crate (push) Failing after 11m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:00:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9f39d325a4 documents: Markdown containers are the elements GitHub writes, repaired with tags as HTML5 repairs; shortcodes read from decoded text, kept in autolinks and <code>
CI / site (push) Successful in 27s
CI / crate (push) Failing after 12m13s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
CI / platforms (windows-latest) (push) Failing after 10s
A raw end tag ends the Markdown element of its name; an end tag pops every
element above its target and only formatting elements reopen, so mark, kbd
and del stop where the browser stops them; a new anchor ends the open one.
Emoji read after escapes and references, as GitHub's Markdown API shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:48:52 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1bba544e73 documents: references scan in bounded steps; attribute values decode and split as HTML does; tags cross emphasis as a browser repairs them; GitHub's emoji names
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:22:52 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2736a109b2 documents: shortcodes read from the source, so escapes, references and autolinks keep them; whole tag names, decoded attributes, only anchors link
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:22:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8d27f9088a documents: emoji shortcodes, inline HTML tags and numbered footnote references draw as Typora draws them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:22:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b3d6cc023e Property grid: a group's title keys its fold, so a group put first takes no other group's fold
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 24s
CI / crate (push) Failing after 10m48s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:22:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1b8d67839a Menus · button rows: a reader presses a button through Click and hears the marked one; the panel is a menu
CI / crate (push) Failing after 10m45s
CI / site (push) Successful in 27s
CI / platforms (windows-latest) (push) Failing after 10s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:25:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ed75621a6d Menus: a mark holds its item, so a neighbor shifted into its place is not chosen; a move after the menu closed marks nothing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:47:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a1718b6f7c Menus · button rows: a marked button that turns off or leaves clears the mark, a filter field gives its arrows to a marked row, a disabled button says it is unavailable
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:31:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 3c35daa4d8 Menus · button rows: Menu::buttons lays icon buttons in one row, arrows cross it, a press inside a panel stays the menu's
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:20:18 -04:00
115 changed files with 13703 additions and 785 deletions

No files matched your search

+7 -5
View File
@@ -13,7 +13,7 @@ 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. - 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. - 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. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
- 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. `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; 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 a paragraph's lines: 40,000 lines take 0.9 s). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it 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. - 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. - 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. - 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.
@@ -47,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. - 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. - 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. - One component, one home. Duplicates point to it.
- Fail fast. No silent fallbacks. Log state changes with `log`. - 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. - 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.
@@ -66,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. - 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`). - 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. - `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. - 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`). - 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`). - 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`).
@@ -88,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. - 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 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 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 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 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. - 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.
@@ -220,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). - `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). - 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. - 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). - 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 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. - 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. - 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`). - 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. - 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). - 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`). - 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`).
@@ -275,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: `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-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-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
+1
View File
@@ -1685,6 +1685,7 @@ dependencies = [
"similar", "similar",
"smallvec", "smallvec",
"unicode-segmentation", "unicode-segmentation",
"unicode-width",
"usvg", "usvg",
"vte", "vte",
"wasm-bindgen", "wasm-bindgen",
+1
View File
@@ -43,6 +43,7 @@ serde_json = { version = "1", features = ["preserve_order"] }
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] } similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
smallvec = "1" smallvec = "1"
unicode-segmentation = "1" unicode-segmentation = "1"
unicode-width = "0.2"
usvg = "0.46" usvg = "0.46"
vte = { version = "0.15", default-features = false, features = ["std", "ansi"] } vte = { version = "0.15", default-features = false, features = ["std", "ansi"] }
web-time = "1.1" web-time = "1.1"
+17
View File
@@ -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

+19 -1
View File
@@ -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<> { pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let (last, caption) = ran("context-ran", window, cx); let (last, caption) = ran("context-ran", window, cx);
let menu = Menu::new() 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( .item(
MenuItem::new("Back") MenuItem::new("Back")
.icon(IconName::ArrowLeft) .icon(IconName::ArrowLeft)
@@ -242,7 +260,7 @@ pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme(); let theme = cx.theme();
section( section(
"ContextMenu", "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, cx,
) )
.child(specimen( .child(specimen(
+7 -8
View File
@@ -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 { pub struct PropertyGroup {
title: SharedString, title: SharedString,
open: bool, open: bool,
@@ -157,10 +157,9 @@ impl RenderOnce for PropertyGrid {
let folds: Vec<_> = self let folds: Vec<_> = self
.groups .groups
.iter() .iter()
.enumerate() .map(|group| {
.map(|(ix, group)| {
use_seeded( use_seeded(
(self.id.clone(), format!("fold-{ix}")), (self.id.clone(), format!("fold-{}", group.title)),
group.open, group.open,
window, window,
cx, cx,
@@ -180,14 +179,14 @@ impl RenderOnce for PropertyGrid {
|(ix, PropertyGroup { title, rows, .. })| { |(ix, PropertyGroup { title, rows, .. })| {
let shown = folds[ix].read(cx).value; let shown = folds[ix].read(cx).value;
let (toggle, key) = (folds[ix].clone(), title.clone()); 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() div()
.when(ix > 0, |group| { .when(ix > 0, |group| {
group.border_t_1().border_color(colors.border) group.border_t_1().border_color(colors.border)
}) })
.child( .child(
div() div()
.id((id.clone(), format!("group-{ix}"))) .id((id.clone(), format!("group-{title}")))
.flex() .flex()
.items_center() .items_center()
.gap_1() .gap_1()
@@ -208,10 +207,10 @@ impl RenderOnce for PropertyGrid {
}) })
}) })
.child(chevron) .child(chevron)
.child(title), .child(title.clone()),
) )
.child( .child(
Collapsible::new((id.clone(), format!("rows-{ix}")), shown).child( Collapsible::new((id.clone(), format!("rows-{title}")), shown).child(
div() div()
.pb_1() .pb_1()
.children(rows.into_iter().map(|(name, editor)| { .children(rows.into_iter().map(|(name, editor)| {
+42 -2
View File
@@ -5,8 +5,8 @@ use std::{
}; };
use gpui::{ use gpui::{
AnyElement, Bounds, Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled, AnyElement, Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels,
TestAppContext, VisualTestContext, Window, div, point, px, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
use super::{ 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"); 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. /// 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]>>); struct Facts(Pixels, fn() -> AnyElement, Rc<Cell<[Bounds<Pixels>; 2]>>);
+211
View File
@@ -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)
}
+458
View File
@@ -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,
}
}
+109
View File
@@ -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)))
})
}
}
+221
View File
@@ -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
}
+319
View File
@@ -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))
}
+315
View File
@@ -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)
}
+166
View File
@@ -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
}
+229
View File
@@ -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")),
}
}
+338
View File
@@ -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);
}
}
+192 -21
View File
@@ -2,7 +2,10 @@ use std::ops::Range;
use gpui::{AppContext as _, Context, Focusable as _, Window}; use gpui::{AppContext as _, Context, Focusable as _, Window};
use super::{DocumentEvent, MarkdownDocument, Open}; use super::{
DocumentEvent, MarkdownDocument, Open, TargetBlock,
cells::{self, Aim, Move},
};
use crate::{ use crate::{
documents::{ documents::{
MarkdownMode, MarkdownMode,
@@ -10,13 +13,36 @@ use crate::{
modes::{at_edge, joint}, modes::{at_edge, joint},
}, },
forms::{InputEvent, TextInput}, 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. /// Where byte `at` lies after `edit`, if it survives.
fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> { pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
match edit { match edit {
None => Some(at), None => Some(at),
Some((replaced, _)) if at < replaced.start => Some(at), Some((replaced, _)) if at <= replaced.start => Some(at),
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written), Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
Some(_) => None, Some(_) => None,
} }
@@ -25,9 +51,12 @@ fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
impl MarkdownDocument { impl MarkdownDocument {
/// An empty, read visual document takes typing at once. /// An empty, read visual document takes typing at once.
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) { 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); let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty { if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
self.open(None, false, window, cx); self.open(None, Landing::End, window, cx);
} }
} }
@@ -47,10 +76,11 @@ impl MarkdownDocument {
pub(super) fn open( pub(super) fn open(
&mut self, &mut self,
ix: Option<usize>, ix: Option<usize>,
at_start: bool, landing: Landing,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.waiting = None;
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) { 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"), Some(None) => return log::warn!("markdown document: a press on a block read away"),
pressed => pressed.flatten().map(|block| block.range.start), pressed => pressed.flatten().map(|block| block.range.start),
@@ -59,12 +89,25 @@ impl MarkdownDocument {
if written.is_some() { if written.is_some() {
self.reparse(true, cx); 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) { if !self.read_now(cx) {
return log::warn!("markdown document: pressed while the text is read again"); return log::warn!("markdown document: pressed while the text is read again");
} }
let text = self.editor.read(cx).text(); let text = self.editor.read(cx).text();
let (range, append) = match pressed { let (range, append, cell) = match pressed {
None => (text.len()..text.len(), true), None => (text.len()..text.len(), true, None),
Some(start) => { Some(start) => {
let found = moved(start, &written).and_then(|start| { let found = moved(start, &written).and_then(|start| {
self.parsed self.parsed
@@ -75,25 +118,65 @@ impl MarkdownDocument {
let Some(block) = found else { let Some(block) = found else {
return log::warn!("markdown document: the pressed block changed as one wrote"); return log::warn!("markdown document: the pressed block changed as one wrote");
}; };
(block.range.clone(), false) 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 source = text[range.clone()].to_string();
let input = cx.new(|cx| { let input = cx.new(|cx| {
TextInput::new(window, cx) let input = TextInput::new(window, cx).highlighter(markdown_highlights);
.multi_line(1, usize::MAX) match cell {
.highlighter(markdown_highlights) Some(_) => input,
None => input.multi_line(1, usize::MAX),
}
}); });
input.update(cx, |input, cx| { input.update(cx, |input, cx| {
input.set_text(source, cx); input.set_text(source, cx);
if at_start { if let Some(caret) = caret {
input.select(0..0, cx); input.select(caret..caret, cx);
} }
}); });
window.focus(&input.focus_handle(cx), cx); window.focus(&input.focus_handle(cx), cx);
let subscriptions = let subscriptions = vec![cx.subscribe_in(
vec![ &input,
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event { window,
|document, _, event: &InputEvent, window, cx| {
match event {
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
InputEvent::Blur => { InputEvent::Blur => {
document.commit(cx); document.commit(cx);
} }
@@ -101,30 +184,107 @@ impl MarkdownDocument {
document.typed += 1; document.typed += 1;
cx.emit(DocumentEvent::Changed) 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);
}
_ => {} _ => {}
}), }
]; },
log::info!("markdown document: block {range:?} opens"); )];
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 { self.open = Some(Open {
range, range,
input, input,
append, append,
cell: cell.map(|(at, _)| at),
lent: None,
_subscriptions: subscriptions, _subscriptions: subscriptions,
}); });
cx.notify(); 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. /// Writes the open block as one step; the edit made.
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> { pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
let open = self.open.take()?; let open = self.open.take()?;
self.orphan = Some(open.input.focus_handle(cx));
cx.notify(); cx.notify();
let typed = open.input.read(cx).text().to_string(); let typed = open.input.read(cx).text().to_string();
let caret = open.input.read(cx).cursor(); let caret = open.input.read(cx).cursor();
let text = self.editor.read(cx).text(); let text = self.editor.read(cx).text();
let edges = cells::edges(text, open.range.clone());
let (lead, written) = match open.append { let (lead, written) = match open.append {
true if typed.is_empty() => return None, true if typed.is_empty() => return None,
true => (joint(text).len(), format!("{}{typed}", joint(text))), true => (joint(text).len(), format!("{}{typed}", joint(text))),
false => (0, typed), 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 at = open.range.start + lead + caret;
let edit = (text[open.range.clone()] != written).then(|| { let edit = (text[open.range.clone()] != written).then(|| {
@@ -159,6 +319,15 @@ impl MarkdownDocument {
return false; return false;
} }
let here = self.block_at(open.range.start); 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 { let next = match down {
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()), true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
false => here.checked_sub(1), false => here.checked_sub(1),
@@ -166,7 +335,8 @@ impl MarkdownDocument {
let Some(next) = next else { let Some(next) = next else {
return false; return false;
}; };
self.open(Some(next), down, window, cx); let landing = if down { Landing::Start } else { Landing::End };
self.open(Some(next), landing, window, cx);
true true
} }
} }
@@ -178,6 +348,7 @@ mod tests {
let edit = Some((2..6, 9)); let edit = Some((2..6, 9));
assert_eq!(super::moved(10, &edit), Some(15)); assert_eq!(super::moved(10, &edit), Some(15));
assert_eq!(super::moved(1, &edit), Some(1), "before stays"); 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(3, &edit), None, "inside is gone");
assert_eq!(super::moved(7, &None), Some(7)); assert_eq!(super::moved(7, &None), Some(7));
} }
+18 -4
View File
@@ -4,7 +4,11 @@ use std::ops::Range;
use gpui::{Context, Window}; use gpui::{Context, Window};
use super::MarkdownDocument; use super::{
MarkdownDocument,
cells::{self, Aim},
edit::Landing,
};
use crate::documents::MarkdownMode; use crate::documents::MarkdownMode;
impl MarkdownDocument { impl MarkdownDocument {
@@ -41,12 +45,22 @@ impl MarkdownDocument {
// Outside one block: its line. // Outside one block: its line.
return self.reveal(line(self, cx), window, cx); return self.reveal(line(self, cx), window, cx);
}; };
let start = block.start; // A match in a table opens its cell.
self.open(Some(ix), false, window, cx); 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); self.list.scroll_to_reveal_item(ix);
if let Some(open) = &self.open { if let Some(open) = &self.open {
let input = open.input.clone(); let input = open.input.clone();
let inside = range.start - start..range.end - start; let inside = range.start - open.range.start..range.end - open.range.start;
input.update(cx, |input, cx| input.select(inside, cx)); input.update(cx, |input, cx| input.select(inside, cx));
} }
} }
+471
View File
@@ -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))
}
+85
View File
@@ -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),
))
}
+24 -4
View File
@@ -5,7 +5,11 @@ use std::ops::Range;
use gpui::{Context, Window}; use gpui::{Context, Window};
use markdown::mdast; use markdown::mdast;
use super::MarkdownDocument; use super::{
MarkdownDocument,
cells::{Aim, Move, beside},
edit::Landing,
};
use crate::documents::markdown::{span, tree, walk}; use crate::documents::markdown::{span, tree, walk};
/// Spaces one indent step adds. /// Spaces one indent step adds.
@@ -196,7 +200,7 @@ impl MarkdownDocument {
/// Enter on a block list line; false leaves it. /// Enter on a block list line; false leaves it.
pub(super) fn block_newline(&mut self, cx: &mut Context<Self>) -> bool { pub(super) fn block_newline(&mut self, cx: &mut Context<Self>) -> bool {
let Some(open) = &self.open else { let Some(open) = self.open.as_ref().filter(|open| open.cell.is_none()) else {
return false; return false;
}; };
let input = open.input.clone(); let input = open.input.clone();
@@ -218,11 +222,27 @@ impl MarkdownDocument {
true true
} }
/// Tab or Shift-Tab in the open block. /// Tab or Shift-Tab; in a cell, moves between cells.
pub(super) fn block_shift(&mut self, deeper: bool, _: &mut Window, cx: &mut Context<Self>) { pub(super) fn block_shift(
&mut self,
deeper: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(open) = &self.open else { let Some(open) = &self.open else {
return; 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 input = open.input.clone();
let (text, selection) = { let (text, selection) = {
let input = input.read(cx); let input = input.read(cx);
+3 -3
View File
@@ -90,9 +90,9 @@ impl MarkdownDocument {
block.size.height -= self.room; block.size.height -= self.room;
} }
let view = self.list.viewport_bounds(); let view = self.list.viewport_bounds();
// A new layout or a resize moves it too. // New layout, resize or end room moves it.
let heights = let heights = [block.size.height, view.size.height, self.room]
[block.size.height, view.size.height].map(|height| f32::from(height).round() as u64); .map(|height| f32::from(height).round() as u64);
let moved = (ix, self.edits, self.typed, heights); let moved = (ix, self.edits, self.typed, heights);
if self.centered == Some(moved) { if self.centered == Some(moved) {
return; return;
+54 -5
View File
@@ -1,10 +1,15 @@
mod apart;
mod attach; mod attach;
mod block;
mod cells;
mod edit; mod edit;
mod finding; mod finding;
mod format;
mod lists; mod lists;
mod look; mod look;
mod parse; mod parse;
mod place; mod place;
mod point;
mod tables; mod tables;
#[cfg(all(test, feature = "test-support"))] #[cfg(all(test, feature = "test-support"))]
mod tests; mod tests;
@@ -19,13 +24,21 @@ use gpui::{
ListState, Pixels, Subscription, Task, Window, ListState, Pixels, Subscription, Task, Window,
}; };
pub use crate::documents::tree::DocumentHeading;
pub use attach::Attachment; pub use attach::Attachment;
pub use block::BlockCommand;
pub use format::{EditRefusal, InlineFormat};
pub use look::DocumentLook; pub use look::DocumentLook;
pub use parse::DocumentHeading;
use parse::{Parsed, alike, parsed}; use parse::{Parsed, alike, parsed};
pub use point::{DocumentTarget, TargetBlock};
pub use tables::{TableEdit, TableRefusal}; pub use tables::{TableEdit, TableRefusal};
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink}; use super::{
MarkdownMode,
markdown::markdown_highlights,
modes::joint,
render::{CellAt, OnHeading, OnLink},
};
use crate::{ use crate::{
editor::{CodeEditor, EditorEvent, LineNumbers}, editor::{CodeEditor, EditorEvent, LineNumbers},
forms::TextInput, forms::TextInput,
@@ -41,12 +54,16 @@ pub const LARGEST_READ: usize = 2 << 20;
const PAUSE: Duration = Duration::from_millis(300); const PAUSE: Duration = Duration::from_millis(300);
/// What the document tells its owner. /// What the document tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum DocumentEvent { pub enum DocumentEvent {
/// The text changed, typed in any mode or replaced. /// The text changed, typed in any mode or replaced.
Changed, Changed,
/// A new read of the text: blocks and headings. /// A new read of the text: blocks and headings.
Parsed, 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. /// A top line asked, numbered so late callbacks know theirs.
@@ -64,6 +81,10 @@ struct Open {
input: Entity<TextInput>, input: Entity<TextInput>,
/// Opened past the end, to add a block. /// Opened past the end, to add a block.
append: bool, 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>, _subscriptions: Vec<Subscription>,
} }
@@ -76,8 +97,11 @@ pub struct MarkdownDocument {
/// Text changes counted; a read of an older one lapses. /// Text changes counted; a read of an older one lapses.
edits: u64, edits: u64,
_parsing: Option<Task<()>>, _parsing: Option<Task<()>>,
editing: bool,
list: ListState, list: ListState,
open: Option<Open>, 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. /// A top line asked for, until the view holds it.
top: Option<Placement>, top: Option<Placement>,
asks: u64, asks: u64,
@@ -85,15 +109,22 @@ pub struct MarkdownDocument {
writes: usize, writes: usize,
/// A match asked before its text was read; its version. /// A match asked before its text was read; its version.
showing: Option<(Range<usize>, u64)>, showing: Option<(Range<usize>, u64)>,
/// A block asked open before its text was read.
waiting: Option<(Option<usize>, edit::Landing, u64)>,
look: DocumentLook, look: DocumentLook,
/// Typing in the open block, counted. /// Typing in the open block, counted.
typed: u64, typed: u64,
/// Typewriter room past each end of the list. /// Typewriter room past each end of the list.
room: Pixels, room: Pixels,
/// The block, text, typing and heights last centered. /// The block, text, typing and heights last centered.
centered: Option<(usize, u64, u64, [u64; 2])>, centered: Option<(usize, u64, u64, [u64; 3])>,
focus: FocusHandle, focus: FocusHandle,
/// A secondary press took focus.
pointing: bool,
/// The latest press; older lookups lapse.
pressed: u64,
on_link: Option<OnLink>, on_link: Option<OnLink>,
on_heading: Option<OnHeading>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -132,6 +163,11 @@ impl MarkdownDocument {
cx.on_focus(&focus, window, Self::focused), cx.on_focus(&focus, window, Self::focused),
]; ];
let overdraw = window.rem_size() * 40.0; 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 { let mut document = Self {
editor, editor,
mode, mode,
@@ -139,18 +175,24 @@ impl MarkdownDocument {
parsed: Arc::default(), parsed: Arc::default(),
edits: 0, edits: 0,
_parsing: None, _parsing: None,
editing: false,
list: ListState::new(1, ListAlignment::Top, overdraw), list: ListState::new(1, ListAlignment::Top, overdraw),
open: None, open: None,
orphan: None,
top: None, top: None,
asks: 0, asks: 0,
writes: 0, writes: 0,
showing: None, showing: None,
waiting: None,
look: DocumentLook::default(), look: DocumentLook::default(),
typed: 0, typed: 0,
centered: None, centered: None,
room: Pixels::ZERO, room: Pixels::ZERO,
focus, focus,
pointing: false,
pressed: 0,
on_link: None, on_link: None,
on_heading: Some(on_heading),
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; };
document.reparse(true, cx); document.reparse(true, cx);
@@ -207,6 +249,9 @@ impl MarkdownDocument {
let written = match open.append { let written = match open.append {
true if typed.is_empty() => String::new(), true if typed.is_empty() => String::new(),
true => format!("{}{typed}", joint(text)), true => format!("{}{typed}", joint(text)),
false if open.cell.is_some() => {
cells::kept(typed, cells::edges(text, open.range.clone()))
}
false => typed.to_string(), false => typed.to_string(),
}; };
[&text[..open.range.start], &written, &text[open.range.end..]].concat() [&text[..open.range.start], &written, &text[open.range.end..]].concat()
@@ -304,6 +349,9 @@ impl MarkdownDocument {
let old = self.parsed.blocks.len(); let old = self.parsed.blocks.len();
self.list self.list
.splice(head..old - tail, read.blocks.len() - head - tail); .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); self.parsed = Arc::new(read);
cx.emit(DocumentEvent::Parsed); cx.emit(DocumentEvent::Parsed);
cx.notify(); cx.notify();
@@ -311,7 +359,8 @@ impl MarkdownDocument {
/// Closes the open block without writing it. /// Closes the open block without writing it.
fn drop_open(&mut self, cx: &mut Context<Self>) { fn drop_open(&mut self, cx: &mut Context<Self>) {
if self.open.take().is_some() { if let Some(open) = self.open.take() {
self.orphan = Some(open.input.focus_handle(cx));
log::info!("markdown document: the open block closed unwritten"); log::info!("markdown document: the open block closed unwritten");
cx.notify(); cx.notify();
} }
+43 -37
View File
@@ -3,27 +3,21 @@ use std::ops::Range;
use markdown::mdast; use markdown::mdast;
use crate::documents::{ use crate::documents::{
markdown::{file_tree, span, walk}, markdown::{file_tree, span},
tree::{Node, Reader}, tree::{DocumentHeading, Node, Reader},
}; };
use super::TargetBlock;
/// A top-level block: its source, and what it draws. /// A top-level block: its source, and what it draws.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct Block { pub(crate) struct Block {
pub range: Range<usize>, pub range: Range<usize>,
/// None shows the source as written: definitions, HTML, front matter. pub kind: TargetBlock,
/// None shows a link definition as written.
pub nodes: Option<Vec<Node>>, pub nodes: Option<Vec<Node>>,
} }
/// 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 file read as blocks and headings. /// A file read as blocks and headings.
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Parsed { pub(crate) struct Parsed {
@@ -44,41 +38,24 @@ pub(crate) fn parsed(text: &str) -> Parsed {
.map(|node| { .map(|node| {
let range = span(node); let range = span(node);
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len(); let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
let raw = matches!( let raw = matches!(node, mdast::Node::Definition(_));
node,
mdast::Node::Definition(_)
| mdast::Node::Html(_)
| mdast::Node::Yaml(_)
| mdast::Node::Toml(_)
);
let nodes = (!raw).then(|| { let nodes = (!raw).then(|| {
let mut out = Vec::new(); let mut out = Vec::new();
reader.block(node, &mut out); reader.top(node, &mut out);
out.iter_mut().for_each(|drawn| drawn.rebase(range.start));
out out
}); });
Block { Block {
range: range.start..range.start + kept, range: range.start..range.start + kept,
kind: TargetBlock::of(node, text),
nodes, nodes,
} }
}) })
.collect(); .collect();
let mut nodes = Vec::new();
walk(&root, &mut nodes);
let headings = nodes
.into_iter()
.filter_map(|node| match node {
mdast::Node::Heading(heading) => Some(DocumentHeading {
depth: heading.depth,
title: reader.inline(&heading.children).text,
at: span(node).start,
}),
_ => None,
})
.collect();
Parsed { Parsed {
text: text.to_string(), text: text.to_string(),
blocks, blocks,
headings, headings: reader.headings,
} }
} }
@@ -111,7 +88,7 @@ mod tests {
use crate::documents::tree::Style; use crate::documents::tree::Style;
#[test] #[test]
fn every_block_is_shown_raw_ones_as_written() { 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 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 found = parsed(text);
let shown: Vec<(&str, bool)> = found let shown: Vec<(&str, bool)> = found
@@ -122,13 +99,17 @@ mod tests {
assert_eq!( assert_eq!(
shown, shown,
[ [
("---\ntitle: a\n---", false), ("---\ntitle: a\n---", true),
("# Lift", true), ("# Lift", true),
("[site]: https://ely.dev", false), ("[site]: https://ely.dev", false),
("<div>x</div>", false), ("<div>x</div>", true),
("> ## Quoted", 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 let headings: Vec<(u8, &str, usize)> = found
.headings .headings
.iter() .iter()
@@ -174,6 +155,31 @@ mod tests {
assert_eq!(alike(&old, &shorter), (1, 0)); assert_eq!(alike(&old, &shorter), (1, 0));
} }
#[test]
fn pictures_and_code_count_from_their_block() {
let old = parsed(
"a
![p](p.png)
```
x
```
",
);
let new = parsed(
"a and more
![p](p.png)
```
x
```
",
);
assert_eq!(alike(&old, &new), (0, 2));
}
#[test] #[test]
fn a_reference_resolves_across_blocks() { fn a_reference_resolves_across_blocks() {
let found = parsed("[site]\n\n[site]: https://ely.dev\n"); let found = parsed("[site]\n\n[site]: https://ely.dev\n");
+39 -2
View File
@@ -1,6 +1,6 @@
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px}; use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
use super::{MarkdownDocument, Placement}; use super::{MarkdownDocument, Placement, cells};
use crate::documents::{MarkdownMode, modes::joint}; use crate::documents::{MarkdownMode, modes::joint};
/// Line and column, from zero, of byte `at` in `text`. /// Line and column, from zero, of byte `at` in `text`.
@@ -25,7 +25,15 @@ impl MarkdownDocument {
true => joint(editor.text()).len(), true => joint(editor.text()).len(),
false => 0, false => 0,
}; };
point(&self.text(cx), open.range.start + joined + input.cursor()) // 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. /// The selected Markdown: the source's, or the open block's.
@@ -189,6 +197,35 @@ impl MarkdownDocument {
.saturating_sub(1) .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. /// Line `line` at top and focused, caret at start.
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) { pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
self.set_caret(line, 0, cx); self.set_caret(line, 0, cx);
+361
View File
@@ -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()))
}
+268 -106
View File
@@ -1,14 +1,17 @@
//! Tables change by rows and columns (MARK-06). //! Tables change by rows and columns (MARK-06).
use std::ops::Range;
use gpui::Context; use gpui::Context;
use markdown::mdast::{self, AlignKind}; use markdown::mdast::{self, AlignKind};
use unicode_width::UnicodeWidthStr;
use super::MarkdownDocument; use super::{
MarkdownDocument,
block::{self, BlockCommand, eol, line_end, line_start, next_line, prev_line, settled},
format::{Change, EditRefusal},
};
use crate::documents::{ use crate::documents::{
MarkdownMode, MarkdownMode,
markdown::{span, tree, walk}, markdown::{file_tree, span, walk},
}; };
/// What a table edit does at the caret. /// What a table edit does at the caret.
@@ -20,11 +23,16 @@ pub enum TableEdit {
ColumnRight, ColumnRight,
DeleteRow, DeleteRow,
DeleteColumn, DeleteColumn,
MoveRowUp,
MoveRowDown,
MoveColumnLeft,
MoveColumnRight,
/// Every cell padded to its column, as Typora tidies.
Tidy,
/// The whole table.
Delete,
} }
/// A new table: three columns, a head and two rows.
const NEW: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |";
/// Why a table edit did not happen. /// Why a table edit did not happen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableRefusal { pub enum TableRefusal {
@@ -36,46 +44,43 @@ pub enum TableRefusal {
Beyond, Beyond,
/// Its lines carry different quote or list marks. /// Its lines carry different quote or list marks.
Mixed, Mixed,
/// Preview takes no edits. /// Nothing lies past the caret's row or column that way.
Preview, Edge,
} }
/// The table around `at` after `edit`: its range, text, caret. /// The table around `at` after `edit`, and the caret.
pub(super) fn edit_table( pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Change, TableRefusal> {
text: &str, let root = file_tree(text);
at: usize,
edit: TableEdit,
) -> Result<(Range<usize>, String, usize), TableRefusal> {
let root = tree(text);
let mut nodes = Vec::new(); let mut nodes = Vec::new();
walk(&root, &mut nodes); walk(&root, &mut nodes);
let (range, table) = nodes let (range, table) = nodes
.into_iter() .into_iter()
.find_map(|node| match node { .find_map(|node| match node {
mdast::Node::Table(table) if (span(node).start..=span(node).end).contains(&at) => { mdast::Node::Table(table) if around(node, at) => Some((span(node), table)),
Some((span(node), table))
}
_ => None, _ => None,
}) })
.ok_or(TableRefusal::Outside)?; .ok_or(TableRefusal::Outside)?;
let prefix = &text[text[..range.start].rfind('\n').map_or(0, |found| found + 1)..range.start]; let prefix = &text[line_start(text, range.start)..range.start];
if text[range.clone()] let mut line = range.start;
.split('\n') while let Some(next) = next_line(text, line).filter(|next| *next < range.end) {
.skip(1) if !text[next..].starts_with(prefix) {
.any(|line| !line.starts_with(prefix))
{
return Err(TableRefusal::Mixed); return Err(TableRefusal::Mixed);
} }
line = next;
}
let eol = eol(&text[range.clone()]);
let rows: Vec<&mdast::Node> = table.children.iter().collect(); let rows: Vec<&mdast::Node> = table.children.iter().collect();
let row = rows let row = rows
.iter() .iter()
.rposition(|row| span(row).start <= at) .rposition(|row| span(row).start <= at)
.unwrap_or(0); .unwrap_or(0);
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone(); let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
let column = cells_of(rows[row]) let cells = cells_of(rows[row]);
let column = cells
.iter() .iter()
.rposition(|cell| span(cell).start <= at) .rposition(|cell| span(cell).start <= at)
.unwrap_or(0); .unwrap_or(0);
let offset = cells.get(column).map_or(0, |cell| within(cell, at));
let width = table.align.len(); let width = table.align.len();
// Cells past the head stay as written. // Cells past the head stay as written.
let mut grid: Vec<Vec<String>> = rows let mut grid: Vec<Vec<String>> = rows
@@ -94,40 +99,85 @@ pub(super) fn edit_table(
let mut aligns = table.align.clone(); let mut aligns = table.align.clone();
let column_edit = matches!( let column_edit = matches!(
edit, edit,
TableEdit::ColumnLeft | TableEdit::ColumnRight | TableEdit::DeleteColumn TableEdit::ColumnLeft
| TableEdit::ColumnRight
| TableEdit::DeleteColumn
| TableEdit::MoveColumnLeft
| TableEdit::MoveColumnRight
); );
if column_edit && column >= width { if column_edit && column >= width {
return Err(TableRefusal::Beyond); return Err(TableRefusal::Beyond);
} }
let (row, column) = match edit { let caret = match edit {
TableEdit::RowAbove | TableEdit::RowBelow => { TableEdit::RowAbove | TableEdit::RowBelow => {
let below = edit == TableEdit::RowBelow || row == 0; let below = edit == TableEdit::RowBelow || row == 0;
let at = if below { row + 1 } else { row }; let at = if below { row + 1 } else { row };
grid.insert(at, vec![String::new(); width]); grid.insert(at, vec![String::new(); width]);
(at, column.min(width - 1)) (at, column.min(width - 1), 0)
} }
TableEdit::DeleteRow if row > 0 => { TableEdit::DeleteRow if row > 0 => {
grid.remove(row); grid.remove(row);
(row.min(grid.len() - 1), column.min(width - 1)) (row.min(grid.len() - 1), column.min(width - 1), 0)
} }
TableEdit::ColumnLeft | TableEdit::ColumnRight => { TableEdit::ColumnLeft | TableEdit::ColumnRight => {
let at = column + usize::from(edit == TableEdit::ColumnRight); let at = column + usize::from(edit == TableEdit::ColumnRight);
grid.iter_mut() grid.iter_mut()
.for_each(|cells| cells.insert(at, String::new())); .for_each(|cells| cells.insert(at, String::new()));
aligns.insert(at, AlignKind::None); aligns.insert(at, AlignKind::None);
(row, at) (row, at, 0)
} }
TableEdit::DeleteColumn if width > 1 => { TableEdit::DeleteColumn if width > 1 => {
grid.iter_mut().for_each(|cells| { grid.iter_mut().for_each(|cells| {
cells.remove(column); cells.remove(column);
}); });
aligns.remove(column); aligns.remove(column);
(row, column.min(width - 2)) (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), _ => return Err(TableRefusal::Kept),
}; };
let (out, caret) = table_text(&grid, &aligns, (row, column), prefix); let (out, caret) = table_text(&grid, &aligns, caret, prefix, eol);
Ok((range, out, caret)) 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. /// A cell's source between its pipes.
@@ -139,40 +189,83 @@ fn written(text: &str, cell: &mdast::Node) -> String {
} }
} }
/// The table's text, each line after the first under `prefix`. /// `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( fn table_text(
grid: &[Vec<String>], grid: &[Vec<String>],
aligns: &[AlignKind], aligns: &[AlignKind],
at: (usize, usize), at: (usize, usize, usize),
prefix: &str, prefix: &str,
eol: &str,
) -> (String, usize) { ) -> (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 out = String::new();
let mut caret = 0; let mut caret = 0;
for (row, cells) in grid.iter().enumerate() { for (row, cells) in grid.iter().enumerate() {
out.push('|');
for (column, cell) in cells.iter().enumerate() { for (column, cell) in cells.iter().enumerate() {
out.push_str("| "); let room = widths
if (row, column) == at { .get(column)
caret = out.len(); .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(cell);
out.push(' '); out.push_str(&" ".repeat(room - before));
out.push_str(" |");
} }
out.push('|');
if row == 0 { if row == 0 {
out.push('\n'); out.push_str(eol);
out.push_str(prefix); out.push_str(prefix);
for align in aligns {
out.push_str(match align {
AlignKind::Left => "| :-- ",
AlignKind::Right => "| --: ",
AlignKind::Center => "| :-: ",
AlignKind::None => "| --- ",
});
}
out.push('|'); 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() { if row + 1 < grid.len() {
out.push('\n'); out.push_str(eol);
out.push_str(prefix); out.push_str(prefix);
} }
} }
@@ -180,61 +273,34 @@ fn table_text(
} }
impl MarkdownDocument { impl MarkdownDocument {
/// `edit` on the table at the caret, or why not.
pub fn edit_table(
&mut self,
edit: TableEdit,
cx: &mut Context<Self>,
) -> Result<(), TableRefusal> {
if self.mode == MarkdownMode::Preview {
return Err(TableRefusal::Preview);
}
self.commit(cx);
let at = self.editor.read(cx).primary().range().start;
let text = self.editor.read(cx).text().to_string();
let (range, table, caret) = edit_table(&text, at, edit).inspect_err(|why| {
log::info!("markdown document: table edit {edit:?} refused: {why:?}");
})?;
log::info!("markdown document: table edit {edit:?}");
let caret = range.start + caret;
self.editor.update(cx, |editor, cx| {
editor.edit([(range, table)], cx);
editor.select(std::iter::once(caret..caret), cx);
});
Ok(())
}
/// A new table after the caret's line, unless in preview. /// A new table after the caret's line, unless in preview.
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), TableRefusal> { pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
if self.mode == MarkdownMode::Preview { if self.mode == MarkdownMode::Preview {
return Err(TableRefusal::Preview); return Err(EditRefusal::Preview);
} }
self.commit(cx); self.commit(cx);
let text = self.editor.read(cx).text().to_string(); let text = self.editor.read(cx).text().to_string();
let at = self.editor.read(cx).primary().range().start; let at = settled(&text, self.editor.read(cx).primary().range().start);
let end = text[at..].find('\n').map_or(text.len(), |found| at + found); let (start, end, eol) = (line_start(&text, at), line_end(&text, at), eol(&text));
let start = text[..at].rfind('\n').map_or(0, |found| found + 1);
// A blank line gives way; a written one keeps. // A blank line gives way; a written one keeps.
let blank = text[start..end].trim().is_empty(); let blank = text[start..end].trim().is_empty();
let above = text[..start.saturating_sub(1)] let above = prev_line(&text, start).map_or("", |line| &text[line..line_end(&text, line)]);
.rsplit('\n')
.next()
.unwrap_or_default();
let (from, before) = match (blank, above.trim().is_empty()) { let (from, before) = match (blank, above.trim().is_empty()) {
(true, true) => (start, ""), (true, true) => (start, String::new()),
(true, false) => (start, "\n"), (true, false) => (start, eol.to_string()),
(false, _) => (end, "\n\n"), (false, _) => (end, eol.repeat(2)),
}; };
let after = if text[end..].starts_with("\n\n") || end == text.len() { let after = if text[end..].starts_with(&eol.repeat(2)) || end == text.len() {
"" ""
} else { } else {
"\n" eol
}; };
let written = format!("{before}{NEW}{after}"); let grid = vec![vec![String::new(); 3]; 3];
let caret = from + before.len() + 2; 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"); log::info!("markdown document: a table goes in");
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.edit([(from..end, written)], cx); editor.edit([(from..end, format!("{before}{table}{after}"))], cx);
editor.select(std::iter::once(caret..caret), cx); editor.select(std::iter::once(caret..caret), cx);
}); });
Ok(()) Ok(())
@@ -247,42 +313,126 @@ mod tests {
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n"; 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> { fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
let at = text.find(at).expect("in the table"); let at = text.find(at).expect("in the table");
let (range, table, _) = edit_table(text, at, edit)?; let (edits, caret) = edit_table(text, at, edit)?;
Ok(format!( let (range, table) = &edits[0];
"{}{table}{}", let out = format!("{}{table}{}", &text[..range.start], &text[range.end..]);
&text[..range.start], Ok(format!("{}‸{}", &out[..caret.start], &out[caret.start..]))
&text[range.end..]
))
} }
fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> { fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
edited_in(TABLE, at, edit) edited_in(TABLE, at, edit)
} }
fn around(table: &str) -> Result<String, TableRefusal> {
Ok(format!("Intro\n\n{table}\n\nAfter\n"))
}
#[test] #[test]
fn rows_and_columns_come_and_go() { fn rows_and_columns_come_and_go() {
let around = |table: &str| Ok(format!("Intro\n\n{table}\n\nAfter\n"));
assert_eq!( assert_eq!(
edited("3", TableEdit::RowAbove), edited("3", TableEdit::RowAbove),
around("| A | B |\n| :-- | --: |\n| 1 | 2 |\n| | |\n| 3 | 4 |") around(
"| A | B |\n| :--- | ---: |\n| 1 | 2 |\n| ‸ | |\n| 3 | 4 |"
)
); );
assert_eq!( assert_eq!(
edited("A", TableEdit::RowBelow), edited("A", TableEdit::RowBelow),
around("| A | B |\n| :-- | --: |\n| | |\n| 1 | 2 |\n| 3 | 4 |") around(
"| A | B |\n| :--- | ---: |\n| ‸ | |\n| 1 | 2 |\n| 3 | 4 |"
)
); );
assert_eq!( assert_eq!(
edited("2", TableEdit::ColumnLeft), edited("2", TableEdit::ColumnLeft),
around("| A | | B |\n| :-- | --- | --: |\n| 1 | | 2 |\n| 3 | | 4 |") around(
"| A | | B |\n| :--- | ---- | ---: |\n| 1 | ‸ | 2 |\n| 3 | | 4 |"
)
); );
assert_eq!( assert_eq!(
edited("1", TableEdit::DeleteRow), edited("1", TableEdit::DeleteRow),
around("| A | B |\n| :-- | --: |\n| 3 | 4 |") around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |")
); );
assert_eq!( assert_eq!(
edited("B", TableEdit::DeleteColumn), edited("B", TableEdit::DeleteColumn),
around("| A |\n| :-- |\n| 1 |\n| 3 |") 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())
); );
} }
@@ -307,9 +457,18 @@ mod tests {
edited_in(text, "3", TableEdit::DeleteColumn), edited_in(text, "3", TableEdit::DeleteColumn),
Err(TableRefusal::Beyond) 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!( assert_eq!(
edited_in(text, "1", TableEdit::RowBelow), edited_in(text, "1", TableEdit::RowBelow),
Ok("| A | B |\n| --- | --- |\n| 1 | 2 | 3 |\n| | |\n".into()) Ok("| A | B |\n| ---- | ---- |\n| 1 | 2 | 3 |\n| ‸ | |\n".into())
); );
} }
@@ -318,7 +477,10 @@ mod tests {
let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n"; let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n";
assert_eq!( assert_eq!(
edited_in(text, "1", TableEdit::RowBelow), edited_in(text, "1", TableEdit::RowBelow),
Ok("> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n> | | |\n".into()) Ok(
"> | A | B |\n> | ---- | ---- |\n> | 1 | 2 |\n> | ‸ | |\n"
.into()
)
); );
let mixed = "- | A |\n | - |\n | 1 |\n"; let mixed = "- | A |\n | - |\n | 1 |\n";
assert_eq!( assert_eq!(
+374
View File
@@ -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"]);
}
+223
View File
@@ -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]&#10;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");
}
+302
View File
@@ -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 |"));
}
+426
View File
@@ -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), "![cat](‸)");
}
#[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("![c‸at](x.png)", 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> ![e](f.png)",
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");
}
+95
View File
@@ -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");
}
+7 -6
View File
@@ -2,6 +2,7 @@ use gpui::{TestAppContext, px, rems};
use super::{MarkdownMode, frames, open}; use super::{MarkdownMode, frames, open};
use crate::documents::DocumentLook; use crate::documents::DocumentLook;
use crate::documents::document::edit::Landing;
/// Sixty short paragraphs, each its own block. /// Sixty short paragraphs, each its own block.
fn long() -> String { fn long() -> String {
@@ -30,7 +31,7 @@ fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) {
}; };
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.set_look(look, cx); document.set_look(look, cx);
document.open(Some(40), false, window, cx); document.open(Some(40), Landing::End, window, cx);
document.list.scroll_to_reveal_item(40); document.list.scroll_to_reveal_item(40);
}); });
frames(cx); frames(cx);
@@ -99,7 +100,7 @@ fn the_scale_sizes_source_lines(cx: &mut TestAppContext) {
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) { fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx); let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.open(Some(3), false, window, cx) document.open(Some(3), Landing::End, window, cx)
}); });
assert_eq!( assert_eq!(
document.read_with(cx, |document, cx| document.current_block(cx)), document.read_with(cx, |document, cx| document.current_block(cx)),
@@ -137,7 +138,7 @@ fn off_middle(
fn an_open_block_takes_the_scale(cx: &mut TestAppContext) { fn an_open_block_takes_the_scale(cx: &mut TestAppContext) {
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx); let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.open(Some(0), false, window, cx) document.open(Some(0), Landing::End, window, cx)
}); });
frames(cx); frames(cx);
let plain = document let plain = document
@@ -163,7 +164,7 @@ fn a_resize_centers_the_block_again(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx); let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.set_look(typewriter(), cx); document.set_look(typewriter(), cx);
document.open(Some(40), false, window, cx); document.open(Some(40), Landing::End, window, cx);
document.list.scroll_to_reveal_item(40); document.list.scroll_to_reveal_item(40);
}); });
frames(cx); frames(cx);
@@ -177,13 +178,13 @@ fn the_first_and_last_blocks_center(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx); let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.set_look(typewriter(), cx); document.set_look(typewriter(), cx);
document.open(Some(0), false, window, cx); document.open(Some(0), Landing::End, window, cx);
}); });
frames(cx); frames(cx);
let first = off_middle(&document, 0, cx); let first = off_middle(&document, 0, cx);
assert!(first < px(4.0), "the first is {first:?} off"); assert!(first < px(4.0), "the first is {first:?} off");
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.open(Some(59), false, window, cx); document.open(Some(59), Landing::End, window, cx);
document.list.scroll_to_reveal_item(59); document.list.scroll_to_reveal_item(59);
}); });
frames(cx); frames(cx);
+19 -6
View File
@@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, visual}; use super::{AT_ONCE, DocumentEvent, MarkdownDocument, edit::Landing, visual};
use crate::documents::MarkdownMode; use crate::documents::MarkdownMode;
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n"; const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
@@ -210,8 +210,10 @@ fn the_source_colors_markdown(cx: &mut TestAppContext) {
document.editor().read(cx).line_colors("a **b**", cx) document.editor().read(cx).line_colors("a **b**", cx)
}); });
assert!( assert!(
colors.iter().any(|(range, style)| *range == (2..7) colors
&& style.font_weight == Some(gpui::FontWeight::SEMIBOLD)), .iter()
.any(|(range, style)| *range == (2..7)
&& style.font_weight == Some(gpui::FontWeight::BOLD)),
"{colors:?}" "{colors:?}"
); );
} }
@@ -236,7 +238,7 @@ pub(super) fn type_in(
cx: &mut VisualTestContext, cx: &mut VisualTestContext,
) { ) {
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.open(ix, false, window, cx); document.open(ix, Landing::End, window, cx);
if let (Some(typed), Some(open)) = (typed, &document.open) { if let (Some(typed), Some(open)) = (typed, &document.open) {
open.input.update(cx, |input, cx| input.set_text(typed, cx)); open.input.update(cx, |input, cx| input.set_text(typed, cx));
} }
@@ -353,11 +355,11 @@ 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 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); let (document, cx) = open(&long, MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| { document.update_in(cx, |document, window, cx| {
document.open(Some(0), false, window, cx); document.open(Some(0), Landing::End, window, cx);
let input = document.open.as_ref().expect("open").input.clone(); let input = document.open.as_ref().expect("open").input.clone();
input.update(cx, |input, cx| input.set_text("AAAA", cx)); input.update(cx, |input, cx| input.set_text("AAAA", cx));
// B is pressed before the new read lands. // B is pressed before the new read lands.
document.open(Some(1), false, window, cx); document.open(Some(1), Landing::End, window, cx);
assert!(document.open.is_none(), "a stale range opens nothing"); assert!(document.open.is_none(), "a stale range opens nothing");
}); });
cx.run_until_parked(); cx.run_until_parked();
@@ -366,10 +368,21 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
} }
mod attach; mod attach;
mod block;
mod block_edges;
mod cells;
mod format;
mod history; mod history;
mod lend;
mod links; mod links;
mod look; mod look;
mod open_look;
mod place; mod place;
mod point;
mod presses;
mod reading;
mod secondary;
mod toc;
mod tools; mod tools;
#[gpui::test] #[gpui::test]
+197
View File
@@ -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:?}");
}
+8
View File
@@ -229,3 +229,11 @@ fn the_selection_reads_in_source_and_in_an_open_block(cx: &mut TestAppContext) {
"words" "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);
}
+439
View File
@@ -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![pic](missing.png)\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;"));
}
+122
View File
@@ -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 &#10; 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 &#128; 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&#32;</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=&amp;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)]);
}
+181
View File
@@ -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]&#10;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 &amp; 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&#10;beta");
assert_eq!(quote.blocks[0].kind, TargetBlock::Quote);
let alert = parsed("> [!NOTE]&#10;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 &#10; y", "b"),
("ab &#13; y", "b"),
("ab &NewLine; y", "b"),
("<div>ab &#10; y</div>", "b"),
("<div>ab &#9; y</div>", "b"),
] {
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
}
}
#[test]
fn numeric_references_match_the_parser() {
for source in ["ab &#128; y", "ab &#1; y", "ab &#xFDD0; 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&#32;</div>",
"<div>ab&#32;<b>word</b></div>",
"<div>ab &#32; 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=&amp;b=2>";
assert_eq!(read_back(source, "xample"), source.find("xample"));
}
+221
View File
@@ -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 ![p](p.png) 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 ![a](a.png) ![b](b.png)\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 ![a](a.png) ");
}
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"));
}
+190
View File
@@ -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));
}
+187 -3
View File
@@ -47,7 +47,11 @@ fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) {
document.edit_table(crate::documents::TableEdit::RowBelow, cx) document.edit_table(crate::documents::TableEdit::RowBelow, cx)
}); });
assert_eq!(edited, Ok(())); assert_eq!(edited, Ok(()));
assert_eq!(text(&document, cx), "| A |\n| --- |\n| 1 |\n| |\n"); 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()); let editor = document.read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.undo(cx)); editor.update(cx, |editor, cx| editor.undo(cx));
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n"); assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
@@ -61,7 +65,25 @@ fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) {
}); });
assert_eq!( assert_eq!(
text(&document, cx), text(&document, cx),
"Intro\n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nNext\n" "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"
); );
} }
@@ -143,7 +165,7 @@ fn preview_takes_no_edits(cx: &mut TestAppContext) {
document.insert_table(cx), document.insert_table(cx),
) )
}); });
let preview = Err(crate::documents::TableRefusal::Preview); let preview = Err(crate::documents::EditRefusal::Preview);
assert_eq!(refused, (false, preview, preview)); assert_eq!(refused, (false, preview, preview));
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n"); assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
} }
@@ -247,5 +269,167 @@ fn a_table_on_an_indented_blank_line_is_a_table(cx: &mut TestAppContext) {
document.edit_table(crate::documents::TableEdit::ColumnRight, cx) document.edit_table(crate::documents::TableEdit::ColumnRight, cx)
}); });
assert_eq!(column, Ok(()), "{}", text(&document, cx)); assert_eq!(column, Ok(()), "{}", text(&document, cx));
cx.run_until_parked();
assert!(text(&document, cx).starts_with("| ")); 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);
}
+144 -27
View File
@@ -1,15 +1,19 @@
use gpui::{ use gpui::{
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement, AnyElement, App, ClickEvent, Context, Div, ExternalPaths, FontWeight, InteractiveElement,
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _, StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
prelude::FluentBuilder as _, relative,
}; };
use super::MarkdownDocument; use super::{MarkdownDocument, edit::Landing, parse::Block};
use crate::{ use crate::{
documents::{MarkdownMode, render::draw_nodes}, documents::{
MarkdownMode, TargetBlock,
render::{HEAVY, Hooks, OpenCell, Pointed, draw_nodes, heading_step, point_lines},
},
forms::{self, Down, Up}, forms::{self, Down, Up},
primitives::FocusRing as _, primitives::FocusRing as _,
theme::{ActiveTheme, Radius, TextSize}, theme::{ActiveTheme, Radius, TextSize, Theme},
}; };
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]); actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
@@ -19,6 +23,44 @@ const CONTEXT: &str = "MarkdownDocument";
/// How far a faded block recedes. /// How far a faded block recedes.
const FADED: f32 = 0.35; 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. /// Modes with a reader's block to keep.
fn editing_mode(mode: MarkdownMode) -> bool { fn editing_mode(mode: MarkdownMode) -> bool {
mode == MarkdownMode::Visual mode == MarkdownMode::Visual
@@ -55,6 +97,9 @@ impl MarkdownDocument {
/// A block, or the end. /// A block, or the end.
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement { 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 theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let (radius, body, mono) = ( let (radius, body, mono) = (
@@ -64,30 +109,35 @@ impl MarkdownDocument {
); );
// Focus fades all but the reader's block. // Focus fades all but the reader's block.
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx); let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
let editing = self.mode == MarkdownMode::Visual;
let open = self.open.as_ref(); let open = self.open.as_ref();
let field = |open: &super::Open| {
div()
.text_size(body)
.px_3()
.py_2()
.rounded(radius)
.bg(colors.sunken)
.child(open.input.clone())
.into_any_element()
};
let Some(block) = self.parsed.blocks.get(ix) else { let Some(block) = self.parsed.blocks.get(ix) else {
return match (open, editing) { return match (open, editing) {
(Some(open), _) if open.append => field(open), (Some(open), _) if open.append => self.field(open, None, cx),
(_, true) => self (_, true) => self
.pressable(div().id("more").min_h_8(), None, cx) .pressable(div().id("more").min_h_8(), None, cx)
.into_any_element(), .into_any_element(),
(_, false) => div().into_any_element(), (_, false) => div().into_any_element(),
}; };
}; };
if let Some(open) = open.filter(|open| open.range == block.range) { if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) {
return field(open); 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 { let shown = match &block.nodes {
Some(nodes) => div() Some(nodes) => div()
.flex() .flex()
@@ -99,16 +149,35 @@ impl MarkdownDocument {
.children(draw_nodes( .children(draw_nodes(
&("render", ix).into(), &("render", ix).into(),
nodes, nodes,
&self.base, Hooks {
&self.on_link, base: &self.base,
on_link: &self.on_link,
on_heading: &self.on_heading,
on_point: &on_point,
cell: cell.as_ref(),
},
self.look.scale, self.look.scale,
cx, cx,
)), )),
None => div() 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) .text_size(body)
.font_family(mono) .font_family(mono)
.text_color(colors.fg_muted) .text_color(colors.fg_muted)
.child(self.parsed.text[block.range.clone()].to_string()), .child(drawn)
.into_any_element();
div().child(point_lines(
shown,
layout,
raw,
0,
&on_point,
(false, false),
))
}
}; };
let block = div() let block = div()
.id(("block", ix)) .id(("block", ix))
@@ -124,7 +193,39 @@ impl MarkdownDocument {
} }
} }
/// `element` opens block `ix` on a press, Enter or Space. /// 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( fn pressable(
&self, &self,
element: gpui::Stateful<gpui::Div>, element: gpui::Stateful<gpui::Div>,
@@ -148,13 +249,21 @@ impl MarkdownDocument {
} }
window.prevent_default(); window.prevent_default();
cx.stop_propagation(); cx.stop_propagation();
document.open(ix, false, window, cx); 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( .on_click(
cx.listener(move |document, event: &ClickEvent, window, cx| { cx.listener(move |document, event: &ClickEvent, window, cx| {
if event.is_keyboard() { if event.is_keyboard() {
document.open(ix, false, window, cx); document.open(ix, Landing::End, window, cx);
} }
}), }),
) )
@@ -163,9 +272,11 @@ impl MarkdownDocument {
impl Render for MarkdownDocument { impl Render for MarkdownDocument {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 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. // A top line asked before its text was read.
self.place_top(window, cx); self.place_top(window, cx);
self.show_waiting(window, cx); self.show_waiting(window, cx);
self.open_waiting(window, cx);
let colors = cx.theme().colors.clone(); let colors = cx.theme().colors.clone();
let root = div() let root = div()
.size_full() .size_full()
@@ -241,7 +352,13 @@ impl Render for MarkdownDocument {
} }
})) }))
.on_action(cx.listener(|document, _: &Paste, _, cx| { .on_action(cx.listener(|document, _: &Paste, _, cx| {
document.paste_picture(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| { .capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
if document.block_newline(cx) { if document.block_newline(cx) {
+41 -11
View File
@@ -1,10 +1,12 @@
use std::ops::Range; use std::ops::Range;
use gpui::{App, FontWeight}; mod tex;
use gpui::App;
use markdown::{Constructs, ParseOptions, mdast}; use markdown::{Constructs, ParseOptions, mdast};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use crate::{forms::Highlight, theme::ActiveTheme}; use crate::{documents::render::HEAVY, forms::Highlight, theme::ActiveTheme};
/// GitHub's flavor, and math between dollars. /// GitHub's flavor, and math between dollars.
pub(crate) fn options() -> ParseOptions { pub(crate) fn options() -> ParseOptions {
@@ -20,19 +22,35 @@ pub(crate) fn options() -> ParseOptions {
/// `text` as a syntax tree. /// `text` as a syntax tree.
pub(crate) fn tree(text: &str) -> mdast::Node { pub(crate) fn tree(text: &str) -> mdast::Node {
markdown::to_mdast(text, &options()).expect("markdown without MDX always parses") 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. /// How a whole file parses: front matter too.
pub fn file_options() -> ParseOptions { pub(crate) fn file_options() -> ParseOptions {
let mut options = options(); let mut options = options();
options.constructs.frontmatter = true; options.constructs.frontmatter = true;
options options
} }
/// A whole file as a syntax tree. /// A whole file as a syntax tree.
pub(crate) fn file_tree(text: &str) -> mdast::Node { pub fn file_tree(text: &str) -> mdast::Node {
markdown::to_mdast(text, &file_options()).expect("markdown without MDX always parses") parsed(text, &file_options())
} }
/// The bytes of the source a parsed node spans. /// The bytes of the source a parsed node spans.
@@ -83,7 +101,7 @@ pub(crate) fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) {
/// The marks over `text`, with the syntax writing each. /// The marks over `text`, with the syntax writing each.
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> { pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
let root = tree(text); let root = file_tree(text);
let mut nodes = Vec::new(); let mut nodes = Vec::new();
walk(&root, &mut nodes); walk(&root, &mut nodes);
let mut out = Vec::new(); let mut out = Vec::new();
@@ -141,10 +159,22 @@ pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
/// Markdown styled as typed; its syntax recedes. /// Markdown styled as typed; its syntax recedes.
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> { pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let colors = &cx.theme().colors; let colors = &cx.theme().colors;
let plain = Highlight::new(colors.fg); let marks = marks(text);
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() .into_iter()
.map(|(range, mark)| { .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 { let range = match mark {
Mark::Strike => { Mark::Strike => {
let span = &text[range.clone()]; let span = &text[range.clone()];
@@ -155,7 +185,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
}; };
let highlight = match mark { let highlight = match mark {
Mark::Strong | Mark::Heading(_) => Highlight { Mark::Strong | Mark::Heading(_) => Highlight {
weight: Some(FontWeight::SEMIBOLD), weight: Some(HEAVY),
..plain ..plain
}, },
Mark::Emphasis => Highlight { Mark::Emphasis => Highlight {
@@ -168,7 +198,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
}, },
Mark::Code => Highlight { Mark::Code => Highlight {
background: Some(colors.sunken), background: Some(colors.sunken),
..plain ..Highlight::new(colors.fg)
}, },
Mark::Link => Highlight::new(colors.link), Mark::Link => Highlight::new(colors.link),
Mark::Quote => Highlight::new(colors.fg_muted), Mark::Quote => Highlight::new(colors.fg_muted),
+294
View File
@@ -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;
+123
View File
@@ -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"![p](pics/\(x\).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");
}
+7 -3
View File
@@ -36,8 +36,9 @@ pub use counts::{ReadingTime, WordCount};
pub use diagram::{Diagram, diagram_svg}; pub use diagram::{Diagram, diagram_svg};
pub(crate) use diagram::{flatten, options as svg_options}; pub(crate) use diagram::{flatten, options as svg_options};
pub use document::{ pub use document::{
Attachment, DocumentEvent, DocumentHeading, DocumentLook, LARGEST_READ, MarkdownDocument, Attachment, BlockCommand, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget,
TableEdit, TableRefusal, EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal,
TargetBlock,
}; };
pub use epub::{Chapter, EpubReader}; pub use epub::{Chapter, EpubReader};
pub use format::{Format, format}; pub use format::{Format, format};
@@ -45,7 +46,7 @@ pub(crate) use history::Change;
pub use history::{PageHistoryDiff, Version, VersionHistory}; pub use history::{PageHistoryDiff, Version, VersionHistory};
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed}; pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
pub use link::LinkEditor; pub use link::LinkEditor;
pub use markdown::file_options as markdown_options; pub use markdown::file_tree as markdown_tree;
pub use markdown::markdown_highlights; pub use markdown::markdown_highlights;
pub use modes::{MarkdownEditor, MarkdownMode}; pub use modes::{MarkdownEditor, MarkdownMode};
pub use pages::{PageCover, PageIcon}; pub use pages::{PageCover, PageIcon};
@@ -57,6 +58,9 @@ pub use templates::{Template, TemplatePicker};
pub use thumbs::PageThumbnailList; pub use thumbs::PageThumbnailList;
pub use todo::{Checklist, TodoItem}; pub use todo::{Checklist, TodoItem};
pub use toolbar::FixedFormatToolbar; 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(crate) use viewer::step as zoom_step;
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote}; pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
+203 -186
View File
@@ -1,11 +1,10 @@
use std::{path::Path, rc::Rc, sync::Arc}; use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
use gpui::{ use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
UnderlineStyle, Window, div, prelude::*, StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
}; };
use markdown::mdast::AlignKind;
use super::{ use super::{
contents::anchor, contents::anchor,
@@ -16,12 +15,22 @@ use super::{
use crate::{ use crate::{
editor::{code_colors, code_colors_with}, editor::{code_colors, code_colors_with},
navigation::{Anchor, Sections}, navigation::{Anchor, Sections},
primitives::{FocusRing as _, Icon, IconName}, primitives::FocusRing as _,
theme::{ActiveTheme, IconSize, Radius, TextSize}, theme::{ActiveTheme, Palette, Radius, TextSize},
typography::{Latex, failed}, typography::{Latex, failed},
}; };
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)>; 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>; type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
/// Markdown drawn as a document; links go to the host. /// Markdown drawn as a document; links go to the host.
@@ -83,37 +92,35 @@ impl MarkdownRenderer {
} }
} }
/// Draws nodes under one owner's ids and link handler. /// What an owner hands its drawing: pictures' folder and presses.
struct Draw<'a> { pub(crate) struct Hooks<'a> {
id: &'a ElementId, pub base: &'a Option<Arc<Path>>,
on_link: &'a Option<OnLink>, pub on_link: &'a Option<OnLink>,
next: std::cell::Cell<usize>, pub on_heading: &'a Option<OnHeading>,
sections: &'a Option<Entity<Sections>>, pub on_point: &'a Option<OnPoint>,
headings: std::cell::Cell<usize>, /// The cell typed in, drawn in its place.
code: &'a Option<DrawCode>, pub cell: Option<&'a OpenCell>,
base: &'a Option<Arc<Path>>,
/// Text size against the theme's.
scale: f32,
} }
impl Draw<'_> { /// Headings and strong text share one weight.
fn key(&self, what: &str) -> ElementId { pub(crate) const HEAVY: FontWeight = FontWeight::BOLD;
let at = self.next.get();
self.next.set(at + 1);
(self.id.clone(), format!("{what}-{at}")).into()
}
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement { /// Typora's heading steps over the body.
let theme = cx.theme(); pub(crate) fn heading_step(level: u8) -> f32 {
let colors = &theme.colors; match level {
let rule = theme.underline_thickness(); 1 => 2.25,
let styles: Vec<_> = inline 2 => 1.75,
.styles 3 => 1.5,
.iter() 4 => 1.25,
.map(|(range, style)| { _ => 1.0,
let highlight = match style { }
}
/// How one inline style draws.
fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
match style {
Style::Strong => HighlightStyle { Style::Strong => HighlightStyle {
font_weight: Some(FontWeight::SEMIBOLD), font_weight: Some(HEAVY),
..HighlightStyle::default() ..HighlightStyle::default()
}, },
Style::Emphasis => HighlightStyle { Style::Emphasis => HighlightStyle {
@@ -149,29 +156,115 @@ impl Draw<'_> {
color: Some(colors.fg_subtle), color: Some(colors.fg_subtle),
..HighlightStyle::default() ..HighlightStyle::default()
}, },
}; Style::Underline => HighlightStyle {
(range.clone(), highlight) underline: Some(UnderlineStyle {
}) thickness: rule,
.collect(); color: None,
let styled = wavy: false,
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); }),
if inline.links.is_empty() { ..HighlightStyle::default()
return styled.into_any_element(); },
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 (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
let on_link = self.on_link.clone(); let on_link = on_link.clone();
InteractiveText::new(self.key("links"), styled) InteractiveText::new(id, styled)
.on_click(ranges, move |ix, window, cx| { .on_click(ranges, move |ix, window, cx| {
open_link(&on_link, &urls[ix], window, cx) open_link(&on_link, &urls[ix], window, cx)
}) })
.into_any_element() .into_any_element()
} }
};
(drawn, layout)
}
/// Draws nodes under one owner's ids and hooks.
struct Draw<'a> {
id: &'a ElementId,
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<'_> {
fn key(&self, what: &str) -> ElementId {
let at = self.next.get();
self.next.set(at + 1);
(self.id.clone(), format!("{what}-{at}")).into()
}
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
let 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 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> { fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
nodes.iter().map(|node| self.node(node, cx)).collect() nodes.iter().map(|node| self.node(node, cx)).collect()
} }
fn node(&self, node: &Node, cx: &App) -> AnyElement { 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 theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
match node { match node {
@@ -180,24 +273,22 @@ impl Draw<'_> {
.child(self.inline(inline, cx)) .child(self.inline(inline, cx))
.into_any_element(), .into_any_element(),
Node::Heading(level, inline) => { Node::Heading(level, inline) => {
let size = match level { // Every level bold.
1 => TextSize::Xxl, let size = theme.text_size(self.body) * heading_step(*level) * self.scale;
2 => TextSize::Xl,
3 => TextSize::Lg,
_ => TextSize::Md,
};
let heading = div()
.pt_2()
.text_size(theme.text_size(size) * self.scale)
.font_weight(if *level <= 2 {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(colors.fg)
.child(self.inline(inline, cx));
let ordinal = self.headings.get(); let ordinal = self.headings.get();
self.headings.set(ordinal + 1); 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 { match self.sections {
Some(sections) => Anchor::new(sections, anchor(ordinal)) Some(sections) => Anchor::new(sections, anchor(ordinal))
.child(heading) .child(heading)
@@ -215,9 +306,14 @@ impl Draw<'_> {
.text_color(colors.fg_muted) .text_color(colors.fg_muted)
.children(self.nodes(inner, cx)) .children(self.nodes(inner, cx))
.into_any_element(), .into_any_element(),
Node::Code { language, text } => { Node::Code {
language,
text,
from,
} => {
if language.as_deref() == Some("mermaid") { if language.as_deref() == Some("mermaid") {
return Diagram::new(self.key("diagram"), text.clone()).into_any_element(); 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 { if let Some(draw) = self.code {
return draw( return draw(
@@ -232,7 +328,9 @@ impl Draw<'_> {
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)), .map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
None => code_colors(text, cx), None => code_colors(text, cx),
}; };
div() let code = StyledText::new(text.clone()).with_highlights(styles);
let layout = code.layout().clone();
let block = div()
.flex() .flex()
.flex_col() .flex_col()
.gap_1() .gap_1()
@@ -248,120 +346,17 @@ impl Draw<'_> {
.text_color(colors.fg_subtle) .text_color(colors.fg_subtle)
.child(name) .child(name)
})) }))
.child(StyledText::new(text.clone()).with_highlights(styles)) .child(code)
.into_any_element() .into_any_element();
} self.point_code(block, Some(layout), text, *from)
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 = |blocks: &[Node], column: usize, header: bool, cx: &App| {
div()
.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(header, |cell| {
cell.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
})
.child(
div()
.max_w_full()
.flex()
.flex_col()
.gap_1()
.children(self.nodes(blocks, 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, blocks)| cell(blocks, column, true, cx)),
),
)
.children(rows.iter().map(|row| {
div()
.flex()
.border_t_1()
.border_color(colors.border)
.children(
row.iter()
.enumerate()
.map(|(column, blocks)| cell(blocks, column, false, cx)),
)
}))
.into_any_element()
} }
Node::List { start, items } => self.list(start, items, cx),
Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
Node::Rule => div() Node::Rule => div()
.debug_selector(|| "markdown-rule".into())
.my_2() .my_2()
.h_0() .border_t_2()
.border_t_1() .border_color(colors.border_strong)
.border_color(colors.border)
.into_any_element(), .into_any_element(),
Node::Math(tex) => div() Node::Math(tex) => div()
.flex() .flex()
@@ -372,24 +367,29 @@ impl Draw<'_> {
Err(error) => failed(&error, tex, cx), Err(error) => failed(&error, tex, cx),
}) })
.into_any_element(), .into_any_element(),
Node::Image { alt, url, link } => { Node::Image { alt, url, link, at } => {
let picture = div().id(self.key("image")).child(Picture { let picture = div().id(self.key("image")).child(Picture {
id: self.key("picture"), id: self.key("picture"),
alt: alt.clone(), alt: alt.clone(),
url: url.clone(), url: url.clone(),
base: self.base.clone(), base: self.hooks.base.clone(),
}); });
let Some(link) = link.clone() else { let drawn = match link.clone() {
return picture.into_any_element(); None => picture.into_any_element(),
}; Some(address) => {
let on_link = self.on_link.clone(); let on_link = self.hooks.on_link.clone();
picture picture
.tab_index(0) .tab_index(0)
.focus_ring(cx) .focus_ring(cx)
.cursor_pointer() .cursor_pointer()
.on_click(move |_, window, cx| open_link(&on_link, &link, window, cx)) .on_click(move |_, window, cx| {
open_link(&on_link, &address, window, cx)
})
.into_any_element() .into_any_element()
} }
};
self.point_image(drawn, *at, url, link)
}
Node::Footnote { label, body } => div() Node::Footnote { label, body } => div()
.flex() .flex()
.gap_2() .gap_2()
@@ -404,6 +404,9 @@ impl Draw<'_> {
.children(self.nodes(body, cx)), .children(self.nodes(body, cx)),
) )
.into_any_element(), .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),
} }
} }
} }
@@ -418,24 +421,26 @@ fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut
} }
} }
/// Parsed `nodes` drawn under `id`; pictures read from `base`. /// Parsed `nodes` drawn under `id`, with the owner's hooks.
pub(crate) fn draw_nodes( pub(crate) fn draw_nodes(
id: &ElementId, id: &ElementId,
nodes: &[Node], nodes: &[Node],
base: &Option<Arc<Path>>, hooks: Hooks,
on_link: &Option<OnLink>,
scale: f32, scale: f32,
cx: &App, cx: &App,
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let draw = Draw { let draw = Draw {
id, id,
on_link, hooks,
next: std::cell::Cell::new(0), next: std::cell::Cell::new(0),
sections: &None, sections: &None,
headings: std::cell::Cell::new(0), headings: std::cell::Cell::new(0),
code: &None, code: &None,
base, body: TextSize::Base,
scale, scale,
lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
}; };
draw.nodes(nodes, cx) draw.nodes(nodes, cx)
} }
@@ -446,13 +451,22 @@ impl RenderOnce for MarkdownRenderer {
let nodes = parse(&self.source); let nodes = parse(&self.source);
let draw = Draw { let draw = Draw {
id: &self.id, id: &self.id,
hooks: Hooks {
base: &self.base,
on_link: &self.on_link, on_link: &self.on_link,
on_heading: &None,
on_point: &None,
cell: None,
},
next: std::cell::Cell::new(0), next: std::cell::Cell::new(0),
sections: &self.sections, sections: &self.sections,
headings: std::cell::Cell::new(0), headings: std::cell::Cell::new(0),
code: &self.code, code: &self.code,
base: &self.base, body: self.size,
scale: 1.0, scale: 1.0,
lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
}; };
div() div()
.flex() .flex()
@@ -463,3 +477,6 @@ impl RenderOnce for MarkdownRenderer {
.children(draw.nodes(&nodes, cx)) .children(draw.nodes(&nodes, cx))
} }
} }
#[cfg(test)]
mod tests;
+197
View File
@@ -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),
)
}
}
+433
View File
@@ -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:?}");
}
}
+207
View File
@@ -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))
})
}
}
+106
View File
@@ -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()
}
}
+153
View File
@@ -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:?}");
}
+216
View File
@@ -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 @ ..] = &quote.children[..] else {
return None;
};
let (kind, after) = marker(&paragraph.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(&note.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)),
("> [!NOT&#69;]\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(_)));
}
}
+103
View File
@@ -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:?}");
}
}
+476
View File
@@ -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&amp;p=2&#38;r=&#x33;&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&#38page=2\">", "?q=1&page=2"),
("<a href=\"/&#128;&#x9F;&#129;\">", "/€Ÿ\u{81}"),
(
"<a href=\"&#0;&#x110000;&#xD800;\">",
"\u{FFFD}\u{FFFD}\u{FFFD}",
),
(
"<a href=\"&amp page&ampx&amp=&notin;&AMP\">",
"& page&ampx&amp=∉&",
),
("<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(&amps, true).len(), amps.len());
assert_eq!(text(&amps).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("&notit; &amp &ampx &lt3 &nope;"),
"¬it; & &x <3 &nope;"
);
assert_eq!(decoded("&notit;&ampx", true), "&notit;&ampx");
}
#[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), "¹²⁰");
}
}
+318
View File
@@ -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: &amp; <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 &lt;b&gt;</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:?}");
}
}
+212 -61
View File
@@ -4,6 +4,19 @@ use markdown::mdast::{self, AlignKind};
use super::markdown::{span, tree, walk}; 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. /// How a stretch of inline text prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Style { pub enum Style {
@@ -14,6 +27,9 @@ pub enum Style {
Link, Link,
Math, Math,
Footnote, Footnote,
Underline,
/// A highlighted run, as `<mark>`.
Mark,
} }
/// Inline text, its styled stretches and link addresses. /// Inline text, its styled stretches and link addresses.
@@ -22,6 +38,8 @@ pub struct Inline {
pub text: String, pub text: String,
pub styles: Vec<(Range<usize>, Style)>, pub styles: Vec<(Range<usize>, Style)>,
pub links: Vec<(Range<usize>, String)>, 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. /// A block of a markdown document.
@@ -33,6 +51,8 @@ pub enum Node {
Code { Code {
language: Option<String>, language: Option<String>,
text: String, text: String,
/// Where its lines start in the source.
from: usize,
}, },
/// Items and blocks; ordered from `start`; tasks carry checkboxes. /// Items and blocks; ordered from `start`; tasks carry checkboxes.
List { List {
@@ -52,24 +72,64 @@ pub enum Node {
alt: String, alt: String,
url: String, url: String,
link: Option<String>, link: Option<String>,
/// Its source start.
at: usize,
}, },
Footnote { Footnote {
label: String, label: String,
body: Vec<Node>, 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. /// A tree read against its source and its link definitions.
pub(crate) struct Reader<'a> { pub struct Reader<'a> {
source: &'a str, source: &'a str,
urls: HashMap<String, String>, 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. /// Inline text being built, and the containers open around it.
#[derive(Default)] #[derive(Default)]
struct Run { struct Run {
text: Inline, text: Inline,
open: Vec<(usize, Style, Option<String>)>, 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 { impl Run {
@@ -77,48 +137,95 @@ impl Run {
fn cut(&mut self) -> Inline { fn cut(&mut self) -> Inline {
let mut done = std::mem::take(&mut self.text); let mut done = std::mem::take(&mut self.text);
let end = done.text.len(); let end = done.text.len();
for (start, style, url) in self.open.iter_mut().rev() { for opened in self.open.iter_mut().rev() {
if *start < end { close(&mut done, opened, end);
done.styles.push((*start..end, *style)); opened.start = 0;
if let Some(url) = url {
done.links.push((*start..end, url.clone()));
}
}
*start = 0;
} }
done done
} }
/// The innermost open link's address. fn count(&self, name: &str) -> usize {
fn link(&self) -> Option<String> { self.named.get(name).copied().unwrap_or(0)
self.open.iter().rev().find_map(|(_, _, url)| url.clone()) }
/// 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. /// `markdown` as blocks.
pub fn parse(markdown: &str) -> Vec<Node> { pub fn parse(markdown: &str) -> Vec<Node> {
let root = tree(markdown); let root = tree(markdown);
let mut out = Vec::new(); let (reader, mut out) = (Reader::new(markdown, &root), Vec::new());
Reader::new(markdown, &root).blocks( for node in root.children().expect("the root holds the blocks") {
root.children().expect("the root holds the blocks"), reader.top(node, &mut out);
&mut out, }
);
out out
} }
impl<'a> Reader<'a> { impl<'a> Reader<'a> {
/// A reader of `root`, parsed from `source`. /// Reads `root`, parsed from `source`, as drawn on screen.
pub(crate) fn new(source: &'a str, root: &mdast::Node) -> Self { 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(); let mut nodes = Vec::new();
walk(root, &mut nodes); walk(root, &mut nodes);
let mut urls = HashMap::new(); let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
for node in nodes { for node in nodes {
if let mdast::Node::Definition(definition) = node { match node {
mdast::Node::Definition(definition) => {
urls.entry(definition.identifier.clone()) urls.entry(definition.identifier.clone())
.or_insert_with(|| definition.url.clone()); .or_insert_with(|| definition.url.clone());
} }
mdast::Node::FootnoteReference(note) => {
let next = notes.len() + 1;
notes.entry(note.identifier.clone()).or_insert(next);
} }
Self { source, urls } 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>) { fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec<Node>) {
@@ -141,11 +248,12 @@ impl<'a> Reader<'a> {
.expect("a reference names a definition") .expect("a reference names a definition")
} }
pub(crate) fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) { /// `node`'s blocks, as the drawing reads them.
pub fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
match node { match node {
mdast::Node::Paragraph(paragraph) => self.paragraph(node, &paragraph.children, out), mdast::Node::Paragraph(paragraph) => self.paragraph(node, &paragraph.children, out),
mdast::Node::Heading(heading) => { mdast::Node::Heading(heading) => {
let (mut run, mut after) = (Run::default(), Vec::new()); let (mut run, mut after) = (self.run(), Vec::new());
for child in &heading.children { for child in &heading.children {
self.inline_into(child, &mut run, &mut Images::After(&mut after)); self.inline_into(child, &mut run, &mut Images::After(&mut after));
} }
@@ -156,6 +264,7 @@ impl<'a> Reader<'a> {
mdast::Node::Code(code) => out.push(Node::Code { mdast::Node::Code(code) => out.push(Node::Code {
language: code.lang.clone(), language: code.lang.clone(),
text: code.value.clone(), text: code.value.clone(),
from: sources::body(self.source, node),
}), }),
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())), mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
mdast::Node::List(list) => { mdast::Node::List(list) => {
@@ -193,34 +302,67 @@ impl<'a> Reader<'a> {
} }
mdast::Node::ThematicBreak(_) => out.push(Node::Rule), mdast::Node::ThematicBreak(_) => out.push(Node::Rule),
mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote { mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote {
label: note label: self.label(note),
.label
.clone()
.unwrap_or_else(|| note.identifier.clone()),
body: self.nested(&note.children), body: self.nested(&note.children),
}), }),
mdast::Node::Html(_) | mdast::Node::Definition(_) => {} 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"), other => unreachable!("{other:?} is no block outside MDX"),
} }
} }
/// Text split at its images; lone `$$` math is block. /// Text splits at images; a lone formula is block.
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) { fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
if let [mdast::Node::InlineMath(math)] = children if let [mdast::Node::InlineMath(math)] = children
&& self.source[span(node)].starts_with("$$") && ["$$", "\\["]
.iter()
.any(|open| self.source[span(&children[0])].starts_with(open))
{ {
return out.push(Node::Math(math.value.clone())); return out.push(Node::Math(math.value.trim().to_string()));
} }
let mut run = Run::default(); let mut run = Run {
table: matches!(node, mdast::Node::TableCell(_)),
..self.run()
};
for child in children { for child in children {
self.inline_into(child, &mut run, &mut Images::Split(out)); self.inline_into(child, &mut run, &mut Images::Split(out));
} }
text_block(run.cut(), 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(&note.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. /// Inline text where images draw as their alt text.
pub(crate) fn inline(&self, nodes: &[mdast::Node]) -> Inline { pub fn inline(&self, nodes: &[mdast::Node]) -> Inline {
let mut run = Run::default(); let mut run = self.run();
for node in nodes { for node in nodes {
self.inline_into(node, &mut run, &mut Images::Alt); self.inline_into(node, &mut run, &mut Images::Alt);
} }
@@ -228,18 +370,28 @@ impl<'a> Reader<'a> {
} }
fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) { fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
let text = &mut run.text; // Any element ends the DOM text node.
let (children, style, url) = match node { if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) {
mdast::Node::Text(words) => { run.shortcodes.split();
let soft = words.value.replace("\r\n", " ").replace(['\r', '\n'], " ");
return text.text.push_str(&soft);
} }
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::Break(_) => return text.text.push('\n'),
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code), mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math), mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math),
mdast::Node::FootnoteReference(note) => { mdast::Node::FootnoteReference(note) => {
let label = note.label.as_ref().unwrap_or(&note.identifier); let number = self.notes[&note.identifier];
return styled(text, &format!("[{label}]"), Style::Footnote); return styled(text, &html::superscript(number), Style::Footnote);
} }
mdast::Node::Image(_) | mdast::Node::ImageReference(_) => { mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
let (alt, url) = match node { let (alt, url) = match node {
@@ -249,40 +401,39 @@ impl<'a> Reader<'a> {
} }
_ => unreachable!("matched as an image"), _ => unreachable!("matched as an image"),
}; };
let link = run.link(); let (link, at) = (run.link(), span(node).start);
return match images { return match images {
Images::Alt => run.text.text.push_str(&alt), Images::Alt => run.text.text.push_str(&alt),
Images::Split(out) => { Images::Split(out) => {
text_block(run.cut(), out); text_block(run.cut(), out);
out.push(Node::Image { alt, url, link }) out.push(Node::Image { alt, url, link, at })
} }
Images::After(out) => out.push(Node::Image { alt, url, link }), Images::After(out) => out.push(Node::Image { alt, url, link, at }),
}; };
} }
mdast::Node::Html(_) => return, mdast::Node::Html(written) => return run.html(&written.value),
mdast::Node::Strong(strong) => (&strong.children, Style::Strong, None), mdast::Node::Strong(strong) => (&strong.children, ("strong", Style::Strong), None),
mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis, None), mdast::Node::Emphasis(emphasis) => (&emphasis.children, ("em", Style::Emphasis), None),
mdast::Node::Delete(delete) => (&delete.children, Style::Strike, None), mdast::Node::Delete(delete) => (&delete.children, ("del", Style::Strike), None),
mdast::Node::Link(link) => (&link.children, Style::Link, Some(link.url.clone())), mdast::Node::Link(link) => (&link.children, ("a", Style::Link), Some(link.url.clone())),
mdast::Node::LinkReference(link) => ( mdast::Node::LinkReference(link) => (
&link.children, &link.children,
Style::Link, ("a", Style::Link),
Some(self.url(&link.identifier)), Some(self.url(&link.identifier)),
), ),
other => unreachable!("{other:?} is no inline outside MDX"), other => unreachable!("{other:?} is no inline outside MDX"),
}; };
run.open.push((run.text.text.len(), style, url)); // 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 { for child in children {
self.inline_into(child, run, images); self.inline_into(child, run, images);
} }
let (start, style, url) = run.open.pop().expect("the container opened above"); run.verbatim = outer;
let end = run.text.text.len(); run.end(name);
if start < end {
run.text.styles.push((start..end, style));
if let Some(url) = url {
run.text.links.push((start..end, url));
}
}
} }
} }
+432
View File
@@ -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 &amp; 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");
}
}
+88
View File
@@ -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()));
}
}
+283 -16
View File
@@ -25,7 +25,8 @@ fn blocks_nest_as_written() {
nodes[4], nodes[4],
Node::Code { Node::Code {
language: Some("rust".into()), language: Some("rust".into()),
text: "fn main() {}".into() text: "fn main() {}".into(),
from: 95
} }
); );
assert_eq!(nodes[5], Node::Rule); assert_eq!(nodes[5], Node::Rule);
@@ -39,7 +40,8 @@ fn an_image_inside_an_image_is_the_outer_one() {
[Node::Image { [Node::Image {
alt: "outer ".into(), alt: "outer ".into(),
url: "outer.png".into(), url: "outer.png".into(),
link: None link: None,
at: 0
}] }]
); );
} }
@@ -53,7 +55,8 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
Node::Image { Node::Image {
alt: "a".into(), alt: "a".into(),
url: "a.png".into(), url: "a.png".into(),
link: None link: None,
at: 7
} }
); );
let Node::Paragraph(after) = &nodes[2] else { let Node::Paragraph(after) = &nodes[2] else {
@@ -65,20 +68,31 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
} }
#[test] #[test]
fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() { fn a_soft_break_draws_as_a_line_and_joins_for_output() {
for (source, shown) in [ for source in [
("one\ntwo \nthree", "one two\nthree"), "one\ntwo \nthree",
("one\r\ntwo \r\nthree", "one two\nthree"), "one\r\ntwo \r\nthree",
("one\rtwo \rthree", "one two\nthree"), "one\rtwo \rthree",
] { ] {
let nodes = parse(source); let nodes = parse(source);
assert!( assert!(
matches!(&nodes[0], Node::Paragraph(text) if text.text == shown), matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"),
"{source:?} reads {nodes:?}" "{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] #[test]
fn definitions_nested_in_quotes_and_lists_resolve() { fn definitions_nested_in_quotes_and_lists_resolve() {
for source in [ for source in [
@@ -95,7 +109,8 @@ fn definitions_nested_in_quotes_and_lists_resolve() {
Node::Image { Node::Image {
alt: "pic".into(), alt: "pic".into(),
url: "a.png".into(), url: "a.png".into(),
link: None link: None,
at: 7
} }
); );
} }
@@ -116,7 +131,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
Node::Image { Node::Image {
alt: "x".into(), alt: "x".into(),
url: "x.png".into(), url: "x.png".into(),
link: None link: None,
at: 3
} }
); );
let Node::Paragraph(after) = &nodes[2] else { let Node::Paragraph(after) = &nodes[2] else {
@@ -131,7 +147,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
Node::Image { Node::Image {
alt: "y".into(), alt: "y".into(),
url: "y.png".into(), url: "y.png".into(),
link: Some("https://ely.dev".into()) link: Some("https://ely.dev".into()),
at: 19
} }
); );
assert_eq!(nodes.len(), 4, "{nodes:?}"); assert_eq!(nodes.len(), 4, "{nodes:?}");
@@ -189,7 +206,257 @@ fn footnotes_number_their_references_and_hold_their_body() {
let Node::Paragraph(line) = &nodes[0] else { let Node::Paragraph(line) = &nodes[0] else {
panic!("a paragraph, not {:?}", nodes[0]) panic!("a paragraph, not {:?}", nodes[0])
}; };
assert_eq!(line.text, "Lift[a]."); assert_eq!(line.text, "Lift¹.");
assert_eq!(line.styles, [(4..7, Style::Footnote)]); assert_eq!(line.styles, [(4..6, Style::Footnote)]);
assert!(matches!(&nodes[1], Node::Footnote { label, body } if label == "a" && body.len() == 1)); 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 &#58;smile: and :sm&#105;le:.");
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&amp;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&amp;: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
View File
@@ -1,6 +1,6 @@
use std::ops::Range; 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. /// 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> { 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) 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. /// A range through `edits`; none when an edit cut into it.
fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> { fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
Some(moved(range.start, edits)?..moved(range.end, edits)?) 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)?; ghost.offset = moved(ghost.offset, edits)?;
Some(ghost) 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. /// 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.backgrounds.clear();
marks.hints.clear(); marks.hints.clear();
marks.ghost = None; 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`. /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::{fits, moved}; use super::{carried, fits, moved};
#[test] #[test]
fn offsets_ride_their_edits() { fn offsets_ride_their_edits() {
@@ -80,4 +119,13 @@ mod tests {
assert_eq!(moved(8, &edits), Some(10)); assert_eq!(moved(8, &edits), Some(10));
assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9))); 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
View File
@@ -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. /// Source text and where each line starts, in bytes.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug)]
pub struct Buffer { pub struct Buffer {
text: String, text: String,
starts: Vec<usize>, 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> { fn starts(text: &str) -> Vec<usize> {
@@ -17,7 +35,16 @@ impl Buffer {
pub fn new(text: impl Into<String>) -> Self { pub fn new(text: impl Into<String>) -> Self {
let text = text.into(); let text = text.into();
let starts = starts(&text); 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 { pub fn text(&self) -> &str {
@@ -107,6 +134,7 @@ impl Buffer {
); );
self.text.replace_range(range, text); self.text.replace_range(range, text);
self.starts = starts(&self.text); self.starts = starts(&self.text);
self.stamp = stamp();
} }
} }
+39 -17
View File
@@ -5,6 +5,7 @@ use gpui::{
div, prelude::*, div, prelude::*,
}; };
use crate::i18n;
use crate::{ use crate::{
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem}, buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
forms::{Input, TextInput}, 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 { impl RenderOnce for FindWidget {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme(); let theme = cx.theme();
@@ -155,7 +176,7 @@ impl RenderOnce for FindWidget {
let options = self.options; let options = self.options;
let toggle = |key: &'static str, let toggle = |key: &'static str,
icon: IconName, icon: IconName,
words: &'static str, words: SharedString,
on: bool, on: bool,
set: fn(&mut FindOptions, bool)| { set: fn(&mut FindOptions, bool)| {
let on_options = self.on_options.clone(); let on_options = self.on_options.clone();
@@ -174,16 +195,11 @@ impl RenderOnce for FindWidget {
} }
}) })
}; };
let (count, missing) = match &self.matches { let (count, missing) = count(&self.matches, cx);
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 found = matches!(self.matches, Ok((_, total)) if total > 0); let found = matches!(self.matches, Ok((_, total)) if total > 0);
let icon_button = |key: &'static str, let icon_button = |key: &'static str,
icon: IconName, icon: IconName,
words: &'static str, words: SharedString,
enabled: bool, enabled: bool,
action: Option<OnBool>, action: Option<OnBool>,
value: bool| { value: bool| {
@@ -229,7 +245,7 @@ impl RenderOnce for FindWidget {
} else { } else {
IconName::ChevronRight IconName::ChevronRight
}, },
"Toggle replace", i18n::text(cx, "find.fold", &[]),
true, true,
Some(fold), Some(fold),
!showing, !showing,
@@ -245,21 +261,21 @@ impl RenderOnce for FindWidget {
row.child(toggle( row.child(toggle(
"case", "case",
IconName::CaseSensitive, IconName::CaseSensitive,
"Match case", i18n::text(cx, "find.case", &[]),
options.case, options.case,
|next, on| next.case = on, |next, on| next.case = on,
)) ))
.child(toggle( .child(toggle(
"word", "word",
IconName::WholeWord, IconName::WholeWord,
"Whole word", i18n::text(cx, "find.word", &[]),
options.word, options.word,
|next, on| next.word = on, |next, on| next.word = on,
)) ))
.child(toggle( .child(toggle(
"regex", "regex",
IconName::Regex, IconName::Regex,
"Regular expression", i18n::text(cx, "find.regex", &[]),
options.regex, options.regex,
|next, on| next.regex = on, |next, on| next.regex = on,
)) ))
@@ -278,7 +294,7 @@ impl RenderOnce for FindWidget {
.child(icon_button( .child(icon_button(
"previous", "previous",
IconName::ArrowUp, IconName::ArrowUp,
"Previous match", i18n::text(cx, "find.previous", &[]),
found, found,
self.on_step.clone(), self.on_step.clone(),
false, false,
@@ -286,7 +302,7 @@ impl RenderOnce for FindWidget {
.child(icon_button( .child(icon_button(
"next", "next",
IconName::ArrowDown, IconName::ArrowDown,
"Next match", i18n::text(cx, "find.next", &[]),
found, found,
self.on_step.clone(), self.on_step.clone(),
true, true,
@@ -295,7 +311,7 @@ impl RenderOnce for FindWidget {
IconButton::new((self.id.clone(), "close"), IconName::X) IconButton::new((self.id.clone(), "close"), IconName::X)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip("Close") .tooltip(i18n::text(cx, "dialog.close", &[]))
.on_click(move |_, window, cx| close(window, cx)) .on_click(move |_, window, cx| close(window, cx))
})), })),
) )
@@ -312,7 +328,10 @@ impl RenderOnce for FindWidget {
.child(Input::new(field).size(ControlSize::Sm)), .child(Input::new(field).size(ControlSize::Sm)),
) )
.child( .child(
Button::new((self.id.clone(), "replace"), "Replace") Button::new(
(self.id.clone(), "replace"),
i18n::text(cx, "find.replace", &[]),
)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.disabled(!found) .disabled(!found)
@@ -321,7 +340,10 @@ impl RenderOnce for FindWidget {
}), }),
) )
.child( .child(
Button::new((self.id.clone(), "all"), "Replace all") Button::new(
(self.id.clone(), "all"),
i18n::text(cx, "find.replace_all", &[]),
)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.disabled(!found) .disabled(!found)
+2 -1
View File
@@ -7,6 +7,7 @@ use gpui::{
}; };
use super::{symbols::SymbolKind, syntax}; use super::{symbols::SymbolKind, syntax};
use crate::i18n;
use crate::{ use crate::{
buttons::{ButtonVariant, IconButton}, buttons::{ButtonVariant, IconButton},
navigation::{fuzzy, marked}, navigation::{fuzzy, marked},
@@ -185,7 +186,7 @@ impl RenderOnce for CompletionMenu {
.px_2() .px_2()
.py_1() .py_1()
.text_color(colors.fg_subtle) .text_color(colors.fg_subtle)
.child("No suggestions"), .child(i18n::text(cx, "completion.empty", &[])),
) )
}) })
.children(rows), .children(rows),
+42 -17
View File
@@ -8,6 +8,7 @@ use gpui::{
use super::{ use super::{
cursor::Selection, cursor::Selection,
layout::{Row, Shown}, layout::{Row, Shown},
measure::Measured,
state::CodeEditor, state::CodeEditor,
}; };
use crate::{ use crate::{
@@ -72,7 +73,12 @@ impl CodeEditor {
} }
/// The offset under a window point, when a row of code is there. /// 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; let metrics = self.metrics;
if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO { if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO {
return None; return None;
@@ -83,20 +89,41 @@ impl CodeEditor {
let Row::Line(line) = *row else { let Row::Line(line) = *row else {
return None; 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. /// 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() { if line >= self.buffer.lines() {
return None; 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 text = self.row_text(line);
let shown = Shown::new(text, self.notes(line, cx), Vec::new()); let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
let column = ((x - self.metrics.left) / self.metrics.advance) let family = cx.theme().mono_family.clone();
.round() let measured = Measured::new(
.max(0.0) as usize; &shown,
Some(self.buffer.offset(line, shown.back(text, column))) family,
self.metrics.size,
self.metrics.advance,
window,
);
(shown, measured)
} }
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> { fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
@@ -206,7 +233,7 @@ impl EntityInputHandler for CodeEditor {
&mut self, &mut self,
range_utf16: Range<usize>, range_utf16: Range<usize>,
_: Bounds<Pixels>, _: Bounds<Pixels>,
_: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> { ) -> Option<Bounds<Pixels>> {
let range = self.byte_range(&range_utf16); let range = self.byte_range(&range_utf16);
@@ -217,27 +244,25 @@ impl EntityInputHandler for CodeEditor {
.iter() .iter()
.position(|row| *row == Row::Line(line))?; .position(|row| *row == Row::Line(line))?;
let text = self.row_text(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 column = shown.column(text, self.buffer.point(range.start).1.min(text.len()));
let metrics = self.metrics; let metrics = self.metrics;
let scrolled = -self.scroll.0.borrow().base_handle.offset().y; let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
let origin = point( let origin = point(
metrics.left + metrics.advance * column as f32, metrics.left + measured.x(column),
metrics.top + metrics.line * row as f32 - scrolled, metrics.top + metrics.line * row as f32 - scrolled,
); );
Some(Bounds::new( let wide = measured.x(column + 1) - measured.x(column);
origin, Some(Bounds::new(origin, gpui::size(wide, metrics.line)))
gpui::size(metrics.advance, metrics.line),
))
} }
fn character_index_for_point( fn character_index_for_point(
&mut self, &mut self,
position: Point<Pixels>, position: Point<Pixels>,
_: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
let offset = self.offset_at(position, cx)?; let offset = self.offset_at(position, window, cx)?;
Some(to_utf16(self.buffer.text(), offset)) Some(to_utf16(self.buffer.text(), offset))
} }
} }
+96 -29
View File
@@ -10,6 +10,7 @@ use super::{
cursor::Selection, cursor::Selection,
gutter::Cell, gutter::Cell,
layout::{Shown, stack}, layout::{Shown, stack},
measure::Measured,
state::{CodeEditor, CursorShape}, state::{CodeEditor, CursorShape},
}; };
use crate::{ use crate::{
@@ -19,7 +20,11 @@ use crate::{
impl CodeEditor { impl CodeEditor {
/// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair. /// 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 colors = &cx.theme().colors;
let range = self.row_range(line); let range = self.row_range(line);
let local = |span: &Range<usize>| { let local = |span: &Range<usize>| {
@@ -29,11 +34,13 @@ impl CodeEditor {
span.start < range.end.max(range.start + 1) && range.start < span.end span.start < range.end.max(range.start + 1) && range.start < span.end
}; };
let mut spans = self.line_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 let brackets = self
.frame .frame
.brackets .brackets
.iter() .iter()
.filter(|bracket| range.contains(&bracket.offset)); .filter(|bracket| fresh && range.contains(&bracket.offset));
for bracket in brackets { for bracket in brackets {
let at = bracket.offset - range.start; let at = bracket.offset - range.start;
let color = if self.options.rainbow { let color = if self.options.rainbow {
@@ -44,7 +51,12 @@ impl CodeEditor {
spans.retain(|(span, _)| *span != (at..at + 1)); spans.retain(|(span, _)| *span != (at..at + 1));
spans.push((at..at + 1, color.into())); 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(( spans.push((
local(span), local(span),
HighlightStyle { HighlightStyle {
@@ -85,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)) { for at in [open, close].into_iter().filter(|at| range.contains(at)) {
let at = at - range.start; let at = at - range.start;
spans.push(( spans.push((
@@ -109,24 +121,34 @@ impl CodeEditor {
(0..indent).step_by(self.options.tab).collect() (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. /// Dots for spaces, arrows for tabs; other characters hidden.
fn whitespace(&self, line: usize, shown: &Shown, cx: &Context<Self>) -> String { fn whitespace(&self, line: usize, cx: &Context<Self>) -> StyledText {
let text = self.row_text(line); let text = self.row_text(line);
let notes = self.notes(line, cx); 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(); let mut note = notes.iter().peekable();
for (at, ch) in text.char_indices() { for (at, ch) in text.char_indices() {
let start = out.len();
while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) { while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) {
out.extend(std::iter::repeat_n(' ', words.chars().count())); out.push_str(words);
} }
out.push(match ch { match ch {
' ' => '·', ' ' | '\t' => {
'\t' => '→', hide.push((start..out.len(), hidden));
_ => ' ', out.push(if ch == ' ' { '·' } else { '→' });
});
} }
debug_assert!(out.chars().count() <= shown.text.chars().count()); _ => {
out out.push(ch);
hide.push((start..out.len(), hidden));
}
}
}
hide.retain(|(range, _)| !range.is_empty());
StyledText::new(out).with_highlights(hide)
} }
pub(crate) fn line_row( pub(crate) fn line_row(
@@ -143,7 +165,16 @@ impl CodeEditor {
let text = self.row_text(line); let text = self.row_text(line);
let range = self.row_range(line); let range = self.row_range(line);
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx)); 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 focused = self.focus.is_focused(window);
let primary = self.primary(); let primary = self.primary();
let current = primary.is_empty() && self.buffer.line_of(primary.head) == line; let current = primary.is_empty() && self.buffer.line_of(primary.head) == line;
@@ -170,7 +201,7 @@ impl CodeEditor {
.absolute() .absolute()
.top_0() .top_0()
.left(x(start)) .left(x(start))
.w(advance * (end - start) as f32) .w(x(end) - x(start))
.h(height) .h(height)
.bg(if focused { .bg(if focused {
colors.selection colors.selection
@@ -198,7 +229,7 @@ impl CodeEditor {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.left(x(*column)) .left(grid(*column))
.h(height) .h(height)
.w_0() .w_0()
.border_l_1() .border_l_1()
@@ -209,7 +240,7 @@ impl CodeEditor {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.left(x(column)) .left(grid(column))
.h(height) .h(height)
.w_0() .w_0()
.border_l_1() .border_l_1()
@@ -225,19 +256,20 @@ impl CodeEditor {
.filter(|selection| self.buffer.line_of(selection.head) == line) .filter(|selection| self.buffer.line_of(selection.head) == line)
.filter(|_| focused && self.caret_on) .filter(|_| focused && self.caret_on)
.map(|selection| { .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 caret = theme.caret_width().to_pixels(window.rem_size());
let mark = div().absolute().left(left); let mark = div().absolute().left(left);
match self.options.cursor { match self.options.cursor {
CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus), CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus),
CursorShape::Block => mark CursorShape::Block => mark
.top_0() .top_0()
.w(advance) .w(wide)
.h(height) .h(height)
.bg(colors.focus.opacity(0.35)), .bg(colors.focus.opacity(0.35)),
CursorShape::Underline => mark CursorShape::Underline => mark
.top(height - caret * 1.5) .top(height - caret * 1.5)
.w(advance) .w(wide)
.h(caret * 1.5) .h(caret * 1.5)
.bg(colors.focus), .bg(colors.focus),
} }
@@ -251,7 +283,7 @@ impl CodeEditor {
.line_height(height) .line_height(height)
.whitespace_nowrap() .whitespace_nowrap()
.text_color(colors.fg_subtle.opacity(0.6)) .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 folded = self.folded.contains(&line).then(|| {
let entity = cx.entity(); let entity = cx.entity();
@@ -335,9 +367,17 @@ impl CodeEditor {
editor.press(line, event, window, cx) editor.press(line, event, window, cx)
}), }),
) )
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| { .on_mouse_down(
editor.drag(line, event, cx) 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)) base.child(self.gutter(Cell::Line(line), row, cx))
.child(code) .child(code)
.into_any_element() .into_any_element()
@@ -352,7 +392,7 @@ impl CodeEditor {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
window.focus(&self.focus, cx); 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; return;
}; };
let chosen = match event.click_count { let chosen = match event.click_count {
@@ -392,12 +432,39 @@ impl CodeEditor {
self.set_selections(chosen, cx); 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. /// 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() { if !self.dragging || !event.dragging() {
return; 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; return;
}; };
let mut all = self.selections.clone(); let mut all = self.selections.clone();
+86
View File
@@ -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()
}
}
+1
View File
@@ -16,6 +16,7 @@ mod keybindings;
mod keys; mod keys;
mod layout; mod layout;
mod line; mod line;
mod measure;
mod moves; mod moves;
mod preview; mod preview;
mod problems; mod problems;
+4
View File
@@ -84,6 +84,8 @@ pub(crate) struct Marks {
pub git: Vec<(usize, GitMark)>, pub git: Vec<(usize, GitMark)>,
pub breakpoints: BTreeSet<usize>, pub breakpoints: BTreeSet<usize>,
pub chat: Option<usize>, pub chat: Option<usize>,
/// The owner's offsets; an edit over one drops it.
pub anchors: Vec<Option<usize>>,
} }
/// The text and cursors one undo step returns to. /// The text and cursors one undo step returns to.
@@ -100,6 +102,8 @@ pub(crate) struct Metrics {
pub top: Pixels, pub top: Pixels,
pub advance: Pixels, pub advance: Pixels,
pub line: Pixels, pub line: Pixels,
/// The code font's size.
pub size: Pixels,
/// The view's height. /// The view's height.
pub height: Pixels, pub height: Pixels,
} }
+40
View File
@@ -0,0 +1,40 @@
//! The find box reads in Chinese under zh-CN.
use gpui::{Entity, IntoElement, Render, TestAppContext, Window, div, prelude::*};
use crate::{forms::TextInput, i18n::I18n, theme::Theme};
struct Host(Entity<TextInput>, Entity<TextInput>);
impl Render for Host {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
let find = super::super::FindWidget::new("find", &self.0, Some(0), 2)
.replace(&self.1)
.on_options(|_, _, _| {})
.on_step(|_, _, _| {})
.on_toggle_replace(|_, _, _| {})
.on_close(|_, _| {});
div().size_full().child(find)
}
}
#[gpui::test]
fn the_find_box_reads_in_chinese(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
cx.set_global(I18n::new("zh-CN").catalog("zh-CN", &[]));
});
cx.update(|cx| {
let count = |matches| super::super::find::count(&matches, cx);
assert_eq!(count(Ok((None, 0))), ("无结果".into(), true));
assert_eq!(count(Ok((Some(0), 2))), ("第 1 项,共 2 项".into(), false));
assert_eq!(count(Ok((None, 3))), ("找到 3 项".into(), false));
});
let (_, cx) = cx.add_window_view(|window, cx| {
Host(
cx.new(|cx| TextInput::new(window, cx)),
cx.new(|cx| TextInput::new(window, cx)),
)
});
cx.run_until_parked();
}
+2
View File
@@ -1,5 +1,7 @@
mod chinese;
mod history; mod history;
mod stale; mod stale;
mod wide;
use std::{cell::Cell, rc::Rc, time::Duration}; use std::{cell::Cell, rc::Rc, time::Duration};
+66
View File
@@ -0,0 +1,66 @@
//! A wide character takes its laid-out width, not one column.
use gpui::{Bounds, EntityInputHandler, TestAppContext, VisualTestContext};
use super::editor;
#[gpui::test]
fn a_wide_character_moves_the_caret_by_its_width(cx: &mut TestAppContext) {
// Test text draws "😄" two ems wide, "a" one.
let (editor, cx) = editor("😄a", cx);
cx.run_until_parked();
let x = |utf16: usize, cx: &mut VisualTestContext| {
editor.update_in(cx, |editor, window, cx| {
let bounds = editor.bounds_for_range(utf16..utf16, Bounds::default(), window, cx);
bounds.expect("a drawn line").origin.x
})
};
let (start, wide, narrow) = (x(0, cx), x(2, cx), x(3, cx));
let off = (wide - start) - (narrow - wide) * 2.0;
assert!(off.abs() < gpui::px(0.01), "{start:?} {wide:?} {narrow:?}");
let at = editor.update_in(cx, |editor, window, cx| {
let left = editor.metrics.left;
editor.offset_in(0, left + (wide - start) * 0.9, window, cx)
});
assert_eq!(at, Some("😄".len()), "a press near its end lands after it");
}
#[gpui::test]
fn a_press_on_the_last_character_takes_its_nearer_edge(cx: &mut TestAppContext) {
for (text, last) in [("ab", "a".len()), ("测", 0)] {
let (editor, cx) = editor(text, cx);
cx.run_until_parked();
let at = |share: f32, cx: &mut VisualTestContext| {
editor.update_in(cx, |editor, window, cx| {
let mut x = |utf16: usize| {
let bounds =
editor.bounds_for_range(utf16..utf16, Bounds::default(), window, cx);
bounds.expect("a drawn line").origin.x
};
let (start, from, to) = (x(0), x(last), x(text.encode_utf16().count()));
let left = editor.metrics.left;
editor.offset_in(0, left + from - start + (to - from) * share, window, cx)
})
};
assert_eq!(at(0.1, cx), Some(last), "{text}: near its start");
assert_eq!(at(0.9, cx), Some(text.len()), "{text}: near its end");
}
}
#[gpui::test]
fn an_input_method_edit_measures_before_the_next_draw(cx: &mut TestAppContext) {
let (editor, cx) = editor("(a)", cx);
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
let start = 0..0;
editor.select([start], cx);
editor.replace_and_mark_text_in_range(None, "测", None, window, cx);
let text = editor.row_text(0).to_string();
for (range, _) in editor.styles(0, cx) {
let whole = text.is_char_boundary(range.start) && text.is_char_boundary(range.end);
assert!(whole, "{range:?} splits a character of {text}");
}
let bounds = editor.bounds_for_range(1..1, Bounds::default(), window, cx);
assert!(bounds.is_some(), "the caret after it is measured");
});
}
+4
View File
@@ -25,6 +25,8 @@ pub(crate) struct Frame {
pub line: Pixels, pub line: Pixels,
pub matched: Option<(usize, usize)>, pub matched: Option<(usize, usize)>,
pub occurrences: Vec<Range<usize>>, pub occurrences: Vec<Range<usize>>,
/// The buffer stamp these offsets were found in.
pub stamp: u64,
/// The minimap's box, from its last paint. /// The minimap's box, from its last paint.
pub minimap: Bounds<Pixels>, pub minimap: Bounds<Pixels>,
} }
@@ -267,10 +269,12 @@ impl Render for CodeEditor {
line, line,
matched, matched,
occurrences, occurrences,
stamp: self.buffer.stamp(),
minimap, minimap,
}; };
self.metrics.advance = advance; self.metrics.advance = advance;
self.metrics.line = line; self.metrics.line = line;
self.metrics.size = pixels;
// Typewriter: the caret's row centers as it moves. // Typewriter: the caret's row centers as it moves.
let centered = (self.primary_row(), self.metrics.height, line); let centered = (self.primary_row(), self.metrics.height, line);
if self.options.typewriter && self.centered != Some(centered) { if self.options.typewriter && self.centered != Some(centered) {
+10 -5
View File
@@ -4,6 +4,7 @@ use gpui::{
}; };
use super::TextInput; use super::TextInput;
use crate::i18n;
use crate::{ use crate::{
buttons::IconButton, buttons::IconButton,
primitives::{Icon, IconName}, primitives::{Icon, IconName},
@@ -148,7 +149,7 @@ impl RenderOnce for Input {
frame.child( frame.child(
IconButton::new(("input-clear", clear_id), IconName::CircleX) IconButton::new(("input-clear", clear_id), IconName::CircleX)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip("Clear") .tooltip(i18n::text(cx, "input.clear", &[]))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
state.update(cx, |input, cx| input.set_text("", cx)); state.update(cx, |input, cx| input.set_text("", cx));
window.focus(&state.read(cx).focus().clone(), cx); window.focus(&state.read(cx).focus().clone(), cx);
@@ -198,11 +199,15 @@ impl RenderOnce for PasswordInput {
}, },
) )
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip(if masked { .tooltip(i18n::text(
"Show password" cx,
if masked {
"input.show_password"
} else { } else {
"Hide password" "input.hide_password"
}) },
&[],
))
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toggle.update(cx, |input, cx| { toggle.update(cx, |input, cx| {
let masked = input.is_masked(); let masked = input.is_masked();
+3 -3
View File
@@ -116,10 +116,10 @@ pub use switch::Switch;
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of}; pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
pub use tags::TagInput; pub use tags::TagInput;
pub(crate) use text::{ pub(crate) use text::{
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up, Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
bind_keys, from_utf16, to_utf16, from_utf16, to_utf16,
}; };
pub use text::{Highlight, History, InputEvent, TextInput}; pub use text::{Copy, Cut, Highlight, History, InputEvent, Paste, SelectAll, TextInput};
pub use transfer::TransferList; pub use transfer::TransferList;
pub use unit::UnitInput; pub use unit::UnitInput;
pub use upload::{Upload, UploadList, UploadState}; pub use upload::{Upload, UploadList, UploadState};
+7 -5
View File
@@ -251,7 +251,7 @@ pub struct Select {
id: ElementId, id: ElementId,
choices: Vec<Choice>, choices: Vec<Choice>,
selected: Option<SharedString>, selected: Option<SharedString>,
placeholder: SharedString, placeholder: Option<SharedString>,
label: Option<SharedString>, label: Option<SharedString>,
size: ControlSize, size: ControlSize,
disabled: bool, disabled: bool,
@@ -264,7 +264,7 @@ impl Select {
id: id.into(), id: id.into(),
choices: choices.into_iter().collect(), choices: choices.into_iter().collect(),
selected: None, selected: None,
placeholder: SharedString::from("Choose…"), placeholder: None,
label: None, label: None,
size: ControlSize::default(), size: ControlSize::default(),
disabled: false, disabled: false,
@@ -284,7 +284,7 @@ impl Select {
} }
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self { pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into(); self.placeholder = Some(text.into());
self self
} }
@@ -326,6 +326,8 @@ impl RenderOnce for Select {
.as_ref() .as_ref()
.and_then(|value| choices.iter().find(|choice| choice.value == *value)) .and_then(|value| choices.iter().find(|choice| choice.value == *value))
.cloned(); .cloned();
let placeholder =
(self.placeholder.clone()).unwrap_or_else(|| i18n::text(cx, "select.placeholder", &[]));
let colors = &cx.theme().colors; let colors = &cx.theme().colors;
let trigger = field_button( let trigger = field_button(
self.id.clone(), self.id.clone(),
@@ -336,7 +338,7 @@ impl RenderOnce for Select {
cx, cx,
) )
.role(Role::ComboBox) .role(Role::ComboBox)
.aria_placeholder(self.placeholder.clone()) .aria_placeholder(placeholder.clone())
.when_some(self.label.clone(), |trigger, label| { .when_some(self.label.clone(), |trigger, label| {
trigger.aria_label(label) trigger.aria_label(label)
}) })
@@ -361,7 +363,7 @@ impl RenderOnce for Select {
) )
.child(field_text( .child(field_text(
shown.map(|choice| choice.label), shown.map(|choice| choice.label),
self.placeholder, placeholder,
self.disabled, self.disabled,
cx, cx,
)) ))
+20
View File
@@ -343,6 +343,25 @@ impl TextInput {
} }
} }
/// A right press keeps a selection it lands in.
fn secondary_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
window.focus(&self.focus, cx);
let at = self.offset_for_point(event.position);
let held =
!self.selection.is_empty() && (self.selection.start..=self.selection.end).contains(&at);
if !held {
self.move_to(at, cx);
}
}
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) { fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.selecting && event.dragging() { if self.selecting && event.dragging() {
let at = self.offset_for_point(event.position); let at = self.offset_for_point(event.position);
@@ -405,6 +424,7 @@ impl Render for TextInput {
.on_action(cx.listener(Self::submit)) .on_action(cx.listener(Self::submit))
.on_action(cx.listener(Self::show_character_palette)) .on_action(cx.listener(Self::show_character_palette))
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
.on_mouse_down(MouseButton::Right, cx.listener(Self::secondary_down))
.on_mouse_move(cx.listener(Self::mouse_move)) .on_mouse_move(cx.listener(Self::mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up)) .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
+2 -2
View File
@@ -13,9 +13,9 @@ use gpui::{
}; };
pub(crate) use actions::{ pub(crate) use actions::{
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up, Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
bind_keys,
}; };
pub use actions::{Copy, Cut, Paste, SelectAll};
pub use edit::History; pub use edit::History;
use edit::Snapshot; use edit::Snapshot;
pub(crate) use edit::{from_utf16, to_utf16}; pub(crate) use edit::{from_utf16, to_utf16};
+70
View File
@@ -115,6 +115,41 @@ const EN: &[(&str, &str)] = &[
("theme.light", "Light"), ("theme.light", "Light"),
("theme.dark", "Dark"), ("theme.dark", "Dark"),
("theme.system", "System"), ("theme.system", "System"),
("select.placeholder", "Choose…"),
("input.clear", "Clear"),
("input.show_password", "Show password"),
("input.hide_password", "Hide password"),
("completion.empty", "No suggestions"),
("builder.remove", "Remove"),
("tabs.unpin", "Unpin"),
("find.none", "No results"),
("find.at", "{at} of {total}"),
("find.found", "{total} found"),
("find.fold", "Toggle replace"),
("find.case", "Match case"),
("find.word", "Whole word"),
("find.regex", "Regular expression"),
("find.previous", "Previous match"),
("find.next", "Next match"),
("find.replace", "Replace"),
("find.replace_all", "Replace all"),
("builder.add_rule", "Add rule"),
("builder.all", "Match all"),
("builder.any", "Match any"),
("builder.add_sort", "Add sort"),
("builder.up", "Ascending"),
("builder.down", "Descending"),
("builder.test.contains", "contains"),
("builder.test.equals", "is"),
("builder.test.not_equals", "is not"),
("builder.test.above", "is above"),
("builder.test.below", "is below"),
("builder.test.empty", "is empty"),
("theme.size.reset", "Back to the default size"),
(
"theme.size.sample",
"The quick brown fox jumps over the lazy dog.",
),
]; ];
const ZH_CN: &[(&str, &str)] = &[ const ZH_CN: &[(&str, &str)] = &[
@@ -228,6 +263,41 @@ const ZH_CN: &[(&str, &str)] = &[
("theme.light", "浅色"), ("theme.light", "浅色"),
("theme.dark", "深色"), ("theme.dark", "深色"),
("theme.system", "跟随系统"), ("theme.system", "跟随系统"),
("select.placeholder", "请选择…"),
("input.clear", "清除"),
("input.show_password", "显示密码"),
("input.hide_password", "隐藏密码"),
("completion.empty", "没有建议"),
("builder.remove", "移除"),
("tabs.unpin", "取消固定"),
("find.none", "无结果"),
("find.at", "第 {at} 项,共 {total} 项"),
("find.found", "找到 {total} 项"),
("find.fold", "切换替换"),
("find.case", "区分大小写"),
("find.word", "全字匹配"),
("find.regex", "使用正则表达式"),
("find.previous", "上一个匹配项"),
("find.next", "下一个匹配项"),
("find.replace", "替换"),
("find.replace_all", "全部替换"),
("builder.add_rule", "添加规则"),
("builder.all", "满足全部"),
("builder.any", "满足任一"),
("builder.add_sort", "添加排序"),
("builder.up", "升序"),
("builder.down", "降序"),
("builder.test.contains", "包含"),
("builder.test.equals", "等于"),
("builder.test.not_equals", "不等于"),
("builder.test.above", "大于"),
("builder.test.below", "小于"),
("builder.test.empty", "为空"),
("theme.size.reset", "恢复默认大小"),
(
"theme.size.sample",
"天地玄黄,宇宙洪荒。The quick brown fox.",
),
]; ];
/// Ely's message for `key`; English if unshipped, fails if unknown. /// Ely's message for `key`; English if unshipped, fails if unknown.
+2 -1
View File
@@ -2,8 +2,9 @@ use jiff::{Timestamp, tz::TimeZone};
use super::{Direction, I18n, LOCALES, Locale, fill}; use super::{Direction, I18n, LOCALES, Locale, fill};
#[cfg(feature = "test-support")]
mod a11y; mod a11y;
#[cfg(feature = "test-support")]
mod shipped;
fn i18n() -> I18n { fn i18n() -> I18n {
I18n::new("en-US") I18n::new("en-US")
+74
View File
@@ -0,0 +1,74 @@
//! Components Ely ships say nothing in fixed English.
/// Sources of components the Ely apps draw.
const SHIPPED: &[(&str, &str)] = &[
("editor/find.rs", include_str!("../../editor/find.rs")),
("editor/hints.rs", include_str!("../../editor/hints.rs")),
("forms/input.rs", include_str!("../../forms/input.rs")),
("forms/select.rs", include_str!("../../forms/select.rs")),
(
"tables/builders.rs",
include_str!("../../tables/builders.rs"),
),
("tables/rules.rs", include_str!("../../tables/rules.rs")),
(
"navigation/editor_tabs.rs",
include_str!("../../navigation/editor_tabs.rs"),
),
(
"settings/appearance.rs",
include_str!("../../settings/appearance.rs"),
),
];
#[test]
fn shipped_components_show_no_fixed_english() {
let shown = [".tooltip(\"", ".child(\"", "SharedString::from(\"", "=> \""];
// After an id: words, not a lowercase id.
let labels = ["), \"", "\", \"", "(\""];
let mut found = Vec::new();
for (path, source) in SHIPPED {
let code = source.split("#[cfg(test)]").next().unwrap_or(source);
for (no, line) in code.lines().enumerate() {
let checks = ["assert", "panic!", "log::", ".expect("];
if checks.iter().any(|check| line.contains(check)) {
continue;
}
let literals = |calls: &[&str]| -> Vec<&str> {
(calls.iter())
.flat_map(|call| {
line.match_indices(call)
.map(|(at, _)| &line[at + call.len()..])
})
.map(|rest| rest.split('"').next().unwrap_or(rest))
.filter(|literal| literal.starts_with(|ch: char| ch.is_ascii_alphabetic()))
.collect()
};
let key = |literal: &str| literal.contains('.') && !literal.contains(' ');
let words =
|literal: &str| literal.contains(' ') || literal.starts_with(char::is_uppercase);
let english = (literals(&shown).into_iter().filter(|literal| !key(literal)))
.chain(
literals(&labels)
.into_iter()
.filter(|literal| words(literal)),
)
.chain(
line.trim()
.strip_prefix('"')
.and_then(|rest| rest.split('"').next()),
)
.filter(|literal| {
literal.starts_with(char::is_uppercase) || !line.trim().starts_with('"')
});
if english.count() > 0 {
found.push(format!("{path}:{}: {}", no + 1, line.trim()));
}
}
}
assert!(
found.is_empty(),
"English outside the catalog:\n{}",
found.join("\n")
);
}
+7 -6
View File
@@ -29,6 +29,7 @@ fn handle(id: impl Into<ElementId>, axis: Axis, line: Hsla, cx: &App) -> Statefu
.flex() .flex()
.justify_center() .justify_center()
.items_center(); .items_center();
// A filled line: gpui paints a border no wider than half its box.
match axis { match axis {
Axis::Horizontal => base Axis::Horizontal => base
.w(hit) .w(hit)
@@ -36,10 +37,10 @@ fn handle(id: impl Into<ElementId>, axis: Axis, line: Hsla, cx: &App) -> Statefu
.cursor(CursorStyle::ResizeLeftRight) .cursor(CursorStyle::ResizeLeftRight)
.child( .child(
div() div()
.w_px()
.h_full() .h_full()
.border_l_1() .bg(line)
.border_color(line) .group_hover("resize", |s| s.bg(lit)),
.group_hover("resize", |s| s.border_color(lit)),
), ),
Axis::Vertical => base Axis::Vertical => base
.h(hit) .h(hit)
@@ -47,10 +48,10 @@ fn handle(id: impl Into<ElementId>, axis: Axis, line: Hsla, cx: &App) -> Statefu
.cursor(CursorStyle::ResizeUpDown) .cursor(CursorStyle::ResizeUpDown)
.child( .child(
div() div()
.h_px()
.w_full() .w_full()
.border_t_1() .bg(line)
.border_color(line) .group_hover("resize", |s| s.bg(lit)),
.group_hover("resize", |s| s.border_color(lit)),
), ),
} }
} }
+15 -4
View File
@@ -19,6 +19,8 @@ type OnClick = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
pub struct ListItem { pub struct ListItem {
id: ElementId, id: ElementId,
title: SharedString, title: SharedString,
/// A field drawn in the title's place.
editor: Option<AnyElement>,
description: Option<SharedString>, description: Option<SharedString>,
detail: Option<SharedString>, detail: Option<SharedString>,
leading: Option<AnyElement>, leading: Option<AnyElement>,
@@ -39,6 +41,7 @@ impl ListItem {
Self { Self {
id: id.into(), id: id.into(),
title: title.into(), title: title.into(),
editor: None,
description: None, description: None,
detail: None, detail: None,
leading: None, leading: None,
@@ -72,6 +75,12 @@ impl ListItem {
self self
} }
/// A field in place of the title, as a row renamed in place has it; the title stays the row's name.
pub fn editor(mut self, editor: impl IntoElement) -> Self {
self.editor = Some(editor.into_any_element());
self
}
/// A second line, quieter than the title. /// A second line, quieter than the title.
pub fn description(mut self, text: impl Into<SharedString>) -> Self { pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into()); self.description = Some(text.into());
@@ -191,8 +200,9 @@ impl RenderOnce for ListItem {
.min_w_0() .min_w_0()
.flex() .flex()
.flex_col() .flex_col()
.child( .child(match self.editor {
line(self.title) Some(editor) => div().min_w_0().child(editor).into_any_element(),
None => line(self.title)
.text_size(theme.text_size(TextSize::Base)) .text_size(theme.text_size(TextSize::Base))
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD)) .when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
.when(self.struck, |title| title.line_through()) .when(self.struck, |title| title.line_through())
@@ -200,8 +210,9 @@ impl RenderOnce for ListItem {
colors.fg_muted colors.fg_muted
} else { } else {
colors.fg colors.fg
}), })
) .into_any_element(),
})
.children(self.description.map(|text| { .children(self.description.map(|text| {
line(text.clone()) line(text.clone())
.debug_selector(|| format!("item-description-{text}")) .debug_selector(|| format!("item-description-{text}"))
+49
View File
@@ -0,0 +1,49 @@
use gpui::{
Context, InteractiveElement, IntoElement, ParentElement, Render, Styled, TestAppContext,
Window, div, px,
};
use super::{settle, setup};
use crate::lists::{List, ListItem};
/// Two rows, the first renamed in place.
struct Renaming;
impl Render for Renaming {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let field = div()
.debug_selector(|| "row-editor".into())
.h(px(12.0))
.w_full();
div().w(px(240.0)).child(
List::new()
.child(
ListItem::new("renamed", "Old name")
.description("~/work")
.selected(true)
.editor(field),
)
.child(ListItem::new("other", "Other").description("~/other")),
)
}
}
#[gpui::test]
fn an_editor_takes_the_title_s_place_in_its_row(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Renaming);
settle(cx);
let row = cx
.debug_bounds("item-selected-Old name")
.expect("the renamed row");
let field = cx.debug_bounds("row-editor").expect("its editor");
let line = cx
.debug_bounds("item-description-~/work")
.expect("its description");
assert!(row.contains(&field.center()), "{field:?} in {row:?}");
assert!(
field.bottom() <= line.top(),
"the editor stands over the description"
);
assert!(cx.debug_bounds("item-description-~/other").is_some());
}
+1
View File
@@ -18,6 +18,7 @@ use crate::{
theme::Theme, theme::Theme,
}; };
mod editor;
mod keys; mod keys;
fn setup(cx: &mut TestAppContext) { fn setup(cx: &mut TestAppContext) {
+129
View File
@@ -0,0 +1,129 @@
//! Hover intent: pause before opening, hold while aiming.
use std::time::Duration;
use gpui::{App, Bounds, Entity, Pixels, Point};
use super::{
menu::{Open, mark},
model::Menu,
};
/// A submenu row opens after the pointer rests this long.
pub(super) const OPEN: Duration = Duration::from_millis(150);
/// A row crossed toward an open submenu waits this long.
pub(super) const HOLD: Duration = Duration::from_millis(300);
/// Pointer over a row; `sub` when it opens a submenu.
pub(super) fn hover(
state: &Entity<Open>,
menu: &Menu,
(depth, cell): (usize, (usize, usize)),
sub: bool,
at: Point<Pixels>,
cx: &mut App,
) {
let seen = state.update(cx, |open, _| {
let from = open.pointer.replace(at);
open.over = Some((depth, cell));
let level = open.levels.get(depth)?;
let shown = (open.levels.get(depth + 1)).map(|level| level.panel);
Some((from, (level.at, level.column), shown))
});
let Some((from, marked, shown)) = seen else {
return;
};
if marked == (Some(cell.0), cell.1) {
if !sub || shown.is_some() {
state.update(cx, |open, _| open.pending = None);
} else if state.read(cx).pending.is_none() {
later(state, menu, (depth, cell), OPEN, cx);
}
return;
}
let aimed = from
.zip(shown.filter(|panel| *panel != Bounds::default()))
.is_some_and(|(from, panel)| toward(from, at, panel));
if aimed {
log::debug!("menu: row {} of level {depth} waits on the aim", cell.0);
return later(state, menu, (depth, cell), HOLD, cx);
}
state.update(cx, |open, _| open.pending = None);
mark(state, menu, depth, cell, false, cx);
if sub {
later(state, menu, (depth, cell), OPEN, cx);
}
}
/// The pointer left a row: its delayed mark is dropped.
pub(super) fn leave(state: &Entity<Open>, over: (usize, (usize, usize)), cx: &mut App) {
state.update(cx, |open, _| {
if open.over == Some(over) {
(open.over, open.pending) = (None, None);
}
});
}
/// Marks the row after `wait` if still under the pointer.
fn later(
state: &Entity<Open>,
menu: &Menu,
(depth, cell): (usize, (usize, usize)),
wait: Duration,
cx: &mut App,
) {
let (state_, menu) = (state.clone(), menu.clone());
let task = cx.spawn(async move |cx| {
cx.background_executor().timer(wait).await;
cx.update(|cx| {
if state_.read(cx).over != Some((depth, cell)) {
return log::debug!("menu: the pointer left row {} before it opened", cell.0);
}
state_.update(cx, |open, _| open.pending = None);
mark(&state_, &menu, depth, cell, true, cx);
});
});
state.update(cx, |open, _| open.pending = Some(task));
}
/// Whether `to` lies between `from` and `panel`'s near edge.
pub(super) fn toward(from: Point<Pixels>, to: Point<Pixels>, panel: Bounds<Pixels>) -> bool {
let near = if panel.left() >= from.x {
panel.left()
} else {
panel.right()
};
let (top, bottom) = (
Point::new(near, panel.top()),
Point::new(near, panel.bottom()),
);
let side = |a: Point<Pixels>, b: Point<Pixels>| {
f32::from(b.x - a.x) * f32::from(to.y - a.y) - f32::from(b.y - a.y) * f32::from(to.x - a.x)
};
let sides = [side(from, top), side(top, bottom), side(bottom, from)];
sides.iter().all(|side| *side >= 0.0) || sides.iter().all(|side| *side <= 0.0)
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::toward;
#[test]
fn the_aim_holds_toward_the_panel_on_either_side() {
let right = Bounds::new(point(px(200.), px(0.)), size(px(100.), px(200.)));
let from = point(px(100.), px(100.));
assert!(toward(from, point(px(110.), px(98.)), right));
assert!(
!toward(from, point(px(100.), px(130.)), right),
"straight down"
);
let left = Bounds::new(point(px(0.), px(0.)), size(px(50.), px(200.)));
assert!(toward(from, point(px(90.), px(102.)), left));
assert!(
!toward(from, point(px(110.), px(100.)), left),
"away from it"
);
}
}
+148 -13
View File
@@ -1,16 +1,21 @@
use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Stateful, Styled, div, prelude::*, MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action,
canvas, div, prelude::*,
}; };
use super::{ use super::{
menu::{Open, at_depth, choose, mark, measure}, aim,
menu::{Open, at_depth, choose},
model::{Entry, Kind, Menu, MenuItem}, model::{Entry, Kind, Menu, MenuItem},
}; };
use crate::{ use crate::{
forms::{Run, surface}, forms::{Run, surface},
i18n,
navigation::marked, navigation::marked,
primitives::{Icon, IconName}, primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::KbdCombo, typography::KbdCombo,
}; };
@@ -82,6 +87,71 @@ fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
}) })
} }
/// Button `column` of row `ix`, named by its label.
pub(super) fn button(
item: &MenuItem,
(ix, column): (usize, usize),
current: bool,
press: Run,
cx: &App,
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
let fg = match (item.disabled, current) {
(true, _) => colors.fg_disabled,
(false, true) => colors.fg,
(false, false) => colors.fg_muted,
};
let (label, keys) = (item.label.clone(), item.keys.clone());
let icon = item.icon.expect("a menu button has its icon");
div()
.id((SharedString::from(format!("button-{ix}")), column))
.debug_selector(move || format!("menu-button-{ix}-{column}"))
.role(Role::Button)
.aria_label(item.label.clone())
.flex_1()
.flex()
.items_center()
.justify_center()
.h(theme.control_height(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.aria_selected(current)
.when(current, |button| button.aria_active_descendant())
.when(current && !item.disabled, |button| button.bg(colors.hover))
.when(item.disabled, |button| {
button.aria_description(i18n::text(cx, "state.unavailable", &[]))
})
.when(!item.disabled, |button| {
let read = press.clone();
button
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
press(window, cx);
})
.on_a11y_action(Action::Click, move |_, window, cx| read(window, cx))
})
.child(Icon::new(icon).size(IconSize::Sm).color(fg))
.tooltip(Tooltip::rich(move |_, cx| {
div()
.flex()
.items_center()
.gap_2()
.child(label.clone())
.when_some(keys.clone(), |tip, keys| {
tip.child(
div()
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
.child(KbdCombo::new(&keys)),
)
})
.into_any_element()
}))
}
pub(super) fn panel( pub(super) fn panel(
id: &ElementId, id: &ElementId,
menu: &Menu, menu: &Menu,
@@ -111,22 +181,58 @@ pub(super) fn panel(
.text_color(colors.fg_subtle) .text_color(colors.fg_subtle)
.child(title.clone()) .child(title.clone())
.into_any_element(), .into_any_element(),
Entry::Buttons(buttons) => div()
.flex()
.gap_1()
.py_0p5()
.children(buttons.iter().enumerate().map(|(column, item)| {
let current = at == Some(ix) && open.levels[depth].column == column;
let (hover, click, close, menu) =
(state.clone(), state.clone(), close.clone(), menu.clone());
let hover_menu = menu.clone();
let press: Run = Rc::new(move |window, cx| {
choose(&click, &menu, depth, (ix, column), &close, window, cx)
});
let drawn = button(item, (ix, column), current, press, cx);
let left = state.clone();
let here = (depth, (ix, column));
drawn.when(!item.disabled, |button| {
button
.on_mouse_move(move |event, _, cx| {
aim::hover(&hover, &hover_menu, here, false, event.position, cx)
})
.on_hover(move |hovered, _, cx| {
if !hovered {
aim::leave(&left, here, cx);
}
})
})
}))
.into_any_element(),
Entry::Item(item) => { Entry::Item(item) => {
let marked = at == Some(ix); let marked = at == Some(ix);
let sub = matches!(item.kind, Kind::Sub(_)); let sub = matches!(item.kind, Kind::Sub(_));
let (hover, click, close, menu) = let (hover, click, close, menu) =
(state.clone(), state.clone(), close.clone(), menu.clone()); (state.clone(), state.clone(), close.clone(), menu.clone());
let hover_menu = menu.clone(); let hover_menu = menu.clone();
let label = item.label.clone();
row(item, ix, marked, cx) row(item, ix, marked, cx)
.debug_selector(move || format!("menu-row-{label}"))
.when(!item.disabled, |row| { .when(!item.disabled, |row| {
row.on_mouse_move(move |_, _, cx| { let left = state.clone();
let open = hover.read(cx); row.on_mouse_move(move |event, _, cx| {
let Some(level) = open.levels.get(depth) else { aim::hover(
return; &hover,
}; &hover_menu,
let shown = open.levels.len() > depth + 1; (depth, (ix, 0)),
if level.at != Some(ix) || (sub && !shown) { sub,
mark(&hover, &hover_menu, depth, ix, true, cx); event.position,
cx,
)
})
.on_hover(move |hovered, _, cx| {
if !hovered {
aim::leave(&left, (depth, (ix, 0)), cx);
} }
}) })
.on_mouse_down( .on_mouse_down(
@@ -134,7 +240,7 @@ pub(super) fn panel(
move |_, window, cx| { move |_, window, cx| {
window.prevent_default(); window.prevent_default();
cx.stop_propagation(); cx.stop_propagation();
choose(&click, &menu, depth, ix, &close, window, cx); choose(&click, &menu, depth, (ix, 0), &close, window, cx);
}, },
) )
}) })
@@ -150,7 +256,16 @@ pub(super) fn panel(
} }
}); });
surface((id.clone(), format!("panel-{depth}")), cx) surface((id.clone(), format!("panel-{depth}")), cx)
.role(Role::Menu)
.debug_selector(move || format!("menu-panel-{depth}"))
.relative() .relative()
// Rows under this panel take no hover.
.occlude()
// Presses here are the menu's, not its host's.
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.min_w(theme.menu_width()) .min_w(theme.menu_width())
.p_1() .p_1()
.flex() .flex()
@@ -164,3 +279,23 @@ pub(super) fn panel(
changed changed
})) }))
} }
/// Records the host's, a marked row's or a panel's box.
pub(super) fn measure(
state: Entity<Open>,
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
if state.update(cx, |open, _| write(open, bounds)) {
state.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
+136 -68
View File
@@ -1,16 +1,17 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, Anchor, Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity,
IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, div, prelude::*, InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task,
Window, anchored, div, prelude::*,
}; };
use super::{ use super::{
draw, draw::{self, measure},
model::{Kind, Menu}, model::{Kind, Menu},
}; };
use crate::{ use crate::{
forms::{Down, Enter, Run, TextInput, Up, float}, forms::{Down, Enter, Left, Right, Run, TextInput, Up, float},
motion, motion,
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus}, primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
theme::{ActiveTheme, IconSize}, theme::{ActiveTheme, IconSize},
@@ -21,7 +22,7 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
levels.get(..depth)?.iter().try_fold(menu, |menu, level| { levels.get(..depth)?.iter().try_fold(menu, |menu, level| {
match level match level
.at .at
.and_then(|ix| menu.item_at(ix)) .and_then(|ix| menu.item_at(ix, level.column))
.map(|item| &item.kind) .map(|item| &item.kind)
{ {
Some(Kind::Sub(sub)) => Some(sub), Some(Kind::Sub(sub)) => Some(sub),
@@ -34,11 +35,17 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
fn settle(menu: &Menu, levels: &mut Vec<Level>) { fn settle(menu: &Menu, levels: &mut Vec<Level>) {
let mut here = menu; let mut here = menu;
for depth in 0..levels.len() { for depth in 0..levels.len() {
let at = levels[depth] let (column, name) = (levels[depth].column, levels[depth].name.clone());
.at // A mark holds its item, not just its place.
.filter(|&ix| here.item_at(ix).is_some_and(|item| !item.disabled)); let at = levels[depth].at.filter(|&ix| {
here.item_at(ix, column)
.is_some_and(|item| !item.disabled && item.label == name)
});
levels[depth].at = at; levels[depth].at = at;
match at.and_then(|ix| here.item_at(ix)).map(|item| &item.kind) { match at
.and_then(|ix| here.item_at(ix, column))
.map(|item| &item.kind)
{
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub, Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
_ => { _ => {
levels.truncate(depth + 1); levels.truncate(depth + 1);
@@ -48,10 +55,12 @@ fn settle(menu: &Menu, levels: &mut Vec<Level>) {
} }
} }
/// One open level: its marked row, that row's box and its panel's box. /// An open level: marked row, button, its label, and boxes.
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Level { pub(super) struct Level {
pub(super) at: Option<usize>, pub(super) at: Option<usize>,
pub(super) column: usize,
pub(super) name: SharedString,
pub(super) row: Bounds<Pixels>, pub(super) row: Bounds<Pixels>,
pub(super) panel: Bounds<Pixels>, pub(super) panel: Bounds<Pixels>,
} }
@@ -71,6 +80,12 @@ pub(crate) struct Open {
host: Bounds<Pixels>, host: Bounds<Pixels>,
anchor: Bounds<Pixels>, anchor: Bounds<Pixels>,
takeover: Option<Entity<Takeover>>, takeover: Option<Entity<Takeover>>,
/// The pointer's last place over a row.
pub(super) pointer: Option<Point<Pixels>>,
/// The row under the pointer: depth, index and column.
pub(super) over: Option<(usize, (usize, usize))>,
/// A delayed mark; dropping it cancels.
pub(super) pending: Option<Task<()>>,
} }
impl Open { impl Open {
@@ -90,25 +105,20 @@ impl Open {
/// Opens at `spot`, with the first row marked when `marked`. /// Opens at `spot`, with the first row marked when `marked`.
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) { pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
let first = menu.step(None, 1).filter(|_| marked); state.update(cx, |open, _| open.spot = Some(spot));
state.update(cx, |open, cx| { Self::begin(state, menu, menu.step(None, 1).filter(|_| marked), cx);
open.spot = Some(spot);
open.levels = vec![Level {
at: first,
..Level::default()
}];
cx.notify();
});
} }
/// Starts over on new rows with the first marked; the menu stays where it hangs. /// Starts over on new rows with the first marked; the menu stays where it hangs.
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) { pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
let first = menu.step(None, 1); Self::begin(state, menu, menu.step(None, 1), cx);
}
/// Fresh levels marking `first`; the old hover is dropped.
fn begin(state: &Entity<Open>, menu: &Menu, first: Option<usize>, cx: &mut App) {
state.update(cx, |open, cx| { state.update(cx, |open, cx| {
open.levels = vec![Level { open.levels = vec![first_of(menu, first)];
at: first, (open.pointer, open.over, open.pending) = (None, None, None);
..Level::default()
}];
cx.notify(); cx.notify();
}); });
} }
@@ -118,6 +128,7 @@ impl Open {
let takeover = state.update(cx, |open, cx| { let takeover = state.update(cx, |open, cx| {
open.spot = None; open.spot = None;
open.levels.clear(); open.levels.clear();
(open.pointer, open.over, open.pending) = (None, None, None);
cx.notify(); cx.notify();
open.takeover.take() open.takeover.take()
}); });
@@ -127,25 +138,49 @@ impl Open {
} }
} }
/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`. /// A level marking row `at` at its first enabled button.
fn first_of(menu: &Menu, at: Option<usize>) -> Level {
let Some(ix) = at else {
return Level::default();
};
let column = menu.first(ix).expect("a stepped row runs");
Level {
at,
column,
name: menu
.item_at(ix, column)
.expect("its first spot")
.label
.clone(),
..Level::default()
}
}
/// Marks a spot at `depth`; `open_sub` opens its submenu.
pub(super) fn mark( pub(super) fn mark(
state: &Entity<Open>, state: &Entity<Open>,
menu: &Menu, menu: &Menu,
depth: usize, depth: usize,
at: usize, (at, column): (usize, usize),
open_sub: bool, open_sub: bool,
cx: &mut App, cx: &mut App,
) { ) {
state.update(cx, |open, cx| { state.update(cx, |open, cx| {
let Some(here) = at_depth(menu, &open.levels, depth) else { if open.levels.len() <= depth {
return log::debug!("menu: level {depth} closed before its mark");
}
let Some(item) =
at_depth(menu, &open.levels, depth).and_then(|here| here.item_at(at, column))
else {
return; return;
}; };
let sub = matches!(here.item_at(at).map(|item| &item.kind), Some(Kind::Sub(_))); let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
open.levels.truncate(depth + 1); open.levels.truncate(depth + 1);
if open.levels[depth].at != Some(at) { let level = &mut open.levels[depth];
open.levels[depth].at = Some(at); if level.at != Some(at) {
open.levels[depth].row = Bounds::default(); level.row = Bounds::default();
} }
(level.at, level.column, level.name) = (Some(at), column, name);
if open_sub && sub { if open_sub && sub {
open.levels.push(Level::default()); open.levels.push(Level::default());
} }
@@ -153,12 +188,12 @@ pub(super) fn mark(
}); });
} }
/// Runs a row. A submenu opens with its first row marked; anything else closes the menu first. /// Runs a spot; a submenu opens, others close first.
pub(super) fn choose( pub(super) fn choose(
state: &Entity<Open>, state: &Entity<Open>,
menu: &Menu, menu: &Menu,
depth: usize, depth: usize,
at: usize, (at, column): (usize, usize),
close: &Run, close: &Run,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
@@ -166,23 +201,23 @@ pub(super) fn choose(
let levels = &state.read(cx).levels; let levels = &state.read(cx).levels;
let Some(item) = at_depth(menu, levels, depth) let Some(item) = at_depth(menu, levels, depth)
.filter(|_| levels.len() > depth) .filter(|_| levels.len() > depth)
.and_then(|here| here.item_at(at)) .and_then(|here| here.item_at(at, column))
.cloned() .cloned()
else { else {
log::debug!("menu: a press from an older frame found row {at} of level {depth} gone"); log::debug!(
"menu: a press from an older frame found row {at}:{column} of level {depth} gone"
);
return; return;
}; };
if item.disabled { if item.disabled {
return; return;
} }
state.update(cx, |open, _| open.pending = None);
if let Kind::Sub(sub) = &item.kind { if let Kind::Sub(sub) = &item.kind {
let first = sub.step(None, 1); let first = first_of(sub, sub.step(None, 1));
mark(state, menu, depth, at, false, cx); mark(state, menu, depth, (at, column), false, cx);
state.update(cx, |open, cx| { state.update(cx, |open, cx| {
open.levels.push(Level { open.levels.push(first);
at: first,
..Level::default()
});
cx.notify(); cx.notify();
}); });
return; return;
@@ -194,7 +229,7 @@ pub(super) fn choose(
} }
} }
/// Arrows walk the deepest level; Right opens a submenu; Enter and Space choose; Left and Escape back out. /// Arrows, Enter, Space and Escape drive the deepest level.
fn keys( fn keys(
menu: &Menu, menu: &Menu,
state: &Entity<Open>, state: &Entity<Open>,
@@ -210,19 +245,28 @@ fn keys(
let Some(here) = at_depth(menu, &levels, depth) else { let Some(here) = at_depth(menu, &levels, depth) else {
return false; return false;
}; };
let at = levels[depth].at; state.update(cx, |open, _| open.pending = None);
let (at, column) = (levels[depth].at, levels[depth].column);
let sub = at let sub = at
.and_then(|at| here.item_at(at)) .and_then(|at| here.item_at(at, column))
.is_some_and(|item| matches!(item.kind, Kind::Sub(_))); .is_some_and(|item| matches!(item.kind, Kind::Sub(_)));
let across = |by| at.and_then(|at| here.across(at, column, by).map(|to| (at, to)));
match (key, at) { match (key, at) {
("down" | "up", _) => { ("down" | "up", _) => {
let by = if key == "down" { 1 } else { -1 }; let by = if key == "down" { 1 } else { -1 };
if let Some(to) = here.step(at, by) { if let Some(to) = here.step(at, by) {
let column = here.first(to).expect("a stepped row runs");
mark(state, menu, depth, (to, column), false, cx);
}
}
("left" | "right", Some(_)) if across(1).is_some() => {
let to = across(if key == "right" { 1 } else { -1 }).expect("a row with a button");
mark(state, menu, depth, to, false, cx); mark(state, menu, depth, to, false, cx);
} }
("right", Some(at)) if sub => choose(state, menu, depth, (at, column), close, window, cx),
("enter" | "space", Some(at)) => {
choose(state, menu, depth, (at, column), close, window, cx)
} }
("right", Some(at)) if sub => choose(state, menu, depth, at, close, window, cx),
("enter" | "space", Some(at)) => choose(state, menu, depth, at, close, window, cx),
("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| { ("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| {
open.levels.pop(); open.levels.pop();
cx.notify(); cx.notify();
@@ -233,24 +277,15 @@ fn keys(
true true
} }
/// Records a box into the host's state: the host's own, a marked row's or a panel's. /// Whether the deepest level marks a button row.
pub(super) fn measure( fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
state: Entity<Open>, let levels = &state.read(cx).levels;
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static, let Some(depth) = levels.len().checked_sub(1) else {
) -> impl IntoElement { return false;
canvas( };
move |bounds, window, cx| { let here = at_depth(menu, levels, depth);
if state.update(cx, |open, _| write(open, bounds)) { here.zip(levels[depth].at)
state.update(cx, |_, cx| cx.notify()); .is_some_and(|(here, ix)| here.is_buttons(ix))
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
} }
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it. /// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
@@ -333,6 +368,8 @@ pub(crate) fn hang(
}); });
let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone()); let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone());
let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone()); let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone());
let (left_menu, left_state, left_close) = (menu.clone(), state.clone(), close.clone());
let (right_menu, right_state, right_close) = (menu.clone(), state.clone(), close.clone());
let root = draw::panel(id, menu, state, 0, &close, head, cx) let root = draw::panel(id, menu, state, 0, &close, head, cx)
.track_focus(&focus) .track_focus(&focus)
.when(!spaced, |root| { .when(!spaced, |root| {
@@ -345,6 +382,19 @@ pub(crate) fn hang(
keys(&down_menu, &down_state, "down", &down_close, window, cx); keys(&down_menu, &down_state, "down", &down_close, window, cx);
}) })
.capture_action(|_: &Enter, _, cx| cx.stop_propagation()) .capture_action(|_: &Enter, _, cx| cx.stop_propagation())
// A button row takes the arrows from the filter.
.capture_action(move |_: &Left, window, cx| {
if on_buttons(&left_menu, &left_state, cx) {
cx.stop_propagation();
keys(&left_menu, &left_state, "left", &left_close, window, cx);
}
})
.capture_action(move |_: &Right, window, cx| {
if on_buttons(&right_menu, &right_state, cx) {
cx.stop_propagation();
keys(&right_menu, &right_state, "right", &right_close, window, cx);
}
})
}) })
.on_key_down(move |event, window, cx| { .on_key_down(move |event, window, cx| {
let stroke = &event.keystroke; let stroke = &event.keystroke;
@@ -404,18 +454,36 @@ pub(crate) fn hang(
.with_priority(1) .with_priority(1)
.into_any_element(), .into_any_element(),
}; };
let room = window.viewport_size().width;
let subs = (1..depth).filter_map(|level| { let subs = (1..depth).filter_map(|level| {
let row = state.read(cx).levels[level - 1].row; let open = state.read(cx);
let row = open.levels[level - 1].row;
let width = match open.levels[level].panel.size.width {
width if width > Pixels::ZERO => width,
_ => cx.theme().menu_width().to_pixels(window.rem_size()),
};
// No room on the right: open on the left.
let left = row.right() + width > room && row.left() >= width;
let (corner, from, slide) = match left {
true => (Anchor::TopRight, row.origin, -1.0),
false => (Anchor::TopLeft, row.top_right(), 1.0),
};
(row != Bounds::default()).then(|| { (row != Bounds::default()).then(|| {
raise( raise(
(id.clone(), format!("level-{level}")), (id.clone(), format!("level-{level}")),
anchored().position(row.top_right()).snap_to_window().child( anchored()
.anchor(corner)
.position(from)
.snap_to_window()
.child(
draw::panel(id, menu, state, level, &close, None, cx) draw::panel(id, menu, state, level, &close, None, cx)
.mt_neg_1() .mt_neg_1()
.with_animation( .with_animation(
(id.clone(), format!("sub-{level}")), (id.clone(), format!("sub-{level}")),
enter.clone(), enter.clone(),
|panel, t| panel.opacity(t).ml(motion::NUDGE * (t - 1.0)), move |panel, t| {
panel.opacity(t).left(motion::NUDGE * (t - 1.0) * slide)
},
), ),
), ),
) )
+1
View File
@@ -1,3 +1,4 @@
mod aim;
mod bar; mod bar;
mod draw; mod draw;
mod hosts; mod hosts;
+59 -7
View File
@@ -82,6 +82,8 @@ impl MenuItem {
#[derive(Clone)] #[derive(Clone)]
pub(super) enum Entry { pub(super) enum Entry {
Item(MenuItem), Item(MenuItem),
/// Icon buttons side by side; labels name them.
Buttons(Vec<MenuItem>),
Separator, Separator,
Heading(SharedString), Heading(SharedString),
} }
@@ -121,20 +123,66 @@ impl Menu {
self self
} }
pub(super) fn item_at(&self, ix: usize) -> Option<&MenuItem> { /// A row of icon buttons; labels name them.
pub fn buttons(mut self, buttons: impl IntoIterator<Item = MenuItem>) -> Self {
let buttons: Vec<MenuItem> = buttons.into_iter().collect();
assert!(!buttons.is_empty(), "a button row holds buttons");
for button in &buttons {
assert!(
button.icon.is_some() && matches!(button.kind, Kind::Run),
"menu button {:?} needs an icon and runs",
button.label
);
}
self.entries.push(Entry::Buttons(buttons));
self
}
/// Row `ix`, or button `column` of its button row.
pub(super) fn item_at(&self, ix: usize, column: usize) -> Option<&MenuItem> {
match self.entries.get(ix) { match self.entries.get(ix) {
Some(Entry::Item(item)) => Some(item), Some(Entry::Item(item)) if column == 0 => Some(item),
Some(Entry::Buttons(buttons)) => buttons.get(column),
_ => None, _ => None,
} }
} }
/// Row `ix`'s first spot that runs.
pub(super) fn first(&self, ix: usize) -> Option<usize> {
match self.entries.get(ix) {
Some(Entry::Item(item)) => (!item.disabled).then_some(0),
Some(Entry::Buttons(buttons)) => buttons.iter().position(|button| !button.disabled),
_ => None,
}
}
pub(super) fn is_buttons(&self, ix: usize) -> bool {
matches!(self.entries.get(ix), Some(Entry::Buttons(_)))
}
/// The next enabled row from `from`, stepping by `by` and wrapping. /// The next enabled row from `from`, stepping by `by` and wrapping.
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> { pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
let count = self.entries.len() as isize; let count = self.entries.len() as isize;
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize); let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
for _ in 0..count { for _ in 0..count {
at = (at + by).rem_euclid(count); at = (at + by).rem_euclid(count);
if self.item_at(at as usize).is_some_and(|item| !item.disabled) { if self.first(at as usize).is_some() {
return Some(at as usize);
}
}
None
}
/// Row `ix`'s next enabled button after `column`, wrapping.
pub(super) fn across(&self, ix: usize, column: usize, by: isize) -> Option<usize> {
let Some(Entry::Buttons(buttons)) = self.entries.get(ix) else {
return None;
};
let count = buttons.len() as isize;
let mut at = column as isize;
for _ in 0..count {
at = (at + by).rem_euclid(count);
if !buttons[at as usize].disabled {
return Some(at as usize); return Some(at as usize);
} }
} }
@@ -149,15 +197,19 @@ impl Menu {
let mut found: Vec<(i32, MenuItem)> = self let mut found: Vec<(i32, MenuItem)> = self
.entries .entries
.iter() .iter()
.filter_map(|entry| match entry { .flat_map(|entry| match entry {
Entry::Item(item) => fuzzy(query, &item.label).map(|fit| { Entry::Item(item) => std::slice::from_ref(item),
Entry::Buttons(buttons) => buttons.as_slice(),
_ => &[],
})
.filter_map(|item| {
fuzzy(query, &item.label).map(|fit| {
let item = MenuItem { let item = MenuItem {
hits: fit.hits, hits: fit.hits,
..item.clone() ..item.clone()
}; };
(fit.score, item) (fit.score, item)
}), })
_ => None,
}) })
.collect(); .collect();
found.sort_by(|(a, _), (b, _)| b.cmp(a)); found.sort_by(|(a, _), (b, _)| b.cmp(a));
+283
View File
@@ -0,0 +1,283 @@
//! Hover intent: delayed submenus, the aim triangle, flips, occlusion.
use gpui::{
Bounds, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Pixels,
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px, size,
};
use super::settle;
use crate::{
menus::{ContextMenu, Menu, MenuItem, aim},
primitives::{FocusScope, IconName},
theme::Theme,
};
/// Two submenus and a plain row.
fn menu() -> Menu {
let insert = Menu::new()
.item(MenuItem::new("Row above"))
.item(MenuItem::new("Row below"))
.item(MenuItem::new("Column left"));
let delete = Menu::new()
.item(MenuItem::new("Delete row"))
.item(MenuItem::new("Delete column"));
Menu::new()
.item(MenuItem::submenu("Insert", insert))
.item(MenuItem::submenu("Delete", delete))
.item(MenuItem::new("Plain"))
}
/// A submenu over icon buttons and a disabled row.
fn tools() -> Menu {
let insert = Menu::new()
.item(MenuItem::new("Row above"))
.item(MenuItem::new("Row below"))
.item(MenuItem::new("Column left"))
.item(MenuItem::new("Column right"));
Menu::new()
.item(MenuItem::submenu("Insert", insert))
.buttons([
MenuItem::new("Cut").icon(IconName::Scissors),
MenuItem::new("Copy").icon(IconName::Copy),
MenuItem::new("Paste").icon(IconName::Clipboard),
])
.item(MenuItem::new("Gone").disabled(true))
.item(MenuItem::new("Plain"))
}
/// A region whose right click opens `menu`.
struct Region {
root: FocusHandle,
menu: fn() -> Menu,
}
impl Render for Region {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
FocusScope::new(&self.root).size_full().child(
ContextMenu::new("region", (self.menu)())
.size_full()
.child(div().size_full()),
)
}
}
/// A window `width` wide, `menu` opened at `x`.
fn region(width: f32, x: f32, cx: &mut TestAppContext) -> &mut VisualTestContext {
region_of(menu, width, x, cx)
}
fn region_of(
menu: fn() -> Menu,
width: f32,
x: f32,
cx: &mut TestAppContext,
) -> &mut VisualTestContext {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
let (_, cx) = cx.add_window_view(|_, cx| Region {
root: cx.focus_handle(),
menu,
});
cx.simulate_resize(size(px(width), px(600.0)));
settle(cx);
right_click(point(px(x), px(20.0)), cx);
cx
}
fn right_click(at: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
settle(cx);
}
fn bounds(name: &str, cx: &mut VisualTestContext) -> Option<Bounds<Pixels>> {
cx.debug_bounds(Box::leak(name.to_string().into_boxed_str()))
}
fn row(label: &str, cx: &mut VisualTestContext) -> Bounds<Pixels> {
bounds(&format!("menu-row-{label}"), cx).expect("the row is drawn")
}
fn shown(label: &str, cx: &mut VisualTestContext) -> bool {
bounds(&format!("menu-row-{label}"), cx).is_some()
}
fn move_to(at: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_move(at, None, Modifiers::none());
settle(cx);
}
fn wait(time: std::time::Duration, cx: &mut VisualTestContext) {
cx.executor().advance_clock(time);
settle(cx);
}
/// Opens Insert's submenu by resting on it.
fn rest_on_insert(cx: &mut VisualTestContext) {
move_to(row("Insert", cx).center(), cx);
wait(aim::OPEN, cx);
assert!(shown("Row above", cx), "Insert's submenu opened");
}
#[gpui::test]
fn a_submenu_opens_after_the_pointer_rests(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
move_to(row("Insert", cx).center(), cx);
assert!(!shown("Row above", cx), "not yet");
wait(aim::OPEN, cx);
assert!(shown("Row above", cx));
}
#[gpui::test]
fn aiming_at_the_submenu_holds_it_across_rows(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let delete = row("Delete", cx);
move_to(point(delete.right() - px(16.0), delete.center().y), cx);
assert!(
shown("Row above", cx),
"crossing Delete keeps Insert's submenu"
);
wait(aim::HOLD / 2, cx);
assert!(shown("Row above", cx));
move_to(row("Row below", cx).center(), cx);
wait(aim::HOLD, cx);
assert!(shown("Row above", cx), "the submenu was reached");
assert!(!shown("Delete row", cx));
}
#[gpui::test]
fn resting_on_a_crossed_row_switches_after_the_hold(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let delete = row("Delete", cx);
move_to(point(delete.right() - px(16.0), delete.center().y), cx);
wait(aim::HOLD, cx);
assert!(shown("Delete row", cx), "Delete's submenu replaced it");
assert!(!shown("Row above", cx));
}
#[gpui::test]
fn moving_away_from_the_submenu_switches_at_once(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let insert = row("Insert", cx).center();
move_to(point(insert.x, row("Delete", cx).center().y), cx);
assert!(!shown("Row above", cx), "straight down left Insert");
wait(aim::OPEN, cx);
assert!(shown("Delete row", cx));
}
#[gpui::test]
fn a_submenu_without_room_on_the_right_opens_on_the_left(cx: &mut TestAppContext) {
let cx = region(800.0, 790.0, cx);
rest_on_insert(cx);
let parent = bounds("menu-panel-0", cx).expect("the menu");
let sub = bounds("menu-panel-1", cx).expect("the submenu");
// It meets the row's edge, as on the right.
let edge = row("Insert", cx).left() + crate::motion::NUDGE;
assert!(sub.left() < parent.left(), "{sub:?} left of {parent:?}");
assert!(sub.right() <= edge, "{sub:?} leaves the rows of {parent:?}");
}
#[gpui::test]
fn rows_under_a_submenu_take_no_hover(cx: &mut TestAppContext) {
let cx = region(320.0, 10.0, cx);
rest_on_insert(cx);
let (plain, sub) = (
row("Plain", cx),
bounds("menu-panel-1", cx).expect("submenu"),
);
assert!(sub.left() < plain.right(), "the submenu covers the menu");
let under = point((sub.left() + plain.right()) / 2.0, plain.center().y);
assert!(sub.contains(&under), "the point is on the submenu");
move_to(under, cx);
wait(aim::HOLD + aim::OPEN, cx);
assert!(
shown("Row above", cx),
"Plain under the submenu stayed unmarked"
);
}
#[gpui::test]
fn leaving_a_row_drops_its_delayed_open(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
let insert = row("Insert", cx);
move_to(insert.center(), cx);
wait(aim::OPEN / 2, cx);
let panel = bounds("menu-panel-0", cx).expect("the menu");
move_to(point(panel.left() + px(1.0), insert.center().y), cx);
wait(aim::OPEN, cx);
assert!(!shown("Row above", cx), "padding is not the row");
move_to(insert.center(), cx);
wait(aim::OPEN / 2, cx);
assert!(!shown("Row above", cx), "re-entry waits the full pause");
wait(aim::OPEN / 2, cx);
assert!(shown("Row above", cx));
}
#[gpui::test]
fn leaving_the_menu_drops_the_delayed_open(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
move_to(row("Insert", cx).center(), cx);
wait(aim::OPEN / 2, cx);
move_to(point(px(900.0), px(500.0)), cx);
wait(aim::OPEN, cx);
assert!(!shown("Row above", cx));
}
#[gpui::test]
fn a_reopened_menu_forgets_the_old_hover(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
move_to(row("Insert", cx).center(), cx);
wait(aim::OPEN / 2, cx);
right_click(point(px(500.0), px(300.0)), cx);
wait(aim::OPEN, cx);
assert!(!shown("Row above", cx));
}
#[gpui::test]
fn leaving_a_crossed_row_keeps_the_submenu(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let delete = row("Delete", cx);
move_to(point(delete.right() - px(16.0), delete.center().y), cx);
move_to(point(px(900.0), px(500.0)), cx);
wait(aim::HOLD, cx);
assert!(shown("Row above", cx), "Delete was abandoned");
assert!(!shown("Delete row", cx));
}
#[gpui::test]
fn a_disabled_row_drops_the_delayed_open(cx: &mut TestAppContext) {
let cx = region_of(tools, 1000.0, 40.0, cx);
move_to(row("Insert", cx).center(), cx);
wait(aim::OPEN / 2, cx);
move_to(row("Gone", cx).center(), cx);
wait(aim::OPEN, cx);
assert!(!shown("Row above", cx));
}
#[gpui::test]
fn aiming_across_icon_buttons_holds_the_submenu(cx: &mut TestAppContext) {
let cx = region_of(tools, 1000.0, 40.0, cx);
let insert = row("Insert", cx);
let from = point(insert.left() + px(4.0), insert.center().y);
move_to(from, cx);
wait(aim::OPEN, cx);
let sub = bounds("menu-panel-1", cx).expect("Insert's submenu");
let paste = bounds("menu-button-1-2", cx).expect("the paste button");
assert!(aim::toward(from, paste.center(), sub), "the crossing aims");
move_to(paste.center(), cx);
assert!(shown("Row above", cx), "crossing a button keeps it");
move_to(row("Row below", cx).center(), cx);
wait(aim::HOLD, cx);
assert!(shown("Row above", cx), "the submenu was reached");
move_to(from, cx);
move_to(paste.center(), cx);
wait(aim::HOLD, cx);
assert!(!shown("Row above", cx), "resting on the button marks it");
}
+247
View File
@@ -0,0 +1,247 @@
use gpui::{
App, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Render,
SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{press, settle};
use crate::{
menus::{ContextMenu, Menu, MenuItem, SearchableMenu},
primitives::{FocusScope, IconName},
theme::Theme,
};
/// A context menu headed by clipboard buttons.
struct Clip {
root: FocusHandle,
ran: Vec<SharedString>,
paste_off: bool,
gone: Option<&'static str>,
}
impl Clip {
fn menu(&self, cx: &mut Context<Self>) -> Menu {
let run = |name: &'static str| {
let view = cx.entity();
move |_: &mut Window, cx: &mut App| {
view.update(cx, |clip, _| clip.ran.push(name.into()))
}
};
Menu::new()
.buttons(
[
MenuItem::new("Cut")
.icon(IconName::Scissors)
.on_click(run("cut")),
MenuItem::new("Copy")
.icon(IconName::Copy)
.disabled(true)
.on_click(run("copy")),
MenuItem::new("Paste")
.icon(IconName::Clipboard)
.keys("secondary-v")
.disabled(self.paste_off)
.on_click(run("paste")),
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(run("delete")),
]
.into_iter()
.filter(|item| Some(item.label.as_ref()) != self.gone),
)
.item(MenuItem::new("Select all").on_click(run("select all")))
}
}
impl Render for Clip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu = self.menu(cx);
FocusScope::new(&self.root)
.size_full()
.child(SearchableMenu::new("find", "Find", menu.clone()))
.child(ContextMenu::new("area", menu).child(div().w(px(300.0)).h(px(200.0))))
}
}
fn clip(cx: &mut TestAppContext) -> (gpui::Entity<Clip>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
let (view, cx) = cx.add_window_view(|_, cx| Clip {
root: cx.focus_handle(),
ran: Vec::new(),
paste_off: false,
gone: None,
});
settle(cx);
(view, cx)
}
fn ran(view: &gpui::Entity<Clip>, cx: &mut VisualTestContext) -> Vec<SharedString> {
view.read_with(cx, |clip, _| clip.ran.clone())
}
fn right_click(cx: &mut VisualTestContext) {
let at = point(px(60.0), px(120.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
settle(cx);
}
fn press_button(selector: &'static str, cx: &mut VisualTestContext) {
let at = cx.debug_bounds(selector).expect("the button").center();
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
settle(cx);
}
#[gpui::test]
fn arrows_walk_a_button_row_past_its_disabled_button(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
press("down", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut"], "down marks the row's first button");
right_click(cx);
press("down", cx);
press("right", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut", "paste"], "right skips copy");
right_click(cx);
press("down", cx);
press("left", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut", "paste", "delete"], "left wraps");
right_click(cx);
press("down", cx);
press("down", cx);
press("enter", cx);
assert_eq!(
ran(&view, cx).last().map(|name| name.as_ref()),
Some("select all")
);
}
#[gpui::test]
fn a_pressed_button_runs_and_a_disabled_one_does_not(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
press_button("menu-button-0-1", cx);
assert!(ran(&view, cx).is_empty(), "copy is off");
press_button("menu-button-0-2", cx);
assert_eq!(ran(&view, cx), ["paste"]);
assert!(
cx.debug_bounds("menu-button-0-0").is_none(),
"the menu closed after paste"
);
}
#[gpui::test]
fn a_move_after_a_button_closed_the_menu_does_nothing(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
let at = cx.debug_bounds("menu-button-0-2").expect("paste").center();
// Both events land before any repaint.
cx.update(|window, cx| {
let down = gpui::MouseDownEvent {
position: at,
button: MouseButton::Left,
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
};
let moved = gpui::MouseMoveEvent {
position: at + point(px(1.0), px(0.0)),
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::none(),
};
window.dispatch_event(gpui::PlatformInput::MouseDown(down), cx);
window.dispatch_event(gpui::PlatformInput::MouseMove(moved), cx);
});
settle(cx);
assert_eq!(ran(&view, cx), ["paste"]);
}
#[gpui::test]
fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
cx.update(|window, cx| window.focus_next(cx));
press("enter", cx);
cx.simulate_input("pas");
settle(cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["paste"]);
}
#[gpui::test]
fn arrows_cross_buttons_while_the_filter_holds_focus(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
cx.update(|window, cx| window.focus_next(cx));
press("enter", cx);
press("right", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["paste"], "right skips copy");
}
#[gpui::test]
fn a_marked_button_turned_off_or_gone_runs_nothing(cx: &mut TestAppContext) {
for change in [
|clip: &mut Clip| clip.paste_off = true,
|clip: &mut Clip| clip.gone = Some("Paste"),
] {
let (view, cx) = clip(cx);
right_click(cx);
press("down", cx);
press("right", cx);
view.update(cx, |clip, cx| {
change(clip);
cx.notify();
});
settle(cx);
press("enter", cx);
assert!(
ran(&view, cx).is_empty(),
"another button ran: {:?}",
ran(&view, cx)
);
}
}
#[gpui::test]
fn a_disabled_button_says_it_is_unavailable(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
let on = MenuItem::new("Cut").icon(IconName::Scissors);
let read =
|item: &MenuItem, cx: &mut App| node(item, false, cx).description().map(str::to_owned);
let expected = crate::i18n::text(cx, "state.unavailable", &[]).to_string();
assert_eq!(read(&off, cx), Some(expected));
assert_eq!(read(&on, cx), None);
});
}
/// The accessibility node `item` draws as a menu button.
fn node(item: &MenuItem, current: bool, cx: &mut App) -> gpui::accesskit::Node {
let mut node = gpui::accesskit::Node::new(gpui::Role::Button);
let press: crate::forms::Run = std::rc::Rc::new(|_, _| {});
let drawn = super::super::draw::button(item, (0, 0), current, press, cx);
gpui::Element::write_a11y_info(&drawn, &mut node);
node
}
#[gpui::test]
fn a_button_offers_its_press_and_says_it_is_marked(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
let cut = MenuItem::new("Cut").icon(IconName::Scissors);
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
let click = gpui::accesskit::Action::Click;
let marked = node(&cut, true, cx);
assert!(marked.supports_action(click), "a reader can press it");
assert_eq!(marked.is_selected(), Some(true));
assert_eq!(node(&cut, false, cx).is_selected(), Some(false));
assert!(!node(&off, false, cx).supports_action(click), "off");
});
}
+2
View File
@@ -4,6 +4,8 @@ use gpui::{
Window, div, point, prelude::*, px, Window, div, point, prelude::*, px,
}; };
mod aim;
mod buttons;
mod more; mod more;
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu}; use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};
+1 -1
View File
@@ -195,7 +195,7 @@ impl RenderOnce for EditorTabs {
IconButton::new(("pin", ix), IconName::Pin) IconButton::new(("pin", ix), IconName::Pin)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.tooltip("Unpin") .tooltip(i18n::text(cx, "tabs.unpin", &[]))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
cx.stop_propagation(); cx.stop_propagation();
run(&pin, "unpin", &slot_id, window, cx); run(&pin, "unpin", &slot_id, window, cx);
+40 -5
View File
@@ -2,8 +2,8 @@ use std::rc::Rc;
use gpui::{ use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, ScrollHandle,
StatefulInteractiveElement, Styled, Window, div, prelude::*, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*,
}; };
use crate::{ use crate::{
@@ -108,6 +108,16 @@ impl RenderOnce for Tabs {
window, window,
cx, cx,
); );
// The chosen tab scrolls in per choice and width.
let scroll = window
.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
.read(cx)
.clone();
let followed = window.use_keyed_state((self.id.clone(), "followed"), cx, |_, _| {
None::<(SharedString, Pixels)>
});
let follow =
(axis == Axis::Horizontal).then(|| (followed, self.selected.clone(), scroll.clone()));
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window); let focused = focus.is_focused(window);
let tabs = Rc::new(self.tabs); let tabs = Rc::new(self.tabs);
@@ -172,6 +182,7 @@ impl RenderOnce for Tabs {
let value = tab.value.clone(); let value = tab.value.clone();
div() div()
.id(("tab", ix)) .id(("tab", ix))
.debug_selector(move || format!("tab-{ix}"))
.role(Role::Tab) .role(Role::Tab)
.aria_selected(on) .aria_selected(on)
.aria_label(tab.label.clone()) .aria_label(tab.label.clone())
@@ -221,7 +232,10 @@ impl RenderOnce for Tabs {
.when(compact, |item| { .when(compact, |item| {
item.tooltip(Tooltip::text(tab.label.clone())) item.tooltip(Tooltip::text(tab.label.clone()))
}) })
.when(!compact, |item| item.child(tab.label.clone())) .when(axis == Axis::Horizontal, |item| item.max_w_full())
.when(!compact, |item| {
item.child(div().min_w_0().truncate().child(tab.label.clone()))
})
.when_some(tab.note.clone().filter(|_| !compact), |item, note| { .when_some(tab.note.clone().filter(|_| !compact), |item, note| {
item.child(div().text_color(colors.fg_subtle).child(note)) item.child(div().text_color(colors.fg_subtle).child(note))
}) })
@@ -236,6 +250,9 @@ impl RenderOnce for Tabs {
.relative() .relative()
.flex() .flex()
.when(axis == Axis::Vertical, |strip| strip.flex_col()) .when(axis == Axis::Vertical, |strip| strip.flex_col())
.when(axis == Axis::Horizontal, |strip| {
strip.min_w_0().overflow_x_scroll().track_scroll(&scroll)
})
.gap_1() .gap_1()
.border_color(colors.border) .border_color(colors.border)
.map(|strip| match placement { .map(|strip| match placement {
@@ -270,10 +287,28 @@ impl RenderOnce for Tabs {
key_pick(to, window, cx); key_pick(to, window, cx);
} }
}) })
// Items first: a child's index is its tab's.
.children(items)
.child(measure_width(strip_width)) .child(measure_width(strip_width))
.child(measure_origin(state, axis)) .child(measure_origin(state, axis))
.children(indicator) .children(indicator)
.children(items); .children(follow.map(|(followed, selected, scroll)| {
canvas(
move |_, window, cx| {
let now = (selected, scroll.bounds().size.width);
if now.1 <= Pixels::ZERO || followed.read(cx).as_ref() == Some(&now) {
return;
}
followed.update(cx, |followed, _| *followed = Some(now));
if let Some(ix) = chosen {
scroll.scroll_to_item(ix);
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
}));
let panel = self.panel.map(|panel| { let panel = self.panel.map(|panel| {
div().flex_1().min_w_0().child(panel).with_animation( div().flex_1().min_w_0().child(panel).with_animation(
("tab-panel", turn), ("tab-panel", turn),
@@ -282,7 +317,7 @@ impl RenderOnce for Tabs {
move |panel, t| if turn == 0 { panel } else { panel.opacity(t) }, move |panel, t| if turn == 0 { panel } else { panel.opacity(t) },
) )
}); });
let frame = div().id(self.id).flex().gap_4(); let frame = div().id(self.id).flex().min_w_0().gap_4();
match placement { match placement {
TabPlacement::Top => frame.flex_col().child(strip).children(panel), TabPlacement::Top => frame.flex_col().child(strip).children(panel),
TabPlacement::Bottom => frame.flex_col().children(panel).child(strip), TabPlacement::Bottom => frame.flex_col().children(panel).child(strip),
+108
View File
@@ -180,3 +180,111 @@ fn tabs_whose_choice_closed_mark_none_and_arrows_start_at_the_ends(cx: &mut Test
cx.simulate_keystrokes("left"); cx.simulate_keystrokes("left");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c"); assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c");
} }
struct Sheets {
chosen: SharedString,
clicked: Option<SharedString>,
width: f32,
long: bool,
}
impl Render for Sheets {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let tail = if self.long {
"W".repeat(40)
} else {
String::new()
};
let tabs = (0..30).map(|ix| Choice::new(format!("s{ix}"), format!("Sheet {ix}{tail}")));
div()
.w(px(self.width))
.child(
Tabs::new("sheets", tabs, self.chosen.clone()).on_change(move |value, _, cx| {
view.update(cx, |view, _| view.clicked = Some(value.clone()))
}),
)
}
}
/// The tab number a click at `x` picks.
fn picked_at(x: f32, view: &gpui::Entity<Sheets>, cx: &mut VisualTestContext) -> usize {
view.update(cx, |view, _| view.clicked = None);
click_at(x, cx);
let clicked = view
.read_with(cx, |view, _| view.clicked.clone())
.expect("a tab is picked");
clicked[1..].parse().expect("a tab number")
}
#[gpui::test]
fn a_wide_strip_scrolls_its_chosen_tab_in_and_the_wheel_reaches_the_rest(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Sheets {
chosen: "s29".into(),
clicked: None,
width: 300.0,
long: false,
});
settle(cx);
assert!(
picked_at(10.0, &view, cx) > 20,
"the strip starts at its last tab"
);
cx.simulate_event(ScrollWheelEvent {
position: point(px(60.0), px(16.0)),
delta: ScrollDelta::Pixels(point(px(0.0), px(5000.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
view.update(cx, |_, cx| cx.notify());
settle(cx);
assert_eq!(
picked_at(10.0, &view, cx),
0,
"the wheel scrolls back to the first tab"
);
view.update(cx, |view, cx| {
view.chosen = "s25".into();
cx.notify();
});
settle(cx);
assert!(picked_at(10.0, &view, cx) > 15, "a new choice scrolls in");
}
#[gpui::test]
fn a_strip_that_narrows_keeps_its_chosen_tab_in_view(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Sheets {
chosen: "s29".into(),
clicked: None,
width: 700.0,
long: false,
});
settle(cx);
view.update(cx, |view, cx| {
view.width = 250.0;
cx.notify();
});
settle(cx);
assert!(picked_at(10.0, &view, cx) > 25, "the strip shows its end");
view.update(cx, |view, _| view.clicked = None);
click_at(245.0, cx);
let clicked = view.read_with(cx, |view, _| view.clicked.clone());
assert_eq!(clicked, None, "the chosen tab ends the strip");
}
#[gpui::test]
fn a_long_chosen_tab_fits_the_strip(cx: &mut TestAppContext) {
setup(cx);
let (_view, cx) = cx.add_window_view(|_, _| Sheets {
chosen: "s29".into(),
clicked: None,
width: 250.0,
long: true,
});
settle(cx);
let tab = cx.debug_bounds("tab-29").expect("the chosen tab");
assert!(tab.left() >= px(0.0), "starts in view: {tab:?}");
assert!(tab.right() <= px(250.0), "ends in view: {tab:?}");
}
+8
View File
@@ -210,6 +210,14 @@ pub(crate) fn take_focus(
state state
} }
/// Whether an open overlay holds the window's focus.
pub(crate) fn lent(window: &Window, cx: &App) -> bool {
cx.try_global::<Holders>().is_some_and(|holders| {
(holders.0.iter())
.any(|holder| holder.takeover.upgrade().is_some() && holder.focus.is_focused(window))
})
}
/// Returns focus to whatever held it before the overlay opened. /// Returns focus to whatever held it before the overlay opened.
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &mut App) { pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &mut App) {
let closed = state.downgrade(); let closed = state.downgrade();
+1
View File
@@ -226,6 +226,7 @@ icons! {
Merge => "merge", Merge => "merge",
MessageSquare => "message-square", MessageSquare => "message-square",
MessageSquareDiff => "message-square-diff", MessageSquareDiff => "message-square-diff",
MessageSquareWarning => "message-square-warning",
MicOff => "mic-off", MicOff => "mic-off",
Mic => "mic", Mic => "mic",
Minimize2 => "minimize-2", Minimize2 => "minimize-2",
+2 -1
View File
@@ -25,7 +25,8 @@ pub use divider::Divider;
pub use files::IconTheme; pub use files::IconTheme;
pub(crate) use files::{file_icon, icon_theme}; pub(crate) use files::{file_icon, icon_theme};
pub(crate) use focus::{ pub(crate) use focus::{
CONTEXT as FOCUS_CONTEXT, Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus, CONTEXT as FOCUS_CONTEXT, Takeover, give_back, hand_back, hold_focus, lent, tab_stop,
take_focus,
}; };
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub use ghost::DragGhost; pub use ghost::DragGhost;
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more