Compare commits

...
47 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 / site (push) Successful in 36s
CI / platforms (windows-latest) (push) Successful in 4m59s
CI / crate (push) Failing after 12m49s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 (windows-latest) (push) Successful in 5m41s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / crate (push) Failing after 12m30s
CI / platforms (windows-latest) (push) Successful in 4m36s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / site (push) Successful in 30s
CI / platforms (windows-latest) (push) Successful in 5m29s
CI / crate (push) Failing after 12m24s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 (windows-latest) (push) Successful in 4m42s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 2s
CI / crate (push) Failing after 2s
CI / site (push) Successful in 24s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 10m33s
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 / site (push) Successful in 22s
CI / crate (push) Failing after 11m53s
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 / site (push) Successful in 22s
CI / crate (push) Failing after 11m4s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / site (push) Successful in 23s
CI / crate (push) Failing after 13m33s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m28s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 25s
CI / crate (push) Failing after 11m23s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 12m14s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / crate (push) Failing after 13m36s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m5s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / site (push) Successful in 22s
CI / crate (push) Failing after 11m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
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 / site (push) Successful in 21s
CI / crate (push) Failing after 12m5s
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
85 changed files with 5423 additions and 501 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. 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; 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 paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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 listLine truncated - 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 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. - 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.
+31 -8
View File
@@ -7,7 +7,8 @@ use gpui::{AppContext as _, Context};
use super::{ use super::{
DocumentEvent, LARGEST_READ, MarkdownDocument, DocumentEvent, LARGEST_READ, MarkdownDocument,
block::{self, BlockCommand, settled}, block::{self, BlockCommand, settled},
format::{Change, EditRefusal, InlineFormat, toggle}, cells,
format::{Change, EditRefusal, InlineFormat, drop_image, toggle},
tables::{self, TableEdit}, tables::{self, TableEdit},
}; };
use crate::documents::{MarkdownMode, modes::joint}; use crate::documents::{MarkdownMode, modes::joint};
@@ -29,6 +30,12 @@ impl MarkdownDocument {
) )
} }
/// 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. /// `command` at the caret, read apart; refusals by event.
pub fn block( pub fn block(
&mut self, &mut self,
@@ -50,8 +57,9 @@ impl MarkdownDocument {
) -> Result<(), EditRefusal> { ) -> Result<(), EditRefusal> {
self.apart( self.apart(
format!("table edit {edit:?}"), format!("table edit {edit:?}"),
move |full, selection| { move |full, selection| match edit {
tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells) TableEdit::Delete => tables::delete(full, selection.start),
_ => tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells),
}, },
cx, cx,
) )
@@ -106,7 +114,7 @@ impl MarkdownDocument {
} }
/// The text an edit reads: the open block's in place. /// The text an edit reads: the open block's in place.
fn snapshot(&self, cx: &Context<Self>) -> Snapshot { pub(super) fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
let text = self.editor.read(cx).text(); let text = self.editor.read(cx).text();
let Some(open) = &self.open else { let Some(open) = &self.open else {
let picked = self.editor.read(cx).primary().range(); let picked = self.editor.read(cx).primary().range();
@@ -120,9 +128,19 @@ impl MarkdownDocument {
&text[open.range.end..], &text[open.range.end..],
), ),
}; };
let picked = input.selection(); 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 offset = before.len();
let full = format!("{before}{}{after}", input.text()); let full = format!("{before}{typed}{after}");
settle((full, offset, offset + picked.start..offset + picked.end)) settle((full, offset, offset + picked.start..offset + picked.end))
} }
@@ -137,10 +155,15 @@ impl MarkdownDocument {
) -> Result<(), EditRefusal> { ) -> Result<(), EditRefusal> {
log::info!("markdown document: {name}"); log::info!("markdown document: {name}");
if let Some(open) = &self.open { if let Some(open) = &self.open {
let end = offset + open.input.read(cx).text().len(); 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) let grown = (edits.iter().map(|(_, with)| with.len()).sum::<usize>() + end)
.checked_sub(edits.iter().map(|(range, _)| range.len()).sum::<usize>()); .checked_sub(edits.iter().map(|(range, _)| range.len()).sum::<usize>());
let inside = edits let inside = !raw
&& edits
.iter() .iter()
.all(|(range, _)| offset <= range.start && range.end <= end) .all(|(range, _)| offset <= range.start && range.end <= end)
&& grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown); && grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown);
+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);
}
}
+163 -16
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,6 +13,7 @@ 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. /// Where an opened block's caret lands.
@@ -19,13 +23,26 @@ pub(super) enum Landing {
End, End,
/// A source byte of the drawn read. /// A source byte of the drawn read.
At(usize), 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.
pub(super) 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,
} }
@@ -63,6 +80,7 @@ impl MarkdownDocument {
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),
@@ -71,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
@@ -87,12 +118,36 @@ 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 { let caret = match landing {
_ if cell.is_some() => cell.map(|(_, caret)| caret),
Landing::Start => Some(0), Landing::Start => Some(0),
Landing::End => None, Landing::End | Landing::Cell(_) => None,
Landing::At(at) => match moved(at, &written) { Landing::At(at) => match moved(at, &written) {
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start), Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
_ => { _ => {
@@ -103,9 +158,11 @@ impl MarkdownDocument {
}; };
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);
@@ -114,9 +171,12 @@ impl MarkdownDocument {
} }
}); });
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);
} }
@@ -124,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(|| {
@@ -182,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),
@@ -202,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, edit::Landing}; 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), Landing::End, 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));
} }
} }
+18
View File
@@ -40,6 +40,8 @@ pub enum EditRefusal {
Outside, Outside,
/// A table holds the caret. /// A table holds the caret.
Table, Table,
/// No picture holds the caret.
NoImage,
/// Front matter is there already. /// Front matter is there already.
Present, Present,
/// The selection crosses a block or another mark. /// The selection crosses a block or another mark.
@@ -59,6 +61,22 @@ pub enum EditRefusal {
/// The edits and the selection after them. /// The edits and the selection after them.
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>); 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. /// Typora's word stops: punctuation and space.
fn stop(ch: char) -> bool { fn stop(ch: char) -> bool {
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch) ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
+2 -2
View File
@@ -5,7 +5,7 @@ use std::ops::Range;
use markdown::mdast::Node; use markdown::mdast::Node;
use super::{Change, EditRefusal, after, block, cut, holder, marks, room}; use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
use crate::documents::{markdown::span, tree::inside}; use crate::documents::markdown::{math_inside, span};
/// The backslashes of escapes in a text node's span. /// The backslashes of escapes in a text node's span.
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> { fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
@@ -33,7 +33,7 @@ fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(), Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
Node::Text(_) => escapes(text, whole), Node::Text(_) => escapes(text, whole),
Node::InlineMath(_) => { Node::InlineMath(_) => {
let inner = inside(text, whole.clone(), '$'); let inner = math_inside(text, whole.clone());
vec![whole.start..inner.start, inner.end..whole.end] vec![whole.start..inner.start, inner.end..whole.end]
} }
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end], Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
+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;
+21 -3
View File
@@ -1,6 +1,7 @@
mod apart; mod apart;
mod attach; mod attach;
mod block; mod block;
mod cells;
mod edit; mod edit;
mod finding; mod finding;
mod format; mod format;
@@ -36,7 +37,7 @@ use super::{
MarkdownMode, MarkdownMode,
markdown::markdown_highlights, markdown::markdown_highlights,
modes::joint, modes::joint,
render::{OnHeading, OnLink}, render::{CellAt, OnHeading, OnLink},
}; };
use crate::{ use crate::{
editor::{CodeEditor, EditorEvent, LineNumbers}, editor::{CodeEditor, EditorEvent, LineNumbers},
@@ -80,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>,
} }
@@ -95,6 +100,8 @@ pub struct MarkdownDocument {
editing: bool, 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,
@@ -102,16 +109,20 @@ 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. /// A secondary press took focus.
pointing: bool, pointing: bool,
/// The latest press; older lookups lapse.
pressed: u64,
on_link: Option<OnLink>, on_link: Option<OnLink>,
on_heading: Option<OnHeading>, on_heading: Option<OnHeading>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
@@ -167,16 +178,19 @@ impl MarkdownDocument {
editing: false, 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, pointing: false,
pressed: 0,
on_link: None, on_link: None,
on_heading: Some(on_heading), on_heading: Some(on_heading),
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -235,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()
@@ -342,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();
} }
+10 -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.
+211 -13
View File
@@ -1,12 +1,19 @@
use std::rc::Rc; use std::{ops::Range, rc::Rc};
use gpui::{Context, MouseButton, Window}; use gpui::{AppContext as _, Context, Focusable as _, MouseButton, Pixels, Point, Window};
use markdown::mdast; use markdown::mdast;
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block}; use super::{
DocumentEvent, MarkdownDocument,
cells::{self, Aim},
edit::Landing,
edit::moved,
parse::Block,
};
use crate::documents::{ use crate::documents::{
render::{OnPoint, Place, Pointed}, markdown::{file_tree, span, walk},
tree::{Node, alert, is_toc, lines_at}, render::{CellAt, OnPoint, Place, Pointed},
tree::{Node, Reader, alert, is_toc, lines_at},
}; };
/// A top-level block's kind. /// A top-level block's kind.
@@ -54,7 +61,7 @@ impl TargetBlock {
/// What a secondary press landed on. /// What a secondary press landed on.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentTarget { pub struct DocumentTarget {
/// The source byte the caret went to. /// The pressed source byte; an open block's as unwritten.
pub at: usize, pub at: usize,
/// None past the last block. /// None past the last block.
pub block: Option<TargetBlock>, pub block: Option<TargetBlock>,
@@ -69,7 +76,8 @@ impl MarkdownDocument {
/// Presses on block `ix`'s drawing, read as it was drawn. /// Presses on block `ix`'s drawing, read as it was drawn.
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint { pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone()); let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
let start = parsed.blocks[ix].range.start; let block = parsed.blocks[ix].range.clone();
let start = block.start;
Rc::new(move |pointed, button, window, cx| { Rc::new(move |pointed, button, window, cx| {
// The drawing counts from its block's start. // The drawing counts from its block's start.
let at = match &pointed.place { let at = match &pointed.place {
@@ -79,22 +87,59 @@ impl MarkdownDocument {
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from)) Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
} }
Place::At(at) => Some(*at), 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, Place::Block => None,
} }
.map(|at| start + at); .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); let ix = Some(ix);
document document
.update(cx, |document, cx| match button { .update(cx, |document, cx| {
MouseButton::Left => { // The open cell's padding keeps its typing.
document.open(ix, at.map_or(Landing::End, Landing::At), window, cx) let open = match pointed.place {
} Place::Cell(cell) => document.open_cell(&block) == Some(cell),
MouseButton::Right => document.point(ix, at, pointed, window, cx), _ => 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"); .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. /// A block's kind, text and what it draws.
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) { fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
let text = self.parsed.text[block.range.clone()].to_string(); let text = self.parsed.text[block.range.clone()].to_string();
@@ -110,6 +155,7 @@ impl MarkdownDocument {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.pressed += 1;
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) { 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"), Some(None) => return log::warn!("markdown document: a press on a block read away"),
block => block.flatten(), block => block.flatten(),
@@ -141,7 +187,9 @@ impl MarkdownDocument {
let now = (self.parsed.blocks.iter()) let now = (self.parsed.blocks.iter())
.find(|block| block.range.start <= at && at <= block.range.end) .find(|block| block.range.start <= at && at <= block.range.end)
.map(|block| self.drawn(block)); .map(|block| self.drawn(block));
if written.is_some() && pressed.is_some() && now != pressed { // 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"); return log::warn!("markdown document: the write-back changed the pressed block");
} }
self.editor self.editor
@@ -160,4 +208,154 @@ impl MarkdownDocument {
})); }));
cx.notify(); 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()))
} }
+20 -4
View File
@@ -6,7 +6,7 @@ use unicode_width::UnicodeWidthStr;
use super::{ use super::{
MarkdownDocument, MarkdownDocument,
block::{eol, line_end, line_start, next_line, prev_line, settled}, block::{self, BlockCommand, eol, line_end, line_start, next_line, prev_line, settled},
format::{Change, EditRefusal}, format::{Change, EditRefusal},
}; };
use crate::documents::{ use crate::documents::{
@@ -29,6 +29,8 @@ pub enum TableEdit {
MoveColumnRight, MoveColumnRight,
/// Every cell padded to its column, as Typora tidies. /// Every cell padded to its column, as Typora tidies.
Tidy, Tidy,
/// The whole table.
Delete,
} }
/// Why a table edit did not happen. /// Why a table edit did not happen.
@@ -54,9 +56,7 @@ pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Chang
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)?;
@@ -164,6 +164,22 @@ pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Chang
Ok((vec![(range, out)], caret..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.
fn written(text: &str, cell: &mdast::Node) -> String { fn written(text: &str, cell: &mdast::Node) -> String {
let inside = cell.children().expect("a cell holds inlines"); let inside = cell.children().expect("a cell holds inlines");
+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 |"));
}
+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");
}
+8 -2
View File
@@ -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:?}"
); );
} }
@@ -368,14 +370,18 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
mod attach; mod attach;
mod block; mod block;
mod block_edges; mod block_edges;
mod cells;
mod format; 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 point;
mod presses; mod presses;
mod reading; mod reading;
mod secondary;
mod toc; mod toc;
mod tools; mod tools;
+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:?}");
}
+27 -2
View File
@@ -89,6 +89,25 @@ fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) {
); );
} }
#[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] #[gpui::test]
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) { fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
@@ -197,8 +216,14 @@ fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
let events = heard(&document, cx); let events = heard(&document, cx);
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn"); let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
let (body, _) = sizes(cx); let (body, _) = sizes(cx);
let x = bounds.left() + px(31.0) + body * 0.6 * 7.25; // "beta" draws on its own line.
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx); 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(); let at = source.find("eta").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]); assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
} }
+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"));
}
+51 -3
View File
@@ -341,9 +341,10 @@ fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) {
} }
#[gpui::test] #[gpui::test]
fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) { fn tidy_in_an_open_cell_writes_it_and_tidies(cx: &mut TestAppContext) {
let (document, cx) = open(SMALL, MarkdownMode::Visual, cx); let (document, cx) = open(SMALL, MarkdownMode::Visual, cx);
press(0, Modifiers::none(), cx); press(0, Modifiers::none(), cx);
assert_eq!(opened(&document, cx), "2");
document.update(cx, |document, cx| { document.update(cx, |document, cx| {
document document
.edit_table(crate::documents::TableEdit::Tidy, cx) .edit_table(crate::documents::TableEdit::Tidy, cx)
@@ -351,8 +352,8 @@ fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) {
}); });
cx.run_until_parked(); cx.run_until_parked();
assert_eq!( assert_eq!(
opened(&document, cx), text(&document, cx),
"| A | B |\n| ---- | ---- |\n| 1 | 2 |" "| A | B |\n| ---- | ---- |\n| 1 | 2 |\n"
); );
} }
@@ -385,3 +386,50 @@ fn a_block_after_end_in_crlf_text_reads_the_line(cx: &mut TestAppContext) {
fn a_selection_from_inside_a_crlf_reads_the_line(cx: &mut TestAppContext) { 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); 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);
}
+105 -20
View File
@@ -1,19 +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, StyledText, Window, actions, div, list, StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
prelude::FluentBuilder as _, prelude::FluentBuilder as _, relative,
}; };
use super::{MarkdownDocument, edit::Landing}; use super::{MarkdownDocument, edit::Landing, parse::Block};
use crate::{ use crate::{
documents::{ documents::{
MarkdownMode, MarkdownMode, TargetBlock,
render::{Hooks, Pointed, draw_nodes, point_lines}, 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]);
@@ -23,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
@@ -72,28 +110,34 @@ 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 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()
@@ -110,6 +154,7 @@ impl MarkdownDocument {
on_link: &self.on_link, on_link: &self.on_link,
on_heading: &self.on_heading, on_heading: &self.on_heading,
on_point: &on_point, on_point: &on_point,
cell: cell.as_ref(),
}, },
self.look.scale, self.look.scale,
cx, cx,
@@ -148,6 +193,38 @@ impl MarkdownDocument {
} }
} }
/// 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. /// `element` opens block `ix`; a right press points.
fn pressable( fn pressable(
&self, &self,
@@ -195,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()
@@ -273,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");
}
+1 -1
View File
@@ -46,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};
+118 -117
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,
@@ -17,14 +16,17 @@ use crate::{
editor::{code_colors, code_colors_with}, editor::{code_colors, code_colors_with},
navigation::{Anchor, Sections}, navigation::{Anchor, Sections},
primitives::FocusRing as _, primitives::FocusRing as _,
theme::{ActiveTheme, Radius, TextSize}, theme::{ActiveTheme, Palette, Radius, TextSize},
typography::{Latex, failed}, typography::{Latex, failed},
}; };
mod blocks; mod blocks;
mod math_text;
mod point; mod point;
mod table;
pub(crate) use point::{OnPoint, Place, Pointed, point_lines}; 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. /// Reveals the heading whose source starts at a byte.
@@ -96,42 +98,29 @@ pub(crate) struct Hooks<'a> {
pub on_link: &'a Option<OnLink>, pub on_link: &'a Option<OnLink>,
pub on_heading: &'a Option<OnHeading>, pub on_heading: &'a Option<OnHeading>,
pub on_point: &'a Option<OnPoint>, pub on_point: &'a Option<OnPoint>,
/// The cell typed in, drawn in its place.
pub cell: Option<&'a OpenCell>,
} }
/// Draws nodes under one owner's ids and hooks. /// Headings and strong text share one weight.
struct Draw<'a> { pub(crate) const HEAVY: FontWeight = FontWeight::BOLD;
id: &'a ElementId,
hooks: Hooks<'a>, /// Typora's heading steps over the body.
next: std::cell::Cell<usize>, pub(crate) fn heading_step(level: u8) -> f32 {
sections: &'a Option<Entity<Sections>>, match level {
headings: std::cell::Cell<usize>, 1 => 2.25,
code: &'a Option<DrawCode>, 2 => 1.75,
/// Text size against the theme's. 3 => 1.5,
scale: f32, 4 => 1.25,
/// Lists open around the node drawn. _ => 1.0,
lists: std::cell::Cell<usize>, }
/// Drawing a table's cells.
table: std::cell::Cell<bool>,
} }
impl Draw<'_> { /// How one inline style draws.
fn key(&self, what: &str) -> ElementId { fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
let at = self.next.get(); match style {
self.next.set(at + 1);
(self.id.clone(), format!("{what}-{at}")).into()
}
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let rule = theme.underline_thickness();
let styles: Vec<_> = inline
.styles
.iter()
.map(|(range, style)| {
let highlight = match style {
Style::Strong => HighlightStyle { Style::Strong => HighlightStyle {
font_weight: Some(FontWeight::SEMIBOLD), font_weight: Some(HEAVY),
..HighlightStyle::default() ..HighlightStyle::default()
}, },
Style::Emphasis => HighlightStyle { Style::Emphasis => HighlightStyle {
@@ -179,26 +168,88 @@ impl Draw<'_> {
background_color: Some(colors.warning_subtle), background_color: Some(colors.warning_subtle),
..HighlightStyle::default() ..HighlightStyle::default()
}, },
}; }
(range.clone(), highlight) }
})
/// 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(); .collect();
let styled = let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
let layout = styled.layout().clone(); let layout = styled.layout().clone();
let drawn = match inline.links.is_empty() { let drawn = match links {
true => styled.into_any_element(), None => styled.into_any_element(),
false => { 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.hooks.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()
} }
}; };
self.point_text(drawn, layout, inline) (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> {
@@ -222,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)
@@ -302,60 +351,7 @@ impl Draw<'_> {
self.point_code(block, Some(layout), text, *from) self.point_code(block, Some(layout), text, *from)
} }
Node::List { start, items } => self.list(start, items, cx), Node::List { start, items } => self.list(start, items, cx),
Node::Table { aligns, head, rows } => { Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
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::Rule => div() Node::Rule => div()
.debug_selector(|| "markdown-rule".into()) .debug_selector(|| "markdown-rule".into())
.my_2() .my_2()
@@ -440,9 +436,11 @@ pub(crate) fn draw_nodes(
sections: &None, sections: &None,
headings: std::cell::Cell::new(0), headings: std::cell::Cell::new(0),
code: &None, code: &None,
body: TextSize::Base,
scale, scale,
lists: std::cell::Cell::new(0), lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false), table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
}; };
draw.nodes(nodes, cx) draw.nodes(nodes, cx)
} }
@@ -458,14 +456,17 @@ impl RenderOnce for MarkdownRenderer {
on_link: &self.on_link, on_link: &self.on_link,
on_heading: &None, on_heading: &None,
on_point: &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,
body: self.size,
scale: 1.0, scale: 1.0,
lists: std::cell::Cell::new(0), lists: std::cell::Cell::new(0),
table: std::cell::Cell::new(false), table: std::cell::Cell::new(false),
tables: std::cell::Cell::new(0),
}; };
div() div()
.flex() .flex()
+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:?}");
}
}
+28 -10
View File
@@ -1,11 +1,11 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent, AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent,
ParentElement as _, TextLayout, Window, div, ParentElement as _, TextLayout, Window, div,
}; };
use super::Draw; use super::{Draw, table::CellAt};
use crate::documents::tree::Inline; use crate::documents::tree::Inline;
/// Where a press fell, as the drawing knows it. /// Where a press fell, as the drawing knows it.
@@ -20,6 +20,8 @@ pub(crate) enum Place {
from: usize, from: usize,
at: usize, at: usize,
}, },
/// A table cell, pressed past its text.
Cell(CellAt),
/// Somewhere the block holds no byte for. /// Somewhere the block holds no byte for.
Block, Block,
} }
@@ -61,6 +63,19 @@ fn pointable(
table: bool, table: bool,
point: impl Fn(&MouseDownEvent) -> Pointed + 'static, point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
) -> AnyElement { ) -> 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 { let Some(on_point) = on_point.clone() else {
return element; return element;
}; };
@@ -79,11 +94,9 @@ fn pointable(
on_point(pointed, button, window, cx); on_point(pointed, button, window, cx);
} }
}; };
div() element
.on_mouse_down(MouseButton::Left, press(MouseButton::Left)) .on_mouse_down(MouseButton::Left, press(MouseButton::Left))
.on_mouse_down(MouseButton::Right, press(MouseButton::Right)) .on_mouse_down(MouseButton::Right, press(MouseButton::Right))
.child(element)
.into_any_element()
} }
/// Drawn `text`, its lines written from source byte `from`. /// Drawn `text`, its lines written from source byte `from`.
@@ -120,11 +133,18 @@ impl Draw<'_> {
pointable(element, self.hooks.on_point, self.table.get(), point) pointable(element, self.hooks.on_point, self.table.get(), point)
} }
/// Drawn `inline`; a press finds its byte through `layout`. /// 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( pub(super) fn point_text(
&self, &self,
element: AnyElement, element: AnyElement,
layout: TextLayout, at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
inline: &Inline, inline: &Inline,
) -> AnyElement { ) -> AnyElement {
if self.hooks.on_point.is_none() { if self.hooks.on_point.is_none() {
@@ -132,9 +152,7 @@ impl Draw<'_> {
} }
let inline = Rc::new(inline.clone()); let inline = Rc::new(inline.clone());
self.pointable(element, move |event| { self.pointable(element, move |event| {
let at = layout let at = at(event.position);
.index_for_position(event.position)
.unwrap_or_else(|near| near);
let link = inline.links.iter().find(|(range, _)| range.contains(&at)); let link = inline.links.iter().find(|(range, _)| range.contains(&at));
Pointed { Pointed {
link: link.map(|(_, url)| url.clone()), link: link.map(|(_, url)| url.clone()),
+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()
}
}
+103 -3
View File
@@ -1,7 +1,7 @@
use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px}; use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px, rems};
use super::MarkdownRenderer; use super::{HEAVY, MarkdownRenderer, Style, highlight};
use crate::theme::Theme; use crate::theme::{ActiveTheme, TextSize, Theme};
/// `source` drawn in a 400-point column. /// `source` drawn in a 400-point column.
struct Page(&'static str); struct Page(&'static str);
@@ -51,3 +51,103 @@ fn alerts_lists_of_headings_and_front_matter_draw(cx: &mut TestAppContext) {
); );
assert!(two.top() > one.top(), "{one:?} {two:?}"); 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:?}");
}
+1 -1
View File
@@ -194,7 +194,7 @@ mod tests {
let bold = 2..3; let bold = 2..3;
assert_eq!( assert_eq!(
(line.text.as_str(), &line.styles[..]), (line.text.as_str(), &line.styles[..]),
("a b c", &[(bold, Style::Strong)][..]) ("a b\nc", &[(bold, Style::Strong)][..])
); );
} }
+1 -1
View File
@@ -197,7 +197,7 @@ mod tests {
other => panic!("a paragraph, not {other:?}"), other => panic!("a paragraph, not {other:?}"),
}) })
.collect(); .collect();
assert_eq!(lines, ["<script> first", "second </script>"]); assert_eq!(lines, ["<script> first", "second\n</script>"]);
let inline = html("a <script>b</script> c").expect("text"); let inline = html("a <script>b</script> c").expect("text");
assert_eq!(inline.text, "a <script>b</script> c"); assert_eq!(inline.text, "a <script>b</script> c");
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b"); assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
+36 -10
View File
@@ -6,7 +6,7 @@ use super::markdown::{span, tree, walk};
mod blocks; mod blocks;
mod emoji; mod emoji;
mod html; pub(crate) mod html;
mod html_block; mod html_block;
mod sources; mod sources;
mod stack; mod stack;
@@ -101,6 +101,8 @@ pub struct Reader<'a> {
notes: HashMap<String, usize>, notes: HashMap<String, usize>,
/// Every heading, as `[toc]` lists them. /// Every heading, as `[toc]` lists them.
pub headings: Vec<DocumentHeading>, pub headings: Vec<DocumentHeading>,
/// Soft breaks as lines, as the screen draws them.
lines: bool,
} }
/// An open element, as HTML names it. /// An open element, as HTML names it.
@@ -122,6 +124,8 @@ struct Run {
verbatim: bool, verbatim: bool,
/// In a table cell, whose code reads `\|` as `|`. /// In a table cell, whose code reads `\|` as `|`.
table: bool, table: bool,
/// Soft breaks as lines, not spaces.
lines: bool,
/// The current DOM text node's shortcodes. /// The current DOM text node's shortcodes.
shortcodes: emoji::Shortcodes, shortcodes: emoji::Shortcodes,
/// White space owed, and the open elements before it. /// White space owed, and the open elements before it.
@@ -159,8 +163,9 @@ impl Run {
} }
fn push_plain(&mut self, shown: &str) { fn push_plain(&mut self, shown: &str) {
let soft = shown.replace("\r\n", " ").replace(['\r', '\n'], " "); let soft = if self.lines { "\n" } else { " " };
self.text.text.push_str(&soft); let shown = shown.replace("\r\n", soft).replace(['\r', '\n'], soft);
self.text.text.push_str(&shown);
} }
} }
@@ -175,8 +180,17 @@ pub fn parse(markdown: &str) -> Vec<Node> {
} }
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 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, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new()); let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
@@ -199,6 +213,7 @@ impl<'a> Reader<'a> {
urls, urls,
notes, notes,
headings: Vec::new(), headings: Vec::new(),
lines: false,
}; };
reader.headings = headings reader.headings = headings
.into_iter() .into_iter()
@@ -208,6 +223,8 @@ impl<'a> Reader<'a> {
at, at,
}) })
.collect(); .collect();
// Titles read on one line; the body as asked.
reader.lines = lines;
reader reader
} }
@@ -236,7 +253,7 @@ impl<'a> Reader<'a> {
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));
} }
@@ -301,16 +318,18 @@ impl<'a> Reader<'a> {
} }
} }
/// 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 { let mut run = Run {
table: matches!(node, mdast::Node::TableCell(_)), table: matches!(node, mdast::Node::TableCell(_)),
..Run::default() ..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));
@@ -318,6 +337,13 @@ impl<'a> Reader<'a> {
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. /// A footnote's number, by its first reference.
pub fn note(&self, identifier: &str) -> Option<usize> { pub fn note(&self, identifier: &str) -> Option<usize> {
self.notes.get(identifier).copied() self.notes.get(identifier).copied()
@@ -336,7 +362,7 @@ impl<'a> Reader<'a> {
/// Inline text where images draw as their alt text. /// Inline text where images draw as their alt text.
pub 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);
} }
+93 -18
View File
@@ -32,7 +32,10 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escape
Some(match node { Some(match node {
Node::Text(_) => (whole, Escapes::Markdown), Node::Text(_) => (whole, Escapes::Markdown),
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain), Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
Node::InlineMath(_) => (inside(source, whole, '$'), Escapes::Plain), Node::InlineMath(_) => (
crate::documents::markdown::math_inside(source, whole),
Escapes::Plain,
),
_ => (whole, Escapes::Plain), _ => (whole, Escapes::Plain),
}) })
} }
@@ -196,6 +199,25 @@ impl Inline {
// Unreadable text lands at its start. // Unreadable text lands at its start.
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0)) 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. /// A source char as read, and where it was written.
@@ -285,19 +307,31 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
if at >= drawn.len() { if at >= drawn.len() {
return Some(written.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 source = read(written, escapes);
let mut j = source.len(); let mut j = source.len();
let mut out = Vec::new();
for (byte, d) in drawn.char_indices().rev() { for (byte, d) in drawn.char_indices().rev() {
let found = loop { let found = loop {
let s = source[..j].last()?; let Some(s) = source[..j].last() else {
if d == s.ch { return out;
};
if d == s.ch || (d.is_whitespace() && s.ends_line()) {
j -= 1; j -= 1;
break s.at; // A CRLF drawn as one char is one end.
} let collapsed = !drawn[..byte].ends_with('\r');
// A join: a line's end drawn as white space. if collapsed
if d.is_whitespace() && s.ends_line() { && s.ch == '\n'
j -= 1; && j > 0
if s.ch == '\n' && j > 0 && source[j - 1].ch == '\r' && source[j - 1].raw == s.raw { && source[j - 1].ch == '\r'
&& source[j - 1].raw == s.raw
{
j -= 1; j -= 1;
} }
break source[j].at; break source[j].at;
@@ -307,29 +341,54 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
j -= 1; j -= 1;
continue; continue;
} }
return None; return out;
}; };
if byte == at { out.push((byte, found));
return Some(found);
} }
} out
None
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::{Reader, tree}; use super::super::{Inline, Reader, tree};
/// The source byte under drawn text in the first paragraph. /// The first paragraph, as drawn.
fn under(source: &str, drawn: &str) -> usize { fn first(source: &str) -> Inline {
let root = tree(source); let root = tree(source);
let reader = Reader::new(source, &root); let reader = Reader::new(source, &root);
let paragraph = &root.children().expect("blocks")[0]; let paragraph = &root.children().expect("blocks")[0];
let inline = reader.inline(paragraph.children().expect("inlines")); 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"); let at = inline.text.find(drawn).expect("drawn");
inline.source_at(source, at).expect("a piece") 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] #[test]
fn a_word_lands_where_it_was_written() { fn a_word_lands_where_it_was_written() {
let source = "Say **bold** and `code` here\nnext line"; let source = "Say **bold** and `code` here\nnext line";
@@ -339,6 +398,22 @@ mod tests {
assert_eq!(under(source, "next"), source.find("next").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] #[test]
fn joins_escapes_and_indents_read_back() { fn joins_escapes_and_indents_read_back() {
let source = "a \\*star\\* x\n y"; let source = "a \\*star\\* x\n y";
+64 -9
View File
@@ -68,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 [
@@ -385,9 +396,9 @@ fn the_first_shortcode_decides_its_text_node() {
("a.:x.y: :smile:", "a.:x.y: 😄"), ("a.:x.y: :smile:", "a.:x.y: 😄"),
("x\t:smile: y:smile:", "x\t:smile: y:smile:"), ("x\t:smile: y:smile:", "x\t:smile: y:smile:"),
("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"), ("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"),
("x\n:smile: y:smile:", "x :smile: y:smile:"), ("x\n:smile: y:smile:", "x\n:smile: y:smile:"),
("*x*:smile: :smile:", "x😄 😄"), ("*x*:smile: :smile:", "x😄 😄"),
("*x*\n:smile: y:smile:", "x 😄 y😄"), ("*x*\n:smile: y:smile:", "x\n😄 y😄"),
("x:smile: `c` :smile:", "x:smile: c 😄"), ("x:smile: `c` :smile:", "x:smile: c 😄"),
("x:smile:<!-- c --> :smile:", "x:smile: 😄"), ("x:smile:<!-- c --> :smile:", "x:smile: 😄"),
("x:smile:<span>s</span> :smile:", "x:smile:s 😄"), ("x:smile:<span>s</span> :smile:", "x:smile:s 😄"),
@@ -405,3 +416,47 @@ fn the_first_shortcode_decides_its_text_node() {
assert_eq!(line(source).text, shown, "{source:?}"); 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:?}"
);
}
+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;
+2
View File
@@ -102,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, Left, Paste, Redo, Right, Submit, Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
Undo, Up, 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, Left, Paste, Redo, Right, Submit, Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
Undo, Up, 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"
);
}
}
+53 -17
View File
@@ -1,13 +1,14 @@
use std::rc::Rc; 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, Role, SharedString, Stateful, Styled, accesskit::Action, div, MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action,
prelude::*, 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::{
@@ -193,12 +194,16 @@ pub(super) fn panel(
choose(&click, &menu, depth, (ix, column), &close, window, cx) choose(&click, &menu, depth, (ix, column), &close, window, cx)
}); });
let drawn = button(item, (ix, column), current, press, cx); let drawn = button(item, (ix, column), current, press, cx);
let left = state.clone();
let here = (depth, (ix, column));
drawn.when(!item.disabled, |button| { drawn.when(!item.disabled, |button| {
button.on_mouse_move(move |_, _, cx| { button
let open = hover.read(cx); .on_mouse_move(move |event, _, cx| {
let marked = open.levels.get(depth).map(|l| (l.at, l.column)); aim::hover(&hover, &hover_menu, here, false, event.position, cx)
if marked != Some((Some(ix), column)) { })
mark(&hover, &hover_menu, depth, (ix, column), false, cx); .on_hover(move |hovered, _, cx| {
if !hovered {
aim::leave(&left, here, cx);
} }
}) })
}) })
@@ -210,16 +215,24 @@ pub(super) fn panel(
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, 0), 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(
@@ -244,7 +257,10 @@ pub(super) fn panel(
}); });
surface((id.clone(), format!("panel-{depth}")), cx) surface((id.clone(), format!("panel-{depth}")), cx)
.role(Role::Menu) .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. // Presses here are the menu's, not its host's.
.on_mouse_down(MouseButton::Left, |_, window, cx| { .on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default(); window.prevent_default();
@@ -263,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()
}
+42 -34
View File
@@ -1,13 +1,13 @@
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, SharedString, Styled, Window, anchored, canvas, div, InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task,
prelude::*, Window, anchored, div, prelude::*,
}; };
use super::{ use super::{
draw, draw::{self, measure},
model::{Kind, Menu}, model::{Kind, Menu},
}; };
use crate::{ use crate::{
@@ -80,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 {
@@ -99,19 +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![first_of(menu, first)];
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![first_of(menu, first)]; open.levels = vec![first_of(menu, first)];
(open.pointer, open.over, open.pending) = (None, None, None);
cx.notify(); cx.notify();
}); });
} }
@@ -121,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()
}); });
@@ -204,6 +212,7 @@ pub(super) fn choose(
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 = first_of(sub, sub.step(None, 1)); let first = first_of(sub, sub.step(None, 1));
mark(state, menu, depth, (at, column), false, cx); mark(state, menu, depth, (at, column), false, cx);
@@ -236,6 +245,7 @@ fn keys(
let Some(here) = at_depth(menu, &levels, depth) else { let Some(here) = at_depth(menu, &levels, depth) else {
return false; return false;
}; };
state.update(cx, |open, _| open.pending = None);
let (at, column) = (levels[depth].at, levels[depth].column); let (at, column) = (levels[depth].at, levels[depth].column);
let sub = at let sub = at
.and_then(|at| here.item_at(at, column)) .and_then(|at| here.item_at(at, column))
@@ -278,26 +288,6 @@ fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
.is_some_and(|(here, ix)| here.is_buttons(ix)) .is_some_and(|(here, ix)| here.is_buttons(ix))
} }
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
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()
}
/// 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.
pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement { pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement {
measure(state, move |open, bounds| { measure(state, move |open, bounds| {
@@ -464,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;
+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");
}
+1
View File
@@ -4,6 +4,7 @@ use gpui::{
Window, div, point, prelude::*, px, Window, div, point, prelude::*, px,
}; };
mod aim;
mod buttons; mod buttons;
mod more; mod more;
+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();
+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;
+2 -2
View File
@@ -223,7 +223,7 @@ impl RenderOnce for FontSizeControl {
) )
.child( .child(
IconButton::new((self.id, "reset"), IconName::RotateCcw) IconButton::new((self.id, "reset"), IconName::RotateCcw)
.tooltip("Back to the default size") .tooltip(i18n::text(cx, "theme.size.reset", &[]))
.disabled(self.size == default) .disabled(self.size == default)
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
reset(default, window, cx); reset(default, window, cx);
@@ -237,7 +237,7 @@ impl RenderOnce for FontSizeControl {
.min_w_0() .min_w_0()
.text_size(Pixels::from(self.size)) .text_size(Pixels::from(self.size))
.text_color(theme.colors.fg) .text_color(theme.colors.fg)
.child("The quick brown fox jumps over the lazy dog."), .child(i18n::text(cx, "theme.size.sample", &[])),
), ),
) )
} }
+22 -16
View File
@@ -102,11 +102,13 @@ impl WindowControls {
impl RenderOnce for WindowControls { impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.platform.unwrap_or(cx.theme().platform) { let controls = match self.platform.unwrap_or(cx.theme().platform) {
Platform::Mac => traffic_lights(self.id, self.on_close, window, cx), Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
Platform::Windows => caption_buttons(self.id, self.on_close, window, cx), Platform::Windows => caption_buttons(self.id, self.on_close, window, cx),
Platform::Linux => round_buttons(self.id, self.on_close, window, cx), Platform::Linux => round_buttons(self.id, self.on_close, window, cx),
} };
// Windows takes a drag region under controls as caption.
controls.occlude()
} }
} }
@@ -115,7 +117,7 @@ fn traffic_lights(
on_close: Option<OnClose>, on_close: Option<OnClose>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> Stateful<Div> {
let theme = cx.theme(); let theme = cx.theme();
let colors = &theme.colors; let colors = &theme.colors;
let active = window.is_window_active(); let active = window.is_window_active();
@@ -154,7 +156,6 @@ fn traffic_lights(
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx)) .on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(glyph) .child(glyph)
})) }))
.into_any_element()
} }
fn caption_buttons( fn caption_buttons(
@@ -162,7 +163,7 @@ fn caption_buttons(
on_close: Option<OnClose>, on_close: Option<OnClose>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> Stateful<Div> {
let theme = cx.theme(); let theme = cx.theme();
let colors = &theme.colors; let colors = &theme.colors;
let maximize = if window.is_maximized() { let maximize = if window.is_maximized() {
@@ -207,7 +208,6 @@ fn caption_buttons(
.group_hover_color(group, glyph), .group_hover_color(group, glyph),
) )
})) }))
.into_any_element()
} }
fn round_buttons( fn round_buttons(
@@ -215,7 +215,7 @@ fn round_buttons(
on_close: Option<OnClose>, on_close: Option<OnClose>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> Stateful<Div> {
let theme = cx.theme(); let theme = cx.theme();
let colors = &theme.colors; let colors = &theme.colors;
let offered = window.window_controls(); let offered = window.window_controls();
@@ -224,12 +224,7 @@ fn round_buttons(
(Control::Maximize, IconName::Square, offered.maximize), (Control::Maximize, IconName::Square, offered.maximize),
(Control::Close, IconName::X, true), (Control::Close, IconName::X, true),
]; ];
div() div().id(id).flex().items_center().gap_2().children(
.id(id)
.flex()
.items_center()
.gap_2()
.children(
buttons buttons
.into_iter() .into_iter()
.filter(|(_, _, shown)| *shown) .filter(|(_, _, shown)| *shown)
@@ -249,7 +244,18 @@ fn round_buttons(
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg)) .child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
}), }),
) )
.into_any_element() }
/// A slot's controls, occluding the drag region beneath.
fn slot(items: SmallVec<[AnyElement; 2]>) -> Option<Div> {
(!items.is_empty()).then(|| {
div()
.flex()
.items_center()
.gap_2()
.occlude()
.children(items)
})
} }
/// Whether the system draws the window controls, and whose manner they take. /// Whether the system draws the window controls, and whose manner they take.
@@ -404,7 +410,7 @@ impl RenderOnce for TitleBar {
false => start.pl_3(), false => start.pl_3(),
}) })
.children(leading) .children(leading)
.children(self.leading), .children(slot(self.leading)),
); );
let end = div().flex().justify_end().child( let end = div().flex().justify_end().child(
div() div()
@@ -412,7 +418,7 @@ impl RenderOnce for TitleBar {
.items_center() .items_center()
.gap_2() .gap_2()
.when(centered, |end| end.pr_2()) .when(centered, |end| end.pr_2())
.children(self.actions) .children(slot(self.actions))
.children(trailing.map(|controls| { .children(trailing.map(|controls| {
div() div()
.flex_none() .flex_none()
+199 -14
View File
@@ -1,22 +1,54 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, DefiniteLength, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, AnyElement, App, Axis, DefiniteLength, Div, DragMoveEvent, ElementId, EmptyView, EntityId,
FontWeight, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, RenderOnce,
SharedString, Styled, Window, div, prelude::*, SharedString, Styled, Window, div, prelude::*,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close}; use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close};
use crate::{ use crate::{
layout::on_axis, layout::{on_axis, resize_handle},
theme::{ActiveTheme, ControlSize, Platform, TextSize}, theme::{ActiveTheme, ControlSize, Platform, TextSize},
typography::Ellipsis, typography::Ellipsis,
}; };
type OnResize = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
/// A width the owner keeps, which a drag on one border moves.
struct Resize {
width: Pixels,
min: Pixels,
max: Pixels,
on_resize: OnResize,
}
/// The border a column's width is dragged by.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Side {
Left,
Right,
}
#[derive(Clone, Copy)]
struct BorderDrag {
owner: EntityId,
side: Side,
}
/// Where a press on a border landed; once it moves sideways, the width it sets off from and the owner's then.
#[derive(Clone, Copy)]
struct Press {
at: Pixels,
start: Option<(Pixels, Pixels)>,
}
/// One column of a `ColumnShell`: a header that drags the window, then its body. /// One column of a `ColumnShell`: a header that drags the window, then its body.
pub struct ShellColumn { pub struct ShellColumn {
id: ElementId, id: ElementId,
width: Option<DefiniteLength>, width: Option<DefiniteLength>,
resize: Option<Resize>,
leading: SmallVec<[AnyElement; 2]>, leading: SmallVec<[AnyElement; 2]>,
title: Option<SharedString>, title: Option<SharedString>,
actions: SmallVec<[AnyElement; 2]>, actions: SmallVec<[AnyElement; 2]>,
@@ -29,6 +61,7 @@ impl ShellColumn {
Self { Self {
id: id.into(), id: id.into(),
width: None, width: None,
resize: None,
leading: SmallVec::new(), leading: SmallVec::new(),
title: None, title: None,
actions: SmallVec::new(), actions: SmallVec::new(),
@@ -36,12 +69,35 @@ impl ShellColumn {
} }
} }
/// A set width instead of the rest; it yields before a header's fixed parts. /// A set width instead of the rest; it holds its header's fixed parts and yields when the shell runs short.
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self { pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into()); self.width = Some(width.into());
self self
} }
/// A set width the owner keeps: a drag on the border toward the filling column moves it within `min` and `max`, and the owner hears each new width.
pub fn resizable(
mut self,
width: Pixels,
min: Pixels,
max: Pixels,
on_resize: impl Fn(Pixels, &mut Window, &mut App) + 'static,
) -> Self {
assert!(
min <= max,
"shell column {:?}: limits {min:?} to {max:?} run backwards",
self.id
);
self.width = Some(width.into());
self.resize = Some(Resize {
width,
min,
max,
on_resize: Rc::new(on_resize),
});
self
}
/// Header content before the title. /// Header content before the title.
pub fn leading(mut self, item: impl IntoElement) -> Self { pub fn leading(mut self, item: impl IntoElement) -> Self {
self.leading.push(item.into_any_element()); self.leading.push(item.into_any_element());
@@ -73,6 +129,7 @@ pub struct ColumnShell {
platform: Option<Platform>, platform: Option<Platform>,
on_close: Option<OnClose>, on_close: Option<OnClose>,
columns: Vec<ShellColumn>, columns: Vec<ShellColumn>,
glass: bool,
} }
impl ColumnShell { impl ColumnShell {
@@ -83,6 +140,7 @@ impl ColumnShell {
platform: None, platform: None,
on_close: None, on_close: None,
columns: Vec::new(), columns: Vec::new(),
glass: false,
} }
} }
@@ -102,6 +160,12 @@ impl ColumnShell {
self.on_close = Some(Rc::new(handler)); self.on_close = Some(Rc::new(handler));
self self
} }
/// Grounds the columns in the palette's glass, so a blurred window shows through them.
pub fn glass(mut self) -> Self {
self.glass = true;
self
}
} }
impl RenderOnce for ColumnShell { impl RenderOnce for ColumnShell {
@@ -124,9 +188,19 @@ impl RenderOnce for ColumnShell {
.child(controls) .child(controls)
}; };
let room = system && !window.is_fullscreen(); let room = system && !window.is_fullscreen();
// Each border lies toward the column that fills, so a drag on it moves one width.
let fill = self
.columns
.iter()
.position(|column| column.width.is_none());
let mut columns = Vec::with_capacity(count); let mut columns = Vec::with_capacity(count);
for (ix, column) in self.columns.into_iter().enumerate() { for (ix, column) in self.columns.into_iter().enumerate() {
let (first, last) = (ix == 0, ix + 1 == count); let (first, last) = (ix == 0, ix + 1 == count);
let side = match fill {
Some(fill) if ix > fill => Some(Side::Left),
Some(fill) if ix == fill => None,
_ => (!last).then_some(Side::Right),
};
let lead = (!system && first && style == Platform::Mac).then(controls); let lead = (!system && first && style == Platform::Mac).then(controls);
let trail = (!system && last && style != Platform::Mac).then(controls); let trail = (!system && last && style != Platform::Mac).then(controls);
let flush = trail.is_some() && style == Platform::Windows; let flush = trail.is_some() && style == Platform::Windows;
@@ -141,7 +215,7 @@ impl RenderOnce for ColumnShell {
items( items(
column.id.clone(), column.id.clone(),
column.leading, column.leading,
title(column.title, cx), title(column.id.clone(), column.title, window, cx),
column.actions, column.actions,
) )
.min_w(cx.theme().control_height(ControlSize::Lg)) .min_w(cx.theme().control_height(ControlSize::Lg))
@@ -149,16 +223,23 @@ impl RenderOnce for ColumnShell {
) )
.children(trail); .children(trail);
let colors = &cx.theme().colors; let colors = &cx.theme().colors;
columns.push( let box_ = div()
div() .relative()
.flex() .flex()
.flex_col() .flex_col()
.h_full() .h_full()
// A basis, not a width, so the header's fixed parts set the least width.
.map(|box_| match column.width { .map(|box_| match column.width {
Some(width) => box_.w(width), Some(width) => box_.flex_basis(width),
None => box_.flex_1(), None => box_.flex_1(),
}) })
.when(!last, |box_| box_.border_r_1().border_color(colors.border)) // A resizing column's handle draws its border.
.map(|box_| match (side, column.resize.is_some()) {
(Some(Side::Left), false) => box_.border_l_1(),
(Some(Side::Right), false) => box_.border_r_1(),
_ => box_,
})
.border_color(colors.border)
.debug_selector(move || format!("column-shell-{ix}")) .debug_selector(move || format!("column-shell-{ix}"))
.child(header) .child(header)
.child( .child(
@@ -171,20 +252,116 @@ impl RenderOnce for ColumnShell {
.flex_col() .flex_col()
.children(column.body), .children(column.body),
), ),
),
); );
columns.push(match column.resize {
Some(resize) => {
let side = side.unwrap_or_else(|| {
panic!(
"column shell {:?}: column {ix} resizes, but no border lies toward a filling column",
self.id
)
});
resizing(box_, column.id, ix, side, resize, window, cx)
}
None => box_,
});
} }
let theme = cx.theme(); let theme = cx.theme();
let ground = if self.glass {
theme.colors.glass
} else {
theme.colors.bg
};
div() div()
.flex() .flex()
.h_full() .h_full()
.debug_selector(|| "column-shell".into()) .debug_selector(|| "column-shell".into())
.bg(theme.colors.bg) .bg(ground)
.text_color(theme.colors.fg) .text_color(theme.colors.fg)
.children(columns) .children(columns)
} }
} }
/// `box_` with a handle on its `side` border, whose drag moves its width.
fn resizing(
box_: Div,
id: ElementId,
ix: usize,
side: Side,
resize: Resize,
window: &mut Window,
cx: &mut App,
) -> Div {
let Resize {
width,
min,
max,
on_resize,
} = resize;
let pressed = window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::<Press>);
let owner = pressed.entity_id();
let drag = BorderDrag { owner, side };
let at = pressed.clone();
let handle = resize_handle((id, "border"), Axis::Horizontal, cx)
.absolute()
.top_0()
.map(|handle| match side {
Side::Left => handle.left_0().justify_start(),
Side::Right => handle.right_0().justify_end(),
})
// Over a header, so a press drags the border, not the window.
.occlude()
.debug_selector(move || format!("column-shell-border-{ix}"))
.on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| {
at.update(cx, |at, _| {
*at = Some(Press {
at: event.position.x,
start: None,
})
})
})
.on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView));
box_.on_drag_move(move |event: &DragMoveEvent<BorderDrag>, window, cx| {
let BorderDrag {
owner: dragged,
side,
} = *event.drag(cx);
if dragged != owner {
return;
}
let Some(press) = *pressed.read(cx) else {
log::error!("column shell: a drag on column {ix} with no press behind it");
return;
};
let moved = event.event.position.x - press.at;
let (start, kept) = match press.start {
Some(start) => start,
// A drag that has not moved sideways yet changes nothing.
None if moved == Pixels::ZERO => return,
// A header's parts may hold a column wider, a short shell squeeze it narrower.
None => {
let start = (event.bounds.size.width.max(width), width);
pressed.update(cx, |held, _| {
*held = Some(Press {
start: Some(start),
..press
})
});
start
}
};
let next = match side {
_ if moved == Pixels::ZERO => kept,
Side::Left => (start - moved).clamp(min, max),
Side::Right => (start + moved).clamp(min, max),
};
if next != width {
on_resize(next, window, cx);
}
})
.child(handle)
}
/// A header strip at the title bar's height that drags the window. /// A header strip at the title bar's height that drags the window.
fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gpui::Div> { fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gpui::Div> {
drag_region((id, "header"), window, cx) drag_region((id, "header"), window, cx)
@@ -199,7 +376,7 @@ fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gp
fn items( fn items(
id: ElementId, id: ElementId,
leading: SmallVec<[AnyElement; 2]>, leading: SmallVec<[AnyElement; 2]>,
title: gpui::Div, title: gpui::Stateful<gpui::Div>,
actions: SmallVec<[AnyElement; 2]>, actions: SmallVec<[AnyElement; 2]>,
) -> gpui::Div { ) -> gpui::Div {
let group = || div().flex_none().flex().items_center().gap_2(); let group = || div().flex_none().flex().items_center().gap_2();
@@ -211,16 +388,24 @@ fn items(
.items_center() .items_center()
.gap_2() .gap_2()
.overflow_x_scroll() .overflow_x_scroll()
// Windows takes a drag region under controls as caption.
.occlude()
.child(group().children(leading)) .child(group().children(leading))
.child(title) .child(title)
.child(group().children(actions)), .child(group().children(actions)),
) )
} }
/// The title, adding nothing to the least width; a spacer without one. /// Title or spacer: drags the window, adds no width.
fn title(title: Option<SharedString>, cx: &App) -> gpui::Div { fn title(
id: ElementId,
title: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
let region = drag_region((id, "title"), window, cx);
let theme = cx.theme(); let theme = cx.theme();
div().flex_1().min_w_0().h_full().relative().child( region.flex_1().min_w_0().h_full().relative().child(
div() div()
.absolute() .absolute()
.inset_0() .inset_0()
+12 -2
View File
@@ -37,12 +37,19 @@ fn cursor(edge: ResizeEdge) -> CursorStyle {
#[derive(IntoElement, Default)] #[derive(IntoElement, Default)]
pub struct ResizeBorder { pub struct ResizeBorder {
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
glass: bool,
} }
impl ResizeBorder { impl ResizeBorder {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Leaves a see-through window's pane to its content: no ground, nor the shadow gpui draws under it.
pub fn glass(mut self) -> Self {
self.glass = true;
self
}
} }
impl ParentElement for ResizeBorder { impl ParentElement for ResizeBorder {
@@ -61,6 +68,7 @@ impl RenderOnce for ResizeBorder {
window.set_client_inset(inset); window.set_client_inset(inset);
let radius = theme.radius(Radius::Lg); let radius = theme.radius(Radius::Lg);
let floating = !tiling.is_tiled(); let floating = !tiling.is_tiled();
let glass = self.glass;
div() div()
.id("resize-border") .id("resize-border")
.size_full() .size_full()
@@ -100,12 +108,14 @@ impl RenderOnce for ResizeBorder {
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.cursor(CursorStyle::Arrow) .cursor(CursorStyle::Arrow)
.bg(theme.colors.bg) .when(!glass, |pane| pane.bg(theme.colors.bg))
.when(floating, |pane| { .when(floating, |pane| {
pane.rounded(radius) pane.rounded(radius)
.border_1() .border_1()
.border_color(theme.colors.border_strong) .border_color(theme.colors.border_strong)
.shadow(theme.elevation(Elevation::Modal)) })
.when(floating && !glass, |pane| {
pane.shadow(theme.elevation(Elevation::Modal))
}) })
.on_mouse_move(|_, _, cx| cx.stop_propagation()) .on_mouse_move(|_, _, cx| cx.stop_propagation())
.children(self.children), .children(self.children),
+2
View File
@@ -1,3 +1,5 @@
mod border;
use gpui::{ use gpui::{
AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke,
ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size, ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size,
+264
View File
@@ -0,0 +1,264 @@
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
TestAppContext, VisualTestContext, Window, div, point, prelude::*, px,
};
use crate::shell::{ColumnShell, ShellColumn};
use crate::theme::{Platform, Theme};
/// Two set columns round a filling one; this view keeps their widths.
struct Sided {
platform: Platform,
width: Pixels,
widths: [Pixels; 2],
heard: Vec<Pixels>,
}
impl Render for Sided {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let side = |id: &'static str, ix: usize| {
let view = view.clone();
ShellColumn::new(id).title(id).resizable(
self.widths[ix],
px(100.0),
px(400.0),
move |width, _, cx| {
view.update(cx, |view, cx| {
view.widths[ix] = width;
view.heard.push(width);
cx.notify();
})
},
)
};
div().w(self.width).h(px(400.0)).flex().flex_col().child(
ColumnShell::new("shell")
.platform(self.platform)
.column(side("left", 0))
.column(ShellColumn::new("main").title("main"))
.column(side("right", 1)),
)
}
}
fn sided(
platform: Platform,
widths: [Pixels; 2],
cx: &mut TestAppContext,
) -> (Entity<Sided>, &mut VisualTestContext) {
let (view, cx) = cx.add_window_view(|_, _| Sided {
platform,
width: px(1000.0),
widths,
heard: Vec::new(),
});
cx.run_until_parked();
(view, cx)
}
fn column(ix: usize, cx: &mut VisualTestContext) -> gpui::Bounds<Pixels> {
let name: &'static str = ["column-shell-0", "column-shell-1", "column-shell-2"][ix];
cx.debug_bounds(name).expect("a column")
}
/// Presses at `from`, moves past gpui's drag threshold, then to each offset `path` names, and lets go.
fn drag_path(from: Point<Pixels>, path: &[Pixels], cx: &mut VisualTestContext) {
let none = Modifiers::none();
cx.simulate_mouse_down(from, MouseButton::Left, none);
let set_off = from + point(px(0.0), px(4.0));
cx.simulate_mouse_move(set_off, Some(MouseButton::Left), none);
cx.run_until_parked();
let mut to = set_off;
for by in path {
to = set_off + point(*by, px(0.0));
cx.simulate_mouse_move(to, Some(MouseButton::Left), none);
cx.run_until_parked();
}
cx.simulate_mouse_up(to, MouseButton::Left, none);
cx.run_until_parked();
}
fn drag(from: Point<Pixels>, by: Pixels, cx: &mut VisualTestContext) {
drag_path(from, &[by], cx);
}
fn heard(view: &Entity<Sided>, cx: &mut VisualTestContext) -> Option<Pixels> {
view.read_with(cx, |view, _| view.heard.last().copied())
}
/// A column before the filling one drags by its right border, one after it by its left.
#[gpui::test]
fn a_border_toward_the_filling_column_drags_its_width(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx);
let left = cx.debug_bounds("column-shell-border-0").expect("a border");
let right = cx.debug_bounds("column-shell-border-2").expect("a border");
assert_eq!(left.right(), column(0, cx).right());
assert_eq!(right.left(), column(2, cx).left());
drag(left.center(), px(50.0), cx);
assert_eq!(heard(&view, cx), Some(px(250.0)));
assert_eq!(column(0, cx).size.width, px(250.0));
drag(right.center(), px(-50.0), cx);
assert_eq!(heard(&view, cx), Some(px(250.0)));
let (main, panel) = (column(1, cx), column(2, cx));
assert_eq!((panel.size.width, panel.right()), (px(250.0), px(1000.0)));
assert_eq!((main.left(), main.right()), (px(250.0), px(750.0)));
}
/// A drag stops at the column's least and greatest width.
#[gpui::test]
fn a_border_drag_keeps_within_the_limits(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx);
let border = |cx: &mut VisualTestContext| {
cx.debug_bounds("column-shell-border-0")
.expect("a border")
.center()
};
drag(border(cx), px(900.0), cx);
assert_eq!(heard(&view, cx), Some(px(400.0)));
drag(border(cx), px(-900.0), cx);
assert_eq!(heard(&view, cx), Some(px(100.0)));
}
/// In a header the border takes the press, so no system's drag moves the window, which panics here.
#[gpui::test]
fn a_border_drag_in_a_header_moves_no_window(cx: &mut TestAppContext) {
cx.update(Theme::init);
for platform in [Platform::Windows, Platform::Mac, Platform::Linux] {
let (view, cx) = sided(platform, [px(200.0), px(200.0)], cx);
for (ix, by) in [(0, px(30.0)), (2, px(-30.0))] {
let border = cx
.debug_bounds(["column-shell-border-0", "", "column-shell-border-2"][ix])
.expect("a border");
let header = column(ix, cx).top() + px(4.0);
drag(point(border.center().x, header), by, cx);
assert_eq!(heard(&view, cx), Some(px(230.0)), "{platform:?}");
}
}
}
/// A header's own parts hold a column past the owner's width; a drag sets off from the drawn one.
#[gpui::test]
fn a_border_drag_sets_off_from_the_drawn_width(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, cx) = sided(Platform::Windows, [px(200.0), px(100.0)], cx);
let (panel, close) = (column(2, cx), cx.debug_bounds("caption-Close"));
let close = close.expect("a close button");
assert!(close.right() <= panel.right(), "{close:?} in {panel:?}");
let drawn = panel.size.width;
assert!(drawn > px(100.0), "{drawn:?} holds the caption buttons");
let border = cx.debug_bounds("column-shell-border-2").expect("a border");
drag(border.center(), px(-10.0), cx);
assert_eq!(heard(&view, cx), Some(drawn + px(10.0)));
}
/// Two set columns and nothing that fills.
struct Unfilled;
impl Render for Unfilled {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let column = |id: &'static str| ShellColumn::new(id).width(px(200.0));
ColumnShell::new("shell")
.platform(Platform::Linux)
.column(column("first"))
.column(column("last").resizable(px(200.0), px(100.0), px(400.0), |_, _, _| {}))
}
}
/// The last column of a shell that nothing fills has no border to drag.
#[gpui::test]
#[should_panic(expected = "no border lies toward a filling column")]
fn a_resizing_column_needs_a_border_toward_a_filling_one(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Unfilled);
cx.run_until_parked();
}
/// A short shell squeezes both columns; a drag moves a border with the pointer, from the owner's width.
#[gpui::test]
fn a_squeezed_column_drags_from_the_owner_s_width(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, cx) = cx.add_window_view(|_, _| Sided {
platform: Platform::Linux,
width: px(700.0),
widths: [px(350.0), px(350.0)],
heard: Vec::new(),
});
cx.run_until_parked();
let left = column(0, cx);
assert!(left.size.width < px(350.0), "{left:?} is squeezed");
let border = |ix: usize, cx: &mut VisualTestContext| {
let name = ["column-shell-border-0", "", "column-shell-border-2"][ix];
cx.debug_bounds(name).expect("a border").center()
};
drag(border(0, cx), px(0.0), cx);
assert_eq!(heard(&view, cx), None, "a drag with no sideways move");
drag(border(0, cx), px(20.0), cx);
assert_eq!(heard(&view, cx), Some(px(370.0)));
assert!(
column(0, cx).right() > left.right(),
"the left border moves right"
);
let right = column(2, cx);
drag(border(2, cx), px(-20.0), cx);
assert_eq!(heard(&view, cx), Some(px(370.0)));
assert!(
column(2, cx).left() < right.left(),
"the right border moves left"
);
}
/// A drag that comes back where it set off gives the width the owner had, a held or squeezed one too.
#[gpui::test]
fn a_drag_back_where_it_set_off_gives_the_width_it_had(cx: &mut TestAppContext) {
cx.update(Theme::init);
let cases = [
(
Platform::Linux,
px(1000.0),
[px(200.0), px(200.0)],
0,
px(50.0),
),
(
Platform::Linux,
px(1000.0),
[px(200.0), px(200.0)],
2,
px(-50.0),
),
(
Platform::Windows,
px(1000.0),
[px(200.0), px(100.0)],
2,
px(-10.0),
),
(
Platform::Linux,
px(700.0),
[px(350.0), px(350.0)],
0,
px(20.0),
),
];
for (platform, width, widths, ix, by) in cases {
let (view, cx) = cx.add_window_view(move |_, _| Sided {
platform,
width,
widths,
heard: Vec::new(),
});
cx.run_until_parked();
let name = ["column-shell-border-0", "", "column-shell-border-2"][ix];
let border = cx.debug_bounds(name).expect("a border").center();
let drawn = column(ix, cx);
drag_path(border, &[by, px(0.0)], cx);
let heard = view.read_with(cx, |view, _| view.heard.clone());
assert_eq!(heard.len(), 2, "{platform:?} {ix}: out and back, {heard:?}");
assert_eq!(heard[1], widths[ix / 2], "{platform:?} {ix}");
assert_eq!(column(ix, cx), drawn, "{platform:?} {ix}: drawn as before");
}
}
+102 -1
View File
@@ -3,7 +3,7 @@ use gpui::{
ParentElement, Point, Render, TestAppContext, Window, div, point, prelude::*, px, ParentElement, Point, Render, TestAppContext, Window, div, point, prelude::*, px,
}; };
use super::TitleBar; use super::{ColumnShell, ShellColumn, TitleBar, WindowControls};
use crate::theme::{Platform, Theme}; use crate::theme::{Platform, Theme};
struct Bar { struct Bar {
@@ -71,3 +71,104 @@ fn a_wobbling_press_on_the_bar_moves_the_window(cx: &mut TestAppContext) {
.expect("drawn controls"); .expect("drawn controls");
wobble(point(controls.left() - px(200.0), controls.center().y), cx); wobble(point(controls.left() - px(200.0), controls.center().y), cx);
} }
/// Counts presses that reach the box under the controls.
struct Under {
platform: Platform,
presses: usize,
}
impl Render for Under {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
div()
.id("under")
.flex()
.items_start()
.w_full()
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
view.update(cx, |under, _| under.presses += 1)
})
.child(
div()
.debug_selector(|| "controls".into())
.child(WindowControls::new("controls").platform(self.platform)),
)
}
}
/// Each style's controls hide what lies under them.
#[gpui::test]
fn window_controls_occlude_what_lies_under(cx: &mut TestAppContext) {
cx.update(Theme::init);
for platform in [Platform::Windows, Platform::Mac, Platform::Linux] {
let (view, cx) = cx.add_window_view(move |_, _| Under {
platform,
presses: 0,
});
cx.run_until_parked();
let controls = cx.debug_bounds("controls").expect("drawn controls");
cx.simulate_event(MouseDownEvent {
button: MouseButton::Left,
position: controls.center(),
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
});
assert_eq!(
view.read_with(cx, |under, _| under.presses),
0,
"{platform:?}"
);
}
}
/// Title bar and column header with inert slot items.
struct Slotted;
impl Render for Slotted {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let item = |name: &'static str| div().debug_selector(move || name.into()).size_6();
let column = ShellColumn::new("main")
.leading(item("shell-lead"))
.action(item("shell-act"));
div()
.size_full()
.flex()
.flex_col()
.child(
TitleBar::new("bar")
.platform(Platform::Windows)
.child(item("bar-lead"))
.action(item("bar-act")),
)
.child(
ColumnShell::new("shell")
.platform(Platform::Windows)
.column(column),
)
}
}
/// A press on a slot item never moves the window.
#[gpui::test]
fn a_wobbling_press_on_a_slot_moves_no_window(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Slotted);
cx.run_until_parked();
for name in ["bar-lead", "bar-act", "shell-lead", "shell-act"] {
let item = cx.debug_bounds(name).expect("a slot item");
wobble(item.center(), cx);
}
}
/// A column's title still moves the window; a move panics.
#[gpui::test]
#[should_panic(expected = "not implemented")]
fn a_wobbling_press_on_a_column_title_moves_the_window(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Slotted);
cx.run_until_parked();
let items = cx.debug_bounds("column-shell-items-0").expect("the items");
wobble(items.center(), cx);
}
+11 -3
View File
@@ -212,7 +212,7 @@ impl MiniWindow {
impl Render for MiniWindow { impl Render for MiniWindow {
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 {
let bar = drag_region("mini-bar", window, cx); let drag = drag_region("mini-bar", window, cx);
let theme = cx.theme(); let theme = cx.theme();
let back = self.back; let back = self.back;
div() div()
@@ -225,7 +225,8 @@ impl Render for MiniWindow {
.font_family(theme.font_family.clone()) .font_family(theme.font_family.clone())
.child(self.view.clone()) .child(self.view.clone())
.child( .child(
bar.absolute() div()
.absolute()
.top_0() .top_0()
.left_0() .left_0()
.right_0() .right_0()
@@ -241,7 +242,14 @@ impl Render for MiniWindow {
.text_color(theme.colors.fg_muted) .text_color(theme.colors.fg_muted)
.invisible() .invisible()
.group_hover("mini-window", |style| style.visible()) .group_hover("mini-window", |style| style.visible())
.child(div().flex_1().child(self.title.clone())) // Only the title drags: Windows takes the region as caption.
.child(
drag.flex_1()
.h_full()
.flex()
.items_center()
.child(self.title.clone()),
)
.when_some(back, |bar, back| { .when_some(back, |bar, back| {
bar.child( bar.child(
IconButton::new("mini-expand", IconName::Maximize2) IconButton::new("mini-expand", IconName::Maximize2)
+26 -11
View File
@@ -6,6 +6,7 @@ use gpui::{
}; };
use super::{FilterRule, SortKey, Test}; use super::{FilterRule, SortKey, Test};
use crate::i18n;
use crate::{ use crate::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
forms::{Choice, Input, InputEvent, Select, TextInput}, forms::{Choice, Input, InputEvent, Select, TextInput},
@@ -151,7 +152,7 @@ impl RenderOnce for FilterBuilder {
let emit = Rc::new(emit); let emit = Rc::new(emit);
let tests: Vec<Choice> = Test::ALL let tests: Vec<Choice> = Test::ALL
.iter() .iter()
.map(|test| Choice::new(test.label(), test.label())) .map(|test| Choice::new(test.key(), i18n::text(cx, test.key(), &[])))
.collect(); .collect();
let theme = cx.theme(); let theme = cx.theme();
let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone()); let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone());
@@ -186,13 +187,13 @@ impl RenderOnce for FilterBuilder {
.child( .child(
div().w(theme.label_width() * 0.75).child( div().w(theme.label_width() * 0.75).child(
Select::new((id.clone(), format!("test-{ix}")), tests.clone()) Select::new((id.clone(), format!("test-{ix}")), tests.clone())
.selected(rule.test.label()) .selected(rule.test.key())
.size(ControlSize::Sm) .size(ControlSize::Sm)
.on_change(move |label, window, cx| { .on_change(move |label, window, cx| {
let mut next = for_test.clone(); let mut next = for_test.clone();
next[ix].test = *Test::ALL next[ix].test = *Test::ALL
.iter() .iter()
.find(|test| test.label() == label.as_ref()) .find(|test| test.key() == label.as_ref())
.expect("a listed test"); .expect("a listed test");
test(next, any, window, cx) test(next, any, window, cx)
}), }),
@@ -205,7 +206,7 @@ impl RenderOnce for FilterBuilder {
IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X) IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip("Remove") .tooltip(i18n::text(cx, "builder.remove", &[]))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
let mut next = for_remove.clone(); let mut next = for_remove.clone();
next.remove(ix); next.remove(ix);
@@ -223,7 +224,10 @@ impl RenderOnce for FilterBuilder {
.items_center() .items_center()
.justify_between() .justify_between()
.child( .child(
Button::new((self.id.clone(), "add"), "Add rule") Button::new(
(self.id.clone(), "add"),
i18n::text(cx, "builder.add_rule", &[]),
)
.icon(IconName::Plus) .icon(IconName::Plus)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
@@ -242,8 +246,8 @@ impl RenderOnce for FilterBuilder {
(self.id.clone(), "join"), (self.id.clone(), "join"),
if any { "any" } else { "all" }, if any { "any" } else { "all" },
) )
.segment("all", "Match all", None) .segment("all", i18n::text(cx, "builder.all", &[]), None)
.segment("any", "Match any", None) .segment("any", i18n::text(cx, "builder.any", &[]), None)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.on_change(move |value, window, cx| { .on_change(move |value, window, cx| {
join(to_join.clone(), value.as_ref() == "any", window, cx) join(to_join.clone(), value.as_ref() == "any", window, cx)
@@ -364,8 +368,16 @@ impl RenderOnce for SortBuilder {
(id.clone(), format!("sort-way-{ix}")), (id.clone(), format!("sort-way-{ix}")),
if sort.rising { "up" } else { "down" }, if sort.rising { "up" } else { "down" },
) )
.segment("up", "Ascending", Some(IconName::ArrowUp)) .segment(
.segment("down", "Descending", Some(IconName::ArrowDown)) "up",
i18n::text(cx, "builder.up", &[]),
Some(IconName::ArrowUp),
)
.segment(
"down",
i18n::text(cx, "builder.down", &[]),
Some(IconName::ArrowDown),
)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.on_change(move |way, window, cx| { .on_change(move |way, window, cx| {
let mut next = for_flip.clone(); let mut next = for_flip.clone();
@@ -377,7 +389,7 @@ impl RenderOnce for SortBuilder {
IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X) IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip("Remove") .tooltip(i18n::text(cx, "builder.remove", &[]))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
let mut next = for_remove.clone(); let mut next = for_remove.clone();
next.remove(ix); next.remove(ix);
@@ -394,7 +406,10 @@ impl RenderOnce for SortBuilder {
.gap_1() .gap_1()
.child(list) .child(list)
.children(unused.map(|column| { .children(unused.map(|column| {
Button::new((self.id.clone(), "add"), "Add sort") Button::new(
(self.id.clone(), "add"),
i18n::text(cx, "builder.add_sort", &[]),
)
.icon(IconName::Plus) .icon(IconName::Plus)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
+8 -7
View File
@@ -26,14 +26,15 @@ impl Test {
Test::Empty, Test::Empty,
]; ];
pub fn label(self) -> &'static str { /// Its catalog key, which also names it.
pub fn key(self) -> &'static str {
match self { match self {
Self::Contains => "contains", Self::Contains => "builder.test.contains",
Self::Equals => "is", Self::Equals => "builder.test.equals",
Self::NotEquals => "is not", Self::NotEquals => "builder.test.not_equals",
Self::Above => "is above", Self::Above => "builder.test.above",
Self::Below => "is below", Self::Below => "builder.test.below",
Self::Empty => "is empty", Self::Empty => "builder.test.empty",
} }
} }
+62
View File
@@ -0,0 +1,62 @@
//! The sort and filter builders read in Chinese under zh-CN.
use gpui::{IntoElement, Render, TestAppContext, Window, div, prelude::*};
use crate::{
i18n::{self, I18n},
tables::{FilterBuilder, FilterRule, SortBuilder, SortKey, Test},
theme::Theme,
};
struct Builders;
impl Render for Builders {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
let columns = [("a", "第一列"), ("b", "第二列")];
let rule = FilterRule {
column: "a".into(),
..FilterRule::default()
};
let key = SortKey {
column: "a".into(),
rising: true,
};
div()
.size_full()
.child(FilterBuilder::new("filter", columns).rules([rule.clone(), rule], true))
.child(SortBuilder::new("sort", columns).keys([key]))
}
}
#[gpui::test]
fn the_builders_read_in_chinese(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
cx.set_global(I18n::new("zh-CN").catalog("zh-CN", &[]));
});
cx.update(|cx| {
for test in Test::ALL {
let shown = i18n::text(cx, test.key(), &[]);
assert!(
!shown.chars().any(|ch| ch.is_ascii_alphabetic()),
"{test:?}: {shown}"
);
}
for key in [
"builder.add_rule",
"builder.all",
"builder.any",
"builder.add_sort",
"builder.up",
"builder.down",
] {
let shown = i18n::text(cx, key, &[]);
assert!(
!shown.chars().any(|ch| ch.is_ascii_alphabetic()),
"{key}: {shown}"
);
}
});
let (_, cx) = cx.add_window_view(|_, _| Builders);
cx.run_until_parked();
}
+1
View File
@@ -9,6 +9,7 @@ use gpui::{
Styled, TestAppContext, VisualTestContext, Window, div, point, px, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
mod chinese;
mod clipboard; mod clipboard;
mod columns; mod columns;
mod stale; mod stale;
+38 -20
View File
@@ -96,7 +96,7 @@ struct Formula {
} }
/// A typeset formula: its picture and logical size. /// A typeset formula: its picture and logical size.
type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>; pub(crate) type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
enum Typesetter {} enum Typesetter {}
@@ -166,7 +166,9 @@ fn laid(formula: &Formula) -> Result<(String, f32, f32), MathError> {
(list.width * font, (list.height + list.depth) * font, svg) (list.width * font, (list.height + list.depth) * font, svg)
})?; })?;
let (width, height, svg) = typeset; let (width, height, svg) = typeset;
Ok((svg, width as f32, height as f32)) let (width, height) = (width as f32, height as f32);
// RaTeX sizes its svg in pt; px keeps one unit.
Ok((wrapped(&svg, [0.0, 0.0, width, height])?, width, height))
} }
/// Runs RaTeX's layout; a panic becomes an error. /// Runs RaTeX's layout; a panic becomes an error.
@@ -187,41 +189,57 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>),
let top = ink.top().min(0.0); let top = ink.top().min(0.0);
let right = ink.right().max(width); let right = ink.right().max(width);
let bottom = ink.bottom().max(height); let bottom = ink.bottom().max(height);
let (w, h) = (right - left, bottom - top);
Ok((wrapped(svg, [left, top, w, h])?, size(px(w), px(h))))
}
/// `svg` in a px root viewing `[left, top, w, h]`.
fn wrapped(svg: &str, [left, top, w, h]: [f32; 4]) -> Result<String, MathError> {
let open = svg let open = svg
.find('>') .find('>')
.ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?; .ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?;
let close = svg let close = svg
.rfind("</svg>") .rfind("</svg>")
.ok_or_else(|| MathError::said("RaTeX left the svg open"))?; .ok_or_else(|| MathError::said("RaTeX left the svg open"))?;
let (w, h) = (right - left, bottom - top); Ok(format!(
let reframed = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {w} {h}\" width=\"{w}\" height=\"{h}\">{}</svg>", "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {w} {h}\" width=\"{w}\" height=\"{h}\">{}</svg>",
&svg[open + 1..close] &svg[open + 1..close]
); ))
Ok((reframed, size(px(w), px(h))))
} }
impl Latex { /// The asset key for `source` at `font` in `window`.
/// The asset key this formula draws from in `window`. fn formula(
fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) { (source, display): (&SharedString, bool),
let theme = cx.theme(); font: Pixels,
let font = theme.text_size(self.size).to_pixels(window.rem_size()); window: &Window,
let formula = Formula { cx: &App,
source: self.source.clone(), ) -> Formula {
display: self.display, Formula {
rgba: u32::from(theme.colors.fg.to_rgb()), source: source.clone(),
display,
rgba: u32::from(cx.theme().colors.fg.to_rgb()),
font: (font.as_f32() * 100.0).round() as u32, font: (font.as_f32() * 100.0).round() as u32,
scale: (window.scale_factor() * 100.0).round() as u32, scale: (window.scale_factor() * 100.0).round() as u32,
};
(formula, font)
} }
} }
/// `source` typeset at `font` in `window`; None while it loads.
pub(crate) fn typeset(
id: impl Into<ElementId>,
(source, display): (&SharedString, bool),
font: Pixels,
window: &mut Window,
cx: &mut App,
) -> Option<Typeset> {
let formula = formula((source, display), font, window, cx);
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
hold::<Typesetter>(id, &formula, picture, window, cx)
}
impl RenderOnce for Latex { impl RenderOnce for Latex {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (formula, font) = self.formula(window, cx); let font = cx.theme().text_size(self.size).to_pixels(window.rem_size());
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone()); match typeset(self.id, (&self.source, self.display), font, window, cx) {
match hold::<Typesetter>(self.id, &formula, picture, window, cx) {
Some(Ok((picture, shape))) => img(picture) Some(Ok((picture, shape))) => img(picture)
.w(shape.width) .w(shape.width)
.h(shape.height) .h(shape.height)
+20 -2
View File
@@ -114,8 +114,11 @@ fn flush(cx: &mut gpui::VisualTestContext) {
/// Whether `source`'s picture sits in the asset cache. /// Whether `source`'s picture sits in the asset cache.
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool { fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
cx.update(|window, cx| { cx.update(|window, cx| {
let latex = Latex::parse("probe", source).expect("parses"); let font = cx
let (formula, _) = latex.formula(window, cx); .theme()
.text_size(TextSize::Lg)
.to_pixels(window.rem_size());
let formula = super::formula((&source.to_string().into(), false), font, window, cx);
cx.has_asset::<Typesetter>(&formula) cx.has_asset::<Typesetter>(&formula)
}) })
} }
@@ -253,3 +256,18 @@ fn drawn_fallback_text_stays_inside_the_frame() {
); );
} }
} }
#[test]
fn a_picture_spans_its_formula_not_a_point_rescale() {
let formula = super::Formula {
source: "x^2+y^2=r^2".into(),
display: false,
rgba: 0xff,
font: 1600,
scale: 100,
};
let (_, width, _) = super::laid(&formula).expect("laid");
let (_, shape) = super::svg(&formula).expect("framed");
let ratio = shape.width.as_f32() / width;
assert!((0.95..1.05).contains(&ratio), "{} for {width}", shape.width);
}
+1
View File
@@ -26,6 +26,7 @@ pub use formatted::{
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo}; pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
pub use link::{ExternalLink, Link}; pub use link::{ExternalLink, Link};
pub(crate) use math::failed; pub(crate) use math::failed;
pub(crate) use math::typeset;
pub use math::{Latex, MathError, formula_svg}; pub use math::{Latex, MathError, formula_svg};
pub use rolling::AnimatedNumber; pub use rolling::AnimatedNumber;
pub use select::SelectableText; pub use select::SelectableText;