Compare commits
79
Commits
8fd3e431db
...
main
No files matched your search
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
|
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
|
||||||
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
|
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
|
||||||
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
|
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
|
||||||
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of a paragraph's lines: 40,000 lines take 0.9 s). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written.
|
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, a trailing backslash doubled only when a pipe follows at once, an emptied edge cell of a row without that outer pipe written as one so its column stays, the caret read through the same escaping, a padding press on the open cell keeping its typing, and a table edit writing it back before it runs; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraLine truncated
|
||||||
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
||||||
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
|
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
|
||||||
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
||||||
@@ -47,7 +47,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
|
|
||||||
- Colors, control and icon sizes, text sizes, radii, shadows come from `cx.theme()`. No raw `px()` literals inside components.
|
- Colors, control and icon sizes, text sizes, radii, shadows come from `cx.theme()`. No raw `px()` literals inside components.
|
||||||
- Gaps and padding use gpui's rem scale helpers (`gap_2`, `px_3`). That scale is the spacing system.
|
- Gaps and padding use gpui's rem scale helpers (`gap_2`, `px_3`). That scale is the spacing system.
|
||||||
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale.
|
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale. gpui paints a border no wider than half its box, so a line as thin as its box is a fill, `w_px` or `h_px` with `bg`, as the resize handles draw theirs.
|
||||||
- One component, one home. Duplicates point to it.
|
- One component, one home. Duplicates point to it.
|
||||||
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
||||||
- A handler a component needs is an argument of `new`, so leaving it out fails to compile. A state live data or the outside world can reach, a key gone from a list that changed or a failed load, logs an error naming the owner and draws a visible state, nothing chosen or an error line, and the app runs on. An assert on a caller's argument or on a fact true by construction stays; one that runtime data or input can trip is fixed. Chapters move to this as `tasks/panics.md` ticks them.
|
- A handler a component needs is an argument of `new`, so leaving it out fails to compile. A state live data or the outside world can reach, a key gone from a list that changed or a failed load, logs an error naming the owner and draws a visible state, nothing chosen or an error line, and the app runs on. An assert on a caller's argument or on a fact true by construction stays; one that runtime data or input can trip is fixed. Chapters move to this as `tasks/panics.md` ticks them.
|
||||||
@@ -66,6 +66,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
|
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
|
||||||
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
|
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
|
||||||
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
||||||
|
- A control inside a `drag_region` occludes it. On Windows gpui answers the system's hit test with the caption wherever a `Drag` hitbox lies under the pointer unoccluded, and Windows then keeps the press: no click reaches the control. `block_mouse_except_scroll` is not enough, since that answer reads every hitbox under the pointer. Window buttons occlude, which lets them answer as close, maximize and minimize, and so do a title bar's slots; a column header's item row occludes, staying the scroll container, and its title is a drag region of its own; the mini window drags by its title alone, so its buttons sit outside the region and its hover still shows the bar.
|
||||||
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
||||||
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
||||||
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
||||||
@@ -88,7 +89,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
|
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
|
||||||
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
|
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
|
||||||
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
|
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
|
||||||
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
|
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked. Panels occlude what lies under them. A submenu that does not fit on the right opens on the left of its row, sliding in from that side. A row's submenu opens once the pointer rests on it 150 ms; while a submenu is open, a row crossed inside the triangle from the last pointer place to the submenu's near corners waits 300 ms and switches only if the pointer still rests there (`menus::aim`); a row outside it marks at once. Keys and presses open at once.
|
||||||
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
|
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
|
||||||
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
|
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
|
||||||
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
|
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
|
||||||
@@ -220,13 +221,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
||||||
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
||||||
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
|
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
|
||||||
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width yields before a header's own parts; the host's header items keep one control's width and scroll when short.
|
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width is a flex basis, not a width, so it holds its header's own parts, which a width let run past it, and yields when the shell runs short; the host's header items keep one control's width and scroll when short. A resizable column drags by its border toward the filling column, its right before it and its left after it; the handle draws that border and occludes the header under it, and a drag sets off from the wider of the width drawn and the owner's, since a header's parts may hold a column wider and a short shell squeeze it narrower; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width the owner had.
|
||||||
- Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body).
|
- Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body).
|
||||||
- A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`).
|
- A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`).
|
||||||
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency.
|
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency.
|
||||||
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
||||||
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
||||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. Copy a component draws, tooltips and default placeholders too, reads `i18n::text` (app catalog, then the builtin English and Chinese); `i18n/tests/shipped.rs` fails on a fixed English literal in a component Ely ships (labels, tooltips, button and segment text, match arms, capitalized literal arguments); a choice's value stays its key, its label the catalog's (`tables::Test::key`). A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||||
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
||||||
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
||||||
- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
||||||
@@ -275,3 +276,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
||||||
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
||||||
- 2026-10-07: gpui comes from crates.io's `gpui-pre` at the exact snapshot GPUI Kit pins, not from Zed's git. Two sources build two gpui crates whose types do not mix, so Ely and Kit could not share a window. The code is still Zed's; the publisher is not. This replaces the 2026-09-28 choice of Zed's git, and the pin moves when Kit's does.
|
- 2026-10-07: gpui comes from crates.io's `gpui-pre` at the exact snapshot GPUI Kit pins, not from Zed's git. Two sources build two gpui crates whose types do not mix, so Ely and Kit could not share a window. The code is still Zed's; the publisher is not. This replaces the 2026-09-28 choice of Zed's git, and the pin moves when Kit's does.
|
||||||
|
- 2026-10-19: `ListItem::editor` draws a field in the title's place, for a row renamed in place, as ELYTerminal's session rows rename; the title stays the row's name in the AX tree, and the row's press, selection and description stay as they were.
|
||||||
Generated
+1
@@ -1685,6 +1685,7 @@ dependencies = [
|
|||||||
"similar",
|
"similar",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
"usvg",
|
"usvg",
|
||||||
"vte",
|
"vte",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ serde_json = { version = "1", features = ["preserve_order"] }
|
|||||||
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
|
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
|
||||||
smallvec = "1"
|
smallvec = "1"
|
||||||
unicode-segmentation = "1"
|
unicode-segmentation = "1"
|
||||||
|
unicode-width = "0.2"
|
||||||
usvg = "0.46"
|
usvg = "0.46"
|
||||||
vte = { version = "0.15", default-features = false, features = ["std", "ansi"] }
|
vte = { version = "0.15", default-features = false, features = ["std", "ansi"] }
|
||||||
web-time = "1.1"
|
web-time = "1.1"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-message-square-warning"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
|
||||||
|
<path d="M12 15h.01" />
|
||||||
|
<path d="M12 7v4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 481 B |
@@ -211,6 +211,24 @@ pub fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|||||||
pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
let (last, caption) = ran("context-ran", window, cx);
|
let (last, caption) = ran("context-ran", window, cx);
|
||||||
let menu = Menu::new()
|
let menu = Menu::new()
|
||||||
|
.buttons([
|
||||||
|
MenuItem::new("Cut")
|
||||||
|
.icon(IconName::Scissors)
|
||||||
|
.keys("secondary-x")
|
||||||
|
.on_click(run(&last, "cut")),
|
||||||
|
MenuItem::new("Copy")
|
||||||
|
.icon(IconName::Copy)
|
||||||
|
.keys("secondary-c")
|
||||||
|
.on_click(run(&last, "copy")),
|
||||||
|
MenuItem::new("Paste")
|
||||||
|
.icon(IconName::Clipboard)
|
||||||
|
.keys("secondary-v")
|
||||||
|
.on_click(run(&last, "paste")),
|
||||||
|
MenuItem::new("Delete")
|
||||||
|
.icon(IconName::Trash2)
|
||||||
|
.disabled(true),
|
||||||
|
])
|
||||||
|
.separator()
|
||||||
.item(
|
.item(
|
||||||
MenuItem::new("Back")
|
MenuItem::new("Back")
|
||||||
.icon(IconName::ArrowLeft)
|
.icon(IconName::ArrowLeft)
|
||||||
@@ -242,7 +260,7 @@ pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
section(
|
section(
|
||||||
"ContextMenu",
|
"ContextMenu",
|
||||||
"A right click opens the menu where it lands; it stays inside the window.",
|
"A right click opens the menu where it lands; it stays inside the window. A row of icon buttons heads it; arrows cross the row.",
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
.child(specimen(
|
.child(specimen(
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl RenderOnce for DescriptionList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A titled group of a `PropertyGrid`, open unless `folded`.
|
/// A `PropertyGrid` group; its unique title keys its fold.
|
||||||
pub struct PropertyGroup {
|
pub struct PropertyGroup {
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
open: bool,
|
open: bool,
|
||||||
@@ -157,10 +157,9 @@ impl RenderOnce for PropertyGrid {
|
|||||||
let folds: Vec<_> = self
|
let folds: Vec<_> = self
|
||||||
.groups
|
.groups
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|group| {
|
||||||
.map(|(ix, group)| {
|
|
||||||
use_seeded(
|
use_seeded(
|
||||||
(self.id.clone(), format!("fold-{ix}")),
|
(self.id.clone(), format!("fold-{}", group.title)),
|
||||||
group.open,
|
group.open,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
@@ -180,14 +179,14 @@ impl RenderOnce for PropertyGrid {
|
|||||||
|(ix, PropertyGroup { title, rows, .. })| {
|
|(ix, PropertyGroup { title, rows, .. })| {
|
||||||
let shown = folds[ix].read(cx).value;
|
let shown = folds[ix].read(cx).value;
|
||||||
let (toggle, key) = (folds[ix].clone(), title.clone());
|
let (toggle, key) = (folds[ix].clone(), title.clone());
|
||||||
let chevron = Disclosure::new((id.clone(), format!("chevron-{ix}")), shown);
|
let chevron = Disclosure::new((id.clone(), format!("chevron-{title}")), shown);
|
||||||
div()
|
div()
|
||||||
.when(ix > 0, |group| {
|
.when(ix > 0, |group| {
|
||||||
group.border_t_1().border_color(colors.border)
|
group.border_t_1().border_color(colors.border)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id((id.clone(), format!("group-{ix}")))
|
.id((id.clone(), format!("group-{title}")))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -208,10 +207,10 @@ impl RenderOnce for PropertyGrid {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(chevron)
|
.child(chevron)
|
||||||
.child(title),
|
.child(title.clone()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Collapsible::new((id.clone(), format!("rows-{ix}")), shown).child(
|
Collapsible::new((id.clone(), format!("rows-{title}")), shown).child(
|
||||||
div()
|
div()
|
||||||
.pb_1()
|
.pb_1()
|
||||||
.children(rows.into_iter().map(|(name, editor)| {
|
.children(rows.into_iter().map(|(name, editor)| {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, Bounds, Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled,
|
AnyElement, Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels,
|
||||||
TestAppContext, VisualTestContext, Window, div, point, px,
|
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -75,6 +75,46 @@ fn a_property_group_folds_from_its_header_and_stays_folded(cx: &mut TestAppConte
|
|||||||
assert_eq!(height.get(), open, "a second press opens it");
|
assert_eq!(height.get(), open, "a second press opens it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A grid that may put a Table group first.
|
||||||
|
struct Reorder(bool);
|
||||||
|
|
||||||
|
impl Render for Reorder {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let row = |name: &'static str| div().h(px(20.0)).debug_selector(move || name.into());
|
||||||
|
let grid = PropertyGrid::new("grid");
|
||||||
|
let grid = if self.0 {
|
||||||
|
grid.group(PropertyGroup::new("Table").row("Rows", row("table")))
|
||||||
|
} else {
|
||||||
|
grid
|
||||||
|
};
|
||||||
|
div().w(px(320.0)).child(
|
||||||
|
grid.group(PropertyGroup::new("Layout").row("Width", row("layout")))
|
||||||
|
.group(PropertyGroup::new("Export").row("Scale", row("export"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_fold_stays_with_its_group_when_a_group_is_put_first(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||||
|
});
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Reorder(false));
|
||||||
|
settle(cx);
|
||||||
|
cx.simulate_click(point(px(4.0), px(12.0)), Modifiers::none());
|
||||||
|
settle(cx);
|
||||||
|
assert!(cx.debug_bounds("layout").is_none(), "Layout folds");
|
||||||
|
view.update(cx, |view, cx| {
|
||||||
|
view.0 = true;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
assert!(cx.debug_bounds("table").is_some(), "the new group opens");
|
||||||
|
assert!(cx.debug_bounds("layout").is_none(), "Layout stays folded");
|
||||||
|
assert!(cx.debug_bounds("export").is_some(), "Export stays open");
|
||||||
|
}
|
||||||
|
|
||||||
/// A one-row description list, this wide, that reports where the list and its value sit.
|
/// A one-row description list, this wide, that reports where the list and its value sit.
|
||||||
struct Facts(Pixels, fn() -> AnyElement, Rc<Cell<[Bounds<Pixels>; 2]>>);
|
struct Facts(Pixels, fn() -> AnyElement, Rc<Cell<[Bounds<Pixels>; 2]>>);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! Edits read off the UI thread: formats and block commands.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::{AppContext as _, Context};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
DocumentEvent, LARGEST_READ, MarkdownDocument,
|
||||||
|
block::{self, BlockCommand, settled},
|
||||||
|
cells,
|
||||||
|
format::{Change, EditRefusal, InlineFormat, drop_image, toggle},
|
||||||
|
tables::{self, TableEdit},
|
||||||
|
};
|
||||||
|
use crate::documents::{MarkdownMode, modes::joint};
|
||||||
|
|
||||||
|
/// The text read, the open block's offset, the selection.
|
||||||
|
type Snapshot = (String, usize, Range<usize>);
|
||||||
|
|
||||||
|
impl MarkdownDocument {
|
||||||
|
/// `format` at the selection, read apart; refusals by event.
|
||||||
|
pub fn format(
|
||||||
|
&mut self,
|
||||||
|
format: InlineFormat,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
self.apart(
|
||||||
|
format!("format {format:?}"),
|
||||||
|
move |full, selection| toggle(full, selection, format),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the picture at source byte `at`, read apart.
|
||||||
|
pub fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
|
||||||
|
let drop = move |full: &str, _| drop_image(full, at);
|
||||||
|
self.apart("delete image".to_string(), drop, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `command` at the caret, read apart; refusals by event.
|
||||||
|
pub fn block(
|
||||||
|
&mut self,
|
||||||
|
command: BlockCommand,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
self.apart(
|
||||||
|
format!("block {command:?}"),
|
||||||
|
move |full, selection| block::run(full, selection, command),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `edit` on the table at the caret, read apart.
|
||||||
|
pub fn edit_table(
|
||||||
|
&mut self,
|
||||||
|
edit: TableEdit,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
self.apart(
|
||||||
|
format!("table edit {edit:?}"),
|
||||||
|
move |full, selection| match edit {
|
||||||
|
TableEdit::Delete => tables::delete(full, selection.start),
|
||||||
|
_ => tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `read` off the UI thread, then writes its change.
|
||||||
|
fn apart(
|
||||||
|
&mut self,
|
||||||
|
name: String,
|
||||||
|
read: impl FnOnce(&str, Range<usize>) -> Result<Change, EditRefusal> + Send + 'static,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
if self.mode == MarkdownMode::Preview {
|
||||||
|
return Err(EditRefusal::Preview);
|
||||||
|
}
|
||||||
|
let (full, offset, selection) = self.snapshot(cx);
|
||||||
|
if full.len() > LARGEST_READ {
|
||||||
|
log::info!("markdown document: {name} refused: {} bytes", full.len());
|
||||||
|
return Err(EditRefusal::TooLong);
|
||||||
|
}
|
||||||
|
if self.editing {
|
||||||
|
log::info!("markdown document: {name} refused: one runs");
|
||||||
|
return Err(EditRefusal::Busy);
|
||||||
|
}
|
||||||
|
log::info!("markdown document: {name} reads {} bytes apart", full.len());
|
||||||
|
let taken = (full.clone(), offset, selection.clone());
|
||||||
|
self.editing = true;
|
||||||
|
cx.spawn(async move |document, cx| {
|
||||||
|
let change = cx
|
||||||
|
.background_spawn(async move { read(&full, selection) })
|
||||||
|
.await;
|
||||||
|
document
|
||||||
|
.update(cx, |document, cx| {
|
||||||
|
document.editing = false;
|
||||||
|
let done = if document.mode == MarkdownMode::Preview {
|
||||||
|
Err(EditRefusal::Preview)
|
||||||
|
} else if document.snapshot(cx) != taken {
|
||||||
|
Err(EditRefusal::Changed)
|
||||||
|
} else {
|
||||||
|
change
|
||||||
|
.and_then(|change| document.apply(&name, change, &taken.0, taken.1, cx))
|
||||||
|
};
|
||||||
|
if let Err(why) = done {
|
||||||
|
log::info!("markdown document: {name} refused: {why:?}");
|
||||||
|
cx.emit(DocumentEvent::EditRefused(why));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text an edit reads: the open block's in place.
|
||||||
|
pub(super) fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
|
||||||
|
let text = self.editor.read(cx).text();
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
let picked = self.editor.read(cx).primary().range();
|
||||||
|
return settle((text.to_string(), 0, picked));
|
||||||
|
};
|
||||||
|
let input = open.input.read(cx);
|
||||||
|
let (before, after) = match open.append {
|
||||||
|
true => (format!("{text}{}", joint(text)), ""),
|
||||||
|
false => (
|
||||||
|
text[..open.range.start].to_string(),
|
||||||
|
&text[open.range.end..],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (typed, mut picked) = (input.text(), input.selection());
|
||||||
|
// A cell reads as it writes back.
|
||||||
|
let typed = match open.cell {
|
||||||
|
Some(_) => {
|
||||||
|
let edges = cells::edges(text, open.range.clone());
|
||||||
|
let shift = |at: usize| cells::kept_at(typed, at, edges);
|
||||||
|
picked = shift(picked.start)..shift(picked.end);
|
||||||
|
cells::kept(typed, edges)
|
||||||
|
}
|
||||||
|
None => typed.to_string(),
|
||||||
|
};
|
||||||
|
let offset = before.len();
|
||||||
|
let full = format!("{before}{typed}{after}");
|
||||||
|
settle((full, offset, offset + picked.start..offset + picked.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `change`: into the open block while it stays inside.
|
||||||
|
fn apply(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
(edits, selected): Change,
|
||||||
|
full: &str,
|
||||||
|
offset: usize,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
log::info!("markdown document: {name}");
|
||||||
|
if let Some(open) = &self.open {
|
||||||
|
let typed = open.input.read(cx).text();
|
||||||
|
// Offsets read the escaped cell; its field holds it raw.
|
||||||
|
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||||
|
let raw = open.cell.is_some() && cells::kept(typed, edges) != typed;
|
||||||
|
let end = offset + typed.len();
|
||||||
|
let grown = (edits.iter().map(|(_, with)| with.len()).sum::<usize>() + end)
|
||||||
|
.checked_sub(edits.iter().map(|(range, _)| range.len()).sum::<usize>());
|
||||||
|
let inside = !raw
|
||||||
|
&& edits
|
||||||
|
.iter()
|
||||||
|
.all(|(range, _)| offset <= range.start && range.end <= end)
|
||||||
|
&& grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown);
|
||||||
|
if inside {
|
||||||
|
let (Some(from), Some(to)) = (
|
||||||
|
edits.iter().map(|(range, _)| range.start).min(),
|
||||||
|
edits.iter().map(|(range, _)| range.end).max(),
|
||||||
|
) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut written = String::new();
|
||||||
|
let mut at = from;
|
||||||
|
for (range, with) in &edits {
|
||||||
|
written.push_str(&full[at..range.start]);
|
||||||
|
written.push_str(with);
|
||||||
|
at = range.end;
|
||||||
|
}
|
||||||
|
written.push_str(&full[at..to]);
|
||||||
|
open.input.update(cx, |input, cx| {
|
||||||
|
input.select(from - offset..to - offset, cx);
|
||||||
|
input.insert(&written, cx);
|
||||||
|
input.select(selected.start - offset..selected.end - offset, cx);
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
log::info!("markdown document: {name} reaches past the open block");
|
||||||
|
self.commit(cx);
|
||||||
|
if self.editor.read(cx).text() != full {
|
||||||
|
log::error!("markdown document: {name}: the write-back is not the text read");
|
||||||
|
return Err(EditRefusal::Changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit(edits, cx);
|
||||||
|
editor.select(std::iter::once(selected), cx);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The editor splits lines on LF only; reads start off a CRLF.
|
||||||
|
fn settle((full, offset, picked): Snapshot) -> Snapshot {
|
||||||
|
let picked = settled(&full, picked.start)..settled(&full, picked.end);
|
||||||
|
(full, offset, picked)
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
//! Block commands at the caret, as Typora's Paragraph menu (R4.7).
|
||||||
|
|
||||||
|
mod check;
|
||||||
|
mod heading;
|
||||||
|
mod insert;
|
||||||
|
mod list;
|
||||||
|
mod quote;
|
||||||
|
mod split;
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::format::{Change, EditRefusal};
|
||||||
|
use crate::documents::markdown::{file_tree, span, walk};
|
||||||
|
|
||||||
|
/// A command of Typora's Paragraph menu.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum BlockCommand {
|
||||||
|
/// Levels 1 to 6.
|
||||||
|
Heading(u8),
|
||||||
|
Paragraph,
|
||||||
|
/// A level up; a paragraph turns level 6.
|
||||||
|
Promote,
|
||||||
|
/// A level down; level 6 turns a paragraph.
|
||||||
|
Demote,
|
||||||
|
Quote,
|
||||||
|
OrderedList,
|
||||||
|
BulletList,
|
||||||
|
TaskList,
|
||||||
|
Rule,
|
||||||
|
CodeBlock,
|
||||||
|
MathBlock,
|
||||||
|
Toc,
|
||||||
|
FrontMatter,
|
||||||
|
Footnote,
|
||||||
|
LinkDefinition,
|
||||||
|
ParagraphBefore,
|
||||||
|
ParagraphAfter,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `command` at `selection` in `text`, or why not.
|
||||||
|
pub(super) fn run(
|
||||||
|
text: &str,
|
||||||
|
selection: Range<usize>,
|
||||||
|
command: BlockCommand,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let here = Here::new(text, &root, selection);
|
||||||
|
match command {
|
||||||
|
BlockCommand::Heading(_)
|
||||||
|
| BlockCommand::Paragraph
|
||||||
|
| BlockCommand::Promote
|
||||||
|
| BlockCommand::Demote => heading::run(&here, command),
|
||||||
|
BlockCommand::Quote => quote::run(&here),
|
||||||
|
BlockCommand::OrderedList | BlockCommand::BulletList | BlockCommand::TaskList => {
|
||||||
|
list::run(&here, command)
|
||||||
|
}
|
||||||
|
BlockCommand::FrontMatter => insert::front_matter(&here),
|
||||||
|
BlockCommand::ParagraphBefore => insert::paragraph(&here, true),
|
||||||
|
BlockCommand::ParagraphAfter => insert::paragraph(&here, false),
|
||||||
|
BlockCommand::Delete => insert::delete(&here),
|
||||||
|
_ => insert::run(&here, command),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caret's place in the parsed text.
|
||||||
|
pub(super) struct Here<'a> {
|
||||||
|
pub(super) text: &'a str,
|
||||||
|
pub(super) selection: Range<usize>,
|
||||||
|
/// Root down to the deepest block at the selection's start.
|
||||||
|
pub(super) path: Vec<&'a Node>,
|
||||||
|
pub(super) root: &'a Node,
|
||||||
|
/// Every node, in document order.
|
||||||
|
pub(super) nodes: Vec<&'a Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Here<'a> {
|
||||||
|
fn new(text: &'a str, root: &'a Node, selection: Range<usize>) -> Self {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(root, &mut nodes);
|
||||||
|
Here {
|
||||||
|
text,
|
||||||
|
path: path(text, root, selection.start),
|
||||||
|
selection,
|
||||||
|
root,
|
||||||
|
nodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caret's block, unless the path ends on a container.
|
||||||
|
pub(super) fn leaf(&self) -> Option<&'a Node> {
|
||||||
|
let last = *self.path.last()?;
|
||||||
|
(!holds(last)).then_some(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The containers around `path[..upto]`.
|
||||||
|
pub(super) fn containers(&self, upto: usize) -> Vec<&'a Node> {
|
||||||
|
self.path[..upto]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|node| container(node))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the caret's line holds nothing past its prefix.
|
||||||
|
pub(super) fn blank(&self) -> bool {
|
||||||
|
let line = line_start(self.text, self.selection.start);
|
||||||
|
let containers = self.containers(self.path.len());
|
||||||
|
inside(self.text, line, &containers)
|
||||||
|
.is_some_and(|at| self.text[at..line_end(self.text, line)].trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root down to the deepest block at `at`.
|
||||||
|
pub(super) fn path_at(&self, at: usize) -> Vec<&'a Node> {
|
||||||
|
path(self.text, self.root, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The common holder's index and the blocks the selection touches.
|
||||||
|
pub(super) fn blocks(&self) -> Option<(usize, Vec<&'a Node>)> {
|
||||||
|
let end = self.path_at(self.selection.end);
|
||||||
|
let mut k = 0;
|
||||||
|
while k + 1 < self.path.len()
|
||||||
|
&& k + 1 < end.len()
|
||||||
|
&& std::ptr::eq(self.path[k + 1], end[k + 1])
|
||||||
|
{
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
if !holds(self.path[k]) {
|
||||||
|
return Some((k - 1, vec![self.path[k]]));
|
||||||
|
}
|
||||||
|
let (s, e) = (self.selection.start, self.selection.end);
|
||||||
|
let kids: Vec<&Node> = (self.path[k].children().map(Vec::as_slice))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|kid| span(kid).start <= e && s <= span(kid).end)
|
||||||
|
.collect();
|
||||||
|
(!kids.is_empty()).then_some((k, kids))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a paragraph ending on the line before joins `block`.
|
||||||
|
pub(super) fn joins_before(&self, block: &Range<usize>, gone: &[&Node]) -> bool {
|
||||||
|
let line = line_start(self.text, block.start);
|
||||||
|
let Some(previous) = prev_line(self.text, line) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ended = (self.nodes.iter()).any(|node| {
|
||||||
|
matches!(node, Node::Paragraph(_)) && line_start(self.text, span(node).end) == previous
|
||||||
|
});
|
||||||
|
ended && !self.opens(line, gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the line after `block` would join it as text.
|
||||||
|
pub(super) fn joins_after(&self, block: &Range<usize>) -> bool {
|
||||||
|
let Some(next) = next_line(self.text, block.end) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let starts = (self.nodes.iter()).any(|node| {
|
||||||
|
let at = span(node).start;
|
||||||
|
line_start(self.text, at) == next
|
||||||
|
&& match node {
|
||||||
|
Node::Paragraph(_) => true,
|
||||||
|
Node::ThematicBreak(_) => self.text[at..].starts_with('-'),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
starts && !self.opens(next, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `line` opens a container that stays and interrupts.
|
||||||
|
fn opens(&self, line: usize, gone: &[&Node]) -> bool {
|
||||||
|
let on = |node: &Node| line_start(self.text, span(node).start) == line;
|
||||||
|
let left = |node: &Node| !gone.iter().any(|gone| std::ptr::eq(*gone, node));
|
||||||
|
(self.nodes.iter()).any(|node| match node {
|
||||||
|
Node::List(list) if left(node) => list
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.any(|item| on(item) && (!list.ordered || list.start == Some(1))),
|
||||||
|
Node::Blockquote(_) => on(node) && left(node),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blockquotes, list items and footnote definitions.
|
||||||
|
pub(super) fn container(node: &Node) -> bool {
|
||||||
|
matches!(
|
||||||
|
node,
|
||||||
|
Node::Blockquote(_) | Node::ListItem(_) | Node::FootnoteDefinition(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `node` is task item `holder`'s first text.
|
||||||
|
pub(super) fn task_head(holder: &Node, node: &Node) -> bool {
|
||||||
|
matches!(holder, Node::ListItem(item)
|
||||||
|
if item.checked.is_some()
|
||||||
|
&& item.children.first().is_some_and(|kid| std::ptr::eq(kid, node)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a heading-closing `#` run starts in `line`.
|
||||||
|
pub(super) fn closing(line: &str) -> Option<usize> {
|
||||||
|
let line = line.trim_end_matches([' ', '\t']);
|
||||||
|
let kept = line.trim_end_matches('#');
|
||||||
|
(kept.len() < line.len() && kept.ends_with([' ', '\t'])).then_some(kept.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte to escape so `line` reads as prose.
|
||||||
|
pub(super) fn unblocked(line: &str) -> Result<Option<usize>, EditRefusal> {
|
||||||
|
let prose = |line: &str| {
|
||||||
|
let root = file_tree(line);
|
||||||
|
matches!(
|
||||||
|
root.children().and_then(|kids| kids.first()),
|
||||||
|
Some(Node::Paragraph(_))
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if prose(line) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mark = line.find(|ch: char| ch.is_ascii_punctuation());
|
||||||
|
match mark.filter(|mark| prose(&format!("{}\\{}", &line[..*mark], &line[*mark..]))) {
|
||||||
|
Some(mark) => Ok(Some(mark)),
|
||||||
|
None => Err(check::misread("a line stays a block")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the path goes on below `node`.
|
||||||
|
fn holds(node: &Node) -> bool {
|
||||||
|
container(node) || matches!(node, Node::Root(_) | Node::List(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root down to the deepest block at `at`.
|
||||||
|
fn path<'a>(text: &str, root: &'a Node, at: usize) -> Vec<&'a Node> {
|
||||||
|
let mut path = vec![root];
|
||||||
|
while let Some(node) = path.last().copied().filter(|node| holds(node)) {
|
||||||
|
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
let found = (kids.iter())
|
||||||
|
.find(|kid| span(kid).contains(&at) || span(kid).end == at)
|
||||||
|
.or_else(|| {
|
||||||
|
// A caret before a block on its line is inside.
|
||||||
|
(kids.iter()).find(|kid| {
|
||||||
|
let start = span(kid).start;
|
||||||
|
at < start && !text[at..start].contains(['\n', '\r'])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let Some(kid) = found else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
path.push(kid);
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `at` falls between a CR and its LF.
|
||||||
|
fn splits(text: &str, at: usize) -> bool {
|
||||||
|
text[..at].ends_with('\r') && text[at..].starts_with('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `at`, before the CR when it splits a CRLF.
|
||||||
|
pub(super) fn settled(text: &str, at: usize) -> usize {
|
||||||
|
if splits(text, at) { at - 1 } else { at }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn line_start(text: &str, at: usize) -> usize {
|
||||||
|
debug_assert!(!splits(text, at), "line_start inside a CRLF");
|
||||||
|
text[..at].rfind(['\n', '\r']).map_or(0, |at| at + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn line_end(text: &str, at: usize) -> usize {
|
||||||
|
debug_assert!(!splits(text, at), "line_end inside a CRLF");
|
||||||
|
at + text[at..].find(['\n', '\r']).unwrap_or(text.len() - at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of the line after `at`'s.
|
||||||
|
pub(super) fn next_line(text: &str, at: usize) -> Option<usize> {
|
||||||
|
let end = line_end(text, at);
|
||||||
|
let rest = &text[end..];
|
||||||
|
let ending = if rest.starts_with("\r\n") { 2 } else { 1 };
|
||||||
|
(end < text.len()).then_some(end + ending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of the line before `at`'s.
|
||||||
|
pub(super) fn prev_line(text: &str, at: usize) -> Option<usize> {
|
||||||
|
let start = line_start(text, at);
|
||||||
|
let ending = if text[..start].ends_with("\r\n") {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
(start > 0).then(|| line_start(text, start - ending))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text's own line ending.
|
||||||
|
pub(super) fn eol(text: &str) -> &'static str {
|
||||||
|
match (text.contains("\r\n"), text.contains('\r')) {
|
||||||
|
(true, _) => "\r\n",
|
||||||
|
(false, true) => "\r",
|
||||||
|
_ => "\n",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Columns before `at` on its line, tabs stopping at four.
|
||||||
|
pub(super) fn column(text: &str, at: usize) -> usize {
|
||||||
|
advance(0, &text[line_start(text, at)..at])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(col: usize, run: &str) -> usize {
|
||||||
|
run.chars().fold(col, |col, ch| match ch {
|
||||||
|
'\t' => col / 4 * 4 + 4,
|
||||||
|
_ => col + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list marker's or footnote label's bytes, then its spaces.
|
||||||
|
pub(super) fn marker(text: &str, node: &Node) -> (usize, usize) {
|
||||||
|
let start = span(node).start;
|
||||||
|
let line = &text[start..line_end(text, start)];
|
||||||
|
let mark = match node {
|
||||||
|
Node::FootnoteDefinition(_) => line.find("]:").map_or(line.len(), |at| at + 2),
|
||||||
|
_ if line.starts_with(['-', '*', '+']) => 1,
|
||||||
|
_ => line.find(['.', ')']).map_or(line.len(), |at| at + 1),
|
||||||
|
};
|
||||||
|
let after = &line[mark..];
|
||||||
|
let spaces = after.len() - after.trim_start_matches([' ', '\t']).len();
|
||||||
|
(mark, spaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The column a container's continuation lines start at.
|
||||||
|
pub(super) fn indent(text: &str, node: &Node) -> usize {
|
||||||
|
let start = span(node).start;
|
||||||
|
let base = column(text, start);
|
||||||
|
let (mark, spaces) = marker(text, node);
|
||||||
|
if matches!(node, Node::FootnoteDefinition(_)) {
|
||||||
|
return base + 4;
|
||||||
|
}
|
||||||
|
let after = advance(base + mark, &text[start + mark..start + mark + spaces]) - base - mark;
|
||||||
|
let blank = text[start + mark..line_end(text, start)].trim().is_empty();
|
||||||
|
match blank || !(1..=4).contains(&after) {
|
||||||
|
true => base + mark + 1,
|
||||||
|
false => base + mark + after,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a container's text starts on its first line.
|
||||||
|
pub(super) fn opener(text: &str, node: &Node) -> usize {
|
||||||
|
let start = span(node).start;
|
||||||
|
let (mark, spaces) = marker(text, node);
|
||||||
|
let width = advance(
|
||||||
|
column(text, start + mark),
|
||||||
|
&text[start + mark..start + mark + spaces],
|
||||||
|
) - column(text, start + mark);
|
||||||
|
match (1..=4).contains(&width) {
|
||||||
|
true => start + mark + spaces,
|
||||||
|
false => start + mark + spaces.min(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `line`'s text starts inside `containers`; none when lazy.
|
||||||
|
pub(super) fn inside(text: &str, line: usize, containers: &[&Node]) -> Option<usize> {
|
||||||
|
let end = line_end(text, line);
|
||||||
|
let mut at = line;
|
||||||
|
for node in containers {
|
||||||
|
if text[at..end].trim().is_empty() {
|
||||||
|
return Some(end);
|
||||||
|
}
|
||||||
|
if matches!(node, Node::Blockquote(_)) {
|
||||||
|
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||||
|
if lead > 3 || !text[at + lead..end].starts_with('>') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
at += lead + 1;
|
||||||
|
at += usize::from(text[at..end].starts_with([' ', '\t']));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = span(node).start;
|
||||||
|
if line <= start && start < end {
|
||||||
|
at = opener(text, node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let want = indent(text, node);
|
||||||
|
let mut col = column(text, at);
|
||||||
|
while col < want {
|
||||||
|
let ch = text[at..end]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.filter(|ch| matches!(ch, ' ' | '\t'))?;
|
||||||
|
col = advance(col, &text[at..at + 1]);
|
||||||
|
at += ch.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefix a new line takes inside `containers`.
|
||||||
|
pub(super) fn prefix(text: &str, containers: &[&Node]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (ix, node) in containers.iter().enumerate() {
|
||||||
|
if matches!(node, Node::Blockquote(_)) {
|
||||||
|
out.push_str("> ");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = span(node).start;
|
||||||
|
let outer = inside(text, line_start(text, start), &containers[..ix]).unwrap_or(start);
|
||||||
|
let width = indent(text, node).saturating_sub(column(text, outer.min(start)));
|
||||||
|
out.push_str(&" ".repeat(width));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where byte `at` lies once `edits`, sorted, are written.
|
||||||
|
pub(super) fn shifted(at: usize, edits: &[(Range<usize>, String)]) -> usize {
|
||||||
|
let mut moved = at;
|
||||||
|
for (range, with) in edits {
|
||||||
|
if range.end <= at {
|
||||||
|
moved = moved + with.len() - range.len();
|
||||||
|
} else if range.start < at {
|
||||||
|
moved -= at - range.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `edits`, sorted, and the caret mapped through them.
|
||||||
|
pub(super) fn change(mut edits: Vec<(Range<usize>, String)>, caret: Range<usize>) -> Change {
|
||||||
|
edits.sort_by_key(|(range, _)| range.start);
|
||||||
|
assert!(
|
||||||
|
edits
|
||||||
|
.windows(2)
|
||||||
|
.all(|pair| pair[0].0.end <= pair[1].0.start),
|
||||||
|
"block edits overlap"
|
||||||
|
);
|
||||||
|
let caret = shifted(caret.start, &edits)..shifted(caret.end, &edits);
|
||||||
|
(edits, caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `edits`, sorted, with the caret already where it lands.
|
||||||
|
pub(super) fn placed(mut edits: Vec<(Range<usize>, String)>, caret: usize) -> Change {
|
||||||
|
edits.sort_by_key(|(range, _)| range.start);
|
||||||
|
assert!(
|
||||||
|
edits
|
||||||
|
.windows(2)
|
||||||
|
.all(|pair| pair[0].0.end <= pair[1].0.start),
|
||||||
|
"block edits overlap"
|
||||||
|
);
|
||||||
|
(edits, caret..caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why `node` takes no block command at the caret.
|
||||||
|
pub(super) fn refusal(node: &Node) -> EditRefusal {
|
||||||
|
match node {
|
||||||
|
Node::Code(_) => EditRefusal::Code,
|
||||||
|
Node::Math(_) => EditRefusal::Math,
|
||||||
|
Node::Html(_) => EditRefusal::Html,
|
||||||
|
Node::Table(_) => EditRefusal::Table,
|
||||||
|
Node::Yaml(_) | Node::Toml(_) => EditRefusal::FrontMatter,
|
||||||
|
_ => EditRefusal::Outside,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! A command's result read back: its text and formats kept.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use crate::documents::{
|
||||||
|
MarkdownReader, Style,
|
||||||
|
document::format::EditRefusal,
|
||||||
|
markdown::{file_tree, span, walk},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEPT: u16 = bit(Style::Code) | bit(Style::Math);
|
||||||
|
const CODE: u16 = 1 << 9;
|
||||||
|
const MATH: u16 = 1 << 10;
|
||||||
|
const HTML: u16 = 1 << 11;
|
||||||
|
|
||||||
|
/// Refuses a result that reads as something else.
|
||||||
|
pub(super) fn misread(why: &str) -> EditRefusal {
|
||||||
|
log::warn!("block command: {why}");
|
||||||
|
EditRefusal::Misread
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` with sorted `edits` written, and its parse.
|
||||||
|
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> (String, Node) {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut at = 0;
|
||||||
|
for (range, with) in edits {
|
||||||
|
out.push_str(&text[at..range.start]);
|
||||||
|
out.push_str(with);
|
||||||
|
at = range.end;
|
||||||
|
}
|
||||||
|
out.push_str(&text[at..]);
|
||||||
|
let root = file_tree(&out);
|
||||||
|
(out, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn bit(style: Style) -> u16 {
|
||||||
|
1 << style as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed document read as the drawing reads it.
|
||||||
|
pub(super) struct Shown<'a> {
|
||||||
|
root: &'a Node,
|
||||||
|
reader: MarkdownReader<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Shown<'a> {
|
||||||
|
pub(super) fn new(source: &'a str, root: &'a Node) -> Self {
|
||||||
|
let reader = MarkdownReader::new(source, root);
|
||||||
|
Self { root, reader }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each shown character, with its formats.
|
||||||
|
pub(super) fn all(&self) -> Vec<(char, u16)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
self.read(self.root, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, node: &Node, out: &mut Vec<(char, u16)>) {
|
||||||
|
let (value, format) = match node {
|
||||||
|
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_) => {
|
||||||
|
return out.extend(self.inline(node));
|
||||||
|
}
|
||||||
|
Node::Code(code) => (code.value.as_str(), CODE),
|
||||||
|
Node::Math(math) => (math.value.as_str(), MATH),
|
||||||
|
Node::Html(html) => (html.value.as_str(), HTML),
|
||||||
|
_ => {
|
||||||
|
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||||
|
self.read(kid, out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.extend(value.chars().map(|ch| (ch, format)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `node`'s drawn text; white space counts only in code.
|
||||||
|
pub(super) fn inline(&self, node: &Node) -> Vec<(char, u16)> {
|
||||||
|
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
let inline = self.reader.inline(kids);
|
||||||
|
let mut formats = vec![0u16; inline.text.len()];
|
||||||
|
for (range, style) in &inline.styles {
|
||||||
|
for format in &mut formats[range.clone()] {
|
||||||
|
*format |= bit(*style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(inline.text.char_indices())
|
||||||
|
.map(|(at, ch)| (ch, formats[at]))
|
||||||
|
.filter(|(ch, format)| !ch.is_whitespace() || format & KEPT != 0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The level (0 a paragraph) and characters at `at`.
|
||||||
|
pub(super) fn at(&self, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(self.root, &mut nodes);
|
||||||
|
nodes.into_iter().find_map(|node| {
|
||||||
|
let level = match node {
|
||||||
|
Node::Paragraph(_) => 0,
|
||||||
|
Node::Heading(heading) => heading.depth,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let whole = span(node);
|
||||||
|
(whole.start <= at && at <= whole.end).then(|| (level, self.inline(node)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Heading levels and paragraphs, as Typora's `changeBlock`.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BlockCommand, Here, change, check, closing, eol, insert, inside, line_end, line_start,
|
||||||
|
next_line, prefix, prev_line, refusal, unblocked,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
document::format::{Change, EditRefusal},
|
||||||
|
markdown::span,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The level `command` asks at `level`; 0 is a paragraph.
|
||||||
|
fn target(level: u8, command: BlockCommand) -> u8 {
|
||||||
|
match command {
|
||||||
|
BlockCommand::Heading(n) => {
|
||||||
|
assert!((1..=6).contains(&n), "a heading level is 1 to 6");
|
||||||
|
n
|
||||||
|
}
|
||||||
|
BlockCommand::Promote if level == 0 => 6,
|
||||||
|
BlockCommand::Promote => (level - 1).max(1),
|
||||||
|
BlockCommand::Demote if level == 0 || level == 6 => 0,
|
||||||
|
BlockCommand::Demote => level + 1,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let caret = here.selection.clone();
|
||||||
|
let leaf = here.leaf();
|
||||||
|
let level = match leaf {
|
||||||
|
Some(Node::Paragraph(_)) => 0,
|
||||||
|
Some(Node::Heading(heading)) => heading.depth,
|
||||||
|
Some(node @ (Node::Math(_) | Node::Html(_) | Node::Table(_)))
|
||||||
|
if !matches!(command, BlockCommand::Promote | BlockCommand::Demote) =>
|
||||||
|
{
|
||||||
|
// Typora gives these an empty paragraph after.
|
||||||
|
log::info!("block command: {:?} takes a paragraph after", refusal(node));
|
||||||
|
return insert::paragraph(here, false);
|
||||||
|
}
|
||||||
|
Some(node) => return Err(refusal(node)),
|
||||||
|
None if here.blank() => 0,
|
||||||
|
None => return Err(EditRefusal::Outside),
|
||||||
|
};
|
||||||
|
let goal = target(level, command);
|
||||||
|
if goal == level {
|
||||||
|
return Ok((Vec::new(), caret));
|
||||||
|
}
|
||||||
|
let containers = here.containers(here.path.len());
|
||||||
|
let hashes = "#".repeat(usize::from(goal)) + " ";
|
||||||
|
let Some(node) = leaf else {
|
||||||
|
let line = line_start(text, caret.start);
|
||||||
|
let at = inside(text, line, &containers).expect("a blank line holds its prefix");
|
||||||
|
return Ok(change(vec![(at..at, hashes)], caret));
|
||||||
|
};
|
||||||
|
let whole = span(node);
|
||||||
|
let edits = match (level, goal) {
|
||||||
|
(0, _) if task_head(here, &whole) => return Err(EditRefusal::Outside),
|
||||||
|
(0, _) => to_heading(here, &containers, &whole, hashes),
|
||||||
|
(_, 0) => to_paragraph(here, node, &containers)?,
|
||||||
|
_ => relevel(here, node, &containers, goal),
|
||||||
|
};
|
||||||
|
let change = change(edits, caret);
|
||||||
|
// Every shown character stays; the caret's block is `goal`.
|
||||||
|
let (written, after) = check::reread(text, &change.0);
|
||||||
|
let before = check::Shown::new(text, here.root);
|
||||||
|
let now = check::Shown::new(&written, &after);
|
||||||
|
let placed = match now.at(change.1.start) {
|
||||||
|
Some((level, _)) => level == goal,
|
||||||
|
// An empty paragraph has no node.
|
||||||
|
None => goal == 0 && before.inline(node).is_empty(),
|
||||||
|
};
|
||||||
|
let kept = placed && now.all() == before.all();
|
||||||
|
match kept {
|
||||||
|
true => Ok(change),
|
||||||
|
false => Err(check::misread("a heading change would reread otherwise")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `node` is an ATX heading, `#` led.
|
||||||
|
fn atx(text: &str, node: &Node) -> bool {
|
||||||
|
text[span(node)].trim_start().starts_with('#')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the caret is on a task item's first line.
|
||||||
|
fn task_head(here: &Here, whole: &Range<usize>) -> bool {
|
||||||
|
let first = line_start(here.text, whole.start) == line_start(here.text, here.selection.start);
|
||||||
|
let leaf = here.path[here.path.len() - 1];
|
||||||
|
first && super::task_head(here.path[here.path.len() - 2], leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caret's line led by `hashes`; lazy lines kept in.
|
||||||
|
fn to_heading(
|
||||||
|
here: &Here,
|
||||||
|
containers: &[&Node],
|
||||||
|
whole: &Range<usize>,
|
||||||
|
hashes: String,
|
||||||
|
) -> Vec<(Range<usize>, String)> {
|
||||||
|
let text = here.text;
|
||||||
|
let lead = prefix(text, containers);
|
||||||
|
let first = line_start(text, here.selection.start);
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut line = first;
|
||||||
|
loop {
|
||||||
|
let at = match line_start(text, whole.start) == line {
|
||||||
|
true => Some(whole.start),
|
||||||
|
false => inside(text, line, containers),
|
||||||
|
};
|
||||||
|
let content = |from: usize| {
|
||||||
|
from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len()
|
||||||
|
};
|
||||||
|
let (at, lazy) = match at {
|
||||||
|
Some(at) => (content(at), ""),
|
||||||
|
None => (content(line), lead.as_str()),
|
||||||
|
};
|
||||||
|
if line == first {
|
||||||
|
edits.push((at..at, format!("{lazy}{hashes}")));
|
||||||
|
if let Some(run) = closing(&text[at..line_end(text, at)]) {
|
||||||
|
edits.push((at + run..at + run, "\\".to_string()));
|
||||||
|
}
|
||||||
|
} else if !lazy.is_empty() {
|
||||||
|
edits.push((at..at, lazy.to_string()));
|
||||||
|
}
|
||||||
|
match next_line(text, line) {
|
||||||
|
Some(next) if next < whole.end => line = next,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading's marks off; a blank line where text joins.
|
||||||
|
fn to_paragraph(
|
||||||
|
here: &Here,
|
||||||
|
node: &Node,
|
||||||
|
containers: &[&Node],
|
||||||
|
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let whole = span(node);
|
||||||
|
let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text));
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
if here.joins_before(&whole, &[]) {
|
||||||
|
let line = line_start(text, whole.start);
|
||||||
|
edits.push((line..line, blank.clone()));
|
||||||
|
}
|
||||||
|
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
match atx(text, node) {
|
||||||
|
true => match (kids.first(), kids.last()) {
|
||||||
|
(Some(first), Some(last)) => {
|
||||||
|
let start = span(first).start;
|
||||||
|
edits.push((whole.start..start, String::new()));
|
||||||
|
// The body kept: closing hashes go.
|
||||||
|
let end = line_end(text, start).min(span(last).end);
|
||||||
|
if let Some(mark) = unblocked(&text[start..end])? {
|
||||||
|
edits.push((start + mark..start + mark, "\\".to_string()));
|
||||||
|
}
|
||||||
|
edits.push((span(last).end..whole.end, String::new()));
|
||||||
|
}
|
||||||
|
_ => edits.push((whole.clone(), String::new())),
|
||||||
|
},
|
||||||
|
false => {
|
||||||
|
// The underline line goes with the break before it.
|
||||||
|
let under = line_start(text, whole.end);
|
||||||
|
let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len();
|
||||||
|
edits.push((under - ending.min(2)..whole.end, String::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if here.joins_after(&whole) {
|
||||||
|
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||||
|
edits.push((next..next, blank.clone()));
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading moved to level `goal`.
|
||||||
|
fn relevel(
|
||||||
|
here: &Here,
|
||||||
|
node: &Node,
|
||||||
|
containers: &[&Node],
|
||||||
|
goal: u8,
|
||||||
|
) -> Vec<(Range<usize>, String)> {
|
||||||
|
let text = here.text;
|
||||||
|
let whole = span(node);
|
||||||
|
if atx(text, node) {
|
||||||
|
let at = whole.start + text[whole.clone()].find('#').expect("an ATX heading has #");
|
||||||
|
let run = text[at..].len() - text[at..].trim_start_matches('#').len();
|
||||||
|
return vec![(at..at + run, "#".repeat(usize::from(goal)))];
|
||||||
|
}
|
||||||
|
let under = line_start(text, whole.end);
|
||||||
|
let marks = &text[under..whole.end];
|
||||||
|
let at = under + marks.find(['=', '-']).expect("a setext underline");
|
||||||
|
let run = marks.trim_end().len() - (at - under);
|
||||||
|
if goal <= 2 {
|
||||||
|
let mark = if goal == 1 { "=" } else { "-" };
|
||||||
|
return vec![(at..at + run, mark.repeat(run))];
|
||||||
|
}
|
||||||
|
// Levels past 2 are ATX: one line, the underline gone.
|
||||||
|
let mut edits = vec![(
|
||||||
|
whole.start..whole.start,
|
||||||
|
"#".repeat(usize::from(goal)) + " ",
|
||||||
|
)];
|
||||||
|
let mut line = line_start(text, whole.start);
|
||||||
|
while let Some(next) = next_line(text, line).filter(|next| *next < under) {
|
||||||
|
let end = line_end(text, line);
|
||||||
|
let from = inside(text, next, containers).unwrap_or(next);
|
||||||
|
let from = from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len();
|
||||||
|
edits.push((end..from, " ".to_string()));
|
||||||
|
line = next;
|
||||||
|
}
|
||||||
|
let last = prev_line(text, under).expect("a setext heading has text");
|
||||||
|
if let Some(run) = closing(&text[last..line_end(text, last)]) {
|
||||||
|
edits.push((last + run..last + run, "\\".to_string()));
|
||||||
|
}
|
||||||
|
let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len();
|
||||||
|
edits.push((under - ending.min(2)..whole.end, String::new()));
|
||||||
|
edits
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
//! Blocks put in and taken out, as Typora's `insertBlock`.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BlockCommand, Here, container, eol, inside, line_end, line_start, next_line, placed, prefix,
|
||||||
|
prev_line, refusal,
|
||||||
|
split::{Room, content, split},
|
||||||
|
task_head,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
document::format::{Change, EditRefusal},
|
||||||
|
markdown::span,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether `line` holds text past `containers`' prefix.
|
||||||
|
fn text_on(text: &str, line: usize, containers: &[&Node]) -> bool {
|
||||||
|
let at = content(text, line, containers);
|
||||||
|
at < line_end(text, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selection's text, line by line, prefixes cut.
|
||||||
|
fn picked(here: &Here, containers: &[&Node]) -> Vec<String> {
|
||||||
|
let text = here.text;
|
||||||
|
let (s, e) = (here.selection.start, here.selection.end);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut at = s;
|
||||||
|
while at < e {
|
||||||
|
out.push(text[at..line_end(text, at).min(e)].to_string());
|
||||||
|
match next_line(text, at).filter(|next| *next < e) {
|
||||||
|
Some(next) => at = inside(text, next, containers).unwrap_or(next).min(e),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest footnote number not taken.
|
||||||
|
fn free_note(here: &Here) -> usize {
|
||||||
|
let used: HashSet<&str> = (here.nodes.iter())
|
||||||
|
.filter_map(|node| match node {
|
||||||
|
Node::FootnoteDefinition(note) => Some(note.identifier.as_str()),
|
||||||
|
Node::FootnoteReference(note) => Some(note.identifier.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(1..)
|
||||||
|
.find(|n: &usize| !used.contains(n.to_string().as_str()))
|
||||||
|
.expect("a free number")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `command`'s lines and the caret's line and byte.
|
||||||
|
fn lines(
|
||||||
|
here: &Here,
|
||||||
|
command: BlockCommand,
|
||||||
|
body: Vec<String>,
|
||||||
|
) -> (Vec<String>, Option<(usize, usize)>) {
|
||||||
|
let fenced = |fence: String, body: Vec<String>| {
|
||||||
|
let mut lines = vec![fence.clone()];
|
||||||
|
lines.extend(if body.is_empty() {
|
||||||
|
vec![String::new()]
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
});
|
||||||
|
lines.push(fence);
|
||||||
|
(lines, Some((1, 0)))
|
||||||
|
};
|
||||||
|
match command {
|
||||||
|
BlockCommand::CodeBlock => {
|
||||||
|
let longest = (body.iter())
|
||||||
|
.flat_map(|line| line.split(|ch| ch != '`'))
|
||||||
|
.map(str::len)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
fenced("`".repeat(longest.max(2) + 1), body)
|
||||||
|
}
|
||||||
|
BlockCommand::MathBlock => fenced("$$".to_string(), body),
|
||||||
|
BlockCommand::Toc => (vec!["[toc]".to_string()], Some((0, 5))),
|
||||||
|
BlockCommand::Footnote => {
|
||||||
|
let line = format!("[^{}]: ", free_note(here));
|
||||||
|
let end = line.len();
|
||||||
|
(vec![line], Some((0, end)))
|
||||||
|
}
|
||||||
|
BlockCommand::LinkDefinition => {
|
||||||
|
let first = body.first().map_or("", |line| line.trim());
|
||||||
|
if first.contains(['/', '\\']) {
|
||||||
|
let address: String = first.chars().take(160).collect();
|
||||||
|
return (vec![format!("[]: {address}")], Some((0, 1)));
|
||||||
|
}
|
||||||
|
let label: String = (first.split_whitespace().next().unwrap_or_default())
|
||||||
|
.chars()
|
||||||
|
.take(20)
|
||||||
|
.collect();
|
||||||
|
let line = format!("[{label}]: ");
|
||||||
|
let caret = if label.is_empty() { 1 } else { line.len() };
|
||||||
|
(vec![line], Some((0, caret)))
|
||||||
|
}
|
||||||
|
_ => (vec!["---".to_string()], None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lines` joined inside `lead`, and the caret's byte in them.
|
||||||
|
fn joined(
|
||||||
|
lines: &[String],
|
||||||
|
caret: Option<(usize, usize)>,
|
||||||
|
nl: &str,
|
||||||
|
lead: &str,
|
||||||
|
) -> (String, Option<usize>) {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut at = None;
|
||||||
|
for (ix, line) in lines.iter().enumerate() {
|
||||||
|
if ix > 0 {
|
||||||
|
out.push_str(nl);
|
||||||
|
out.push_str(lead);
|
||||||
|
}
|
||||||
|
if let Some((row, byte)) = caret
|
||||||
|
&& row == ix
|
||||||
|
{
|
||||||
|
at = Some(out.len() + byte);
|
||||||
|
}
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
(out, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a paragraph or ATX heading splits at the caret.
|
||||||
|
fn splits(text: &str, node: &Node) -> bool {
|
||||||
|
match node {
|
||||||
|
Node::Paragraph(_) => true,
|
||||||
|
Node::Heading(_) => text[span(node)].trim_start().starts_with('#'),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let leaf = here.leaf();
|
||||||
|
let containers = here.containers(here.path.len() - usize::from(leaf.is_some()));
|
||||||
|
let lead = prefix(text, &containers);
|
||||||
|
let room = Room {
|
||||||
|
nl: eol(text),
|
||||||
|
blank: lead.trim_end().to_string(),
|
||||||
|
lead,
|
||||||
|
containers,
|
||||||
|
};
|
||||||
|
let (lines, caret) = lines(here, command, picked(here, &room.containers));
|
||||||
|
let (block, inner) = joined(&lines, caret, room.nl, &room.lead);
|
||||||
|
let rule = inner.is_none();
|
||||||
|
match leaf {
|
||||||
|
Some(node) if splits(text, node) => {
|
||||||
|
if here.selection.end > span(node).end {
|
||||||
|
return Err(EditRefusal::Across);
|
||||||
|
}
|
||||||
|
split(here, node, &room, &block, inner)
|
||||||
|
}
|
||||||
|
Some(node) if !here.selection.is_empty() => Err(refusal(node)),
|
||||||
|
Some(node) => {
|
||||||
|
let at = line_end(text, span(node).end);
|
||||||
|
let next =
|
||||||
|
next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers));
|
||||||
|
let gap = room.gap();
|
||||||
|
let tail = match rule {
|
||||||
|
true => gap.clone(),
|
||||||
|
false => String::new(),
|
||||||
|
};
|
||||||
|
let sep = if next {
|
||||||
|
format!("{}{}", room.nl, room.blank)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let caret = at + gap.len() + inner.unwrap_or(block.len() + tail.len());
|
||||||
|
Ok(placed(
|
||||||
|
vec![(at..at, format!("{gap}{block}{tail}{sep}"))],
|
||||||
|
caret,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None if here.blank() && here.selection.is_empty() => {
|
||||||
|
let line = line_start(text, here.selection.start);
|
||||||
|
let at = inside(text, line, &room.containers).expect("a blank line holds its prefix");
|
||||||
|
let end = line_end(text, line);
|
||||||
|
let prev =
|
||||||
|
prev_line(text, line).is_some_and(|prev| text_on(text, prev, &room.containers));
|
||||||
|
let next =
|
||||||
|
next_line(text, line).is_some_and(|next| text_on(text, next, &room.containers));
|
||||||
|
let head = if prev {
|
||||||
|
format!("{}{}", room.nl, room.lead)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let tail = if rule { room.gap() } else { String::new() };
|
||||||
|
let sep = if next {
|
||||||
|
format!("{}{}", room.nl, room.blank)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let caret = at + head.len() + inner.unwrap_or(block.len() + tail.len());
|
||||||
|
Ok(placed(
|
||||||
|
vec![(at..end, format!("{head}{block}{tail}{sep}"))],
|
||||||
|
caret,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => Err(EditRefusal::Outside),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty line before or after the caret's block.
|
||||||
|
pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let leaf = here.leaf().ok_or(EditRefusal::Outside)?;
|
||||||
|
let whole = span(leaf);
|
||||||
|
let containers = here.containers(here.path.len() - 1);
|
||||||
|
let lead = prefix(text, &containers);
|
||||||
|
let room = Room {
|
||||||
|
nl: eol(text),
|
||||||
|
blank: lead.trim_end().to_string(),
|
||||||
|
lead,
|
||||||
|
containers,
|
||||||
|
};
|
||||||
|
let gap = room.gap();
|
||||||
|
let holder = here.path[here.path.len() - 2];
|
||||||
|
if before && task_head(holder, leaf) {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
// A holder's first line keeps its opener: text moves down.
|
||||||
|
if before
|
||||||
|
&& container(holder)
|
||||||
|
&& !matches!(holder, Node::Blockquote(_))
|
||||||
|
&& line_start(text, span(holder).start) == line_start(text, whole.start)
|
||||||
|
{
|
||||||
|
let down = format!("{}{}", room.nl, room.lead);
|
||||||
|
return Ok(placed(vec![(whole.start..whole.start, down)], whole.start));
|
||||||
|
}
|
||||||
|
if before {
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut shift = 0;
|
||||||
|
if here.joins_before(&whole, &[]) {
|
||||||
|
let line = line_start(text, whole.start);
|
||||||
|
let blank = format!("{}{}", room.blank, room.nl);
|
||||||
|
shift = blank.len();
|
||||||
|
edits.push((line..line, blank));
|
||||||
|
}
|
||||||
|
edits.push((whole.start..whole.start, gap));
|
||||||
|
return Ok(placed(edits, whole.start + shift));
|
||||||
|
}
|
||||||
|
let at = line_end(text, whole.end);
|
||||||
|
let next = next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers));
|
||||||
|
let sep = if next {
|
||||||
|
format!("{}{}", room.nl, room.blank)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
Ok(placed(
|
||||||
|
vec![(at..at, format!("{gap}{sep}"))],
|
||||||
|
at + gap.len(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Front matter at the top, unless there is some.
|
||||||
|
pub(super) fn front_matter(here: &Here) -> Result<Change, EditRefusal> {
|
||||||
|
let first = here.root.children().and_then(|kids| kids.first());
|
||||||
|
if matches!(first, Some(Node::Yaml(_) | Node::Toml(_))) {
|
||||||
|
return Err(EditRefusal::Present);
|
||||||
|
}
|
||||||
|
let nl = eol(here.text);
|
||||||
|
let tail = match here.text.is_empty() || here.text.starts_with(['\n', '\r']) {
|
||||||
|
true => "",
|
||||||
|
false => nl,
|
||||||
|
};
|
||||||
|
Ok(placed(
|
||||||
|
vec![(0..0, format!("---{nl}{nl}---{nl}{tail}"))],
|
||||||
|
3 + nl.len(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caret's block gone; a lone paragraph takes its holder.
|
||||||
|
pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let leaf = here.leaf().ok_or(EditRefusal::Outside)?;
|
||||||
|
let mut ix = here.path.len() - 1;
|
||||||
|
if matches!(leaf, Node::Paragraph(_))
|
||||||
|
&& let Some(parent) = here.path.get(ix - 1)
|
||||||
|
&& container(parent)
|
||||||
|
&& parent.children().is_some_and(|kids| kids.len() == 1)
|
||||||
|
{
|
||||||
|
ix -= 1;
|
||||||
|
}
|
||||||
|
let whole = span(here.path[ix]);
|
||||||
|
let line = line_start(text, whole.start);
|
||||||
|
if !text[line..whole.start]
|
||||||
|
.chars()
|
||||||
|
.all(|ch| matches!(ch, ' ' | '\t' | '>'))
|
||||||
|
{
|
||||||
|
return Ok(placed(vec![(whole.clone(), String::new())], whole.start));
|
||||||
|
}
|
||||||
|
// Containers wholly on the lines go too.
|
||||||
|
let last = line_end(text, whole.end);
|
||||||
|
let top = (1..=ix)
|
||||||
|
.find(|j| {
|
||||||
|
let held = span(here.path[*j]);
|
||||||
|
line <= held.start && held.end <= last
|
||||||
|
})
|
||||||
|
.expect("the block lies on its lines");
|
||||||
|
let gone = &here.path[top..=ix];
|
||||||
|
let outer = here.containers(top);
|
||||||
|
let mut end = next_line(text, whole.end).unwrap_or(text.len());
|
||||||
|
let blank_above = prev_line(text, line).is_none_or(|prev| !text_on(text, prev, &outer));
|
||||||
|
if blank_above && end < text.len() && !text_on(text, end, &outer) {
|
||||||
|
end = next_line(text, end).unwrap_or(text.len());
|
||||||
|
}
|
||||||
|
// Neighbors that would join keep a blank line between.
|
||||||
|
let joins = here.joins_before(&whole, gone) && here.joins_after(&whole);
|
||||||
|
let with = match joins {
|
||||||
|
true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)),
|
||||||
|
false => String::new(),
|
||||||
|
};
|
||||||
|
Ok(placed(vec![(line..end, with)], line))
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
//! Lists on, off and retyped, as Typora's `toggleIndent`.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::{Code, List, Node};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
|
||||||
|
opener, prefix, refusal, unblocked,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
document::format::{Change, EditRefusal},
|
||||||
|
markdown::span,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn kind(list: &List) -> BlockCommand {
|
||||||
|
match (list.ordered, list.children.first()) {
|
||||||
|
(true, _) => BlockCommand::OrderedList,
|
||||||
|
(false, Some(Node::ListItem(item))) if item.checked.is_some() => BlockCommand::TaskList,
|
||||||
|
_ => BlockCommand::BulletList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marker an item takes: number `n` or the bullet.
|
||||||
|
fn marker(kind: BlockCommand, n: u32, bullet: char) -> String {
|
||||||
|
match kind {
|
||||||
|
BlockCommand::OrderedList => format!("{n}. "),
|
||||||
|
BlockCommand::TaskList => format!("{bullet} [ ] "),
|
||||||
|
_ => format!("{bullet} "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A marker's width for continuation lines; boxes add none.
|
||||||
|
fn width(kind: BlockCommand, n: u32) -> usize {
|
||||||
|
match kind {
|
||||||
|
BlockCommand::OrderedList => format!("{n}. ").len(),
|
||||||
|
_ => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
|
||||||
|
let caret = here.selection.clone();
|
||||||
|
let path = &here.path;
|
||||||
|
let item = match here.leaf() {
|
||||||
|
Some(leaf) => path.len().checked_sub(2).filter(|ix| match path[*ix] {
|
||||||
|
Node::ListItem(item) => {
|
||||||
|
!caret.is_empty()
|
||||||
|
|| item
|
||||||
|
.children
|
||||||
|
.first()
|
||||||
|
.is_some_and(|kid| std::ptr::eq(kid, leaf))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}),
|
||||||
|
None => Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))),
|
||||||
|
};
|
||||||
|
// A selection past the list takes every touched sibling.
|
||||||
|
if let Some(ix) = item
|
||||||
|
&& let Node::List(list) = path[ix - 1]
|
||||||
|
&& (here.path_at(caret.end).iter()).any(|node| std::ptr::eq(*node, path[ix - 1]))
|
||||||
|
{
|
||||||
|
let edits = match kind(list) == command {
|
||||||
|
true => unwrap(here, ix - 1, list)?,
|
||||||
|
false => {
|
||||||
|
let start = if list.ordered {
|
||||||
|
list.start.unwrap_or(1)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let outer = here.containers(ix - 1);
|
||||||
|
retype(here.text, path[ix - 1], list, &outer, command, start)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Ok(change(edits, caret));
|
||||||
|
}
|
||||||
|
if here.leaf().is_none() && caret.is_empty() {
|
||||||
|
if !here.blank() {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
let line = line_start(here.text, caret.start);
|
||||||
|
let at = inside(here.text, line, &here.containers(path.len()))
|
||||||
|
.expect("a blank line holds its prefix");
|
||||||
|
return Ok(change(vec![(at..at, marker(command, 1, '-'))], caret));
|
||||||
|
}
|
||||||
|
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
|
||||||
|
Ok(change(wrap(here, k, &blocks, command)?, caret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indent from `at` to column `want`: its end, columns past.
|
||||||
|
fn indented(text: &str, at: usize, want: usize) -> Option<(usize, usize)> {
|
||||||
|
let end = line_end(text, at);
|
||||||
|
let mut cut = at;
|
||||||
|
let mut col = column(text, at);
|
||||||
|
while col < want {
|
||||||
|
let ch = text[cut..end]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.filter(|ch| matches!(ch, ' ' | '\t'))?;
|
||||||
|
col = if ch == '\t' { col / 4 * 4 + 4 } else { col + 1 };
|
||||||
|
cut += 1;
|
||||||
|
}
|
||||||
|
Some((cut, col - want))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each non-lazy line of `item` after its first, with indent.
|
||||||
|
fn continued<'a>(
|
||||||
|
text: &'a str,
|
||||||
|
item: &Node,
|
||||||
|
outer: &'a [&Node],
|
||||||
|
) -> impl Iterator<Item = (usize, usize, usize)> + 'a {
|
||||||
|
let whole = span(item);
|
||||||
|
let want = indent(text, item);
|
||||||
|
std::iter::successors(next_line(text, whole.start), move |line| {
|
||||||
|
next_line(text, *line)
|
||||||
|
})
|
||||||
|
.take_while(move |line| *line < whole.end)
|
||||||
|
.filter_map(move |line| {
|
||||||
|
let at = inside(text, line, outer)?;
|
||||||
|
if text[at..line_end(text, line)].trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (cut, past) = indented(text, at, want)?;
|
||||||
|
Some((at, cut, past))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every item's marker off, its lines out one level.
|
||||||
|
fn unwrap(here: &Here, ix: usize, list: &List) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let outer = here.containers(ix);
|
||||||
|
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
|
||||||
|
let whole = span(here.path[ix]);
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
for (n, item) in list.children.iter().enumerate() {
|
||||||
|
let start = span(item).start;
|
||||||
|
let line = line_start(text, start);
|
||||||
|
let joins = match n {
|
||||||
|
0 => here.joins_before(&whole, &[here.path[ix]]),
|
||||||
|
_ => prev_text(text, line, &outer),
|
||||||
|
};
|
||||||
|
if joins {
|
||||||
|
edits.push((line..line, blank.clone()));
|
||||||
|
}
|
||||||
|
let first = item.children().and_then(|kids| kids.first()).map(span);
|
||||||
|
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||||
|
match &first {
|
||||||
|
Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => {
|
||||||
|
edits.push((start..kid.start, String::new()));
|
||||||
|
}
|
||||||
|
Some(kid) => {
|
||||||
|
let next = next_line(text, start).expect("an item's text lies below");
|
||||||
|
edits.push((line..next, String::new()));
|
||||||
|
if boxed {
|
||||||
|
edits.push((unbox(text, kid.start), String::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => edits.push((start..line_end(text, start), String::new())),
|
||||||
|
}
|
||||||
|
if boxed && let Some(kid) = first {
|
||||||
|
edits.extend(escaped(text, kid.start)?);
|
||||||
|
}
|
||||||
|
for (at, cut, past) in continued(text, item, &outer) {
|
||||||
|
edits.push((at..cut, " ".repeat(past)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if here.joins_after(&whole) {
|
||||||
|
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||||
|
edits.push((next..next, blank.clone()));
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The task box before the text at `kid`.
|
||||||
|
fn unbox(text: &str, kid: usize) -> Range<usize> {
|
||||||
|
let line = line_start(text, kid);
|
||||||
|
line + text[line..kid].rfind('[').expect("a task's box")..kid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backslash where text at `at`, unboxed, opens a block.
|
||||||
|
fn escaped(text: &str, at: usize) -> Result<Option<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let mark = unblocked(&text[at..line_end(text, at)])?;
|
||||||
|
Ok(mark.map(|mark| (at + mark..at + mark, "\\".to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `node`, holding `code`, is fenced in the source.
|
||||||
|
fn fenced(text: &str, node: &Node, code: &Code) -> bool {
|
||||||
|
code.lang.is_some()
|
||||||
|
|| code.value.is_empty()
|
||||||
|
|| text[span(node)].lines().count() > code.value.lines().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the line before `line` holds text inside `outer`.
|
||||||
|
fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
|
||||||
|
super::prev_line(text, line).is_some_and(|prev| {
|
||||||
|
inside(text, prev, outer).is_none_or(|at| !text[at..line_end(text, prev)].trim().is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each marker as `kind`'s from `start`; lines follow.
|
||||||
|
fn retype(
|
||||||
|
text: &str,
|
||||||
|
node: &Node,
|
||||||
|
list: &List,
|
||||||
|
outer: &[&Node],
|
||||||
|
kind: BlockCommand,
|
||||||
|
start: u32,
|
||||||
|
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let bullet = match list.ordered {
|
||||||
|
true => '-',
|
||||||
|
false => text[span(node).start..].chars().next().unwrap_or('-'),
|
||||||
|
};
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let task = kind == BlockCommand::TaskList;
|
||||||
|
for (n, item) in list.children.iter().enumerate() {
|
||||||
|
let number = start + n as u32;
|
||||||
|
let at = span(item).start;
|
||||||
|
let (mark, spaces) = super::marker(text, item);
|
||||||
|
let first = item.children().and_then(|kids| kids.first());
|
||||||
|
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||||
|
let body = at + mark + spaces;
|
||||||
|
let blank = body == line_end(text, at);
|
||||||
|
if boxed {
|
||||||
|
// A box stays when the kind keeps boxes.
|
||||||
|
let kind = if task { BlockCommand::BulletList } else { kind };
|
||||||
|
let end = if blank { opener(text, item) } else { body };
|
||||||
|
edits.push((at..end, marker(kind, number, bullet)));
|
||||||
|
if !task {
|
||||||
|
let kid = span(first.expect("a boxed item has text")).start;
|
||||||
|
edits.push((unbox(text, kid), String::new()));
|
||||||
|
edits.extend(escaped(text, kid)?);
|
||||||
|
}
|
||||||
|
} else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) {
|
||||||
|
// GFM boxes only a paragraph.
|
||||||
|
return Err(refusal(kid));
|
||||||
|
} else if blank {
|
||||||
|
edits.push((at..opener(text, item), marker(kind, number, bullet)));
|
||||||
|
} else {
|
||||||
|
// Indented code is written again from its value.
|
||||||
|
let pad = match first {
|
||||||
|
Some(kid @ Node::Code(code)) if !fenced(text, kid, code) => {
|
||||||
|
let line = code.value.lines().next().unwrap_or_default();
|
||||||
|
4 + line.len() - line.trim_start_matches(' ').len()
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
edits.push((at..body, marker(kind, number, bullet) + &" ".repeat(pad)));
|
||||||
|
}
|
||||||
|
let want = column(text, at) + width(kind, number);
|
||||||
|
for (from, cut, past) in continued(text, item, outer) {
|
||||||
|
let pad = want.saturating_sub(column(text, from)) + past;
|
||||||
|
edits.push((from..cut, " ".repeat(pad)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each paragraph line, and each other block, made an item.
|
||||||
|
fn wrap(
|
||||||
|
here: &Here,
|
||||||
|
k: usize,
|
||||||
|
blocks: &[&Node],
|
||||||
|
command: BlockCommand,
|
||||||
|
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let outer = here.containers(k + 1);
|
||||||
|
let lead = prefix(text, &outer);
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut n = 1;
|
||||||
|
for block in blocks {
|
||||||
|
if let Node::List(list) = block {
|
||||||
|
if kind(list) != command {
|
||||||
|
edits.extend(retype(text, block, list, &outer, command, n)?);
|
||||||
|
}
|
||||||
|
n += list.children.len() as u32;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if command == BlockCommand::TaskList && !matches!(block, Node::Paragraph(_)) {
|
||||||
|
return Err(refusal(block));
|
||||||
|
}
|
||||||
|
let whole = span(block);
|
||||||
|
let mut line = line_start(text, whole.start);
|
||||||
|
while line <= whole.end {
|
||||||
|
let first = line == line_start(text, whole.start);
|
||||||
|
let (at, lazy) = match first {
|
||||||
|
true => (Some(whole.start), ""),
|
||||||
|
false => match inside(text, line, &outer) {
|
||||||
|
Some(at) => (Some(at), ""),
|
||||||
|
None => (None, lead.as_str()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let at = at.unwrap_or(line);
|
||||||
|
let content = at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len();
|
||||||
|
if !text[at..line_end(text, line)].trim().is_empty() {
|
||||||
|
let mark = match first || matches!(block, Node::Paragraph(_)) {
|
||||||
|
true => {
|
||||||
|
n += 1;
|
||||||
|
marker(command, n - 1, '-')
|
||||||
|
}
|
||||||
|
false => " ".repeat(width(command, n - 1)),
|
||||||
|
};
|
||||||
|
let at = if matches!(block, Node::Paragraph(_)) {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
at
|
||||||
|
};
|
||||||
|
edits.push((at..at, format!("{lazy}{mark}")));
|
||||||
|
}
|
||||||
|
match next_line(text, line) {
|
||||||
|
Some(next) => line = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! Quotes on and off, as Typora's `toggleIndent`.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Here, change, check, eol, inside, line_end, line_start, next_line, prefix, split::content,
|
||||||
|
task_head,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
alert,
|
||||||
|
document::format::{Change, EditRefusal},
|
||||||
|
markdown::{file_tree, span},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
|
||||||
|
let caret = here.selection.clone();
|
||||||
|
let ix = match here.leaf() {
|
||||||
|
Some(_) => here.path.len() - 2,
|
||||||
|
None => here.path.len() - 1,
|
||||||
|
};
|
||||||
|
if let Node::Blockquote(_) = here.path[ix]
|
||||||
|
&& whole(here, ix)
|
||||||
|
{
|
||||||
|
return unwrap(here, ix).map(|edits| change(edits, caret));
|
||||||
|
}
|
||||||
|
if here.leaf().is_none() && here.selection.is_empty() {
|
||||||
|
if !here.blank() {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
let line = line_start(here.text, caret.start);
|
||||||
|
let at = inside(here.text, line, &here.containers(here.path.len()))
|
||||||
|
.expect("a blank line holds its prefix");
|
||||||
|
return Ok(change(vec![(at..at, "> ".to_string())], caret));
|
||||||
|
}
|
||||||
|
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
|
||||||
|
// GFM reads a task's first line as text.
|
||||||
|
if task_head(here.path[k], blocks[0]) {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
Ok(change(wrap(here, k, &blocks), caret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the selection takes in the whole quote at `ix`.
|
||||||
|
fn whole(here: &Here, ix: usize) -> bool {
|
||||||
|
if here.selection.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let quote = here.path[ix];
|
||||||
|
let end = here.path_at(here.selection.end);
|
||||||
|
let kids = quote.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
let first = here.path.get(ix + 1).copied();
|
||||||
|
let last = end.get(ix + 1).copied();
|
||||||
|
end.get(ix).is_some_and(|node| std::ptr::eq(*node, quote))
|
||||||
|
&& first.is_some_and(|first| kids.first().is_some_and(|kid| std::ptr::eq(kid, first)))
|
||||||
|
&& last.is_some_and(|last| kids.last().is_some_and(|kid| std::ptr::eq(kid, last)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `>` off each line of the quote at `ix`.
|
||||||
|
fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let quote = here.path[ix];
|
||||||
|
let whole = span(quote);
|
||||||
|
let outer = here.containers(ix);
|
||||||
|
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut line = line_start(text, whole.start);
|
||||||
|
if here.joins_before(&whole, &[quote]) {
|
||||||
|
edits.push((line..line, blank.clone()));
|
||||||
|
}
|
||||||
|
if let Node::Blockquote(inner) = quote
|
||||||
|
&& alert(inner).is_some()
|
||||||
|
{
|
||||||
|
if !marker_only(&text[whole.start..line_end(text, line)]) {
|
||||||
|
return Err(check::misread("an alert marker shares its line"));
|
||||||
|
}
|
||||||
|
// A body block keeps its indent; text sheds it.
|
||||||
|
let continues = inner
|
||||||
|
.children
|
||||||
|
.first()
|
||||||
|
.is_some_and(|head| span(head).end > line_end(text, line));
|
||||||
|
// The marker goes with blank lines; an outer opener stays.
|
||||||
|
let (mut cut, mut rest) = (whole.end, whole.end);
|
||||||
|
let mut next = next_line(text, line);
|
||||||
|
while let Some(at) = next.filter(|at| *at < whole.end) {
|
||||||
|
let lead = match mark(text, at, &outer) {
|
||||||
|
Some(mark) => mark.end,
|
||||||
|
None => content(text, at, &outer),
|
||||||
|
};
|
||||||
|
let body =
|
||||||
|
lead + text[lead..].len() - text[lead..].trim_start_matches([' ', '\t']).len();
|
||||||
|
if body < line_end(text, at) {
|
||||||
|
cut = if continues { body } else { lead };
|
||||||
|
rest = next_line(text, at).unwrap_or(text.len());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
next = next_line(text, at);
|
||||||
|
}
|
||||||
|
edits.push((whole.start..cut, String::new()));
|
||||||
|
line = rest;
|
||||||
|
}
|
||||||
|
while line < whole.end {
|
||||||
|
if let Some(mark) = mark(text, line, &outer) {
|
||||||
|
edits.push((mark, String::new()));
|
||||||
|
}
|
||||||
|
match next_line(text, line) {
|
||||||
|
Some(next) => line = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if here.joins_after(&whole) {
|
||||||
|
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||||
|
edits.push((next..next, blank.clone()));
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `line`, a quote's first, holds only an alert marker.
|
||||||
|
fn marker_only(line: &str) -> bool {
|
||||||
|
let root = file_tree(&format!("{line}\n> x"));
|
||||||
|
let Some(Node::Blockquote(quote)) = root.children().and_then(|kids| kids.first()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(alert(quote), Some((_, body)) if matches!(&body[..],
|
||||||
|
[Node::Paragraph(head)] if matches!(&head.children[..],
|
||||||
|
[Node::Text(text)] if text.value == "x")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `line`'s `>` and one space after, inside `outer`.
|
||||||
|
fn mark(text: &str, line: usize, outer: &[&Node]) -> Option<Range<usize>> {
|
||||||
|
let at = inside(text, line, outer)?;
|
||||||
|
let end = line_end(text, line);
|
||||||
|
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
|
||||||
|
let mark = at + lead;
|
||||||
|
(lead <= 3 && text[mark..end].starts_with('>')).then(|| {
|
||||||
|
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
|
||||||
|
mark..mark + 1 + space
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `> ` before each line of `blocks`, held by `path[k]`.
|
||||||
|
fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range<usize>, String)> {
|
||||||
|
let text = here.text;
|
||||||
|
let outer = here.containers(k + 1);
|
||||||
|
let first = span(blocks[0]).start;
|
||||||
|
let last = span(blocks[blocks.len() - 1]).end;
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut line = line_start(text, first);
|
||||||
|
while line <= last {
|
||||||
|
let at = match line == line_start(text, first) {
|
||||||
|
true => Some(first),
|
||||||
|
false => inside(text, line, &outer),
|
||||||
|
};
|
||||||
|
// A lazy line stays in its paragraph.
|
||||||
|
if let Some(at) = at {
|
||||||
|
let blank = text[at..line_end(text, line)].trim().is_empty();
|
||||||
|
edits.push((at..at, if blank { ">" } else { "> " }.to_string()));
|
||||||
|
}
|
||||||
|
match next_line(text, line) {
|
||||||
|
Some(next) => line = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edits
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! A paragraph or heading split, as Typora's `splitAt`.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Here, check, closing, inside, line_end, line_start, next_line, placed, prev_line, shifted,
|
||||||
|
task_head, unblocked,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
document::format::{Change, EditRefusal, marks},
|
||||||
|
markdown::span,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Where the new block's text goes, ending the edit.
|
||||||
|
pub(super) struct Room<'a> {
|
||||||
|
pub(super) nl: &'static str,
|
||||||
|
pub(super) lead: String,
|
||||||
|
pub(super) blank: String,
|
||||||
|
pub(super) containers: Vec<&'a Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Room<'_> {
|
||||||
|
/// A blank line, then a new line's prefix.
|
||||||
|
pub(super) fn gap(&self) -> String {
|
||||||
|
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `line`'s text starts inside `containers`; a lazy line's own.
|
||||||
|
pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
|
||||||
|
let at = inside(text, line, containers).unwrap_or(line);
|
||||||
|
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back from `at` over blanks and prefixes, to `floor`.
|
||||||
|
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
|
||||||
|
loop {
|
||||||
|
let line = line_start(text, at).max(floor);
|
||||||
|
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
|
||||||
|
if line == floor || at > content(text, line, containers) {
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On from `at` over blanks and prefixes, to `ceil`.
|
||||||
|
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
|
||||||
|
loop {
|
||||||
|
let end = line_end(text, at).min(ceil);
|
||||||
|
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
|
||||||
|
if at < end {
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
match next_line(text, at) {
|
||||||
|
Some(next) if next < ceil => at = content(text, next, containers),
|
||||||
|
_ => return ceil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline nodes with no text to cut.
|
||||||
|
fn sealed(node: &Node) -> bool {
|
||||||
|
matches!(
|
||||||
|
node,
|
||||||
|
Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `at` off inline edges; marks to close, then reopen.
|
||||||
|
fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let marked: Vec<(&Node, [Range<usize>; 2])> = (here.nodes.iter())
|
||||||
|
.filter_map(|node| Some((*node, marks(node, text)?)))
|
||||||
|
.collect();
|
||||||
|
while let Some([open, _]) = (marked.iter())
|
||||||
|
.map(|(_, holes)| holes)
|
||||||
|
.find(|[open, _]| open.start < at && open.end == at)
|
||||||
|
{
|
||||||
|
at = open.start;
|
||||||
|
}
|
||||||
|
while let Some([_, close]) = (marked.iter())
|
||||||
|
.map(|(_, holes)| holes)
|
||||||
|
.find(|[_, close]| close.start == at && at < close.end)
|
||||||
|
{
|
||||||
|
at = close.end;
|
||||||
|
}
|
||||||
|
let strictly = |range: Range<usize>| range.start < at && at < range.end;
|
||||||
|
let mut cut: Vec<&(&Node, [Range<usize>; 2])> = (marked.iter())
|
||||||
|
.filter(|(node, _)| strictly(span(node)))
|
||||||
|
.collect();
|
||||||
|
let paired =
|
||||||
|
|node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_));
|
||||||
|
let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node)))
|
||||||
|
|| (cut.iter()).any(|(node, [open, close])| {
|
||||||
|
!paired(node) || strictly(open.clone()) || strictly(close.clone())
|
||||||
|
});
|
||||||
|
if unsafe_cut {
|
||||||
|
return Err(EditRefusal::Across);
|
||||||
|
}
|
||||||
|
cut.sort_by_key(|(node, _)| span(node).start);
|
||||||
|
let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]);
|
||||||
|
let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]);
|
||||||
|
Ok((at, close.collect(), open.collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `block` put where the selection splits `node`.
|
||||||
|
pub(super) fn split(
|
||||||
|
here: &Here,
|
||||||
|
node: &Node,
|
||||||
|
room: &Room,
|
||||||
|
block: &str,
|
||||||
|
inner: Option<usize>,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
let text = here.text;
|
||||||
|
let whole = span(node);
|
||||||
|
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
let (marks, body) = match node {
|
||||||
|
Node::Heading(heading) => (
|
||||||
|
"#".repeat(usize::from(heading.depth)) + " ",
|
||||||
|
kids.first().map_or(whole.end, |kid| span(kid).start)
|
||||||
|
..kids.last().map_or(whole.end, |kid| span(kid).end),
|
||||||
|
),
|
||||||
|
_ => (String::new(), whole.clone()),
|
||||||
|
};
|
||||||
|
// A caret in heading marks takes the text's edge.
|
||||||
|
let s = here.selection.start.clamp(body.start, body.end);
|
||||||
|
let e = here.selection.end.clamp(body.start, body.end);
|
||||||
|
let (s, close, _) = seam(here, s)?;
|
||||||
|
let (e, _, open) = seam(here, e)?;
|
||||||
|
let containers = &room.containers;
|
||||||
|
let mut from = back(text, s, body.start, containers);
|
||||||
|
let after = forth(text, e, body.end, containers);
|
||||||
|
let before = from > body.start;
|
||||||
|
let follows = after < body.end;
|
||||||
|
// GFM reads a task's first line as text.
|
||||||
|
if !before && task_head(here.path[here.path.len() - 2], node) {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
if !before {
|
||||||
|
from = whole.start;
|
||||||
|
}
|
||||||
|
let to = if follows { after } else { whole.end };
|
||||||
|
let gap = room.gap();
|
||||||
|
let mut written = String::new();
|
||||||
|
if before {
|
||||||
|
written.push_str(&close);
|
||||||
|
written.push_str(&gap);
|
||||||
|
}
|
||||||
|
let start = written.len();
|
||||||
|
written.push_str(block);
|
||||||
|
let lead = if follows { marks.len() + open.len() } else { 0 };
|
||||||
|
let caret = match inner {
|
||||||
|
Some(inner) => start + inner,
|
||||||
|
None => {
|
||||||
|
written.push_str(&gap);
|
||||||
|
written.len() + lead
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if follows {
|
||||||
|
if inner.is_some() {
|
||||||
|
written.push_str(&gap);
|
||||||
|
}
|
||||||
|
written.push_str(&marks);
|
||||||
|
written.push_str(&open);
|
||||||
|
}
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
let mut shift = 0;
|
||||||
|
if !before && here.joins_before(&whole, &[]) {
|
||||||
|
let line = line_start(text, whole.start);
|
||||||
|
let blank = format!("{}{}", room.blank, room.nl);
|
||||||
|
shift = blank.len();
|
||||||
|
edits.push((line..line, blank));
|
||||||
|
}
|
||||||
|
edits.push((from..to, written));
|
||||||
|
// A heading's cut end must not close it.
|
||||||
|
let line = line_start(text, from);
|
||||||
|
if before
|
||||||
|
&& close.is_empty()
|
||||||
|
&& matches!(node, Node::Heading(_))
|
||||||
|
&& let Some(run) = closing(&text[line..from])
|
||||||
|
{
|
||||||
|
edits.push((line + run..line + run, "\\".to_string()));
|
||||||
|
shift += 1;
|
||||||
|
}
|
||||||
|
// The rest must not open a block.
|
||||||
|
if follows
|
||||||
|
&& marks.is_empty()
|
||||||
|
&& open.is_empty()
|
||||||
|
&& let Some(mark) = unblocked(&text[after..line_end(text, after)])?
|
||||||
|
{
|
||||||
|
edits.push((after + mark..after + mark, "\\".to_string()));
|
||||||
|
}
|
||||||
|
let change = placed(edits, from + shift + caret);
|
||||||
|
let edits = &change.0;
|
||||||
|
let (written, after_root) = check::reread(text, edits);
|
||||||
|
let now = check::Shown::new(&written, &after_root);
|
||||||
|
let level = match node {
|
||||||
|
Node::Heading(heading) => heading.depth,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let piece = |at: usize| {
|
||||||
|
now.at(shifted(at, edits))
|
||||||
|
.and_then(|(got, chars)| (got == level).then_some(chars))
|
||||||
|
};
|
||||||
|
let head = if before {
|
||||||
|
piece(from - 1)
|
||||||
|
} else {
|
||||||
|
Some(Vec::new())
|
||||||
|
};
|
||||||
|
let tail = if follows { piece(to) } else { Some(Vec::new()) };
|
||||||
|
let want = check::Shown::new(text, here.root).inline(node);
|
||||||
|
let kept = head
|
||||||
|
.zip(tail)
|
||||||
|
.is_some_and(|(head, tail)| match here.selection.is_empty() {
|
||||||
|
true => [head, tail].concat() == want,
|
||||||
|
false => {
|
||||||
|
want.starts_with(&head)
|
||||||
|
&& want.ends_with(&tail)
|
||||||
|
&& head.len() + tail.len() <= want.len()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match kept {
|
||||||
|
true => Ok(change),
|
||||||
|
false => Err(check::misread("a split would reread otherwise")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! A table cell opens alone, as Typora edits tables.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use crate::documents::{
|
||||||
|
markdown::{file_tree, span, walk},
|
||||||
|
render::CellAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A cell: which, its source, its caret.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct Cell {
|
||||||
|
pub at: CellAt,
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub caret: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What names a cell in a block.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(super) enum Aim {
|
||||||
|
/// The cell holding this byte of the text.
|
||||||
|
Byte(usize),
|
||||||
|
/// This cell, its caret at the end.
|
||||||
|
At(CellAt),
|
||||||
|
First,
|
||||||
|
Last,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A way out of a cell.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) enum Move {
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows of each table in `root`, in drawing order.
|
||||||
|
fn tables(root: &Node) -> Vec<Vec<&Node>> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(root, &mut nodes);
|
||||||
|
nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|node| matches!(node, Node::Table(_)))
|
||||||
|
.map(|table| {
|
||||||
|
table
|
||||||
|
.children()
|
||||||
|
.expect("a table holds rows")
|
||||||
|
.iter()
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cells(row: &Node) -> &[Node] {
|
||||||
|
row.children().expect("a row holds cells")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell `aim` names in `block` of `text`.
|
||||||
|
pub(super) fn find(text: &str, block: Range<usize>, aim: Aim) -> Option<Cell> {
|
||||||
|
let root = file_tree(&text[block.clone()]);
|
||||||
|
let tables = tables(&root);
|
||||||
|
let at = match aim {
|
||||||
|
Aim::At(at) => at,
|
||||||
|
Aim::First if !tables.is_empty() => CellAt {
|
||||||
|
table: 0,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
},
|
||||||
|
Aim::Last => {
|
||||||
|
let table = tables.len().checked_sub(1)?;
|
||||||
|
let row = tables[table].len() - 1;
|
||||||
|
let column = cells(tables[table][row]).len().checked_sub(1)?;
|
||||||
|
CellAt { table, row, column }
|
||||||
|
}
|
||||||
|
Aim::First => return None,
|
||||||
|
Aim::Byte(byte) => {
|
||||||
|
let byte = byte.checked_sub(block.start)?;
|
||||||
|
let table = (tables.iter()).position(|rows| {
|
||||||
|
let (first, last) = (rows[0], rows[rows.len() - 1]);
|
||||||
|
(span(first).start..=span(last).end).contains(&byte)
|
||||||
|
})?;
|
||||||
|
let rows = &tables[table];
|
||||||
|
let row = rows.iter().rposition(|row| span(row).start <= byte)?;
|
||||||
|
let column = (cells(rows[row]).iter()).rposition(|cell| span(cell).start <= byte)?;
|
||||||
|
CellAt { table, row, column }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let cell = cells(tables.get(at.table)?.get(at.row)?).get(at.column)?;
|
||||||
|
let range = written(&text[block.clone()], cell);
|
||||||
|
let caret = match aim {
|
||||||
|
Aim::Byte(byte) => (byte - block.start).clamp(range.start, range.end) - range.start,
|
||||||
|
_ => range.len(),
|
||||||
|
};
|
||||||
|
Some(Cell {
|
||||||
|
at,
|
||||||
|
range: block.start + range.start..block.start + range.end,
|
||||||
|
caret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell `by` reaches from `at`; none past the edge.
|
||||||
|
pub(super) fn beside(text: &str, block: Range<usize>, at: CellAt, by: Move) -> Option<CellAt> {
|
||||||
|
let root = file_tree(&text[block]);
|
||||||
|
let tables = tables(&root);
|
||||||
|
let rows = tables.get(at.table)?;
|
||||||
|
let width = |row: usize| rows.get(row).map_or(0, |row| cells(row).len());
|
||||||
|
let (row, column) = match by {
|
||||||
|
Move::Next if at.column + 1 < width(at.row) => (at.row, at.column + 1),
|
||||||
|
Move::Next => (at.row + 1, 0),
|
||||||
|
Move::Previous if at.column > 0 => (at.row, at.column - 1),
|
||||||
|
Move::Previous => {
|
||||||
|
let row = at.row.checked_sub(1)?;
|
||||||
|
(row, width(row).checked_sub(1)?)
|
||||||
|
}
|
||||||
|
Move::Up => (at.row.checked_sub(1)?, at.column),
|
||||||
|
Move::Down => (at.row + 1, at.column),
|
||||||
|
};
|
||||||
|
let column = column.min(width(row).checked_sub(1)?);
|
||||||
|
Some(CellAt {
|
||||||
|
table: at.table,
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell's source; empty, just past its opening pipe.
|
||||||
|
fn written(text: &str, cell: &Node) -> Range<usize> {
|
||||||
|
let inside = cell.children().expect("a cell holds inlines");
|
||||||
|
if let (Some(first), Some(last)) = (inside.first(), inside.last()) {
|
||||||
|
return span(first).start..span(last).end;
|
||||||
|
}
|
||||||
|
let span = span(cell);
|
||||||
|
let mut at = span.start;
|
||||||
|
if text[at..span.end].starts_with('|') {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
if text[at..span.end].starts_with([' ', '\t']) {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
at..at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `typed` with each bare `|` escaped.
|
||||||
|
fn guarded(typed: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(typed.len());
|
||||||
|
let mut slashes = 0;
|
||||||
|
for ch in typed.chars() {
|
||||||
|
if ch == '|' && slashes % 2 == 0 {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
slashes = if ch == '\\' { slashes + 1 } else { 0 };
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What borders a cell's source in its row.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(super) struct Edges {
|
||||||
|
/// A pipe follows at once.
|
||||||
|
closed: bool,
|
||||||
|
/// No pipe before it on its line.
|
||||||
|
first: bool,
|
||||||
|
/// No pipe after it on its line.
|
||||||
|
last: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The borders of the cell source at `range` of `text`.
|
||||||
|
pub(super) fn edges(text: &str, range: Range<usize>) -> Edges {
|
||||||
|
let line = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
||||||
|
let end = (text[range.end..].find('\n')).map_or(text.len(), |at| range.end + at);
|
||||||
|
let (before, after) = (&text[line..range.start], &text[range.end..end]);
|
||||||
|
Edges {
|
||||||
|
closed: after.starts_with('|'),
|
||||||
|
first: !before.contains('|'),
|
||||||
|
last: !after.contains('|'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `typed` as a cell keeps it, its row's pipes intact.
|
||||||
|
pub(super) fn kept(typed: &str, edges: Edges) -> String {
|
||||||
|
let mut out = guarded(typed);
|
||||||
|
let slashes = out.len() - out.trim_end_matches('\\').len();
|
||||||
|
if edges.closed && slashes % 2 == 1 {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
// An emptied bare edge cell keeps its column.
|
||||||
|
if out.is_empty() && (edges.first || edges.last) {
|
||||||
|
out.push('|');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte `at` of `typed` within `kept(typed, edges)`.
|
||||||
|
pub(super) fn kept_at(typed: &str, at: usize, edges: Edges) -> usize {
|
||||||
|
match at == typed.len() {
|
||||||
|
true if typed.is_empty() && !edges.first => 0,
|
||||||
|
true => kept(typed, edges).len(),
|
||||||
|
false => guarded(&typed[..at]).len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Aim, CellAt, Edges, Move, beside, edges, find, guarded, kept, kept_at};
|
||||||
|
|
||||||
|
const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n";
|
||||||
|
|
||||||
|
fn block() -> std::ops::Range<usize> {
|
||||||
|
7..TABLE.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(row: usize, column: usize) -> CellAt {
|
||||||
|
CellAt {
|
||||||
|
table: 0,
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_byte_opens_its_cell_with_the_caret_there() {
|
||||||
|
let byte = TABLE.find("**b**").expect("in the table") + 2;
|
||||||
|
let cell = find(TABLE, block(), Aim::Byte(byte)).expect("a cell");
|
||||||
|
assert_eq!(cell.at, at(0, 1));
|
||||||
|
assert_eq!(&TABLE[cell.range.clone()], "**b**");
|
||||||
|
assert_eq!(cell.caret, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_cell_opens_between_its_pipes() {
|
||||||
|
let cell = find(TABLE, block(), Aim::At(at(1, 1))).expect("a cell");
|
||||||
|
assert!(cell.range.is_empty());
|
||||||
|
let written = [&TABLE[..cell.range.start], "x", &TABLE[cell.range.end..]].concat();
|
||||||
|
assert!(written.contains("| c | x |"), "{written:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ends_open_the_first_and_last_cells() {
|
||||||
|
let first = find(TABLE, block(), Aim::First).expect("a cell");
|
||||||
|
assert_eq!((first.at, &TABLE[first.range]), (at(0, 0), "a"));
|
||||||
|
let last = find(TABLE, block(), Aim::Last).expect("a cell");
|
||||||
|
assert_eq!((last.at, &TABLE[last.range]), (at(2, 1), "e"));
|
||||||
|
assert_eq!(find("plain\n", 0..5, Aim::First), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_wrap_rows_and_stop_at_edges() {
|
||||||
|
let to = |from, by| beside(TABLE, block(), from, by);
|
||||||
|
assert_eq!(to(at(0, 1), Move::Next), Some(at(1, 0)));
|
||||||
|
assert_eq!(to(at(1, 0), Move::Previous), Some(at(0, 1)));
|
||||||
|
assert_eq!(to(at(0, 0), Move::Previous), None);
|
||||||
|
assert_eq!(to(at(2, 1), Move::Next), None);
|
||||||
|
assert_eq!(to(at(1, 1), Move::Up), Some(at(0, 1)));
|
||||||
|
assert_eq!(to(at(2, 0), Move::Down), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_pipes_escape_and_escaped_stay() {
|
||||||
|
assert_eq!(guarded("a|b"), "a\\|b");
|
||||||
|
assert_eq!(guarded("a\\|b"), "a\\|b");
|
||||||
|
assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cell `(row, column)` of `text`'s one table, written as `typed`.
|
||||||
|
fn write(text: &str, row: usize, column: usize, typed: &str) -> String {
|
||||||
|
let block = 0..text.len() - 1;
|
||||||
|
let cell = find(text, block, Aim::At(at(row, column))).expect("a cell");
|
||||||
|
let kept = kept(typed, edges(text, cell.range.clone()));
|
||||||
|
[&text[..cell.range.start], &kept, &text[cell.range.end..]].concat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source of each cell of `text`'s one table.
|
||||||
|
fn grid(text: &str) -> Vec<Vec<String>> {
|
||||||
|
let block = 0..text.len() - 1;
|
||||||
|
(0..)
|
||||||
|
.map_while(|row| {
|
||||||
|
let cells: Vec<String> = (0..)
|
||||||
|
.map_while(|column| {
|
||||||
|
let cell = find(text, block.clone(), Aim::At(at(row, column)))?;
|
||||||
|
Some(text[cell.range].to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(!cells.is_empty()).then_some(cells)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closing_backslash_keeps_the_next_pipe() {
|
||||||
|
let shut = Edges {
|
||||||
|
closed: true,
|
||||||
|
first: false,
|
||||||
|
last: false,
|
||||||
|
};
|
||||||
|
assert_eq!(kept("a\\", shut), "a\\\\");
|
||||||
|
assert_eq!(kept("a\\\\", shut), "a\\\\");
|
||||||
|
assert_eq!(kept_at("a\\", 2, shut), 3);
|
||||||
|
assert_eq!(kept_at("a\\b", 2, shut), 2);
|
||||||
|
let written = write("|a|b|\n|---|---|\n|c|d|\n", 0, 0, "a\\");
|
||||||
|
assert_eq!(grid(&written)[0], ["a\\\\", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_backslash_before_padding_stays_single() {
|
||||||
|
let text = "| a\\ | b |\n| --- | --- |\n";
|
||||||
|
assert_eq!(write(text, 0, 0, "a\\"), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_emptied_bare_edge_cell_keeps_its_column() {
|
||||||
|
let text = "alpha | beta\n--- | ---\nc | d\n";
|
||||||
|
let rows = |written: &str| grid(written).iter().map(Vec::len).collect::<Vec<_>>();
|
||||||
|
for (row, column) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||||
|
let written = write(text, row, column, "");
|
||||||
|
assert_eq!(rows(&written), [2, 2], "{written:?}");
|
||||||
|
assert_eq!(grid(&written)[row][column], "", "{written:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(grid(&write(text, 1, 0, ""))[1], ["", "d"]);
|
||||||
|
let empty = edges(text, 0..5);
|
||||||
|
assert_eq!(kept_at("", 0, empty), 1, "past its new pipe");
|
||||||
|
let last = edges(text, 8..12);
|
||||||
|
assert_eq!(kept_at("", 0, last), 0, "before its new pipe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_pipes_shift_later_bytes() {
|
||||||
|
let open = Edges {
|
||||||
|
closed: false,
|
||||||
|
first: false,
|
||||||
|
last: false,
|
||||||
|
};
|
||||||
|
assert_eq!(kept_at("||x", 2, open), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
-21
@@ -2,7 +2,10 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
||||||
|
|
||||||
use super::{DocumentEvent, MarkdownDocument, Open};
|
use super::{
|
||||||
|
DocumentEvent, MarkdownDocument, Open, TargetBlock,
|
||||||
|
cells::{self, Aim, Move},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::{
|
documents::{
|
||||||
MarkdownMode,
|
MarkdownMode,
|
||||||
@@ -10,13 +13,36 @@ use crate::{
|
|||||||
modes::{at_edge, joint},
|
modes::{at_edge, joint},
|
||||||
},
|
},
|
||||||
forms::{InputEvent, TextInput},
|
forms::{InputEvent, TextInput},
|
||||||
|
primitives::lent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Where an opened block's caret lands.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(super) enum Landing {
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
/// A source byte of the drawn read.
|
||||||
|
At(usize),
|
||||||
|
/// A table cell, by byte or place.
|
||||||
|
Cell(Aim),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Landing {
|
||||||
|
/// The same landing after `edit`, if it survives.
|
||||||
|
fn moved(self, edit: &Option<(Range<usize>, usize)>) -> Option<Self> {
|
||||||
|
Some(match self {
|
||||||
|
Self::At(at) => Self::At(moved(at, edit)?),
|
||||||
|
Self::Cell(Aim::Byte(at)) => Self::Cell(Aim::Byte(moved(at, edit)?)),
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where byte `at` lies after `edit`, if it survives.
|
/// Where byte `at` lies after `edit`, if it survives.
|
||||||
fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
||||||
match edit {
|
match edit {
|
||||||
None => Some(at),
|
None => Some(at),
|
||||||
Some((replaced, _)) if at < replaced.start => Some(at),
|
Some((replaced, _)) if at <= replaced.start => Some(at),
|
||||||
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
||||||
Some(_) => None,
|
Some(_) => None,
|
||||||
}
|
}
|
||||||
@@ -25,9 +51,12 @@ fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
|||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
/// An empty, read visual document takes typing at once.
|
/// An empty, read visual document takes typing at once.
|
||||||
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if std::mem::take(&mut self.pointing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
|
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
|
||||||
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
|
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
|
||||||
self.open(None, false, window, cx);
|
self.open(None, Landing::End, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +76,11 @@ impl MarkdownDocument {
|
|||||||
pub(super) fn open(
|
pub(super) fn open(
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: Option<usize>,
|
ix: Option<usize>,
|
||||||
at_start: bool,
|
landing: Landing,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
self.waiting = None;
|
||||||
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||||
pressed => pressed.flatten().map(|block| block.range.start),
|
pressed => pressed.flatten().map(|block| block.range.start),
|
||||||
@@ -59,12 +89,25 @@ impl MarkdownDocument {
|
|||||||
if written.is_some() {
|
if written.is_some() {
|
||||||
self.reparse(true, cx);
|
self.reparse(true, cx);
|
||||||
}
|
}
|
||||||
|
if !self.read_now(cx) && written.is_some() {
|
||||||
|
// A long write-back reads first; it opens after.
|
||||||
|
let start = match pressed {
|
||||||
|
None => Some(None),
|
||||||
|
Some(start) => moved(start, &written).map(Some),
|
||||||
|
};
|
||||||
|
let (Some(start), Some(landing)) = (start, landing.moved(&written)) else {
|
||||||
|
return log::warn!("markdown document: the pressed block was written over");
|
||||||
|
};
|
||||||
|
log::info!("markdown document: a block opens once its text is read");
|
||||||
|
self.waiting = Some((start, landing, self.edits + self.writes as u64));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !self.read_now(cx) {
|
if !self.read_now(cx) {
|
||||||
return log::warn!("markdown document: pressed while the text is read again");
|
return log::warn!("markdown document: pressed while the text is read again");
|
||||||
}
|
}
|
||||||
let text = self.editor.read(cx).text();
|
let text = self.editor.read(cx).text();
|
||||||
let (range, append) = match pressed {
|
let (range, append, cell) = match pressed {
|
||||||
None => (text.len()..text.len(), true),
|
None => (text.len()..text.len(), true, None),
|
||||||
Some(start) => {
|
Some(start) => {
|
||||||
let found = moved(start, &written).and_then(|start| {
|
let found = moved(start, &written).and_then(|start| {
|
||||||
self.parsed
|
self.parsed
|
||||||
@@ -75,25 +118,65 @@ impl MarkdownDocument {
|
|||||||
let Some(block) = found else {
|
let Some(block) = found else {
|
||||||
return log::warn!("markdown document: the pressed block changed as one wrote");
|
return log::warn!("markdown document: the pressed block changed as one wrote");
|
||||||
};
|
};
|
||||||
(block.range.clone(), false)
|
let table = block.kind == TargetBlock::Table;
|
||||||
|
let aim = match landing {
|
||||||
|
Landing::Cell(Aim::Byte(at)) => match moved(at, &written) {
|
||||||
|
Some(at) => Some(Aim::Byte(at)),
|
||||||
|
None => {
|
||||||
|
return log::warn!(
|
||||||
|
"markdown document: the pressed cell was written over"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Landing::Cell(aim) => Some(aim),
|
||||||
|
Landing::Start if table => Some(Aim::First),
|
||||||
|
Landing::End if table => Some(Aim::Last),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match aim {
|
||||||
|
None => (block.range.clone(), false, None),
|
||||||
|
Some(aim) => match cells::find(text, block.range.clone(), aim) {
|
||||||
|
Some(cell) => (cell.range, false, Some((cell.at, cell.caret))),
|
||||||
|
None => {
|
||||||
|
return log::warn!("markdown document: no cell {aim:?} in the block");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let caret = match landing {
|
||||||
|
_ if cell.is_some() => cell.map(|(_, caret)| caret),
|
||||||
|
Landing::Start => Some(0),
|
||||||
|
Landing::End | Landing::Cell(_) => None,
|
||||||
|
Landing::At(at) => match moved(at, &written) {
|
||||||
|
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
|
||||||
|
_ => {
|
||||||
|
log::warn!("markdown document: the pressed byte left its block");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
let source = text[range.clone()].to_string();
|
let source = text[range.clone()].to_string();
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
TextInput::new(window, cx)
|
let input = TextInput::new(window, cx).highlighter(markdown_highlights);
|
||||||
.multi_line(1, usize::MAX)
|
match cell {
|
||||||
.highlighter(markdown_highlights)
|
Some(_) => input,
|
||||||
|
None => input.multi_line(1, usize::MAX),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
input.update(cx, |input, cx| {
|
input.update(cx, |input, cx| {
|
||||||
input.set_text(source, cx);
|
input.set_text(source, cx);
|
||||||
if at_start {
|
if let Some(caret) = caret {
|
||||||
input.select(0..0, cx);
|
input.select(caret..caret, cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.focus(&input.focus_handle(cx), cx);
|
window.focus(&input.focus_handle(cx), cx);
|
||||||
let subscriptions =
|
let subscriptions = vec![cx.subscribe_in(
|
||||||
vec![
|
&input,
|
||||||
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event {
|
window,
|
||||||
|
|document, _, event: &InputEvent, window, cx| {
|
||||||
|
match event {
|
||||||
|
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
|
||||||
InputEvent::Blur => {
|
InputEvent::Blur => {
|
||||||
document.commit(cx);
|
document.commit(cx);
|
||||||
}
|
}
|
||||||
@@ -101,30 +184,107 @@ impl MarkdownDocument {
|
|||||||
document.typed += 1;
|
document.typed += 1;
|
||||||
cx.emit(DocumentEvent::Changed)
|
cx.emit(DocumentEvent::Changed)
|
||||||
}
|
}
|
||||||
|
// Enter in a cell writes it back.
|
||||||
|
InputEvent::Submit
|
||||||
|
if document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|open| open.cell.is_some()) =>
|
||||||
|
{
|
||||||
|
document.commit(cx);
|
||||||
|
window.focus(&document.focus, cx);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}
|
||||||
];
|
},
|
||||||
log::info!("markdown document: block {range:?} opens");
|
)];
|
||||||
|
match cell {
|
||||||
|
Some((at, _)) => log::info!("markdown document: cell {at:?} at {range:?} opens"),
|
||||||
|
None => log::info!("markdown document: block {range:?} opens"),
|
||||||
|
}
|
||||||
self.open = Some(Open {
|
self.open = Some(Open {
|
||||||
range,
|
range,
|
||||||
input,
|
input,
|
||||||
append,
|
append,
|
||||||
|
cell: cell.map(|(at, _)| at),
|
||||||
|
lent: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
});
|
});
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A block asked open before its text was read, now opened.
|
||||||
|
pub(super) fn open_waiting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.waiting.is_none() || !self.read_now(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (start, landing, expected) = self.waiting.take().expect("checked above");
|
||||||
|
if self.edits != expected {
|
||||||
|
return log::info!("markdown document: a waiting block lapsed with its text");
|
||||||
|
}
|
||||||
|
let ix = match start {
|
||||||
|
None => None,
|
||||||
|
Some(start) => match (self.parsed.blocks.iter()).position(|b| b.range.start == start) {
|
||||||
|
Some(ix) => Some(ix),
|
||||||
|
None => return log::warn!("markdown document: the waiting block was read away"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.open(ix, landing, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the focus a dropped block still holds.
|
||||||
|
pub(super) fn take_orphan(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(orphan) = self.orphan.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.open.is_none() && orphan.is_focused(window) {
|
||||||
|
log::info!("markdown document: a dropped block's focus passes to the document");
|
||||||
|
window.focus(&self.focus_handle(cx), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the block open while an overlay holds focus.
|
||||||
|
fn lend(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let (Some(open), Some(holder)) = (self.open.as_mut(), window.focused(cx)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("markdown document: an overlay borrows the open block's focus");
|
||||||
|
let watch = cx.on_focus_out(&holder, window, |document, _, window, cx| {
|
||||||
|
let back = (document.open.as_ref())
|
||||||
|
.is_some_and(|open| open.input.focus_handle(cx).is_focused(window));
|
||||||
|
match (back, lent(window, cx)) {
|
||||||
|
(true, _) => {
|
||||||
|
if let Some(open) = document.open.as_mut() {
|
||||||
|
open.lent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, true) => document.lend(window, cx),
|
||||||
|
(false, false) => {
|
||||||
|
document.commit(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
open.lent = Some(watch);
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes the open block as one step; the edit made.
|
/// Writes the open block as one step; the edit made.
|
||||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
||||||
let open = self.open.take()?;
|
let open = self.open.take()?;
|
||||||
|
self.orphan = Some(open.input.focus_handle(cx));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
let typed = open.input.read(cx).text().to_string();
|
let typed = open.input.read(cx).text().to_string();
|
||||||
let caret = open.input.read(cx).cursor();
|
let caret = open.input.read(cx).cursor();
|
||||||
let text = self.editor.read(cx).text();
|
let text = self.editor.read(cx).text();
|
||||||
|
let edges = cells::edges(text, open.range.clone());
|
||||||
let (lead, written) = match open.append {
|
let (lead, written) = match open.append {
|
||||||
true if typed.is_empty() => return None,
|
true if typed.is_empty() => return None,
|
||||||
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
||||||
false => (0, typed),
|
false if open.cell.is_some() => (0, cells::kept(&typed, edges)),
|
||||||
|
false => (0, typed.clone()),
|
||||||
|
};
|
||||||
|
let caret = match open.cell {
|
||||||
|
Some(_) => cells::kept_at(&typed, caret, edges),
|
||||||
|
None => caret,
|
||||||
};
|
};
|
||||||
let at = open.range.start + lead + caret;
|
let at = open.range.start + lead + caret;
|
||||||
let edit = (text[open.range.clone()] != written).then(|| {
|
let edit = (text[open.range.clone()] != written).then(|| {
|
||||||
@@ -159,6 +319,15 @@ impl MarkdownDocument {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let here = self.block_at(open.range.start);
|
let here = self.block_at(open.range.start);
|
||||||
|
if let Some(at) = open.cell {
|
||||||
|
let block = self.parsed.blocks[here].range.clone();
|
||||||
|
let by = if down { Move::Down } else { Move::Up };
|
||||||
|
let text = self.editor.read(cx).text();
|
||||||
|
if let Some(to) = cells::beside(text, block, at, by) {
|
||||||
|
self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
let next = match down {
|
let next = match down {
|
||||||
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
||||||
false => here.checked_sub(1),
|
false => here.checked_sub(1),
|
||||||
@@ -166,7 +335,8 @@ impl MarkdownDocument {
|
|||||||
let Some(next) = next else {
|
let Some(next) = next else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.open(Some(next), down, window, cx);
|
let landing = if down { Landing::Start } else { Landing::End };
|
||||||
|
self.open(Some(next), landing, window, cx);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +348,7 @@ mod tests {
|
|||||||
let edit = Some((2..6, 9));
|
let edit = Some((2..6, 9));
|
||||||
assert_eq!(super::moved(10, &edit), Some(15));
|
assert_eq!(super::moved(10, &edit), Some(15));
|
||||||
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
||||||
|
assert_eq!(super::moved(2, &edit), Some(2), "its start stays");
|
||||||
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
||||||
assert_eq!(super::moved(7, &None), Some(7));
|
assert_eq!(super::moved(7, &None), Some(7));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use gpui::{Context, Window};
|
use gpui::{Context, Window};
|
||||||
|
|
||||||
use super::MarkdownDocument;
|
use super::{
|
||||||
|
MarkdownDocument,
|
||||||
|
cells::{self, Aim},
|
||||||
|
edit::Landing,
|
||||||
|
};
|
||||||
use crate::documents::MarkdownMode;
|
use crate::documents::MarkdownMode;
|
||||||
|
|
||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
@@ -41,12 +45,22 @@ impl MarkdownDocument {
|
|||||||
// Outside one block: its line.
|
// Outside one block: its line.
|
||||||
return self.reveal(line(self, cx), window, cx);
|
return self.reveal(line(self, cx), window, cx);
|
||||||
};
|
};
|
||||||
let start = block.start;
|
// A match in a table opens its cell.
|
||||||
self.open(Some(ix), false, window, cx);
|
let text = self.editor.read(cx).text();
|
||||||
|
let landing = match cells::find(text, block.clone(), Aim::Byte(range.start)) {
|
||||||
|
Some(cell)
|
||||||
|
if cell.range.start <= range.start && range.end <= cell.range.end =>
|
||||||
|
{
|
||||||
|
Landing::Cell(Aim::At(cell.at))
|
||||||
|
}
|
||||||
|
Some(_) => return self.reveal(line(self, cx), window, cx),
|
||||||
|
None => Landing::End,
|
||||||
|
};
|
||||||
|
self.open(Some(ix), landing, window, cx);
|
||||||
self.list.scroll_to_reveal_item(ix);
|
self.list.scroll_to_reveal_item(ix);
|
||||||
if let Some(open) = &self.open {
|
if let Some(open) = &self.open {
|
||||||
let input = open.input.clone();
|
let input = open.input.clone();
|
||||||
let inside = range.start - start..range.end - start;
|
let inside = range.start - open.range.start..range.end - open.range.start;
|
||||||
input.update(cx, |input, cx| input.select(inside, cx));
|
input.update(cx, |input, cx| input.select(inside, cx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
//! Inline formats toggle at the selection, as Typora's (R4.6).
|
||||||
|
|
||||||
|
mod clear;
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::TableRefusal;
|
||||||
|
|
||||||
|
use crate::documents::{
|
||||||
|
markdown::{file_tree, span, walk},
|
||||||
|
tree::inside,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An inline format of Typora's Format menu.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum InlineFormat {
|
||||||
|
Strong,
|
||||||
|
Emphasis,
|
||||||
|
Code,
|
||||||
|
Strike,
|
||||||
|
Underline,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
/// Every inline mark in the touched blocks.
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a format or block command did not happen.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EditRefusal {
|
||||||
|
/// Preview takes no edits.
|
||||||
|
Preview,
|
||||||
|
Code,
|
||||||
|
Math,
|
||||||
|
Html,
|
||||||
|
FrontMatter,
|
||||||
|
/// No paragraph, heading or cell at the caret.
|
||||||
|
Outside,
|
||||||
|
/// A table holds the caret.
|
||||||
|
Table,
|
||||||
|
/// No picture holds the caret.
|
||||||
|
NoImage,
|
||||||
|
/// Front matter is there already.
|
||||||
|
Present,
|
||||||
|
/// The selection crosses a block or another mark.
|
||||||
|
Across,
|
||||||
|
/// Past the largest read: source alone.
|
||||||
|
TooLong,
|
||||||
|
/// Another edit is still read.
|
||||||
|
Busy,
|
||||||
|
/// The text changed before the edit was read.
|
||||||
|
Changed,
|
||||||
|
/// The result would read as other text or blocks.
|
||||||
|
Misread,
|
||||||
|
/// A table edit refused, and why.
|
||||||
|
Cells(TableRefusal),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edits and the selection after them.
|
||||||
|
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
|
||||||
|
|
||||||
|
/// Removes the picture at the caret, as Typora's Delete Image.
|
||||||
|
pub(super) fn drop_image(text: &str, at: usize) -> Result<Change, EditRefusal> {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let picture = (nodes.iter())
|
||||||
|
.filter(|node| matches!(node, Node::Image(_) | Node::ImageReference(_)))
|
||||||
|
.map(|node| span(node))
|
||||||
|
.find(|range| range.contains(&at))
|
||||||
|
.ok_or(EditRefusal::NoImage)?;
|
||||||
|
Ok((
|
||||||
|
vec![(picture.clone(), String::new())],
|
||||||
|
picture.start..picture.start,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typora's word stops: punctuation and space.
|
||||||
|
fn stop(ch: char) -> bool {
|
||||||
|
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text a block holds: its children, past heading marks.
|
||||||
|
fn content(node: &Node) -> Range<usize> {
|
||||||
|
let whole = span(node);
|
||||||
|
match node.children().map(|kids| (kids.first(), kids.last())) {
|
||||||
|
Some((Some(first), Some(last))) => span(first).start..span(last).end,
|
||||||
|
_ => whole.end..whole.end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Room for text: inside a cell's pipes, trailing spaces too.
|
||||||
|
fn room(node: &Node, text: &str) -> Range<usize> {
|
||||||
|
let inner = match node {
|
||||||
|
Node::TableCell(_) => {
|
||||||
|
let whole = span(node);
|
||||||
|
let cell = &text[whole.clone()];
|
||||||
|
whole.start + usize::from(cell.starts_with('|'))
|
||||||
|
..whole.end - usize::from(cell.len() > 1 && cell.ends_with('|'))
|
||||||
|
}
|
||||||
|
_ => content(node),
|
||||||
|
};
|
||||||
|
let rest = &text[inner.end..];
|
||||||
|
let spaces = rest.len() - rest.trim_start_matches([' ', '\t']).len();
|
||||||
|
inner.start..inner.end + spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of `sorted`, disjoint spans, holds `inner`.
|
||||||
|
pub(super) fn holder(sorted: &[Range<usize>], inner: &Range<usize>) -> Option<usize> {
|
||||||
|
let at = sorted.partition_point(|span| span.start <= inner.start);
|
||||||
|
(at > 0 && inner.end <= sorted[at - 1].end).then(|| at - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(node: &Node) -> bool {
|
||||||
|
matches!(
|
||||||
|
node,
|
||||||
|
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A styled node's two marks, kept text between.
|
||||||
|
pub(super) fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
||||||
|
let whole = span(node);
|
||||||
|
let inner = match node {
|
||||||
|
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
|
||||||
|
Node::Link(_) | Node::LinkReference(_) => content(node),
|
||||||
|
Node::InlineCode(_) => inside(text, whole.clone(), '`'),
|
||||||
|
Node::Image(_) | Node::ImageReference(_) => {
|
||||||
|
// Typora keeps the alt up to its first `]`.
|
||||||
|
let end = whole.start + text[whole.clone()].find(']')?;
|
||||||
|
whole.start + 2..end
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some([whole.start..inner.start, inner.end..whole.end])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `at` lies strictly inside one of `node`'s marks.
|
||||||
|
fn in_marks(node: &Node, text: &str, at: usize) -> bool {
|
||||||
|
marks(node, text).is_some_and(|holes| holes.iter().any(|hole| hole.start < at && at < hole.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn styled(format: InlineFormat, node: &Node) -> bool {
|
||||||
|
match format {
|
||||||
|
InlineFormat::Strong => matches!(node, Node::Strong(_)),
|
||||||
|
InlineFormat::Emphasis => matches!(node, Node::Emphasis(_)),
|
||||||
|
InlineFormat::Code => matches!(node, Node::InlineCode(_)),
|
||||||
|
InlineFormat::Strike => matches!(node, Node::Delete(_)),
|
||||||
|
InlineFormat::Link => matches!(node, Node::Link(_) | Node::LinkReference(_)),
|
||||||
|
InlineFormat::Image => matches!(node, Node::Image(_) | Node::ImageReference(_)),
|
||||||
|
InlineFormat::Underline | InlineFormat::Clear => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merges(format: InlineFormat) -> bool {
|
||||||
|
matches!(
|
||||||
|
format,
|
||||||
|
InlineFormat::Strong | InlineFormat::Emphasis | InlineFormat::Strike
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text[range]` without `holes`, sorted and inside it.
|
||||||
|
fn cut(text: &str, range: Range<usize>, holes: &[Range<usize>]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut at = range.start;
|
||||||
|
for hole in holes {
|
||||||
|
out.push_str(&text[at..hole.start]);
|
||||||
|
at = hole.end;
|
||||||
|
}
|
||||||
|
out.push_str(&text[at..range.end]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte `at` once `holes` are cut; inside one, its start.
|
||||||
|
fn after(at: usize, holes: &[Range<usize>]) -> usize {
|
||||||
|
let gone: usize = holes
|
||||||
|
.iter()
|
||||||
|
.map(|hole| at.clamp(hole.start, hole.end) - hole.start)
|
||||||
|
.sum();
|
||||||
|
at - gone
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses an end inside code, math, HTML or front matter.
|
||||||
|
fn verbatim(
|
||||||
|
nodes: &[&Node],
|
||||||
|
selection: &Range<usize>,
|
||||||
|
format: InlineFormat,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
let mut blocks: Vec<Range<usize>> = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| block(node))
|
||||||
|
.map(|node| span(node))
|
||||||
|
.collect();
|
||||||
|
blocks.sort_by_key(|block| block.start);
|
||||||
|
for node in nodes {
|
||||||
|
let whole = span(node);
|
||||||
|
let touches = |edge: bool| {
|
||||||
|
[selection.start, selection.end]
|
||||||
|
.iter()
|
||||||
|
.any(|&at| match edge {
|
||||||
|
true => whole.start <= at && at <= whole.end,
|
||||||
|
false => whole.start < at && at < whole.end,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Clear takes a block's inline code and tags too.
|
||||||
|
let held = format != InlineFormat::Clear && touches(false);
|
||||||
|
let why = match node {
|
||||||
|
Node::Code(_) if touches(true) => EditRefusal::Code,
|
||||||
|
Node::Math(_) if touches(true) => EditRefusal::Math,
|
||||||
|
Node::Yaml(_) | Node::Toml(_) if touches(true) => EditRefusal::FrontMatter,
|
||||||
|
Node::InlineCode(_) if format != InlineFormat::Code && held => EditRefusal::Code,
|
||||||
|
Node::InlineMath(_) if held => EditRefusal::Math,
|
||||||
|
Node::Html(_) => match holder(&blocks, &whole).is_some() {
|
||||||
|
true if held => EditRefusal::Html,
|
||||||
|
false if touches(true) => EditRefusal::Html,
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
return Err(why);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest `<u>` and `</u>` pair around `s..e` in `within`.
|
||||||
|
fn underline(
|
||||||
|
nodes: &[&Node],
|
||||||
|
within: &Range<usize>,
|
||||||
|
s: usize,
|
||||||
|
e: usize,
|
||||||
|
) -> Option<[Range<usize>; 2]> {
|
||||||
|
let mut tags: Vec<(&str, Range<usize>)> = (nodes.iter())
|
||||||
|
.filter_map(|node| match node {
|
||||||
|
Node::Html(html) => Some((html.value.trim(), span(node))),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|(_, at)| within.start <= at.start && at.end <= within.end)
|
||||||
|
.collect();
|
||||||
|
tags.sort_by_key(|(_, at)| at.start);
|
||||||
|
let mut opened = Vec::new();
|
||||||
|
for (tag, at) in tags {
|
||||||
|
if tag.eq_ignore_ascii_case("<u>") {
|
||||||
|
opened.push(at);
|
||||||
|
} else if tag.eq_ignore_ascii_case("</u>")
|
||||||
|
&& let Some(open) = opened.pop()
|
||||||
|
&& open.end <= s
|
||||||
|
&& e <= at.start
|
||||||
|
{
|
||||||
|
// Inner pairs close first.
|
||||||
|
return Some([open, at]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format` toggled at `selection` in `text`, or why not.
|
||||||
|
pub(super) fn toggle(
|
||||||
|
text: &str,
|
||||||
|
selection: Range<usize>,
|
||||||
|
format: InlineFormat,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
toggle_tree(text, &file_tree(text), selection, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `toggle` on `root`, the tree `text` parses to.
|
||||||
|
pub(super) fn toggle_tree(
|
||||||
|
text: &str,
|
||||||
|
root: &Node,
|
||||||
|
selection: Range<usize>,
|
||||||
|
format: InlineFormat,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(root, &mut nodes);
|
||||||
|
verbatim(&nodes, &selection, format)?;
|
||||||
|
if format == InlineFormat::Clear {
|
||||||
|
return clear::clear(text, &nodes, selection);
|
||||||
|
}
|
||||||
|
let picked = &text[selection.clone()];
|
||||||
|
let s = selection.start + (picked.len() - picked.trim_start().len());
|
||||||
|
let e = (selection.end - (picked.len() - picked.trim_end().len())).max(s);
|
||||||
|
let holds = |node: &Node| {
|
||||||
|
let whole = span(node);
|
||||||
|
let inside = match s == e {
|
||||||
|
true => whole.start < s && s < whole.end,
|
||||||
|
false => whole.start <= s && e <= whole.end,
|
||||||
|
};
|
||||||
|
inside && !in_marks(node, text, s) && !in_marks(node, text, e)
|
||||||
|
};
|
||||||
|
let unwrap = |range: Range<usize>, holes: [Range<usize>; 2]| -> Change {
|
||||||
|
let written = cut(text, range.clone(), &holes);
|
||||||
|
(vec![(range, written)], after(s, &holes)..after(e, &holes))
|
||||||
|
};
|
||||||
|
let found = (nodes.iter())
|
||||||
|
.filter(|node| styled(format, node) && holds(node))
|
||||||
|
.min_by_key(|node| span(node).len());
|
||||||
|
if let Some(node) = found {
|
||||||
|
crossed(text, &nodes, &span(node), s, e, format)?;
|
||||||
|
return Ok(unwrap(
|
||||||
|
span(node),
|
||||||
|
marks(node, text).expect("a styled node has marks"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rooms: Vec<Range<usize>> = (nodes.iter())
|
||||||
|
.filter(|node| block(node))
|
||||||
|
.map(|node| room(node, text))
|
||||||
|
.collect();
|
||||||
|
let within = (rooms.iter())
|
||||||
|
.filter(|range| range.start <= s && e <= range.end)
|
||||||
|
.min_by_key(|range| range.len())
|
||||||
|
.cloned();
|
||||||
|
let Some(within) = within else {
|
||||||
|
if s != e {
|
||||||
|
let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end);
|
||||||
|
return Err(match touched {
|
||||||
|
true => EditRefusal::Across,
|
||||||
|
false => EditRefusal::Outside,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let line = text[..s]
|
||||||
|
.rsplit(['\r', '\n'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
+ text[s..].split(['\r', '\n']).next().unwrap_or_default();
|
||||||
|
return match line.trim().is_empty() {
|
||||||
|
true => Ok(pair(s, format)),
|
||||||
|
false => Err(EditRefusal::Outside),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if format == InlineFormat::Underline
|
||||||
|
&& let Some(tags) = underline(&nodes, &within, s, e)
|
||||||
|
{
|
||||||
|
crossed(text, &nodes, &(tags[0].start..tags[1].end), s, e, format)?;
|
||||||
|
return Ok(unwrap(tags[0].start..tags[1].end, tags));
|
||||||
|
}
|
||||||
|
if s != e {
|
||||||
|
return wrap(text, &nodes, &within, s, e, format);
|
||||||
|
}
|
||||||
|
let prev = text[within.start..s].chars().next_back();
|
||||||
|
let next = text[s..within.end].chars().next();
|
||||||
|
if prev.is_none_or(stop) && next.is_none_or(stop) {
|
||||||
|
crossed(text, &nodes, &within, s, s, format)?;
|
||||||
|
return Ok(pair(s, format));
|
||||||
|
}
|
||||||
|
let word = (nodes.iter())
|
||||||
|
.filter(|node| matches!(node, Node::Text(_)))
|
||||||
|
.map(|node| span(node))
|
||||||
|
.find(|run| run.start <= s && s <= run.end)
|
||||||
|
.map(|run| {
|
||||||
|
let start = (text[run.start..s].char_indices().rev())
|
||||||
|
.find(|(_, ch)| stop(*ch))
|
||||||
|
.map_or(run.start, |(at, ch)| run.start + at + ch.len_utf8());
|
||||||
|
let end = s + text[s..run.end].find(stop).unwrap_or(run.end - s);
|
||||||
|
start..end
|
||||||
|
})
|
||||||
|
.filter(|word| !word.is_empty());
|
||||||
|
match word {
|
||||||
|
Some(word) => wrap(text, &nodes, &within, word.start, word.end, format),
|
||||||
|
None => {
|
||||||
|
crossed(text, &nodes, &within, s, s, format)?;
|
||||||
|
Ok(pair(s, format))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marks `format` writes around `inner`.
|
||||||
|
fn written(format: InlineFormat, inner: &str) -> (String, String) {
|
||||||
|
let (open, close) = match format {
|
||||||
|
InlineFormat::Strong => ("**", "**"),
|
||||||
|
InlineFormat::Emphasis => ("*", "*"),
|
||||||
|
InlineFormat::Strike => ("~~", "~~"),
|
||||||
|
InlineFormat::Underline => ("<u>", "</u>"),
|
||||||
|
InlineFormat::Link => ("[", "]()"),
|
||||||
|
InlineFormat::Image => ("![", "]()"),
|
||||||
|
InlineFormat::Code => {
|
||||||
|
let longest = inner.split(|ch| ch != '`').map(str::len).max().unwrap_or(0);
|
||||||
|
let fence = "`".repeat(longest + 1);
|
||||||
|
let pad = if inner.starts_with('`') || inner.ends_with('`') {
|
||||||
|
" "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
return (format!("{fence}{pad}"), format!("{pad}{fence}"));
|
||||||
|
}
|
||||||
|
InlineFormat::Clear => unreachable!("clear writes no marks"),
|
||||||
|
};
|
||||||
|
(open.into(), close.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty pair at `at`, the caret inside it.
|
||||||
|
fn pair(at: usize, format: InlineFormat) -> Change {
|
||||||
|
let (open, close) = written(format, "");
|
||||||
|
let caret = at + open.len();
|
||||||
|
(vec![(at..at, format!("{open}{close}"))], caret..caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses an end in marks, or a mark split off.
|
||||||
|
fn crossed(
|
||||||
|
text: &str,
|
||||||
|
nodes: &[&Node],
|
||||||
|
within: &Range<usize>,
|
||||||
|
s: usize,
|
||||||
|
e: usize,
|
||||||
|
format: InlineFormat,
|
||||||
|
) -> Result<(), EditRefusal> {
|
||||||
|
for node in nodes {
|
||||||
|
let whole = span(node);
|
||||||
|
if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let holes = match node {
|
||||||
|
Node::InlineMath(_) | Node::Html(_) | Node::Break(_) | Node::FootnoteReference(_) => {
|
||||||
|
vec![whole.clone()]
|
||||||
|
}
|
||||||
|
_ => match marks(node, text) {
|
||||||
|
Some(marks) => marks.to_vec(),
|
||||||
|
None => continue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let strictly = |at: usize| holes.iter().any(|hole| hole.start < at && at < hole.end);
|
||||||
|
let overlaps = holes.iter().any(|hole| s < hole.end && hole.start < e);
|
||||||
|
let holds_all = s <= whole.start && whole.end <= e;
|
||||||
|
// A same style cut is taken in whole.
|
||||||
|
let same = merges(format) && styled(format, node);
|
||||||
|
if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) {
|
||||||
|
return Err(EditRefusal::Across);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `s..e` in `format`, same marks inside taken in.
|
||||||
|
fn wrap(
|
||||||
|
text: &str,
|
||||||
|
nodes: &[&Node],
|
||||||
|
within: &Range<usize>,
|
||||||
|
mut s: usize,
|
||||||
|
mut e: usize,
|
||||||
|
format: InlineFormat,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
crossed(text, nodes, within, s, e, format)?;
|
||||||
|
if merges(format) {
|
||||||
|
for node in nodes.iter().filter(|node| styled(format, node)) {
|
||||||
|
let whole = span(node);
|
||||||
|
if whole.start < e
|
||||||
|
&& s < whole.end
|
||||||
|
&& within.start <= whole.start
|
||||||
|
&& whole.end <= within.end
|
||||||
|
{
|
||||||
|
(s, e) = (s.min(whole.start), e.max(whole.end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crossed(text, nodes, within, s, e, format)?;
|
||||||
|
}
|
||||||
|
let mut holes: Vec<Range<usize>> = (nodes.iter())
|
||||||
|
.filter(|node| styled(format, node) && s <= span(node).start && span(node).end <= e)
|
||||||
|
.flat_map(|node| marks(node, text).expect("a styled node has marks"))
|
||||||
|
.filter(|hole| !hole.is_empty())
|
||||||
|
.collect();
|
||||||
|
holes.sort_by_key(|hole| hole.start);
|
||||||
|
let inner = cut(text, s..e, &holes);
|
||||||
|
let (open, close) = written(format, &inner);
|
||||||
|
let at = s + open.len();
|
||||||
|
let selection = match format {
|
||||||
|
InlineFormat::Link | InlineFormat::Image => at + inner.len() + 2..at + inner.len() + 2,
|
||||||
|
_ => at..at + inner.len(),
|
||||||
|
};
|
||||||
|
Ok((vec![(s..e, format!("{open}{inner}{close}"))], selection))
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Clear Format: every inline mark off the touched blocks.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
|
||||||
|
use crate::documents::markdown::{math_inside, span};
|
||||||
|
|
||||||
|
/// The backslashes of escapes in a text node's span.
|
||||||
|
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut chars = text[whole.clone()].char_indices().peekable();
|
||||||
|
while let Some((at, ch)) = chars.next() {
|
||||||
|
if ch == '\\'
|
||||||
|
&& chars
|
||||||
|
.peek()
|
||||||
|
.is_some_and(|(_, next)| next.is_ascii_punctuation())
|
||||||
|
{
|
||||||
|
out.push(whole.start + at..whole.start + at + 1);
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node's inline marks, as Typora's `.md-meta`.
|
||||||
|
fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>> {
|
||||||
|
let whole = span(node);
|
||||||
|
match node {
|
||||||
|
Node::Html(_) => vec![whole],
|
||||||
|
// Autolink text keeps its backslashes.
|
||||||
|
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
||||||
|
Node::Text(_) => escapes(text, whole),
|
||||||
|
Node::InlineMath(_) => {
|
||||||
|
let inner = math_inside(text, whole.clone());
|
||||||
|
vec![whole.start..inner.start, inner.end..whole.end]
|
||||||
|
}
|
||||||
|
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
||||||
|
_ => marks(node, text).map(Vec::from).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every inline mark in the blocks `selection` touches.
|
||||||
|
pub(super) fn clear(
|
||||||
|
text: &str,
|
||||||
|
nodes: &[&Node],
|
||||||
|
selection: Range<usize>,
|
||||||
|
) -> Result<Change, EditRefusal> {
|
||||||
|
let mut blocks: Vec<Range<usize>> = (nodes.iter())
|
||||||
|
.filter(|node| block(node))
|
||||||
|
.map(|node| room(node, text))
|
||||||
|
.filter(|range| range.start <= selection.end && selection.start <= range.end)
|
||||||
|
.collect();
|
||||||
|
if blocks.is_empty() {
|
||||||
|
return Err(EditRefusal::Outside);
|
||||||
|
}
|
||||||
|
blocks.sort_by_key(|block| block.start);
|
||||||
|
let mut autolinks: Vec<Range<usize>> = (nodes.iter())
|
||||||
|
.filter(|node| matches!(node, Node::Link(_)))
|
||||||
|
.map(|node| span(node))
|
||||||
|
.filter(|whole| !text[whole.clone()].starts_with('['))
|
||||||
|
.collect();
|
||||||
|
autolinks.sort_by_key(|link| link.start);
|
||||||
|
let mut inside: Vec<Vec<Range<usize>>> = vec![Vec::new(); blocks.len()];
|
||||||
|
for hole in (nodes.iter()).flat_map(|node| meta(node, text, &autolinks)) {
|
||||||
|
if let Some(at) = holder(&blocks, &hole).filter(|_| !hole.is_empty()) {
|
||||||
|
inside[at].push(hole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut holes = Vec::new();
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
for (block, mut cuts) in blocks.into_iter().zip(inside) {
|
||||||
|
if cuts.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cuts.sort_by_key(|hole| hole.start);
|
||||||
|
edits.push((block.clone(), cut(text, block, &cuts)));
|
||||||
|
holes.extend(cuts);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
edits,
|
||||||
|
after(selection.start, &holes)..after(selection.end, &holes),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
mod apart;
|
||||||
mod attach;
|
mod attach;
|
||||||
|
mod block;
|
||||||
|
mod cells;
|
||||||
mod edit;
|
mod edit;
|
||||||
mod finding;
|
mod finding;
|
||||||
|
mod format;
|
||||||
mod lists;
|
mod lists;
|
||||||
mod look;
|
mod look;
|
||||||
mod parse;
|
mod parse;
|
||||||
mod place;
|
mod place;
|
||||||
|
mod point;
|
||||||
mod tables;
|
mod tables;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -19,13 +24,21 @@ use gpui::{
|
|||||||
ListState, Pixels, Subscription, Task, Window,
|
ListState, Pixels, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use crate::documents::tree::DocumentHeading;
|
||||||
pub use attach::Attachment;
|
pub use attach::Attachment;
|
||||||
|
pub use block::BlockCommand;
|
||||||
|
pub use format::{EditRefusal, InlineFormat};
|
||||||
pub use look::DocumentLook;
|
pub use look::DocumentLook;
|
||||||
pub use parse::DocumentHeading;
|
|
||||||
use parse::{Parsed, alike, parsed};
|
use parse::{Parsed, alike, parsed};
|
||||||
|
pub use point::{DocumentTarget, TargetBlock};
|
||||||
pub use tables::{TableEdit, TableRefusal};
|
pub use tables::{TableEdit, TableRefusal};
|
||||||
|
|
||||||
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
use super::{
|
||||||
|
MarkdownMode,
|
||||||
|
markdown::markdown_highlights,
|
||||||
|
modes::joint,
|
||||||
|
render::{CellAt, OnHeading, OnLink},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
editor::{CodeEditor, EditorEvent, LineNumbers},
|
editor::{CodeEditor, EditorEvent, LineNumbers},
|
||||||
forms::TextInput,
|
forms::TextInput,
|
||||||
@@ -41,12 +54,16 @@ pub const LARGEST_READ: usize = 2 << 20;
|
|||||||
const PAUSE: Duration = Duration::from_millis(300);
|
const PAUSE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// What the document tells its owner.
|
/// What the document tells its owner.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum DocumentEvent {
|
pub enum DocumentEvent {
|
||||||
/// The text changed, typed in any mode or replaced.
|
/// The text changed, typed in any mode or replaced.
|
||||||
Changed,
|
Changed,
|
||||||
/// A new read of the text: blocks and headings.
|
/// A new read of the text: blocks and headings.
|
||||||
Parsed,
|
Parsed,
|
||||||
|
/// A secondary press moved the caret onto this.
|
||||||
|
Pointed(DocumentTarget),
|
||||||
|
/// A long format, accepted, did not happen.
|
||||||
|
EditRefused(EditRefusal),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A top line asked, numbered so late callbacks know theirs.
|
/// A top line asked, numbered so late callbacks know theirs.
|
||||||
@@ -64,6 +81,10 @@ struct Open {
|
|||||||
input: Entity<TextInput>,
|
input: Entity<TextInput>,
|
||||||
/// Opened past the end, to add a block.
|
/// Opened past the end, to add a block.
|
||||||
append: bool,
|
append: bool,
|
||||||
|
/// A table cell alone; its bare pipes escape.
|
||||||
|
cell: Option<CellAt>,
|
||||||
|
/// Watches an overlay that borrowed focus.
|
||||||
|
lent: Option<Subscription>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +97,11 @@ pub struct MarkdownDocument {
|
|||||||
/// Text changes counted; a read of an older one lapses.
|
/// Text changes counted; a read of an older one lapses.
|
||||||
edits: u64,
|
edits: u64,
|
||||||
_parsing: Option<Task<()>>,
|
_parsing: Option<Task<()>>,
|
||||||
|
editing: bool,
|
||||||
list: ListState,
|
list: ListState,
|
||||||
open: Option<Open>,
|
open: Option<Open>,
|
||||||
|
/// A dropped block's focus, for the document to take.
|
||||||
|
orphan: Option<FocusHandle>,
|
||||||
/// A top line asked for, until the view holds it.
|
/// A top line asked for, until the view holds it.
|
||||||
top: Option<Placement>,
|
top: Option<Placement>,
|
||||||
asks: u64,
|
asks: u64,
|
||||||
@@ -85,15 +109,22 @@ pub struct MarkdownDocument {
|
|||||||
writes: usize,
|
writes: usize,
|
||||||
/// A match asked before its text was read; its version.
|
/// A match asked before its text was read; its version.
|
||||||
showing: Option<(Range<usize>, u64)>,
|
showing: Option<(Range<usize>, u64)>,
|
||||||
|
/// A block asked open before its text was read.
|
||||||
|
waiting: Option<(Option<usize>, edit::Landing, u64)>,
|
||||||
look: DocumentLook,
|
look: DocumentLook,
|
||||||
/// Typing in the open block, counted.
|
/// Typing in the open block, counted.
|
||||||
typed: u64,
|
typed: u64,
|
||||||
/// Typewriter room past each end of the list.
|
/// Typewriter room past each end of the list.
|
||||||
room: Pixels,
|
room: Pixels,
|
||||||
/// The block, text, typing and heights last centered.
|
/// The block, text, typing and heights last centered.
|
||||||
centered: Option<(usize, u64, u64, [u64; 2])>,
|
centered: Option<(usize, u64, u64, [u64; 3])>,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
|
/// A secondary press took focus.
|
||||||
|
pointing: bool,
|
||||||
|
/// The latest press; older lookups lapse.
|
||||||
|
pressed: u64,
|
||||||
on_link: Option<OnLink>,
|
on_link: Option<OnLink>,
|
||||||
|
on_heading: Option<OnHeading>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +163,11 @@ impl MarkdownDocument {
|
|||||||
cx.on_focus(&focus, window, Self::focused),
|
cx.on_focus(&focus, window, Self::focused),
|
||||||
];
|
];
|
||||||
let overdraw = window.rem_size() * 40.0;
|
let overdraw = window.rem_size() * 40.0;
|
||||||
|
let this = cx.entity().downgrade();
|
||||||
|
let on_heading: OnHeading = Rc::new(move |at, window, cx| {
|
||||||
|
this.update(cx, |document, cx| document.reveal_at(at, window, cx))
|
||||||
|
.expect("a heading list lives in its document");
|
||||||
|
});
|
||||||
let mut document = Self {
|
let mut document = Self {
|
||||||
editor,
|
editor,
|
||||||
mode,
|
mode,
|
||||||
@@ -139,18 +175,24 @@ impl MarkdownDocument {
|
|||||||
parsed: Arc::default(),
|
parsed: Arc::default(),
|
||||||
edits: 0,
|
edits: 0,
|
||||||
_parsing: None,
|
_parsing: None,
|
||||||
|
editing: false,
|
||||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||||
open: None,
|
open: None,
|
||||||
|
orphan: None,
|
||||||
top: None,
|
top: None,
|
||||||
asks: 0,
|
asks: 0,
|
||||||
writes: 0,
|
writes: 0,
|
||||||
showing: None,
|
showing: None,
|
||||||
|
waiting: None,
|
||||||
look: DocumentLook::default(),
|
look: DocumentLook::default(),
|
||||||
typed: 0,
|
typed: 0,
|
||||||
centered: None,
|
centered: None,
|
||||||
room: Pixels::ZERO,
|
room: Pixels::ZERO,
|
||||||
focus,
|
focus,
|
||||||
|
pointing: false,
|
||||||
|
pressed: 0,
|
||||||
on_link: None,
|
on_link: None,
|
||||||
|
on_heading: Some(on_heading),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
document.reparse(true, cx);
|
document.reparse(true, cx);
|
||||||
@@ -207,6 +249,9 @@ impl MarkdownDocument {
|
|||||||
let written = match open.append {
|
let written = match open.append {
|
||||||
true if typed.is_empty() => String::new(),
|
true if typed.is_empty() => String::new(),
|
||||||
true => format!("{}{typed}", joint(text)),
|
true => format!("{}{typed}", joint(text)),
|
||||||
|
false if open.cell.is_some() => {
|
||||||
|
cells::kept(typed, cells::edges(text, open.range.clone()))
|
||||||
|
}
|
||||||
false => typed.to_string(),
|
false => typed.to_string(),
|
||||||
};
|
};
|
||||||
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
||||||
@@ -304,6 +349,9 @@ impl MarkdownDocument {
|
|||||||
let old = self.parsed.blocks.len();
|
let old = self.parsed.blocks.len();
|
||||||
self.list
|
self.list
|
||||||
.splice(head..old - tail, read.blocks.len() - head - tail);
|
.splice(head..old - tail, read.blocks.len() - head - tail);
|
||||||
|
let headings: Vec<usize> = read.headings.iter().map(|heading| heading.at).collect();
|
||||||
|
self.editor
|
||||||
|
.update(cx, |editor, _| editor.set_anchors(headings));
|
||||||
self.parsed = Arc::new(read);
|
self.parsed = Arc::new(read);
|
||||||
cx.emit(DocumentEvent::Parsed);
|
cx.emit(DocumentEvent::Parsed);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -311,7 +359,8 @@ impl MarkdownDocument {
|
|||||||
|
|
||||||
/// Closes the open block without writing it.
|
/// Closes the open block without writing it.
|
||||||
fn drop_open(&mut self, cx: &mut Context<Self>) {
|
fn drop_open(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.open.take().is_some() {
|
if let Some(open) = self.open.take() {
|
||||||
|
self.orphan = Some(open.input.focus_handle(cx));
|
||||||
log::info!("markdown document: the open block closed unwritten");
|
log::info!("markdown document: the open block closed unwritten");
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,21 @@ use std::ops::Range;
|
|||||||
use markdown::mdast;
|
use markdown::mdast;
|
||||||
|
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
markdown::{file_tree, span, walk},
|
markdown::{file_tree, span},
|
||||||
tree::{Node, Reader},
|
tree::{DocumentHeading, Node, Reader},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::TargetBlock;
|
||||||
|
|
||||||
/// A top-level block: its source, and what it draws.
|
/// A top-level block: its source, and what it draws.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct Block {
|
pub(crate) struct Block {
|
||||||
pub range: Range<usize>,
|
pub range: Range<usize>,
|
||||||
/// None shows the source as written: definitions, HTML, front matter.
|
pub kind: TargetBlock,
|
||||||
|
/// None shows a link definition as written.
|
||||||
pub nodes: Option<Vec<Node>>,
|
pub nodes: Option<Vec<Node>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A heading anywhere in the file.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct DocumentHeading {
|
|
||||||
pub depth: u8,
|
|
||||||
pub title: String,
|
|
||||||
/// Where its source starts, in bytes.
|
|
||||||
pub at: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file read as blocks and headings.
|
/// A file read as blocks and headings.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub(crate) struct Parsed {
|
pub(crate) struct Parsed {
|
||||||
@@ -44,41 +38,24 @@ pub(crate) fn parsed(text: &str) -> Parsed {
|
|||||||
.map(|node| {
|
.map(|node| {
|
||||||
let range = span(node);
|
let range = span(node);
|
||||||
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
||||||
let raw = matches!(
|
let raw = matches!(node, mdast::Node::Definition(_));
|
||||||
node,
|
|
||||||
mdast::Node::Definition(_)
|
|
||||||
| mdast::Node::Html(_)
|
|
||||||
| mdast::Node::Yaml(_)
|
|
||||||
| mdast::Node::Toml(_)
|
|
||||||
);
|
|
||||||
let nodes = (!raw).then(|| {
|
let nodes = (!raw).then(|| {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
reader.block(node, &mut out);
|
reader.top(node, &mut out);
|
||||||
|
out.iter_mut().for_each(|drawn| drawn.rebase(range.start));
|
||||||
out
|
out
|
||||||
});
|
});
|
||||||
Block {
|
Block {
|
||||||
range: range.start..range.start + kept,
|
range: range.start..range.start + kept,
|
||||||
|
kind: TargetBlock::of(node, text),
|
||||||
nodes,
|
nodes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut nodes = Vec::new();
|
|
||||||
walk(&root, &mut nodes);
|
|
||||||
let headings = nodes
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|node| match node {
|
|
||||||
mdast::Node::Heading(heading) => Some(DocumentHeading {
|
|
||||||
depth: heading.depth,
|
|
||||||
title: reader.inline(&heading.children).text,
|
|
||||||
at: span(node).start,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Parsed {
|
Parsed {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
blocks,
|
blocks,
|
||||||
headings,
|
headings: reader.headings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +88,7 @@ mod tests {
|
|||||||
use crate::documents::tree::Style;
|
use crate::documents::tree::Style;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_block_is_shown_raw_ones_as_written() {
|
fn every_block_is_shown_definitions_as_written() {
|
||||||
let text = "---\ntitle: a\n---\n\n# Lift\n\n[site]: https://ely.dev\n\n<div>x</div>\n\n> ## Quoted\n";
|
let text = "---\ntitle: a\n---\n\n# Lift\n\n[site]: https://ely.dev\n\n<div>x</div>\n\n> ## Quoted\n";
|
||||||
let found = parsed(text);
|
let found = parsed(text);
|
||||||
let shown: Vec<(&str, bool)> = found
|
let shown: Vec<(&str, bool)> = found
|
||||||
@@ -122,13 +99,17 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
shown,
|
shown,
|
||||||
[
|
[
|
||||||
("---\ntitle: a\n---", false),
|
("---\ntitle: a\n---", true),
|
||||||
("# Lift", true),
|
("# Lift", true),
|
||||||
("[site]: https://ely.dev", false),
|
("[site]: https://ely.dev", false),
|
||||||
("<div>x</div>", false),
|
("<div>x</div>", true),
|
||||||
("> ## Quoted", true),
|
("> ## Quoted", true),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn");
|
||||||
|
let text = "title: a".into();
|
||||||
|
assert_eq!(drawn(0), [Node::FrontMatter { text, from: 4 }]);
|
||||||
|
assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x"));
|
||||||
let headings: Vec<(u8, &str, usize)> = found
|
let headings: Vec<(u8, &str, usize)> = found
|
||||||
.headings
|
.headings
|
||||||
.iter()
|
.iter()
|
||||||
@@ -174,6 +155,31 @@ mod tests {
|
|||||||
assert_eq!(alike(&old, &shorter), (1, 0));
|
assert_eq!(alike(&old, &shorter), (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pictures_and_code_count_from_their_block() {
|
||||||
|
let old = parsed(
|
||||||
|
"a
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
```
|
||||||
|
x
|
||||||
|
```
|
||||||
|
",
|
||||||
|
);
|
||||||
|
assert_eq!(alike(&old, &new), (0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_reference_resolves_across_blocks() {
|
fn a_reference_resolves_across_blocks() {
|
||||||
let found = parsed("[site]\n\n[site]: https://ely.dev\n");
|
let found = parsed("[site]\n\n[site]: https://ely.dev\n");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
||||||
|
|
||||||
use super::{MarkdownDocument, Placement};
|
use super::{MarkdownDocument, Placement, cells};
|
||||||
use crate::documents::{MarkdownMode, modes::joint};
|
use crate::documents::{MarkdownMode, modes::joint};
|
||||||
|
|
||||||
/// Line and column, from zero, of byte `at` in `text`.
|
/// Line and column, from zero, of byte `at` in `text`.
|
||||||
@@ -25,7 +25,15 @@ impl MarkdownDocument {
|
|||||||
true => joint(editor.text()).len(),
|
true => joint(editor.text()).len(),
|
||||||
false => 0,
|
false => 0,
|
||||||
};
|
};
|
||||||
point(&self.text(cx), open.range.start + joined + input.cursor())
|
// A cell's caret reads its escaped write-back.
|
||||||
|
let cursor = match open.cell {
|
||||||
|
Some(_) => {
|
||||||
|
let edges = cells::edges(editor.text(), open.range.clone());
|
||||||
|
cells::kept_at(input.text(), input.cursor(), edges)
|
||||||
|
}
|
||||||
|
None => input.cursor(),
|
||||||
|
};
|
||||||
|
point(&self.text(cx), open.range.start + joined + cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected Markdown: the source's, or the open block's.
|
/// The selected Markdown: the source's, or the open block's.
|
||||||
@@ -189,6 +197,35 @@ impl MarkdownDocument {
|
|||||||
.saturating_sub(1)
|
.saturating_sub(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reveals the pressed heading where the edits since carried it.
|
||||||
|
pub(super) fn reveal_at(&mut self, at: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let headings = &self.parsed.headings;
|
||||||
|
let Some(index) = headings.iter().position(|heading| heading.at == at) else {
|
||||||
|
return log::warn!("markdown document: a heading read away");
|
||||||
|
};
|
||||||
|
self.commit(cx);
|
||||||
|
let anchors = self.editor.read(cx).anchors();
|
||||||
|
let Some(found) = *anchors.get(index).expect("an anchor per heading read") else {
|
||||||
|
return log::warn!("markdown document: the pressed heading changed as one wrote");
|
||||||
|
};
|
||||||
|
if !self.read_now(cx) {
|
||||||
|
self.reparse(true, cx);
|
||||||
|
}
|
||||||
|
if !self.read_now(cx) {
|
||||||
|
return log::warn!("markdown document: a heading pressed while the text is read again");
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.parsed
|
||||||
|
.headings
|
||||||
|
.iter()
|
||||||
|
.any(|heading| heading.at == found)
|
||||||
|
{
|
||||||
|
return log::warn!("markdown document: the pressed heading reads as no heading now");
|
||||||
|
}
|
||||||
|
let line = self.editor.read(cx).buffer.line_of(found);
|
||||||
|
self.reveal(line, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Line `line` at top and focused, caret at start.
|
/// Line `line` at top and focused, caret at start.
|
||||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.set_caret(line, 0, cx);
|
self.set_caret(line, 0, cx);
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
use std::{ops::Range, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{AppContext as _, Context, Focusable as _, MouseButton, Pixels, Point, Window};
|
||||||
|
use markdown::mdast;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
DocumentEvent, MarkdownDocument,
|
||||||
|
cells::{self, Aim},
|
||||||
|
edit::Landing,
|
||||||
|
edit::moved,
|
||||||
|
parse::Block,
|
||||||
|
};
|
||||||
|
use crate::documents::{
|
||||||
|
markdown::{file_tree, span, walk},
|
||||||
|
render::{CellAt, OnPoint, Place, Pointed},
|
||||||
|
tree::{Node, Reader, alert, is_toc, lines_at},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A top-level block's kind.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TargetBlock {
|
||||||
|
Paragraph,
|
||||||
|
Heading(u8),
|
||||||
|
Quote,
|
||||||
|
Alert,
|
||||||
|
List,
|
||||||
|
Code,
|
||||||
|
Math,
|
||||||
|
Table,
|
||||||
|
Rule,
|
||||||
|
Html,
|
||||||
|
Footnote,
|
||||||
|
Definition,
|
||||||
|
FrontMatter,
|
||||||
|
Toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetBlock {
|
||||||
|
/// `node`'s kind, read from `source`.
|
||||||
|
pub(super) fn of(node: &mdast::Node, source: &str) -> Self {
|
||||||
|
match node {
|
||||||
|
mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc,
|
||||||
|
mdast::Node::Paragraph(_) => Self::Paragraph,
|
||||||
|
mdast::Node::Heading(heading) => Self::Heading(heading.depth),
|
||||||
|
mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert,
|
||||||
|
mdast::Node::Blockquote(_) => Self::Quote,
|
||||||
|
mdast::Node::List(_) => Self::List,
|
||||||
|
mdast::Node::Code(_) => Self::Code,
|
||||||
|
mdast::Node::Math(_) => Self::Math,
|
||||||
|
mdast::Node::Table(_) => Self::Table,
|
||||||
|
mdast::Node::ThematicBreak(_) => Self::Rule,
|
||||||
|
mdast::Node::Html(_) => Self::Html,
|
||||||
|
mdast::Node::FootnoteDefinition(_) => Self::Footnote,
|
||||||
|
mdast::Node::Definition(_) => Self::Definition,
|
||||||
|
mdast::Node::Yaml(_) | mdast::Node::Toml(_) => Self::FrontMatter,
|
||||||
|
other => unreachable!("{other:?} is no block outside MDX"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a secondary press landed on.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct DocumentTarget {
|
||||||
|
/// The pressed source byte; an open block's as unwritten.
|
||||||
|
pub at: usize,
|
||||||
|
/// None past the last block.
|
||||||
|
pub block: Option<TargetBlock>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub in_table: bool,
|
||||||
|
/// The code block's text, pressed in one.
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownDocument {
|
||||||
|
/// Presses on block `ix`'s drawing, read as it was drawn.
|
||||||
|
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
|
||||||
|
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
|
||||||
|
let block = parsed.blocks[ix].range.clone();
|
||||||
|
let start = block.start;
|
||||||
|
Rc::new(move |pointed, button, window, cx| {
|
||||||
|
// The drawing counts from its block's start.
|
||||||
|
let at = match &pointed.place {
|
||||||
|
Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn),
|
||||||
|
// Unreadable lines land at their start.
|
||||||
|
Place::Lines { text, from, at } => {
|
||||||
|
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
||||||
|
}
|
||||||
|
Place::At(at) => Some(*at),
|
||||||
|
// A cell's padding lands at its end.
|
||||||
|
Place::Cell(cell) => cells::find(&parsed.text, block.clone(), Aim::At(*cell))
|
||||||
|
.map(|found| found.range.end - start),
|
||||||
|
Place::Block => None,
|
||||||
|
}
|
||||||
|
.map(|at| start + at);
|
||||||
|
// A press in a table opens only its cell.
|
||||||
|
let landing = match (&pointed.place, at) {
|
||||||
|
(Place::Cell(cell), _) => Landing::Cell(Aim::At(*cell)),
|
||||||
|
(_, Some(at)) if pointed.table => Landing::Cell(Aim::Byte(at)),
|
||||||
|
(_, Some(at)) => Landing::At(at),
|
||||||
|
(_, None) => Landing::End,
|
||||||
|
};
|
||||||
|
let ix = Some(ix);
|
||||||
|
document
|
||||||
|
.update(cx, |document, cx| {
|
||||||
|
// The open cell's padding keeps its typing.
|
||||||
|
let open = match pointed.place {
|
||||||
|
Place::Cell(cell) => document.open_cell(&block) == Some(cell),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
match (button, open) {
|
||||||
|
(MouseButton::Left, true) => document.cell_end(window, cx),
|
||||||
|
(MouseButton::Right, true) => document.point_open(None, cx),
|
||||||
|
(MouseButton::Left, false) => document.open(ix, landing, window, cx),
|
||||||
|
(MouseButton::Right, false) => document.point(ix, at, pointed, window, cx),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("a block's drawing lives in its document");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell open in `block`, if any.
|
||||||
|
fn open_cell(&self, block: &Range<usize>) -> Option<CellAt> {
|
||||||
|
let open = self.open.as_ref()?;
|
||||||
|
let inside = block.start <= open.range.start && open.range.end <= block.end;
|
||||||
|
open.cell.filter(|_| inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caret to the open cell's end.
|
||||||
|
fn cell_end(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let input = open.input.clone();
|
||||||
|
input.update(cx, |input, cx| {
|
||||||
|
let end = input.text().len();
|
||||||
|
input.select(end..end, cx);
|
||||||
|
});
|
||||||
|
window.focus(&input.focus_handle(cx), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A block's kind, text and what it draws.
|
||||||
|
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
|
||||||
|
let text = self.parsed.text[block.range.clone()].to_string();
|
||||||
|
(block.kind, text, block.nodes.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caret to `at`, else block `ix`; the owner hears.
|
||||||
|
pub(super) fn point(
|
||||||
|
&mut self,
|
||||||
|
ix: Option<usize>,
|
||||||
|
at: Option<usize>,
|
||||||
|
pointed: Pointed,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.pressed += 1;
|
||||||
|
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||||
|
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||||
|
block => block.flatten(),
|
||||||
|
};
|
||||||
|
let kind = block.map(|block| block.kind);
|
||||||
|
let pressed = block.map(|block| self.drawn(block));
|
||||||
|
let code = (pointed.code.as_deref().map(str::to_string)).or_else(|| {
|
||||||
|
match block.and_then(|block| block.nodes.as_deref()) {
|
||||||
|
Some([Node::Code { text, .. }]) => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let at = at.or(block.map(|block| block.range.start));
|
||||||
|
let written = self.commit(cx);
|
||||||
|
if written.is_some() {
|
||||||
|
self.reparse(true, cx);
|
||||||
|
}
|
||||||
|
if !self.read_now(cx) {
|
||||||
|
return log::warn!("markdown document: pressed while the text is read again");
|
||||||
|
}
|
||||||
|
let at = match at {
|
||||||
|
None => self.editor.read(cx).text().len(),
|
||||||
|
Some(at) => match moved(at, &written) {
|
||||||
|
Some(at) => at,
|
||||||
|
None => return log::warn!("markdown document: the pressed text was written over"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// A write-back may change the pressed block.
|
||||||
|
let now = (self.parsed.blocks.iter())
|
||||||
|
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||||
|
.map(|block| self.drawn(block));
|
||||||
|
// A cell beside the written one keeps its place.
|
||||||
|
let beside = pointed.table && now.as_ref().is_some_and(|now| now.0 == TargetBlock::Table);
|
||||||
|
if written.is_some() && pressed.is_some() && now != pressed && !beside {
|
||||||
|
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||||
|
}
|
||||||
|
self.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||||
|
// Its focus opens no empty block.
|
||||||
|
self.pointing = !self.focus.is_focused(window);
|
||||||
|
window.focus(&self.focus, cx);
|
||||||
|
log::info!("markdown document: a secondary press puts the caret at byte {at}");
|
||||||
|
cx.emit(DocumentEvent::Pointed(DocumentTarget {
|
||||||
|
at,
|
||||||
|
block: kind,
|
||||||
|
link: pointed.link,
|
||||||
|
image: pointed.image,
|
||||||
|
in_table: pointed.table || kind == Some(TargetBlock::Table),
|
||||||
|
code,
|
||||||
|
}));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A right press at `position`, else at the field's end.
|
||||||
|
pub(super) fn point_open(&mut self, position: Option<Point<Pixels>>, cx: &mut Context<Self>) {
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
return log::warn!("markdown document: a press on a block closed since");
|
||||||
|
};
|
||||||
|
let input = open.input.read(cx);
|
||||||
|
let pressed = match position {
|
||||||
|
Some(position) => input.offset_for_point(position),
|
||||||
|
None => input.text().len(),
|
||||||
|
};
|
||||||
|
// A cell's typed pipes shift its bytes.
|
||||||
|
let pressed = match open.cell {
|
||||||
|
Some(_) => {
|
||||||
|
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||||
|
cells::kept_at(input.text(), pressed, edges)
|
||||||
|
}
|
||||||
|
None => pressed,
|
||||||
|
};
|
||||||
|
let taken = self.snapshot(cx);
|
||||||
|
let (full, offset, _) = taken.clone();
|
||||||
|
self.pressed += 1;
|
||||||
|
let press = self.pressed;
|
||||||
|
cx.spawn(async move |document, cx| {
|
||||||
|
let target = cx
|
||||||
|
.background_spawn(async move { target_at(&full, offset + pressed) })
|
||||||
|
.await;
|
||||||
|
document
|
||||||
|
.update(cx, |document, cx| {
|
||||||
|
if document.pressed != press {
|
||||||
|
return log::info!("markdown document: a newer press came first");
|
||||||
|
}
|
||||||
|
if document.open.is_none() || document.snapshot(cx) != taken {
|
||||||
|
return log::info!("markdown document: the pressed block changed first");
|
||||||
|
}
|
||||||
|
log::info!("markdown document: a secondary press in the open block");
|
||||||
|
cx.emit(DocumentEvent::Pointed(target));
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What byte `at` of `text` holds, read as drawn.
|
||||||
|
fn target_at(text: &str, at: usize) -> DocumentTarget {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let top = (root
|
||||||
|
.children()
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter())
|
||||||
|
.find(|node| span(node).start <= at && at <= span(node).end);
|
||||||
|
let block = top.map(|node| TargetBlock::of(node, text));
|
||||||
|
let mut drawn = Vec::new();
|
||||||
|
if let Some(node) = top.filter(|node| !matches!(node, mdast::Node::Definition(_))) {
|
||||||
|
Reader::new(text, &root).top(node, &mut drawn);
|
||||||
|
}
|
||||||
|
let mut flat = Vec::new();
|
||||||
|
flatten(&drawn, &mut flat);
|
||||||
|
let around: Vec<&mdast::Node> = (nodes.iter().copied())
|
||||||
|
.filter(|node| span(node).contains(&at))
|
||||||
|
.collect();
|
||||||
|
let defined = |identifier: &str| {
|
||||||
|
nodes.iter().find_map(|node| match node {
|
||||||
|
mdast::Node::Definition(definition) if definition.identifier == identifier => {
|
||||||
|
Some(definition.url.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let written = || {
|
||||||
|
around.iter().rev().find_map(|node| match node {
|
||||||
|
mdast::Node::Link(link) => Some(link.url.clone()),
|
||||||
|
mdast::Node::LinkReference(link) => defined(&link.identifier),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let picture = around
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|node| matches!(node, mdast::Node::Image(_) | mdast::Node::ImageReference(_)));
|
||||||
|
let shown = picture.and_then(|node| {
|
||||||
|
flat.iter().find_map(|kid| match kid {
|
||||||
|
Node::Image { at, url, link, .. } if *at == span(node).start => {
|
||||||
|
Some((url.clone(), link.clone()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let image = match &shown {
|
||||||
|
Some((url, _)) => Some(url.clone()),
|
||||||
|
None => picture.and_then(|node| match node {
|
||||||
|
mdast::Node::Image(picture) => Some(picture.url.clone()),
|
||||||
|
mdast::Node::ImageReference(picture) => defined(&picture.identifier),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let link = match shown {
|
||||||
|
Some((_, link)) => link,
|
||||||
|
None => drawn_link(&flat, text, at).unwrap_or_else(written),
|
||||||
|
};
|
||||||
|
let code = around.iter().find_map(|node| match node {
|
||||||
|
mdast::Node::Code(code) => Some(code.value.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
DocumentTarget {
|
||||||
|
at,
|
||||||
|
block,
|
||||||
|
link,
|
||||||
|
image,
|
||||||
|
in_table: around
|
||||||
|
.iter()
|
||||||
|
.any(|node| matches!(node, mdast::Node::Table(_))),
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nodes` and all they hold, depth first.
|
||||||
|
fn flatten<'a>(nodes: &'a [Node], out: &mut Vec<&'a Node>) {
|
||||||
|
for node in nodes {
|
||||||
|
out.push(node);
|
||||||
|
match node {
|
||||||
|
Node::Quote(body) | Node::Footnote { body, .. } | Node::Alert { body, .. } => {
|
||||||
|
flatten(body, out)
|
||||||
|
}
|
||||||
|
Node::List { items, .. } => items.iter().for_each(|(_, body)| flatten(body, out)),
|
||||||
|
Node::Table { head, rows, .. } => {
|
||||||
|
(head.iter().chain(rows.iter().flatten())).for_each(|cell| flatten(cell, out))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link drawn at source byte `at`; none if undrawn.
|
||||||
|
fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option<Option<String>> {
|
||||||
|
let inline = nodes.iter().find_map(|node| match node {
|
||||||
|
Node::Paragraph(inline) | Node::Heading(_, inline)
|
||||||
|
if (inline.sources.iter()).any(|(_, span, _)| span.contains(&at)) =>
|
||||||
|
{
|
||||||
|
Some(inline)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})?;
|
||||||
|
let drawn = inline.drawn_at(text, at)?;
|
||||||
|
let link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn));
|
||||||
|
Some(link.map(|(_, url)| url.clone()))
|
||||||
|
}
|
||||||
+269
-107
@@ -1,14 +1,17 @@
|
|||||||
//! Tables change by rows and columns (MARK-06).
|
//! Tables change by rows and columns (MARK-06).
|
||||||
|
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use gpui::Context;
|
use gpui::Context;
|
||||||
use markdown::mdast::{self, AlignKind};
|
use markdown::mdast::{self, AlignKind};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use super::MarkdownDocument;
|
use super::{
|
||||||
|
MarkdownDocument,
|
||||||
|
block::{self, BlockCommand, eol, line_end, line_start, next_line, prev_line, settled},
|
||||||
|
format::{Change, EditRefusal},
|
||||||
|
};
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
MarkdownMode,
|
MarkdownMode,
|
||||||
markdown::{span, tree, walk},
|
markdown::{file_tree, span, walk},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a table edit does at the caret.
|
/// What a table edit does at the caret.
|
||||||
@@ -20,11 +23,16 @@ pub enum TableEdit {
|
|||||||
ColumnRight,
|
ColumnRight,
|
||||||
DeleteRow,
|
DeleteRow,
|
||||||
DeleteColumn,
|
DeleteColumn,
|
||||||
|
MoveRowUp,
|
||||||
|
MoveRowDown,
|
||||||
|
MoveColumnLeft,
|
||||||
|
MoveColumnRight,
|
||||||
|
/// Every cell padded to its column, as Typora tidies.
|
||||||
|
Tidy,
|
||||||
|
/// The whole table.
|
||||||
|
Delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new table: three columns, a head and two rows.
|
|
||||||
const NEW: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |";
|
|
||||||
|
|
||||||
/// Why a table edit did not happen.
|
/// Why a table edit did not happen.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum TableRefusal {
|
pub enum TableRefusal {
|
||||||
@@ -36,46 +44,43 @@ pub enum TableRefusal {
|
|||||||
Beyond,
|
Beyond,
|
||||||
/// Its lines carry different quote or list marks.
|
/// Its lines carry different quote or list marks.
|
||||||
Mixed,
|
Mixed,
|
||||||
/// Preview takes no edits.
|
/// Nothing lies past the caret's row or column that way.
|
||||||
Preview,
|
Edge,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The table around `at` after `edit`: its range, text, caret.
|
/// The table around `at` after `edit`, and the caret.
|
||||||
pub(super) fn edit_table(
|
pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result<Change, TableRefusal> {
|
||||||
text: &str,
|
let root = file_tree(text);
|
||||||
at: usize,
|
|
||||||
edit: TableEdit,
|
|
||||||
) -> Result<(Range<usize>, String, usize), TableRefusal> {
|
|
||||||
let root = tree(text);
|
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
walk(&root, &mut nodes);
|
walk(&root, &mut nodes);
|
||||||
let (range, table) = nodes
|
let (range, table) = nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|node| match node {
|
.find_map(|node| match node {
|
||||||
mdast::Node::Table(table) if (span(node).start..=span(node).end).contains(&at) => {
|
mdast::Node::Table(table) if around(node, at) => Some((span(node), table)),
|
||||||
Some((span(node), table))
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.ok_or(TableRefusal::Outside)?;
|
.ok_or(TableRefusal::Outside)?;
|
||||||
let prefix = &text[text[..range.start].rfind('\n').map_or(0, |found| found + 1)..range.start];
|
let prefix = &text[line_start(text, range.start)..range.start];
|
||||||
if text[range.clone()]
|
let mut line = range.start;
|
||||||
.split('\n')
|
while let Some(next) = next_line(text, line).filter(|next| *next < range.end) {
|
||||||
.skip(1)
|
if !text[next..].starts_with(prefix) {
|
||||||
.any(|line| !line.starts_with(prefix))
|
return Err(TableRefusal::Mixed);
|
||||||
{
|
}
|
||||||
return Err(TableRefusal::Mixed);
|
line = next;
|
||||||
}
|
}
|
||||||
|
let eol = eol(&text[range.clone()]);
|
||||||
let rows: Vec<&mdast::Node> = table.children.iter().collect();
|
let rows: Vec<&mdast::Node> = table.children.iter().collect();
|
||||||
let row = rows
|
let row = rows
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|row| span(row).start <= at)
|
.rposition(|row| span(row).start <= at)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
|
let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone();
|
||||||
let column = cells_of(rows[row])
|
let cells = cells_of(rows[row]);
|
||||||
|
let column = cells
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|cell| span(cell).start <= at)
|
.rposition(|cell| span(cell).start <= at)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let offset = cells.get(column).map_or(0, |cell| within(cell, at));
|
||||||
let width = table.align.len();
|
let width = table.align.len();
|
||||||
// Cells past the head stay as written.
|
// Cells past the head stay as written.
|
||||||
let mut grid: Vec<Vec<String>> = rows
|
let mut grid: Vec<Vec<String>> = rows
|
||||||
@@ -94,40 +99,85 @@ pub(super) fn edit_table(
|
|||||||
let mut aligns = table.align.clone();
|
let mut aligns = table.align.clone();
|
||||||
let column_edit = matches!(
|
let column_edit = matches!(
|
||||||
edit,
|
edit,
|
||||||
TableEdit::ColumnLeft | TableEdit::ColumnRight | TableEdit::DeleteColumn
|
TableEdit::ColumnLeft
|
||||||
|
| TableEdit::ColumnRight
|
||||||
|
| TableEdit::DeleteColumn
|
||||||
|
| TableEdit::MoveColumnLeft
|
||||||
|
| TableEdit::MoveColumnRight
|
||||||
);
|
);
|
||||||
if column_edit && column >= width {
|
if column_edit && column >= width {
|
||||||
return Err(TableRefusal::Beyond);
|
return Err(TableRefusal::Beyond);
|
||||||
}
|
}
|
||||||
let (row, column) = match edit {
|
let caret = match edit {
|
||||||
TableEdit::RowAbove | TableEdit::RowBelow => {
|
TableEdit::RowAbove | TableEdit::RowBelow => {
|
||||||
let below = edit == TableEdit::RowBelow || row == 0;
|
let below = edit == TableEdit::RowBelow || row == 0;
|
||||||
let at = if below { row + 1 } else { row };
|
let at = if below { row + 1 } else { row };
|
||||||
grid.insert(at, vec![String::new(); width]);
|
grid.insert(at, vec![String::new(); width]);
|
||||||
(at, column.min(width - 1))
|
(at, column.min(width - 1), 0)
|
||||||
}
|
}
|
||||||
TableEdit::DeleteRow if row > 0 => {
|
TableEdit::DeleteRow if row > 0 => {
|
||||||
grid.remove(row);
|
grid.remove(row);
|
||||||
(row.min(grid.len() - 1), column.min(width - 1))
|
(row.min(grid.len() - 1), column.min(width - 1), 0)
|
||||||
}
|
}
|
||||||
TableEdit::ColumnLeft | TableEdit::ColumnRight => {
|
TableEdit::ColumnLeft | TableEdit::ColumnRight => {
|
||||||
let at = column + usize::from(edit == TableEdit::ColumnRight);
|
let at = column + usize::from(edit == TableEdit::ColumnRight);
|
||||||
grid.iter_mut()
|
grid.iter_mut()
|
||||||
.for_each(|cells| cells.insert(at, String::new()));
|
.for_each(|cells| cells.insert(at, String::new()));
|
||||||
aligns.insert(at, AlignKind::None);
|
aligns.insert(at, AlignKind::None);
|
||||||
(row, at)
|
(row, at, 0)
|
||||||
}
|
}
|
||||||
TableEdit::DeleteColumn if width > 1 => {
|
TableEdit::DeleteColumn if width > 1 => {
|
||||||
grid.iter_mut().for_each(|cells| {
|
grid.iter_mut().for_each(|cells| {
|
||||||
cells.remove(column);
|
cells.remove(column);
|
||||||
});
|
});
|
||||||
aligns.remove(column);
|
aligns.remove(column);
|
||||||
(row, column.min(width - 2))
|
(row, column.min(width - 2), 0)
|
||||||
}
|
}
|
||||||
|
TableEdit::MoveRowUp | TableEdit::MoveRowDown => {
|
||||||
|
let to = match edit {
|
||||||
|
TableEdit::MoveRowUp => row.checked_sub(1),
|
||||||
|
_ => Some(row + 1).filter(|to| *to < grid.len()),
|
||||||
|
}
|
||||||
|
.ok_or(TableRefusal::Edge)?;
|
||||||
|
// GFM reads a head of other width as no table.
|
||||||
|
if row.min(to) == 0 && grid[row.max(to)].len() > width {
|
||||||
|
return Err(TableRefusal::Beyond);
|
||||||
|
}
|
||||||
|
grid.swap(row, to);
|
||||||
|
(to, column, offset)
|
||||||
|
}
|
||||||
|
TableEdit::MoveColumnLeft | TableEdit::MoveColumnRight => {
|
||||||
|
let to = match edit {
|
||||||
|
TableEdit::MoveColumnLeft => column.checked_sub(1),
|
||||||
|
_ => Some(column + 1).filter(|to| *to < width),
|
||||||
|
}
|
||||||
|
.ok_or(TableRefusal::Edge)?;
|
||||||
|
grid.iter_mut().for_each(|cells| cells.swap(column, to));
|
||||||
|
aligns.swap(column, to);
|
||||||
|
(row, to, offset)
|
||||||
|
}
|
||||||
|
TableEdit::Tidy => (row, column, offset),
|
||||||
_ => return Err(TableRefusal::Kept),
|
_ => return Err(TableRefusal::Kept),
|
||||||
};
|
};
|
||||||
let (out, caret) = table_text(&grid, &aligns, (row, column), prefix);
|
let (out, caret) = table_text(&grid, &aligns, caret, prefix, eol);
|
||||||
Ok((range, out, caret))
|
let caret = range.start + caret;
|
||||||
|
Ok((vec![(range, out)], caret..caret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the table at the caret, as Delete does.
|
||||||
|
pub(super) fn delete(text: &str, at: usize) -> Result<Change, EditRefusal> {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
if !nodes.iter().any(|node| around(node, at)) {
|
||||||
|
return Err(EditRefusal::Cells(TableRefusal::Outside));
|
||||||
|
}
|
||||||
|
block::run(text, at..at, BlockCommand::Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `node` is a table holding `at`.
|
||||||
|
fn around(node: &mdast::Node, at: usize) -> bool {
|
||||||
|
matches!(node, mdast::Node::Table(_)) && (span(node).start..=span(node).end).contains(&at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cell's source between its pipes.
|
/// A cell's source between its pipes.
|
||||||
@@ -139,40 +189,83 @@ fn written(text: &str, cell: &mdast::Node) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The table's text, each line after the first under `prefix`.
|
/// `at`'s byte in `cell`'s written text.
|
||||||
|
fn within(cell: &mdast::Node, at: usize) -> usize {
|
||||||
|
let inside = cell.children().expect("a cell holds inlines");
|
||||||
|
match (inside.first(), inside.last()) {
|
||||||
|
(Some(first), Some(last)) => {
|
||||||
|
let start = span(first).start;
|
||||||
|
at.clamp(start, span(last).end) - start
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display columns, a tab four and controls none, as Typora's.
|
||||||
|
fn width(cell: &str) -> usize {
|
||||||
|
let tabs = cell.matches('\t').count();
|
||||||
|
let shown: usize = cell
|
||||||
|
.split(char::is_control)
|
||||||
|
.map(UnicodeWidthStr::width)
|
||||||
|
.sum();
|
||||||
|
shown + 4 * tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The table's text and the caret at row, column, byte.
|
||||||
fn table_text(
|
fn table_text(
|
||||||
grid: &[Vec<String>],
|
grid: &[Vec<String>],
|
||||||
aligns: &[AlignKind],
|
aligns: &[AlignKind],
|
||||||
at: (usize, usize),
|
at: (usize, usize, usize),
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
|
eol: &str,
|
||||||
) -> (String, usize) {
|
) -> (String, usize) {
|
||||||
|
// Typora pads columns 4 to 60 display columns wide.
|
||||||
|
let widths: Vec<usize> = (0..aligns.len())
|
||||||
|
.map(|column| {
|
||||||
|
(grid.iter())
|
||||||
|
.map(|cells| width(&cells[column]))
|
||||||
|
.fold(4, usize::max)
|
||||||
|
.min(60)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut caret = 0;
|
let mut caret = 0;
|
||||||
for (row, cells) in grid.iter().enumerate() {
|
for (row, cells) in grid.iter().enumerate() {
|
||||||
|
out.push('|');
|
||||||
for (column, cell) in cells.iter().enumerate() {
|
for (column, cell) in cells.iter().enumerate() {
|
||||||
out.push_str("| ");
|
let room = widths
|
||||||
if (row, column) == at {
|
.get(column)
|
||||||
caret = out.len();
|
.map_or(0, |widest| widest.saturating_sub(width(cell)));
|
||||||
|
let before = match aligns.get(column) {
|
||||||
|
Some(AlignKind::Right) => room,
|
||||||
|
Some(AlignKind::Center) => room / 2,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&" ".repeat(before));
|
||||||
|
if (row, column) == (at.0, at.1) {
|
||||||
|
caret = out.len() + at.2;
|
||||||
}
|
}
|
||||||
out.push_str(cell);
|
out.push_str(cell);
|
||||||
out.push(' ');
|
out.push_str(&" ".repeat(room - before));
|
||||||
|
out.push_str(" |");
|
||||||
}
|
}
|
||||||
out.push('|');
|
|
||||||
if row == 0 {
|
if row == 0 {
|
||||||
out.push('\n');
|
out.push_str(eol);
|
||||||
out.push_str(prefix);
|
out.push_str(prefix);
|
||||||
for align in aligns {
|
|
||||||
out.push_str(match align {
|
|
||||||
AlignKind::Left => "| :-- ",
|
|
||||||
AlignKind::Right => "| --: ",
|
|
||||||
AlignKind::Center => "| :-: ",
|
|
||||||
AlignKind::None => "| --- ",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out.push('|');
|
out.push('|');
|
||||||
|
for (align, width) in aligns.iter().zip(&widths) {
|
||||||
|
let dashes = match align {
|
||||||
|
AlignKind::Left => format!(":{}", "-".repeat(width - 1)),
|
||||||
|
AlignKind::Right => format!("{}:", "-".repeat(width - 1)),
|
||||||
|
AlignKind::Center => format!(":{}:", "-".repeat(width - 2)),
|
||||||
|
AlignKind::None => "-".repeat(*width),
|
||||||
|
};
|
||||||
|
out.push_str(&format!(" {dashes} |"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if row + 1 < grid.len() {
|
if row + 1 < grid.len() {
|
||||||
out.push('\n');
|
out.push_str(eol);
|
||||||
out.push_str(prefix);
|
out.push_str(prefix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,61 +273,34 @@ fn table_text(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
/// `edit` on the table at the caret, or why not.
|
|
||||||
pub fn edit_table(
|
|
||||||
&mut self,
|
|
||||||
edit: TableEdit,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Result<(), TableRefusal> {
|
|
||||||
if self.mode == MarkdownMode::Preview {
|
|
||||||
return Err(TableRefusal::Preview);
|
|
||||||
}
|
|
||||||
self.commit(cx);
|
|
||||||
let at = self.editor.read(cx).primary().range().start;
|
|
||||||
let text = self.editor.read(cx).text().to_string();
|
|
||||||
let (range, table, caret) = edit_table(&text, at, edit).inspect_err(|why| {
|
|
||||||
log::info!("markdown document: table edit {edit:?} refused: {why:?}");
|
|
||||||
})?;
|
|
||||||
log::info!("markdown document: table edit {edit:?}");
|
|
||||||
let caret = range.start + caret;
|
|
||||||
self.editor.update(cx, |editor, cx| {
|
|
||||||
editor.edit([(range, table)], cx);
|
|
||||||
editor.select(std::iter::once(caret..caret), cx);
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A new table after the caret's line, unless in preview.
|
/// A new table after the caret's line, unless in preview.
|
||||||
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), TableRefusal> {
|
pub fn insert_table(&mut self, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
|
||||||
if self.mode == MarkdownMode::Preview {
|
if self.mode == MarkdownMode::Preview {
|
||||||
return Err(TableRefusal::Preview);
|
return Err(EditRefusal::Preview);
|
||||||
}
|
}
|
||||||
self.commit(cx);
|
self.commit(cx);
|
||||||
let text = self.editor.read(cx).text().to_string();
|
let text = self.editor.read(cx).text().to_string();
|
||||||
let at = self.editor.read(cx).primary().range().start;
|
let at = settled(&text, self.editor.read(cx).primary().range().start);
|
||||||
let end = text[at..].find('\n').map_or(text.len(), |found| at + found);
|
let (start, end, eol) = (line_start(&text, at), line_end(&text, at), eol(&text));
|
||||||
let start = text[..at].rfind('\n').map_or(0, |found| found + 1);
|
|
||||||
// A blank line gives way; a written one keeps.
|
// A blank line gives way; a written one keeps.
|
||||||
let blank = text[start..end].trim().is_empty();
|
let blank = text[start..end].trim().is_empty();
|
||||||
let above = text[..start.saturating_sub(1)]
|
let above = prev_line(&text, start).map_or("", |line| &text[line..line_end(&text, line)]);
|
||||||
.rsplit('\n')
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let (from, before) = match (blank, above.trim().is_empty()) {
|
let (from, before) = match (blank, above.trim().is_empty()) {
|
||||||
(true, true) => (start, ""),
|
(true, true) => (start, String::new()),
|
||||||
(true, false) => (start, "\n"),
|
(true, false) => (start, eol.to_string()),
|
||||||
(false, _) => (end, "\n\n"),
|
(false, _) => (end, eol.repeat(2)),
|
||||||
};
|
};
|
||||||
let after = if text[end..].starts_with("\n\n") || end == text.len() {
|
let after = if text[end..].starts_with(&eol.repeat(2)) || end == text.len() {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
"\n"
|
eol
|
||||||
};
|
};
|
||||||
let written = format!("{before}{NEW}{after}");
|
let grid = vec![vec![String::new(); 3]; 3];
|
||||||
let caret = from + before.len() + 2;
|
let (table, caret) = table_text(&grid, &[AlignKind::None; 3], (0, 0, 0), "", eol);
|
||||||
|
let caret = from + before.len() + caret;
|
||||||
log::info!("markdown document: a table goes in");
|
log::info!("markdown document: a table goes in");
|
||||||
self.editor.update(cx, |editor, cx| {
|
self.editor.update(cx, |editor, cx| {
|
||||||
editor.edit([(from..end, written)], cx);
|
editor.edit([(from..end, format!("{before}{table}{after}"))], cx);
|
||||||
editor.select(std::iter::once(caret..caret), cx);
|
editor.select(std::iter::once(caret..caret), cx);
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -247,42 +313,126 @@ mod tests {
|
|||||||
|
|
||||||
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n";
|
const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n";
|
||||||
|
|
||||||
|
/// `text` after `edit` at `at`, the caret as `‸`.
|
||||||
fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
||||||
let at = text.find(at).expect("in the table");
|
let at = text.find(at).expect("in the table");
|
||||||
let (range, table, _) = edit_table(text, at, edit)?;
|
let (edits, caret) = edit_table(text, at, edit)?;
|
||||||
Ok(format!(
|
let (range, table) = &edits[0];
|
||||||
"{}{table}{}",
|
let out = format!("{}{table}{}", &text[..range.start], &text[range.end..]);
|
||||||
&text[..range.start],
|
Ok(format!("{}‸{}", &out[..caret.start], &out[caret.start..]))
|
||||||
&text[range.end..]
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
fn edited(at: &str, edit: TableEdit) -> Result<String, TableRefusal> {
|
||||||
edited_in(TABLE, at, edit)
|
edited_in(TABLE, at, edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn around(table: &str) -> Result<String, TableRefusal> {
|
||||||
|
Ok(format!("Intro\n\n{table}\n\nAfter\n"))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rows_and_columns_come_and_go() {
|
fn rows_and_columns_come_and_go() {
|
||||||
let around = |table: &str| Ok(format!("Intro\n\n{table}\n\nAfter\n"));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited("3", TableEdit::RowAbove),
|
edited("3", TableEdit::RowAbove),
|
||||||
around("| A | B |\n| :-- | --: |\n| 1 | 2 |\n| | |\n| 3 | 4 |")
|
around(
|
||||||
|
"| A | B |\n| :--- | ---: |\n| 1 | 2 |\n| ‸ | |\n| 3 | 4 |"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited("A", TableEdit::RowBelow),
|
edited("A", TableEdit::RowBelow),
|
||||||
around("| A | B |\n| :-- | --: |\n| | |\n| 1 | 2 |\n| 3 | 4 |")
|
around(
|
||||||
|
"| A | B |\n| :--- | ---: |\n| ‸ | |\n| 1 | 2 |\n| 3 | 4 |"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited("2", TableEdit::ColumnLeft),
|
edited("2", TableEdit::ColumnLeft),
|
||||||
around("| A | | B |\n| :-- | --- | --: |\n| 1 | | 2 |\n| 3 | | 4 |")
|
around(
|
||||||
|
"| A | | B |\n| :--- | ---- | ---: |\n| 1 | ‸ | 2 |\n| 3 | | 4 |"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited("1", TableEdit::DeleteRow),
|
edited("1", TableEdit::DeleteRow),
|
||||||
around("| A | B |\n| :-- | --: |\n| 3 | 4 |")
|
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited("B", TableEdit::DeleteColumn),
|
edited("B", TableEdit::DeleteColumn),
|
||||||
around("| A |\n| :-- |\n| 1 |\n| 3 |")
|
around("| ‸A |\n| :--- |\n| 1 |\n| 3 |")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_and_columns_move_with_the_caret() {
|
||||||
|
assert_eq!(
|
||||||
|
edited("3", TableEdit::MoveRowUp),
|
||||||
|
around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |\n| 1 | 2 |")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("A", TableEdit::MoveRowDown),
|
||||||
|
around("| 1 | 2 |\n| :--- | ---: |\n| ‸A | B |\n| 3 | 4 |")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("2", TableEdit::MoveColumnLeft),
|
||||||
|
around("| B | A |\n| ---: | :--- |\n| ‸2 | 1 |\n| 4 | 3 |")
|
||||||
|
);
|
||||||
|
assert_eq!(edited("A", TableEdit::MoveRowUp), Err(TableRefusal::Edge));
|
||||||
|
assert_eq!(edited("3", TableEdit::MoveRowDown), Err(TableRefusal::Edge));
|
||||||
|
assert_eq!(
|
||||||
|
edited("1", TableEdit::MoveColumnLeft),
|
||||||
|
Err(TableRefusal::Edge)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited("4", TableEdit::MoveColumnRight),
|
||||||
|
Err(TableRefusal::Edge)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tidy_pads_by_display_width_and_keeps_the_caret() {
|
||||||
|
let text = "| 名前 | x |\n|:-:|-|\n| 長い名前です | yy‸z |\n";
|
||||||
|
let at = text.find('‸').unwrap();
|
||||||
|
let text = text.replace('‸', "");
|
||||||
|
let (edits, caret) = edit_table(&text, at, TableEdit::Tidy).unwrap();
|
||||||
|
let out = format!(
|
||||||
|
"{}‸{}",
|
||||||
|
&edits[0].1[..caret.start],
|
||||||
|
&edits[0].1[caret.start..]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"| 名前 | x |\n| :----------: | ---- |\n| 長い名前です | yy‸z |"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rewrite_keeps_the_tables_line_ending() {
|
||||||
|
let crlf = "Before\r\n\r\n| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n\r\nAfter\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(crlf, "1", TableEdit::Tidy),
|
||||||
|
Ok("Before\r\n\r\n| A | B |\r\n| ---- | ---- |\r\n| ‸1 | 2 |\r\n\r\nAfter\r\n".into())
|
||||||
|
);
|
||||||
|
let cr = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(cr, "1", TableEdit::Tidy),
|
||||||
|
Ok("Before\r\r| A | B |\r| ---- | ---- |\r| ‸1 | 2 |\r\rAfter\r".into())
|
||||||
|
);
|
||||||
|
let moved = "| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(moved, "1", TableEdit::MoveRowUp),
|
||||||
|
Ok("| ‸1 | 2 |\r\n| ---- | ---- |\r\n| A | B |\r\n".into())
|
||||||
|
);
|
||||||
|
let quoted = "> | A |\r> | - |\r> | 1 |\r";
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(quoted, "1", TableEdit::RowBelow),
|
||||||
|
Ok("> | A |\r> | ---- |\r> | 1 |\r> | ‸ |\r".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tab_counts_four_columns_as_in_typora() {
|
||||||
|
let text = "| a\tb | x |\n| - | - |\n| q | r |\n";
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(text, "q", TableEdit::Tidy),
|
||||||
|
Ok("| a\tb | x |\n| ------ | ---- |\n| ‸q | r |\n".into())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,9 +457,18 @@ mod tests {
|
|||||||
edited_in(text, "3", TableEdit::DeleteColumn),
|
edited_in(text, "3", TableEdit::DeleteColumn),
|
||||||
Err(TableRefusal::Beyond)
|
Err(TableRefusal::Beyond)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(text, "3", TableEdit::MoveColumnLeft),
|
||||||
|
Err(TableRefusal::Beyond)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edited_in(text, "1", TableEdit::MoveRowUp),
|
||||||
|
Err(TableRefusal::Beyond),
|
||||||
|
"a head wider than its delimiter is no table"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited_in(text, "1", TableEdit::RowBelow),
|
edited_in(text, "1", TableEdit::RowBelow),
|
||||||
Ok("| A | B |\n| --- | --- |\n| 1 | 2 | 3 |\n| | |\n".into())
|
Ok("| A | B |\n| ---- | ---- |\n| 1 | 2 | 3 |\n| ‸ | |\n".into())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +477,10 @@ mod tests {
|
|||||||
let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n";
|
let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edited_in(text, "1", TableEdit::RowBelow),
|
edited_in(text, "1", TableEdit::RowBelow),
|
||||||
Ok("> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n> | | |\n".into())
|
Ok(
|
||||||
|
"> | A | B |\n> | ---- | ---- |\n> | 1 | 2 |\n> | ‸ | |\n"
|
||||||
|
.into()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
let mixed = "- | A |\n | - |\n | 1 |\n";
|
let mixed = "- | A |\n | - |\n | 1 |\n";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! Block commands as Typora's Paragraph menu (R4.7).
|
||||||
|
|
||||||
|
use gpui::{Modifiers, TestAppContext};
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::{open, opened, press, text};
|
||||||
|
use crate::documents::{
|
||||||
|
BlockCommand, EditRefusal, MarkdownMode, document::block::run, markdown::file_tree,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection.
|
||||||
|
pub(super) fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
|
||||||
|
let caret = marked.find('‸');
|
||||||
|
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||||
|
let selection = match caret {
|
||||||
|
Some(at) => at..at,
|
||||||
|
None => {
|
||||||
|
let start = marked.find('⟨').expect("a selection");
|
||||||
|
start..marked.find('⟩').expect("its end") - '⟨'.len_utf8()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (edits, selected) = run(&text, selection, command)?;
|
||||||
|
let mut out = text.clone();
|
||||||
|
for (range, with) in edits.into_iter().rev() {
|
||||||
|
out.replace_range(range, &with);
|
||||||
|
}
|
||||||
|
Ok(match selected.is_empty() {
|
||||||
|
true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]),
|
||||||
|
false => format!(
|
||||||
|
"{}⟨{}⟩{}",
|
||||||
|
&out[..selected.start],
|
||||||
|
&out[selected.clone()],
|
||||||
|
&out[selected.end..]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ok(marked: &str, command: BlockCommand) -> String {
|
||||||
|
ran(marked, command).expect("ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top-level blocks `text` reads as.
|
||||||
|
pub(super) fn kinds(text: &str) -> Vec<&'static str> {
|
||||||
|
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||||
|
let root = file_tree(&text);
|
||||||
|
let kids = root.children().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
(kids.iter())
|
||||||
|
.map(|kid| match kid {
|
||||||
|
Node::Paragraph(_) => "p",
|
||||||
|
Node::Heading(_) => "h",
|
||||||
|
Node::Blockquote(_) => "quote",
|
||||||
|
Node::List(_) => "list",
|
||||||
|
Node::Code(_) => "code",
|
||||||
|
Node::Math(_) => "math",
|
||||||
|
Node::ThematicBreak(_) => "rule",
|
||||||
|
Node::Yaml(_) => "yaml",
|
||||||
|
Node::Definition(_) => "definition",
|
||||||
|
Node::FootnoteDefinition(_) => "note",
|
||||||
|
_ => "other",
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headings_set_as_typoras_menu() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ok("a‸b", Heading(2)), "## a‸b");
|
||||||
|
assert_eq!(ok("## a‸b", Heading(2)), "## a‸b");
|
||||||
|
assert_eq!(ok("## a‸b", Heading(3)), "### a‸b");
|
||||||
|
assert_eq!(ok("a‸", Promote), "###### a‸");
|
||||||
|
assert_eq!(ok("# a‸", Promote), "# a‸");
|
||||||
|
assert_eq!(ok("## a‸", Promote), "# a‸");
|
||||||
|
assert_eq!(ok("###### a‸", Demote), "a‸");
|
||||||
|
assert_eq!(ok("a‸", Demote), "a‸");
|
||||||
|
assert_eq!(ok("a\n\n‸\n\nb", Heading(2)), "a\n\n## ‸\n\nb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paragraph_line_becomes_the_heading_alone() {
|
||||||
|
let out = ok("a\nb‸\nc", BlockCommand::Heading(1));
|
||||||
|
assert_eq!(out, "a\n# b‸\nc");
|
||||||
|
assert_eq!(kinds(&out), ["p", "h", "p"]);
|
||||||
|
assert_eq!(ok("> a\nb‸", BlockCommand::Heading(1)), "> a\n> # b‸");
|
||||||
|
assert_eq!(ok("> a‸\nb", BlockCommand::Heading(1)), "> # a‸\n> b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setext_headings_relevel_and_go_atx_past_two() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ok("a‸\n===", Heading(2)), "a‸\n---");
|
||||||
|
assert_eq!(ok("a‸\n===", Heading(3)), "### a‸");
|
||||||
|
assert_eq!(ok("a\nb‸\n---", Heading(4)), "#### a b‸");
|
||||||
|
assert_eq!(ok("a‸\n===", Paragraph), "a‸");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_made_text_keeps_apart_from_its_neighbors() {
|
||||||
|
use BlockCommand::Paragraph;
|
||||||
|
assert_eq!(ok("a\n# h‸", Paragraph), "a\n\nh‸");
|
||||||
|
assert_eq!(ok("# h‸\nb", Paragraph), "h‸\n\nb");
|
||||||
|
let out = ok("# h‸\n---", Paragraph);
|
||||||
|
assert_eq!(kinds(&out), ["p", "rule"]);
|
||||||
|
assert_eq!(ok("- a\n- # h‸", Paragraph), "- a\n- h‸");
|
||||||
|
let out = ok("> # h‸\nb", Paragraph);
|
||||||
|
assert_eq!(kinds(&out), ["quote", "p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_commands_refuse_or_add_a_paragraph_as_typora() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ran("```\na‸\n```", Heading(1)), Err(EditRefusal::Code));
|
||||||
|
assert_eq!(ran("$$\na‸\n$$", Promote), Err(EditRefusal::Math));
|
||||||
|
assert_eq!(ok("$$\na‸\n$$", Heading(1)), "$$\na\n$$\n\n‸");
|
||||||
|
assert_eq!(
|
||||||
|
ran("---\nt: a‸\n---\n", Heading(1)),
|
||||||
|
Err(EditRefusal::FrontMatter)
|
||||||
|
);
|
||||||
|
assert_eq!(ran("- [ ] a‸", Heading(1)), Err(EditRefusal::Outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quotes_wrap_and_unwrap() {
|
||||||
|
use BlockCommand::Quote;
|
||||||
|
assert_eq!(ok("a‸", Quote), "> a‸");
|
||||||
|
assert_eq!(ok("> a‸", Quote), "a‸");
|
||||||
|
assert_eq!(ok("> a\n> b‸", Quote), "a\nb‸");
|
||||||
|
assert_eq!(ok("> a\nb‸", Quote), "a\nb‸");
|
||||||
|
assert_eq!(ok("⟨a\n\nb⟩", Quote), "> ⟨a\n>\n> b⟩");
|
||||||
|
assert_eq!(ok("x\n> a‸", Quote), "x\n\na‸");
|
||||||
|
assert_eq!(ok("> [!NOTE]\n> a‸", Quote), "a‸");
|
||||||
|
assert_eq!(ok("- a‸", Quote), "- > a‸");
|
||||||
|
assert_eq!(ok("> a\n>\n> ⟨b⟩", Quote), "> a\n>\n> > ⟨b⟩");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_wrap_each_line() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ok("a‸", BulletList), "- a‸");
|
||||||
|
assert_eq!(ok("a‸", OrderedList), "1. a‸");
|
||||||
|
assert_eq!(ok("a‸", TaskList), "- [ ] a‸");
|
||||||
|
assert_eq!(ok("⟨a\nb⟩", OrderedList), "1. ⟨a\n2. b⟩");
|
||||||
|
assert_eq!(ok("> a‸", BulletList), "> - a‸");
|
||||||
|
assert_eq!(ok("- a\n\n b‸", BulletList), "- a\n\n - b‸");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_of_the_kind_unwraps_and_another_retypes() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
let out = ok("- a\n- b‸", BulletList);
|
||||||
|
assert_eq!(out, "a\n\nb‸");
|
||||||
|
assert_eq!(kinds(&out), ["p", "p"]);
|
||||||
|
assert_eq!(ok("- a\n- b‸", OrderedList), "1. a\n2. b‸");
|
||||||
|
assert_eq!(ok("- a\n b‸", OrderedList), "1. a\n b‸");
|
||||||
|
assert_eq!(ok("- [ ] a‸", BulletList), "- a‸");
|
||||||
|
assert_eq!(ok("* a‸", TaskList), "* [ ] a‸");
|
||||||
|
assert_eq!(ok("3. a\n4. b‸", BulletList), "- a\n- b‸");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inserts_split_the_paragraph_at_the_caret() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ok("ab‸cd", Rule), "ab\n\n---\n\n‸cd");
|
||||||
|
assert_eq!(ok("ab‸", CodeBlock), "ab\n\n```\n‸\n```");
|
||||||
|
assert_eq!(ok("a ⟨b⟩", CodeBlock), "a\n\n```\n‸b\n```");
|
||||||
|
assert_eq!(ok("‸ab", Toc), "[toc]‸\n\nab");
|
||||||
|
assert_eq!(ok("# ab‸cd", Rule), "# ab\n\n---\n\n# ‸cd");
|
||||||
|
assert_eq!(ok("- ab‸", Rule), "- ab\n\n ---\n\n ‸");
|
||||||
|
assert_eq!(ok("```\na‸\n```", Rule), "```\na\n```\n\n---\n\n‸");
|
||||||
|
assert_eq!(ok("a\n\n‸\n\nb", Rule), "a\n\n---\n\n‸\n\nb");
|
||||||
|
assert_eq!(ran("⟨a\n\nb⟩", Rule), Err(EditRefusal::Across));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notes_and_definitions_take_free_names() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
let out = ok("x[^1]\n\n[^1]: y\n\nab‸", Footnote);
|
||||||
|
assert_eq!(out, "x[^1]\n\n[^1]: y\n\nab\n\n[^2]: ‸");
|
||||||
|
assert_eq!(ok("‸", LinkDefinition), "[‸]: ");
|
||||||
|
assert_eq!(ok("⟨see⟩", LinkDefinition), "[see]: ‸");
|
||||||
|
assert_eq!(ok("⟨a/b.md⟩", LinkDefinition), "[‸]: a/b.md");
|
||||||
|
let out = ok("ab‸", MathBlock);
|
||||||
|
assert_eq!(kinds(&out), ["p", "math"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_paragraphs_come_before_and_after() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ok("a‸", ParagraphAfter), "a\n\n‸");
|
||||||
|
assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb");
|
||||||
|
assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb");
|
||||||
|
assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h");
|
||||||
|
let out = ok("- a‸", ParagraphBefore);
|
||||||
|
assert_eq!(out, "- ‸\n a");
|
||||||
|
assert_eq!(kinds(&out), ["list"]);
|
||||||
|
let out = ok("x[^1]\n\n[^1]: a‸", ParagraphBefore);
|
||||||
|
assert_eq!(out, "x[^1]\n\n[^1]: ‸\n a");
|
||||||
|
assert_eq!(kinds(&out), ["p", "note"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn front_matter_goes_on_top_once() {
|
||||||
|
let out = ok("a‸", BlockCommand::FrontMatter);
|
||||||
|
assert_eq!(out, "---\n‸\n---\n\na");
|
||||||
|
assert_eq!(kinds(&out), ["yaml", "p"]);
|
||||||
|
assert_eq!(
|
||||||
|
ran("---\nt: 1\n---\n\na‸", BlockCommand::FrontMatter),
|
||||||
|
Err(EditRefusal::Present)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_takes_the_block_or_its_lone_holder() {
|
||||||
|
use BlockCommand::Delete;
|
||||||
|
assert_eq!(ok("a\n\nb‸\n\nc", Delete), "a\n\n‸c");
|
||||||
|
assert_eq!(ok("x\n\n> b‸\n\nc", Delete), "x\n\n‸c");
|
||||||
|
assert_eq!(ok("- a\n- b‸\n- c", Delete), "- a\n‸- c");
|
||||||
|
assert_eq!(ok("- a‸\n\n b", Delete), "- ‸\n\n b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_endings_follow_the_text() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("ab‸\r\n", BlockCommand::ParagraphAfter),
|
||||||
|
"ab\r\n\r\n‸\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_source_command_is_one_undo_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha\n", MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||||
|
document.block(BlockCommand::Heading(2), cx).expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "## alpha\n");
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
});
|
||||||
|
assert_eq!(text(&document, cx), "alpha\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_open_block_takes_a_command_and_stays_open(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha\n", MarkdownMode::Visual, cx);
|
||||||
|
press(0, Modifiers::none(), cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.block(BlockCommand::Quote, cx).expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(opened(&document, cx), "> alpha", "still open to type in");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("first\n\nalpha\n", MarkdownMode::Visual, cx);
|
||||||
|
press(1, Modifiers::none(), cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let open = document.open.as_ref().expect("opened");
|
||||||
|
open.input.update(cx, |input, cx| input.insert("x", cx));
|
||||||
|
document
|
||||||
|
.block(BlockCommand::FrontMatter, cx)
|
||||||
|
.expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let written = text(&document, cx);
|
||||||
|
assert!(written.starts_with("---\n\n---\n"), "{written:?}");
|
||||||
|
assert!(
|
||||||
|
written.contains("alpha") && written.contains('x'),
|
||||||
|
"{written:?}"
|
||||||
|
);
|
||||||
|
document.read_with(cx, |document, _| assert!(document.open.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one list's items, each as its blocks' kinds.
|
||||||
|
fn items(text: &str) -> Vec<Vec<&'static str>> {
|
||||||
|
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||||
|
let root = file_tree(&text);
|
||||||
|
let Some(Node::List(list)) = root.children().and_then(|kids| kids.first()) else {
|
||||||
|
panic!("a list first");
|
||||||
|
};
|
||||||
|
(list.children.iter())
|
||||||
|
.map(|item| {
|
||||||
|
(item
|
||||||
|
.children()
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter())
|
||||||
|
.map(|kid| match kid {
|
||||||
|
Node::Paragraph(_) => "p",
|
||||||
|
Node::Code(_) => "code",
|
||||||
|
_ => "other",
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_caret_in_heading_marks_splits_at_the_text() {
|
||||||
|
use BlockCommand::Rule;
|
||||||
|
assert_eq!(ok("‸# alpha", Rule), "---\n\n# ‸alpha");
|
||||||
|
let out = ok("# alpha #‸##", Rule);
|
||||||
|
assert_eq!(kinds(&out), ["h", "rule"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_list_keeps_checked_boxes() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("- a\n- [x] b‸", BlockCommand::TaskList),
|
||||||
|
"- [ ] a\n- [x] b‸"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_alert_in_an_item_keeps_the_item() {
|
||||||
|
let out = ok("- > [!NOTE]\n > a‸\n- b", BlockCommand::Quote);
|
||||||
|
assert_eq!(out, "- a‸\n- b");
|
||||||
|
assert_eq!(items(&out), [vec!["p"], vec!["p"]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_split_closes_and_reopens_inline_marks() {
|
||||||
|
use BlockCommand::Rule;
|
||||||
|
let out = ok("**ab‸cd**", Rule);
|
||||||
|
assert_eq!(out, "**ab**\n\n---\n\n**‸cd**");
|
||||||
|
assert_eq!(kinds(&out), ["p", "rule", "p"]);
|
||||||
|
assert_eq!(ran("[a‸b](u)", Rule), Err(EditRefusal::Across));
|
||||||
|
assert_eq!(ran("`a‸b`", Rule), Err(EditRefusal::Across));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_retyped_item_keeps_its_code_indent() {
|
||||||
|
let out = ok("- code‸", BlockCommand::OrderedList);
|
||||||
|
assert_eq!(out, "1. code‸");
|
||||||
|
assert_eq!(items(&out), [vec!["code"]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_keeps_neighbors_apart() {
|
||||||
|
let out = ok("p\n# h‸\nq", BlockCommand::Delete);
|
||||||
|
assert_eq!(out, "p\n‸\nq");
|
||||||
|
assert_eq!(kinds(&out), ["p", "p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_marks_keep_their_text() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
let out = ok("alpha #‸", Heading(1));
|
||||||
|
assert_eq!(out, "# alpha \\#‸");
|
||||||
|
assert_eq!(kinds(&out), ["h"]);
|
||||||
|
let out = ok("# - alpha‸", Paragraph);
|
||||||
|
assert_eq!(out, "\\- alpha‸");
|
||||||
|
assert_eq!(kinds(&out), ["p"]);
|
||||||
|
assert_eq!(kinds(&ok("# 1. a‸", Paragraph)), ["p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tasks_first_text_takes_no_block() {
|
||||||
|
use BlockCommand::*;
|
||||||
|
assert_eq!(ran("- [ ] a‸", Quote), Err(EditRefusal::Outside));
|
||||||
|
assert_eq!(ran("- [ ] ‸ab", Rule), Err(EditRefusal::Outside));
|
||||||
|
assert_eq!(ran("- [ ] ‸ab", CodeBlock), Err(EditRefusal::Outside));
|
||||||
|
assert_eq!(ran("- [ ] a‸", ParagraphBefore), Err(EditRefusal::Outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_selection_past_a_list_takes_its_siblings() {
|
||||||
|
let out = ok("- ⟨a\n\nb⟩", BlockCommand::OrderedList);
|
||||||
|
assert_eq!(out, "1. ⟨a\n\n2. b⟩");
|
||||||
|
assert_eq!(kinds(&out), ["list"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! Block commands whose results reread as written.
|
||||||
|
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::block::{kinds, ok, ran};
|
||||||
|
use crate::documents::{BlockCommand, EditRefusal, markdown::file_tree};
|
||||||
|
|
||||||
|
fn plain(marked: &str) -> String {
|
||||||
|
marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every node of `text`, in document order.
|
||||||
|
fn nodes(text: &str, keep: &mut dyn FnMut(&Node)) {
|
||||||
|
fn walk(node: &Node, keep: &mut dyn FnMut(&Node)) {
|
||||||
|
keep(node);
|
||||||
|
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||||
|
walk(kid, keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(&file_tree(text), keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checks(text: &str) -> Vec<Option<bool>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
nodes(text, &mut |node| {
|
||||||
|
if let Node::ListItem(item) = node {
|
||||||
|
out.push(item.checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codes(text: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
nodes(text, &mut |node| {
|
||||||
|
if let Node::Code(code) = node {
|
||||||
|
out.push(code.value.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texts(text: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
nodes(text, &mut |node| {
|
||||||
|
if let Node::Text(text) = node {
|
||||||
|
out.push(text.value.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_split_that_would_reread_otherwise_refuses() {
|
||||||
|
assert_eq!(
|
||||||
|
ran("~~ab‸~cd~~", BlockCommand::Rule),
|
||||||
|
Err(EditRefusal::Misread)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ran("__ab‸_cd__", BlockCommand::Rule),
|
||||||
|
Err(EditRefusal::Misread)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_split_rest_that_would_open_a_block_is_escaped() {
|
||||||
|
let out = plain(&ok("a ‸- b", BlockCommand::Rule));
|
||||||
|
assert_eq!(out, "a\n\n---\n\n\\- b");
|
||||||
|
assert_eq!(kinds(&out), ["p", "rule", "p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_hashes_stay_text_wherever_a_heading_line_ends() {
|
||||||
|
let out = plain(&ok("alpha #‸\n===", BlockCommand::Heading(3)));
|
||||||
|
assert_eq!(out, "### alpha \\#");
|
||||||
|
assert_eq!(texts(&out), ["alpha #"]);
|
||||||
|
let out = plain(&ok("# a #‸ b", BlockCommand::Rule));
|
||||||
|
assert_eq!(kinds(&out), ["h", "rule", "h"]);
|
||||||
|
assert_eq!(texts(&out), ["a #", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paragraph_reads_the_heading_body_without_closing_hashes() {
|
||||||
|
let out = plain(&ok("# ---‸ ###", BlockCommand::Paragraph));
|
||||||
|
assert_eq!(out, "\\---");
|
||||||
|
assert_eq!(kinds(&out), ["p"]);
|
||||||
|
let out = plain(&ok("# [x]: /u‸ ###", BlockCommand::Paragraph));
|
||||||
|
assert_eq!(kinds(&out), ["p"]);
|
||||||
|
assert_eq!(texts(&out).concat(), "[x]: /u");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_box_below_its_marker_stays_checked() {
|
||||||
|
let out = plain(&ok("1.‸\n [x] a", BlockCommand::TaskList));
|
||||||
|
assert_eq!(checks(&out), [Some(true)]);
|
||||||
|
assert_eq!(texts(&out), ["a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retyping_keeps_code_columns_past_tabs() {
|
||||||
|
let source = "-\t\t‸code";
|
||||||
|
let out = plain(&ok(source, BlockCommand::OrderedList));
|
||||||
|
assert_eq!(codes(&out), codes(&plain(source)));
|
||||||
|
assert_eq!(codes(&out).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_cannot_box_code() {
|
||||||
|
assert_eq!(
|
||||||
|
ran("- ‸a", BlockCommand::TaskList),
|
||||||
|
Err(EditRefusal::Code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_alert_unwraps_into_its_holder() {
|
||||||
|
assert_eq!(
|
||||||
|
plain(&ok("- > [!NOTE]‸\n a", BlockCommand::Quote)),
|
||||||
|
"- a"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plain(&ok("> > [!NOTE]‸\n> a", BlockCommand::Quote)),
|
||||||
|
"> a"
|
||||||
|
);
|
||||||
|
let out = plain(&ok("- > [!NOTE]‸\n >\n >\n > a", BlockCommand::Quote));
|
||||||
|
assert_eq!(out, "- a");
|
||||||
|
assert_eq!(kinds(&out), ["list"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_alert_marker_sharing_its_line_refuses() {
|
||||||
|
assert_eq!(
|
||||||
|
ran("> [!NOTE] a‸", BlockCommand::Quote),
|
||||||
|
Err(EditRefusal::Misread)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_keeps_neighbors_apart_past_gone_openers() {
|
||||||
|
let out = plain(&ok("p\n> # ‸h\nq", BlockCommand::Delete));
|
||||||
|
assert_eq!(out, "p\n\nq");
|
||||||
|
assert_eq!(kinds(&out), ["p", "p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inline_codes(text: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
nodes(text, &mut |node| {
|
||||||
|
if let Node::InlineCode(code) = node {
|
||||||
|
out.push(code.value.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_join_keeps_white_space_in_code() {
|
||||||
|
let source = "a `x\n y`‸\n===";
|
||||||
|
match ran(source, BlockCommand::Heading(3)) {
|
||||||
|
Err(EditRefusal::Misread) => {}
|
||||||
|
Ok(out) => assert_eq!(inline_codes(&plain(&out)), inline_codes(&plain(source))),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_split_that_draws_otherwise_refuses() {
|
||||||
|
for (source, command) in [
|
||||||
|
("<u>ab‸cd</u>", BlockCommand::Rule),
|
||||||
|
("<u>a‸\nb</u>", BlockCommand::Heading(1)),
|
||||||
|
("x:smile: ‸:smile:", BlockCommand::Rule),
|
||||||
|
] {
|
||||||
|
assert_eq!(ran(source, command), Err(EditRefusal::Misread), "{source}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_alert_body_block_keeps_its_indent() {
|
||||||
|
let out = plain(&ok("> [!NOTE]\n>\n> co‸de", BlockCommand::Quote));
|
||||||
|
assert_eq!(
|
||||||
|
(kinds(&out), codes(&out)),
|
||||||
|
(vec!["code"], vec!["code".to_string()])
|
||||||
|
);
|
||||||
|
let out = plain(&ok(
|
||||||
|
"> [!NOTE]\n>\n> ```\n> co‸de\n> ```",
|
||||||
|
BlockCommand::Quote,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
(kinds(&out), codes(&out)),
|
||||||
|
(vec!["code"], vec!["code".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retyping_reads_a_fence_from_its_source() {
|
||||||
|
let out = plain(&ok("- ```lang‸", BlockCommand::OrderedList));
|
||||||
|
assert_eq!(codes(&out), vec!["```lang".to_string()]);
|
||||||
|
let out = plain(&ok("- \u{a0}code‸", BlockCommand::OrderedList));
|
||||||
|
assert_eq!(codes(&out), vec!["\u{a0}code".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_removed_box_leaves_text() {
|
||||||
|
assert_eq!(
|
||||||
|
plain(&ok("- [x] ---‸", BlockCommand::BulletList)),
|
||||||
|
"- \\---"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plain(&ok("- [x] # h‸", BlockCommand::BulletList)),
|
||||||
|
"- \\# h"
|
||||||
|
);
|
||||||
|
assert_eq!(plain(&ok("-\n [x] a‸", BlockCommand::TaskList)), "a");
|
||||||
|
assert_eq!(plain(&ok("- [x] # h‸", BlockCommand::TaskList)), "\\# h");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_heading_becomes_an_empty_paragraph() {
|
||||||
|
assert!(ran("# ‸", BlockCommand::Paragraph).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_escaped_alert_marker_on_its_own_line_unwraps() {
|
||||||
|
assert_eq!(plain(&ok("> \\[!NOTE]\n> a‸", BlockCommand::Quote)), "a");
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! A press in a table opens only its cell (R6.1).
|
||||||
|
|
||||||
|
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||||
|
|
||||||
|
use super::{open, opened, point::heard, text};
|
||||||
|
use crate::documents::{MarkdownDocument, MarkdownMode};
|
||||||
|
|
||||||
|
const TABLE: &str = "| a | **bold** |\n| --- | --- |\n| c | |\n";
|
||||||
|
|
||||||
|
/// Presses cell `row`, `column` of the first table.
|
||||||
|
fn press_cell(row: usize, column: usize, cx: &mut VisualTestContext) {
|
||||||
|
press_cell_with(row, column, MouseButton::Left, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_cell_with(row: usize, column: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||||
|
let name = format!("markdown-cell-0-{row}-{column}");
|
||||||
|
let bounds = cx
|
||||||
|
.debug_bounds(Box::leak(name.into_boxed_str()))
|
||||||
|
.expect("the cell is drawn");
|
||||||
|
let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0));
|
||||||
|
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presses the open cell's field `x` past its left.
|
||||||
|
fn press_field(x: f32, button: MouseButton, cx: &mut VisualTestContext) {
|
||||||
|
let field = cx
|
||||||
|
.debug_bounds("markdown-open-cell")
|
||||||
|
.expect("a cell is open");
|
||||||
|
let at = point(field.left() + px(x), field.top() + px(10.0));
|
||||||
|
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
|
||||||
|
document.read_with(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.expect("open")
|
||||||
|
.input
|
||||||
|
.read(cx)
|
||||||
|
.cursor()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where byte `k` of the open cell lies.
|
||||||
|
fn x_of(document: &Entity<MarkdownDocument>, k: usize, cx: &mut VisualTestContext) -> f32 {
|
||||||
|
(0..400)
|
||||||
|
.map(|step| step as f32)
|
||||||
|
.find(|&x| {
|
||||||
|
press_field(x, MouseButton::Left, cx);
|
||||||
|
caret(document, cx) == k
|
||||||
|
})
|
||||||
|
.expect("the byte is drawn")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_opens_its_cell_and_writes_one_undo_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 1, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "**bold**");
|
||||||
|
assert!(
|
||||||
|
cx.debug_bounds("markdown-cell-0-0-0").is_some(),
|
||||||
|
"the rest stays drawn"
|
||||||
|
);
|
||||||
|
press_cell(1, 1, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "");
|
||||||
|
cx.simulate_input("x|y");
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| a | **bold** |\n| --- | --- |\n| c | x\\|y |\n"
|
||||||
|
);
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tab_and_arrows_walk_the_cells(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TABLE, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "a");
|
||||||
|
cx.simulate_keystrokes("tab");
|
||||||
|
assert_eq!(opened(&document, cx), "**bold**");
|
||||||
|
cx.simulate_keystrokes("shift-tab");
|
||||||
|
assert_eq!(opened(&document, cx), "a");
|
||||||
|
cx.simulate_keystrokes("down");
|
||||||
|
assert_eq!(opened(&document, cx), "c");
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||||
|
assert_eq!(text(&document, cx), TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORDS: &str = "| alpha | beta |\n| --- | --- |\n| c | d |\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_in_the_open_cell_moves_its_caret_and_keeps_one_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
assert_eq!(caret(&document, cx), 5);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
let x = x_of(&document, 1, cx);
|
||||||
|
press_field(x, MouseButton::Left, cx);
|
||||||
|
assert_eq!(caret(&document, cx), 1, "the cell stays open");
|
||||||
|
cx.simulate_input("Y");
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| aYlphaX | beta |\n| --- | --- |\n| c | d |\n"
|
||||||
|
);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), WORDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_match_in_a_table_opens_its_cell_selected(cx: &mut TestAppContext) {
|
||||||
|
let table = "| needle | B |\n| --- | --- |\n| C | tail |\n";
|
||||||
|
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||||
|
let at = table.find("needle").expect("the word");
|
||||||
|
document.update_in(cx, |document, window, cx| {
|
||||||
|
document.show_match(at..at + 6, window, cx)
|
||||||
|
});
|
||||||
|
assert_eq!(opened(&document, cx), "needle");
|
||||||
|
let picked = document.read_with(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.expect("open")
|
||||||
|
.input
|
||||||
|
.read(cx)
|
||||||
|
.selection()
|
||||||
|
});
|
||||||
|
assert_eq!(picked, 0..6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_closing_backslash_keeps_the_table(cx: &mut TestAppContext) {
|
||||||
|
let table = "|a|b|\n|---|---|\n|c|d|\n";
|
||||||
|
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("\\");
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(text(&document, cx), "|a\\\\|b|\n|---|---|\n|c|d|\n");
|
||||||
|
press_cell(0, 1, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "b", "still its own cell");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_after_typed_pipes_finds_the_link(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_cell(1, 1, cx);
|
||||||
|
cx.simulate_keystrokes("backspace");
|
||||||
|
cx.simulate_input("||[go](https://ely.dev)");
|
||||||
|
let x = x_of(&document, 3, cx);
|
||||||
|
press_field(x, MouseButton::Right, cx);
|
||||||
|
let target = heard.borrow().last().cloned().expect("heard");
|
||||||
|
assert_eq!(target.link.as_deref(), Some("https://ely.dev"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_on_a_cell_points_at_it(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_cell_with(1, 1, MouseButton::Right, cx);
|
||||||
|
let at = heard.borrow().last().expect("heard").at;
|
||||||
|
assert_eq!(at, WORDS.rfind(" d").expect("the cell") + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_untouched_backslash_cell_keeps_its_bytes(cx: &mut TestAppContext) {
|
||||||
|
let table = "| a\\ | b |\n| --- | --- |\n| c | d |\n";
|
||||||
|
let (document, cx) = open(table, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "a\\");
|
||||||
|
document.update_in(cx, |document, window, cx| {
|
||||||
|
document.set_mode(MarkdownMode::Source, window, cx)
|
||||||
|
});
|
||||||
|
assert_eq!(text(&document, cx), table);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BARE: &str = "alpha | beta\n--- | ---\nc | d\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tab_leaves_an_edited_first_cell_without_outer_pipes(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
cx.simulate_keystrokes("tab");
|
||||||
|
assert_eq!(opened(&document, cx), "beta");
|
||||||
|
assert_eq!(text(&document, cx), "alphaX | beta\n--- | ---\nc | d\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_neighbour_press_leaves_an_edited_first_cell(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
press_cell(0, 1, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "beta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_open_cells_padding_lands_at_its_end_in_one_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
let x = x_of(&document, 1, cx);
|
||||||
|
press_field(x, MouseButton::Left, cx);
|
||||||
|
assert_eq!(caret(&document, cx), 1);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
assert_eq!(caret(&document, cx), 6, "the padding lands at the end");
|
||||||
|
cx.simulate_input("Y");
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| alphaXY | beta |\n| --- | --- |\n| c | d |\n"
|
||||||
|
);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), WORDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_on_the_open_cells_padding_points_at_its_end(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("||");
|
||||||
|
press_cell_with(0, 0, MouseButton::Right, cx);
|
||||||
|
let target = heard.borrow().last().cloned().expect("heard");
|
||||||
|
assert!(target.in_table);
|
||||||
|
assert_eq!(
|
||||||
|
target.at,
|
||||||
|
WORDS.find("alpha").expect("the cell") + "alpha\\|\\|".len()
|
||||||
|
);
|
||||||
|
assert_eq!(opened(&document, cx), "alpha||", "the cell stays open");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_caret_after_a_typed_pipe_reads_the_escaped_cell(cx: &mut TestAppContext) {
|
||||||
|
for (typed, column) in [("|中", 5), ("|x", 5)] {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_keystrokes("backspace backspace backspace backspace backspace");
|
||||||
|
cx.simulate_input(typed);
|
||||||
|
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||||
|
assert_eq!(caret, (0, column), "{typed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn emptied_bare_edge_cells_keep_the_table(cx: &mut TestAppContext) {
|
||||||
|
let cases = [
|
||||||
|
(0, 0, 5, "| | beta\n--- | ---\nc | d\n"),
|
||||||
|
(0, 1, 4, "alpha | |\n--- | ---\nc | d\n"),
|
||||||
|
(1, 0, 1, "alpha | beta\n--- | ---\n| | d\n"),
|
||||||
|
];
|
||||||
|
for (row, column, chars, written) in cases {
|
||||||
|
let (document, cx) = open(BARE, MarkdownMode::Visual, cx);
|
||||||
|
press_cell(row, column, cx);
|
||||||
|
for _ in 0..chars {
|
||||||
|
cx.simulate_keystrokes("backspace");
|
||||||
|
}
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(text(&document, cx), written);
|
||||||
|
press_cell(1, 1, cx);
|
||||||
|
assert_eq!(opened(&document, cx), "d", "{written:?} is still a table");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_beside_an_edited_cell_points(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(WORDS, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
press_cell_with(0, 1, MouseButton::Right, cx);
|
||||||
|
let at = heard.borrow().last().expect("heard").at;
|
||||||
|
let written = "| alphaX | beta |\n| --- | --- |\n| c | d |\n";
|
||||||
|
assert_eq!(text(&document, cx), written);
|
||||||
|
assert_eq!(at, written.find("beta").expect("the cell") + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tab_in_a_long_document_opens_the_next_cell_once_read(cx: &mut TestAppContext) {
|
||||||
|
let long = format!("{WORDS}\n```\n{}\n```\n", "x".repeat(super::super::AT_ONCE));
|
||||||
|
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||||
|
document.update_in(cx, |document, window, cx| document.reveal(0, window, cx));
|
||||||
|
super::frames(cx);
|
||||||
|
press_cell(0, 0, cx);
|
||||||
|
cx.simulate_input("X");
|
||||||
|
cx.simulate_keystrokes("tab");
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(opened(&document, cx), "beta");
|
||||||
|
assert!(text(&document, cx).starts_with("| alphaX | beta |"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
//! Inline formats toggle as Typora's (R4.6).
|
||||||
|
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
|
||||||
|
use markdown::mdast::Node;
|
||||||
|
use web_time::Instant;
|
||||||
|
|
||||||
|
use super::{open, opened, press, text};
|
||||||
|
use crate::documents::{
|
||||||
|
DocumentEvent, EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
||||||
|
document::{
|
||||||
|
format::{toggle, toggle_tree},
|
||||||
|
visual,
|
||||||
|
},
|
||||||
|
markdown::file_tree,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection.
|
||||||
|
fn toggled(marked: &str, format: InlineFormat) -> Result<String, EditRefusal> {
|
||||||
|
let caret = marked.find('‸');
|
||||||
|
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||||
|
let selection = match caret {
|
||||||
|
Some(at) => at..at,
|
||||||
|
None => {
|
||||||
|
let start = marked.find('⟨').expect("a selection");
|
||||||
|
start..marked.find('⟩').expect("its end") - '⟨'.len_utf8()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (edits, selected) = toggle(&text, selection, format)?;
|
||||||
|
let mut out = text.clone();
|
||||||
|
for (range, with) in edits.into_iter().rev() {
|
||||||
|
out.replace_range(range, &with);
|
||||||
|
}
|
||||||
|
Ok(match selected.is_empty() {
|
||||||
|
true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]),
|
||||||
|
false => format!(
|
||||||
|
"{}⟨{}⟩{}",
|
||||||
|
&out[..selected.start],
|
||||||
|
&out[selected.clone()],
|
||||||
|
&out[selected.end..]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(marked: &str, format: InlineFormat) -> String {
|
||||||
|
toggled(marked, format).expect("toggled")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_selection_wraps_and_a_styled_one_unwraps() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(ok("a ⟨bold⟩ z", Strong), "a **⟨bold⟩** z");
|
||||||
|
assert_eq!(
|
||||||
|
ok("a ⟨word ⟩z", Strong),
|
||||||
|
"a **⟨word⟩** z",
|
||||||
|
"end space stays out"
|
||||||
|
);
|
||||||
|
assert_eq!(ok("a **⟨bold⟩** z", Strong), "a ⟨bold⟩ z");
|
||||||
|
assert_eq!(ok("⟨a⟩", Emphasis), "*⟨a⟩*");
|
||||||
|
assert_eq!(ok("⟨a⟩", Strike), "~~⟨a⟩~~");
|
||||||
|
assert_eq!(ok("⟨a⟩", Underline), "<u>⟨a⟩</u>");
|
||||||
|
assert_eq!(ok("x ⟨a`b⟩ y", Code), "x ``⟨a`b⟩`` y");
|
||||||
|
assert_eq!(ok("⟨site⟩", Link), "[site](‸)");
|
||||||
|
assert_eq!(ok("⟨cat⟩", Image), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_caret_takes_its_word_or_an_empty_pair() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(ok("a wo‸rd z", Strong), "a **⟨word⟩** z");
|
||||||
|
assert_eq!(ok("a ‸ z", Strong), "a **‸** z");
|
||||||
|
assert_eq!(ok("a\n\n‸", Emphasis), "a\n\n*‸*", "a blank line");
|
||||||
|
assert_eq!(ok("a ‸ z", Link), "a [‸]() z");
|
||||||
|
assert_eq!(
|
||||||
|
ok("x wo‸rd", Strong),
|
||||||
|
"x **⟨word⟩**",
|
||||||
|
"a wide space stops it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_caret_in_a_style_takes_it_off() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(ok("a **b‸old** z", Strong), "a b‸old z");
|
||||||
|
assert_eq!(ok("~~a‸~~", Strike), "a‸");
|
||||||
|
assert_eq!(ok("x `co‸de` y", Code), "x co‸de y");
|
||||||
|
assert_eq!(ok("<u>a‸b</u>", Underline), "a‸b");
|
||||||
|
assert_eq!(ok("[si‸te](https://x)", Link), "si‸te");
|
||||||
|
assert_eq!(ok("", Image), "c‸at");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partly_bold_selection_takes_the_bold_in() {
|
||||||
|
assert_eq!(ok("a **b** ⟨c⟩", InlineFormat::Strong), "a **b** **⟨c⟩**");
|
||||||
|
assert_eq!(ok("a **⟨b** c⟩", InlineFormat::Strong), "a **⟨b c⟩**");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_takes_every_mark_off_the_touched_blocks() {
|
||||||
|
let cleared = ok(
|
||||||
|
"**a** ‸`b` [c](u) <u>d</u> ",
|
||||||
|
InlineFormat::Clear,
|
||||||
|
);
|
||||||
|
assert_eq!(cleared, "a ‸b c d e");
|
||||||
|
assert_eq!(ok("**a‸**\n\n*b*", InlineFormat::Clear), "a‸\n\n*b*");
|
||||||
|
assert_eq!(ok("⟨**a**\n\n*b*⟩", InlineFormat::Clear), "⟨a\n\nb⟩");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_math_html_and_front_matter_refuse() {
|
||||||
|
use EditRefusal as Why;
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code));
|
||||||
|
assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code));
|
||||||
|
assert_eq!(toggled("$$\nx‸\n$$", Strong), Err(Why::Math));
|
||||||
|
assert_eq!(toggled("a $x‸$ b", Emphasis), Err(Why::Math));
|
||||||
|
assert_eq!(toggled("<div>\nx‸\n</div>", Strong), Err(Why::Html));
|
||||||
|
assert_eq!(
|
||||||
|
toggled("---\nti‸tle: a\n---\n\nbody", Strong),
|
||||||
|
Err(Why::FrontMatter)
|
||||||
|
);
|
||||||
|
assert_eq!(toggled("```\nco‸de\n```", Clear), Err(Why::Code));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_selection_across_a_link_or_into_its_address_refuses() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(EditRefusal::Across));
|
||||||
|
assert_eq!(toggled("[a](ht‸tp://x)", Strong), Err(EditRefusal::Across));
|
||||||
|
assert_eq!(toggled("a\n\n> ‸", Strong), Err(EditRefusal::Outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_open_block_stays_open_and_undoes_as_one_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha beta\n", MarkdownMode::Visual, cx);
|
||||||
|
press(0, Modifiers::none(), cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let open = document.open.as_ref().expect("opened");
|
||||||
|
open.input.update(cx, |input, cx| input.select(6..10, cx));
|
||||||
|
document
|
||||||
|
.format(InlineFormat::Strong, cx)
|
||||||
|
.expect("formatted");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
opened(&document, cx),
|
||||||
|
"alpha **beta**",
|
||||||
|
"still open to type in"
|
||||||
|
);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let open = document.open.as_ref().expect("opened");
|
||||||
|
assert_eq!(
|
||||||
|
open.input.read(cx).selection(),
|
||||||
|
8..12,
|
||||||
|
"the word stays picked"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cx.dispatch_action(visual::Undo);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "alpha beta\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_source_takes_a_format_as_one_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||||
|
document.format(InlineFormat::Code, cx).expect("formatted");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "`alpha` beta\n");
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
});
|
||||||
|
assert_eq!(text(&document, cx), "alpha beta\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn preview_refuses_a_format(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx);
|
||||||
|
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||||
|
assert_eq!(refused, Err(EditRefusal::Preview));
|
||||||
|
assert_eq!(text(&document, cx), "alpha\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_end_in_marks_or_an_address_refuses() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
let across = Err(EditRefusal::Across);
|
||||||
|
assert_eq!(toggled("a *‸*bold** z", Strong), across);
|
||||||
|
assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across);
|
||||||
|
assert_eq!(toggled("a *⟨*b** c⟩", Strong), across);
|
||||||
|
assert_eq!(toggled("*a⟨b*⟩", Strong), across);
|
||||||
|
assert_eq!(toggled("⟨alpha\n\nbeta⟩", Strong), across);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_spaces_cells_and_cr_lines_take_a_pair() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
assert_eq!(ok("alpha ‸", Strong), "alpha **‸**");
|
||||||
|
assert_eq!(ok("| h |\n| - |\n| ‸ |", Strong), "| h |\n| - |\n| **‸** |");
|
||||||
|
assert_eq!(ok("a\r‸\rb", Strong), "a\r**‸**\rb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_takes_math_footnote_and_escape_marks() {
|
||||||
|
use InlineFormat::Clear;
|
||||||
|
assert_eq!(ok("**a** $x‸+y$", Clear), "a x‸+y");
|
||||||
|
assert_eq!(ok("a‸ [^n]\n\n[^n]: note", Clear), "a‸ n\n\n[^n]: note");
|
||||||
|
assert_eq!(ok(r"a‸ \*b\*", Clear), "a‸ *b*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn underline_comes_off_past_other_tags() {
|
||||||
|
use InlineFormat::Underline;
|
||||||
|
assert_eq!(ok("<u>a <br>b‸c</u>", Underline), "a <br>b‸c");
|
||||||
|
assert_eq!(ok("<u>a <em>b‸c</em> d</u>", Underline), "a <em>b‸c</em> d");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source document of one word, caret at 1.
|
||||||
|
fn word(cx: &mut TestAppContext, len: usize) -> (Entity<MarkdownDocument>, &mut VisualTestContext) {
|
||||||
|
let (document, cx) = open(&"x".repeat(len), MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
|
||||||
|
});
|
||||||
|
(document, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_format_reads_apart(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, 64);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken")
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!text(&document, cx).starts_with("**"),
|
||||||
|
"not on the UI thread"
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(text(&document, cx).starts_with("**x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn many_short_paragraphs_format_apart(cx: &mut TestAppContext) {
|
||||||
|
let source = "a\n\n".repeat(100);
|
||||||
|
let (document, cx) = open(&source, MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(0..1), cx));
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
});
|
||||||
|
assert_eq!(text(&document, cx), source, "not on the UI thread");
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(text(&document, cx).starts_with("**a**\n\na"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, 64);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(
|
||||||
|
text(&document, cx).starts_with("yx"),
|
||||||
|
"the typing stays, unformatted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusals a document sends by event.
|
||||||
|
pub(super) fn refusals(
|
||||||
|
document: &Entity<MarkdownDocument>,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) -> Rc<RefCell<Vec<EditRefusal>>> {
|
||||||
|
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let kept = heard.clone();
|
||||||
|
cx.update(|_, cx| {
|
||||||
|
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
||||||
|
if let DocumentEvent::EditRefused(why) = event {
|
||||||
|
kept.borrow_mut().push(*why);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach()
|
||||||
|
});
|
||||||
|
heard
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_lapse_comes_by_event(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, 64);
|
||||||
|
let heard = refusals(&document, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(*heard.borrow(), [EditRefusal::Changed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unwrap_refuses_an_end_in_nested_marks() {
|
||||||
|
use InlineFormat::*;
|
||||||
|
let across = Err(EditRefusal::Across);
|
||||||
|
assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across);
|
||||||
|
assert_eq!(toggled("**~~a~‸~**", Strong), across);
|
||||||
|
assert_eq!(toggled("<u>[a](ht‸tps://x)</u>", Underline), across);
|
||||||
|
assert_eq!(toggled("**a *b⟨* c⟩**", Strong), across);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_keeps_an_autolink_backslash() {
|
||||||
|
assert_eq!(
|
||||||
|
ok(r"a‸ <https://x/\*>", InlineFormat::Clear),
|
||||||
|
r"a‸ https://x/\*"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk past the parse, on `n` then `4n` repeats.
|
||||||
|
fn linear(unit: &str, run: impl Fn(&str, &Node)) {
|
||||||
|
let time = |n: usize| {
|
||||||
|
let text = unit.repeat(n);
|
||||||
|
let root = file_tree(&text);
|
||||||
|
let start = Instant::now();
|
||||||
|
run(&text, &root);
|
||||||
|
start.elapsed()
|
||||||
|
};
|
||||||
|
let (small, large) = (time(4000), time(16000));
|
||||||
|
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn many_paragraphs_format_in_linear_time() {
|
||||||
|
linear("a\n\n", |text, root| {
|
||||||
|
toggle_tree(text, root, 0..1, InlineFormat::Strong).expect("toggled");
|
||||||
|
});
|
||||||
|
linear("**a**\n\n", |text, root| {
|
||||||
|
toggle_tree(text, root, 0..text.len(), InlineFormat::Clear).expect("cleared");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_second_format_refuses_while_one_runs(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, 64);
|
||||||
|
let second = document.update(cx, |document, cx| {
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
document.format(InlineFormat::Emphasis, cx)
|
||||||
|
});
|
||||||
|
assert_eq!(second, Err(EditRefusal::Busy));
|
||||||
|
cx.run_until_parked();
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.format(InlineFormat::Emphasis, cx).expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(text(&document, cx).starts_with("***x"));
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
});
|
||||||
|
assert!(text(&document, cx).starts_with("**x"));
|
||||||
|
assert!(!text(&document, cx).starts_with("***"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_refusal_comes_by_event(cx: &mut TestAppContext) {
|
||||||
|
let source = format!("`code` {}", "x".repeat(64));
|
||||||
|
let (document, cx) = open(&source, MarkdownMode::Source, cx);
|
||||||
|
let heard = refusals(&document, cx);
|
||||||
|
let taken = document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||||
|
document.format(InlineFormat::Strong, cx)
|
||||||
|
});
|
||||||
|
assert_eq!(taken, Ok(()));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(*heard.borrow(), [EditRefusal::Code]);
|
||||||
|
assert_eq!(text(&document, cx), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_format_drops_once_preview_shows(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, 64);
|
||||||
|
let heard = refusals(&document, cx);
|
||||||
|
document.update_in(cx, |document, window, cx| {
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
document.set_mode(MarkdownMode::Preview, window, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(text(&document, cx).starts_with("xx"));
|
||||||
|
assert_eq!(*heard.borrow(), [EditRefusal::Preview]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = word(cx, LARGEST_READ + 1);
|
||||||
|
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||||
|
assert_eq!(refused, Err(EditRefusal::TooLong));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_visual_format_reads_apart(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("word\n\nmore\n", MarkdownMode::Visual, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
|
||||||
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"word\n\nmore\n",
|
||||||
|
"not on the UI thread"
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "**word**\n\nmore\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,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");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use gpui::{TestAppContext, px, rems};
|
|||||||
|
|
||||||
use super::{MarkdownMode, frames, open};
|
use super::{MarkdownMode, frames, open};
|
||||||
use crate::documents::DocumentLook;
|
use crate::documents::DocumentLook;
|
||||||
|
use crate::documents::document::edit::Landing;
|
||||||
|
|
||||||
/// Sixty short paragraphs, each its own block.
|
/// Sixty short paragraphs, each its own block.
|
||||||
fn long() -> String {
|
fn long() -> String {
|
||||||
@@ -30,7 +31,7 @@ fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) {
|
|||||||
};
|
};
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.set_look(look, cx);
|
document.set_look(look, cx);
|
||||||
document.open(Some(40), false, window, cx);
|
document.open(Some(40), Landing::End, window, cx);
|
||||||
document.list.scroll_to_reveal_item(40);
|
document.list.scroll_to_reveal_item(40);
|
||||||
});
|
});
|
||||||
frames(cx);
|
frames(cx);
|
||||||
@@ -99,7 +100,7 @@ fn the_scale_sizes_source_lines(cx: &mut TestAppContext) {
|
|||||||
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
|
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
|
||||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.open(Some(3), false, window, cx)
|
document.open(Some(3), Landing::End, window, cx)
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document.read_with(cx, |document, cx| document.current_block(cx)),
|
document.read_with(cx, |document, cx| document.current_block(cx)),
|
||||||
@@ -137,7 +138,7 @@ fn off_middle(
|
|||||||
fn an_open_block_takes_the_scale(cx: &mut TestAppContext) {
|
fn an_open_block_takes_the_scale(cx: &mut TestAppContext) {
|
||||||
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
|
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.open(Some(0), false, window, cx)
|
document.open(Some(0), Landing::End, window, cx)
|
||||||
});
|
});
|
||||||
frames(cx);
|
frames(cx);
|
||||||
let plain = document
|
let plain = document
|
||||||
@@ -163,7 +164,7 @@ fn a_resize_centers_the_block_again(cx: &mut TestAppContext) {
|
|||||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.set_look(typewriter(), cx);
|
document.set_look(typewriter(), cx);
|
||||||
document.open(Some(40), false, window, cx);
|
document.open(Some(40), Landing::End, window, cx);
|
||||||
document.list.scroll_to_reveal_item(40);
|
document.list.scroll_to_reveal_item(40);
|
||||||
});
|
});
|
||||||
frames(cx);
|
frames(cx);
|
||||||
@@ -177,13 +178,13 @@ fn the_first_and_last_blocks_center(cx: &mut TestAppContext) {
|
|||||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.set_look(typewriter(), cx);
|
document.set_look(typewriter(), cx);
|
||||||
document.open(Some(0), false, window, cx);
|
document.open(Some(0), Landing::End, window, cx);
|
||||||
});
|
});
|
||||||
frames(cx);
|
frames(cx);
|
||||||
let first = off_middle(&document, 0, cx);
|
let first = off_middle(&document, 0, cx);
|
||||||
assert!(first < px(4.0), "the first is {first:?} off");
|
assert!(first < px(4.0), "the first is {first:?} off");
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.open(Some(59), false, window, cx);
|
document.open(Some(59), Landing::End, window, cx);
|
||||||
document.list.scroll_to_reveal_item(59);
|
document.list.scroll_to_reveal_item(59);
|
||||||
});
|
});
|
||||||
frames(cx);
|
frames(cx);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc};
|
|||||||
|
|
||||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||||
|
|
||||||
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, visual};
|
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, edit::Landing, visual};
|
||||||
use crate::documents::MarkdownMode;
|
use crate::documents::MarkdownMode;
|
||||||
|
|
||||||
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
|
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
|
||||||
@@ -210,8 +210,10 @@ fn the_source_colors_markdown(cx: &mut TestAppContext) {
|
|||||||
document.editor().read(cx).line_colors("a **b**", cx)
|
document.editor().read(cx).line_colors("a **b**", cx)
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
colors.iter().any(|(range, style)| *range == (2..7)
|
colors
|
||||||
&& style.font_weight == Some(gpui::FontWeight::SEMIBOLD)),
|
.iter()
|
||||||
|
.any(|(range, style)| *range == (2..7)
|
||||||
|
&& style.font_weight == Some(gpui::FontWeight::BOLD)),
|
||||||
"{colors:?}"
|
"{colors:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -236,7 +238,7 @@ pub(super) fn type_in(
|
|||||||
cx: &mut VisualTestContext,
|
cx: &mut VisualTestContext,
|
||||||
) {
|
) {
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.open(ix, false, window, cx);
|
document.open(ix, Landing::End, window, cx);
|
||||||
if let (Some(typed), Some(open)) = (typed, &document.open) {
|
if let (Some(typed), Some(open)) = (typed, &document.open) {
|
||||||
open.input.update(cx, |input, cx| input.set_text(typed, cx));
|
open.input.update(cx, |input, cx| input.set_text(typed, cx));
|
||||||
}
|
}
|
||||||
@@ -353,11 +355,11 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
|
|||||||
let long = format!("A\n\nB\n\nCCCC\n\n{}", "Prose.\n\n".repeat(AT_ONCE / 8 + 1));
|
let long = format!("A\n\nB\n\nCCCC\n\n{}", "Prose.\n\n".repeat(AT_ONCE / 8 + 1));
|
||||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||||
document.update_in(cx, |document, window, cx| {
|
document.update_in(cx, |document, window, cx| {
|
||||||
document.open(Some(0), false, window, cx);
|
document.open(Some(0), Landing::End, window, cx);
|
||||||
let input = document.open.as_ref().expect("open").input.clone();
|
let input = document.open.as_ref().expect("open").input.clone();
|
||||||
input.update(cx, |input, cx| input.set_text("AAAA", cx));
|
input.update(cx, |input, cx| input.set_text("AAAA", cx));
|
||||||
// B is pressed before the new read lands.
|
// B is pressed before the new read lands.
|
||||||
document.open(Some(1), false, window, cx);
|
document.open(Some(1), Landing::End, window, cx);
|
||||||
assert!(document.open.is_none(), "a stale range opens nothing");
|
assert!(document.open.is_none(), "a stale range opens nothing");
|
||||||
});
|
});
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
@@ -366,10 +368,21 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod attach;
|
mod attach;
|
||||||
|
mod block;
|
||||||
|
mod block_edges;
|
||||||
|
mod cells;
|
||||||
|
mod format;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod lend;
|
||||||
mod links;
|
mod links;
|
||||||
mod look;
|
mod look;
|
||||||
|
mod open_look;
|
||||||
mod place;
|
mod place;
|
||||||
|
mod point;
|
||||||
|
mod presses;
|
||||||
|
mod reading;
|
||||||
|
mod secondary;
|
||||||
|
mod toc;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -229,3 +229,11 @@ fn the_selection_reads_in_source_and_in_an_open_block(cx: &mut TestAppContext) {
|
|||||||
"words"
|
"words"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_visual_opening_starts_at_the_top(cx: &mut TestAppContext) {
|
||||||
|
let source = format!("alpha beta\n\n{}", "x".repeat(20000));
|
||||||
|
let (document, cx) = open(&source, MarkdownMode::Visual, cx);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(landed(&document, cx), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
//! A press lands on the pressed character (R4.5).
|
||||||
|
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{Entity, Modifiers, MouseButton, Pixels, TestAppContext, VisualTestContext, point, px};
|
||||||
|
|
||||||
|
use super::{open, type_in};
|
||||||
|
use crate::{
|
||||||
|
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
|
||||||
|
theme::{ActiveTheme, TextSize},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Presses block `ix` on its first line's character `k`.
|
||||||
|
pub(super) fn press_on(ix: usize, k: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||||
|
let bounds = cx
|
||||||
|
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||||
|
.expect("the block is drawn");
|
||||||
|
// The test text system sets glyphs 0.6 em apart.
|
||||||
|
let size = cx.update(|window, cx| {
|
||||||
|
cx.theme()
|
||||||
|
.text_size(TextSize::Base)
|
||||||
|
.to_pixels(window.rem_size())
|
||||||
|
});
|
||||||
|
let em = size * 0.6;
|
||||||
|
let x = bounds.left() + px(13.0) + em * (k as f32 + 0.25);
|
||||||
|
let at = point(x, bounds.top() + px(10.0));
|
||||||
|
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn heard(
|
||||||
|
document: &Entity<MarkdownDocument>,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) -> Rc<RefCell<Vec<DocumentTarget>>> {
|
||||||
|
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let kept = heard.clone();
|
||||||
|
cx.update(|_, cx| {
|
||||||
|
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
||||||
|
if let DocumentEvent::Pointed(target) = event {
|
||||||
|
kept.borrow_mut().push(target.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach()
|
||||||
|
});
|
||||||
|
heard
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
|
||||||
|
document.read_with(cx, |document, cx| document.editor.read(cx).primary().head)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn opened_caret(
|
||||||
|
document: &Entity<MarkdownDocument>,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) -> usize {
|
||||||
|
document.read_with(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.expect("open")
|
||||||
|
.input
|
||||||
|
.read(cx)
|
||||||
|
.cursor()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn target(at: usize, block: TargetBlock) -> DocumentTarget {
|
||||||
|
DocumentTarget {
|
||||||
|
at,
|
||||||
|
block: Some(block),
|
||||||
|
link: None,
|
||||||
|
image: None,
|
||||||
|
in_table: false,
|
||||||
|
code: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT: &str = "First.\n\nSay **bold** and more.\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||||
|
press_on(1, 4, MouseButton::Left, cx);
|
||||||
|
assert_eq!(
|
||||||
|
opened_caret(&document, cx),
|
||||||
|
"Say **".len(),
|
||||||
|
"on the b of bold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_past_a_crlf_line_types_before_its_end(cx: &mut TestAppContext) {
|
||||||
|
let source = "one\r\ntwo\r\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 3, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), "one".len());
|
||||||
|
cx.simulate_input("X");
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(super::text(&document, cx), "oneX\r\ntwo\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_in_code_keeping_a_crlf_lands_on_its_character(cx: &mut TestAppContext) {
|
||||||
|
let source = "`one\r\ntwo`\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 2, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), "`on".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(1, 4, MouseButton::Right, cx);
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||||
|
let at = TEXT.find("bold").unwrap();
|
||||||
|
assert_eq!(caret(&document, cx), at);
|
||||||
|
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_on_a_link_names_its_address(cx: &mut TestAppContext) {
|
||||||
|
let text = "Go [there](https://ely.dev) now\n";
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 4, MouseButton::Right, cx);
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
link: Some("https://ely.dev".into()),
|
||||||
|
..target(text.find("here").unwrap(), TargetBlock::Paragraph)
|
||||||
|
};
|
||||||
|
assert_eq!(*heard.borrow(), [expected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) {
|
||||||
|
let text = "```rust\nlet x = 1;\n```\n";
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 0, MouseButton::Right, cx);
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
code: Some("let x = 1;".into()),
|
||||||
|
..target("```rust\n".len(), TargetBlock::Code)
|
||||||
|
};
|
||||||
|
assert_eq!(*heard.borrow(), [expected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_on_a_picture_names_it(cx: &mut TestAppContext) {
|
||||||
|
let text = "Lead.\n\n\n";
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(1, 1, MouseButton::Right, cx);
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
image: Some("missing.png".into()),
|
||||||
|
..target(text.find("![").unwrap(), TargetBlock::Paragraph)
|
||||||
|
};
|
||||||
|
assert_eq!(*heard.borrow(), [expected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_writes_the_open_block_first(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
type_in(&document, Some(0), Some("First, longer."), cx);
|
||||||
|
press_on(1, 4, MouseButton::Right, cx);
|
||||||
|
let written = "First, longer.\n\nSay **bold** and more.\n";
|
||||||
|
assert_eq!(super::text(&document, cx), written);
|
||||||
|
let at = written.find("bold").unwrap();
|
||||||
|
assert_eq!(caret(&document, cx), at, "the byte moved with the write");
|
||||||
|
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
let end = cx.debug_bounds("markdown-block-1").expect("drawn").bottom() + px(12.0);
|
||||||
|
cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Right, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
block: None,
|
||||||
|
..target(TEXT.len(), TargetBlock::Paragraph)
|
||||||
|
};
|
||||||
|
assert_eq!(*heard.borrow(), [expected]);
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base and small text sizes, in pixels.
|
||||||
|
pub(super) fn sizes(cx: &mut VisualTestContext) -> (Pixels, Pixels) {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
let size = |size| cx.theme().text_size(size).to_pixels(window.rem_size());
|
||||||
|
(size(TextSize::Base), size(TextSize::Sm))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn press_at(x: Pixels, y: Pixels, button: MouseButton, cx: &mut VisualTestContext) {
|
||||||
|
cx.simulate_mouse_down(point(x, y), button, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(point(x, y), button, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presses block 0's code glyph `k` on `line`.
|
||||||
|
pub(super) fn press_lines(k: usize, line: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, small) = sizes(cx);
|
||||||
|
let x = bounds.left() + px(25.0) + small * 0.6 * (k as f32 + 0.25);
|
||||||
|
let y = bounds.top() + px(17.0) + body * 1.6 * (line as f32 + 0.25);
|
||||||
|
press_at(x, y, button, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
|
||||||
|
let source = "> alpha\n> beta";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, _) = sizes(cx);
|
||||||
|
// "beta" draws on its own line.
|
||||||
|
let x = bounds.left() + px(31.0) + body * 0.6 * 1.25;
|
||||||
|
press_at(
|
||||||
|
x,
|
||||||
|
bounds.top() + px(10.0) + body * 1.6,
|
||||||
|
MouseButton::Right,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let at = source.find("eta").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_in_an_alert_lands_past_its_marker(cx: &mut TestAppContext) {
|
||||||
|
let source = "> [!NOTE]\n> alpha beta";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, _) = sizes(cx);
|
||||||
|
let x = bounds.left() + px(31.0) + body * 0.6 * 6.25;
|
||||||
|
let y = bounds.top() + px(15.0) + body * 1.6;
|
||||||
|
press_at(x, y, MouseButton::Right, cx);
|
||||||
|
let at = source.find("beta").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Alert)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_html_block_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||||
|
let source = "<div>alpha beta</div>\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 7, MouseButton::Right, cx);
|
||||||
|
let at = source.find("eta").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||||
|
press_on(0, 7, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_definition_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||||
|
let source = "[ref]: https://ely.dev\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 10, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
|
||||||
|
for source in [" alpha\n beta\n", "```\r\nalpha\r\nbeta\r\n```\r\n"] {
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(1, 1, MouseButton::Right, cx);
|
||||||
|
let at = source.find("eta").unwrap();
|
||||||
|
let eol = if source.contains('\r') { "\r\n" } else { "\n" };
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
code: Some(format!("alpha{eol}beta")),
|
||||||
|
..target(at, TargetBlock::Code)
|
||||||
|
};
|
||||||
|
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
||||||
|
press_lines(1, 1, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), at, "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_front_matter_press_lands_past_its_fence(cx: &mut TestAppContext) {
|
||||||
|
let source = "---\ntitle: value\n---\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(7, 0, MouseButton::Right, cx);
|
||||||
|
let at = source.find("value").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||||
|
press_lines(7, 0, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_secondary_press_on_an_empty_document_opens_nothing(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_at(px(25.0), px(25.0), MouseButton::Right, cx);
|
||||||
|
assert_eq!(events.borrow().len(), 1);
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_write_back_that_changes_the_pressed_block_points_nowhere(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("First.\n\nalpha beta\n", MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
type_in(&document, Some(0), Some("```"), cx);
|
||||||
|
press_on(1, 7, MouseButton::Right, cx);
|
||||||
|
let written = "```\n\nalpha beta\n";
|
||||||
|
assert_eq!(super::text(&document, cx), written);
|
||||||
|
assert!(
|
||||||
|
events.borrow().is_empty(),
|
||||||
|
"the pressed paragraph became code"
|
||||||
|
);
|
||||||
|
assert_ne!(caret(&document, cx), written.find("eta").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn crlf_lines_press_where_they_were_written(cx: &mut TestAppContext) {
|
||||||
|
let source = "```
|
||||||
|
alpha
|
||||||
|
beta
|
||||||
|
```
|
||||||
|
";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(2, 0, MouseButton::Right, cx);
|
||||||
|
let at = source.find("pha").unwrap();
|
||||||
|
let expected = DocumentTarget {
|
||||||
|
code: Some("alpha\nbeta".into()),
|
||||||
|
..target(at, TargetBlock::Code)
|
||||||
|
};
|
||||||
|
assert_eq!(*events.borrow(), [expected]);
|
||||||
|
press_lines(2, 0, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn crlf_front_matter_presses_where_written(cx: &mut TestAppContext) {
|
||||||
|
let source = "---
|
||||||
|
title: value
|
||||||
|
author: person
|
||||||
|
---
|
||||||
|
";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(7, 0, MouseButton::Right, cx);
|
||||||
|
let at = source.find("value").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||||
|
press_lines(7, 0, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_crlf_definition_presses_where_written(cx: &mut TestAppContext) {
|
||||||
|
let source = "[ref]: https://ely.dev
|
||||||
|
\"title\"
|
||||||
|
";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 10, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_changed_definition_points_nowhere(cx: &mut TestAppContext) {
|
||||||
|
let source = "[ref]: https://old.dev
|
||||||
|
|
||||||
|
Go [there][ref]
|
||||||
|
";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
type_in(&document, Some(0), Some("[ref]: https://new.dev"), cx);
|
||||||
|
press_on(1, 4, MouseButton::Right, cx);
|
||||||
|
assert_eq!(
|
||||||
|
super::text(&document, cx),
|
||||||
|
source.replace("old.dev", "new.dev")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
events.borrow().is_empty(),
|
||||||
|
"the link changed under the press"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_quoted_greater_than_presses_on_itself(cx: &mut TestAppContext) {
|
||||||
|
let source = "> <div>
|
||||||
|
> >alpha beta</div>";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, _) = sizes(cx);
|
||||||
|
let x = bounds.left() + px(31.0) + body * 0.6 * 0.25;
|
||||||
|
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||||
|
let at = source.find(">alpha").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_escaped_backslash_presses_on_itself(cx: &mut TestAppContext) {
|
||||||
|
let source = r"a \\ z";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 2, MouseButton::Right, cx);
|
||||||
|
let at = source.find(r"\ z").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Paragraph)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) {
|
||||||
|
let source = "<div>alpha <b> beta</b></div>";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 8, MouseButton::Right, cx);
|
||||||
|
let at = source.find("eta").unwrap();
|
||||||
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_beside_the_code_still_names_it(cx: &mut TestAppContext) {
|
||||||
|
let source = "```\nlet x;\n```\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
press_at(
|
||||||
|
bounds.left() + px(4.0),
|
||||||
|
bounds.top() + px(4.0),
|
||||||
|
MouseButton::Right,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let code = events
|
||||||
|
.borrow()
|
||||||
|
.first()
|
||||||
|
.and_then(|target| target.code.clone());
|
||||||
|
assert_eq!(code.as_deref(), Some("let x;"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Presses on text the parser rewrote (R4.5).
|
||||||
|
|
||||||
|
use gpui::{MouseButton, TestAppContext, px};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
open,
|
||||||
|
point::{heard, opened_caret, press_at, press_lines, press_on, sizes, target},
|
||||||
|
};
|
||||||
|
use crate::documents::{MarkdownMode, TargetBlock};
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn inline_code_kept_indent_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "a `fox\n y` z";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 3, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(4, TargetBlock::Paragraph)]);
|
||||||
|
press_on(0, 3, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn quoted_html_kept_mark_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "> <div>\n> > alpha beta</div>";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, _) = sizes(cx);
|
||||||
|
let x = bounds.left() + px(31.0) + body * 0.6 * 0.25;
|
||||||
|
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(10, TargetBlock::Quote)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn decoded_line_end_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "ab y";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn numeric_control_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "ab € y";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn html_decoded_space_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "<div>ab </div>";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(6, TargetBlock::Html)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn inline_code_kept_indent_left_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "a `fox\n y` z";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 3, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn autolink_literal_reference_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "<https://example.com/?a=&b=2>";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 9, MouseButton::Right, cx);
|
||||||
|
assert_eq!(events.borrow().len(), 1);
|
||||||
|
assert_eq!(events.borrow()[0].at, source.find('x').unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn table_escaped_pipe_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
|
let (body, _) = sizes(cx);
|
||||||
|
let x = bounds.left() + px(26.0) + body * 0.6 * 1.25;
|
||||||
|
press_at(x, bounds.top() + px(17.0), MouseButton::Right, cx);
|
||||||
|
assert_eq!(events.borrow().len(), 1);
|
||||||
|
assert!(events.borrow()[0].in_table);
|
||||||
|
assert_eq!(events.borrow()[0].at, source.find('b').unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn prior_crlf_code_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "```\r\nalpha\r\nbeta\r\n```\r\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(2, 0, MouseButton::Right, cx);
|
||||||
|
assert_eq!(events.borrow()[0].at, source.find("pha").unwrap());
|
||||||
|
press_lines(2, 0, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), source.find("pha").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn prior_crlf_front_matter_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "---\r\ntitle: value\r\nauthor: person\r\n---\r\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_lines(7, 0, MouseButton::Right, cx);
|
||||||
|
assert_eq!(events.borrow()[0].at, source.find("value").unwrap());
|
||||||
|
press_lines(7, 0, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), source.find("value").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn prior_crlf_definition_press(cx: &mut TestAppContext) {
|
||||||
|
let source = "[ref]: https://ely.dev\r\n \"title\"\r\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let events = heard(&document, cx);
|
||||||
|
press_on(0, 10, MouseButton::Right, cx);
|
||||||
|
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! Drawn text reads back to its source byte (R4.5).
|
||||||
|
|
||||||
|
use crate::documents::{
|
||||||
|
TargetBlock,
|
||||||
|
document::parse::parsed,
|
||||||
|
tree::{Inline, Node, lines_at},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The source byte under drawn `shown` in block 0.
|
||||||
|
fn read_back(source: &str, shown: &str) -> Option<usize> {
|
||||||
|
fn find<'a>(nodes: &'a [Node], shown: &str) -> Option<&'a Inline> {
|
||||||
|
nodes.iter().find_map(|node| match node {
|
||||||
|
Node::Paragraph(inline) | Node::Heading(_, inline) if inline.text.contains(shown) => {
|
||||||
|
Some(inline)
|
||||||
|
}
|
||||||
|
Node::Quote(body) | Node::Alert { body, .. } | Node::Footnote { body, .. } => {
|
||||||
|
find(body, shown)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let parsed = parsed(source);
|
||||||
|
let block = &parsed.blocks[0];
|
||||||
|
let inline = find(block.nodes.as_ref().expect("drawn"), shown).expect("shown");
|
||||||
|
let at = inline.text.find(shown).expect("shown");
|
||||||
|
let written = inline.source_at(&source[block.range.start..], at)?;
|
||||||
|
Some(block.range.start + written)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source byte under drawn `word` of block 0's code.
|
||||||
|
fn code_back(source: &str, word: &str) -> Option<usize> {
|
||||||
|
fn code(nodes: &[Node]) -> (&str, usize) {
|
||||||
|
match &nodes[0] {
|
||||||
|
Node::Code { text, from, .. } => (text, *from),
|
||||||
|
Node::Quote(body) => code(body),
|
||||||
|
other => panic!("not code: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed = parsed(source);
|
||||||
|
let block = &parsed.blocks[0];
|
||||||
|
let (drawn, from) = code(block.nodes.as_ref().expect("drawn"));
|
||||||
|
let at = drawn.find(word).expect("drawn");
|
||||||
|
let written = lines_at(&source[block.range.start..], from, drawn, at)?;
|
||||||
|
Some(block.range.start + written)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_reads_back_past_marks_escapes_trims_and_markers() {
|
||||||
|
let cases = [
|
||||||
|
("> alpha\n> beta", "eta"),
|
||||||
|
("> [!NOTE]\n> alpha beta", "beta"),
|
||||||
|
("> [!NOTE] \n> alpha beta", "beta"),
|
||||||
|
("> [!NOTE]\\\n> alpha beta", "beta"),
|
||||||
|
("> [!NOTE]\n> \\*beta\\*", "*beta"),
|
||||||
|
("> [!NOTE] alpha beta", "beta"),
|
||||||
|
("a \\*star\\* z", "*star"),
|
||||||
|
("a \\\\ z", "\\ z"),
|
||||||
|
("a ` xy ` z", "xy"),
|
||||||
|
("a ` xy ` z", " xy "),
|
||||||
|
("a ` >xy ` z", ">xy"),
|
||||||
|
("a `\\\\xy` z", "xy"),
|
||||||
|
("<div>alpha beta</div>\n", "eta"),
|
||||||
|
("> <div>\n> alpha beta</div>\n", "beta"),
|
||||||
|
("> <div>\n> >alpha beta</div>", ">alpha"),
|
||||||
|
("> <div>\n> >alpha beta</div>", "beta"),
|
||||||
|
("<div>alpha <b>beta</b> gamma</div>", "gamma"),
|
||||||
|
("<div>alpha <b>beta</b> gamma</div>", "beta"),
|
||||||
|
("<div>alpha <b> beta</b></div>", "eta"),
|
||||||
|
("<div>one</div>\n<div>alpha beta</div>", "beta"),
|
||||||
|
];
|
||||||
|
for (source, shown) in cases {
|
||||||
|
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reference_reads_back_to_its_ampersand() {
|
||||||
|
let source = "a & b";
|
||||||
|
assert_eq!(read_back(source, "& b"), source.find('&'));
|
||||||
|
assert_eq!(read_back(source, "b"), source.find('b'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_after_an_emoji_reads_back_and_before_it_lands_at_its_start() {
|
||||||
|
let source = "ab :smile: y";
|
||||||
|
assert_eq!(read_back(source, "y"), source.find('y'));
|
||||||
|
assert_eq!(read_back(source, "b"), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_reads_back_line_for_line_whatever_its_line_ends() {
|
||||||
|
let cases = [
|
||||||
|
"```\nalpha\nbeta\n```\n",
|
||||||
|
"```\r\nalpha\r\nbeta\r\n```\r\n",
|
||||||
|
"```\ralpha\rbeta\r```\r",
|
||||||
|
" alpha\n beta\n",
|
||||||
|
"> ```\n> alpha\n> beta\n> ```\n",
|
||||||
|
];
|
||||||
|
for source in cases {
|
||||||
|
for word in ["pha", "eta"] {
|
||||||
|
assert_eq!(code_back(source, word), source.find(word), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_cut_from_a_tab_reads_back_past_it() {
|
||||||
|
for spaces in 0..=3 {
|
||||||
|
for prefix in ["\t", " \t", " \t", " \t", "\t\t"] {
|
||||||
|
let indent = " ".repeat(spaces);
|
||||||
|
let source = format!("{indent}```\n{indent}alpha\n{prefix}beta\n{indent}```\n");
|
||||||
|
assert_eq!(code_back(&source, "eta"), source.find("eta"), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decoded_line_end_reads_without_a_written_one() {
|
||||||
|
let quote = parsed("> alpha beta");
|
||||||
|
assert_eq!(quote.blocks[0].kind, TargetBlock::Quote);
|
||||||
|
let alert = parsed("> [!NOTE] alpha beta");
|
||||||
|
assert_eq!(alert.blocks[0].kind, TargetBlock::Alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kept_leading_marks_still_map() {
|
||||||
|
let source = "a `fox\n y` z";
|
||||||
|
assert_eq!(read_back(source, "ox"), source.find("ox"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoded_line_end_maps_nearby_text() {
|
||||||
|
for (source, shown) in [
|
||||||
|
("ab y", "b"),
|
||||||
|
("ab y", "b"),
|
||||||
|
("ab 
 y", "b"),
|
||||||
|
("<div>ab y</div>", "b"),
|
||||||
|
("<div>ab 	 y</div>", "b"),
|
||||||
|
] {
|
||||||
|
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_references_match_the_parser() {
|
||||||
|
for source in ["ab € y", "ab  y", "ab  y"] {
|
||||||
|
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_trailing_encoded_space_maps_previous_word() {
|
||||||
|
for source in [
|
||||||
|
"<div>ab </div>",
|
||||||
|
"<div>ab <b>word</b></div>",
|
||||||
|
"<div>ab   word</div>",
|
||||||
|
] {
|
||||||
|
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_pipe_in_a_table_maps_previous_text() {
|
||||||
|
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
|
||||||
|
let parsed = parsed(source);
|
||||||
|
let nodes = parsed.blocks[0].nodes.as_ref().unwrap();
|
||||||
|
let Node::Table { head, .. } = &nodes[0] else {
|
||||||
|
panic!("table")
|
||||||
|
};
|
||||||
|
let Node::Paragraph(inline) = &head[0][0] else {
|
||||||
|
panic!("text")
|
||||||
|
};
|
||||||
|
assert_eq!(inline.text, "ab|cd");
|
||||||
|
assert_eq!(inline.source_at(source, 1), source.find('b'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn autolink_does_not_decode_literal_reference() {
|
||||||
|
let source = "<https://example.com/?a=&b=2>";
|
||||||
|
assert_eq!(read_back(source, "xample"), source.find("xample"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Right presses that keep a selection; Delete Image (R4.10).
|
||||||
|
|
||||||
|
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||||
|
|
||||||
|
use super::{MarkdownMode, open, opened, point::heard, point::press_on, text};
|
||||||
|
use crate::documents::{DocumentTarget, EditRefusal, MarkdownDocument, TargetBlock};
|
||||||
|
|
||||||
|
/// A right press near the open block's left edge.
|
||||||
|
fn press_open(cx: &mut VisualTestContext) {
|
||||||
|
let field = cx.debug_bounds("markdown-open").expect("a block is open");
|
||||||
|
let at = point(field.left() + px(20.0), field.top() + px(14.0));
|
||||||
|
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_selection(
|
||||||
|
document: &Entity<MarkdownDocument>,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) -> Option<std::ops::Range<usize>> {
|
||||||
|
document.read_with(cx, |document, cx| {
|
||||||
|
(document.open.as_ref()).map(|open| open.input.read(cx).selection())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_open(
|
||||||
|
document: &Entity<MarkdownDocument>,
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) {
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let input = document.open.as_ref().expect("open").input.clone();
|
||||||
|
input.update(cx, |input, cx| input.select(range, cx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINKED: &str = "[docs](https://x.test)\n\nNext.\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..22, cx);
|
||||||
|
press_open(cx);
|
||||||
|
assert_eq!(
|
||||||
|
open_selection(&document, cx),
|
||||||
|
Some(0..22),
|
||||||
|
"kept open and selected"
|
||||||
|
);
|
||||||
|
let at = heard.borrow().first().expect("heard").at;
|
||||||
|
assert!(at < 6, "the pressed byte, not the selection's: {at}");
|
||||||
|
let target = DocumentTarget {
|
||||||
|
link: Some("https://x.test".into()),
|
||||||
|
..super::point::target(at, TargetBlock::Paragraph)
|
||||||
|
};
|
||||||
|
assert_eq!(*heard.borrow(), [target]);
|
||||||
|
assert_eq!(text(&document, cx), LINKED, "nothing written");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_outside_the_selection_moves_the_caret(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 22..22, cx);
|
||||||
|
press_open(cx);
|
||||||
|
let at = open_selection(&document, cx).expect("still open");
|
||||||
|
assert!(
|
||||||
|
at.is_empty() && at.start < 5,
|
||||||
|
"the caret went to the press: {at:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_reference_in_the_open_block_reads_its_definition_outside(cx: &mut TestAppContext) {
|
||||||
|
let source = "[docs][d]\n\n[d]: https://x.test\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..9, cx);
|
||||||
|
press_open(cx);
|
||||||
|
let link = heard
|
||||||
|
.borrow()
|
||||||
|
.first()
|
||||||
|
.and_then(|target| target.link.clone());
|
||||||
|
assert_eq!(link.as_deref(), Some("https://x.test"));
|
||||||
|
assert_eq!(opened(&document, cx), "[docs][d]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_right_press_in_source_keeps_the_selection(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..10), cx));
|
||||||
|
let line = cx.debug_bounds("editor-line-0").expect("drawn");
|
||||||
|
let at = point(line.left() + px(60.0), line.top() + px(6.0));
|
||||||
|
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
let kept = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||||
|
assert_eq!(kept, 0..10);
|
||||||
|
editor.update(cx, |editor, cx| editor.select(std::iter::once(10..10), cx));
|
||||||
|
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
let moved = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||||
|
assert!(moved.is_empty() && moved.start < 10, "{moved:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) {
|
||||||
|
let source = "A  B\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||||
|
let heard = super::format::refusals(&document, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.delete_image(0, cx).expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(*heard.borrow(), [EditRefusal::NoImage]);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.delete_image(4, cx).expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "A B\n");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn text_pastes_at_the_caret_with_no_block_open(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("First.\n", MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 0, MouseButton::Right, cx);
|
||||||
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string("New ".into()));
|
||||||
|
cx.simulate_keystrokes("secondary-v");
|
||||||
|
assert_eq!(text(&document, cx), "New First.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A right press on byte `at` of the open block.
|
||||||
|
fn press_open_at(document: &Entity<MarkdownDocument>, at: usize, cx: &mut VisualTestContext) {
|
||||||
|
let spot = document.read_with(cx, |document, cx| {
|
||||||
|
let input = document.open.as_ref().expect("open").input.read(cx);
|
||||||
|
input.bounds_for_range(at..at + 1)[0].center()
|
||||||
|
});
|
||||||
|
cx.simulate_mouse_down(spot, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(spot, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TWO: &str = "[one](https://a.test) [two](https://b.test)\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_in_a_selection_reads_the_pressed_link(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TWO, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..43, cx);
|
||||||
|
press_open_at(&document, TWO.find("two").expect("two") + 1, cx);
|
||||||
|
assert_eq!(open_selection(&document, cx), Some(0..43), "kept");
|
||||||
|
let link = heard
|
||||||
|
.borrow()
|
||||||
|
.first()
|
||||||
|
.and_then(|target| target.link.clone());
|
||||||
|
assert_eq!(link.as_deref(), Some("https://b.test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn only_the_latest_press_in_an_open_block_is_heard(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TWO, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..43, cx);
|
||||||
|
let spots = document.read_with(cx, |document, cx| {
|
||||||
|
let input = document.open.as_ref().expect("open").input.read(cx);
|
||||||
|
["one", "two"].map(|word| {
|
||||||
|
let at = TWO.find(word).expect("word") + 1;
|
||||||
|
input.bounds_for_range(at..at + 1)[0].center()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.point_open(Some(spots[0]), cx);
|
||||||
|
document.point_open(Some(spots[1]), cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let links: Vec<_> = (heard.borrow().iter())
|
||||||
|
.map(|target| target.link.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(links, [Some("https://b.test".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PICTURES: &str = "x  \n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn delete_image_takes_the_pressed_picture(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(PICTURES, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 0, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..25, cx);
|
||||||
|
press_open_at(&document, PICTURES.find("b.png").expect("b"), cx);
|
||||||
|
let target = heard.borrow().first().cloned().expect("heard");
|
||||||
|
assert_eq!(target.image.as_deref(), Some("b.png"));
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.delete_image(target.at, cx).expect("taken");
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(opened(&document, cx), "x  ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANCHORED: &str = "[<a href=\"https://inner.test\">inside</a>](https://outer.test)\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn an_open_block_reads_an_anchor_as_drawn(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(ANCHORED, MarkdownMode::Visual, cx);
|
||||||
|
let heard = heard(&document, cx);
|
||||||
|
press_on(0, 1, MouseButton::Left, cx);
|
||||||
|
select_open(&document, 0..0, cx);
|
||||||
|
press_open_at(&document, ANCHORED.find("inside").expect("word") + 2, cx);
|
||||||
|
let link = heard
|
||||||
|
.borrow()
|
||||||
|
.first()
|
||||||
|
.and_then(|target| target.link.clone());
|
||||||
|
assert_eq!(link.as_deref(), Some("https://inner.test"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||||
|
|
||||||
|
use super::{MarkdownDocument, MarkdownMode, frames, open, type_in};
|
||||||
|
|
||||||
|
const LISTED: &str = "[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||||
|
|
||||||
|
/// Presses entry `ix` of the drawn heading list.
|
||||||
|
fn press_entry(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||||
|
let bounds = cx
|
||||||
|
.debug_bounds(Box::leak(format!("markdown-toc-{ix}").into_boxed_str()))
|
||||||
|
.expect("the entry is drawn");
|
||||||
|
let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0));
|
||||||
|
cx.simulate_mouse_down(at, MouseButton::Left, modifiers);
|
||||||
|
frames(cx);
|
||||||
|
cx.simulate_mouse_up(at, MouseButton::Left, modifiers);
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_heading_written_above_with_the_same_title_is_not_the_pressed_one(cx: &mut TestAppContext) {
|
||||||
|
let text = "before\n\n[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||||
|
type_in(&document, Some(0), Some("## B"), cx);
|
||||||
|
frames(cx);
|
||||||
|
press_entry(1, Modifiers::secondary_key(), cx);
|
||||||
|
let (caret, now) =
|
||||||
|
document.read_with(cx, |document, cx| (document.caret(cx), document.text(cx)));
|
||||||
|
assert_eq!(now, "## B\n\n[toc]\n\n# A\n\ntext\n\n## B\n");
|
||||||
|
assert_eq!(caret, (8, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_twin_typed_above_while_the_text_waits_is_not_the_pressed_one(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LISTED, MarkdownMode::Split, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([(0..0, "## B\n\n".to_string())], cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||||
|
press_entry(1, Modifiers::none(), cx);
|
||||||
|
assert_eq!(
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||||
|
(8, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TWINS: &str = "[toc]\n\n## B\n\ntext\n\n## B\n";
|
||||||
|
|
||||||
|
/// Deletes the first twin while the text waits.
|
||||||
|
fn delete_first_twin(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) {
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([(7..13, String::new())], cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_deleted_twin_leaves_the_other_found(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TWINS, MarkdownMode::Split, cx);
|
||||||
|
delete_first_twin(&document, cx);
|
||||||
|
press_entry(1, Modifiers::none(), cx);
|
||||||
|
assert_eq!(
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||||
|
(4, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_on_a_deleted_twin_moves_nothing(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(TWINS, MarkdownMode::Split, cx);
|
||||||
|
delete_first_twin(&document, cx);
|
||||||
|
let before = document.read_with(cx, |document, cx| document.caret(cx));
|
||||||
|
press_entry(0, Modifiers::none(), cx);
|
||||||
|
assert_eq!(
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_in_preview_reveals_the_heading(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LISTED, MarkdownMode::Preview, cx);
|
||||||
|
press_entry(1, Modifiers::none(), cx);
|
||||||
|
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||||
|
assert_eq!(caret, (6, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn visual_reveals_on_a_secondary_press_and_edits_on_a_plain_one(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LISTED, MarkdownMode::Visual, cx);
|
||||||
|
press_entry(1, Modifiers::secondary_key(), cx);
|
||||||
|
let (caret, open) = document.read_with(cx, |document, cx| {
|
||||||
|
(document.caret(cx), document.open.is_some())
|
||||||
|
});
|
||||||
|
assert_eq!((caret, open), ((6, 0), false));
|
||||||
|
press_entry(0, Modifiers::none(), cx);
|
||||||
|
assert!(document.read_with(cx, |document, _| document.open.is_some()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_after_an_earlier_block_grew_finds_the_heading(cx: &mut TestAppContext) {
|
||||||
|
let text = "before\n\n[toc]\n\n# A\n\ntext\n\n## B\n";
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Visual, cx);
|
||||||
|
type_in(&document, Some(0), Some("before\nextra\nextra"), cx);
|
||||||
|
frames(cx);
|
||||||
|
press_entry(1, Modifiers::secondary_key(), cx);
|
||||||
|
frames(cx);
|
||||||
|
assert_eq!(
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||||
|
(10, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_while_the_text_waits_to_be_read_finds_the_heading(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(LISTED, MarkdownMode::Split, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.editor.update(cx, |editor, cx| {
|
||||||
|
editor.edit([(0..0, "inserted\n".repeat(20))], cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||||
|
press_entry(1, Modifiers::none(), cx);
|
||||||
|
frames(cx);
|
||||||
|
assert_eq!(
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||||
|
(26, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes `edits` while the text waits, caret at the top.
|
||||||
|
fn edit_waiting<'a>(
|
||||||
|
text: &str,
|
||||||
|
edits: Vec<(std::ops::Range<usize>, String)>,
|
||||||
|
cx: &'a mut TestAppContext,
|
||||||
|
) -> (Entity<MarkdownDocument>, &'a mut VisualTestContext) {
|
||||||
|
let (document, cx) = open(text, MarkdownMode::Split, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.editor
|
||||||
|
.update(cx, |editor, cx| editor.edit(edits, cx));
|
||||||
|
document.set_caret(0, 0, cx);
|
||||||
|
});
|
||||||
|
frames(cx);
|
||||||
|
assert!(!document.read_with(cx, |document, cx| document.read_now(cx)));
|
||||||
|
(document, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> (usize, usize) {
|
||||||
|
document.read_with(cx, |document, cx| document.caret(cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_twin_written_right_at_the_heading_goes_before_it(cx: &mut TestAppContext) {
|
||||||
|
let edits = vec![(7..7, "## B\n\n".to_string())];
|
||||||
|
let (document, cx) = edit_waiting("[toc]\n\n## B\n\ntext\n", edits, cx);
|
||||||
|
press_entry(0, Modifiers::none(), cx);
|
||||||
|
assert_eq!(caret(&document, cx), (4, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn adjacent_twins_keep_who_was_deleted(cx: &mut TestAppContext) {
|
||||||
|
let twins = "[toc]\n\n## B\n\n## B\n\ntext\n";
|
||||||
|
let (document, cx) = edit_waiting(twins, vec![(7..13, String::new())], cx);
|
||||||
|
press_entry(0, Modifiers::none(), cx);
|
||||||
|
assert_eq!(
|
||||||
|
caret(&document, cx),
|
||||||
|
(0, 0),
|
||||||
|
"the deleted one moves nothing"
|
||||||
|
);
|
||||||
|
press_entry(1, Modifiers::none(), cx);
|
||||||
|
assert_eq!(caret(&document, cx), (2, 0), "the kept one moved up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_heading_between_two_edits_stays_found(cx: &mut TestAppContext) {
|
||||||
|
let text = "before\n\n[toc]\n\n# A\n\nafter\n";
|
||||||
|
let edits = vec![
|
||||||
|
(0..0, "start\n".to_string()),
|
||||||
|
(text.len()..text.len(), "end\n".to_string()),
|
||||||
|
];
|
||||||
|
let (document, cx) = edit_waiting(text, edits, cx);
|
||||||
|
press_entry(0, Modifiers::none(), cx);
|
||||||
|
assert_eq!(caret(&document, cx), (5, 0));
|
||||||
|
}
|
||||||
@@ -47,7 +47,11 @@ fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) {
|
|||||||
document.edit_table(crate::documents::TableEdit::RowBelow, cx)
|
document.edit_table(crate::documents::TableEdit::RowBelow, cx)
|
||||||
});
|
});
|
||||||
assert_eq!(edited, Ok(()));
|
assert_eq!(edited, Ok(()));
|
||||||
assert_eq!(text(&document, cx), "| A |\n| --- |\n| 1 |\n| |\n");
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| A |\n| ---- |\n| 1 |\n| |\n"
|
||||||
|
);
|
||||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||||
@@ -61,7 +65,25 @@ fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) {
|
|||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text(&document, cx),
|
text(&document, cx),
|
||||||
"Intro\n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nNext\n"
|
"Intro\n\n| | | |\n| ---- | ---- | ---- |\n| | | |\n| | | |\n\nNext\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_new_table_after_end_keeps_crlf(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("Intro\r\nNext\r\n", MarkdownMode::Source, cx);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||||
|
cx.update(|window, cx| window.focus(&focus, cx));
|
||||||
|
cx.simulate_keystrokes("end");
|
||||||
|
let caret = editor.read_with(cx, |editor, _| editor.primary().range().start);
|
||||||
|
assert_eq!(caret, 6, "End lands between CR and LF");
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.insert_table(cx).expect("inserted")
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"Intro\r\n\r\n| | | |\r\n| ---- | ---- | ---- |\r\n| | | |\r\n| | | |\r\n\r\nNext\r\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +165,7 @@ fn preview_takes_no_edits(cx: &mut TestAppContext) {
|
|||||||
document.insert_table(cx),
|
document.insert_table(cx),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let preview = Err(crate::documents::TableRefusal::Preview);
|
let preview = Err(crate::documents::EditRefusal::Preview);
|
||||||
assert_eq!(refused, (false, preview, preview));
|
assert_eq!(refused, (false, preview, preview));
|
||||||
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n");
|
||||||
}
|
}
|
||||||
@@ -247,5 +269,167 @@ fn a_table_on_an_indented_blank_line_is_a_table(cx: &mut TestAppContext) {
|
|||||||
document.edit_table(crate::documents::TableEdit::ColumnRight, cx)
|
document.edit_table(crate::documents::TableEdit::ColumnRight, cx)
|
||||||
});
|
});
|
||||||
assert_eq!(column, Ok(()), "{}", text(&document, cx));
|
assert_eq!(column, Ok(()), "{}", text(&document, cx));
|
||||||
|
cx.run_until_parked();
|
||||||
assert!(text(&document, cx).starts_with("| "));
|
assert!(text(&document, cx).starts_with("| "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SMALL: &str = "| A | B |\n| - | - |\n| 1 | 2 |\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_table_move_reads_apart_as_one_undo_step(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
|
||||||
|
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||||
|
let moved = document.update(cx, |document, cx| {
|
||||||
|
document.edit_table(crate::documents::TableEdit::MoveRowUp, cx)
|
||||||
|
});
|
||||||
|
assert_eq!(moved, Ok(()));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
|
||||||
|
);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), SMALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_table_edit_at_an_edge_refuses_by_event(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(SMALL, MarkdownMode::Source, cx);
|
||||||
|
let heard = super::format::refusals(&document, cx);
|
||||||
|
document.update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.edit_table(crate::documents::TableEdit::MoveRowDown, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let edge = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Edge);
|
||||||
|
assert_eq!(*heard.borrow(), [edge]);
|
||||||
|
assert_eq!(text(&document, cx), SMALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tidy_in_cr_text_keeps_what_comes_before(cx: &mut TestAppContext) {
|
||||||
|
let source = "Before\r\r| A | B |\r| - | - |\r| 1 | 2 |\r\rAfter\r";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||||
|
let at = source.find('1').expect("a cell");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.edit_table(crate::documents::TableEdit::Tidy, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"Before\r\r| A | B |\r| ---- | ---- |\r| 1 | 2 |\r\rAfter\r"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open("Intro\rNext\r", MarkdownMode::Visual, cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document.insert_table(cx).expect("inserted")
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"Intro\r\r| | | |\r| ---- | ---- | ---- |\r| | | |\r| | | |\r\rNext\r"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn tidy_in_an_open_cell_writes_it_and_tidies(cx: &mut TestAppContext) {
|
||||||
|
let (document, cx) = open(SMALL, MarkdownMode::Visual, cx);
|
||||||
|
press(0, Modifiers::none(), cx);
|
||||||
|
assert_eq!(opened(&document, cx), "2");
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.edit_table(crate::documents::TableEdit::Tidy, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
text(&document, cx),
|
||||||
|
"| A | B |\n| ---- | ---- |\n| 1 | 2 |\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRLF: &str = "Intro\r\nNext\r\n";
|
||||||
|
|
||||||
|
/// A code block in CRLF text after `keys`.
|
||||||
|
fn code_block_after(keys: &str, cx: &mut TestAppContext) -> String {
|
||||||
|
let (document, cx) = open(CRLF, MarkdownMode::Source, cx);
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||||
|
cx.update(|window, cx| window.focus(&focus, cx));
|
||||||
|
cx.simulate_keystrokes(keys);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.block(crate::documents::BlockCommand::CodeBlock, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
text(&document, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FENCE_AFTER_INTRO: &str = "Intro\r\n\r\n```\r\n\r\n```\r\n\r\nNext\r\n";
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_block_after_end_in_crlf_text_reads_the_line(cx: &mut TestAppContext) {
|
||||||
|
assert_eq!(code_block_after("end", cx), FENCE_AFTER_INTRO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_selection_from_inside_a_crlf_reads_the_line(cx: &mut TestAppContext) {
|
||||||
|
assert_eq!(code_block_after("end shift-right", cx), FENCE_AFTER_INTRO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn delete_table_takes_the_table_or_refuses_outside(cx: &mut TestAppContext) {
|
||||||
|
let source = "before\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Source, cx);
|
||||||
|
let heard = super::format::refusals(&document, cx);
|
||||||
|
let delete = |cx: &mut gpui::VisualTestContext| {
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.edit_table(crate::documents::TableEdit::Delete, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
};
|
||||||
|
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
|
||||||
|
delete(cx);
|
||||||
|
assert_eq!(text(&document, cx), "before\n\nafter\n");
|
||||||
|
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||||
|
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||||
|
assert_eq!(text(&document, cx), source);
|
||||||
|
document.update(cx, |document, cx| document.set_caret(0, 2, cx));
|
||||||
|
delete(cx);
|
||||||
|
let outside = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Outside);
|
||||||
|
assert_eq!(*heard.borrow(), [outside]);
|
||||||
|
assert_eq!(text(&document, cx), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn undo_follows_a_table_edit_that_closes_the_block(cx: &mut TestAppContext) {
|
||||||
|
let source = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press(1, Modifiers::none(), cx);
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
document
|
||||||
|
.edit_table(crate::documents::TableEdit::Delete, cx)
|
||||||
|
.expect("taken")
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(text(&document, cx), "before\n\nafter\n");
|
||||||
|
let held = cx.update(|window, cx| {
|
||||||
|
let handle = gpui::Focusable::focus_handle(document.read(cx), cx);
|
||||||
|
handle.contains_focused(window, cx)
|
||||||
|
});
|
||||||
|
assert!(held, "focus stays in the document");
|
||||||
|
cx.simulate_keystrokes("secondary-z");
|
||||||
|
assert_eq!(text(&document, cx), source);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement,
|
AnyElement, App, ClickEvent, Context, Div, ExternalPaths, FontWeight, InteractiveElement,
|
||||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||||
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
|
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
||||||
|
prelude::FluentBuilder as _, relative,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::MarkdownDocument;
|
use super::{MarkdownDocument, edit::Landing, parse::Block};
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::{MarkdownMode, render::draw_nodes},
|
documents::{
|
||||||
|
MarkdownMode, TargetBlock,
|
||||||
|
render::{HEAVY, Hooks, OpenCell, Pointed, draw_nodes, heading_step, point_lines},
|
||||||
|
},
|
||||||
forms::{self, Down, Up},
|
forms::{self, Down, Up},
|
||||||
primitives::FocusRing as _,
|
primitives::FocusRing as _,
|
||||||
theme::{ActiveTheme, Radius, TextSize},
|
theme::{ActiveTheme, Radius, TextSize, Theme},
|
||||||
};
|
};
|
||||||
|
|
||||||
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
||||||
@@ -19,6 +23,44 @@ const CONTEXT: &str = "MarkdownDocument";
|
|||||||
/// How far a faded block recedes.
|
/// How far a faded block recedes.
|
||||||
const FADED: f32 = 0.35;
|
const FADED: f32 = 0.35;
|
||||||
|
|
||||||
|
/// The open block's text box: its closed font and panel.
|
||||||
|
pub(super) fn open_text(kind: Option<(TargetBlock, bool)>, scale: f32, theme: &Theme) -> Div {
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let body = theme.text_size(TextSize::Base) * scale;
|
||||||
|
let mono = theme.text_size(TextSize::Sm) * scale;
|
||||||
|
let text = div()
|
||||||
|
.debug_selector(|| "markdown-open-text".to_string())
|
||||||
|
.text_size(body)
|
||||||
|
.line_height(body * 1.6)
|
||||||
|
.text_color(colors.fg);
|
||||||
|
match kind {
|
||||||
|
Some((TargetBlock::Heading(level), true)) => text
|
||||||
|
.pt_2()
|
||||||
|
.text_size(body * heading_step(level))
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.font_weight(HEAVY)
|
||||||
|
.when(level == 6, |text| text.text_color(colors.fg_muted)),
|
||||||
|
// Code, math and front matter keep their panel.
|
||||||
|
Some((kind @ (TargetBlock::Code | TargetBlock::Math | TargetBlock::FrontMatter), true)) => {
|
||||||
|
text.p_3()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(colors.sunken)
|
||||||
|
.when(kind != TargetBlock::Math, |text| {
|
||||||
|
text.font_family(theme.mono_family.clone()).text_size(mono)
|
||||||
|
})
|
||||||
|
.when(kind == TargetBlock::FrontMatter, |text| {
|
||||||
|
text.text_color(colors.fg_muted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Written as is: mono and muted.
|
||||||
|
Some((_, false)) => text
|
||||||
|
.font_family(theme.mono_family.clone())
|
||||||
|
.line_height(gpui::phi())
|
||||||
|
.text_color(colors.fg_muted),
|
||||||
|
_ => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Modes with a reader's block to keep.
|
/// Modes with a reader's block to keep.
|
||||||
fn editing_mode(mode: MarkdownMode) -> bool {
|
fn editing_mode(mode: MarkdownMode) -> bool {
|
||||||
mode == MarkdownMode::Visual
|
mode == MarkdownMode::Visual
|
||||||
@@ -55,6 +97,9 @@ impl MarkdownDocument {
|
|||||||
|
|
||||||
/// A block, or the end.
|
/// A block, or the end.
|
||||||
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let editing = self.mode == MarkdownMode::Visual;
|
||||||
|
let drawn = editing && ix < self.parsed.blocks.len();
|
||||||
|
let on_point = drawn.then(|| self.on_point(ix, cx));
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = theme.colors.clone();
|
let colors = theme.colors.clone();
|
||||||
let (radius, body, mono) = (
|
let (radius, body, mono) = (
|
||||||
@@ -64,30 +109,35 @@ impl MarkdownDocument {
|
|||||||
);
|
);
|
||||||
// Focus fades all but the reader's block.
|
// Focus fades all but the reader's block.
|
||||||
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
||||||
let editing = self.mode == MarkdownMode::Visual;
|
|
||||||
let open = self.open.as_ref();
|
let open = self.open.as_ref();
|
||||||
let field = |open: &super::Open| {
|
|
||||||
div()
|
|
||||||
.text_size(body)
|
|
||||||
.px_3()
|
|
||||||
.py_2()
|
|
||||||
.rounded(radius)
|
|
||||||
.bg(colors.sunken)
|
|
||||||
.child(open.input.clone())
|
|
||||||
.into_any_element()
|
|
||||||
};
|
|
||||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||||
return match (open, editing) {
|
return match (open, editing) {
|
||||||
(Some(open), _) if open.append => field(open),
|
(Some(open), _) if open.append => self.field(open, None, cx),
|
||||||
(_, true) => self
|
(_, true) => self
|
||||||
.pressable(div().id("more").min_h_8(), None, cx)
|
.pressable(div().id("more").min_h_8(), None, cx)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
(_, false) => div().into_any_element(),
|
(_, false) => div().into_any_element(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if let Some(open) = open.filter(|open| open.range == block.range) {
|
if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) {
|
||||||
return field(open);
|
return self.field(open, Some(block), cx);
|
||||||
}
|
}
|
||||||
|
// A cell open in this block, typed in place.
|
||||||
|
let cell = open.and_then(|open| {
|
||||||
|
let inside = block.range.start <= open.range.start && open.range.end <= block.range.end;
|
||||||
|
Some(OpenCell {
|
||||||
|
at: open.cell.filter(|_| inside)?,
|
||||||
|
field: std::cell::RefCell::new(Some(
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "markdown-open-cell".to_string())
|
||||||
|
.on_mouse_down(MouseButton::Right, Self::point_in_open(cx))
|
||||||
|
.rounded(radius)
|
||||||
|
.bg(colors.sunken)
|
||||||
|
.child(open.input.clone())
|
||||||
|
.into_any_element(),
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
});
|
||||||
let shown = match &block.nodes {
|
let shown = match &block.nodes {
|
||||||
Some(nodes) => div()
|
Some(nodes) => div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -99,16 +149,35 @@ impl MarkdownDocument {
|
|||||||
.children(draw_nodes(
|
.children(draw_nodes(
|
||||||
&("render", ix).into(),
|
&("render", ix).into(),
|
||||||
nodes,
|
nodes,
|
||||||
&self.base,
|
Hooks {
|
||||||
&self.on_link,
|
base: &self.base,
|
||||||
|
on_link: &self.on_link,
|
||||||
|
on_heading: &self.on_heading,
|
||||||
|
on_point: &on_point,
|
||||||
|
cell: cell.as_ref(),
|
||||||
|
},
|
||||||
self.look.scale,
|
self.look.scale,
|
||||||
cx,
|
cx,
|
||||||
)),
|
)),
|
||||||
None => div()
|
None => {
|
||||||
.text_size(body)
|
let raw = &self.parsed.text[block.range.clone()];
|
||||||
.font_family(mono)
|
let drawn = StyledText::new(raw.to_string());
|
||||||
.text_color(colors.fg_muted)
|
let layout = drawn.layout().clone();
|
||||||
.child(self.parsed.text[block.range.clone()].to_string()),
|
let shown = div()
|
||||||
|
.text_size(body)
|
||||||
|
.font_family(mono)
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(drawn)
|
||||||
|
.into_any_element();
|
||||||
|
div().child(point_lines(
|
||||||
|
shown,
|
||||||
|
layout,
|
||||||
|
raw,
|
||||||
|
0,
|
||||||
|
&on_point,
|
||||||
|
(false, false),
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let block = div()
|
let block = div()
|
||||||
.id(("block", ix))
|
.id(("block", ix))
|
||||||
@@ -124,7 +193,39 @@ impl MarkdownDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `element` opens block `ix` on a press, Enter or Space.
|
/// The open block in its closed frame, size and weight.
|
||||||
|
fn field(
|
||||||
|
&self,
|
||||||
|
open: &super::Open,
|
||||||
|
block: Option<&Block>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let point = Self::point_in_open(cx);
|
||||||
|
let kind = block.map(|block| (block.kind, block.nodes.is_some()));
|
||||||
|
let text = open_text(kind, self.look.scale, cx.theme());
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "markdown-open".to_string())
|
||||||
|
.on_mouse_down(MouseButton::Right, point)
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(gpui::transparent_black())
|
||||||
|
.child(text.child(open.input.clone()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A right press in the open field points there.
|
||||||
|
fn point_in_open(
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static {
|
||||||
|
cx.listener(|document, event: &MouseDownEvent, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
document.point_open(Some(event.position), cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `element` opens block `ix`; a right press points.
|
||||||
fn pressable(
|
fn pressable(
|
||||||
&self,
|
&self,
|
||||||
element: gpui::Stateful<gpui::Div>,
|
element: gpui::Stateful<gpui::Div>,
|
||||||
@@ -148,13 +249,21 @@ impl MarkdownDocument {
|
|||||||
}
|
}
|
||||||
window.prevent_default();
|
window.prevent_default();
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
document.open(ix, false, window, cx);
|
document.open(ix, Landing::End, window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Right,
|
||||||
|
cx.listener(move |document, _: &MouseDownEvent, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
document.point(ix, None, Pointed::block(), window, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.on_click(
|
.on_click(
|
||||||
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
||||||
if event.is_keyboard() {
|
if event.is_keyboard() {
|
||||||
document.open(ix, false, window, cx);
|
document.open(ix, Landing::End, window, cx);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -163,9 +272,11 @@ impl MarkdownDocument {
|
|||||||
|
|
||||||
impl Render for MarkdownDocument {
|
impl Render for MarkdownDocument {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
self.take_orphan(window, cx);
|
||||||
// A top line asked before its text was read.
|
// A top line asked before its text was read.
|
||||||
self.place_top(window, cx);
|
self.place_top(window, cx);
|
||||||
self.show_waiting(window, cx);
|
self.show_waiting(window, cx);
|
||||||
|
self.open_waiting(window, cx);
|
||||||
let colors = cx.theme().colors.clone();
|
let colors = cx.theme().colors.clone();
|
||||||
let root = div()
|
let root = div()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -241,7 +352,13 @@ impl Render for MarkdownDocument {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.on_action(cx.listener(|document, _: &Paste, _, cx| {
|
.on_action(cx.listener(|document, _: &Paste, _, cx| {
|
||||||
document.paste_picture(cx);
|
if document.paste_picture(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||||
|
Some(text) => _ = document.insert(&text, cx),
|
||||||
|
None => log::info!("markdown document: nothing to paste"),
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
||||||
if document.block_newline(cx) {
|
if document.block_newline(cx) {
|
||||||
|
|||||||
+41
-11
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! `\(…\)` and `\[…\]` read as math between dollars.
|
||||||
|
|
||||||
|
use std::{borrow::Cow, ops::Range};
|
||||||
|
|
||||||
|
use markdown::{ParseOptions, mdast, unist::Position};
|
||||||
|
|
||||||
|
use super::{span, walk};
|
||||||
|
use crate::documents::tree::{Style, html};
|
||||||
|
|
||||||
|
/// Bytes a literal dollar may stand in as while parsing.
|
||||||
|
const STAND_INS: std::ops::RangeInclusive<u8> = 0x01..=0x08;
|
||||||
|
|
||||||
|
/// One pair's delimiters as written.
|
||||||
|
pub(super) struct Pair {
|
||||||
|
open: Range<usize>,
|
||||||
|
close: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swapped text and what to put back after parsing.
|
||||||
|
pub(super) struct Swapped<'a> {
|
||||||
|
pub text: Cow<'a, str>,
|
||||||
|
pairs: Vec<Pair>,
|
||||||
|
/// Spaces that only part two dollars.
|
||||||
|
gaps: Vec<usize>,
|
||||||
|
/// The byte every other dollar was written as.
|
||||||
|
stand_in: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` with each pair's delimiters as dollars, byte for byte.
|
||||||
|
pub(super) fn swapped<'a>(text: &'a str, options: &ParseOptions) -> Swapped<'a> {
|
||||||
|
let stand_in = STAND_INS
|
||||||
|
.clone()
|
||||||
|
.find(|byte| !text.as_bytes().contains(byte));
|
||||||
|
let none = || Swapped {
|
||||||
|
text: Cow::Borrowed(text),
|
||||||
|
pairs: Vec::new(),
|
||||||
|
gaps: Vec::new(),
|
||||||
|
stand_in: 0,
|
||||||
|
};
|
||||||
|
if !text.contains("\\(") && !text.contains("\\[") {
|
||||||
|
return none();
|
||||||
|
}
|
||||||
|
let Some(stand_in) = stand_in else {
|
||||||
|
log::warn!("markdown: \\( reads as text; every stand-in byte is used");
|
||||||
|
return none();
|
||||||
|
};
|
||||||
|
let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses");
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let mut kept = vec![false; text.len()];
|
||||||
|
let (mut pairs, mut dollars) = (Vec::new(), Vec::new());
|
||||||
|
for node in nodes {
|
||||||
|
use mdast::Node::{Heading, Paragraph, TableCell};
|
||||||
|
if matches!(node, Paragraph(_) | Heading(_) | TableCell(_)) {
|
||||||
|
keep(node, text, &mut kept);
|
||||||
|
paired(text, span(node), &kept, &mut pairs, &mut dollars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return none();
|
||||||
|
}
|
||||||
|
let (out, gaps) = written(text, &pairs, &dollars, stand_in);
|
||||||
|
Swapped {
|
||||||
|
text: Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")),
|
||||||
|
pairs,
|
||||||
|
gaps,
|
||||||
|
stand_in,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks what in `container` is not prose: code, tags, addresses.
|
||||||
|
fn keep(container: &mdast::Node, text: &str, kept: &mut [bool]) {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(container, &mut nodes);
|
||||||
|
// Open styled tags, and where code text last started.
|
||||||
|
let (mut open, mut from) = (Vec::new(), 0);
|
||||||
|
for node in &nodes[1..] {
|
||||||
|
use mdast::Node::*;
|
||||||
|
let whole = span(node);
|
||||||
|
match node {
|
||||||
|
InlineCode(_) | InlineMath(_) | Image(_) | ImageReference(_) | FootnoteReference(_) => {
|
||||||
|
kept[whole].fill(true)
|
||||||
|
}
|
||||||
|
Link(_) | LinkReference(_) => {
|
||||||
|
kept[whole.clone()].fill(true);
|
||||||
|
// Bracketed text is prose; its address is not.
|
||||||
|
let last = node.children().and_then(|children| children.last());
|
||||||
|
if text[whole.clone()].starts_with('[')
|
||||||
|
&& let Some(last) = last
|
||||||
|
{
|
||||||
|
// A text's span skips a leading escape: start at `[`.
|
||||||
|
kept[whole.start + 1..span(last).end].fill(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Html(_) => {
|
||||||
|
kept[whole.clone()].fill(true);
|
||||||
|
let Some(tag) = html::tag(&text[whole.clone()]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if html::inline_style(&tag.name).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let was = coded(&open);
|
||||||
|
match tag.closing {
|
||||||
|
true => end(&mut open, &tag.name),
|
||||||
|
false => {
|
||||||
|
if tag.name == "a" {
|
||||||
|
end(&mut open, "a");
|
||||||
|
}
|
||||||
|
open.push(tag.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (was, coded(&open)) {
|
||||||
|
(false, true) => from = whole.end,
|
||||||
|
(true, false) => kept[from..whole.start].fill(true),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unclosed `<code>` runs to its block's end.
|
||||||
|
if coded(&open) {
|
||||||
|
kept[from..span(container).end].fill(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an open tag shows its text as code.
|
||||||
|
fn coded(open: &[String]) -> bool {
|
||||||
|
(open.iter()).any(|name| html::inline_style(name) == Some(Style::Code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the innermost `name` as the reader does: inner tags close.
|
||||||
|
fn end(open: &mut Vec<String>, name: &str) {
|
||||||
|
let Some(at) = open.iter().rposition(|opened| opened == name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let closed = open.split_off(at);
|
||||||
|
open.extend(
|
||||||
|
closed
|
||||||
|
.into_iter()
|
||||||
|
.skip(1)
|
||||||
|
.filter(|name| html::reopens(name)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairs and bare dollars in `within`; pairs cross nothing kept.
|
||||||
|
fn paired(
|
||||||
|
text: &str,
|
||||||
|
within: Range<usize>,
|
||||||
|
kept: &[bool],
|
||||||
|
pairs: &mut Vec<Pair>,
|
||||||
|
dollars: &mut Vec<usize>,
|
||||||
|
) {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut open: Option<(usize, u8)> = None;
|
||||||
|
let mut at = within.start;
|
||||||
|
while at < within.end {
|
||||||
|
if kept[at] {
|
||||||
|
open = None;
|
||||||
|
} else if bytes[at] == b'$' {
|
||||||
|
dollars.push(at);
|
||||||
|
} else if bytes[at] == b'\\' && at + 1 < within.end {
|
||||||
|
match (bytes[at + 1], open) {
|
||||||
|
(b'(', None) => open = Some((at, b')')),
|
||||||
|
(b'[', None) => open = Some((at, b']')),
|
||||||
|
(close, Some((start, wanted))) if close == wanted && !kept[at + 1] => {
|
||||||
|
if !bytes[start + 2..at].iter().all(u8::is_ascii_whitespace) {
|
||||||
|
pairs.push(Pair {
|
||||||
|
open: start..start + 2,
|
||||||
|
close: at..at + 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
open = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// An escaped byte escapes nothing after it.
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swapped bytes; a space parts dollars that would touch.
|
||||||
|
fn written(text: &str, pairs: &[Pair], dollars: &[usize], stand_in: u8) -> (Vec<u8>, Vec<usize>) {
|
||||||
|
let mut out = text.as_bytes().to_vec();
|
||||||
|
for at in dollars {
|
||||||
|
out[*at] = stand_in;
|
||||||
|
}
|
||||||
|
let mut gaps = Vec::new();
|
||||||
|
for (ix, pair) in pairs.iter().enumerate() {
|
||||||
|
for byte in &mut out[pair.open.end..pair.close.start] {
|
||||||
|
if *byte == b'$' {
|
||||||
|
*byte = stand_in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let open = match pair.open.start.checked_sub(1).map(|before| out[before]) {
|
||||||
|
Some(b'$') => {
|
||||||
|
gaps.push(pair.open.start);
|
||||||
|
*b" $"
|
||||||
|
}
|
||||||
|
_ => *b"$ ",
|
||||||
|
};
|
||||||
|
out[pair.open.clone()].copy_from_slice(&open);
|
||||||
|
let after = pair.close.end;
|
||||||
|
let opens_next = pairs
|
||||||
|
.get(ix + 1)
|
||||||
|
.is_some_and(|next| next.open.start == after);
|
||||||
|
let close = match opens_next || out.get(after) == Some(&b'$') {
|
||||||
|
true => {
|
||||||
|
gaps.push(pair.close.start + 1);
|
||||||
|
*b"$ "
|
||||||
|
}
|
||||||
|
false => *b" $",
|
||||||
|
};
|
||||||
|
out[pair.close.clone()].copy_from_slice(&close);
|
||||||
|
}
|
||||||
|
(out, gaps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts TeX, spans and dollars back; drops parting spaces.
|
||||||
|
pub(super) fn mend(root: &mut mdast::Node, swapped: &Swapped) {
|
||||||
|
if swapped.pairs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let mdast::Node::InlineMath(math) = root
|
||||||
|
&& let Some(position) = &mut math.position
|
||||||
|
&& let Some(pair) =
|
||||||
|
(swapped.pairs.iter()).find(|pair| pair.open.contains(&position.start.offset))
|
||||||
|
{
|
||||||
|
math.value = written_tex(&math.value, pair, swapped, position);
|
||||||
|
position.start.column -= position.start.offset - pair.open.start;
|
||||||
|
position.start.offset = pair.open.start;
|
||||||
|
position.end.column += pair.close.end - position.end.offset;
|
||||||
|
position.end.offset = pair.close.end;
|
||||||
|
}
|
||||||
|
if let Some(children) = root.children_mut() {
|
||||||
|
children.iter_mut().for_each(|child| trim(child, swapped));
|
||||||
|
children.retain(|child| !matches!(child, mdast::Node::Text(text) if text.value.is_empty()));
|
||||||
|
children.iter_mut().for_each(|child| mend(child, swapped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TeX as written, from the value markdown-rs read.
|
||||||
|
fn written_tex(value: &str, pair: &Pair, swapped: &Swapped, at: &Position) -> String {
|
||||||
|
let bytes = swapped.text.as_bytes();
|
||||||
|
// A pad: the space a delimiter writes inside its dollar.
|
||||||
|
let pads = (
|
||||||
|
at.start.offset == pair.open.start,
|
||||||
|
at.end.offset == pair.close.end,
|
||||||
|
);
|
||||||
|
let ends = (
|
||||||
|
pads.0 || bytes[pair.open.end] == b' ',
|
||||||
|
pads.1 || bytes[pair.close.start - 1] == b' ',
|
||||||
|
);
|
||||||
|
// With spaces at both ends, markdown-rs trimmed one each.
|
||||||
|
let mut tex = match ends {
|
||||||
|
(true, true) => format!(" {value} "),
|
||||||
|
_ => value.to_string(),
|
||||||
|
};
|
||||||
|
if pads.1 {
|
||||||
|
tex.pop();
|
||||||
|
}
|
||||||
|
if pads.0 {
|
||||||
|
tex.remove(0);
|
||||||
|
}
|
||||||
|
tex.replace(char::from(swapped.stand_in), "$")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores a text's dollars; drops parting spaces.
|
||||||
|
fn trim(node: &mut mdast::Node, swapped: &Swapped) {
|
||||||
|
let mdast::Node::Text(text) = node else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
text.value = text.value.replace(char::from(swapped.stand_in), "$");
|
||||||
|
let Some(position) = &mut text.position else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if swapped.gaps.contains(&position.start.offset) && text.value.starts_with(' ') {
|
||||||
|
text.value.remove(0);
|
||||||
|
position.start.offset += 1;
|
||||||
|
position.start.column += 1;
|
||||||
|
}
|
||||||
|
if swapped.gaps.contains(&(position.end.offset - 1)) && text.value.ends_with(' ') {
|
||||||
|
text.value.pop();
|
||||||
|
position.end.offset -= 1;
|
||||||
|
position.end.column -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use crate::documents::markdown::{options, span, tree, walk};
|
||||||
|
|
||||||
|
/// Each formula's TeX and span, and the text left.
|
||||||
|
fn read(source: &str) -> (Vec<(String, std::ops::Range<usize>)>, String) {
|
||||||
|
let root = tree(source);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let mut maths = Vec::new();
|
||||||
|
let mut text = String::new();
|
||||||
|
for node in nodes {
|
||||||
|
match node {
|
||||||
|
Node::InlineMath(math) => maths.push((math.value.clone(), span(node))),
|
||||||
|
Node::Text(written) => text.push_str(&written.value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(maths, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tex(source: &str) -> Vec<String> {
|
||||||
|
read(source).0.into_iter().map(|(tex, _)| tex).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn untouched(source: &str) {
|
||||||
|
let plain = markdown::to_mdast(source, &options()).unwrap();
|
||||||
|
assert_eq!(tree(source), plain, "{source:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pair_spans_its_delimiters_and_keeps_its_tex() {
|
||||||
|
assert_eq!(
|
||||||
|
read(r"a \(x\) b"),
|
||||||
|
(vec![("x".into(), 2..7)], "a b".into())
|
||||||
|
);
|
||||||
|
assert_eq!(tex(r"\( x \)"), [" x "]);
|
||||||
|
assert_eq!(tex(r"\(x \)\(y\)"), ["x ", "y"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjacent_formulas_stay_two() {
|
||||||
|
for (source, maths) in [
|
||||||
|
(r"\(x\)\(y\)", vec!["x", "y"]),
|
||||||
|
(r"$x$\(y\)", vec!["x", "y"]),
|
||||||
|
(r"\(y\)$x$", vec!["y", "x"]),
|
||||||
|
(r"\[x\]\(y\)", vec!["x", "y"]),
|
||||||
|
] {
|
||||||
|
let (found, text) = read(source);
|
||||||
|
let found: Vec<_> = found.into_iter().map(|(tex, _)| tex).collect();
|
||||||
|
assert_eq!(found, maths, "{source:?}");
|
||||||
|
assert_eq!(text, "", "no parting space shows: {source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollars_stay_dollars() {
|
||||||
|
assert_eq!(tex(r"\(\$5\)"), [r"\$5"]);
|
||||||
|
assert_eq!(tex(r"\[a \$ b\]"), [r"a \$ b"]);
|
||||||
|
assert_eq!(tex(r"\(a$b\)"), ["a$b"]);
|
||||||
|
assert_eq!(
|
||||||
|
read(r"costs $5 and \(x\)"),
|
||||||
|
(vec![("x".into(), 13..18)], "costs $5 and ".into()),
|
||||||
|
"a stray dollar does not pair with a formula"
|
||||||
|
);
|
||||||
|
assert_eq!(read(r"\$\(x\)").1, "$", "an escaped dollar before");
|
||||||
|
assert_eq!(read(r"\(x\)\$").1, "$", "an escaped dollar after");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addresses_keep_their_parentheses() {
|
||||||
|
let link = r"[go](https://e.test/\(x\))";
|
||||||
|
untouched(link);
|
||||||
|
untouched(r".png)");
|
||||||
|
untouched("[go][a\\(x\\)]\n\n[a\\(x\\)]: /u");
|
||||||
|
untouched(r"<https://e.test/\(x\)>");
|
||||||
|
assert_eq!(tex(r"[\(x\)](https://e.test)"), ["x"], "link text is prose");
|
||||||
|
untouched(r"[a\(b](c)\)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_and_tags_stay_literal() {
|
||||||
|
untouched(r"a <code>\(x\)</code> b");
|
||||||
|
untouched(r"a <kbd>\(x\)</kbd> b");
|
||||||
|
untouched(r"a <code>\(x\) b");
|
||||||
|
untouched(r"a <code><code>\(x\)</code> \(y\)</code> b");
|
||||||
|
untouched(r"a <kbd><code>\(x\)</code> \(y\)</kbd> b");
|
||||||
|
assert_eq!(tex(r"<code>a</code> \(x\)"), ["x"], "after code, prose");
|
||||||
|
for crossed in [
|
||||||
|
r"a <code><kbd>x</code> \(y\) b",
|
||||||
|
r"a <b><kbd>x</b> \(y\) b",
|
||||||
|
r"a <code><b>x</code> \(y\)</b> b",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
tex(crossed),
|
||||||
|
["y"],
|
||||||
|
"a crossed close ends code: {crossed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
untouched(r"a <b><code>x</b> \(y\) b");
|
||||||
|
let reopened = crate::documents::tree::parse(r"a <b><code>x</b> \(y\) b");
|
||||||
|
let crate::documents::tree::Node::Paragraph(inline) = &reopened[0] else {
|
||||||
|
panic!("a paragraph: {reopened:?}");
|
||||||
|
};
|
||||||
|
let code = crate::documents::tree::Style::Code;
|
||||||
|
assert!(
|
||||||
|
(inline.styles.iter()).any(|(range, style)| *style == code && range.contains(&6)),
|
||||||
|
"the reader reopens code over (y): {inline:?}"
|
||||||
|
);
|
||||||
|
untouched(r"`\(x\)`");
|
||||||
|
untouched(r"\(a `b` c\)");
|
||||||
|
assert_eq!(tex(r"<b>\(x\)</b>"), ["x"], "other tags hold prose");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pair_stays_in_one_block() {
|
||||||
|
untouched("- \\(a\n- b\\)");
|
||||||
|
untouched("\\(a\r\rb\\)");
|
||||||
|
untouched("\\(a\r\n\r\nb\\)");
|
||||||
|
untouched("# \\(a\nb\\)");
|
||||||
|
untouched("| \\(a | b\\) |\n| - | - |");
|
||||||
|
assert_eq!(tex("> \\(a\n> b\\)"), ["a\nb"], "a quote's lines");
|
||||||
|
}
|
||||||
@@ -36,8 +36,9 @@ pub use counts::{ReadingTime, WordCount};
|
|||||||
pub use diagram::{Diagram, diagram_svg};
|
pub use diagram::{Diagram, diagram_svg};
|
||||||
pub(crate) use diagram::{flatten, options as svg_options};
|
pub(crate) use diagram::{flatten, options as svg_options};
|
||||||
pub use document::{
|
pub use document::{
|
||||||
Attachment, DocumentEvent, DocumentHeading, DocumentLook, LARGEST_READ, MarkdownDocument,
|
Attachment, BlockCommand, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget,
|
||||||
TableEdit, TableRefusal,
|
EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal,
|
||||||
|
TargetBlock,
|
||||||
};
|
};
|
||||||
pub use epub::{Chapter, EpubReader};
|
pub use epub::{Chapter, EpubReader};
|
||||||
pub use format::{Format, format};
|
pub use format::{Format, format};
|
||||||
@@ -45,7 +46,7 @@ pub(crate) use history::Change;
|
|||||||
pub use history::{PageHistoryDiff, Version, VersionHistory};
|
pub use history::{PageHistoryDiff, Version, VersionHistory};
|
||||||
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
||||||
pub use link::LinkEditor;
|
pub use link::LinkEditor;
|
||||||
pub use markdown::file_options as markdown_options;
|
pub use markdown::file_tree as markdown_tree;
|
||||||
pub use markdown::markdown_highlights;
|
pub use markdown::markdown_highlights;
|
||||||
pub use modes::{MarkdownEditor, MarkdownMode};
|
pub use modes::{MarkdownEditor, MarkdownMode};
|
||||||
pub use pages::{PageCover, PageIcon};
|
pub use pages::{PageCover, PageIcon};
|
||||||
@@ -57,6 +58,9 @@ pub use templates::{Template, TemplatePicker};
|
|||||||
pub use thumbs::PageThumbnailList;
|
pub use thumbs::PageThumbnailList;
|
||||||
pub use todo::{Checklist, TodoItem};
|
pub use todo::{Checklist, TodoItem};
|
||||||
pub use toolbar::FixedFormatToolbar;
|
pub use toolbar::FixedFormatToolbar;
|
||||||
|
pub use tree::{
|
||||||
|
AlertKind, Inline, Node as MarkdownBlock, Reader as MarkdownReader, Style, alert, is_toc,
|
||||||
|
};
|
||||||
pub(crate) use viewer::step as zoom_step;
|
pub(crate) use viewer::step as zoom_step;
|
||||||
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
|
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
|
||||||
|
|
||||||
|
|||||||
+234
-217
@@ -1,11 +1,10 @@
|
|||||||
use std::{path::Path, rc::Rc, sync::Arc};
|
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
|
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
||||||
UnderlineStyle, Window, div, prelude::*,
|
StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
|
||||||
};
|
};
|
||||||
use markdown::mdast::AlignKind;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
contents::anchor,
|
contents::anchor,
|
||||||
@@ -16,12 +15,22 @@ use super::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
editor::{code_colors, code_colors_with},
|
editor::{code_colors, code_colors_with},
|
||||||
navigation::{Anchor, Sections},
|
navigation::{Anchor, Sections},
|
||||||
primitives::{FocusRing as _, Icon, IconName},
|
primitives::FocusRing as _,
|
||||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, Palette, Radius, TextSize},
|
||||||
typography::{Latex, failed},
|
typography::{Latex, failed},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod blocks;
|
||||||
|
mod math_text;
|
||||||
|
mod point;
|
||||||
|
mod table;
|
||||||
|
|
||||||
|
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
|
||||||
|
pub(crate) use table::{CellAt, OpenCell};
|
||||||
|
|
||||||
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||||
|
/// Reveals the heading whose source starts at a byte.
|
||||||
|
pub(crate) type OnHeading = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||||
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
|
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
|
||||||
|
|
||||||
/// Markdown drawn as a document; links go to the host.
|
/// Markdown drawn as a document; links go to the host.
|
||||||
@@ -83,17 +92,139 @@ impl MarkdownRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws nodes under one owner's ids and link handler.
|
/// What an owner hands its drawing: pictures' folder and presses.
|
||||||
|
pub(crate) struct Hooks<'a> {
|
||||||
|
pub base: &'a Option<Arc<Path>>,
|
||||||
|
pub on_link: &'a Option<OnLink>,
|
||||||
|
pub on_heading: &'a Option<OnHeading>,
|
||||||
|
pub on_point: &'a Option<OnPoint>,
|
||||||
|
/// The cell typed in, drawn in its place.
|
||||||
|
pub cell: Option<&'a OpenCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headings and strong text share one weight.
|
||||||
|
pub(crate) const HEAVY: FontWeight = FontWeight::BOLD;
|
||||||
|
|
||||||
|
/// Typora's heading steps over the body.
|
||||||
|
pub(crate) fn heading_step(level: u8) -> f32 {
|
||||||
|
match level {
|
||||||
|
1 => 2.25,
|
||||||
|
2 => 1.75,
|
||||||
|
3 => 1.5,
|
||||||
|
4 => 1.25,
|
||||||
|
_ => 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one inline style draws.
|
||||||
|
fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
|
||||||
|
match style {
|
||||||
|
Style::Strong => HighlightStyle {
|
||||||
|
font_weight: Some(HEAVY),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Emphasis => HighlightStyle {
|
||||||
|
font_style: Some(FontStyle::Italic),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Strike => HighlightStyle {
|
||||||
|
strikethrough: Some(StrikethroughStyle {
|
||||||
|
thickness: rule,
|
||||||
|
color: Some(colors.fg_muted),
|
||||||
|
}),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Code | Style::Math => HighlightStyle {
|
||||||
|
background_color: Some(colors.sunken),
|
||||||
|
color: Some(if *style == Style::Math {
|
||||||
|
colors.syntax.function
|
||||||
|
} else {
|
||||||
|
colors.fg
|
||||||
|
}),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Link => HighlightStyle {
|
||||||
|
color: Some(colors.link),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: rule,
|
||||||
|
color: Some(colors.link.opacity(0.4)),
|
||||||
|
wavy: false,
|
||||||
|
}),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Footnote => HighlightStyle {
|
||||||
|
color: Some(colors.fg_subtle),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Underline => HighlightStyle {
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: rule,
|
||||||
|
color: None,
|
||||||
|
wavy: false,
|
||||||
|
}),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
Style::Mark => HighlightStyle {
|
||||||
|
background_color: Some(colors.warning_subtle),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlights laid over a text's own.
|
||||||
|
type Over = Vec<(Range<usize>, HighlightStyle)>;
|
||||||
|
|
||||||
|
/// `inline` as text; hidden ranges lose highlight, `over` adds some.
|
||||||
|
fn drawn_text(
|
||||||
|
inline: &Inline,
|
||||||
|
(hidden, over): (&[Range<usize>], Over),
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: &Option<OnLink>,
|
||||||
|
cx: &App,
|
||||||
|
) -> (AnyElement, TextLayout) {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let rule = theme.underline_thickness();
|
||||||
|
let styles: Vec<_> = (inline.styles.iter())
|
||||||
|
.filter(|(range, _)| !hidden.contains(range))
|
||||||
|
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
||||||
|
.chain(over)
|
||||||
|
.collect();
|
||||||
|
let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||||
|
let layout = styled.layout().clone();
|
||||||
|
let drawn = match links {
|
||||||
|
None => styled.into_any_element(),
|
||||||
|
Some(id) => {
|
||||||
|
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
||||||
|
let on_link = on_link.clone();
|
||||||
|
InteractiveText::new(id, styled)
|
||||||
|
.on_click(ranges, move |ix, window, cx| {
|
||||||
|
open_link(&on_link, &urls[ix], window, cx)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(drawn, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws nodes under one owner's ids and hooks.
|
||||||
struct Draw<'a> {
|
struct Draw<'a> {
|
||||||
id: &'a ElementId,
|
id: &'a ElementId,
|
||||||
on_link: &'a Option<OnLink>,
|
hooks: Hooks<'a>,
|
||||||
next: std::cell::Cell<usize>,
|
next: std::cell::Cell<usize>,
|
||||||
sections: &'a Option<Entity<Sections>>,
|
sections: &'a Option<Entity<Sections>>,
|
||||||
headings: std::cell::Cell<usize>,
|
headings: std::cell::Cell<usize>,
|
||||||
code: &'a Option<DrawCode>,
|
code: &'a Option<DrawCode>,
|
||||||
base: &'a Option<Arc<Path>>,
|
/// The body's size; headings step up from it.
|
||||||
|
body: TextSize,
|
||||||
/// Text size against the theme's.
|
/// Text size against the theme's.
|
||||||
scale: f32,
|
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<'_> {
|
impl Draw<'_> {
|
||||||
@@ -104,67 +235,21 @@ impl Draw<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let links = (!inline.links.is_empty()).then(|| self.key("links"));
|
||||||
let colors = &theme.colors;
|
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
||||||
let rule = theme.underline_thickness();
|
let (drawn, layout) =
|
||||||
let styles: Vec<_> = inline
|
drawn_text(inline, (&[], Vec::new()), links, self.hooks.on_link, cx);
|
||||||
.styles
|
let at = move |position| {
|
||||||
.iter()
|
layout
|
||||||
.map(|(range, style)| {
|
.index_for_position(position)
|
||||||
let highlight = match style {
|
.unwrap_or_else(|near| near)
|
||||||
Style::Strong => HighlightStyle {
|
};
|
||||||
font_weight: Some(FontWeight::SEMIBOLD),
|
return self.point_text(drawn, at, inline);
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
Style::Emphasis => HighlightStyle {
|
|
||||||
font_style: Some(FontStyle::Italic),
|
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
Style::Strike => HighlightStyle {
|
|
||||||
strikethrough: Some(StrikethroughStyle {
|
|
||||||
thickness: rule,
|
|
||||||
color: Some(colors.fg_muted),
|
|
||||||
}),
|
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
Style::Code | Style::Math => HighlightStyle {
|
|
||||||
background_color: Some(colors.sunken),
|
|
||||||
color: Some(if *style == Style::Math {
|
|
||||||
colors.syntax.function
|
|
||||||
} else {
|
|
||||||
colors.fg
|
|
||||||
}),
|
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
Style::Link => HighlightStyle {
|
|
||||||
color: Some(colors.link),
|
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: rule,
|
|
||||||
color: Some(colors.link.opacity(0.4)),
|
|
||||||
wavy: false,
|
|
||||||
}),
|
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
Style::Footnote => HighlightStyle {
|
|
||||||
color: Some(colors.fg_subtle),
|
|
||||||
..HighlightStyle::default()
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(range.clone(), highlight)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let styled =
|
|
||||||
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
|
||||||
if inline.links.is_empty() {
|
|
||||||
return styled.into_any_element();
|
|
||||||
}
|
}
|
||||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
let on_link = self.hooks.on_link.clone();
|
||||||
let on_link = self.on_link.clone();
|
let (drawn, at) =
|
||||||
InteractiveText::new(self.key("links"), styled)
|
math_text::MathText::new(self.key("math"), inline.clone(), links, on_link);
|
||||||
.on_click(ranges, move |ix, window, cx| {
|
self.point_text(drawn.into_any_element(), at, inline)
|
||||||
open_link(&on_link, &urls[ix], window, cx)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||||
@@ -172,6 +257,14 @@ impl Draw<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn node(&self, node: &Node, cx: &App) -> AnyElement {
|
fn node(&self, node: &Node, cx: &App) -> AnyElement {
|
||||||
|
let table = self.table.get();
|
||||||
|
self.table.set(table || matches!(node, Node::Table { .. }));
|
||||||
|
let drawn = self.drawn(node, cx);
|
||||||
|
self.table.set(table);
|
||||||
|
drawn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drawn(&self, node: &Node, cx: &App) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = theme.colors.clone();
|
let colors = theme.colors.clone();
|
||||||
match node {
|
match node {
|
||||||
@@ -180,24 +273,22 @@ impl Draw<'_> {
|
|||||||
.child(self.inline(inline, cx))
|
.child(self.inline(inline, cx))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Heading(level, inline) => {
|
Node::Heading(level, inline) => {
|
||||||
let size = match level {
|
// Every level bold.
|
||||||
1 => TextSize::Xxl,
|
let size = theme.text_size(self.body) * heading_step(*level) * self.scale;
|
||||||
2 => TextSize::Xl,
|
|
||||||
3 => TextSize::Lg,
|
|
||||||
_ => TextSize::Md,
|
|
||||||
};
|
|
||||||
let heading = div()
|
|
||||||
.pt_2()
|
|
||||||
.text_size(theme.text_size(size) * self.scale)
|
|
||||||
.font_weight(if *level <= 2 {
|
|
||||||
FontWeight::SEMIBOLD
|
|
||||||
} else {
|
|
||||||
FontWeight::MEDIUM
|
|
||||||
})
|
|
||||||
.text_color(colors.fg)
|
|
||||||
.child(self.inline(inline, cx));
|
|
||||||
let ordinal = self.headings.get();
|
let ordinal = self.headings.get();
|
||||||
self.headings.set(ordinal + 1);
|
self.headings.set(ordinal + 1);
|
||||||
|
let heading = div()
|
||||||
|
.debug_selector(move || format!("markdown-heading-{ordinal}"))
|
||||||
|
.pt_2()
|
||||||
|
.text_size(size)
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.font_weight(HEAVY)
|
||||||
|
.text_color(if *level == 6 {
|
||||||
|
colors.fg_muted
|
||||||
|
} else {
|
||||||
|
colors.fg
|
||||||
|
})
|
||||||
|
.child(self.inline(inline, cx));
|
||||||
match self.sections {
|
match self.sections {
|
||||||
Some(sections) => Anchor::new(sections, anchor(ordinal))
|
Some(sections) => Anchor::new(sections, anchor(ordinal))
|
||||||
.child(heading)
|
.child(heading)
|
||||||
@@ -215,9 +306,14 @@ impl Draw<'_> {
|
|||||||
.text_color(colors.fg_muted)
|
.text_color(colors.fg_muted)
|
||||||
.children(self.nodes(inner, cx))
|
.children(self.nodes(inner, cx))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Code { language, text } => {
|
Node::Code {
|
||||||
|
language,
|
||||||
|
text,
|
||||||
|
from,
|
||||||
|
} => {
|
||||||
if language.as_deref() == Some("mermaid") {
|
if language.as_deref() == Some("mermaid") {
|
||||||
return Diagram::new(self.key("diagram"), text.clone()).into_any_element();
|
let diagram = Diagram::new(self.key("diagram"), text.clone());
|
||||||
|
return self.point_code(diagram.into_any_element(), None, text, *from);
|
||||||
}
|
}
|
||||||
if let Some(draw) = self.code {
|
if let Some(draw) = self.code {
|
||||||
return draw(
|
return draw(
|
||||||
@@ -232,7 +328,9 @@ impl Draw<'_> {
|
|||||||
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
|
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
|
||||||
None => code_colors(text, cx),
|
None => code_colors(text, cx),
|
||||||
};
|
};
|
||||||
div()
|
let code = StyledText::new(text.clone()).with_highlights(styles);
|
||||||
|
let layout = code.layout().clone();
|
||||||
|
let block = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -248,120 +346,17 @@ impl Draw<'_> {
|
|||||||
.text_color(colors.fg_subtle)
|
.text_color(colors.fg_subtle)
|
||||||
.child(name)
|
.child(name)
|
||||||
}))
|
}))
|
||||||
.child(StyledText::new(text.clone()).with_highlights(styles))
|
.child(code)
|
||||||
.into_any_element()
|
.into_any_element();
|
||||||
}
|
self.point_code(block, Some(layout), text, *from)
|
||||||
Node::List { start, items } => div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.children(items.iter().enumerate().map(|(ix, (task, body))| {
|
|
||||||
let marker: AnyElement = match (task, start) {
|
|
||||||
(Some(done), _) => Icon::new(if *done {
|
|
||||||
IconName::SquareCheck
|
|
||||||
} else {
|
|
||||||
IconName::Square
|
|
||||||
})
|
|
||||||
.size(IconSize::Sm)
|
|
||||||
.color(if *done {
|
|
||||||
colors.focus
|
|
||||||
} else {
|
|
||||||
colors.fg_subtle
|
|
||||||
})
|
|
||||||
.into_any_element(),
|
|
||||||
(None, Some(first)) => div()
|
|
||||||
.text_color(colors.fg_muted)
|
|
||||||
.child(format!("{}.", first + ix as u64))
|
|
||||||
.into_any_element(),
|
|
||||||
(None, None) => div()
|
|
||||||
.text_color(colors.fg_muted)
|
|
||||||
.child("•")
|
|
||||||
.into_any_element(),
|
|
||||||
};
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_none()
|
|
||||||
.min_w_5()
|
|
||||||
.flex()
|
|
||||||
.justify_end()
|
|
||||||
.child(marker),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.when(*task == Some(true), |body| {
|
|
||||||
body.text_color(colors.fg_subtle)
|
|
||||||
})
|
|
||||||
.children(self.nodes(body, cx)),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
.into_any_element(),
|
|
||||||
Node::Table { aligns, head, rows } => {
|
|
||||||
let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| {
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.px_3()
|
|
||||||
.py_1p5()
|
|
||||||
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
|
|
||||||
cell.justify_end()
|
|
||||||
})
|
|
||||||
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
|
|
||||||
cell.justify_center()
|
|
||||||
})
|
|
||||||
.when(header, |cell| {
|
|
||||||
cell.font_weight(FontWeight::MEDIUM)
|
|
||||||
.text_color(colors.fg_muted)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.max_w_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.children(self.nodes(blocks, cx)),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.rounded(theme.radius(Radius::Md))
|
|
||||||
.border_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(
|
|
||||||
div().flex().bg(colors.hover).children(
|
|
||||||
head.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(column, blocks)| cell(blocks, column, true, cx)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.children(rows.iter().map(|row| {
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.border_t_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.children(
|
|
||||||
row.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(column, blocks)| cell(blocks, column, false, cx)),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
Node::List { start, items } => self.list(start, items, cx),
|
||||||
|
Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
|
||||||
Node::Rule => div()
|
Node::Rule => div()
|
||||||
|
.debug_selector(|| "markdown-rule".into())
|
||||||
.my_2()
|
.my_2()
|
||||||
.h_0()
|
.border_t_2()
|
||||||
.border_t_1()
|
.border_color(colors.border_strong)
|
||||||
.border_color(colors.border)
|
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Math(tex) => div()
|
Node::Math(tex) => div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -372,23 +367,28 @@ impl Draw<'_> {
|
|||||||
Err(error) => failed(&error, tex, cx),
|
Err(error) => failed(&error, tex, cx),
|
||||||
})
|
})
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Image { alt, url, link } => {
|
Node::Image { alt, url, link, at } => {
|
||||||
let picture = div().id(self.key("image")).child(Picture {
|
let picture = div().id(self.key("image")).child(Picture {
|
||||||
id: self.key("picture"),
|
id: self.key("picture"),
|
||||||
alt: alt.clone(),
|
alt: alt.clone(),
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
base: self.base.clone(),
|
base: self.hooks.base.clone(),
|
||||||
});
|
});
|
||||||
let Some(link) = link.clone() else {
|
let drawn = match link.clone() {
|
||||||
return picture.into_any_element();
|
None => picture.into_any_element(),
|
||||||
|
Some(address) => {
|
||||||
|
let on_link = self.hooks.on_link.clone();
|
||||||
|
picture
|
||||||
|
.tab_index(0)
|
||||||
|
.focus_ring(cx)
|
||||||
|
.cursor_pointer()
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
open_link(&on_link, &address, window, cx)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let on_link = self.on_link.clone();
|
self.point_image(drawn, *at, url, link)
|
||||||
picture
|
|
||||||
.tab_index(0)
|
|
||||||
.focus_ring(cx)
|
|
||||||
.cursor_pointer()
|
|
||||||
.on_click(move |_, window, cx| open_link(&on_link, &link, window, cx))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
Node::Footnote { label, body } => div()
|
Node::Footnote { label, body } => div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -404,6 +404,9 @@ impl Draw<'_> {
|
|||||||
.children(self.nodes(body, cx)),
|
.children(self.nodes(body, cx)),
|
||||||
)
|
)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
|
Node::Alert { kind, body } => self.alert(*kind, body, cx),
|
||||||
|
Node::Toc(headings) => self.toc(headings, cx),
|
||||||
|
Node::FrontMatter { text, from } => self.front_matter(text, *from, cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,24 +421,26 @@ fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed `nodes` drawn under `id`; pictures read from `base`.
|
/// Parsed `nodes` drawn under `id`, with the owner's hooks.
|
||||||
pub(crate) fn draw_nodes(
|
pub(crate) fn draw_nodes(
|
||||||
id: &ElementId,
|
id: &ElementId,
|
||||||
nodes: &[Node],
|
nodes: &[Node],
|
||||||
base: &Option<Arc<Path>>,
|
hooks: Hooks,
|
||||||
on_link: &Option<OnLink>,
|
|
||||||
scale: f32,
|
scale: f32,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Vec<AnyElement> {
|
) -> Vec<AnyElement> {
|
||||||
let draw = Draw {
|
let draw = Draw {
|
||||||
id,
|
id,
|
||||||
on_link,
|
hooks,
|
||||||
next: std::cell::Cell::new(0),
|
next: std::cell::Cell::new(0),
|
||||||
sections: &None,
|
sections: &None,
|
||||||
headings: std::cell::Cell::new(0),
|
headings: std::cell::Cell::new(0),
|
||||||
code: &None,
|
code: &None,
|
||||||
base,
|
body: TextSize::Base,
|
||||||
scale,
|
scale,
|
||||||
|
lists: std::cell::Cell::new(0),
|
||||||
|
table: std::cell::Cell::new(false),
|
||||||
|
tables: std::cell::Cell::new(0),
|
||||||
};
|
};
|
||||||
draw.nodes(nodes, cx)
|
draw.nodes(nodes, cx)
|
||||||
}
|
}
|
||||||
@@ -446,13 +451,22 @@ impl RenderOnce for MarkdownRenderer {
|
|||||||
let nodes = parse(&self.source);
|
let nodes = parse(&self.source);
|
||||||
let draw = Draw {
|
let draw = Draw {
|
||||||
id: &self.id,
|
id: &self.id,
|
||||||
on_link: &self.on_link,
|
hooks: Hooks {
|
||||||
|
base: &self.base,
|
||||||
|
on_link: &self.on_link,
|
||||||
|
on_heading: &None,
|
||||||
|
on_point: &None,
|
||||||
|
cell: None,
|
||||||
|
},
|
||||||
next: std::cell::Cell::new(0),
|
next: std::cell::Cell::new(0),
|
||||||
sections: &self.sections,
|
sections: &self.sections,
|
||||||
headings: std::cell::Cell::new(0),
|
headings: std::cell::Cell::new(0),
|
||||||
code: &self.code,
|
code: &self.code,
|
||||||
base: &self.base,
|
body: self.size,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
lists: std::cell::Cell::new(0),
|
||||||
|
table: std::cell::Cell::new(false),
|
||||||
|
tables: std::cell::Cell::new(0),
|
||||||
};
|
};
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -463,3 +477,6 @@ impl RenderOnce for MarkdownRenderer {
|
|||||||
.children(draw.nodes(&nodes, cx))
|
.children(draw.nodes(&nodes, cx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use gpui::{
|
||||||
|
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, StyledText, div,
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Draw, point::point_lines};
|
||||||
|
use crate::{
|
||||||
|
documents::tree::{AlertKind, DocumentHeading, Node},
|
||||||
|
primitives::{FocusRing as _, Icon, IconName},
|
||||||
|
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Draw<'_> {
|
||||||
|
/// A list; bullets turn with depth, as browsers draw them.
|
||||||
|
pub(super) fn list(
|
||||||
|
&self,
|
||||||
|
start: &Option<u64>,
|
||||||
|
items: &[(Option<bool>, Vec<Node>)],
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let colors = cx.theme().colors.clone();
|
||||||
|
let depth = self.lists.get();
|
||||||
|
self.lists.set(depth + 1);
|
||||||
|
let bullet = match depth {
|
||||||
|
0 => "•",
|
||||||
|
1 => "◦",
|
||||||
|
_ => "▪",
|
||||||
|
};
|
||||||
|
let list = div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.children(items.iter().enumerate().map(|(ix, (task, body))| {
|
||||||
|
let marker: AnyElement = match (task, start) {
|
||||||
|
(Some(done), _) => Icon::new(if *done {
|
||||||
|
IconName::SquareCheck
|
||||||
|
} else {
|
||||||
|
IconName::Square
|
||||||
|
})
|
||||||
|
.size(IconSize::Sm)
|
||||||
|
.color(if *done {
|
||||||
|
colors.focus
|
||||||
|
} else {
|
||||||
|
colors.fg_subtle
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
(None, Some(first)) => div()
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(format!("{}.", first + ix as u64))
|
||||||
|
.into_any_element(),
|
||||||
|
(None, None) => div()
|
||||||
|
.debug_selector(move || format!("markdown-bullet-{depth}"))
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(bullet)
|
||||||
|
.into_any_element(),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.min_w_5()
|
||||||
|
.flex()
|
||||||
|
.justify_end()
|
||||||
|
.child(marker),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.when(*task == Some(true), |body| {
|
||||||
|
body.text_color(colors.fg_subtle)
|
||||||
|
})
|
||||||
|
.children(self.nodes(body, cx)),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.into_any_element();
|
||||||
|
self.lists.set(depth);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GitHub alert: a colored rule, icon, title and body.
|
||||||
|
pub(super) fn alert(&self, kind: AlertKind, body: &[Node], cx: &App) -> AnyElement {
|
||||||
|
let colors = &cx.theme().colors;
|
||||||
|
let (tone, icon) = match kind {
|
||||||
|
AlertKind::Note => (colors.info, IconName::Info),
|
||||||
|
AlertKind::Tip => (colors.success, IconName::Lightbulb),
|
||||||
|
// The palette's violet, read at 4.5:1.
|
||||||
|
AlertKind::Important => (colors.syntax.keyword, IconName::MessageSquareWarning),
|
||||||
|
AlertKind::Warning => (colors.warning, IconName::TriangleAlert),
|
||||||
|
AlertKind::Caution => (colors.danger, IconName::OctagonAlert),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.py_1()
|
||||||
|
.pl_4()
|
||||||
|
.border_l_2()
|
||||||
|
.border_color(tone)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(tone)
|
||||||
|
.child(Icon::new(icon).size(IconSize::Sm).color(tone))
|
||||||
|
.child(kind.title()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.text_color(colors.fg)
|
||||||
|
.children(self.nodes(body, cx)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[toc]`: each heading by depth; a press reveals it.
|
||||||
|
pub(super) fn toc(&self, headings: &[DocumentHeading], cx: &App) -> AnyElement {
|
||||||
|
let colors = cx.theme().colors.clone();
|
||||||
|
let top = headings.iter().map(|heading| heading.depth).min();
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.children(headings.iter().enumerate().map(|(ix, heading)| {
|
||||||
|
let entry = div()
|
||||||
|
.id(self.key("toc"))
|
||||||
|
.debug_selector(move || format!("markdown-toc-{ix}"))
|
||||||
|
.child(heading.title.clone());
|
||||||
|
let entry = match heading.depth - top.expect("a heading is listed") {
|
||||||
|
0 => entry,
|
||||||
|
1 => entry.pl_4(),
|
||||||
|
2 => entry.pl_8(),
|
||||||
|
3 => entry.pl_12(),
|
||||||
|
4 => entry.pl_16(),
|
||||||
|
_ => entry.pl_20(),
|
||||||
|
};
|
||||||
|
let Some(reveal) = self.hooks.on_heading.clone() else {
|
||||||
|
return entry.text_color(colors.fg_muted).into_any_element();
|
||||||
|
};
|
||||||
|
let (at, pressed) = (heading.at, reveal.clone());
|
||||||
|
entry
|
||||||
|
.text_color(colors.link)
|
||||||
|
.tab_index(0)
|
||||||
|
.focus_ring(cx)
|
||||||
|
.cursor_pointer()
|
||||||
|
// On press: the layout may move before release.
|
||||||
|
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||||
|
if event.modifiers.secondary() {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
pressed(at, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click(move |event, window, cx| {
|
||||||
|
if !event.modifiers().secondary() {
|
||||||
|
reveal(at, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Front matter as written, inside its box.
|
||||||
|
pub(super) fn front_matter(&self, text: &str, from: usize, cx: &App) -> AnyElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let drawn = StyledText::new(text.to_string());
|
||||||
|
let layout = drawn.layout().clone();
|
||||||
|
let block = div()
|
||||||
|
.p_3()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(theme.colors.sunken)
|
||||||
|
.font_family(theme.mono_family.clone())
|
||||||
|
.text_size(theme.text_size(TextSize::Sm) * self.scale)
|
||||||
|
.text_color(theme.colors.fg_muted)
|
||||||
|
.child(drawn)
|
||||||
|
.into_any_element();
|
||||||
|
point_lines(
|
||||||
|
block,
|
||||||
|
layout,
|
||||||
|
text,
|
||||||
|
from,
|
||||||
|
self.hooks.on_point,
|
||||||
|
(false, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
//! Inline formulas typeset among a paragraph's words.
|
||||||
|
|
||||||
|
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, HighlightStyle,
|
||||||
|
InspectorElementId, IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size,
|
||||||
|
TextLayout, TextStyleRefinement, Window, point,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{OnLink, drawn_text};
|
||||||
|
use crate::{
|
||||||
|
documents::tree::{Inline, Style},
|
||||||
|
theme::ActiveTheme,
|
||||||
|
typography::typeset,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Glue that reserves a formula's width and never breaks.
|
||||||
|
const GLUE: char = '\u{00A0}';
|
||||||
|
|
||||||
|
/// An original span and the drawn span standing for it.
|
||||||
|
type Swap = (Range<usize>, Range<usize>);
|
||||||
|
|
||||||
|
/// The drawn text and how its bytes map back.
|
||||||
|
struct Shown {
|
||||||
|
layout: TextLayout,
|
||||||
|
swaps: Vec<Swap>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paragraph text whose formulas draw typeset over reserved glue.
|
||||||
|
pub(super) struct MathText {
|
||||||
|
id: ElementId,
|
||||||
|
inline: Inline,
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: Option<OnLink>,
|
||||||
|
shown: Rc<RefCell<Option<Shown>>>,
|
||||||
|
text: Option<AnyElement>,
|
||||||
|
/// Each picture, its size, and the drawn span it covers.
|
||||||
|
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
|
||||||
|
/// One glue character's width.
|
||||||
|
glue: Pixels,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MathText {
|
||||||
|
/// The element and how a point finds its original byte.
|
||||||
|
pub(super) fn new(
|
||||||
|
id: ElementId,
|
||||||
|
inline: Inline,
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: Option<OnLink>,
|
||||||
|
) -> (Self, impl Fn(Point<Pixels>) -> usize + 'static) {
|
||||||
|
let shown = Rc::new(RefCell::new(None));
|
||||||
|
let read = shown.clone();
|
||||||
|
let at = move |position| match &*read.borrow() {
|
||||||
|
Some(Shown { layout, swaps }) => back(
|
||||||
|
swaps,
|
||||||
|
layout
|
||||||
|
.index_for_position(position)
|
||||||
|
.unwrap_or_else(|near| near),
|
||||||
|
),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let text = Self {
|
||||||
|
id,
|
||||||
|
inline,
|
||||||
|
links,
|
||||||
|
on_link,
|
||||||
|
shown,
|
||||||
|
text: None,
|
||||||
|
pictures: Vec::new(),
|
||||||
|
glue: Pixels::ZERO,
|
||||||
|
};
|
||||||
|
(text, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where original byte `at` is drawn.
|
||||||
|
fn forward(swaps: &[Swap], at: usize) -> usize {
|
||||||
|
let mut shift = 0isize;
|
||||||
|
for (written, drawn) in swaps {
|
||||||
|
if at < written.start {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if at < written.end {
|
||||||
|
return drawn.start;
|
||||||
|
}
|
||||||
|
shift = drawn.end as isize - written.end as isize;
|
||||||
|
}
|
||||||
|
(at as isize + shift) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original byte drawn byte `at` stands for.
|
||||||
|
fn back(swaps: &[Swap], at: usize) -> usize {
|
||||||
|
let mut shift = 0isize;
|
||||||
|
for (written, drawn) in swaps {
|
||||||
|
if at < drawn.start {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if at < drawn.end {
|
||||||
|
return written.start;
|
||||||
|
}
|
||||||
|
shift = written.end as isize - drawn.end as isize;
|
||||||
|
}
|
||||||
|
(at as isize + shift) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inline` with each swapped span drawn as `drawn_as[ix]`.
|
||||||
|
fn shown(inline: &Inline, swaps: &mut [Swap], drawn_as: &[String]) -> Inline {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut from = 0;
|
||||||
|
for ((written, drawn), with) in swaps.iter_mut().zip(drawn_as) {
|
||||||
|
text.push_str(&inline.text[from..written.start]);
|
||||||
|
let start = text.len();
|
||||||
|
text.push_str(with);
|
||||||
|
*drawn = start..text.len();
|
||||||
|
from = written.end;
|
||||||
|
}
|
||||||
|
text.push_str(&inline.text[from..]);
|
||||||
|
let span = |range: &Range<usize>| forward(swaps, range.start)..forward(swaps, range.end);
|
||||||
|
Inline {
|
||||||
|
text,
|
||||||
|
styles: (inline.styles.iter())
|
||||||
|
.map(|(range, style)| (span(range), *style))
|
||||||
|
.collect(),
|
||||||
|
links: (inline.links.iter())
|
||||||
|
.map(|(range, url)| (span(range), url.clone()))
|
||||||
|
.collect(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for MathText {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for MathText {
|
||||||
|
type RequestLayoutState = ();
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
Some(self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (LayoutId, ()) {
|
||||||
|
let style = window.text_style();
|
||||||
|
let rem = window.rem_size();
|
||||||
|
let font = style.font_size.to_pixels(rem);
|
||||||
|
let glue = String::from(GLUE);
|
||||||
|
let run = style.to_run(glue.len());
|
||||||
|
let width = window
|
||||||
|
.text_system()
|
||||||
|
.shape_line(glue.into(), font, &[run], None)
|
||||||
|
.width;
|
||||||
|
let mut maths: Vec<Range<usize>> = (self.inline.styles.iter())
|
||||||
|
.filter(|(_, style)| *style == Style::Math)
|
||||||
|
.map(|(range, _)| range.clone())
|
||||||
|
.collect();
|
||||||
|
maths.sort_by_key(|range| range.start);
|
||||||
|
let (mut swaps, mut drawn_as) = (Vec::new(), Vec::new());
|
||||||
|
let (mut pictures, mut errors) = (Vec::new(), Vec::new());
|
||||||
|
for (ix, range) in maths.into_iter().enumerate() {
|
||||||
|
let source = SharedString::from(self.inline.text[range.clone()].to_string());
|
||||||
|
match typeset(
|
||||||
|
(self.id.clone(), SharedString::from(format!("formula-{ix}"))),
|
||||||
|
(&source, false),
|
||||||
|
font,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
) {
|
||||||
|
Some(Ok((image, size))) => {
|
||||||
|
let count = (size.width / width).ceil().max(1.0) as usize;
|
||||||
|
drawn_as.push(GLUE.to_string().repeat(count));
|
||||||
|
pictures.push((image, size, swaps.len()));
|
||||||
|
swaps.push((range, 0..0));
|
||||||
|
}
|
||||||
|
// The formula is the user's text: not logged.
|
||||||
|
Some(Err(error)) => {
|
||||||
|
log::warn!("markdown: an inline formula shows as TeX, unset");
|
||||||
|
drawn_as.push(format!(" ({error})"));
|
||||||
|
errors.push(swaps.len());
|
||||||
|
swaps.push((range.end..range.end, 0..0));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inline = shown(&self.inline, &mut swaps, &drawn_as);
|
||||||
|
let hidden: Vec<Range<usize>> = (pictures.iter())
|
||||||
|
.map(|(_, _, ix)| swaps[*ix].1.clone())
|
||||||
|
.collect();
|
||||||
|
let danger = HighlightStyle {
|
||||||
|
color: Some(cx.theme().colors.danger),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
};
|
||||||
|
let said = (errors.iter())
|
||||||
|
.map(|ix| (swaps[*ix].1.clone(), danger))
|
||||||
|
.collect();
|
||||||
|
let (mut text, layout) = drawn_text(
|
||||||
|
&inline,
|
||||||
|
(&hidden, said),
|
||||||
|
self.links.clone(),
|
||||||
|
&self.on_link,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let tallest = pictures
|
||||||
|
.iter()
|
||||||
|
.map(|(_, size, _)| size.height)
|
||||||
|
.fold(Pixels::ZERO, Pixels::max);
|
||||||
|
// A tall formula opens the paragraph's lines.
|
||||||
|
let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement {
|
||||||
|
line_height: Some(tallest.into()),
|
||||||
|
..TextStyleRefinement::default()
|
||||||
|
});
|
||||||
|
let id = window.with_text_style(taller, |window| text.request_layout(window, cx));
|
||||||
|
self.pictures = (pictures.into_iter().zip(hidden))
|
||||||
|
.map(|((image, size, _), drawn)| (image, size, drawn))
|
||||||
|
.collect();
|
||||||
|
self.glue = width;
|
||||||
|
*self.shown.borrow_mut() = Some(Shown { layout, swaps });
|
||||||
|
self.text = Some(text);
|
||||||
|
(id, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
_: Bounds<Pixels>,
|
||||||
|
_: &mut (),
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if let Some(text) = &mut self.text {
|
||||||
|
text.prepaint(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
_: Bounds<Pixels>,
|
||||||
|
_: &mut (),
|
||||||
|
_: &mut (),
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(text) = &mut self.text else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
text.paint(window, cx);
|
||||||
|
let shown = self.shown.borrow();
|
||||||
|
let Some(Shown { layout, .. }) = &*shown else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let line = layout.line_height();
|
||||||
|
for (image, size, drawn) in &self.pictures {
|
||||||
|
let Some((start, room)) = place(layout, drawn, self.glue) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0);
|
||||||
|
let bounds = Bounds {
|
||||||
|
origin,
|
||||||
|
size: *size,
|
||||||
|
};
|
||||||
|
if let Err(error) =
|
||||||
|
window.paint_image(bounds, bounds, Corners::default(), image.clone(), 0, false)
|
||||||
|
{
|
||||||
|
log::error!("markdown: an inline formula did not paint: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where glue `drawn` starts, and how wide it runs.
|
||||||
|
fn place(
|
||||||
|
layout: &TextLayout,
|
||||||
|
drawn: &Range<usize>,
|
||||||
|
glue: Pixels,
|
||||||
|
) -> Option<(Point<Pixels>, Pixels)> {
|
||||||
|
let last = layout.position_for_index(drawn.end - GLUE.len_utf8())?;
|
||||||
|
let first = layout.position_for_index(drawn.start)?;
|
||||||
|
// A wrap's index reads as the previous line's end.
|
||||||
|
let start = match first.y == last.y {
|
||||||
|
true => first,
|
||||||
|
false => point(layout.bounds().origin.x, last.y),
|
||||||
|
};
|
||||||
|
Some((start, last.x + glue - start.x))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_map_both_ways_around_a_formula() {
|
||||||
|
let inline = Inline {
|
||||||
|
text: "a x^2 b".into(),
|
||||||
|
styles: vec![(2..5, Style::Math), (6..7, Style::Strong)],
|
||||||
|
..Inline::default()
|
||||||
|
};
|
||||||
|
let mut swaps = vec![(2..5, 0..0)];
|
||||||
|
let drawn = shown(&inline, &mut swaps, &[GLUE.to_string().repeat(4)]);
|
||||||
|
assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4)));
|
||||||
|
let glue = GLUE.len_utf8() * 4;
|
||||||
|
assert_eq!(swaps[0].1, 2..2 + glue);
|
||||||
|
assert_eq!(
|
||||||
|
drawn.styles[1].0,
|
||||||
|
3 + glue..4 + glue,
|
||||||
|
"the bold b moves past the glue"
|
||||||
|
);
|
||||||
|
assert_eq!(back(&swaps, 3), 2, "glue reads back to its formula");
|
||||||
|
assert_eq!(back(&swaps, 3 + glue), 6);
|
||||||
|
assert_eq!(forward(&swaps, 6), 3 + glue);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Read = Box<dyn Fn(Point<Pixels>) -> usize>;
|
||||||
|
type Found = Rc<RefCell<Option<(Rc<RefCell<Option<Shown>>>, Read)>>>;
|
||||||
|
|
||||||
|
/// Draws `text` with `math` a formula, `width` wide.
|
||||||
|
struct Probe {
|
||||||
|
text: &'static str,
|
||||||
|
math: Range<usize>,
|
||||||
|
width: f32,
|
||||||
|
found: Found,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl gpui::Render for Probe {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
use gpui::{ParentElement as _, Styled as _};
|
||||||
|
let inline = Inline {
|
||||||
|
text: self.text.into(),
|
||||||
|
styles: vec![(self.math.clone(), Style::Math)],
|
||||||
|
..Inline::default()
|
||||||
|
};
|
||||||
|
let (text, at) = MathText::new("probe".into(), inline, None, None);
|
||||||
|
*self.found.borrow_mut() = Some((text.shown.clone(), Box::new(at)));
|
||||||
|
let shell = gpui::div()
|
||||||
|
.w(gpui::px(self.width))
|
||||||
|
.text_size(gpui::px(16.0));
|
||||||
|
shell.child(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn laid(
|
||||||
|
text: &'static str,
|
||||||
|
math: Range<usize>,
|
||||||
|
width: f32,
|
||||||
|
cx: &mut gpui::TestAppContext,
|
||||||
|
) -> Found {
|
||||||
|
cx.update(crate::theme::Theme::init);
|
||||||
|
let found = Found::default();
|
||||||
|
let held = found.clone();
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Probe {
|
||||||
|
text,
|
||||||
|
math,
|
||||||
|
width,
|
||||||
|
found: held,
|
||||||
|
});
|
||||||
|
for _ in 0..4 {
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, _| window.refresh());
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) {
|
||||||
|
let found = laid("a x^2 b", 2..5, 400.0, cx);
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, at) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
assert_eq!(swaps.len(), 1, "the formula typeset in place");
|
||||||
|
let drawn = &swaps[0].1;
|
||||||
|
let at_x = |ix| layout.position_for_index(ix).expect("on its line").x;
|
||||||
|
let glue = at_x(drawn.end) - at_x(drawn.end - GLUE.len_utf8());
|
||||||
|
let (start, room) = place(layout, drawn, glue).expect("placed");
|
||||||
|
assert!(room > gpui::px(10.0), "glue holds the picture's width");
|
||||||
|
let middle = point(start.x + room / 2.0, start.y + layout.line_height() / 2.0);
|
||||||
|
assert_eq!(at(middle), 2, "a press on the picture is the formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_formula_wrapped_whole_draws_on_its_line(cx: &mut gpui::TestAppContext) {
|
||||||
|
let text = "aaaaaaaaaa x^2+y^2=r^2 tail";
|
||||||
|
let found = laid(text, 11..22, 160.0, cx);
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, at) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
let drawn = &swaps[0].1;
|
||||||
|
let line = layout.line_height();
|
||||||
|
let last = layout.position_for_index(drawn.end - GLUE.len_utf8());
|
||||||
|
assert_eq!(last.expect("its glue").y, layout.bounds().top() + line);
|
||||||
|
let (start, _) = place(layout, drawn, gpui::px(0.0)).expect("placed");
|
||||||
|
assert_eq!(
|
||||||
|
start,
|
||||||
|
point(layout.bounds().left(), layout.bounds().top() + line),
|
||||||
|
"the picture starts the second line"
|
||||||
|
);
|
||||||
|
assert_eq!(at(start + point(gpui::px(2.0), line / 2.0)), 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_failed_formula_says_why_after_its_tex(cx: &mut gpui::TestAppContext) {
|
||||||
|
let found = laid("a x^2^3 b", 2..7, 400.0, cx);
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, _) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
assert_eq!(swaps[0].0, 7..7, "the TeX stays; the reason follows");
|
||||||
|
let text = layout.wrapped_text();
|
||||||
|
assert!(
|
||||||
|
text.starts_with("a x^2^3 (") && text.ends_with(") b"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
assert!(text.contains(" at "), "where RaTeX stopped: {text:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent,
|
||||||
|
ParentElement as _, TextLayout, Window, div,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Draw, table::CellAt};
|
||||||
|
use crate::documents::tree::Inline;
|
||||||
|
|
||||||
|
/// Where a press fell, as the drawing knows it.
|
||||||
|
pub(crate) enum Place {
|
||||||
|
/// A drawn byte, read back through its text's sources.
|
||||||
|
Text(Rc<Inline>, usize),
|
||||||
|
/// A source byte.
|
||||||
|
At(usize),
|
||||||
|
/// Drawn byte `at` of `text`, its lines written from `from`.
|
||||||
|
Lines {
|
||||||
|
text: Rc<str>,
|
||||||
|
from: usize,
|
||||||
|
at: usize,
|
||||||
|
},
|
||||||
|
/// A table cell, pressed past its text.
|
||||||
|
Cell(CellAt),
|
||||||
|
/// Somewhere the block holds no byte for.
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press on drawn content.
|
||||||
|
pub(crate) struct Pointed {
|
||||||
|
pub place: Place,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// The code's text, pressed in a code block.
|
||||||
|
pub code: Option<Rc<str>>,
|
||||||
|
pub table: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pointed {
|
||||||
|
/// A press past any drawn text.
|
||||||
|
pub(crate) fn block() -> Self {
|
||||||
|
Self::at(Place::Block)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(place: Place) -> Self {
|
||||||
|
Self {
|
||||||
|
place,
|
||||||
|
link: None,
|
||||||
|
image: None,
|
||||||
|
code: None,
|
||||||
|
table: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes a press's button and what it fell on.
|
||||||
|
pub(crate) type OnPoint = Rc<dyn Fn(Pointed, MouseButton, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// `element` hands its left and right presses to `on_point`.
|
||||||
|
fn pointable(
|
||||||
|
element: AnyElement,
|
||||||
|
on_point: &Option<OnPoint>,
|
||||||
|
table: bool,
|
||||||
|
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||||
|
) -> AnyElement {
|
||||||
|
match on_point {
|
||||||
|
Some(_) => pressed(div().child(element), on_point, table, point).into_any_element(),
|
||||||
|
None => element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `element` itself takes the presses `pointable` hands on.
|
||||||
|
fn pressed<E: InteractiveElement>(
|
||||||
|
element: E,
|
||||||
|
on_point: &Option<OnPoint>,
|
||||||
|
table: bool,
|
||||||
|
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||||
|
) -> E {
|
||||||
|
let Some(on_point) = on_point.clone() else {
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
let point = Rc::new(point);
|
||||||
|
let press = move |button: MouseButton| {
|
||||||
|
let (on_point, point) = (on_point.clone(), point.clone());
|
||||||
|
move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
|
||||||
|
// Cmd or Ctrl leaves the press to a link.
|
||||||
|
if button == MouseButton::Left && event.modifiers.secondary() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
let mut pointed = point(event);
|
||||||
|
pointed.table |= table;
|
||||||
|
on_point(pointed, button, window, cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element
|
||||||
|
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||||
|
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawn `text`, its lines written from source byte `from`.
|
||||||
|
pub(crate) fn point_lines(
|
||||||
|
element: AnyElement,
|
||||||
|
layout: TextLayout,
|
||||||
|
text: &str,
|
||||||
|
from: usize,
|
||||||
|
on_point: &Option<OnPoint>,
|
||||||
|
(table, code): (bool, bool),
|
||||||
|
) -> AnyElement {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
pointable(element, on_point, table, move |event| {
|
||||||
|
let at = layout
|
||||||
|
.index_for_position(event.position)
|
||||||
|
.unwrap_or_else(|near| near);
|
||||||
|
Pointed {
|
||||||
|
code: code.then(|| text.clone()),
|
||||||
|
..Pointed::at(Place::Lines {
|
||||||
|
text: text.clone(),
|
||||||
|
from,
|
||||||
|
at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Draw<'_> {
|
||||||
|
fn pointable(
|
||||||
|
&self,
|
||||||
|
element: AnyElement,
|
||||||
|
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||||
|
) -> AnyElement {
|
||||||
|
pointable(element, self.hooks.on_point, self.table.get(), point)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table cell taking its presses, past its text too.
|
||||||
|
pub(super) fn pressed_cell(&self, cell: Div, at: CellAt) -> Div {
|
||||||
|
pressed(cell, self.hooks.on_point, true, move |_| {
|
||||||
|
Pointed::at(Place::Cell(at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawn `inline`; a press finds its byte through `at`.
|
||||||
|
pub(super) fn point_text(
|
||||||
|
&self,
|
||||||
|
element: AnyElement,
|
||||||
|
at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
|
||||||
|
inline: &Inline,
|
||||||
|
) -> AnyElement {
|
||||||
|
if self.hooks.on_point.is_none() {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
let inline = Rc::new(inline.clone());
|
||||||
|
self.pointable(element, move |event| {
|
||||||
|
let at = at(event.position);
|
||||||
|
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||||
|
Pointed {
|
||||||
|
link: link.map(|(_, url)| url.clone()),
|
||||||
|
..Pointed::at(Place::Text(inline.clone(), at))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code block; its lines start at source byte `from`.
|
||||||
|
pub(super) fn point_code(
|
||||||
|
&self,
|
||||||
|
element: AnyElement,
|
||||||
|
layout: Option<TextLayout>,
|
||||||
|
text: &str,
|
||||||
|
from: usize,
|
||||||
|
) -> AnyElement {
|
||||||
|
let table = self.table.get();
|
||||||
|
match layout {
|
||||||
|
Some(layout) => point_lines(
|
||||||
|
element,
|
||||||
|
layout,
|
||||||
|
text,
|
||||||
|
from,
|
||||||
|
self.hooks.on_point,
|
||||||
|
(table, true),
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
self.pointable(element, move |_| Pointed {
|
||||||
|
code: Some(text.clone()),
|
||||||
|
..Pointed::block()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picture written at source byte `at`.
|
||||||
|
pub(super) fn point_image(
|
||||||
|
&self,
|
||||||
|
element: AnyElement,
|
||||||
|
at: usize,
|
||||||
|
url: &str,
|
||||||
|
link: &Option<String>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let (url, link) = (url.to_string(), link.clone());
|
||||||
|
self.pointable(element, move |_| Pointed {
|
||||||
|
image: Some(url.clone()),
|
||||||
|
link: link.clone(),
|
||||||
|
..Pointed::at(Place::At(at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, div, prelude::*,
|
||||||
|
};
|
||||||
|
use markdown::mdast::AlignKind;
|
||||||
|
|
||||||
|
use super::Draw;
|
||||||
|
use crate::{
|
||||||
|
documents::tree::Node,
|
||||||
|
theme::{ActiveTheme, Radius},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A cell: table in drawing order, row, column.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct CellAt {
|
||||||
|
pub table: usize,
|
||||||
|
pub row: usize,
|
||||||
|
pub column: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell open for typing and its field, drawn once.
|
||||||
|
pub(crate) struct OpenCell {
|
||||||
|
pub at: CellAt,
|
||||||
|
pub field: RefCell<Option<AnyElement>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Draw<'_> {
|
||||||
|
pub(super) fn table(
|
||||||
|
&self,
|
||||||
|
aligns: &[AlignKind],
|
||||||
|
head: &[Vec<Node>],
|
||||||
|
rows: &[Vec<Vec<Node>>],
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
let table = self.tables.get();
|
||||||
|
self.tables.set(table + 1);
|
||||||
|
let cell = |blocks: &[Node], row: usize, column: usize, cx: &App| {
|
||||||
|
let at = CellAt { table, row, column };
|
||||||
|
let open = (self.hooks.cell)
|
||||||
|
.filter(|open| open.at == at)
|
||||||
|
.and_then(|open| open.field.borrow_mut().take());
|
||||||
|
let drawn = div()
|
||||||
|
.debug_selector(move || format!("markdown-cell-{table}-{row}-{column}"))
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.flex()
|
||||||
|
.px_3()
|
||||||
|
.py_1p5()
|
||||||
|
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
|
||||||
|
cell.justify_end()
|
||||||
|
})
|
||||||
|
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
|
||||||
|
cell.justify_center()
|
||||||
|
})
|
||||||
|
.when(row == 0, |cell| {
|
||||||
|
cell.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
});
|
||||||
|
let inside = match open {
|
||||||
|
// Presses on the open field stay with it.
|
||||||
|
Some(field) => div()
|
||||||
|
.w_full()
|
||||||
|
.child(field)
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||||
|
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()),
|
||||||
|
None => div()
|
||||||
|
.max_w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.children(self.nodes(blocks, cx)),
|
||||||
|
};
|
||||||
|
self.pressed_cell(drawn.child(inside), at)
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(
|
||||||
|
div().flex().bg(colors.hover).children(
|
||||||
|
head.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(column, blocks)| cell(blocks, 0, column, cx)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(rows.iter().enumerate().map(|(row, cells)| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.children(
|
||||||
|
cells
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(column, blocks)| cell(blocks, row + 1, column, cx)),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px, rems};
|
||||||
|
|
||||||
|
use super::{HEAVY, MarkdownRenderer, Style, highlight};
|
||||||
|
use crate::theme::{ActiveTheme, TextSize, Theme};
|
||||||
|
|
||||||
|
/// `source` drawn in a 400-point column.
|
||||||
|
struct Page(&'static str);
|
||||||
|
|
||||||
|
impl Render for Page {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.w(px(400.0))
|
||||||
|
.child(MarkdownRenderer::new("page", self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_rule_draws_a_line_two_pixels_high(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Page("above\n\n---\n\nbelow\n"));
|
||||||
|
cx.run_until_parked();
|
||||||
|
let rule = cx.debug_bounds("markdown-rule").expect("the rule is drawn");
|
||||||
|
assert_eq!(rule.size.height, px(2.0), "{rule:?}");
|
||||||
|
assert!(rule.size.width > px(300.0), "{rule:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn bullets_turn_with_depth(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Page("- a\n - b\n 1. c\n - d\n"));
|
||||||
|
cx.run_until_parked();
|
||||||
|
for depth in [0, 1, 3] {
|
||||||
|
let selector = Box::leak(format!("markdown-bullet-{depth}").into_boxed_str());
|
||||||
|
assert!(cx.debug_bounds(selector).is_some(), "depth {depth}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cx.debug_bounds("markdown-bullet-2").is_none(),
|
||||||
|
"an ordered list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn alerts_lists_of_headings_and_front_matter_draw(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let source = "> [!IMPORTANT]\n> read\n\n[toc]\n\n# One\n\n## Two\n";
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Page(source));
|
||||||
|
cx.run_until_parked();
|
||||||
|
let (one, two) = (
|
||||||
|
cx.debug_bounds("markdown-toc-0").expect("drawn"),
|
||||||
|
cx.debug_bounds("markdown-toc-1").expect("drawn"),
|
||||||
|
);
|
||||||
|
assert!(two.top() > one.top(), "{one:?} {two:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One heading of every level.
|
||||||
|
const HEADINGS: &str = "# a
|
||||||
|
|
||||||
|
## a
|
||||||
|
|
||||||
|
### a
|
||||||
|
|
||||||
|
#### a
|
||||||
|
|
||||||
|
##### a
|
||||||
|
|
||||||
|
###### a
|
||||||
|
";
|
||||||
|
|
||||||
|
/// `source` in a 400-point column, body at `size`.
|
||||||
|
struct Sized(&'static str, TextSize);
|
||||||
|
|
||||||
|
impl Render for Sized {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.w(px(400.0))
|
||||||
|
.child(MarkdownRenderer::new("page", self.0).text_size(self.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn headings_step_up_from_the_body_by_typoras_ratios(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
for body in [TextSize::Base, TextSize::Lg] {
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Sized(HEADINGS, body));
|
||||||
|
cx.run_until_parked();
|
||||||
|
let (base, pad) = cx.update(|window, cx| {
|
||||||
|
let rem = window.rem_size();
|
||||||
|
(
|
||||||
|
cx.theme().text_size(body).to_pixels(rem),
|
||||||
|
rems(0.5).to_pixels(rem),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for (ordinal, step) in [2.25, 1.75, 1.5, 1.25, 1.0, 1.0].into_iter().enumerate() {
|
||||||
|
let selector = Box::leak(format!("markdown-heading-{ordinal}").into_boxed_str());
|
||||||
|
let high = cx.debug_bounds(selector).expect("drawn").size.height;
|
||||||
|
let off = high - pad - base * step * 1.3;
|
||||||
|
assert!(
|
||||||
|
off.abs() < px(0.5),
|
||||||
|
"{body:?} level {}: {high:?}",
|
||||||
|
ordinal + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn strong_text_in_a_heading_keeps_its_weight(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| {
|
||||||
|
Page(
|
||||||
|
"### Plain **Strong** plain
|
||||||
|
",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(cx.debug_bounds("markdown-heading-0").is_some(), "drawn");
|
||||||
|
let strong = cx.update(|_, cx| {
|
||||||
|
let theme = cx.theme();
|
||||||
|
highlight(&Style::Strong, &theme.colors, theme.underline_thickness()).font_weight
|
||||||
|
});
|
||||||
|
assert_eq!(strong, Some(HEAVY), "strong never lightens a heading");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_zoomed_document_scales_its_headings(cx: &mut TestAppContext) {
|
||||||
|
use crate::documents::{DocumentLook, MarkdownDocument, MarkdownMode};
|
||||||
|
cx.update(crate::init_for_tests);
|
||||||
|
let (document, cx) = cx.add_window_view(|window, cx| {
|
||||||
|
MarkdownDocument::new("### a\n", MarkdownMode::Preview, window, cx)
|
||||||
|
});
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let look = DocumentLook {
|
||||||
|
scale: 1.5,
|
||||||
|
..DocumentLook::default()
|
||||||
|
};
|
||||||
|
document.set_look(look, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let (base, pad) = cx.update(|window, cx| {
|
||||||
|
let rem = window.rem_size();
|
||||||
|
(
|
||||||
|
cx.theme().text_size(TextSize::Base).to_pixels(rem),
|
||||||
|
rems(0.5).to_pixels(rem),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let high = cx
|
||||||
|
.debug_bounds("markdown-heading-0")
|
||||||
|
.expect("drawn")
|
||||||
|
.size
|
||||||
|
.height;
|
||||||
|
let off = high - pad - base * 1.5 * 1.5 * 1.3;
|
||||||
|
assert!(off.abs() < px(0.5), "{high:?}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use markdown::mdast;
|
||||||
|
|
||||||
|
use super::{Node, Reader};
|
||||||
|
use crate::documents::markdown::span;
|
||||||
|
|
||||||
|
/// A heading anywhere in the file.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct DocumentHeading {
|
||||||
|
pub depth: u8,
|
||||||
|
pub title: String,
|
||||||
|
/// Where its source starts, in bytes.
|
||||||
|
pub at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GitHub alert's kind, named by its marker.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AlertKind {
|
||||||
|
Note,
|
||||||
|
Tip,
|
||||||
|
Important,
|
||||||
|
Warning,
|
||||||
|
Caution,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlertKind {
|
||||||
|
/// The title GitHub shows.
|
||||||
|
pub fn title(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Note => "Note",
|
||||||
|
Self::Tip => "Tip",
|
||||||
|
Self::Important => "Important",
|
||||||
|
Self::Warning => "Warning",
|
||||||
|
Self::Caution => "Caution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind `line` marks, as `[!NOTE]` in any case.
|
||||||
|
fn marked(line: &str) -> Option<Self> {
|
||||||
|
let name = line
|
||||||
|
.trim_matches([' ', '\t'])
|
||||||
|
.strip_prefix("[!")?
|
||||||
|
.strip_suffix(']')?;
|
||||||
|
[
|
||||||
|
Self::Note,
|
||||||
|
Self::Tip,
|
||||||
|
Self::Important,
|
||||||
|
Self::Warning,
|
||||||
|
Self::Caution,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|kind| kind.title().eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reader<'_> {
|
||||||
|
/// A root block; alerts and `[toc]` live only here.
|
||||||
|
pub(crate) fn top(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||||
|
if let Some(alert) = self.alert(node) {
|
||||||
|
return out.push(alert);
|
||||||
|
}
|
||||||
|
match is_toc(node, self.source) {
|
||||||
|
true => out.push(Node::Toc(self.headings.clone())),
|
||||||
|
false => self.block(node, out),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quote whose first line marks it, with more after.
|
||||||
|
fn alert(&self, node: &mdast::Node) -> Option<Node> {
|
||||||
|
let mdast::Node::Blockquote(quote) = node else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (kind, parts) = alert(quote)?;
|
||||||
|
let mut body = Vec::new();
|
||||||
|
self.blocks(&parts, &mut body);
|
||||||
|
Some(Node::Alert { kind, body })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quote's alert kind and its blocks after the marker.
|
||||||
|
pub fn alert(quote: &mdast::Blockquote) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||||
|
let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (kind, after) = marker(¶graph.children)?;
|
||||||
|
if after.is_empty() && rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let head = (!after.is_empty()).then(|| {
|
||||||
|
mdast::Node::Paragraph(mdast::Paragraph {
|
||||||
|
children: after,
|
||||||
|
position: paragraph.position.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Some((kind, head.into_iter().chain(rest.iter().cloned()).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A paragraph that is `[toc]` alone, as Typora reads it.
|
||||||
|
pub fn is_toc(node: &mdast::Node, source: &str) -> bool {
|
||||||
|
matches!(node, mdast::Node::Paragraph(_))
|
||||||
|
&& source[span(node)].trim().eq_ignore_ascii_case("[toc]")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain first line naming a kind, and what follows.
|
||||||
|
fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
|
||||||
|
let mut line = String::new();
|
||||||
|
for (at, child) in children.iter().enumerate() {
|
||||||
|
let head = match child {
|
||||||
|
mdast::Node::Text(text) => match text.value.find(['\n', '\r']) {
|
||||||
|
None => {
|
||||||
|
line.push_str(&text.value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(end) => {
|
||||||
|
line.push_str(&text.value[..end]);
|
||||||
|
let after = &text.value[end..];
|
||||||
|
let after = after.strip_prefix("\r\n").unwrap_or(&after[1..]);
|
||||||
|
let mut kept = text.clone();
|
||||||
|
kept.value = after.to_string();
|
||||||
|
(!after.is_empty()).then_some(mdast::Node::Text(kept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mdast::Node::Break(_) => None,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let after = head.into_iter().chain(children[at + 1..].iter().cloned());
|
||||||
|
return Some((AlertKind::marked(&line)?, after.collect()));
|
||||||
|
}
|
||||||
|
Some((AlertKind::marked(&line)?, Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::documents::tree::{Style, parse};
|
||||||
|
|
||||||
|
fn alert(source: &str) -> Option<(AlertKind, Vec<Node>)> {
|
||||||
|
match parse(source).into_iter().next() {
|
||||||
|
Some(Node::Alert { kind, body }) => Some((kind, body)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(nodes: &[Node]) -> Vec<String> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| match node {
|
||||||
|
Node::Paragraph(line) => line.text.clone(),
|
||||||
|
Node::Heading(_, line) => format!("# {}", line.text),
|
||||||
|
other => format!("{other:?}"),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alerts_read_as_github_reads_them() {
|
||||||
|
let note = alert("> [!NOTE]\n> one").expect("an alert");
|
||||||
|
assert_eq!(
|
||||||
|
(note.0, text(¬e.1)),
|
||||||
|
(AlertKind::Note, vec!["one".into()])
|
||||||
|
);
|
||||||
|
let cases = [
|
||||||
|
("> [!tip]\n> two", Some(AlertKind::Tip)),
|
||||||
|
("> [!NOTE] \n> trailing", Some(AlertKind::Note)),
|
||||||
|
("> [!TIP]\n> leading", Some(AlertKind::Tip)),
|
||||||
|
("> [!Warning]\n>\n> after a blank", Some(AlertKind::Warning)),
|
||||||
|
("> [!WARNING] same line", None),
|
||||||
|
("> [!IMPORTANT]", None),
|
||||||
|
("> [!FOO]\n> five", None),
|
||||||
|
("> text\n> [!NOTE]", None),
|
||||||
|
("> \\[!NOTE]\n> escaped", Some(AlertKind::Note)),
|
||||||
|
("> [!NOTE]\n> referenced", Some(AlertKind::Note)),
|
||||||
|
("> [!NOTE] \n> hard line", Some(AlertKind::Note)),
|
||||||
|
("> [!NOTE]\rcarriage", Some(AlertKind::Note)),
|
||||||
|
("> [!NOTE]\nlazy", Some(AlertKind::Note)),
|
||||||
|
("> [!NOTE]\u{a0}\n> no-break space", None),
|
||||||
|
("> **[!TIP]**\n> strong", None),
|
||||||
|
("> <span>[!WARNING]</span>\n> tag", None),
|
||||||
|
("[!NOTE]: https://example.com\n\n> [!NOTE]\n> a link", None),
|
||||||
|
];
|
||||||
|
for (source, kind) in cases {
|
||||||
|
assert_eq!(alert(source).map(|found| found.0), kind, "{source:?}");
|
||||||
|
}
|
||||||
|
let nested = parse("- > [!CAUTION]\n > in a list");
|
||||||
|
assert!(matches!(&nested[0], Node::List { .. }), "{nested:?}");
|
||||||
|
let blocks = alert("> [!NOTE]\n> # head\n> - item").expect("an alert");
|
||||||
|
assert!(matches!(
|
||||||
|
&blocks.1[..],
|
||||||
|
[Node::Heading(1, _), Node::List { .. }]
|
||||||
|
));
|
||||||
|
let styled = alert("> [!CAUTION]\n> a **b**\n> c").expect("an alert");
|
||||||
|
let Node::Paragraph(line) = &styled.1[0] else {
|
||||||
|
panic!("a paragraph, not {:?}", styled.1)
|
||||||
|
};
|
||||||
|
let bold = 2..3;
|
||||||
|
assert_eq!(
|
||||||
|
(line.text.as_str(), &line.styles[..]),
|
||||||
|
("a b\nc", &[(bold, Style::Strong)][..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_toc_paragraph_lists_every_heading() {
|
||||||
|
let nodes = parse("[toc]\n\n# A\n\n> ## B :smile:\n");
|
||||||
|
let Node::Toc(headings) = &nodes[0] else {
|
||||||
|
panic!("a toc, not {nodes:?}")
|
||||||
|
};
|
||||||
|
let listed: Vec<(u8, &str, usize)> = headings
|
||||||
|
.iter()
|
||||||
|
.map(|heading| (heading.depth, heading.title.as_str(), heading.at))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(listed, [(1, "A", 7), (2, "B 😄", 14)]);
|
||||||
|
assert!(matches!(&parse(" [TOC] ")[0], Node::Toc(_)));
|
||||||
|
assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_)));
|
||||||
|
assert!(matches!(&parse("> [toc]")[0], Node::Quote(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/// `text` with each known `:shortcode:` as its emoji.
|
||||||
|
fn emoji(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(at) = rest.find(':') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
let after = &rest[at + 1..];
|
||||||
|
let len = after
|
||||||
|
.find(|ch: char| !shortcode_char(ch))
|
||||||
|
.unwrap_or(after.len());
|
||||||
|
let found = after[len..]
|
||||||
|
.starts_with(':')
|
||||||
|
.then(|| emojis::get_by_shortcode(&after[..len]))
|
||||||
|
.flatten();
|
||||||
|
match found {
|
||||||
|
Some(found) => {
|
||||||
|
out.push_str(found.as_str());
|
||||||
|
rest = &after[len + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push(':');
|
||||||
|
rest = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shortcode_char(ch: char) -> bool {
|
||||||
|
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the first `:name:` starts.
|
||||||
|
fn candidate(text: &str) -> Option<usize> {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(at) = text[from..].find(':').map(|at| from + at) {
|
||||||
|
let after = &text[at + 1..];
|
||||||
|
let len = after
|
||||||
|
.find(|ch: char| !shortcode_char(ch))
|
||||||
|
.unwrap_or(after.len());
|
||||||
|
if len > 0 && after[len..].starts_with(':') {
|
||||||
|
return Some(at);
|
||||||
|
}
|
||||||
|
from = at + 1 + len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One DOM text node's shortcodes, as GitHub reads them.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Shortcodes {
|
||||||
|
/// Settled by the node's first `:name:`.
|
||||||
|
reads: Option<bool>,
|
||||||
|
/// Text before it held more than white space.
|
||||||
|
inked: bool,
|
||||||
|
last: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shortcodes {
|
||||||
|
/// A new DOM text node starts.
|
||||||
|
pub(super) fn split(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blank or spaced text before the first reads them all.
|
||||||
|
pub(super) fn read(&mut self, text: &str) -> String {
|
||||||
|
if self.reads.is_none() {
|
||||||
|
let at = candidate(text);
|
||||||
|
let lead = &text[..at.unwrap_or(text.len())];
|
||||||
|
self.inked |= !lead.chars().all(|ch| ch.is_ascii_whitespace());
|
||||||
|
self.last = lead.chars().next_back().or(self.last);
|
||||||
|
self.reads = at.map(|_| !self.inked || self.last == Some(' '));
|
||||||
|
}
|
||||||
|
match self.reads {
|
||||||
|
Some(true) => emoji(text),
|
||||||
|
_ => text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_shortcodes_become_emoji() {
|
||||||
|
assert_eq!(emoji("hi :smile: :+1:"), "hi 😄 👍");
|
||||||
|
assert_eq!(emoji(":nope: at 10:30 and ::"), ":nope: at 10:30 and ::");
|
||||||
|
assert_eq!(emoji("a:smile:b :x:smile:"), "a😄b ❌smile:");
|
||||||
|
assert_eq!(emoji("é :smile: 中"), "é 😄 中");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_runs_scan_in_linear_time() {
|
||||||
|
let colons = ":zq".repeat(1 << 16);
|
||||||
|
let started = web_time::Instant::now();
|
||||||
|
assert_eq!(emoji(&colons).len(), colons.len());
|
||||||
|
assert_eq!(Shortcodes::default().read(&colons).len(), colons.len());
|
||||||
|
let spent = started.elapsed();
|
||||||
|
assert!(spent.as_secs() < 2, "192 KiB took {spent:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
use markdown::decode_named;
|
||||||
|
|
||||||
|
use super::Style;
|
||||||
|
|
||||||
|
/// One HTML tag as written: its lowercase name and attributes.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct Tag {
|
||||||
|
pub name: String,
|
||||||
|
pub closing: bool,
|
||||||
|
pub attributes: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tag {
|
||||||
|
pub fn attribute(&self, name: &str) -> Option<&str> {
|
||||||
|
self.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| key == name)
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How Typora shows an inline tag; others show text.
|
||||||
|
pub(crate) fn inline_style(name: &str) -> Option<Style> {
|
||||||
|
match name {
|
||||||
|
"u" => Some(Style::Underline),
|
||||||
|
"b" | "strong" => Some(Style::Strong),
|
||||||
|
"i" | "em" => Some(Style::Emphasis),
|
||||||
|
"s" | "del" | "strike" => Some(Style::Strike),
|
||||||
|
"code" | "kbd" => Some(Style::Code),
|
||||||
|
"mark" => Some(Style::Mark),
|
||||||
|
"a" => Some(Style::Link),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML5's formatting elements reopen past a crossed close.
|
||||||
|
pub(crate) fn reopens(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"a" | "b" | "code" | "em" | "i" | "s" | "strike" | "strong" | "u"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GFM's tag filter: these tags show as text.
|
||||||
|
pub(crate) fn filtered(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"title"
|
||||||
|
| "textarea"
|
||||||
|
| "style"
|
||||||
|
| "xmp"
|
||||||
|
| "iframe"
|
||||||
|
| "noembed"
|
||||||
|
| "noframes"
|
||||||
|
| "script"
|
||||||
|
| "plaintext"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elements a browser lays as blocks: a line each side.
|
||||||
|
pub(crate) fn breaks(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"address"
|
||||||
|
| "article"
|
||||||
|
| "aside"
|
||||||
|
| "blockquote"
|
||||||
|
| "center"
|
||||||
|
| "details"
|
||||||
|
| "div"
|
||||||
|
| "dl"
|
||||||
|
| "dd"
|
||||||
|
| "dt"
|
||||||
|
| "figcaption"
|
||||||
|
| "figure"
|
||||||
|
| "footer"
|
||||||
|
| "h1"
|
||||||
|
| "h2"
|
||||||
|
| "h3"
|
||||||
|
| "h4"
|
||||||
|
| "h5"
|
||||||
|
| "h6"
|
||||||
|
| "header"
|
||||||
|
| "hr"
|
||||||
|
| "li"
|
||||||
|
| "main"
|
||||||
|
| "nav"
|
||||||
|
| "ol"
|
||||||
|
| "p"
|
||||||
|
| "pre"
|
||||||
|
| "section"
|
||||||
|
| "summary"
|
||||||
|
| "table"
|
||||||
|
| "tr"
|
||||||
|
| "ul"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one inline HTML tag does, as GitHub shows it.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(super) enum InlineTag {
|
||||||
|
/// Opens `name`; an anchor styles only with an address.
|
||||||
|
Open {
|
||||||
|
name: String,
|
||||||
|
style: Option<Style>,
|
||||||
|
href: Option<String>,
|
||||||
|
},
|
||||||
|
Close(String),
|
||||||
|
Break,
|
||||||
|
/// A filtered tag, shown as its text.
|
||||||
|
Text(String),
|
||||||
|
/// Gone; the text around it stays.
|
||||||
|
Gone,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the inline HTML `written` does.
|
||||||
|
pub(super) fn inline_tag(written: &str) -> InlineTag {
|
||||||
|
let Some(tag) = tag(written) else {
|
||||||
|
return InlineTag::Gone;
|
||||||
|
};
|
||||||
|
if filtered(&tag.name) {
|
||||||
|
return InlineTag::Text(text(written));
|
||||||
|
}
|
||||||
|
if tag.name == "br" {
|
||||||
|
return InlineTag::Break;
|
||||||
|
}
|
||||||
|
let Some(style) = inline_style(&tag.name) else {
|
||||||
|
return InlineTag::Gone;
|
||||||
|
};
|
||||||
|
if tag.closing {
|
||||||
|
return InlineTag::Close(tag.name);
|
||||||
|
}
|
||||||
|
let href = match style {
|
||||||
|
Style::Link => tag.attribute("href").map(str::to_string),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let style = (style != Style::Link || href.is_some()).then_some(style);
|
||||||
|
InlineTag::Open {
|
||||||
|
name: tag.name,
|
||||||
|
style,
|
||||||
|
href,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start tags that end an open `p`, as HTML5 lists them.
|
||||||
|
pub(crate) fn closes_p(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"address"
|
||||||
|
| "article"
|
||||||
|
| "aside"
|
||||||
|
| "blockquote"
|
||||||
|
| "center"
|
||||||
|
| "details"
|
||||||
|
| "dialog"
|
||||||
|
| "dir"
|
||||||
|
| "div"
|
||||||
|
| "dl"
|
||||||
|
| "dd"
|
||||||
|
| "dt"
|
||||||
|
| "fieldset"
|
||||||
|
| "figcaption"
|
||||||
|
| "figure"
|
||||||
|
| "footer"
|
||||||
|
| "form"
|
||||||
|
| "h1"
|
||||||
|
| "h2"
|
||||||
|
| "h3"
|
||||||
|
| "h4"
|
||||||
|
| "h5"
|
||||||
|
| "h6"
|
||||||
|
| "header"
|
||||||
|
| "hgroup"
|
||||||
|
| "hr"
|
||||||
|
| "li"
|
||||||
|
| "listing"
|
||||||
|
| "main"
|
||||||
|
| "menu"
|
||||||
|
| "nav"
|
||||||
|
| "ol"
|
||||||
|
| "p"
|
||||||
|
| "pre"
|
||||||
|
| "search"
|
||||||
|
| "section"
|
||||||
|
| "summary"
|
||||||
|
| "table"
|
||||||
|
| "ul"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes through the `>` ending the tag at `html`'s start.
|
||||||
|
pub(crate) fn tag_end(html: &str) -> Option<usize> {
|
||||||
|
let (mut quote, mut last) = (None, '<');
|
||||||
|
for (at, ch) in html.char_indices().skip(1) {
|
||||||
|
match quote {
|
||||||
|
Some(open) if ch == open => quote = None,
|
||||||
|
Some(_) => {}
|
||||||
|
None if ch == '>' => return Some(at + 1),
|
||||||
|
None if matches!(ch, '"' | '\'') && last == '=' => quote = Some(ch),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
if !spacing(ch) {
|
||||||
|
last = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `html` as one tag; comments and the rest are none.
|
||||||
|
pub(crate) fn tag(html: &str) -> Option<Tag> {
|
||||||
|
let inner = html.trim().strip_prefix('<')?.strip_suffix('>')?;
|
||||||
|
let (closing, inner) = match inner.strip_prefix('/') {
|
||||||
|
Some(rest) => (true, rest),
|
||||||
|
None => (false, inner),
|
||||||
|
};
|
||||||
|
if !inner.starts_with(|ch: char| ch.is_ascii_alphabetic()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let named = inner
|
||||||
|
.find(|ch: char| spacing(ch) || ch == '/')
|
||||||
|
.unwrap_or(inner.len());
|
||||||
|
Some(Tag {
|
||||||
|
name: inner[..named].to_ascii_lowercase(),
|
||||||
|
closing,
|
||||||
|
attributes: attributes(&inner[named..]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name="value"`, `name='value'`, `name=value` and bare names.
|
||||||
|
fn attributes(mut rest: &str) -> Vec<(String, String)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
rest = rest.trim_start_matches(spacing);
|
||||||
|
// A slash between attributes closes the tag itself.
|
||||||
|
if let Some(after) = rest.strip_prefix('/') {
|
||||||
|
rest = after;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let named = rest
|
||||||
|
.find(|ch: char| spacing(ch) || ch == '=' || ch == '/')
|
||||||
|
.unwrap_or(rest.len());
|
||||||
|
if named == 0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let name = rest[..named].to_ascii_lowercase();
|
||||||
|
rest = rest[named..].trim_start_matches(spacing);
|
||||||
|
let Some(after) = rest.strip_prefix('=') else {
|
||||||
|
out.push((name, String::new()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let after = after.trim_start_matches(spacing);
|
||||||
|
let (value, left) = match after.chars().next() {
|
||||||
|
Some(quote @ ('"' | '\'')) => match after[1..].find(quote) {
|
||||||
|
Some(end) => (&after[1..1 + end], &after[2 + end..]),
|
||||||
|
None => (&after[1..], ""),
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let end = after.find(spacing).unwrap_or(after.len());
|
||||||
|
(&after[..end], &after[end..])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push((name, decoded(value, true)));
|
||||||
|
rest = left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML's whitespace, which is ASCII alone.
|
||||||
|
pub(crate) fn spacing(ch: char) -> bool {
|
||||||
|
matches!(ch, ' ' | '\t' | '\n' | '\r' | '\x0C')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text between tags, references decoded as HTML does.
|
||||||
|
pub(crate) fn text(written: &str) -> String {
|
||||||
|
decoded(written, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// References decoded as HTML does, in a value or text.
|
||||||
|
fn decoded(text: &str, attribute: bool) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(at) = rest.find('&') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
rest = &rest[at..];
|
||||||
|
match reference(rest, attribute) {
|
||||||
|
Some((value, len)) => {
|
||||||
|
out.push_str(&value);
|
||||||
|
rest = &rest[len..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push('&');
|
||||||
|
rest = &rest[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longest reference name HTML knows, in bytes.
|
||||||
|
const LONGEST: usize = 32;
|
||||||
|
|
||||||
|
/// A reference at `text`'s start, as HTML reads it.
|
||||||
|
pub(super) fn reference(text: &str, attribute: bool) -> Option<(String, usize)> {
|
||||||
|
let body = text.strip_prefix('&')?;
|
||||||
|
if let Some(number) = body.strip_prefix('#') {
|
||||||
|
let (digits, radix) = match number.strip_prefix(['x', 'X']) {
|
||||||
|
Some(hex) => (hex, 16),
|
||||||
|
None => (number, 10),
|
||||||
|
};
|
||||||
|
let len = digits
|
||||||
|
.find(|ch: char| !ch.is_digit(radix))
|
||||||
|
.unwrap_or(digits.len());
|
||||||
|
if len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let code = digits[..len].chars().try_fold(0u32, |code, ch| {
|
||||||
|
code.checked_mul(radix)?.checked_add(ch.to_digit(radix)?)
|
||||||
|
});
|
||||||
|
let ended = usize::from(digits[len..].starts_with(';'));
|
||||||
|
let used = 2 + (number.len() - digits.len()) + len + ended;
|
||||||
|
return Some((numeric(code).to_string(), used));
|
||||||
|
}
|
||||||
|
let len = body
|
||||||
|
.find(|ch: char| !ch.is_ascii_alphanumeric())
|
||||||
|
.unwrap_or(body.len());
|
||||||
|
let name = &body[..len];
|
||||||
|
if (1..=LONGEST).contains(&len)
|
||||||
|
&& body[len..].starts_with(';')
|
||||||
|
&& let Some(value) = decode_named(name, true)
|
||||||
|
{
|
||||||
|
return Some((value, len + 2));
|
||||||
|
}
|
||||||
|
// Text takes a legacy prefix; a value, the whole name.
|
||||||
|
let shortest = match attribute {
|
||||||
|
true if body[len..].starts_with(['=', ';']) => return None,
|
||||||
|
true => len,
|
||||||
|
false => 1,
|
||||||
|
};
|
||||||
|
let name = (shortest..=len.min(LONGEST))
|
||||||
|
.rev()
|
||||||
|
.map(|end| &name[..end])
|
||||||
|
.find(|name| legacy(name))?;
|
||||||
|
let value = decode_named(name, true).expect("a legacy name");
|
||||||
|
Some((value, name.len() + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name HTML decodes without its semicolon.
|
||||||
|
fn legacy(name: &str) -> bool {
|
||||||
|
if matches!(name, "AMP" | "LT" | "GT" | "QUOT" | "COPY" | "REG") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
decode_named(name, false).is_some_and(|value| {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
let one = chars.next().filter(|_| chars.next().is_none());
|
||||||
|
one.is_some_and(|ch| matches!(ch, '&' | '<' | '>' | '"' | '\u{A0}'..='\u{FF}'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The character a numeric reference names, as HTML maps it.
|
||||||
|
fn numeric(code: Option<u32>) -> char {
|
||||||
|
// Windows-1252 for the C1 block, as browsers do.
|
||||||
|
const C1: [u32; 32] = [
|
||||||
|
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
|
||||||
|
0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013,
|
||||||
|
0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||||
|
];
|
||||||
|
match code {
|
||||||
|
Some(code @ 0x80..=0x9F) => char::from_u32(C1[(code - 0x80) as usize]),
|
||||||
|
Some(0) | None => None,
|
||||||
|
Some(code) => char::from_u32(code),
|
||||||
|
}
|
||||||
|
.unwrap_or(char::REPLACEMENT_CHARACTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n` in superscript digits.
|
||||||
|
pub(crate) fn superscript(n: usize) -> String {
|
||||||
|
const DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
||||||
|
n.to_string()
|
||||||
|
.bytes()
|
||||||
|
.map(|digit| DIGITS[usize::from(digit - b'0')])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tag_reads_its_name_and_attributes() {
|
||||||
|
let link = tag("<A HREF=\"https://ely.dev\" title='x' hidden>").expect("a tag");
|
||||||
|
assert_eq!((link.name.as_str(), link.closing), ("a", false));
|
||||||
|
assert_eq!(link.attribute("href"), Some("https://ely.dev"));
|
||||||
|
assert_eq!(link.attribute("title"), Some("x"));
|
||||||
|
assert_eq!(link.attribute("hidden"), Some(""));
|
||||||
|
assert_eq!(
|
||||||
|
tag("</u>").map(|t| (t.name, t.closing)),
|
||||||
|
Some(("u".into(), true))
|
||||||
|
);
|
||||||
|
assert_eq!(tag("<br/>").map(|t| t.name), Some("br".into()));
|
||||||
|
assert_eq!(tag("<u-box>").map(|t| t.name), Some("u-box".into()));
|
||||||
|
assert_eq!(tag("<!-- note -->"), None);
|
||||||
|
assert_eq!(tag("<1>"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attribute_values_keep_slashes_and_decode_references() {
|
||||||
|
let bare = tag("<a href=/path/>").expect("a tag");
|
||||||
|
assert_eq!(bare.attribute("href"), Some("/path/"));
|
||||||
|
let closed = tag("<img src=\"a.png\" />").expect("a tag");
|
||||||
|
assert_eq!(closed.attribute("src"), Some("a.png"));
|
||||||
|
let query = tag("<a href=\"https://x.test/?q=1&p=2&r=3&nope;\">");
|
||||||
|
let query = query.expect("a tag");
|
||||||
|
assert_eq!(
|
||||||
|
query.attribute("href"),
|
||||||
|
Some("https://x.test/?q=1&p=2&r=3&nope;")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn values_decode_as_a_browser_decodes_them() {
|
||||||
|
let href = |html: &str| {
|
||||||
|
tag(html)
|
||||||
|
.expect("a tag")
|
||||||
|
.attribute("href")
|
||||||
|
.map(str::to_owned)
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
("<a href=\"?q=1&page=2\">", "?q=1&page=2"),
|
||||||
|
("<a href=\"/€Ÿ\">", "/€Ÿ\u{81}"),
|
||||||
|
(
|
||||||
|
"<a href=\"���\">",
|
||||||
|
"\u{FFFD}\u{FFFD}\u{FFFD}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<a href=\"& page&x&=∉&\">",
|
||||||
|
"& page&x&=∉&",
|
||||||
|
),
|
||||||
|
("<a href=/a\u{A0}b>", "/a\u{A0}b"),
|
||||||
|
];
|
||||||
|
for (html, want) in cases {
|
||||||
|
assert_eq!(href(html).as_deref(), Some(want), "{html}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_runs_scan_in_linear_time() {
|
||||||
|
let amps = "&".repeat(1 << 18);
|
||||||
|
let started = web_time::Instant::now();
|
||||||
|
assert_eq!(decoded(&s, true).len(), amps.len());
|
||||||
|
assert_eq!(text(&s).len(), amps.len());
|
||||||
|
let spent = started.elapsed();
|
||||||
|
assert!(spent.as_secs() < 2, "256 KiB took {spent:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_takes_the_longest_legacy_name() {
|
||||||
|
assert_eq!(
|
||||||
|
text("¬it; & &x <3 &nope;"),
|
||||||
|
"¬it; & &x <3 &nope;"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded("¬it;&x", true), "¬it;&x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tag_ends_outside_its_quotes() {
|
||||||
|
assert_eq!(tag_end("<a href=\"x>y\">z"), Some(14));
|
||||||
|
assert_eq!(tag_end("<b it's>r"), Some(8));
|
||||||
|
assert_eq!(tag_end("<b"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbers_go_up() {
|
||||||
|
assert_eq!(superscript(1), "¹");
|
||||||
|
assert_eq!(superscript(120), "¹²⁰");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use super::{Escapes, Inline, Run, html, sources::LineMap};
|
||||||
|
|
||||||
|
/// An HTML block's text, as a browser shows it.
|
||||||
|
pub(super) fn html_block_text(written: &str, source: &str, span: Range<usize>) -> Inline {
|
||||||
|
let (mut run, map) = (Run::default(), LineMap::new(source, span.start, written));
|
||||||
|
let mut rest = written;
|
||||||
|
// Each text run keeps where it was written.
|
||||||
|
let words = |run: &mut Run, rest: &str, text: &str| {
|
||||||
|
let from = written.len() - rest.len();
|
||||||
|
let at = |value| map.as_ref()?.at(value);
|
||||||
|
// Blank text draws nothing where it stands.
|
||||||
|
if text.chars().all(html::spacing) {
|
||||||
|
return run.words(text);
|
||||||
|
}
|
||||||
|
match at(from).zip(at(from + text.len())) {
|
||||||
|
Some((start, end)) => {
|
||||||
|
(run.text.sources).push((run.text.text.len(), start..end, Escapes::Html))
|
||||||
|
}
|
||||||
|
None => log::warn!("markdown: an HTML block's text lost its source"),
|
||||||
|
}
|
||||||
|
run.words(text);
|
||||||
|
};
|
||||||
|
while let Some(at) = rest.find('<') {
|
||||||
|
words(&mut run, rest, &rest[..at]);
|
||||||
|
rest = &rest[at..];
|
||||||
|
if let Some(comment) = rest.strip_prefix("<!--") {
|
||||||
|
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||||
|
run.shortcodes.split();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let opens =
|
||||||
|
rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch));
|
||||||
|
if !opens {
|
||||||
|
words(&mut run, rest, "<");
|
||||||
|
rest = &rest[1..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// An unclosed tag drops the rest, as HTML5 does.
|
||||||
|
let Some(end) = html::tag_end(rest) else {
|
||||||
|
rest = "";
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let (tag_text, after) = rest.split_at(end);
|
||||||
|
match html::tag(tag_text) {
|
||||||
|
Some(tag) if html::filtered(&tag.name) => words(&mut run, rest, tag_text),
|
||||||
|
// A block closes what opened inside it.
|
||||||
|
Some(tag) if html::breaks(&tag.name) => {
|
||||||
|
run.line();
|
||||||
|
match tag.closing {
|
||||||
|
true => run.end(&tag.name),
|
||||||
|
false => {
|
||||||
|
run.implied(&tag.name);
|
||||||
|
match tag.name == "hr" {
|
||||||
|
true => run.shortcodes.split(),
|
||||||
|
false => run.start(&tag.name, None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// It ends a `p` but lays no block.
|
||||||
|
Some(tag) if !tag.closing && html::closes_p(&tag.name) => {
|
||||||
|
run.implied(&tag.name);
|
||||||
|
run.html(tag_text);
|
||||||
|
}
|
||||||
|
// A line's end drops its space.
|
||||||
|
Some(tag) if tag.closing || tag.name == "br" => {
|
||||||
|
run.owed = run.owed.filter(|_| tag.name != "br");
|
||||||
|
run.html(tag_text);
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
run.pay();
|
||||||
|
run.html(tag_text);
|
||||||
|
}
|
||||||
|
None => run.shortcodes.split(),
|
||||||
|
}
|
||||||
|
rest = after;
|
||||||
|
}
|
||||||
|
words(&mut run, rest, rest);
|
||||||
|
run.cut()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
/// HTML text: references decoded, white space collapsed.
|
||||||
|
fn words(&mut self, written: &str) {
|
||||||
|
let shown = self.shown(&html::text(written));
|
||||||
|
self.spaced(&shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text with its white space collapsed.
|
||||||
|
fn spaced(&mut self, shown: &str) {
|
||||||
|
for ch in shown.chars() {
|
||||||
|
if !html::spacing(ch) {
|
||||||
|
self.pay();
|
||||||
|
self.text.text.push(ch);
|
||||||
|
} else if self.owed.is_none() && !self.text.text.ends_with('\n') {
|
||||||
|
let open = self.open.len();
|
||||||
|
self.owed = (!self.text.text.is_empty()).then_some((' ', open));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line owed where a block element starts or ends.
|
||||||
|
fn line(&mut self) {
|
||||||
|
let from = self.owed.map_or(self.open.len(), |(_, from)| from);
|
||||||
|
let text = &self.text.text;
|
||||||
|
self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some(('\n', from));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the white space owed, before a word or tag.
|
||||||
|
fn pay(&mut self) {
|
||||||
|
let Some((owed, from)) = self.owed.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.text.text.push(owed);
|
||||||
|
let start = self.text.text.len();
|
||||||
|
// What opened after it starts after it.
|
||||||
|
for opened in &mut self.open[from..] {
|
||||||
|
opened.start = start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends what a block's start tag closes, as HTML5 does.
|
||||||
|
fn implied(&mut self, name: &str) {
|
||||||
|
let items: &[&str] = match name {
|
||||||
|
"li" => &["li"],
|
||||||
|
"dd" | "dt" => &["dd", "dt"],
|
||||||
|
_ => &[],
|
||||||
|
};
|
||||||
|
// The search stops at a special element.
|
||||||
|
let stops = |name: &str| html::breaks(name) && !matches!(name, "address" | "div" | "p");
|
||||||
|
let found = items
|
||||||
|
.iter()
|
||||||
|
.any(|item| self.count(item) > 0)
|
||||||
|
.then(|| {
|
||||||
|
let mut names = self.open.iter().rev().map(|opened| opened.name.as_str());
|
||||||
|
names.find(|name| items.contains(name) || stops(name))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.filter(|name| items.contains(name))
|
||||||
|
.map(str::to_string);
|
||||||
|
if let Some(item) = found {
|
||||||
|
self.end(&item);
|
||||||
|
}
|
||||||
|
if html::closes_p(name) && self.count("p") > 0 {
|
||||||
|
self.line();
|
||||||
|
self.end("p");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::documents::tree::{Inline, Node, Style, parse};
|
||||||
|
|
||||||
|
fn html(source: &str) -> Option<Inline> {
|
||||||
|
match parse(source).into_iter().next() {
|
||||||
|
Some(Node::Paragraph(line)) => Some(line),
|
||||||
|
None => None,
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_html_block_shows_its_text_as_a_browser_does() {
|
||||||
|
let centered = html(
|
||||||
|
"<div align=\"center\">\n <b>Bold</b> and\n :smile: & <code>:smile:</code>\n</div>",
|
||||||
|
)
|
||||||
|
.expect("text");
|
||||||
|
assert_eq!(centered.text, "Bold and 😄 & :smile:");
|
||||||
|
let (bold, code) = (0..4, 16..23);
|
||||||
|
assert_eq!(
|
||||||
|
centered.styles,
|
||||||
|
[(bold, Style::Strong), (code, Style::Code)]
|
||||||
|
);
|
||||||
|
let parts = html("<p>one <b>two </b></p>\n<p>three <b></p>").expect("text");
|
||||||
|
assert_eq!(parts.text, "one two\nthree <b>");
|
||||||
|
assert_eq!(
|
||||||
|
html("<div>\na <br> b</br>c</div>").expect("text").text,
|
||||||
|
"a\nb\nc"
|
||||||
|
);
|
||||||
|
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
|
||||||
|
assert_eq!(
|
||||||
|
html(filtered).expect("text").text,
|
||||||
|
"<script>x</script> shown<style>p{}</style>"
|
||||||
|
);
|
||||||
|
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
|
||||||
|
assert_eq!(
|
||||||
|
html(crossed).expect("text").text,
|
||||||
|
"before<script>ab</script>after"
|
||||||
|
);
|
||||||
|
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
|
||||||
|
let lines: Vec<_> = (split.into_iter())
|
||||||
|
.map(|node| match node {
|
||||||
|
Node::Paragraph(line) => line.text,
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(lines, ["<script> first", "second\n</script>"]);
|
||||||
|
let inline = html("a <script>b</script> c").expect("text");
|
||||||
|
assert_eq!(inline.text, "a <script>b</script> c");
|
||||||
|
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closing_block_ends_what_opened_inside_it() {
|
||||||
|
let marked = html("<div><mark>x</div>y").expect("text");
|
||||||
|
assert_eq!(
|
||||||
|
(marked.text.as_str(), &marked.styles[..]),
|
||||||
|
("x\ny", &[(0..1, Style::Mark)][..])
|
||||||
|
);
|
||||||
|
let bold = html("<div><b>x</div>y").expect("text");
|
||||||
|
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
|
||||||
|
let kept = html("<div><mark><p>x</p>y</mark></div>").expect("text");
|
||||||
|
assert_eq!(kept.styles, [(0..3, Style::Mark)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_block_start_ends_what_html5_ends() {
|
||||||
|
let mark = [(0..1, Style::Mark)];
|
||||||
|
for source in [
|
||||||
|
"<ul><li><mark>x<li>y</ul>z",
|
||||||
|
"<div><p><mark>x<div>y</div>z</div>",
|
||||||
|
"<dl><dt><mark>x<dd>y</dl>z",
|
||||||
|
] {
|
||||||
|
let line = html(source).expect("text");
|
||||||
|
assert_eq!(
|
||||||
|
(line.text.as_str(), &line.styles[..]),
|
||||||
|
("x\ny\nz", &mark[..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
|
||||||
|
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
|
||||||
|
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").expect("text");
|
||||||
|
assert_eq!(
|
||||||
|
nested.styles,
|
||||||
|
[(0..3, Style::Mark)],
|
||||||
|
"a list stops the search"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_tags_are_text_and_names_end_as_html5_ends_them() {
|
||||||
|
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||||
|
for (source, shown) in [
|
||||||
|
(
|
||||||
|
"<div><script>:smile:</script> :smile:</div>",
|
||||||
|
"<script>:smile:</script> :smile:",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<div><script><b>:smile:</b></script> :smile:</div>",
|
||||||
|
"<script>😄</script> 😄",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<div><script> :smile:</script> :smile:</div>",
|
||||||
|
"<script> 😄</script> 😄",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<div><script title=\" :smile:\"> x:smile:</div>",
|
||||||
|
"<script title=\" 😄\"> x😄",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<div><iframe title=\":smile:\"> :smile:</div>",
|
||||||
|
"<iframe title=\":smile:\"> :smile:",
|
||||||
|
),
|
||||||
|
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
|
||||||
|
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
|
||||||
|
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
|
||||||
|
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
|
||||||
|
("<div>:smile:</div>", "😄"),
|
||||||
|
] {
|
||||||
|
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
|
||||||
|
}
|
||||||
|
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
|
||||||
|
assert_eq!(html(named).expect("text").text, "axbyc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_html5_p_closer_ends_a_p() {
|
||||||
|
for name in ["fieldset", "menu"] {
|
||||||
|
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
|
||||||
|
let mark = [(0..1, Style::Mark)];
|
||||||
|
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_html_reads_in_linear_time() {
|
||||||
|
let sources = |n: usize| {
|
||||||
|
[
|
||||||
|
"<".repeat(2 * n),
|
||||||
|
"<a".repeat(n),
|
||||||
|
"<!--".repeat(n),
|
||||||
|
"<b>".repeat(n),
|
||||||
|
"<b>".repeat(n / 2) + &"</i>".repeat(n / 2),
|
||||||
|
"<script></script>".repeat(n / 8),
|
||||||
|
"<div>a ".repeat(n),
|
||||||
|
"<b> ".repeat(n),
|
||||||
|
"<li><b>x ".repeat(n / 2),
|
||||||
|
"<p><i>x ".repeat(n / 2),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let time = |n| {
|
||||||
|
let started = web_time::Instant::now();
|
||||||
|
for source in sources(n) {
|
||||||
|
parse(&format!(
|
||||||
|
"<div>
|
||||||
|
{source}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
started.elapsed()
|
||||||
|
};
|
||||||
|
let (small, large) = (time(1 << 14), time(1 << 16));
|
||||||
|
// Four times the input, at most eight the time.
|
||||||
|
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
-63
@@ -4,6 +4,19 @@ use markdown::mdast::{self, AlignKind};
|
|||||||
|
|
||||||
use super::markdown::{span, tree, walk};
|
use super::markdown::{span, tree, walk};
|
||||||
|
|
||||||
|
mod blocks;
|
||||||
|
mod emoji;
|
||||||
|
pub(crate) mod html;
|
||||||
|
mod html_block;
|
||||||
|
mod sources;
|
||||||
|
mod stack;
|
||||||
|
|
||||||
|
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
|
||||||
|
use html_block::html_block_text;
|
||||||
|
pub use sources::Escapes;
|
||||||
|
pub(crate) use sources::{inside, lines_at};
|
||||||
|
use stack::close;
|
||||||
|
|
||||||
/// How a stretch of inline text prints.
|
/// How a stretch of inline text prints.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Style {
|
pub enum Style {
|
||||||
@@ -14,6 +27,9 @@ pub enum Style {
|
|||||||
Link,
|
Link,
|
||||||
Math,
|
Math,
|
||||||
Footnote,
|
Footnote,
|
||||||
|
Underline,
|
||||||
|
/// A highlighted run, as `<mark>`.
|
||||||
|
Mark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inline text, its styled stretches and link addresses.
|
/// Inline text, its styled stretches and link addresses.
|
||||||
@@ -22,6 +38,8 @@ pub struct Inline {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
pub styles: Vec<(Range<usize>, Style)>,
|
pub styles: Vec<(Range<usize>, Style)>,
|
||||||
pub links: Vec<(Range<usize>, String)>,
|
pub links: Vec<(Range<usize>, String)>,
|
||||||
|
/// Where each piece was written: drawn start, source span.
|
||||||
|
pub sources: Vec<(usize, Range<usize>, Escapes)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A block of a markdown document.
|
/// A block of a markdown document.
|
||||||
@@ -33,6 +51,8 @@ pub enum Node {
|
|||||||
Code {
|
Code {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Where its lines start in the source.
|
||||||
|
from: usize,
|
||||||
},
|
},
|
||||||
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
|
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
|
||||||
List {
|
List {
|
||||||
@@ -52,24 +72,64 @@ pub enum Node {
|
|||||||
alt: String,
|
alt: String,
|
||||||
url: String,
|
url: String,
|
||||||
link: Option<String>,
|
link: Option<String>,
|
||||||
|
/// Its source start.
|
||||||
|
at: usize,
|
||||||
},
|
},
|
||||||
Footnote {
|
Footnote {
|
||||||
label: String,
|
label: String,
|
||||||
body: Vec<Node>,
|
body: Vec<Node>,
|
||||||
},
|
},
|
||||||
|
/// A GitHub alert: a quote under its kind's title.
|
||||||
|
Alert {
|
||||||
|
kind: AlertKind,
|
||||||
|
body: Vec<Node>,
|
||||||
|
},
|
||||||
|
/// `[toc]`: the file's headings.
|
||||||
|
Toc(Vec<DocumentHeading>),
|
||||||
|
/// Front matter, its fences cut; lines start at `from`.
|
||||||
|
FrontMatter {
|
||||||
|
text: String,
|
||||||
|
from: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tree read against its source and its link definitions.
|
/// A tree read against its source and its link definitions.
|
||||||
pub(crate) struct Reader<'a> {
|
pub struct Reader<'a> {
|
||||||
source: &'a str,
|
source: &'a str,
|
||||||
urls: HashMap<String, String>,
|
urls: HashMap<String, String>,
|
||||||
|
/// Footnotes numbered by their first reference.
|
||||||
|
notes: HashMap<String, usize>,
|
||||||
|
/// Every heading, as `[toc]` lists them.
|
||||||
|
pub headings: Vec<DocumentHeading>,
|
||||||
|
/// Soft breaks as lines, as the screen draws them.
|
||||||
|
lines: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An open element, as HTML names it.
|
||||||
|
struct Opened {
|
||||||
|
name: String,
|
||||||
|
start: usize,
|
||||||
|
style: Option<Style>,
|
||||||
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inline text being built, and the containers open around it.
|
/// Inline text being built, and the containers open around it.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Run {
|
struct Run {
|
||||||
text: Inline,
|
text: Inline,
|
||||||
open: Vec<(usize, Style, Option<String>)>,
|
open: Vec<Opened>,
|
||||||
|
/// How many of each name are open.
|
||||||
|
named: HashMap<String, usize>,
|
||||||
|
/// Inside an autolink: its text stays as written.
|
||||||
|
verbatim: bool,
|
||||||
|
/// In a table cell, whose code reads `\|` as `|`.
|
||||||
|
table: bool,
|
||||||
|
/// Soft breaks as lines, not spaces.
|
||||||
|
lines: bool,
|
||||||
|
/// The current DOM text node's shortcodes.
|
||||||
|
shortcodes: emoji::Shortcodes,
|
||||||
|
/// White space owed, and the open elements before it.
|
||||||
|
owed: Option<(char, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
@@ -77,48 +137,95 @@ impl Run {
|
|||||||
fn cut(&mut self) -> Inline {
|
fn cut(&mut self) -> Inline {
|
||||||
let mut done = std::mem::take(&mut self.text);
|
let mut done = std::mem::take(&mut self.text);
|
||||||
let end = done.text.len();
|
let end = done.text.len();
|
||||||
for (start, style, url) in self.open.iter_mut().rev() {
|
for opened in self.open.iter_mut().rev() {
|
||||||
if *start < end {
|
close(&mut done, opened, end);
|
||||||
done.styles.push((*start..end, *style));
|
opened.start = 0;
|
||||||
if let Some(url) = url {
|
|
||||||
done.links.push((*start..end, url.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*start = 0;
|
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The innermost open link's address.
|
fn count(&self, name: &str) -> usize {
|
||||||
fn link(&self) -> Option<String> {
|
self.named.get(name).copied().unwrap_or(0)
|
||||||
self.open.iter().rev().find_map(|(_, _, url)| url.clone())
|
}
|
||||||
|
|
||||||
|
/// Text with its shortcodes read, outside autolinks and `<code>`.
|
||||||
|
fn shown(&mut self, written: &str) -> String {
|
||||||
|
match !self.verbatim && self.count("code") == 0 {
|
||||||
|
true => self.shortcodes.read(written),
|
||||||
|
false => written.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text as shown: shortcodes read, lines joined.
|
||||||
|
fn push_text(&mut self, written: &str) {
|
||||||
|
let shown = self.shown(written);
|
||||||
|
self.push_plain(&shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_plain(&mut self, shown: &str) {
|
||||||
|
let soft = if self.lines { "\n" } else { " " };
|
||||||
|
let shown = shown.replace("\r\n", soft).replace(['\r', '\n'], soft);
|
||||||
|
self.text.text.push_str(&shown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `markdown` as blocks.
|
/// `markdown` as blocks.
|
||||||
pub fn parse(markdown: &str) -> Vec<Node> {
|
pub fn parse(markdown: &str) -> Vec<Node> {
|
||||||
let root = tree(markdown);
|
let root = tree(markdown);
|
||||||
let mut out = Vec::new();
|
let (reader, mut out) = (Reader::new(markdown, &root), Vec::new());
|
||||||
Reader::new(markdown, &root).blocks(
|
for node in root.children().expect("the root holds the blocks") {
|
||||||
root.children().expect("the root holds the blocks"),
|
reader.top(node, &mut out);
|
||||||
&mut out,
|
}
|
||||||
);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Reader<'a> {
|
impl<'a> Reader<'a> {
|
||||||
/// A reader of `root`, parsed from `source`.
|
/// Reads `root`, parsed from `source`, as drawn on screen.
|
||||||
pub(crate) fn new(source: &'a str, root: &mdast::Node) -> Self {
|
pub fn new(source: &'a str, root: &mdast::Node) -> Self {
|
||||||
|
Self::reading(source, root, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, soft breaks joined as GFM output joins them.
|
||||||
|
pub fn joined(source: &'a str, root: &mdast::Node) -> Self {
|
||||||
|
Self::reading(source, root, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reading(source: &'a str, root: &mdast::Node, lines: bool) -> Self {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
walk(root, &mut nodes);
|
walk(root, &mut nodes);
|
||||||
let mut urls = HashMap::new();
|
let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
if let mdast::Node::Definition(definition) = node {
|
match node {
|
||||||
urls.entry(definition.identifier.clone())
|
mdast::Node::Definition(definition) => {
|
||||||
.or_insert_with(|| definition.url.clone());
|
urls.entry(definition.identifier.clone())
|
||||||
|
.or_insert_with(|| definition.url.clone());
|
||||||
|
}
|
||||||
|
mdast::Node::FootnoteReference(note) => {
|
||||||
|
let next = notes.len() + 1;
|
||||||
|
notes.entry(note.identifier.clone()).or_insert(next);
|
||||||
|
}
|
||||||
|
mdast::Node::Heading(heading) => headings.push((heading, span(node).start)),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self { source, urls }
|
let mut reader = Self {
|
||||||
|
source,
|
||||||
|
urls,
|
||||||
|
notes,
|
||||||
|
headings: Vec::new(),
|
||||||
|
lines: false,
|
||||||
|
};
|
||||||
|
reader.headings = headings
|
||||||
|
.into_iter()
|
||||||
|
.map(|(heading, at)| DocumentHeading {
|
||||||
|
depth: heading.depth,
|
||||||
|
title: reader.inline(&heading.children).text,
|
||||||
|
at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Titles read on one line; the body as asked.
|
||||||
|
reader.lines = lines;
|
||||||
|
reader
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec<Node>) {
|
fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec<Node>) {
|
||||||
@@ -141,11 +248,12 @@ impl<'a> Reader<'a> {
|
|||||||
.expect("a reference names a definition")
|
.expect("a reference names a definition")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
/// `node`'s blocks, as the drawing reads them.
|
||||||
|
pub fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||||
match node {
|
match node {
|
||||||
mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶graph.children, out),
|
mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶graph.children, out),
|
||||||
mdast::Node::Heading(heading) => {
|
mdast::Node::Heading(heading) => {
|
||||||
let (mut run, mut after) = (Run::default(), Vec::new());
|
let (mut run, mut after) = (self.run(), Vec::new());
|
||||||
for child in &heading.children {
|
for child in &heading.children {
|
||||||
self.inline_into(child, &mut run, &mut Images::After(&mut after));
|
self.inline_into(child, &mut run, &mut Images::After(&mut after));
|
||||||
}
|
}
|
||||||
@@ -156,6 +264,7 @@ impl<'a> Reader<'a> {
|
|||||||
mdast::Node::Code(code) => out.push(Node::Code {
|
mdast::Node::Code(code) => out.push(Node::Code {
|
||||||
language: code.lang.clone(),
|
language: code.lang.clone(),
|
||||||
text: code.value.clone(),
|
text: code.value.clone(),
|
||||||
|
from: sources::body(self.source, node),
|
||||||
}),
|
}),
|
||||||
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
|
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
|
||||||
mdast::Node::List(list) => {
|
mdast::Node::List(list) => {
|
||||||
@@ -193,34 +302,67 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
mdast::Node::ThematicBreak(_) => out.push(Node::Rule),
|
mdast::Node::ThematicBreak(_) => out.push(Node::Rule),
|
||||||
mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote {
|
mdast::Node::FootnoteDefinition(note) => out.push(Node::Footnote {
|
||||||
label: note
|
label: self.label(note),
|
||||||
.label
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| note.identifier.clone()),
|
|
||||||
body: self.nested(¬e.children),
|
body: self.nested(¬e.children),
|
||||||
}),
|
}),
|
||||||
mdast::Node::Html(_) | mdast::Node::Definition(_) => {}
|
mdast::Node::Html(html) => {
|
||||||
|
text_block(html_block_text(&html.value, self.source, span(node)), out)
|
||||||
|
}
|
||||||
|
mdast::Node::Yaml(mdast::Yaml { value, .. })
|
||||||
|
| mdast::Node::Toml(mdast::Toml { value, .. }) => out.push(Node::FrontMatter {
|
||||||
|
text: value.clone(),
|
||||||
|
from: sources::body(self.source, node),
|
||||||
|
}),
|
||||||
|
mdast::Node::Definition(_) => {}
|
||||||
other => unreachable!("{other:?} is no block outside MDX"),
|
other => unreachable!("{other:?} is no block outside MDX"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text split at its images; lone `$$` math is block.
|
/// Text splits at images; a lone formula is block.
|
||||||
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
||||||
if let [mdast::Node::InlineMath(math)] = children
|
if let [mdast::Node::InlineMath(math)] = children
|
||||||
&& self.source[span(node)].starts_with("$$")
|
&& ["$$", "\\["]
|
||||||
|
.iter()
|
||||||
|
.any(|open| self.source[span(&children[0])].starts_with(open))
|
||||||
{
|
{
|
||||||
return out.push(Node::Math(math.value.clone()));
|
return out.push(Node::Math(math.value.trim().to_string()));
|
||||||
}
|
}
|
||||||
let mut run = Run::default();
|
let mut run = Run {
|
||||||
|
table: matches!(node, mdast::Node::TableCell(_)),
|
||||||
|
..self.run()
|
||||||
|
};
|
||||||
for child in children {
|
for child in children {
|
||||||
self.inline_into(child, &mut run, &mut Images::Split(out));
|
self.inline_into(child, &mut run, &mut Images::Split(out));
|
||||||
}
|
}
|
||||||
text_block(run.cut(), out);
|
text_block(run.cut(), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run(&self) -> Run {
|
||||||
|
Run {
|
||||||
|
lines: self.lines,
|
||||||
|
..Run::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A footnote's number, by its first reference.
|
||||||
|
pub fn note(&self, identifier: &str) -> Option<usize> {
|
||||||
|
self.notes.get(identifier).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A note's label: its number, once referenced.
|
||||||
|
pub fn label(&self, note: &mdast::FootnoteDefinition) -> String {
|
||||||
|
match self.note(¬e.identifier) {
|
||||||
|
Some(number) => number.to_string(),
|
||||||
|
None => note
|
||||||
|
.label
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| note.identifier.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inline text where images draw as their alt text.
|
/// Inline text where images draw as their alt text.
|
||||||
pub(crate) fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
pub fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
||||||
let mut run = Run::default();
|
let mut run = self.run();
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
self.inline_into(node, &mut run, &mut Images::Alt);
|
self.inline_into(node, &mut run, &mut Images::Alt);
|
||||||
}
|
}
|
||||||
@@ -228,18 +370,28 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
|
fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
|
||||||
|
// Any element ends the DOM text node.
|
||||||
|
if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) {
|
||||||
|
run.shortcodes.split();
|
||||||
|
}
|
||||||
|
if let Some((written, escapes)) = sources::written(node, self.source) {
|
||||||
|
let escapes = match (escapes, node) {
|
||||||
|
(Escapes::Markdown, _) if run.verbatim => Escapes::Plain,
|
||||||
|
(_, mdast::Node::InlineCode(_)) if run.table => Escapes::Pipes,
|
||||||
|
_ => escapes,
|
||||||
|
};
|
||||||
|
let at = run.text.text.len();
|
||||||
|
run.text.sources.push((at, written, escapes));
|
||||||
|
}
|
||||||
let text = &mut run.text;
|
let text = &mut run.text;
|
||||||
let (children, style, url) = match node {
|
let (children, (name, style), url) = match node {
|
||||||
mdast::Node::Text(words) => {
|
mdast::Node::Text(words) => return run.push_text(&words.value),
|
||||||
let soft = words.value.replace("\r\n", " ").replace(['\r', '\n'], " ");
|
|
||||||
return text.text.push_str(&soft);
|
|
||||||
}
|
|
||||||
mdast::Node::Break(_) => return text.text.push('\n'),
|
mdast::Node::Break(_) => return text.text.push('\n'),
|
||||||
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
|
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
|
||||||
mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math),
|
mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math),
|
||||||
mdast::Node::FootnoteReference(note) => {
|
mdast::Node::FootnoteReference(note) => {
|
||||||
let label = note.label.as_ref().unwrap_or(¬e.identifier);
|
let number = self.notes[¬e.identifier];
|
||||||
return styled(text, &format!("[{label}]"), Style::Footnote);
|
return styled(text, &html::superscript(number), Style::Footnote);
|
||||||
}
|
}
|
||||||
mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
|
mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
|
||||||
let (alt, url) = match node {
|
let (alt, url) = match node {
|
||||||
@@ -249,40 +401,39 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
_ => unreachable!("matched as an image"),
|
_ => unreachable!("matched as an image"),
|
||||||
};
|
};
|
||||||
let link = run.link();
|
let (link, at) = (run.link(), span(node).start);
|
||||||
return match images {
|
return match images {
|
||||||
Images::Alt => run.text.text.push_str(&alt),
|
Images::Alt => run.text.text.push_str(&alt),
|
||||||
Images::Split(out) => {
|
Images::Split(out) => {
|
||||||
text_block(run.cut(), out);
|
text_block(run.cut(), out);
|
||||||
out.push(Node::Image { alt, url, link })
|
out.push(Node::Image { alt, url, link, at })
|
||||||
}
|
}
|
||||||
Images::After(out) => out.push(Node::Image { alt, url, link }),
|
Images::After(out) => out.push(Node::Image { alt, url, link, at }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
mdast::Node::Html(_) => return,
|
mdast::Node::Html(written) => return run.html(&written.value),
|
||||||
mdast::Node::Strong(strong) => (&strong.children, Style::Strong, None),
|
mdast::Node::Strong(strong) => (&strong.children, ("strong", Style::Strong), None),
|
||||||
mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis, None),
|
mdast::Node::Emphasis(emphasis) => (&emphasis.children, ("em", Style::Emphasis), None),
|
||||||
mdast::Node::Delete(delete) => (&delete.children, Style::Strike, None),
|
mdast::Node::Delete(delete) => (&delete.children, ("del", Style::Strike), None),
|
||||||
mdast::Node::Link(link) => (&link.children, Style::Link, Some(link.url.clone())),
|
mdast::Node::Link(link) => (&link.children, ("a", Style::Link), Some(link.url.clone())),
|
||||||
mdast::Node::LinkReference(link) => (
|
mdast::Node::LinkReference(link) => (
|
||||||
&link.children,
|
&link.children,
|
||||||
Style::Link,
|
("a", Style::Link),
|
||||||
Some(self.url(&link.identifier)),
|
Some(self.url(&link.identifier)),
|
||||||
),
|
),
|
||||||
other => unreachable!("{other:?} is no inline outside MDX"),
|
other => unreachable!("{other:?} is no inline outside MDX"),
|
||||||
};
|
};
|
||||||
run.open.push((run.text.text.len(), style, url));
|
// A container is the element GitHub writes.
|
||||||
|
run.start(name, Some(style), url);
|
||||||
|
let autolink =
|
||||||
|
matches!(node, mdast::Node::Link(_)) && !self.source[span(node)].starts_with('[');
|
||||||
|
let outer = run.verbatim;
|
||||||
|
run.verbatim |= autolink;
|
||||||
for child in children {
|
for child in children {
|
||||||
self.inline_into(child, run, images);
|
self.inline_into(child, run, images);
|
||||||
}
|
}
|
||||||
let (start, style, url) = run.open.pop().expect("the container opened above");
|
run.verbatim = outer;
|
||||||
let end = run.text.text.len();
|
run.end(name);
|
||||||
if start < end {
|
|
||||||
run.text.styles.push((start..end, style));
|
|
||||||
if let Some(url) = url {
|
|
||||||
run.text.links.push((start..end, url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use markdown::{decode_named, mdast::Node};
|
||||||
|
|
||||||
|
use super::{Inline, Node as Drawn, html, span};
|
||||||
|
|
||||||
|
/// How a piece's source turns into its text.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Escapes {
|
||||||
|
Plain,
|
||||||
|
/// Backslash escapes and references, as Markdown reads them.
|
||||||
|
Markdown,
|
||||||
|
/// References, as HTML reads them.
|
||||||
|
Html,
|
||||||
|
/// Escaped pipes, as a table cell's code reads them.
|
||||||
|
Pipes,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A leaf's written span and how it reads.
|
||||||
|
pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escapes)> {
|
||||||
|
let whole = match node {
|
||||||
|
Node::Text(_)
|
||||||
|
| Node::Break(_)
|
||||||
|
| Node::Html(_)
|
||||||
|
| Node::FootnoteReference(_)
|
||||||
|
| Node::Image(_)
|
||||||
|
| Node::ImageReference(_)
|
||||||
|
| Node::InlineCode(_)
|
||||||
|
| Node::InlineMath(_) => span(node),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(match node {
|
||||||
|
Node::Text(_) => (whole, Escapes::Markdown),
|
||||||
|
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
||||||
|
Node::InlineMath(_) => (
|
||||||
|
crate::documents::markdown::math_inside(source, whole),
|
||||||
|
Escapes::Plain,
|
||||||
|
),
|
||||||
|
_ => (whole, Escapes::Plain),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code span's text, one end space cut each side.
|
||||||
|
pub(crate) fn inside(source: &str, whole: Range<usize>, mark: char) -> Range<usize> {
|
||||||
|
let written = &source[whole.clone()];
|
||||||
|
let open = written.len() - written.trim_start_matches(mark).len();
|
||||||
|
let close = written.len() - written.trim_end_matches(mark).len();
|
||||||
|
let text = &written[open..written.len().saturating_sub(close).max(open)];
|
||||||
|
let blank = |ch: char| matches!(ch, ' ' | '\n' | '\r');
|
||||||
|
let cut = text.starts_with(blank) && text.ends_with(blank) && !text.chars().all(blank);
|
||||||
|
let width = |crlf: bool| if crlf { 2 } else { 1 };
|
||||||
|
let (head, tail) = match cut {
|
||||||
|
true => (
|
||||||
|
width(text.starts_with("\r\n")),
|
||||||
|
width(text.ends_with("\r\n")),
|
||||||
|
),
|
||||||
|
false => (0, 0),
|
||||||
|
};
|
||||||
|
whole.start + open + head..whole.start + open + text.len() - tail
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a block's text lines start: past a fence line.
|
||||||
|
pub(super) fn body(source: &str, node: &Node) -> usize {
|
||||||
|
let whole = span(node);
|
||||||
|
let written = &source[whole.clone()];
|
||||||
|
let fenced = matches!(node, Node::Yaml(_) | Node::Toml(_))
|
||||||
|
|| ["```", "~~~"]
|
||||||
|
.iter()
|
||||||
|
.any(|fence| written.starts_with(fence));
|
||||||
|
match fenced {
|
||||||
|
true => lines(written)
|
||||||
|
.nth(1)
|
||||||
|
.map_or(whole.end, |line| whole.start + line.start),
|
||||||
|
false => whole.start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text`'s lines without their endings, split as CommonMark splits.
|
||||||
|
fn lines(text: &str) -> impl Iterator<Item = Range<usize>> + '_ {
|
||||||
|
let mut next = Some(0);
|
||||||
|
std::iter::from_fn(move || {
|
||||||
|
let start = next?;
|
||||||
|
let rest = &text[start..];
|
||||||
|
let Some(end) = rest.find(['\n', '\r']) else {
|
||||||
|
next = None;
|
||||||
|
return Some(start..text.len());
|
||||||
|
};
|
||||||
|
let width = if rest[end..].starts_with("\r\n") {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
next = Some(start + end + width);
|
||||||
|
Some(start..start + end)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source byte under drawn byte `at`; lines start at `from`.
|
||||||
|
pub(crate) fn lines_at(source: &str, from: usize, drawn: &str, at: usize) -> Option<usize> {
|
||||||
|
LineMap::new(source, from, drawn)?.at(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each drawn line set against the written line it ends.
|
||||||
|
pub(super) struct LineMap(Vec<Line>);
|
||||||
|
|
||||||
|
struct Line {
|
||||||
|
drawn: Range<usize>,
|
||||||
|
/// Where its written line starts, and its shared end.
|
||||||
|
written: usize,
|
||||||
|
common: usize,
|
||||||
|
kept: usize,
|
||||||
|
/// The char before the shared end, as a cut tab.
|
||||||
|
before: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LineMap {
|
||||||
|
pub(super) fn new(source: &str, from: usize, drawn: &str) -> Option<Self> {
|
||||||
|
let written = source.get(from..)?;
|
||||||
|
let lines = lines(drawn).zip(lines(written)).map(|(line, place)| {
|
||||||
|
let (shown, text) = (&drawn[line.clone()], &written[place.clone()]);
|
||||||
|
let kept: usize = match text.ends_with(shown) {
|
||||||
|
true => shown.len(),
|
||||||
|
false => (shown.chars().rev())
|
||||||
|
.zip(text.chars().rev())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.map(|(a, _)| a.len_utf8())
|
||||||
|
.sum(),
|
||||||
|
};
|
||||||
|
let common = text.len() - kept;
|
||||||
|
let before =
|
||||||
|
(text[..common].chars().next_back()).map_or(0, |ch| common - ch.len_utf8());
|
||||||
|
Line {
|
||||||
|
drawn: line,
|
||||||
|
written: from + place.start,
|
||||||
|
common,
|
||||||
|
kept,
|
||||||
|
before,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(Self(lines.collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source byte under drawn byte `at`.
|
||||||
|
pub(super) fn at(&self, at: usize) -> Option<usize> {
|
||||||
|
let line = self
|
||||||
|
.0
|
||||||
|
.get(self.0.partition_point(|line| line.drawn.end < at))?;
|
||||||
|
let column = at.saturating_sub(line.drawn.start);
|
||||||
|
let shared = line.drawn.len() - line.kept;
|
||||||
|
Some(
|
||||||
|
line.written
|
||||||
|
+ match column.checked_sub(shared) {
|
||||||
|
Some(into) => line.common + into,
|
||||||
|
None => line.before,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drawn {
|
||||||
|
/// Its source bytes counted from `start`, its block's.
|
||||||
|
pub(crate) fn rebase(&mut self, start: usize) {
|
||||||
|
match self {
|
||||||
|
Drawn::Paragraph(inline) | Drawn::Heading(_, inline) => {
|
||||||
|
for (_, span, _) in &mut inline.sources {
|
||||||
|
*span = span.start - start..span.end - start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Drawn::Quote(body) | Drawn::Alert { body, .. } | Drawn::Footnote { body, .. } => {
|
||||||
|
body.iter_mut().for_each(|node| node.rebase(start))
|
||||||
|
}
|
||||||
|
Drawn::List { items, .. } => items
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|(_, body)| body)
|
||||||
|
.for_each(|node| node.rebase(start)),
|
||||||
|
Drawn::Table { head, rows, .. } => head
|
||||||
|
.iter_mut()
|
||||||
|
.chain(rows.iter_mut().flatten())
|
||||||
|
.flatten()
|
||||||
|
.for_each(|node| node.rebase(start)),
|
||||||
|
Drawn::Code { from, .. } | Drawn::FrontMatter { from, .. } => *from -= start,
|
||||||
|
Drawn::Image { at, .. } => *at -= start,
|
||||||
|
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inline {
|
||||||
|
/// Source byte under drawn byte `at`, in some piece.
|
||||||
|
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||||
|
let piece = self.sources.partition_point(|(start, ..)| *start <= at);
|
||||||
|
let (start, span, escapes) = self.sources.get(piece.checked_sub(1)?)?;
|
||||||
|
let end = self
|
||||||
|
.sources
|
||||||
|
.get(piece)
|
||||||
|
.map_or(self.text.len(), |(next, ..)| *next);
|
||||||
|
let drawn = self.text.get(*start..end.max(*start))?;
|
||||||
|
let written = source.get(span.clone())?;
|
||||||
|
// Unreadable text lands at its start.
|
||||||
|
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last drawn byte written by source byte `at`.
|
||||||
|
pub(crate) fn drawn_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||||
|
(0..self.sources.len()).rev().find_map(|piece| {
|
||||||
|
let (start, span, escapes) = &self.sources[piece];
|
||||||
|
if span.start > at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let end = (self.sources.get(piece + 1)).map_or(self.text.len(), |(next, ..)| *next);
|
||||||
|
let drawn = self.text.get(*start..end.max(*start))?;
|
||||||
|
let written = source.get(span.clone())?;
|
||||||
|
// Unreadable text lands at its start.
|
||||||
|
let mut places = aligned(drawn, written, *escapes).into_iter();
|
||||||
|
(drawn.char_indices().rev())
|
||||||
|
.map(|(byte, _)| (byte, places.next().map_or(0, |(_, place)| place)))
|
||||||
|
.find(|(_, place)| span.start + place <= at)
|
||||||
|
.map(|(byte, _)| start + byte)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source char as read, and where it was written.
|
||||||
|
struct Read {
|
||||||
|
ch: char,
|
||||||
|
at: usize,
|
||||||
|
/// Written as itself, not escaped or referenced.
|
||||||
|
raw: bool,
|
||||||
|
/// A container mark or indent at a line's start.
|
||||||
|
lead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read {
|
||||||
|
fn ends_line(&self) -> bool {
|
||||||
|
matches!(self.ch, '\n' | '\r')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `written`'s chars, escapes and references read.
|
||||||
|
fn read(written: &str, escapes: Escapes) -> Vec<Read> {
|
||||||
|
let mut out = Vec::with_capacity(written.len());
|
||||||
|
let (mut at, mut line_start) = (0, false);
|
||||||
|
while let Some(ch) = written[at..].chars().next() {
|
||||||
|
let rest = &written[at..];
|
||||||
|
let decoded = match (escapes, ch) {
|
||||||
|
(Escapes::Markdown, '\\') => (rest[1..].chars().next())
|
||||||
|
.filter(char::is_ascii_punctuation)
|
||||||
|
.map(|punct| (punct.to_string(), 2, at + 1)),
|
||||||
|
(Escapes::Markdown, '&') => reference(rest).map(|(value, used)| (value, used, at)),
|
||||||
|
(Escapes::Html, '&') => {
|
||||||
|
html::reference(rest, false).map(|(value, used)| (value, used, at))
|
||||||
|
}
|
||||||
|
(Escapes::Pipes, '\\') => {
|
||||||
|
(rest[1..].starts_with('|')).then(|| ("|".to_string(), 2, at + 1))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some((value, used, place)) = decoded {
|
||||||
|
out.extend(value.chars().map(|ch| Read {
|
||||||
|
ch,
|
||||||
|
at: place,
|
||||||
|
raw: false,
|
||||||
|
lead: false,
|
||||||
|
}));
|
||||||
|
(at, line_start) = (at + used, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ends = matches!(ch, '\n' | '\r');
|
||||||
|
let lead = line_start && !ends && matches!(ch, ' ' | '\t' | '>');
|
||||||
|
out.push(Read {
|
||||||
|
ch,
|
||||||
|
at,
|
||||||
|
raw: true,
|
||||||
|
lead,
|
||||||
|
});
|
||||||
|
(at, line_start) = (at + ch.len_utf8(), ends || lead);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference at `text`'s start, as Markdown reads it.
|
||||||
|
fn reference(text: &str) -> Option<(String, usize)> {
|
||||||
|
let body = text.strip_prefix('&')?;
|
||||||
|
let (end, _) = body.char_indices().take(34).find(|(_, ch)| *ch == ';')?;
|
||||||
|
let name = &body[..end];
|
||||||
|
let code = |digits: &str, radix: u32, most: usize| {
|
||||||
|
let fits =
|
||||||
|
(1..=most).contains(&digits.len()) && digits.chars().all(|ch| ch.is_digit(radix));
|
||||||
|
let code = u32::from_str_radix(digits, radix).ok().filter(|_| fits)?;
|
||||||
|
// Controls become U+FFFD, as markdown-rs reads them.
|
||||||
|
let ch = char::from_u32(code).filter(|ch| {
|
||||||
|
!matches!(ch, '\0'..='\u{08}' | '\u{0B}' | '\u{0E}'..='\u{1F}' | '\u{7F}'..='\u{9F}')
|
||||||
|
});
|
||||||
|
Some(ch.unwrap_or(char::REPLACEMENT_CHARACTER).to_string())
|
||||||
|
};
|
||||||
|
let value = match name.strip_prefix('#') {
|
||||||
|
Some(hex) if hex.starts_with(['x', 'X']) => code(&hex[1..], 16, 6)?,
|
||||||
|
Some(digits) => code(digits, 10, 7)?,
|
||||||
|
None if name.chars().all(|ch| ch.is_ascii_alphanumeric()) => decode_named(name, true)?,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
Some((value, end + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where drawn byte `at` was written, matched from the end.
|
||||||
|
fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usize> {
|
||||||
|
if at >= drawn.len() {
|
||||||
|
return Some(written.len());
|
||||||
|
}
|
||||||
|
(aligned(drawn, written, escapes).into_iter())
|
||||||
|
.find(|(byte, _)| *byte == at)
|
||||||
|
.map(|(_, place)| place)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawn chars from the end and where each was written.
|
||||||
|
fn aligned(drawn: &str, written: &str, escapes: Escapes) -> Vec<(usize, usize)> {
|
||||||
|
let source = read(written, escapes);
|
||||||
|
let mut j = source.len();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (byte, d) in drawn.char_indices().rev() {
|
||||||
|
let found = loop {
|
||||||
|
let Some(s) = source[..j].last() else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
if d == s.ch || (d.is_whitespace() && s.ends_line()) {
|
||||||
|
j -= 1;
|
||||||
|
// A CRLF drawn as one char is one end.
|
||||||
|
let collapsed = !drawn[..byte].ends_with('\r');
|
||||||
|
if collapsed
|
||||||
|
&& s.ch == '\n'
|
||||||
|
&& j > 0
|
||||||
|
&& source[j - 1].ch == '\r'
|
||||||
|
&& source[j - 1].raw == s.raw
|
||||||
|
{
|
||||||
|
j -= 1;
|
||||||
|
}
|
||||||
|
break source[j].at;
|
||||||
|
}
|
||||||
|
// White space, line ends and marks the text dropped.
|
||||||
|
if s.lead || matches!(s.ch, ' ' | '\t') || s.ends_line() {
|
||||||
|
j -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
out.push((byte, found));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::{Inline, Reader, tree};
|
||||||
|
|
||||||
|
/// The first paragraph, as drawn.
|
||||||
|
fn first(source: &str) -> Inline {
|
||||||
|
let root = tree(source);
|
||||||
|
let reader = Reader::new(source, &root);
|
||||||
|
let paragraph = &root.children().expect("blocks")[0];
|
||||||
|
reader.inline(paragraph.children().expect("inlines"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source byte under drawn text in the first paragraph.
|
||||||
|
fn under(source: &str, drawn: &str) -> usize {
|
||||||
|
let inline = first(source);
|
||||||
|
let at = inline.text.find(drawn).expect("drawn");
|
||||||
|
inline.source_at(source, at).expect("a piece")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_byte_reads_back_to_its_drawn_byte() {
|
||||||
|
let source = "Say **bold** and \\*x";
|
||||||
|
let inline = first(source);
|
||||||
|
let bold = source.find("bold").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, bold), inline.text.find("bold"));
|
||||||
|
let mark = source.find("**").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, mark), inline.text.find(' '));
|
||||||
|
let star = source.find("*x").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, star), inline.text.find('*'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_paragraph_reads_back_in_one_pass() {
|
||||||
|
let source = "x".repeat(40_000);
|
||||||
|
let inline = first(&source);
|
||||||
|
let started = web_time::Instant::now();
|
||||||
|
assert_eq!(inline.drawn_at(&source, 5), Some(5));
|
||||||
|
let took = started.elapsed();
|
||||||
|
assert!(took.as_millis() < 500, "took {took:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_word_lands_where_it_was_written() {
|
||||||
|
let source = "Say **bold** and `code` here\nnext line";
|
||||||
|
assert_eq!(under(source, "bold"), source.find("bold").unwrap());
|
||||||
|
assert_eq!(under(source, "code"), source.find("code").unwrap());
|
||||||
|
assert_eq!(under(source, "here"), source.find("here").unwrap());
|
||||||
|
assert_eq!(under(source, "next"), source.find("next").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drawn_crlf_reads_back_to_its_start() {
|
||||||
|
let source = "one\r\ntwo";
|
||||||
|
let inline = first(source);
|
||||||
|
let end = inline.text.find('\n').expect("a drawn line end");
|
||||||
|
assert_eq!(inline.source_at(source, end), Some(3));
|
||||||
|
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kept_crlf_reads_back_char_for_char() {
|
||||||
|
let source = "`one\r\ntwo`\n";
|
||||||
|
assert_eq!(under(source, "ne"), source.find("ne").unwrap());
|
||||||
|
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joins_escapes_and_indents_read_back() {
|
||||||
|
let source = "a \\*star\\* x\n y";
|
||||||
|
assert_eq!(under(source, "x"), source.find('x').unwrap());
|
||||||
|
assert_eq!(under(source, "y"), source.find('y').unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn references_read_back_and_emoji_end_the_reading() {
|
||||||
|
let source = "a & b";
|
||||||
|
assert_eq!(under(source, "b"), source.find('b').unwrap());
|
||||||
|
let source = "ab :smile: y";
|
||||||
|
assert_eq!(under(source, "y"), source.find('y').unwrap());
|
||||||
|
assert_eq!(under(source, "b"), 0, "before the emoji: its start");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use super::{Inline, Opened, Run, Style, html, html::InlineTag};
|
||||||
|
|
||||||
|
/// The elements open around inline text.
|
||||||
|
impl Run {
|
||||||
|
pub(super) fn push(&mut self, opened: Opened) {
|
||||||
|
*self.named.entry(opened.name.clone()).or_default() += 1;
|
||||||
|
self.open.push(opened);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The innermost open link's address.
|
||||||
|
pub(super) fn link(&self) -> Option<String> {
|
||||||
|
self.open.iter().rev().find_map(|opened| opened.url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or closes an element for an inline HTML tag.
|
||||||
|
pub(super) fn html(&mut self, written: &str) {
|
||||||
|
match html::inline_tag(written) {
|
||||||
|
InlineTag::Text(shown) => self.push_text(&shown),
|
||||||
|
InlineTag::Open { name, style, href } => self.start(&name, style, href),
|
||||||
|
InlineTag::Close(name) => {
|
||||||
|
self.shortcodes.split();
|
||||||
|
self.end(&name);
|
||||||
|
}
|
||||||
|
InlineTag::Break => {
|
||||||
|
self.shortcodes.split();
|
||||||
|
self.text.text.push('\n');
|
||||||
|
}
|
||||||
|
InlineTag::Gone => self.shortcodes.split(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `name`; a new `a` ends the open one.
|
||||||
|
pub(super) fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
|
||||||
|
if name == "a" {
|
||||||
|
self.end("a");
|
||||||
|
}
|
||||||
|
self.shortcodes.split();
|
||||||
|
self.push(Opened {
|
||||||
|
name: name.to_string(),
|
||||||
|
start: self.text.text.len(),
|
||||||
|
style,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the innermost `name`, as HTML5 repairs nesting.
|
||||||
|
pub(super) fn end(&mut self, name: &str) {
|
||||||
|
if self.count(name) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.shortcodes.split();
|
||||||
|
let at = self
|
||||||
|
.open
|
||||||
|
.iter()
|
||||||
|
.rposition(|opened| opened.name == name)
|
||||||
|
.expect("a counted name is open");
|
||||||
|
let end = self.text.text.len();
|
||||||
|
if let Some((_, from)) = &mut self.owed {
|
||||||
|
*from = (*from).min(at);
|
||||||
|
}
|
||||||
|
let closed = self.open.split_off(at);
|
||||||
|
for opened in &closed {
|
||||||
|
close(&mut self.text, opened, end);
|
||||||
|
*self.named.get_mut(&opened.name).expect("counted") -= 1;
|
||||||
|
}
|
||||||
|
for opened in closed.into_iter().skip(1) {
|
||||||
|
if html::reopens(&opened.name) {
|
||||||
|
self.push(Opened {
|
||||||
|
start: end,
|
||||||
|
..opened
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records `opened` as styled up to `end`.
|
||||||
|
pub(super) fn close(text: &mut Inline, opened: &Opened, end: usize) {
|
||||||
|
if opened.start >= end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(style) = opened.style {
|
||||||
|
text.styles.push((opened.start..end, style));
|
||||||
|
}
|
||||||
|
if let Some(url) = &opened.url {
|
||||||
|
text.links.push((opened.start..end, url.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
-16
@@ -25,7 +25,8 @@ fn blocks_nest_as_written() {
|
|||||||
nodes[4],
|
nodes[4],
|
||||||
Node::Code {
|
Node::Code {
|
||||||
language: Some("rust".into()),
|
language: Some("rust".into()),
|
||||||
text: "fn main() {}".into()
|
text: "fn main() {}".into(),
|
||||||
|
from: 95
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(nodes[5], Node::Rule);
|
assert_eq!(nodes[5], Node::Rule);
|
||||||
@@ -39,7 +40,8 @@ fn an_image_inside_an_image_is_the_outer_one() {
|
|||||||
[Node::Image {
|
[Node::Image {
|
||||||
alt: "outer ".into(),
|
alt: "outer ".into(),
|
||||||
url: "outer.png".into(),
|
url: "outer.png".into(),
|
||||||
link: None
|
link: None,
|
||||||
|
at: 0
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,8 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
|
|||||||
Node::Image {
|
Node::Image {
|
||||||
alt: "a".into(),
|
alt: "a".into(),
|
||||||
url: "a.png".into(),
|
url: "a.png".into(),
|
||||||
link: None
|
link: None,
|
||||||
|
at: 7
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let Node::Paragraph(after) = &nodes[2] else {
|
let Node::Paragraph(after) = &nodes[2] else {
|
||||||
@@ -65,20 +68,31 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() {
|
fn a_soft_break_draws_as_a_line_and_joins_for_output() {
|
||||||
for (source, shown) in [
|
for source in [
|
||||||
("one\ntwo \nthree", "one two\nthree"),
|
"one\ntwo \nthree",
|
||||||
("one\r\ntwo \r\nthree", "one two\nthree"),
|
"one\r\ntwo \r\nthree",
|
||||||
("one\rtwo \rthree", "one two\nthree"),
|
"one\rtwo \rthree",
|
||||||
] {
|
] {
|
||||||
let nodes = parse(source);
|
let nodes = parse(source);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&nodes[0], Node::Paragraph(text) if text.text == shown),
|
matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"),
|
||||||
"{source:?} reads {nodes:?}"
|
"{source:?} draws {nodes:?}"
|
||||||
);
|
);
|
||||||
|
let root = super::super::markdown::tree(source);
|
||||||
|
let paragraph = &root.children().expect("blocks")[0];
|
||||||
|
let joined = Reader::joined(source, &root).inline(paragraph.children().expect("inlines"));
|
||||||
|
assert_eq!(joined.text, "one two\nthree", "{source:?} joins");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_title_reads_on_one_line() {
|
||||||
|
let source = "one\ntwo\n===\n";
|
||||||
|
let root = super::super::markdown::tree(source);
|
||||||
|
assert_eq!(Reader::new(source, &root).headings[0].title, "one two");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn definitions_nested_in_quotes_and_lists_resolve() {
|
fn definitions_nested_in_quotes_and_lists_resolve() {
|
||||||
for source in [
|
for source in [
|
||||||
@@ -95,7 +109,8 @@ fn definitions_nested_in_quotes_and_lists_resolve() {
|
|||||||
Node::Image {
|
Node::Image {
|
||||||
alt: "pic".into(),
|
alt: "pic".into(),
|
||||||
url: "a.png".into(),
|
url: "a.png".into(),
|
||||||
link: None
|
link: None,
|
||||||
|
at: 7
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +131,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
|
|||||||
Node::Image {
|
Node::Image {
|
||||||
alt: "x".into(),
|
alt: "x".into(),
|
||||||
url: "x.png".into(),
|
url: "x.png".into(),
|
||||||
link: None
|
link: None,
|
||||||
|
at: 3
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let Node::Paragraph(after) = &nodes[2] else {
|
let Node::Paragraph(after) = &nodes[2] else {
|
||||||
@@ -131,7 +147,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
|
|||||||
Node::Image {
|
Node::Image {
|
||||||
alt: "y".into(),
|
alt: "y".into(),
|
||||||
url: "y.png".into(),
|
url: "y.png".into(),
|
||||||
link: Some("https://ely.dev".into())
|
link: Some("https://ely.dev".into()),
|
||||||
|
at: 19
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(nodes.len(), 4, "{nodes:?}");
|
assert_eq!(nodes.len(), 4, "{nodes:?}");
|
||||||
@@ -189,7 +206,257 @@ fn footnotes_number_their_references_and_hold_their_body() {
|
|||||||
let Node::Paragraph(line) = &nodes[0] else {
|
let Node::Paragraph(line) = &nodes[0] else {
|
||||||
panic!("a paragraph, not {:?}", nodes[0])
|
panic!("a paragraph, not {:?}", nodes[0])
|
||||||
};
|
};
|
||||||
assert_eq!(line.text, "Lift[a].");
|
assert_eq!(line.text, "Lift¹.");
|
||||||
assert_eq!(line.styles, [(4..7, Style::Footnote)]);
|
assert_eq!(line.styles, [(4..6, Style::Footnote)]);
|
||||||
assert!(matches!(&nodes[1], Node::Footnote { label, body } if label == "a" && body.len() == 1));
|
assert!(matches!(&nodes[1], Node::Footnote { label, body } if label == "1" && body.len() == 1));
|
||||||
|
let alone = parse(
|
||||||
|
"[^z]: Unread.
|
||||||
|
",
|
||||||
|
);
|
||||||
|
assert!(matches!(&alone[0], Node::Footnote { label, .. } if label == "z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(source: &str) -> Inline {
|
||||||
|
match parse(source).into_iter().next() {
|
||||||
|
Some(Node::Paragraph(text)) => text,
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_html_shows_as_typora_shows_it() {
|
||||||
|
let text = line(
|
||||||
|
"a <u>un</u> <b>bo</b> <kbd>K</kbd> <mark>hi</mark> <a href=\"https://ely.dev\">x</a><br>end <span>sp</span> <a name=\"n\">no</a>",
|
||||||
|
);
|
||||||
|
assert_eq!(text.text, "a un bo K hi x\nend sp no");
|
||||||
|
assert_eq!(
|
||||||
|
text.styles,
|
||||||
|
[
|
||||||
|
(2..4, Style::Underline),
|
||||||
|
(5..7, Style::Strong),
|
||||||
|
(8..9, Style::Code),
|
||||||
|
(10..12, Style::Mark),
|
||||||
|
(13..14, Style::Link)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(text.links, [(13..14, "https://ely.dev".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `style` holds, touching stretches joined.
|
||||||
|
fn marked(inline: &Inline, style: Style) -> Vec<(usize, usize)> {
|
||||||
|
let mut ranges: Vec<Range<usize>> = inline
|
||||||
|
.styles
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, held)| *held == style)
|
||||||
|
.map(|(range, _)| range.clone())
|
||||||
|
.collect();
|
||||||
|
ranges.sort_by_key(|range| range.start);
|
||||||
|
let mut out: Vec<Range<usize>> = Vec::new();
|
||||||
|
for range in ranges {
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(last) if last.end >= range.start => last.end = last.end.max(range.end),
|
||||||
|
_ => out.push(range),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.into_iter()
|
||||||
|
.map(|range| (range.start, range.end))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_and_emphasis_cross_as_a_browser_repairs_them() {
|
||||||
|
let opened_inside = line("**a <u>b** c</u> d");
|
||||||
|
assert_eq!(opened_inside.text, "a b c d");
|
||||||
|
assert_eq!(marked(&opened_inside, Style::Strong), [(0, 3)]);
|
||||||
|
assert_eq!(marked(&opened_inside, Style::Underline), [(2, 5)]);
|
||||||
|
let closed_inside = line("a <u>*text</u> outside* end");
|
||||||
|
assert_eq!(closed_inside.text, "a text outside end");
|
||||||
|
assert_eq!(marked(&closed_inside, Style::Underline), [(2, 6)]);
|
||||||
|
assert_eq!(marked(&closed_inside, Style::Emphasis), [(2, 14)]);
|
||||||
|
assert!(line("a </u> b").styles.is_empty(), "a stray close");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_raw_close_ends_the_markdown_element() {
|
||||||
|
let em = line("a *inside</em> outside* end");
|
||||||
|
assert_eq!(em.text, "a inside outside end");
|
||||||
|
assert_eq!(marked(&em, Style::Emphasis), [(2, 8)]);
|
||||||
|
let strong = line("a **inside</strong> outside** end");
|
||||||
|
assert_eq!(marked(&strong, Style::Strong), [(2, 8)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_formatting_elements_reopen_past_a_close() {
|
||||||
|
for (tag, style) in [
|
||||||
|
("mark", Style::Mark),
|
||||||
|
("kbd", Style::Code),
|
||||||
|
("del", Style::Strike),
|
||||||
|
] {
|
||||||
|
let text = line(&format!("a **bold <{tag}>x** outside</{tag}> end"));
|
||||||
|
assert_eq!(text.text, "a bold x outside end", "{tag}");
|
||||||
|
assert_eq!(marked(&text, style), [(7, 8)], "{tag}");
|
||||||
|
}
|
||||||
|
let reopened = line("<b>1<i>2</b>3</i>4");
|
||||||
|
assert_eq!(marked(&reopened, Style::Strong), [(0, 2)]);
|
||||||
|
assert_eq!(marked(&reopened, Style::Emphasis), [(1, 3)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_anchor_ends_the_open_one() {
|
||||||
|
let text = line("[one <a href='/inner'>two</a> three](/outer)");
|
||||||
|
assert_eq!(text.text, "one two three");
|
||||||
|
assert_eq!(
|
||||||
|
text.links,
|
||||||
|
[(0..4, "/outer".to_string()), (4..7, "/inner".to_string())]
|
||||||
|
);
|
||||||
|
assert_eq!(marked(&text, Style::Link), [(0, 7)]);
|
||||||
|
let named = line("[x <a name=n>y</a> z](/u)");
|
||||||
|
assert_eq!(named.links, [(0..2, "/u".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closing_br_breaks_as_html_reads_it() {
|
||||||
|
assert_eq!(line("before<br></br>after").text, "before\n\nafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcodes_mean_what_github_means() {
|
||||||
|
assert_eq!(line(":cricket: :cricket_game:").text, "🦗 🏏");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcodes_draw_as_emoji_outside_code() {
|
||||||
|
let text = line("go :rocket: `:rocket:`");
|
||||||
|
assert_eq!(text.text, "go 🚀 :rocket:");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn footnotes_number_by_their_first_reference() {
|
||||||
|
let nodes = parse("x[^b] y[^a] z[^b]\n\n[^a]: A\n\n[^b]: B\n");
|
||||||
|
let Node::Paragraph(text) = &nodes[0] else {
|
||||||
|
panic!("a paragraph, not {:?}", nodes[0])
|
||||||
|
};
|
||||||
|
assert_eq!(text.text, "x¹ y² z¹");
|
||||||
|
let labels: Vec<&str> = nodes[1..]
|
||||||
|
.iter()
|
||||||
|
.map(|node| match node {
|
||||||
|
Node::Footnote { label, .. } => label.as_str(),
|
||||||
|
other => panic!("a footnote, not {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(labels, ["2", "1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_tilde_still_strikes() {
|
||||||
|
assert_eq!(line("H~2~O").styles, [(1..2, Style::Strike)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcodes_read_as_github_reads_them() {
|
||||||
|
let decoded = line(r"Show \:smile: and :smile: and :smile:.");
|
||||||
|
assert_eq!(decoded.text, "Show 😄 and 😄 and 😄.");
|
||||||
|
let tags = line("a <code>:smile:</code> <kbd>:smile:</kbd> <tt>:smile:</tt> :smile:");
|
||||||
|
assert_eq!(tags.text, "a :smile: 😄 😄 😄");
|
||||||
|
assert_eq!(
|
||||||
|
line("<https://x.test/:smile:>").text,
|
||||||
|
"https://x.test/:smile:"
|
||||||
|
);
|
||||||
|
assert_eq!(line("[go :smile:](https://x.test)").text, "go 😄");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_tags_show_text_and_only_anchors_link() {
|
||||||
|
let custom = line("a <u-box>word</u-box> <br-box>in</br-box> end");
|
||||||
|
assert_eq!(
|
||||||
|
(custom.text.as_str(), custom.styles.len()),
|
||||||
|
("a word in end", 0)
|
||||||
|
);
|
||||||
|
let bold = line("See <b href=\"https://x.test/\">bold</b>.");
|
||||||
|
assert_eq!(bold.styles, [(4..8, Style::Strong)]);
|
||||||
|
assert!(bold.links.is_empty(), "{bold:?}");
|
||||||
|
let query = line("<a href=\"https://x.test/?q=1&p=2\">r</a> <a href=/path/>s</a>");
|
||||||
|
let urls: Vec<&str> = query.links.iter().map(|(_, url)| url.as_str()).collect();
|
||||||
|
assert_eq!(urls, ["https://x.test/?q=1&p=2", "/path/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_shortcode_decides_its_text_node() {
|
||||||
|
// GitHub's answers, `.work/R4.3-fix3/github-*.log`.
|
||||||
|
for (source, shown) in [
|
||||||
|
("a>:smile: :smile:", "a>:smile: :smile:"),
|
||||||
|
("a> :smile: :smile:", "a> 😄 😄"),
|
||||||
|
("x:smile: :smile:", "x:smile: :smile:"),
|
||||||
|
("a;:smile: :smile:", "a;:smile: :smile:"),
|
||||||
|
("a :smile: b>:smile:", "a 😄 b>😄"),
|
||||||
|
("a :zzzq: x:smile: :smile:", "a :zzzq: x😄 😄"),
|
||||||
|
("ax:zzzq: :smile:", "ax:zzzq: :smile:"),
|
||||||
|
("a :x-y: z:smile:", "a :x-y: z😄"),
|
||||||
|
("a :x.y: z:smile:", "a :x.y: z:smile:"),
|
||||||
|
("a.:x.y: :smile:", "a.:x.y: 😄"),
|
||||||
|
("x\t:smile: y:smile:", "x\t:smile: y:smile:"),
|
||||||
|
("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"),
|
||||||
|
("x\n:smile: y:smile:", "x\n:smile: y:smile:"),
|
||||||
|
("*x*:smile: :smile:", "x😄 😄"),
|
||||||
|
("*x*\n:smile: y:smile:", "x\n😄 y😄"),
|
||||||
|
("x:smile: `c` :smile:", "x:smile: c 😄"),
|
||||||
|
("x:smile:<!-- c --> :smile:", "x:smile: 😄"),
|
||||||
|
("x:smile:<span>s</span> :smile:", "x:smile:s 😄"),
|
||||||
|
("x&:smile: :smile:", "x&:smile: :smile:"),
|
||||||
|
("x:smile: \n:smile:", "x:smile:\n😄"),
|
||||||
|
(
|
||||||
|
"a <script>x</style>:smile:</script> :smile:",
|
||||||
|
"a <script>x</style>:smile:</script> :smile:",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a <script><b>:smile:</b></script> :smile:",
|
||||||
|
"a <script>😄</script> 😄",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(line(source).text, shown, "{source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_read_as_math() {
|
||||||
|
assert_eq!(parse(r"\[c = 1\]"), [Node::Math("c = 1".into())]);
|
||||||
|
assert_eq!(parse("\\[\nc = 1\n\\]"), [Node::Math("c = 1".into())]);
|
||||||
|
for indent in [" ", " ", " "] {
|
||||||
|
let source = format!(r"{indent}\[c = 1\]");
|
||||||
|
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||||
|
let source = format!("{indent}$$c = 1$$");
|
||||||
|
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||||
|
}
|
||||||
|
let Node::Paragraph(inline) = &parse(r"so \(c\) holds")[0] else {
|
||||||
|
panic!("inline math stays in its paragraph")
|
||||||
|
};
|
||||||
|
assert_eq!(inline.text, "so c holds");
|
||||||
|
assert_eq!(inline.styles, [(3..4, Style::Math)]);
|
||||||
|
let Node::Paragraph(alone) = &parse(r"\(c\)")[0] else {
|
||||||
|
panic!("a lone formula stays inline")
|
||||||
|
};
|
||||||
|
assert_eq!(alone.styles, [(0..1, Style::Math)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_leave_code_escapes_and_open_pairs() {
|
||||||
|
let text = |source: &str| match &parse(source)[0] {
|
||||||
|
Node::Paragraph(inline) => (inline.text.clone(), inline.styles.clone()),
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(text(r"`\(c\)`").1, [(0..5, Style::Code)], "code stays code");
|
||||||
|
assert_eq!(
|
||||||
|
text(r"\\(c\\)"),
|
||||||
|
(r"\(c\)".into(), Vec::new()),
|
||||||
|
"escaped backslashes"
|
||||||
|
);
|
||||||
|
assert_eq!(text(r"a \(c").0, "a (c", "no closing, no formula");
|
||||||
|
let split = parse("a \\(c\n\nd\\) e");
|
||||||
|
assert!(
|
||||||
|
split.iter().all(|node| match node {
|
||||||
|
Node::Paragraph(inline) => inline.styles.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
}),
|
||||||
|
"a blank line ends the formula: {split:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+50
-2
@@ -1,6 +1,6 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use super::state::Marks;
|
use super::state::{CodeEditor, Marks};
|
||||||
|
|
||||||
/// Where `offset` lands after `edits`, sorted and apart, each a replaced range and the length written; none when an edit replaced it.
|
/// Where `offset` lands after `edits`, sorted and apart, each a replaced range and the length written; none when an edit replaced it.
|
||||||
fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||||
@@ -17,6 +17,21 @@ fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
|||||||
Some(at as usize)
|
Some(at as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the character at `offset` lands; text written at it goes before.
|
||||||
|
fn carried(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||||
|
let mut at = offset as isize;
|
||||||
|
for (range, written) in edits {
|
||||||
|
if offset < range.start {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if offset < range.end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
at += *written as isize - range.len() as isize;
|
||||||
|
}
|
||||||
|
Some(at as usize)
|
||||||
|
}
|
||||||
|
|
||||||
/// A range through `edits`; none when an edit cut into it.
|
/// A range through `edits`; none when an edit cut into it.
|
||||||
fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
|
fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
|
||||||
Some(moved(range.start, edits)?..moved(range.end, edits)?)
|
Some(moved(range.start, edits)?..moved(range.end, edits)?)
|
||||||
@@ -43,6 +58,9 @@ pub(crate) fn shift(marks: &mut Marks, edits: &[(Range<usize>, usize)]) {
|
|||||||
ghost.offset = moved(ghost.offset, edits)?;
|
ghost.offset = moved(ghost.offset, edits)?;
|
||||||
Some(ghost)
|
Some(ghost)
|
||||||
});
|
});
|
||||||
|
for anchor in &mut marks.anchors {
|
||||||
|
*anchor = anchor.and_then(|at| carried(at, edits));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops the owner's offsets when the whole text changes; they named the old one.
|
/// Drops the owner's offsets when the whole text changes; they named the old one.
|
||||||
@@ -51,6 +69,27 @@ pub(crate) fn clear(marks: &mut Marks) {
|
|||||||
marks.backgrounds.clear();
|
marks.backgrounds.clear();
|
||||||
marks.hints.clear();
|
marks.hints.clear();
|
||||||
marks.ghost = None;
|
marks.ghost = None;
|
||||||
|
marks.anchors.fill(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeEditor {
|
||||||
|
/// Offsets the editor carries through each edit.
|
||||||
|
pub fn set_anchors(&mut self, offsets: impl IntoIterator<Item = usize>) {
|
||||||
|
let text = self.buffer.text();
|
||||||
|
let whole = |at: usize| {
|
||||||
|
let fits = text.is_char_boundary(at);
|
||||||
|
if !fits {
|
||||||
|
log::error!("code editor: anchor {at} splits a character");
|
||||||
|
}
|
||||||
|
fits.then_some(at)
|
||||||
|
};
|
||||||
|
self.marks.anchors = offsets.into_iter().map(whole).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where each anchor is now; none once an edit took it.
|
||||||
|
pub fn anchors(&self) -> &[Option<usize>] {
|
||||||
|
&self.marks.anchors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `range` runs forward between whole characters of `text`.
|
/// Whether `range` runs forward between whole characters of `text`.
|
||||||
@@ -62,7 +101,7 @@ pub(crate) fn fits(text: &str, range: &Range<usize>) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{fits, moved};
|
use super::{carried, fits, moved};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn offsets_ride_their_edits() {
|
fn offsets_ride_their_edits() {
|
||||||
@@ -80,4 +119,13 @@ mod tests {
|
|||||||
assert_eq!(moved(8, &edits), Some(10));
|
assert_eq!(moved(8, &edits), Some(10));
|
||||||
assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9)));
|
assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors_go_after_text_written_at_them() {
|
||||||
|
let edits = [(2..4, 1), (6..6, 3)];
|
||||||
|
assert_eq!(carried(2, &edits), None, "its character replaced");
|
||||||
|
assert_eq!(carried(4, &edits), Some(3), "just past a replace");
|
||||||
|
assert_eq!(carried(6, &edits), Some(8), "after an insertion at it");
|
||||||
|
assert_eq!(carried(1, &edits), Some(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+31
-3
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-29
@@ -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,22 +328,28 @@ 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(
|
||||||
.variant(ButtonVariant::Ghost)
|
(self.id.clone(), "replace"),
|
||||||
.size(ControlSize::Sm)
|
i18n::text(cx, "find.replace", &[]),
|
||||||
.disabled(!found)
|
)
|
||||||
.when_some(one, |button, one| {
|
.variant(ButtonVariant::Ghost)
|
||||||
button.on_click(move |_, window, cx| one(false, window, cx))
|
.size(ControlSize::Sm)
|
||||||
}),
|
.disabled(!found)
|
||||||
|
.when_some(one, |button, one| {
|
||||||
|
button.on_click(move |_, window, cx| one(false, window, cx))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new((self.id.clone(), "all"), "Replace all")
|
Button::new(
|
||||||
.variant(ButtonVariant::Ghost)
|
(self.id.clone(), "all"),
|
||||||
.size(ControlSize::Sm)
|
i18n::text(cx, "find.replace_all", &[]),
|
||||||
.disabled(!found)
|
)
|
||||||
.when_some(all, |button, all| {
|
.variant(ButtonVariant::Ghost)
|
||||||
button.on_click(move |_, window, cx| all(true, window, cx))
|
.size(ControlSize::Sm)
|
||||||
}),
|
.disabled(!found)
|
||||||
|
.when_some(all, |button, all| {
|
||||||
|
button.on_click(move |_, window, cx| all(true, window, cx))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -7,6 +7,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{symbols::SymbolKind, syntax};
|
use 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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
' ' | '\t' => {
|
||||||
|
hide.push((start..out.len(), hidden));
|
||||||
|
out.push(if ch == ' ' { '·' } else { '→' });
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.push(ch);
|
||||||
|
hide.push((start..out.len(), hidden));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.push(match ch {
|
|
||||||
' ' => '·',
|
|
||||||
'\t' => '→',
|
|
||||||
_ => ' ',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
debug_assert!(out.chars().count() <= shown.text.chars().count());
|
hide.retain(|(range, _)| !range.is_empty());
|
||||||
out
|
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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pub(crate) struct Marks {
|
|||||||
pub git: Vec<(usize, GitMark)>,
|
pub git: Vec<(usize, GitMark)>,
|
||||||
pub breakpoints: BTreeSet<usize>,
|
pub breakpoints: BTreeSet<usize>,
|
||||||
pub chat: Option<usize>,
|
pub chat: Option<usize>,
|
||||||
|
/// The owner's offsets; an edit over one drops it.
|
||||||
|
pub anchors: Vec<Option<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The text and cursors one undo step returns to.
|
/// The text and cursors one undo step returns to.
|
||||||
@@ -100,6 +102,8 @@ pub(crate) struct Metrics {
|
|||||||
pub top: Pixels,
|
pub top: Pixels,
|
||||||
pub advance: Pixels,
|
pub advance: Pixels,
|
||||||
pub line: Pixels,
|
pub line: Pixels,
|
||||||
|
/// The code font's size.
|
||||||
|
pub size: Pixels,
|
||||||
/// The view's height.
|
/// The view's height.
|
||||||
pub height: Pixels,
|
pub height: Pixels,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
+11
-6
@@ -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,
|
||||||
} else {
|
if masked {
|
||||||
"Hide password"
|
"input.show_password"
|
||||||
})
|
} else {
|
||||||
|
"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
@@ -116,10 +116,10 @@ pub use switch::Switch;
|
|||||||
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
|
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
|
||||||
pub use tags::TagInput;
|
pub use tags::TagInput;
|
||||||
pub(crate) use text::{
|
pub(crate) use text::{
|
||||||
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
|
Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
|
||||||
bind_keys, from_utf16, to_utf16,
|
from_utf16, to_utf16,
|
||||||
};
|
};
|
||||||
pub use text::{Highlight, History, InputEvent, TextInput};
|
pub use text::{Copy, Cut, Highlight, History, InputEvent, Paste, SelectAll, TextInput};
|
||||||
pub use transfer::TransferList;
|
pub use transfer::TransferList;
|
||||||
pub use unit::UnitInput;
|
pub use unit::UnitInput;
|
||||||
pub use upload::{Upload, UploadList, UploadState};
|
pub use upload::{Upload, UploadList, UploadState};
|
||||||
+7
-5
@@ -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,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use actions::{
|
pub(crate) use actions::{
|
||||||
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
|
Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
|
||||||
bind_keys,
|
|
||||||
};
|
};
|
||||||
|
pub use actions::{Copy, Cut, Paste, SelectAll};
|
||||||
pub use edit::History;
|
pub use edit::History;
|
||||||
use edit::Snapshot;
|
use edit::Snapshot;
|
||||||
pub(crate) use edit::{from_utf16, to_utf16};
|
pub(crate) use edit::{from_utf16, to_utf16};
|
||||||
|
|||||||
@@ -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,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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"))
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Hover intent: pause before opening, hold while aiming.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::{App, Bounds, Entity, Pixels, Point};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
menu::{Open, mark},
|
||||||
|
model::Menu,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A submenu row opens after the pointer rests this long.
|
||||||
|
pub(super) const OPEN: Duration = Duration::from_millis(150);
|
||||||
|
/// A row crossed toward an open submenu waits this long.
|
||||||
|
pub(super) const HOLD: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// Pointer over a row; `sub` when it opens a submenu.
|
||||||
|
pub(super) fn hover(
|
||||||
|
state: &Entity<Open>,
|
||||||
|
menu: &Menu,
|
||||||
|
(depth, cell): (usize, (usize, usize)),
|
||||||
|
sub: bool,
|
||||||
|
at: Point<Pixels>,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let seen = state.update(cx, |open, _| {
|
||||||
|
let from = open.pointer.replace(at);
|
||||||
|
open.over = Some((depth, cell));
|
||||||
|
let level = open.levels.get(depth)?;
|
||||||
|
let shown = (open.levels.get(depth + 1)).map(|level| level.panel);
|
||||||
|
Some((from, (level.at, level.column), shown))
|
||||||
|
});
|
||||||
|
let Some((from, marked, shown)) = seen else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if marked == (Some(cell.0), cell.1) {
|
||||||
|
if !sub || shown.is_some() {
|
||||||
|
state.update(cx, |open, _| open.pending = None);
|
||||||
|
} else if state.read(cx).pending.is_none() {
|
||||||
|
later(state, menu, (depth, cell), OPEN, cx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let aimed = from
|
||||||
|
.zip(shown.filter(|panel| *panel != Bounds::default()))
|
||||||
|
.is_some_and(|(from, panel)| toward(from, at, panel));
|
||||||
|
if aimed {
|
||||||
|
log::debug!("menu: row {} of level {depth} waits on the aim", cell.0);
|
||||||
|
return later(state, menu, (depth, cell), HOLD, cx);
|
||||||
|
}
|
||||||
|
state.update(cx, |open, _| open.pending = None);
|
||||||
|
mark(state, menu, depth, cell, false, cx);
|
||||||
|
if sub {
|
||||||
|
later(state, menu, (depth, cell), OPEN, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer left a row: its delayed mark is dropped.
|
||||||
|
pub(super) fn leave(state: &Entity<Open>, over: (usize, (usize, usize)), cx: &mut App) {
|
||||||
|
state.update(cx, |open, _| {
|
||||||
|
if open.over == Some(over) {
|
||||||
|
(open.over, open.pending) = (None, None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the row after `wait` if still under the pointer.
|
||||||
|
fn later(
|
||||||
|
state: &Entity<Open>,
|
||||||
|
menu: &Menu,
|
||||||
|
(depth, cell): (usize, (usize, usize)),
|
||||||
|
wait: Duration,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let (state_, menu) = (state.clone(), menu.clone());
|
||||||
|
let task = cx.spawn(async move |cx| {
|
||||||
|
cx.background_executor().timer(wait).await;
|
||||||
|
cx.update(|cx| {
|
||||||
|
if state_.read(cx).over != Some((depth, cell)) {
|
||||||
|
return log::debug!("menu: the pointer left row {} before it opened", cell.0);
|
||||||
|
}
|
||||||
|
state_.update(cx, |open, _| open.pending = None);
|
||||||
|
mark(&state_, &menu, depth, cell, true, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
state.update(cx, |open, _| open.pending = Some(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `to` lies between `from` and `panel`'s near edge.
|
||||||
|
pub(super) fn toward(from: Point<Pixels>, to: Point<Pixels>, panel: Bounds<Pixels>) -> bool {
|
||||||
|
let near = if panel.left() >= from.x {
|
||||||
|
panel.left()
|
||||||
|
} else {
|
||||||
|
panel.right()
|
||||||
|
};
|
||||||
|
let (top, bottom) = (
|
||||||
|
Point::new(near, panel.top()),
|
||||||
|
Point::new(near, panel.bottom()),
|
||||||
|
);
|
||||||
|
let side = |a: Point<Pixels>, b: Point<Pixels>| {
|
||||||
|
f32::from(b.x - a.x) * f32::from(to.y - a.y) - f32::from(b.y - a.y) * f32::from(to.x - a.x)
|
||||||
|
};
|
||||||
|
let sides = [side(from, top), side(top, bottom), side(bottom, from)];
|
||||||
|
sides.iter().all(|side| *side >= 0.0) || sides.iter().all(|side| *side <= 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui::{Bounds, point, px, size};
|
||||||
|
|
||||||
|
use super::toward;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_aim_holds_toward_the_panel_on_either_side() {
|
||||||
|
let right = Bounds::new(point(px(200.), px(0.)), size(px(100.), px(200.)));
|
||||||
|
let from = point(px(100.), px(100.));
|
||||||
|
assert!(toward(from, point(px(110.), px(98.)), right));
|
||||||
|
assert!(
|
||||||
|
!toward(from, point(px(100.), px(130.)), right),
|
||||||
|
"straight down"
|
||||||
|
);
|
||||||
|
let left = Bounds::new(point(px(0.), px(0.)), size(px(50.), px(200.)));
|
||||||
|
assert!(toward(from, point(px(90.), px(102.)), left));
|
||||||
|
assert!(
|
||||||
|
!toward(from, point(px(110.), px(100.)), left),
|
||||||
|
"away from it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
-13
@@ -1,16 +1,21 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
||||||
MouseButton, ParentElement, Stateful, Styled, div, prelude::*,
|
MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action,
|
||||||
|
canvas, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
menu::{Open, at_depth, choose, mark, measure},
|
aim,
|
||||||
|
menu::{Open, at_depth, choose},
|
||||||
model::{Entry, Kind, Menu, MenuItem},
|
model::{Entry, Kind, Menu, MenuItem},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Run, surface},
|
forms::{Run, surface},
|
||||||
|
i18n,
|
||||||
navigation::marked,
|
navigation::marked,
|
||||||
primitives::{Icon, IconName},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||||
typography::KbdCombo,
|
typography::KbdCombo,
|
||||||
};
|
};
|
||||||
@@ -82,6 +87,71 @@ fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Button `column` of row `ix`, named by its label.
|
||||||
|
pub(super) fn button(
|
||||||
|
item: &MenuItem,
|
||||||
|
(ix, column): (usize, usize),
|
||||||
|
current: bool,
|
||||||
|
press: Run,
|
||||||
|
cx: &App,
|
||||||
|
) -> Stateful<Div> {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let fg = match (item.disabled, current) {
|
||||||
|
(true, _) => colors.fg_disabled,
|
||||||
|
(false, true) => colors.fg,
|
||||||
|
(false, false) => colors.fg_muted,
|
||||||
|
};
|
||||||
|
let (label, keys) = (item.label.clone(), item.keys.clone());
|
||||||
|
let icon = item.icon.expect("a menu button has its icon");
|
||||||
|
div()
|
||||||
|
.id((SharedString::from(format!("button-{ix}")), column))
|
||||||
|
.debug_selector(move || format!("menu-button-{ix}-{column}"))
|
||||||
|
.role(Role::Button)
|
||||||
|
.aria_label(item.label.clone())
|
||||||
|
.flex_1()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.h(theme.control_height(ControlSize::Md))
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.aria_selected(current)
|
||||||
|
.when(current, |button| button.aria_active_descendant())
|
||||||
|
.when(current && !item.disabled, |button| button.bg(colors.hover))
|
||||||
|
.when(item.disabled, |button| {
|
||||||
|
button.aria_description(i18n::text(cx, "state.unavailable", &[]))
|
||||||
|
})
|
||||||
|
.when(!item.disabled, |button| {
|
||||||
|
let read = press.clone();
|
||||||
|
button
|
||||||
|
.cursor_pointer()
|
||||||
|
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
press(window, cx);
|
||||||
|
})
|
||||||
|
.on_a11y_action(Action::Click, move |_, window, cx| read(window, cx))
|
||||||
|
})
|
||||||
|
.child(Icon::new(icon).size(IconSize::Sm).color(fg))
|
||||||
|
.tooltip(Tooltip::rich(move |_, cx| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(label.clone())
|
||||||
|
.when_some(keys.clone(), |tip, keys| {
|
||||||
|
tip.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
|
||||||
|
.child(KbdCombo::new(&keys)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn panel(
|
pub(super) fn panel(
|
||||||
id: &ElementId,
|
id: &ElementId,
|
||||||
menu: &Menu,
|
menu: &Menu,
|
||||||
@@ -111,22 +181,58 @@ pub(super) fn panel(
|
|||||||
.text_color(colors.fg_subtle)
|
.text_color(colors.fg_subtle)
|
||||||
.child(title.clone())
|
.child(title.clone())
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
|
Entry::Buttons(buttons) => div()
|
||||||
|
.flex()
|
||||||
|
.gap_1()
|
||||||
|
.py_0p5()
|
||||||
|
.children(buttons.iter().enumerate().map(|(column, item)| {
|
||||||
|
let current = at == Some(ix) && open.levels[depth].column == column;
|
||||||
|
let (hover, click, close, menu) =
|
||||||
|
(state.clone(), state.clone(), close.clone(), menu.clone());
|
||||||
|
let hover_menu = menu.clone();
|
||||||
|
let press: Run = Rc::new(move |window, cx| {
|
||||||
|
choose(&click, &menu, depth, (ix, column), &close, window, cx)
|
||||||
|
});
|
||||||
|
let drawn = button(item, (ix, column), current, press, cx);
|
||||||
|
let left = state.clone();
|
||||||
|
let here = (depth, (ix, column));
|
||||||
|
drawn.when(!item.disabled, |button| {
|
||||||
|
button
|
||||||
|
.on_mouse_move(move |event, _, cx| {
|
||||||
|
aim::hover(&hover, &hover_menu, here, false, event.position, cx)
|
||||||
|
})
|
||||||
|
.on_hover(move |hovered, _, cx| {
|
||||||
|
if !hovered {
|
||||||
|
aim::leave(&left, here, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
.into_any_element(),
|
||||||
Entry::Item(item) => {
|
Entry::Item(item) => {
|
||||||
let marked = at == Some(ix);
|
let marked = at == Some(ix);
|
||||||
let sub = matches!(item.kind, Kind::Sub(_));
|
let sub = matches!(item.kind, Kind::Sub(_));
|
||||||
let (hover, click, close, menu) =
|
let (hover, click, close, menu) =
|
||||||
(state.clone(), state.clone(), close.clone(), menu.clone());
|
(state.clone(), state.clone(), close.clone(), menu.clone());
|
||||||
let hover_menu = menu.clone();
|
let hover_menu = menu.clone();
|
||||||
|
let label = item.label.clone();
|
||||||
row(item, ix, marked, cx)
|
row(item, ix, marked, cx)
|
||||||
|
.debug_selector(move || format!("menu-row-{label}"))
|
||||||
.when(!item.disabled, |row| {
|
.when(!item.disabled, |row| {
|
||||||
row.on_mouse_move(move |_, _, cx| {
|
let left = state.clone();
|
||||||
let open = hover.read(cx);
|
row.on_mouse_move(move |event, _, cx| {
|
||||||
let Some(level) = open.levels.get(depth) else {
|
aim::hover(
|
||||||
return;
|
&hover,
|
||||||
};
|
&hover_menu,
|
||||||
let shown = open.levels.len() > depth + 1;
|
(depth, (ix, 0)),
|
||||||
if level.at != Some(ix) || (sub && !shown) {
|
sub,
|
||||||
mark(&hover, &hover_menu, depth, ix, true, cx);
|
event.position,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_hover(move |hovered, _, cx| {
|
||||||
|
if !hovered {
|
||||||
|
aim::leave(&left, (depth, (ix, 0)), cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_mouse_down(
|
.on_mouse_down(
|
||||||
@@ -134,7 +240,7 @@ pub(super) fn panel(
|
|||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
window.prevent_default();
|
window.prevent_default();
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
choose(&click, &menu, depth, ix, &close, window, cx);
|
choose(&click, &menu, depth, (ix, 0), &close, window, cx);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -150,7 +256,16 @@ pub(super) fn panel(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
surface((id.clone(), format!("panel-{depth}")), cx)
|
surface((id.clone(), format!("panel-{depth}")), cx)
|
||||||
|
.role(Role::Menu)
|
||||||
|
.debug_selector(move || format!("menu-panel-{depth}"))
|
||||||
.relative()
|
.relative()
|
||||||
|
// Rows under this panel take no hover.
|
||||||
|
.occlude()
|
||||||
|
// Presses here are the menu's, not its host's.
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
})
|
||||||
.min_w(theme.menu_width())
|
.min_w(theme.menu_width())
|
||||||
.p_1()
|
.p_1()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -164,3 +279,23 @@ pub(super) fn panel(
|
|||||||
changed
|
changed
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the host's, a marked row's or a panel's box.
|
||||||
|
pub(super) fn measure(
|
||||||
|
state: Entity<Open>,
|
||||||
|
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
canvas(
|
||||||
|
move |bounds, window, cx| {
|
||||||
|
if state.update(cx, |open, _| write(open, bounds)) {
|
||||||
|
state.update(cx, |_, cx| cx.notify());
|
||||||
|
window.request_animation_frame();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_, _, _, _| {},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.left_0()
|
||||||
|
.size_full()
|
||||||
|
}
|
||||||
+144
-76
@@ -1,16 +1,17 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
|
Anchor, Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity,
|
||||||
IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, div, prelude::*,
|
InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task,
|
||||||
|
Window, anchored, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
draw,
|
draw::{self, measure},
|
||||||
model::{Kind, Menu},
|
model::{Kind, Menu},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Down, Enter, Run, TextInput, Up, float},
|
forms::{Down, Enter, Left, Right, Run, TextInput, Up, float},
|
||||||
motion,
|
motion,
|
||||||
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
|
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
|
||||||
theme::{ActiveTheme, IconSize},
|
theme::{ActiveTheme, IconSize},
|
||||||
@@ -21,7 +22,7 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
|
|||||||
levels.get(..depth)?.iter().try_fold(menu, |menu, level| {
|
levels.get(..depth)?.iter().try_fold(menu, |menu, level| {
|
||||||
match level
|
match level
|
||||||
.at
|
.at
|
||||||
.and_then(|ix| menu.item_at(ix))
|
.and_then(|ix| menu.item_at(ix, level.column))
|
||||||
.map(|item| &item.kind)
|
.map(|item| &item.kind)
|
||||||
{
|
{
|
||||||
Some(Kind::Sub(sub)) => Some(sub),
|
Some(Kind::Sub(sub)) => Some(sub),
|
||||||
@@ -34,11 +35,17 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
|
|||||||
fn settle(menu: &Menu, levels: &mut Vec<Level>) {
|
fn settle(menu: &Menu, levels: &mut Vec<Level>) {
|
||||||
let mut here = menu;
|
let mut here = menu;
|
||||||
for depth in 0..levels.len() {
|
for depth in 0..levels.len() {
|
||||||
let at = levels[depth]
|
let (column, name) = (levels[depth].column, levels[depth].name.clone());
|
||||||
.at
|
// A mark holds its item, not just its place.
|
||||||
.filter(|&ix| here.item_at(ix).is_some_and(|item| !item.disabled));
|
let at = levels[depth].at.filter(|&ix| {
|
||||||
|
here.item_at(ix, column)
|
||||||
|
.is_some_and(|item| !item.disabled && item.label == name)
|
||||||
|
});
|
||||||
levels[depth].at = at;
|
levels[depth].at = at;
|
||||||
match at.and_then(|ix| here.item_at(ix)).map(|item| &item.kind) {
|
match at
|
||||||
|
.and_then(|ix| here.item_at(ix, column))
|
||||||
|
.map(|item| &item.kind)
|
||||||
|
{
|
||||||
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
|
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
|
||||||
_ => {
|
_ => {
|
||||||
levels.truncate(depth + 1);
|
levels.truncate(depth + 1);
|
||||||
@@ -48,10 +55,12 @@ fn settle(menu: &Menu, levels: &mut Vec<Level>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One open level: its marked row, that row's box and its panel's box.
|
/// An open level: marked row, button, its label, and boxes.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub(super) struct Level {
|
pub(super) struct Level {
|
||||||
pub(super) at: Option<usize>,
|
pub(super) at: Option<usize>,
|
||||||
|
pub(super) column: usize,
|
||||||
|
pub(super) name: SharedString,
|
||||||
pub(super) row: Bounds<Pixels>,
|
pub(super) row: Bounds<Pixels>,
|
||||||
pub(super) panel: Bounds<Pixels>,
|
pub(super) panel: Bounds<Pixels>,
|
||||||
}
|
}
|
||||||
@@ -71,6 +80,12 @@ pub(crate) struct Open {
|
|||||||
host: Bounds<Pixels>,
|
host: Bounds<Pixels>,
|
||||||
anchor: Bounds<Pixels>,
|
anchor: Bounds<Pixels>,
|
||||||
takeover: Option<Entity<Takeover>>,
|
takeover: Option<Entity<Takeover>>,
|
||||||
|
/// The pointer's last place over a row.
|
||||||
|
pub(super) pointer: Option<Point<Pixels>>,
|
||||||
|
/// The row under the pointer: depth, index and column.
|
||||||
|
pub(super) over: Option<(usize, (usize, usize))>,
|
||||||
|
/// A delayed mark; dropping it cancels.
|
||||||
|
pub(super) pending: Option<Task<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Open {
|
impl Open {
|
||||||
@@ -90,25 +105,20 @@ impl Open {
|
|||||||
|
|
||||||
/// Opens at `spot`, with the first row marked when `marked`.
|
/// Opens at `spot`, with the first row marked when `marked`.
|
||||||
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
||||||
let first = menu.step(None, 1).filter(|_| marked);
|
state.update(cx, |open, _| open.spot = Some(spot));
|
||||||
state.update(cx, |open, cx| {
|
Self::begin(state, menu, menu.step(None, 1).filter(|_| marked), cx);
|
||||||
open.spot = Some(spot);
|
|
||||||
open.levels = vec![Level {
|
|
||||||
at: first,
|
|
||||||
..Level::default()
|
|
||||||
}];
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts over on new rows with the first marked; the menu stays where it hangs.
|
/// Starts over on new rows with the first marked; the menu stays where it hangs.
|
||||||
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
|
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
|
||||||
let first = menu.step(None, 1);
|
Self::begin(state, menu, menu.step(None, 1), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh levels marking `first`; the old hover is dropped.
|
||||||
|
fn begin(state: &Entity<Open>, menu: &Menu, first: Option<usize>, cx: &mut App) {
|
||||||
state.update(cx, |open, cx| {
|
state.update(cx, |open, cx| {
|
||||||
open.levels = vec![Level {
|
open.levels = vec![first_of(menu, first)];
|
||||||
at: first,
|
(open.pointer, open.over, open.pending) = (None, None, None);
|
||||||
..Level::default()
|
|
||||||
}];
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,6 +128,7 @@ impl Open {
|
|||||||
let takeover = state.update(cx, |open, cx| {
|
let takeover = state.update(cx, |open, cx| {
|
||||||
open.spot = None;
|
open.spot = None;
|
||||||
open.levels.clear();
|
open.levels.clear();
|
||||||
|
(open.pointer, open.over, open.pending) = (None, None, None);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
open.takeover.take()
|
open.takeover.take()
|
||||||
});
|
});
|
||||||
@@ -127,25 +138,49 @@ impl Open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`.
|
/// A level marking row `at` at its first enabled button.
|
||||||
|
fn first_of(menu: &Menu, at: Option<usize>) -> Level {
|
||||||
|
let Some(ix) = at else {
|
||||||
|
return Level::default();
|
||||||
|
};
|
||||||
|
let column = menu.first(ix).expect("a stepped row runs");
|
||||||
|
Level {
|
||||||
|
at,
|
||||||
|
column,
|
||||||
|
name: menu
|
||||||
|
.item_at(ix, column)
|
||||||
|
.expect("its first spot")
|
||||||
|
.label
|
||||||
|
.clone(),
|
||||||
|
..Level::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a spot at `depth`; `open_sub` opens its submenu.
|
||||||
pub(super) fn mark(
|
pub(super) fn mark(
|
||||||
state: &Entity<Open>,
|
state: &Entity<Open>,
|
||||||
menu: &Menu,
|
menu: &Menu,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
at: usize,
|
(at, column): (usize, usize),
|
||||||
open_sub: bool,
|
open_sub: bool,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
state.update(cx, |open, cx| {
|
state.update(cx, |open, cx| {
|
||||||
let Some(here) = at_depth(menu, &open.levels, depth) else {
|
if open.levels.len() <= depth {
|
||||||
|
return log::debug!("menu: level {depth} closed before its mark");
|
||||||
|
}
|
||||||
|
let Some(item) =
|
||||||
|
at_depth(menu, &open.levels, depth).and_then(|here| here.item_at(at, column))
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let sub = matches!(here.item_at(at).map(|item| &item.kind), Some(Kind::Sub(_)));
|
let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
|
||||||
open.levels.truncate(depth + 1);
|
open.levels.truncate(depth + 1);
|
||||||
if open.levels[depth].at != Some(at) {
|
let level = &mut open.levels[depth];
|
||||||
open.levels[depth].at = Some(at);
|
if level.at != Some(at) {
|
||||||
open.levels[depth].row = Bounds::default();
|
level.row = Bounds::default();
|
||||||
}
|
}
|
||||||
|
(level.at, level.column, level.name) = (Some(at), column, name);
|
||||||
if open_sub && sub {
|
if open_sub && sub {
|
||||||
open.levels.push(Level::default());
|
open.levels.push(Level::default());
|
||||||
}
|
}
|
||||||
@@ -153,12 +188,12 @@ pub(super) fn mark(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a row. A submenu opens with its first row marked; anything else closes the menu first.
|
/// Runs a spot; a submenu opens, others close first.
|
||||||
pub(super) fn choose(
|
pub(super) fn choose(
|
||||||
state: &Entity<Open>,
|
state: &Entity<Open>,
|
||||||
menu: &Menu,
|
menu: &Menu,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
at: usize,
|
(at, column): (usize, usize),
|
||||||
close: &Run,
|
close: &Run,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
@@ -166,23 +201,23 @@ pub(super) fn choose(
|
|||||||
let levels = &state.read(cx).levels;
|
let levels = &state.read(cx).levels;
|
||||||
let Some(item) = at_depth(menu, levels, depth)
|
let Some(item) = at_depth(menu, levels, depth)
|
||||||
.filter(|_| levels.len() > depth)
|
.filter(|_| levels.len() > depth)
|
||||||
.and_then(|here| here.item_at(at))
|
.and_then(|here| here.item_at(at, column))
|
||||||
.cloned()
|
.cloned()
|
||||||
else {
|
else {
|
||||||
log::debug!("menu: a press from an older frame found row {at} of level {depth} gone");
|
log::debug!(
|
||||||
|
"menu: a press from an older frame found row {at}:{column} of level {depth} gone"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if item.disabled {
|
if item.disabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
state.update(cx, |open, _| open.pending = None);
|
||||||
if let Kind::Sub(sub) = &item.kind {
|
if let Kind::Sub(sub) = &item.kind {
|
||||||
let first = sub.step(None, 1);
|
let first = first_of(sub, sub.step(None, 1));
|
||||||
mark(state, menu, depth, at, false, cx);
|
mark(state, menu, depth, (at, column), false, cx);
|
||||||
state.update(cx, |open, cx| {
|
state.update(cx, |open, cx| {
|
||||||
open.levels.push(Level {
|
open.levels.push(first);
|
||||||
at: first,
|
|
||||||
..Level::default()
|
|
||||||
});
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -194,7 +229,7 @@ pub(super) fn choose(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrows walk the deepest level; Right opens a submenu; Enter and Space choose; Left and Escape back out.
|
/// Arrows, Enter, Space and Escape drive the deepest level.
|
||||||
fn keys(
|
fn keys(
|
||||||
menu: &Menu,
|
menu: &Menu,
|
||||||
state: &Entity<Open>,
|
state: &Entity<Open>,
|
||||||
@@ -210,19 +245,28 @@ fn keys(
|
|||||||
let Some(here) = at_depth(menu, &levels, depth) else {
|
let Some(here) = at_depth(menu, &levels, depth) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let at = levels[depth].at;
|
state.update(cx, |open, _| open.pending = None);
|
||||||
|
let (at, column) = (levels[depth].at, levels[depth].column);
|
||||||
let sub = at
|
let sub = at
|
||||||
.and_then(|at| here.item_at(at))
|
.and_then(|at| here.item_at(at, column))
|
||||||
.is_some_and(|item| matches!(item.kind, Kind::Sub(_)));
|
.is_some_and(|item| matches!(item.kind, Kind::Sub(_)));
|
||||||
|
let across = |by| at.and_then(|at| here.across(at, column, by).map(|to| (at, to)));
|
||||||
match (key, at) {
|
match (key, at) {
|
||||||
("down" | "up", _) => {
|
("down" | "up", _) => {
|
||||||
let by = if key == "down" { 1 } else { -1 };
|
let by = if key == "down" { 1 } else { -1 };
|
||||||
if let Some(to) = here.step(at, by) {
|
if let Some(to) = here.step(at, by) {
|
||||||
mark(state, menu, depth, to, false, cx);
|
let column = here.first(to).expect("a stepped row runs");
|
||||||
|
mark(state, menu, depth, (to, column), false, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
("right", Some(at)) if sub => choose(state, menu, depth, at, close, window, cx),
|
("left" | "right", Some(_)) if across(1).is_some() => {
|
||||||
("enter" | "space", Some(at)) => choose(state, menu, depth, at, close, window, cx),
|
let to = across(if key == "right" { 1 } else { -1 }).expect("a row with a button");
|
||||||
|
mark(state, menu, depth, to, false, cx);
|
||||||
|
}
|
||||||
|
("right", Some(at)) if sub => choose(state, menu, depth, (at, column), close, window, cx),
|
||||||
|
("enter" | "space", Some(at)) => {
|
||||||
|
choose(state, menu, depth, (at, column), close, window, cx)
|
||||||
|
}
|
||||||
("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| {
|
("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| {
|
||||||
open.levels.pop();
|
open.levels.pop();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -233,24 +277,15 @@ fn keys(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
|
/// Whether the deepest level marks a button row.
|
||||||
pub(super) fn measure(
|
fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
|
||||||
state: Entity<Open>,
|
let levels = &state.read(cx).levels;
|
||||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
let Some(depth) = levels.len().checked_sub(1) else {
|
||||||
) -> impl IntoElement {
|
return false;
|
||||||
canvas(
|
};
|
||||||
move |bounds, window, cx| {
|
let here = at_depth(menu, levels, depth);
|
||||||
if state.update(cx, |open, _| write(open, bounds)) {
|
here.zip(levels[depth].at)
|
||||||
state.update(cx, |_, cx| cx.notify());
|
.is_some_and(|(here, ix)| here.is_buttons(ix))
|
||||||
window.request_animation_frame();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.size_full()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
|
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
|
||||||
@@ -333,6 +368,8 @@ pub(crate) fn hang(
|
|||||||
});
|
});
|
||||||
let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone());
|
let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone());
|
||||||
let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone());
|
let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone());
|
||||||
|
let (left_menu, left_state, left_close) = (menu.clone(), state.clone(), close.clone());
|
||||||
|
let (right_menu, right_state, right_close) = (menu.clone(), state.clone(), close.clone());
|
||||||
let root = draw::panel(id, menu, state, 0, &close, head, cx)
|
let root = draw::panel(id, menu, state, 0, &close, head, cx)
|
||||||
.track_focus(&focus)
|
.track_focus(&focus)
|
||||||
.when(!spaced, |root| {
|
.when(!spaced, |root| {
|
||||||
@@ -345,6 +382,19 @@ pub(crate) fn hang(
|
|||||||
keys(&down_menu, &down_state, "down", &down_close, window, cx);
|
keys(&down_menu, &down_state, "down", &down_close, window, cx);
|
||||||
})
|
})
|
||||||
.capture_action(|_: &Enter, _, cx| cx.stop_propagation())
|
.capture_action(|_: &Enter, _, cx| cx.stop_propagation())
|
||||||
|
// A button row takes the arrows from the filter.
|
||||||
|
.capture_action(move |_: &Left, window, cx| {
|
||||||
|
if on_buttons(&left_menu, &left_state, cx) {
|
||||||
|
cx.stop_propagation();
|
||||||
|
keys(&left_menu, &left_state, "left", &left_close, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.capture_action(move |_: &Right, window, cx| {
|
||||||
|
if on_buttons(&right_menu, &right_state, cx) {
|
||||||
|
cx.stop_propagation();
|
||||||
|
keys(&right_menu, &right_state, "right", &right_close, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.on_key_down(move |event, window, cx| {
|
.on_key_down(move |event, window, cx| {
|
||||||
let stroke = &event.keystroke;
|
let stroke = &event.keystroke;
|
||||||
@@ -404,20 +454,38 @@ 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()
|
||||||
draw::panel(id, menu, state, level, &close, None, cx)
|
.anchor(corner)
|
||||||
.mt_neg_1()
|
.position(from)
|
||||||
.with_animation(
|
.snap_to_window()
|
||||||
(id.clone(), format!("sub-{level}")),
|
.child(
|
||||||
enter.clone(),
|
draw::panel(id, menu, state, level, &close, None, cx)
|
||||||
|panel, t| panel.opacity(t).ml(motion::NUDGE * (t - 1.0)),
|
.mt_neg_1()
|
||||||
),
|
.with_animation(
|
||||||
),
|
(id.clone(), format!("sub-{level}")),
|
||||||
|
enter.clone(),
|
||||||
|
move |panel, t| {
|
||||||
|
panel.opacity(t).left(motion::NUDGE * (t - 1.0) * slide)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.with_priority(2)
|
.with_priority(2)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod aim;
|
||||||
mod bar;
|
mod bar;
|
||||||
mod draw;
|
mod draw;
|
||||||
mod hosts;
|
mod hosts;
|
||||||
|
|||||||
+59
-7
@@ -82,6 +82,8 @@ impl MenuItem {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(super) enum Entry {
|
pub(super) enum Entry {
|
||||||
Item(MenuItem),
|
Item(MenuItem),
|
||||||
|
/// Icon buttons side by side; labels name them.
|
||||||
|
Buttons(Vec<MenuItem>),
|
||||||
Separator,
|
Separator,
|
||||||
Heading(SharedString),
|
Heading(SharedString),
|
||||||
}
|
}
|
||||||
@@ -121,20 +123,66 @@ impl Menu {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn item_at(&self, ix: usize) -> Option<&MenuItem> {
|
/// A row of icon buttons; labels name them.
|
||||||
|
pub fn buttons(mut self, buttons: impl IntoIterator<Item = MenuItem>) -> Self {
|
||||||
|
let buttons: Vec<MenuItem> = buttons.into_iter().collect();
|
||||||
|
assert!(!buttons.is_empty(), "a button row holds buttons");
|
||||||
|
for button in &buttons {
|
||||||
|
assert!(
|
||||||
|
button.icon.is_some() && matches!(button.kind, Kind::Run),
|
||||||
|
"menu button {:?} needs an icon and runs",
|
||||||
|
button.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.entries.push(Entry::Buttons(buttons));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row `ix`, or button `column` of its button row.
|
||||||
|
pub(super) fn item_at(&self, ix: usize, column: usize) -> Option<&MenuItem> {
|
||||||
match self.entries.get(ix) {
|
match self.entries.get(ix) {
|
||||||
Some(Entry::Item(item)) => Some(item),
|
Some(Entry::Item(item)) if column == 0 => Some(item),
|
||||||
|
Some(Entry::Buttons(buttons)) => buttons.get(column),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row `ix`'s first spot that runs.
|
||||||
|
pub(super) fn first(&self, ix: usize) -> Option<usize> {
|
||||||
|
match self.entries.get(ix) {
|
||||||
|
Some(Entry::Item(item)) => (!item.disabled).then_some(0),
|
||||||
|
Some(Entry::Buttons(buttons)) => buttons.iter().position(|button| !button.disabled),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_buttons(&self, ix: usize) -> bool {
|
||||||
|
matches!(self.entries.get(ix), Some(Entry::Buttons(_)))
|
||||||
|
}
|
||||||
|
|
||||||
/// The next enabled row from `from`, stepping by `by` and wrapping.
|
/// The next enabled row from `from`, stepping by `by` and wrapping.
|
||||||
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
|
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
|
||||||
let count = self.entries.len() as isize;
|
let count = self.entries.len() as isize;
|
||||||
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
|
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
at = (at + by).rem_euclid(count);
|
at = (at + by).rem_euclid(count);
|
||||||
if self.item_at(at as usize).is_some_and(|item| !item.disabled) {
|
if self.first(at as usize).is_some() {
|
||||||
|
return Some(at as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row `ix`'s next enabled button after `column`, wrapping.
|
||||||
|
pub(super) fn across(&self, ix: usize, column: usize, by: isize) -> Option<usize> {
|
||||||
|
let Some(Entry::Buttons(buttons)) = self.entries.get(ix) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let count = buttons.len() as isize;
|
||||||
|
let mut at = column as isize;
|
||||||
|
for _ in 0..count {
|
||||||
|
at = (at + by).rem_euclid(count);
|
||||||
|
if !buttons[at as usize].disabled {
|
||||||
return Some(at as usize);
|
return Some(at as usize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,15 +197,19 @@ impl Menu {
|
|||||||
let mut found: Vec<(i32, MenuItem)> = self
|
let mut found: Vec<(i32, MenuItem)> = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|entry| match entry {
|
.flat_map(|entry| match entry {
|
||||||
Entry::Item(item) => fuzzy(query, &item.label).map(|fit| {
|
Entry::Item(item) => std::slice::from_ref(item),
|
||||||
|
Entry::Buttons(buttons) => buttons.as_slice(),
|
||||||
|
_ => &[],
|
||||||
|
})
|
||||||
|
.filter_map(|item| {
|
||||||
|
fuzzy(query, &item.label).map(|fit| {
|
||||||
let item = MenuItem {
|
let item = MenuItem {
|
||||||
hits: fit.hits,
|
hits: fit.hits,
|
||||||
..item.clone()
|
..item.clone()
|
||||||
};
|
};
|
||||||
(fit.score, item)
|
(fit.score, item)
|
||||||
}),
|
})
|
||||||
_ => None,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
use gpui::{
|
||||||
|
App, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Render,
|
||||||
|
SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{press, settle};
|
||||||
|
use crate::{
|
||||||
|
menus::{ContextMenu, Menu, MenuItem, SearchableMenu},
|
||||||
|
primitives::{FocusScope, IconName},
|
||||||
|
theme::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A context menu headed by clipboard buttons.
|
||||||
|
struct Clip {
|
||||||
|
root: FocusHandle,
|
||||||
|
ran: Vec<SharedString>,
|
||||||
|
paste_off: bool,
|
||||||
|
gone: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clip {
|
||||||
|
fn menu(&self, cx: &mut Context<Self>) -> Menu {
|
||||||
|
let run = |name: &'static str| {
|
||||||
|
let view = cx.entity();
|
||||||
|
move |_: &mut Window, cx: &mut App| {
|
||||||
|
view.update(cx, |clip, _| clip.ran.push(name.into()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Menu::new()
|
||||||
|
.buttons(
|
||||||
|
[
|
||||||
|
MenuItem::new("Cut")
|
||||||
|
.icon(IconName::Scissors)
|
||||||
|
.on_click(run("cut")),
|
||||||
|
MenuItem::new("Copy")
|
||||||
|
.icon(IconName::Copy)
|
||||||
|
.disabled(true)
|
||||||
|
.on_click(run("copy")),
|
||||||
|
MenuItem::new("Paste")
|
||||||
|
.icon(IconName::Clipboard)
|
||||||
|
.keys("secondary-v")
|
||||||
|
.disabled(self.paste_off)
|
||||||
|
.on_click(run("paste")),
|
||||||
|
MenuItem::new("Delete")
|
||||||
|
.icon(IconName::Trash2)
|
||||||
|
.on_click(run("delete")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| Some(item.label.as_ref()) != self.gone),
|
||||||
|
)
|
||||||
|
.item(MenuItem::new("Select all").on_click(run("select all")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Clip {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let menu = self.menu(cx);
|
||||||
|
FocusScope::new(&self.root)
|
||||||
|
.size_full()
|
||||||
|
.child(SearchableMenu::new("find", "Find", menu.clone()))
|
||||||
|
.child(ContextMenu::new("area", menu).child(div().w(px(300.0)).h(px(200.0))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip(cx: &mut TestAppContext) -> (gpui::Entity<Clip>, &mut VisualTestContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
crate::forms::bind_keys(cx);
|
||||||
|
});
|
||||||
|
let (view, cx) = cx.add_window_view(|_, cx| Clip {
|
||||||
|
root: cx.focus_handle(),
|
||||||
|
ran: Vec::new(),
|
||||||
|
paste_off: false,
|
||||||
|
gone: None,
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
(view, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ran(view: &gpui::Entity<Clip>, cx: &mut VisualTestContext) -> Vec<SharedString> {
|
||||||
|
view.read_with(cx, |clip, _| clip.ran.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn right_click(cx: &mut VisualTestContext) {
|
||||||
|
let at = point(px(60.0), px(120.0));
|
||||||
|
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||||
|
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||||
|
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||||
|
settle(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_button(selector: &'static str, cx: &mut VisualTestContext) {
|
||||||
|
let at = cx.debug_bounds(selector).expect("the button").center();
|
||||||
|
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||||
|
cx.simulate_click(at, Modifiers::none());
|
||||||
|
settle(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn arrows_walk_a_button_row_past_its_disabled_button(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
right_click(cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["cut"], "down marks the row's first button");
|
||||||
|
right_click(cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("right", cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["cut", "paste"], "right skips copy");
|
||||||
|
right_click(cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("left", cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["cut", "paste", "delete"], "left wraps");
|
||||||
|
right_click(cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(
|
||||||
|
ran(&view, cx).last().map(|name| name.as_ref()),
|
||||||
|
Some("select all")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_pressed_button_runs_and_a_disabled_one_does_not(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
right_click(cx);
|
||||||
|
press_button("menu-button-0-1", cx);
|
||||||
|
assert!(ran(&view, cx).is_empty(), "copy is off");
|
||||||
|
press_button("menu-button-0-2", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["paste"]);
|
||||||
|
assert!(
|
||||||
|
cx.debug_bounds("menu-button-0-0").is_none(),
|
||||||
|
"the menu closed after paste"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_move_after_a_button_closed_the_menu_does_nothing(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
right_click(cx);
|
||||||
|
let at = cx.debug_bounds("menu-button-0-2").expect("paste").center();
|
||||||
|
// Both events land before any repaint.
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
let down = gpui::MouseDownEvent {
|
||||||
|
position: at,
|
||||||
|
button: MouseButton::Left,
|
||||||
|
modifiers: Modifiers::none(),
|
||||||
|
click_count: 1,
|
||||||
|
first_mouse: false,
|
||||||
|
};
|
||||||
|
let moved = gpui::MouseMoveEvent {
|
||||||
|
position: at + point(px(1.0), px(0.0)),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::none(),
|
||||||
|
};
|
||||||
|
window.dispatch_event(gpui::PlatformInput::MouseDown(down), cx);
|
||||||
|
window.dispatch_event(gpui::PlatformInput::MouseMove(moved), cx);
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["paste"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
cx.update(|window, cx| window.focus_next(cx));
|
||||||
|
press("enter", cx);
|
||||||
|
cx.simulate_input("pas");
|
||||||
|
settle(cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["paste"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn arrows_cross_buttons_while_the_filter_holds_focus(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
cx.update(|window, cx| window.focus_next(cx));
|
||||||
|
press("enter", cx);
|
||||||
|
press("right", cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(ran(&view, cx), ["paste"], "right skips copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_marked_button_turned_off_or_gone_runs_nothing(cx: &mut TestAppContext) {
|
||||||
|
for change in [
|
||||||
|
|clip: &mut Clip| clip.paste_off = true,
|
||||||
|
|clip: &mut Clip| clip.gone = Some("Paste"),
|
||||||
|
] {
|
||||||
|
let (view, cx) = clip(cx);
|
||||||
|
right_click(cx);
|
||||||
|
press("down", cx);
|
||||||
|
press("right", cx);
|
||||||
|
view.update(cx, |clip, cx| {
|
||||||
|
change(clip);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
press("enter", cx);
|
||||||
|
assert!(
|
||||||
|
ran(&view, cx).is_empty(),
|
||||||
|
"another button ran: {:?}",
|
||||||
|
ran(&view, cx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_disabled_button_says_it_is_unavailable(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
|
||||||
|
let on = MenuItem::new("Cut").icon(IconName::Scissors);
|
||||||
|
let read =
|
||||||
|
|item: &MenuItem, cx: &mut App| node(item, false, cx).description().map(str::to_owned);
|
||||||
|
let expected = crate::i18n::text(cx, "state.unavailable", &[]).to_string();
|
||||||
|
assert_eq!(read(&off, cx), Some(expected));
|
||||||
|
assert_eq!(read(&on, cx), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accessibility node `item` draws as a menu button.
|
||||||
|
fn node(item: &MenuItem, current: bool, cx: &mut App) -> gpui::accesskit::Node {
|
||||||
|
let mut node = gpui::accesskit::Node::new(gpui::Role::Button);
|
||||||
|
let press: crate::forms::Run = std::rc::Rc::new(|_, _| {});
|
||||||
|
let drawn = super::super::draw::button(item, (0, 0), current, press, cx);
|
||||||
|
gpui::Element::write_a11y_info(&drawn, &mut node);
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_button_offers_its_press_and_says_it_is_marked(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
let cut = MenuItem::new("Cut").icon(IconName::Scissors);
|
||||||
|
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
|
||||||
|
let click = gpui::accesskit::Action::Click;
|
||||||
|
let marked = node(&cut, true, cx);
|
||||||
|
assert!(marked.supports_action(click), "a reader can press it");
|
||||||
|
assert_eq!(marked.is_selected(), Some(true));
|
||||||
|
assert_eq!(node(&cut, false, cx).is_selected(), Some(false));
|
||||||
|
assert!(!node(&off, false, cx).supports_action(click), "off");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ use gpui::{
|
|||||||
Window, div, point, prelude::*, px,
|
Window, div, point, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod aim;
|
||||||
|
mod buttons;
|
||||||
mod more;
|
mod more;
|
||||||
|
|
||||||
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};
|
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ icons! {
|
|||||||
Merge => "merge",
|
Merge => "merge",
|
||||||
MessageSquare => "message-square",
|
MessageSquare => "message-square",
|
||||||
MessageSquareDiff => "message-square-diff",
|
MessageSquareDiff => "message-square-diff",
|
||||||
|
MessageSquareWarning => "message-square-warning",
|
||||||
MicOff => "mic-off",
|
MicOff => "mic-off",
|
||||||
Mic => "mic",
|
Mic => "mic",
|
||||||
Minimize2 => "minimize-2",
|
Minimize2 => "minimize-2",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ pub use divider::Divider;
|
|||||||
pub use files::IconTheme;
|
pub use files::IconTheme;
|
||||||
pub(crate) use files::{file_icon, icon_theme};
|
pub(crate) use files::{file_icon, icon_theme};
|
||||||
pub(crate) use focus::{
|
pub(crate) use focus::{
|
||||||
CONTEXT as FOCUS_CONTEXT, Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus,
|
CONTEXT as FOCUS_CONTEXT, Takeover, give_back, hand_back, hold_focus, lent, tab_stop,
|
||||||
|
take_focus,
|
||||||
};
|
};
|
||||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||||
pub use ghost::DragGhost;
|
pub use ghost::DragGhost;
|
||||||
|
|||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user