Compare commits
55
Commits
1d0354592b
...
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. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
|
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, a trailing backslash doubled only when a pipe follows at once, an emptied edge cell of a row without that outer pipe written as one so its column stays, the caret read through the same escaping, a padding press on the open cell keeping its typing, and a table edit writing it back before it runs; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraLine truncated
|
||||||
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
|
||||||
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
|
- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame.
|
||||||
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
|
||||||
@@ -47,7 +47,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
|
|
||||||
- Colors, control and icon sizes, text sizes, radii, shadows come from `cx.theme()`. No raw `px()` literals inside components.
|
- Colors, control and icon sizes, text sizes, radii, shadows come from `cx.theme()`. No raw `px()` literals inside components.
|
||||||
- Gaps and padding use gpui's rem scale helpers (`gap_2`, `px_3`). That scale is the spacing system.
|
- Gaps and padding use gpui's rem scale helpers (`gap_2`, `px_3`). That scale is the spacing system.
|
||||||
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale.
|
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale. gpui paints a border no wider than half its box, so a line as thin as its box is a fill, `w_px` or `h_px` with `bg`, as the resize handles draw theirs.
|
||||||
- One component, one home. Duplicates point to it.
|
- One component, one home. Duplicates point to it.
|
||||||
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
||||||
- A handler a component needs is an argument of `new`, so leaving it out fails to compile. A state live data or the outside world can reach, a key gone from a list that changed or a failed load, logs an error naming the owner and draws a visible state, nothing chosen or an error line, and the app runs on. An assert on a caller's argument or on a fact true by construction stays; one that runtime data or input can trip is fixed. Chapters move to this as `tasks/panics.md` ticks them.
|
- A handler a component needs is an argument of `new`, so leaving it out fails to compile. A state live data or the outside world can reach, a key gone from a list that changed or a failed load, logs an error naming the owner and draws a visible state, nothing chosen or an error line, and the app runs on. An assert on a caller's argument or on a fact true by construction stays; one that runtime data or input can trip is fixed. Chapters move to this as `tasks/panics.md` ticks them.
|
||||||
@@ -66,6 +66,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
|
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
|
||||||
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
|
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
|
||||||
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
||||||
|
- A control inside a `drag_region` occludes it. On Windows gpui answers the system's hit test with the caption wherever a `Drag` hitbox lies under the pointer unoccluded, and Windows then keeps the press: no click reaches the control. `block_mouse_except_scroll` is not enough, since that answer reads every hitbox under the pointer. Window buttons occlude, which lets them answer as close, maximize and minimize, and so do a title bar's slots; a column header's item row occludes, staying the scroll container, and its title is a drag region of its own; the mini window drags by its title alone, so its buttons sit outside the region and its hover still shows the bar.
|
||||||
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
||||||
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
||||||
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
||||||
@@ -88,7 +89,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
|
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
|
||||||
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
|
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
|
||||||
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
|
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
|
||||||
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked.
|
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked. Panels occlude what lies under them. A submenu that does not fit on the right opens on the left of its row, sliding in from that side. A row's submenu opens once the pointer rests on it 150 ms; while a submenu is open, a row crossed inside the triangle from the last pointer place to the submenu's near corners waits 300 ms and switches only if the pointer still rests there (`menus::aim`); a row outside it marks at once. Keys and presses open at once.
|
||||||
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
|
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
|
||||||
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
|
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
|
||||||
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
|
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
|
||||||
@@ -220,13 +221,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
||||||
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
||||||
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
|
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
|
||||||
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width yields before a header's own parts; the host's header items keep one control's width and scroll when short.
|
- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width is a flex basis, not a width, so it holds its header's own parts, which a width let run past it, and yields when the shell runs short; the host's header items keep one control's width and scroll when short. A resizable column drags by its border toward the filling column, its right before it and its left after it; the handle draws that border and occludes the header under it, and a drag sets off from the wider of the width drawn and the owner's, since a header's parts may hold a column wider and a short shell squeeze it narrower; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width the owner had.
|
||||||
- Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body).
|
- Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body).
|
||||||
- A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`).
|
- A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`).
|
||||||
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency.
|
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency.
|
||||||
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
||||||
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
||||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. Copy a component draws, tooltips and default placeholders too, reads `i18n::text` (app catalog, then the builtin English and Chinese); `i18n/tests/shipped.rs` fails on a fixed English literal in a component Ely ships (labels, tooltips, button and segment text, match arms, capitalized literal arguments); a choice's value stays its key, its label the catalog's (`tables::Test::key`). A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||||
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
||||||
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
||||||
- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
||||||
@@ -275,3 +276,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
||||||
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
||||||
- 2026-10-07: gpui comes from crates.io's `gpui-pre` at the exact snapshot GPUI Kit pins, not from Zed's git. Two sources build two gpui crates whose types do not mix, so Ely and Kit could not share a window. The code is still Zed's; the publisher is not. This replaces the 2026-09-28 choice of Zed's git, and the pin moves when Kit's does.
|
- 2026-10-07: gpui comes from crates.io's `gpui-pre` at the exact snapshot GPUI Kit pins, not from Zed's git. Two sources build two gpui crates whose types do not mix, so Ely and Kit could not share a window. The code is still Zed's; the publisher is not. This replaces the 2026-09-28 choice of Zed's git, and the pin moves when Kit's does.
|
||||||
|
- 2026-10-19: `ListItem::editor` draws a field in the title's place, for a row renamed in place, as ELYTerminal's session rows rename; the title stays the row's name in the AX tree, and the row's press, selection and description stay as they were.
|
||||||
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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
-16
@@ -2,7 +2,10 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
||||||
|
|
||||||
use super::{DocumentEvent, MarkdownDocument, Open};
|
use super::{
|
||||||
|
DocumentEvent, MarkdownDocument, Open, TargetBlock,
|
||||||
|
cells::{self, Aim, Move},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::{
|
documents::{
|
||||||
MarkdownMode,
|
MarkdownMode,
|
||||||
@@ -10,6 +13,7 @@ use crate::{
|
|||||||
modes::{at_edge, joint},
|
modes::{at_edge, joint},
|
||||||
},
|
},
|
||||||
forms::{InputEvent, TextInput},
|
forms::{InputEvent, TextInput},
|
||||||
|
primitives::lent,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Where an opened block's caret lands.
|
/// Where an opened block's caret lands.
|
||||||
@@ -19,13 +23,26 @@ pub(super) enum Landing {
|
|||||||
End,
|
End,
|
||||||
/// A source byte of the drawn read.
|
/// A source byte of the drawn read.
|
||||||
At(usize),
|
At(usize),
|
||||||
|
/// A table cell, by byte or place.
|
||||||
|
Cell(Aim),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Landing {
|
||||||
|
/// The same landing after `edit`, if it survives.
|
||||||
|
fn moved(self, edit: &Option<(Range<usize>, usize)>) -> Option<Self> {
|
||||||
|
Some(match self {
|
||||||
|
Self::At(at) => Self::At(moved(at, edit)?),
|
||||||
|
Self::Cell(Aim::Byte(at)) => Self::Cell(Aim::Byte(moved(at, edit)?)),
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where byte `at` lies after `edit`, if it survives.
|
/// Where byte `at` lies after `edit`, if it survives.
|
||||||
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
||||||
match edit {
|
match edit {
|
||||||
None => Some(at),
|
None => Some(at),
|
||||||
Some((replaced, _)) if at < replaced.start => Some(at),
|
Some((replaced, _)) if at <= replaced.start => Some(at),
|
||||||
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
||||||
Some(_) => None,
|
Some(_) => None,
|
||||||
}
|
}
|
||||||
@@ -63,6 +80,7 @@ impl MarkdownDocument {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
self.waiting = None;
|
||||||
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||||
pressed => pressed.flatten().map(|block| block.range.start),
|
pressed => pressed.flatten().map(|block| block.range.start),
|
||||||
@@ -71,12 +89,25 @@ impl MarkdownDocument {
|
|||||||
if written.is_some() {
|
if written.is_some() {
|
||||||
self.reparse(true, cx);
|
self.reparse(true, cx);
|
||||||
}
|
}
|
||||||
|
if !self.read_now(cx) && written.is_some() {
|
||||||
|
// A long write-back reads first; it opens after.
|
||||||
|
let start = match pressed {
|
||||||
|
None => Some(None),
|
||||||
|
Some(start) => moved(start, &written).map(Some),
|
||||||
|
};
|
||||||
|
let (Some(start), Some(landing)) = (start, landing.moved(&written)) else {
|
||||||
|
return log::warn!("markdown document: the pressed block was written over");
|
||||||
|
};
|
||||||
|
log::info!("markdown document: a block opens once its text is read");
|
||||||
|
self.waiting = Some((start, landing, self.edits + self.writes as u64));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !self.read_now(cx) {
|
if !self.read_now(cx) {
|
||||||
return log::warn!("markdown document: pressed while the text is read again");
|
return log::warn!("markdown document: pressed while the text is read again");
|
||||||
}
|
}
|
||||||
let text = self.editor.read(cx).text();
|
let text = self.editor.read(cx).text();
|
||||||
let (range, append) = match pressed {
|
let (range, append, cell) = match pressed {
|
||||||
None => (text.len()..text.len(), true),
|
None => (text.len()..text.len(), true, None),
|
||||||
Some(start) => {
|
Some(start) => {
|
||||||
let found = moved(start, &written).and_then(|start| {
|
let found = moved(start, &written).and_then(|start| {
|
||||||
self.parsed
|
self.parsed
|
||||||
@@ -87,12 +118,36 @@ impl MarkdownDocument {
|
|||||||
let Some(block) = found else {
|
let Some(block) = found else {
|
||||||
return log::warn!("markdown document: the pressed block changed as one wrote");
|
return log::warn!("markdown document: the pressed block changed as one wrote");
|
||||||
};
|
};
|
||||||
(block.range.clone(), false)
|
let table = block.kind == TargetBlock::Table;
|
||||||
|
let aim = match landing {
|
||||||
|
Landing::Cell(Aim::Byte(at)) => match moved(at, &written) {
|
||||||
|
Some(at) => Some(Aim::Byte(at)),
|
||||||
|
None => {
|
||||||
|
return log::warn!(
|
||||||
|
"markdown document: the pressed cell was written over"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Landing::Cell(aim) => Some(aim),
|
||||||
|
Landing::Start if table => Some(Aim::First),
|
||||||
|
Landing::End if table => Some(Aim::Last),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match aim {
|
||||||
|
None => (block.range.clone(), false, None),
|
||||||
|
Some(aim) => match cells::find(text, block.range.clone(), aim) {
|
||||||
|
Some(cell) => (cell.range, false, Some((cell.at, cell.caret))),
|
||||||
|
None => {
|
||||||
|
return log::warn!("markdown document: no cell {aim:?} in the block");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let caret = match landing {
|
let caret = match landing {
|
||||||
|
_ if cell.is_some() => cell.map(|(_, caret)| caret),
|
||||||
Landing::Start => Some(0),
|
Landing::Start => Some(0),
|
||||||
Landing::End => None,
|
Landing::End | Landing::Cell(_) => None,
|
||||||
Landing::At(at) => match moved(at, &written) {
|
Landing::At(at) => match moved(at, &written) {
|
||||||
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
|
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -103,9 +158,11 @@ impl MarkdownDocument {
|
|||||||
};
|
};
|
||||||
let source = text[range.clone()].to_string();
|
let source = text[range.clone()].to_string();
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
TextInput::new(window, cx)
|
let input = TextInput::new(window, cx).highlighter(markdown_highlights);
|
||||||
.multi_line(1, usize::MAX)
|
match cell {
|
||||||
.highlighter(markdown_highlights)
|
Some(_) => input,
|
||||||
|
None => input.multi_line(1, usize::MAX),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
input.update(cx, |input, cx| {
|
input.update(cx, |input, cx| {
|
||||||
input.set_text(source, cx);
|
input.set_text(source, cx);
|
||||||
@@ -114,9 +171,12 @@ impl MarkdownDocument {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.focus(&input.focus_handle(cx), cx);
|
window.focus(&input.focus_handle(cx), cx);
|
||||||
let subscriptions =
|
let subscriptions = vec![cx.subscribe_in(
|
||||||
vec![
|
&input,
|
||||||
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event {
|
window,
|
||||||
|
|document, _, event: &InputEvent, window, cx| {
|
||||||
|
match event {
|
||||||
|
InputEvent::Blur if lent(window, cx) => document.lend(window, cx),
|
||||||
InputEvent::Blur => {
|
InputEvent::Blur => {
|
||||||
document.commit(cx);
|
document.commit(cx);
|
||||||
}
|
}
|
||||||
@@ -124,30 +184,107 @@ impl MarkdownDocument {
|
|||||||
document.typed += 1;
|
document.typed += 1;
|
||||||
cx.emit(DocumentEvent::Changed)
|
cx.emit(DocumentEvent::Changed)
|
||||||
}
|
}
|
||||||
|
// Enter in a cell writes it back.
|
||||||
|
InputEvent::Submit
|
||||||
|
if document
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|open| open.cell.is_some()) =>
|
||||||
|
{
|
||||||
|
document.commit(cx);
|
||||||
|
window.focus(&document.focus, cx);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}
|
||||||
];
|
},
|
||||||
log::info!("markdown document: block {range:?} opens");
|
)];
|
||||||
|
match cell {
|
||||||
|
Some((at, _)) => log::info!("markdown document: cell {at:?} at {range:?} opens"),
|
||||||
|
None => log::info!("markdown document: block {range:?} opens"),
|
||||||
|
}
|
||||||
self.open = Some(Open {
|
self.open = Some(Open {
|
||||||
range,
|
range,
|
||||||
input,
|
input,
|
||||||
append,
|
append,
|
||||||
|
cell: cell.map(|(at, _)| at),
|
||||||
|
lent: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
});
|
});
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A block asked open before its text was read, now opened.
|
||||||
|
pub(super) fn open_waiting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.waiting.is_none() || !self.read_now(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (start, landing, expected) = self.waiting.take().expect("checked above");
|
||||||
|
if self.edits != expected {
|
||||||
|
return log::info!("markdown document: a waiting block lapsed with its text");
|
||||||
|
}
|
||||||
|
let ix = match start {
|
||||||
|
None => None,
|
||||||
|
Some(start) => match (self.parsed.blocks.iter()).position(|b| b.range.start == start) {
|
||||||
|
Some(ix) => Some(ix),
|
||||||
|
None => return log::warn!("markdown document: the waiting block was read away"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.open(ix, landing, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the focus a dropped block still holds.
|
||||||
|
pub(super) fn take_orphan(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(orphan) = self.orphan.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.open.is_none() && orphan.is_focused(window) {
|
||||||
|
log::info!("markdown document: a dropped block's focus passes to the document");
|
||||||
|
window.focus(&self.focus_handle(cx), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the block open while an overlay holds focus.
|
||||||
|
fn lend(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let (Some(open), Some(holder)) = (self.open.as_mut(), window.focused(cx)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("markdown document: an overlay borrows the open block's focus");
|
||||||
|
let watch = cx.on_focus_out(&holder, window, |document, _, window, cx| {
|
||||||
|
let back = (document.open.as_ref())
|
||||||
|
.is_some_and(|open| open.input.focus_handle(cx).is_focused(window));
|
||||||
|
match (back, lent(window, cx)) {
|
||||||
|
(true, _) => {
|
||||||
|
if let Some(open) = document.open.as_mut() {
|
||||||
|
open.lent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, true) => document.lend(window, cx),
|
||||||
|
(false, false) => {
|
||||||
|
document.commit(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
open.lent = Some(watch);
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes the open block as one step; the edit made.
|
/// Writes the open block as one step; the edit made.
|
||||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
||||||
let open = self.open.take()?;
|
let open = self.open.take()?;
|
||||||
|
self.orphan = Some(open.input.focus_handle(cx));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
let typed = open.input.read(cx).text().to_string();
|
let typed = open.input.read(cx).text().to_string();
|
||||||
let caret = open.input.read(cx).cursor();
|
let caret = open.input.read(cx).cursor();
|
||||||
let text = self.editor.read(cx).text();
|
let text = self.editor.read(cx).text();
|
||||||
|
let edges = cells::edges(text, open.range.clone());
|
||||||
let (lead, written) = match open.append {
|
let (lead, written) = match open.append {
|
||||||
true if typed.is_empty() => return None,
|
true if typed.is_empty() => return None,
|
||||||
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
||||||
false => (0, typed),
|
false if open.cell.is_some() => (0, cells::kept(&typed, edges)),
|
||||||
|
false => (0, typed.clone()),
|
||||||
|
};
|
||||||
|
let caret = match open.cell {
|
||||||
|
Some(_) => cells::kept_at(&typed, caret, edges),
|
||||||
|
None => caret,
|
||||||
};
|
};
|
||||||
let at = open.range.start + lead + caret;
|
let at = open.range.start + lead + caret;
|
||||||
let edit = (text[open.range.clone()] != written).then(|| {
|
let edit = (text[open.range.clone()] != written).then(|| {
|
||||||
@@ -182,6 +319,15 @@ impl MarkdownDocument {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let here = self.block_at(open.range.start);
|
let here = self.block_at(open.range.start);
|
||||||
|
if let Some(at) = open.cell {
|
||||||
|
let block = self.parsed.blocks[here].range.clone();
|
||||||
|
let by = if down { Move::Down } else { Move::Up };
|
||||||
|
let text = self.editor.read(cx).text();
|
||||||
|
if let Some(to) = cells::beside(text, block, at, by) {
|
||||||
|
self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
let next = match down {
|
let next = match down {
|
||||||
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
||||||
false => here.checked_sub(1),
|
false => here.checked_sub(1),
|
||||||
@@ -202,6 +348,7 @@ mod tests {
|
|||||||
let edit = Some((2..6, 9));
|
let edit = Some((2..6, 9));
|
||||||
assert_eq!(super::moved(10, &edit), Some(15));
|
assert_eq!(super::moved(10, &edit), Some(15));
|
||||||
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
||||||
|
assert_eq!(super::moved(2, &edit), Some(2), "its start stays");
|
||||||
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
||||||
assert_eq!(super::moved(7, &None), Some(7));
|
assert_eq!(super::moved(7, &None), Some(7));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use gpui::{Context, Window};
|
use gpui::{Context, Window};
|
||||||
|
|
||||||
use super::{MarkdownDocument, edit::Landing};
|
use super::{
|
||||||
|
MarkdownDocument,
|
||||||
|
cells::{self, Aim},
|
||||||
|
edit::Landing,
|
||||||
|
};
|
||||||
use crate::documents::MarkdownMode;
|
use crate::documents::MarkdownMode;
|
||||||
|
|
||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
@@ -41,12 +45,22 @@ impl MarkdownDocument {
|
|||||||
// Outside one block: its line.
|
// Outside one block: its line.
|
||||||
return self.reveal(line(self, cx), window, cx);
|
return self.reveal(line(self, cx), window, cx);
|
||||||
};
|
};
|
||||||
let start = block.start;
|
// A match in a table opens its cell.
|
||||||
self.open(Some(ix), Landing::End, window, cx);
|
let text = self.editor.read(cx).text();
|
||||||
|
let landing = match cells::find(text, block.clone(), Aim::Byte(range.start)) {
|
||||||
|
Some(cell)
|
||||||
|
if cell.range.start <= range.start && range.end <= cell.range.end =>
|
||||||
|
{
|
||||||
|
Landing::Cell(Aim::At(cell.at))
|
||||||
|
}
|
||||||
|
Some(_) => return self.reveal(line(self, cx), window, cx),
|
||||||
|
None => Landing::End,
|
||||||
|
};
|
||||||
|
self.open(Some(ix), landing, window, cx);
|
||||||
self.list.scroll_to_reveal_item(ix);
|
self.list.scroll_to_reveal_item(ix);
|
||||||
if let Some(open) = &self.open {
|
if let Some(open) = &self.open {
|
||||||
let input = open.input.clone();
|
let input = open.input.clone();
|
||||||
let inside = range.start - start..range.end - start;
|
let inside = range.start - open.range.start..range.end - open.range.start;
|
||||||
input.update(cx, |input, cx| input.select(inside, cx));
|
input.update(cx, |input, cx| input.select(inside, cx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
//! Inline formats toggle at the selection, as Typora's (R4.6).
|
//! Inline formats toggle at the selection, as Typora's (R4.6).
|
||||||
|
|
||||||
mod apply;
|
|
||||||
mod clear;
|
mod clear;
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use markdown::mdast::Node;
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use super::TableRefusal;
|
||||||
|
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
markdown::{file_tree, span, walk},
|
markdown::{file_tree, span, walk},
|
||||||
tree::inside,
|
tree::inside,
|
||||||
@@ -26,9 +27,9 @@ pub enum InlineFormat {
|
|||||||
Clear,
|
Clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a format did not happen.
|
/// Why a format or block command did not happen.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum FormatRefusal {
|
pub enum EditRefusal {
|
||||||
/// Preview takes no edits.
|
/// Preview takes no edits.
|
||||||
Preview,
|
Preview,
|
||||||
Code,
|
Code,
|
||||||
@@ -37,19 +38,45 @@ pub enum FormatRefusal {
|
|||||||
FrontMatter,
|
FrontMatter,
|
||||||
/// No paragraph, heading or cell at the caret.
|
/// No paragraph, heading or cell at the caret.
|
||||||
Outside,
|
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.
|
/// The selection crosses a block or another mark.
|
||||||
Across,
|
Across,
|
||||||
/// Past the largest read: source alone.
|
/// Past the largest read: source alone.
|
||||||
TooLong,
|
TooLong,
|
||||||
/// A long format still runs.
|
/// Another edit is still read.
|
||||||
Busy,
|
Busy,
|
||||||
/// The text changed before a long format finished.
|
/// The text changed before the edit was read.
|
||||||
Changed,
|
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.
|
/// The edits and the selection after them.
|
||||||
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
|
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
|
||||||
|
|
||||||
|
/// Removes the picture at the caret, as Typora's Delete Image.
|
||||||
|
pub(super) fn drop_image(text: &str, at: usize) -> Result<Change, EditRefusal> {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let picture = (nodes.iter())
|
||||||
|
.filter(|node| matches!(node, Node::Image(_) | Node::ImageReference(_)))
|
||||||
|
.map(|node| span(node))
|
||||||
|
.find(|range| range.contains(&at))
|
||||||
|
.ok_or(EditRefusal::NoImage)?;
|
||||||
|
Ok((
|
||||||
|
vec![(picture.clone(), String::new())],
|
||||||
|
picture.start..picture.start,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Typora's word stops: punctuation and space.
|
/// Typora's word stops: punctuation and space.
|
||||||
fn stop(ch: char) -> bool {
|
fn stop(ch: char) -> bool {
|
||||||
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
|
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
|
||||||
@@ -94,7 +121,7 @@ fn block(node: &Node) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A styled node's two marks, kept text between.
|
/// A styled node's two marks, kept text between.
|
||||||
fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
pub(super) fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
|
||||||
let whole = span(node);
|
let whole = span(node);
|
||||||
let inner = match node {
|
let inner = match node {
|
||||||
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
|
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
|
||||||
@@ -160,7 +187,7 @@ fn verbatim(
|
|||||||
nodes: &[&Node],
|
nodes: &[&Node],
|
||||||
selection: &Range<usize>,
|
selection: &Range<usize>,
|
||||||
format: InlineFormat,
|
format: InlineFormat,
|
||||||
) -> Result<(), FormatRefusal> {
|
) -> Result<(), EditRefusal> {
|
||||||
let mut blocks: Vec<Range<usize>> = nodes
|
let mut blocks: Vec<Range<usize>> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|node| block(node))
|
.filter(|node| block(node))
|
||||||
@@ -180,14 +207,14 @@ fn verbatim(
|
|||||||
// Clear takes a block's inline code and tags too.
|
// Clear takes a block's inline code and tags too.
|
||||||
let held = format != InlineFormat::Clear && touches(false);
|
let held = format != InlineFormat::Clear && touches(false);
|
||||||
let why = match node {
|
let why = match node {
|
||||||
Node::Code(_) if touches(true) => FormatRefusal::Code,
|
Node::Code(_) if touches(true) => EditRefusal::Code,
|
||||||
Node::Math(_) if touches(true) => FormatRefusal::Math,
|
Node::Math(_) if touches(true) => EditRefusal::Math,
|
||||||
Node::Yaml(_) | Node::Toml(_) if touches(true) => FormatRefusal::FrontMatter,
|
Node::Yaml(_) | Node::Toml(_) if touches(true) => EditRefusal::FrontMatter,
|
||||||
Node::InlineCode(_) if format != InlineFormat::Code && held => FormatRefusal::Code,
|
Node::InlineCode(_) if format != InlineFormat::Code && held => EditRefusal::Code,
|
||||||
Node::InlineMath(_) if held => FormatRefusal::Math,
|
Node::InlineMath(_) if held => EditRefusal::Math,
|
||||||
Node::Html(_) => match holder(&blocks, &whole).is_some() {
|
Node::Html(_) => match holder(&blocks, &whole).is_some() {
|
||||||
true if held => FormatRefusal::Html,
|
true if held => EditRefusal::Html,
|
||||||
false if touches(true) => FormatRefusal::Html,
|
false if touches(true) => EditRefusal::Html,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
},
|
},
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -233,7 +260,7 @@ pub(super) fn toggle(
|
|||||||
text: &str,
|
text: &str,
|
||||||
selection: Range<usize>,
|
selection: Range<usize>,
|
||||||
format: InlineFormat,
|
format: InlineFormat,
|
||||||
) -> Result<Change, FormatRefusal> {
|
) -> Result<Change, EditRefusal> {
|
||||||
toggle_tree(text, &file_tree(text), selection, format)
|
toggle_tree(text, &file_tree(text), selection, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +270,7 @@ pub(super) fn toggle_tree(
|
|||||||
root: &Node,
|
root: &Node,
|
||||||
selection: Range<usize>,
|
selection: Range<usize>,
|
||||||
format: InlineFormat,
|
format: InlineFormat,
|
||||||
) -> Result<Change, FormatRefusal> {
|
) -> Result<Change, EditRefusal> {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
walk(root, &mut nodes);
|
walk(root, &mut nodes);
|
||||||
verbatim(&nodes, &selection, format)?;
|
verbatim(&nodes, &selection, format)?;
|
||||||
@@ -287,8 +314,8 @@ pub(super) fn toggle_tree(
|
|||||||
if s != e {
|
if s != e {
|
||||||
let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end);
|
let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end);
|
||||||
return Err(match touched {
|
return Err(match touched {
|
||||||
true => FormatRefusal::Across,
|
true => EditRefusal::Across,
|
||||||
false => FormatRefusal::Outside,
|
false => EditRefusal::Outside,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let line = text[..s]
|
let line = text[..s]
|
||||||
@@ -299,7 +326,7 @@ pub(super) fn toggle_tree(
|
|||||||
+ text[s..].split(['\r', '\n']).next().unwrap_or_default();
|
+ text[s..].split(['\r', '\n']).next().unwrap_or_default();
|
||||||
return match line.trim().is_empty() {
|
return match line.trim().is_empty() {
|
||||||
true => Ok(pair(s, format)),
|
true => Ok(pair(s, format)),
|
||||||
false => Err(FormatRefusal::Outside),
|
false => Err(EditRefusal::Outside),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if format == InlineFormat::Underline
|
if format == InlineFormat::Underline
|
||||||
@@ -377,7 +404,7 @@ fn crossed(
|
|||||||
s: usize,
|
s: usize,
|
||||||
e: usize,
|
e: usize,
|
||||||
format: InlineFormat,
|
format: InlineFormat,
|
||||||
) -> Result<(), FormatRefusal> {
|
) -> Result<(), EditRefusal> {
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
let whole = span(node);
|
let whole = span(node);
|
||||||
if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end {
|
if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end {
|
||||||
@@ -398,7 +425,7 @@ fn crossed(
|
|||||||
// A same style cut is taken in whole.
|
// A same style cut is taken in whole.
|
||||||
let same = merges(format) && styled(format, node);
|
let same = merges(format) && styled(format, node);
|
||||||
if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) {
|
if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) {
|
||||||
return Err(FormatRefusal::Across);
|
return Err(EditRefusal::Across);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -412,7 +439,7 @@ fn wrap(
|
|||||||
mut s: usize,
|
mut s: usize,
|
||||||
mut e: usize,
|
mut e: usize,
|
||||||
format: InlineFormat,
|
format: InlineFormat,
|
||||||
) -> Result<Change, FormatRefusal> {
|
) -> Result<Change, EditRefusal> {
|
||||||
crossed(text, nodes, within, s, e, format)?;
|
crossed(text, nodes, within, s, e, format)?;
|
||||||
if merges(format) {
|
if merges(format) {
|
||||||
for node in nodes.iter().filter(|node| styled(format, node)) {
|
for node in nodes.iter().filter(|node| styled(format, node)) {
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
//! The document's format command.
|
|
||||||
|
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use gpui::{AppContext as _, Context};
|
|
||||||
|
|
||||||
use super::{Change, FormatRefusal, InlineFormat, toggle};
|
|
||||||
use crate::documents::{
|
|
||||||
MarkdownMode,
|
|
||||||
document::{DocumentEvent, LARGEST_READ, MarkdownDocument},
|
|
||||||
modes::joint,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The text formatted, 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<(), FormatRefusal> {
|
|
||||||
if self.mode == MarkdownMode::Preview {
|
|
||||||
return Err(FormatRefusal::Preview);
|
|
||||||
}
|
|
||||||
let (full, offset, selection) = self.snapshot(cx);
|
|
||||||
if full.len() > LARGEST_READ {
|
|
||||||
log::info!(
|
|
||||||
"markdown document: format {format:?} refused: {} bytes",
|
|
||||||
full.len()
|
|
||||||
);
|
|
||||||
return Err(FormatRefusal::TooLong);
|
|
||||||
}
|
|
||||||
if self.formatting {
|
|
||||||
log::info!("markdown document: format {format:?} refused: one runs");
|
|
||||||
return Err(FormatRefusal::Busy);
|
|
||||||
}
|
|
||||||
log::info!(
|
|
||||||
"markdown document: format {format:?} reads {} bytes apart",
|
|
||||||
full.len()
|
|
||||||
);
|
|
||||||
let taken = (full.clone(), offset, selection.clone());
|
|
||||||
self.formatting = true;
|
|
||||||
cx.spawn(async move |document, cx| {
|
|
||||||
let change = cx
|
|
||||||
.background_spawn(async move { toggle(&full, selection, format) })
|
|
||||||
.await;
|
|
||||||
document
|
|
||||||
.update(cx, |document, cx| {
|
|
||||||
document.formatting = false;
|
|
||||||
let done = if document.mode == MarkdownMode::Preview {
|
|
||||||
Err(FormatRefusal::Preview)
|
|
||||||
} else if document.snapshot(cx) != taken {
|
|
||||||
Err(FormatRefusal::Changed)
|
|
||||||
} else {
|
|
||||||
change.and_then(|change| {
|
|
||||||
document.apply(format, change, &taken.0, taken.1, cx)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
if let Err(why) = done {
|
|
||||||
log::info!("markdown document: format {format:?} refused: {why:?}");
|
|
||||||
cx.emit(DocumentEvent::FormatRefused(why));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text a format reads: the open block's in place.
|
|
||||||
fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
|
|
||||||
let text = self.editor.read(cx).text();
|
|
||||||
let Some(open) = &self.open else {
|
|
||||||
return (text.to_string(), 0, self.editor.read(cx).primary().range());
|
|
||||||
};
|
|
||||||
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 picked = input.selection();
|
|
||||||
let offset = before.len();
|
|
||||||
let full = format!("{before}{}{after}", input.text());
|
|
||||||
(full, offset, offset + picked.start..offset + picked.end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes `change`, into the open block if one is.
|
|
||||||
fn apply(
|
|
||||||
&mut self,
|
|
||||||
format: InlineFormat,
|
|
||||||
(edits, selected): Change,
|
|
||||||
full: &str,
|
|
||||||
offset: usize,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Result<(), FormatRefusal> {
|
|
||||||
log::info!("markdown document: format {format:?}");
|
|
||||||
let Some(open) = &self.open else {
|
|
||||||
self.editor.update(cx, |editor, cx| {
|
|
||||||
editor.edit(edits, cx);
|
|
||||||
editor.select(std::iter::once(selected), cx);
|
|
||||||
});
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let end = offset + open.input.read(cx).text().len();
|
|
||||||
if !edits
|
|
||||||
.iter()
|
|
||||||
.all(|(range, _)| offset <= range.start && range.end <= end)
|
|
||||||
{
|
|
||||||
log::warn!("markdown document: format {format:?} reaches past the open block");
|
|
||||||
return Err(FormatRefusal::Across);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use markdown::mdast::Node;
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
use super::{Change, FormatRefusal, after, block, cut, holder, marks, room};
|
use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
|
||||||
use crate::documents::{markdown::span, tree::inside};
|
use crate::documents::markdown::{math_inside, span};
|
||||||
|
|
||||||
/// The backslashes of escapes in a text node's span.
|
/// The backslashes of escapes in a text node's span.
|
||||||
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
||||||
@@ -33,7 +33,7 @@ fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>
|
|||||||
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
||||||
Node::Text(_) => escapes(text, whole),
|
Node::Text(_) => escapes(text, whole),
|
||||||
Node::InlineMath(_) => {
|
Node::InlineMath(_) => {
|
||||||
let inner = inside(text, whole.clone(), '$');
|
let inner = math_inside(text, whole.clone());
|
||||||
vec![whole.start..inner.start, inner.end..whole.end]
|
vec![whole.start..inner.start, inner.end..whole.end]
|
||||||
}
|
}
|
||||||
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
||||||
@@ -46,14 +46,14 @@ pub(super) fn clear(
|
|||||||
text: &str,
|
text: &str,
|
||||||
nodes: &[&Node],
|
nodes: &[&Node],
|
||||||
selection: Range<usize>,
|
selection: Range<usize>,
|
||||||
) -> Result<Change, FormatRefusal> {
|
) -> Result<Change, EditRefusal> {
|
||||||
let mut blocks: Vec<Range<usize>> = (nodes.iter())
|
let mut blocks: Vec<Range<usize>> = (nodes.iter())
|
||||||
.filter(|node| block(node))
|
.filter(|node| block(node))
|
||||||
.map(|node| room(node, text))
|
.map(|node| room(node, text))
|
||||||
.filter(|range| range.start <= selection.end && selection.start <= range.end)
|
.filter(|range| range.start <= selection.end && selection.start <= range.end)
|
||||||
.collect();
|
.collect();
|
||||||
if blocks.is_empty() {
|
if blocks.is_empty() {
|
||||||
return Err(FormatRefusal::Outside);
|
return Err(EditRefusal::Outside);
|
||||||
}
|
}
|
||||||
blocks.sort_by_key(|block| block.start);
|
blocks.sort_by_key(|block| block.start);
|
||||||
let mut autolinks: Vec<Range<usize>> = (nodes.iter())
|
let mut autolinks: Vec<Range<usize>> = (nodes.iter())
|
||||||
|
|||||||
@@ -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,4 +1,7 @@
|
|||||||
|
mod apart;
|
||||||
mod attach;
|
mod attach;
|
||||||
|
mod block;
|
||||||
|
mod cells;
|
||||||
mod edit;
|
mod edit;
|
||||||
mod finding;
|
mod finding;
|
||||||
mod format;
|
mod format;
|
||||||
@@ -23,7 +26,8 @@ use gpui::{
|
|||||||
|
|
||||||
pub use crate::documents::tree::DocumentHeading;
|
pub use crate::documents::tree::DocumentHeading;
|
||||||
pub use attach::Attachment;
|
pub use attach::Attachment;
|
||||||
pub use format::{FormatRefusal, InlineFormat};
|
pub use block::BlockCommand;
|
||||||
|
pub use format::{EditRefusal, InlineFormat};
|
||||||
pub use look::DocumentLook;
|
pub use look::DocumentLook;
|
||||||
use parse::{Parsed, alike, parsed};
|
use parse::{Parsed, alike, parsed};
|
||||||
pub use point::{DocumentTarget, TargetBlock};
|
pub use point::{DocumentTarget, TargetBlock};
|
||||||
@@ -33,7 +37,7 @@ use super::{
|
|||||||
MarkdownMode,
|
MarkdownMode,
|
||||||
markdown::markdown_highlights,
|
markdown::markdown_highlights,
|
||||||
modes::joint,
|
modes::joint,
|
||||||
render::{OnHeading, OnLink},
|
render::{CellAt, OnHeading, OnLink},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
editor::{CodeEditor, EditorEvent, LineNumbers},
|
editor::{CodeEditor, EditorEvent, LineNumbers},
|
||||||
@@ -59,7 +63,7 @@ pub enum DocumentEvent {
|
|||||||
/// A secondary press moved the caret onto this.
|
/// A secondary press moved the caret onto this.
|
||||||
Pointed(DocumentTarget),
|
Pointed(DocumentTarget),
|
||||||
/// A long format, accepted, did not happen.
|
/// A long format, accepted, did not happen.
|
||||||
FormatRefused(FormatRefusal),
|
EditRefused(EditRefusal),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A top line asked, numbered so late callbacks know theirs.
|
/// A top line asked, numbered so late callbacks know theirs.
|
||||||
@@ -77,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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +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<()>>,
|
||||||
formatting: bool,
|
editing: bool,
|
||||||
list: ListState,
|
list: ListState,
|
||||||
open: Option<Open>,
|
open: Option<Open>,
|
||||||
|
/// A dropped block's focus, for the document to take.
|
||||||
|
orphan: Option<FocusHandle>,
|
||||||
/// A top line asked for, until the view holds it.
|
/// A top line asked for, until the view holds it.
|
||||||
top: Option<Placement>,
|
top: Option<Placement>,
|
||||||
asks: u64,
|
asks: u64,
|
||||||
@@ -99,16 +109,20 @@ pub struct MarkdownDocument {
|
|||||||
writes: usize,
|
writes: usize,
|
||||||
/// A match asked before its text was read; its version.
|
/// A match asked before its text was read; its version.
|
||||||
showing: Option<(Range<usize>, u64)>,
|
showing: Option<(Range<usize>, u64)>,
|
||||||
|
/// A block asked open before its text was read.
|
||||||
|
waiting: Option<(Option<usize>, edit::Landing, u64)>,
|
||||||
look: DocumentLook,
|
look: DocumentLook,
|
||||||
/// Typing in the open block, counted.
|
/// Typing in the open block, counted.
|
||||||
typed: u64,
|
typed: u64,
|
||||||
/// Typewriter room past each end of the list.
|
/// Typewriter room past each end of the list.
|
||||||
room: Pixels,
|
room: Pixels,
|
||||||
/// The block, text, typing and heights last centered.
|
/// The block, text, typing and heights last centered.
|
||||||
centered: Option<(usize, u64, u64, [u64; 2])>,
|
centered: Option<(usize, u64, u64, [u64; 3])>,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
/// A secondary press took focus.
|
/// A secondary press took focus.
|
||||||
pointing: bool,
|
pointing: bool,
|
||||||
|
/// The latest press; older lookups lapse.
|
||||||
|
pressed: u64,
|
||||||
on_link: Option<OnLink>,
|
on_link: Option<OnLink>,
|
||||||
on_heading: Option<OnHeading>,
|
on_heading: Option<OnHeading>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
@@ -161,19 +175,22 @@ impl MarkdownDocument {
|
|||||||
parsed: Arc::default(),
|
parsed: Arc::default(),
|
||||||
edits: 0,
|
edits: 0,
|
||||||
_parsing: None,
|
_parsing: None,
|
||||||
formatting: false,
|
editing: false,
|
||||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||||
open: None,
|
open: None,
|
||||||
|
orphan: None,
|
||||||
top: None,
|
top: None,
|
||||||
asks: 0,
|
asks: 0,
|
||||||
writes: 0,
|
writes: 0,
|
||||||
showing: None,
|
showing: None,
|
||||||
|
waiting: None,
|
||||||
look: DocumentLook::default(),
|
look: DocumentLook::default(),
|
||||||
typed: 0,
|
typed: 0,
|
||||||
centered: None,
|
centered: None,
|
||||||
room: Pixels::ZERO,
|
room: Pixels::ZERO,
|
||||||
focus,
|
focus,
|
||||||
pointing: false,
|
pointing: false,
|
||||||
|
pressed: 0,
|
||||||
on_link: None,
|
on_link: None,
|
||||||
on_heading: Some(on_heading),
|
on_heading: Some(on_heading),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
@@ -232,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()
|
||||||
@@ -339,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+220
-15
@@ -1,12 +1,19 @@
|
|||||||
use std::rc::Rc;
|
use std::{ops::Range, rc::Rc};
|
||||||
|
|
||||||
use gpui::{Context, MouseButton, Window};
|
use gpui::{AppContext as _, Context, Focusable as _, MouseButton, Pixels, Point, Window};
|
||||||
use markdown::mdast;
|
use markdown::mdast;
|
||||||
|
|
||||||
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block};
|
use super::{
|
||||||
|
DocumentEvent, MarkdownDocument,
|
||||||
|
cells::{self, Aim},
|
||||||
|
edit::Landing,
|
||||||
|
edit::moved,
|
||||||
|
parse::Block,
|
||||||
|
};
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
render::{OnPoint, Place, Pointed},
|
markdown::{file_tree, span, walk},
|
||||||
tree::{Node, alert, is_toc, lines_at},
|
render::{CellAt, OnPoint, Place, Pointed},
|
||||||
|
tree::{Node, Reader, alert, is_toc, lines_at},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A top-level block's kind.
|
/// A top-level block's kind.
|
||||||
@@ -54,21 +61,23 @@ impl TargetBlock {
|
|||||||
/// What a secondary press landed on.
|
/// What a secondary press landed on.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct DocumentTarget {
|
pub struct DocumentTarget {
|
||||||
/// The source byte the caret went to.
|
/// The pressed source byte; an open block's as unwritten.
|
||||||
pub at: usize,
|
pub at: usize,
|
||||||
/// None past the last block.
|
/// None past the last block.
|
||||||
pub block: Option<TargetBlock>,
|
pub block: Option<TargetBlock>,
|
||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub in_table: bool,
|
pub in_table: bool,
|
||||||
pub in_code: bool,
|
/// The code block's text, pressed in one.
|
||||||
|
pub code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
/// Presses on block `ix`'s drawing, read as it was drawn.
|
/// Presses on block `ix`'s drawing, read as it was drawn.
|
||||||
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
|
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
|
||||||
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
|
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
|
||||||
let start = parsed.blocks[ix].range.start;
|
let block = parsed.blocks[ix].range.clone();
|
||||||
|
let start = block.start;
|
||||||
Rc::new(move |pointed, button, window, cx| {
|
Rc::new(move |pointed, button, window, cx| {
|
||||||
// The drawing counts from its block's start.
|
// The drawing counts from its block's start.
|
||||||
let at = match &pointed.place {
|
let at = match &pointed.place {
|
||||||
@@ -78,22 +87,59 @@ impl MarkdownDocument {
|
|||||||
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
||||||
}
|
}
|
||||||
Place::At(at) => Some(*at),
|
Place::At(at) => Some(*at),
|
||||||
|
// A cell's padding lands at its end.
|
||||||
|
Place::Cell(cell) => cells::find(&parsed.text, block.clone(), Aim::At(*cell))
|
||||||
|
.map(|found| found.range.end - start),
|
||||||
Place::Block => None,
|
Place::Block => None,
|
||||||
}
|
}
|
||||||
.map(|at| start + at);
|
.map(|at| start + at);
|
||||||
|
// A press in a table opens only its cell.
|
||||||
|
let landing = match (&pointed.place, at) {
|
||||||
|
(Place::Cell(cell), _) => Landing::Cell(Aim::At(*cell)),
|
||||||
|
(_, Some(at)) if pointed.table => Landing::Cell(Aim::Byte(at)),
|
||||||
|
(_, Some(at)) => Landing::At(at),
|
||||||
|
(_, None) => Landing::End,
|
||||||
|
};
|
||||||
let ix = Some(ix);
|
let ix = Some(ix);
|
||||||
document
|
document
|
||||||
.update(cx, |document, cx| match button {
|
.update(cx, |document, cx| {
|
||||||
MouseButton::Left => {
|
// The open cell's padding keeps its typing.
|
||||||
document.open(ix, at.map_or(Landing::End, Landing::At), window, cx)
|
let open = match pointed.place {
|
||||||
|
Place::Cell(cell) => document.open_cell(&block) == Some(cell),
|
||||||
|
_ => 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),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
MouseButton::Right => document.point(ix, at, pointed, window, cx),
|
|
||||||
_ => {}
|
|
||||||
})
|
})
|
||||||
.expect("a block's drawing lives in its document");
|
.expect("a block's drawing lives in its document");
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cell open in `block`, if any.
|
||||||
|
fn open_cell(&self, block: &Range<usize>) -> Option<CellAt> {
|
||||||
|
let open = self.open.as_ref()?;
|
||||||
|
let inside = block.start <= open.range.start && open.range.end <= block.end;
|
||||||
|
open.cell.filter(|_| inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caret to the open cell's end.
|
||||||
|
fn cell_end(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let input = open.input.clone();
|
||||||
|
input.update(cx, |input, cx| {
|
||||||
|
let end = input.text().len();
|
||||||
|
input.select(end..end, cx);
|
||||||
|
});
|
||||||
|
window.focus(&input.focus_handle(cx), cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// A block's kind, text and what it draws.
|
/// A block's kind, text and what it draws.
|
||||||
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
|
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
|
||||||
let text = self.parsed.text[block.range.clone()].to_string();
|
let text = self.parsed.text[block.range.clone()].to_string();
|
||||||
@@ -109,12 +155,19 @@ impl MarkdownDocument {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
self.pressed += 1;
|
||||||
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||||
block => block.flatten(),
|
block => block.flatten(),
|
||||||
};
|
};
|
||||||
let kind = block.map(|block| block.kind);
|
let kind = block.map(|block| block.kind);
|
||||||
let pressed = block.map(|block| self.drawn(block));
|
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 at = at.or(block.map(|block| block.range.start));
|
||||||
let written = self.commit(cx);
|
let written = self.commit(cx);
|
||||||
if written.is_some() {
|
if written.is_some() {
|
||||||
@@ -134,7 +187,9 @@ impl MarkdownDocument {
|
|||||||
let now = (self.parsed.blocks.iter())
|
let now = (self.parsed.blocks.iter())
|
||||||
.find(|block| block.range.start <= at && at <= block.range.end)
|
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||||
.map(|block| self.drawn(block));
|
.map(|block| self.drawn(block));
|
||||||
if written.is_some() && pressed.is_some() && now != pressed {
|
// A cell beside the written one keeps its place.
|
||||||
|
let beside = pointed.table && now.as_ref().is_some_and(|now| now.0 == TargetBlock::Table);
|
||||||
|
if written.is_some() && pressed.is_some() && now != pressed && !beside {
|
||||||
return log::warn!("markdown document: the write-back changed the pressed block");
|
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||||
}
|
}
|
||||||
self.editor
|
self.editor
|
||||||
@@ -149,8 +204,158 @@ impl MarkdownDocument {
|
|||||||
link: pointed.link,
|
link: pointed.link,
|
||||||
image: pointed.image,
|
image: pointed.image,
|
||||||
in_table: pointed.table || kind == Some(TargetBlock::Table),
|
in_table: pointed.table || kind == Some(TargetBlock::Table),
|
||||||
in_code: pointed.code || kind == Some(TargetBlock::Code),
|
code,
|
||||||
}));
|
}));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A right press at `position`, else at the field's end.
|
||||||
|
pub(super) fn point_open(&mut self, position: Option<Point<Pixels>>, cx: &mut Context<Self>) {
|
||||||
|
let Some(open) = &self.open else {
|
||||||
|
return log::warn!("markdown document: a press on a block closed since");
|
||||||
|
};
|
||||||
|
let input = open.input.read(cx);
|
||||||
|
let pressed = match position {
|
||||||
|
Some(position) => input.offset_for_point(position),
|
||||||
|
None => input.text().len(),
|
||||||
|
};
|
||||||
|
// A cell's typed pipes shift its bytes.
|
||||||
|
let pressed = match open.cell {
|
||||||
|
Some(_) => {
|
||||||
|
let edges = cells::edges(self.editor.read(cx).text(), open.range.clone());
|
||||||
|
cells::kept_at(input.text(), pressed, edges)
|
||||||
|
}
|
||||||
|
None => pressed,
|
||||||
|
};
|
||||||
|
let taken = self.snapshot(cx);
|
||||||
|
let (full, offset, _) = taken.clone();
|
||||||
|
self.pressed += 1;
|
||||||
|
let press = self.pressed;
|
||||||
|
cx.spawn(async move |document, cx| {
|
||||||
|
let target = cx
|
||||||
|
.background_spawn(async move { target_at(&full, offset + pressed) })
|
||||||
|
.await;
|
||||||
|
document
|
||||||
|
.update(cx, |document, cx| {
|
||||||
|
if document.pressed != press {
|
||||||
|
return log::info!("markdown document: a newer press came first");
|
||||||
|
}
|
||||||
|
if document.open.is_none() || document.snapshot(cx) != taken {
|
||||||
|
return log::info!("markdown document: the pressed block changed first");
|
||||||
|
}
|
||||||
|
log::info!("markdown document: a secondary press in the open block");
|
||||||
|
cx.emit(DocumentEvent::Pointed(target));
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What byte `at` of `text` holds, read as drawn.
|
||||||
|
fn target_at(text: &str, at: usize) -> DocumentTarget {
|
||||||
|
let root = file_tree(text);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let top = (root
|
||||||
|
.children()
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter())
|
||||||
|
.find(|node| span(node).start <= at && at <= span(node).end);
|
||||||
|
let block = top.map(|node| TargetBlock::of(node, text));
|
||||||
|
let mut drawn = Vec::new();
|
||||||
|
if let Some(node) = top.filter(|node| !matches!(node, mdast::Node::Definition(_))) {
|
||||||
|
Reader::new(text, &root).top(node, &mut drawn);
|
||||||
|
}
|
||||||
|
let mut flat = Vec::new();
|
||||||
|
flatten(&drawn, &mut flat);
|
||||||
|
let around: Vec<&mdast::Node> = (nodes.iter().copied())
|
||||||
|
.filter(|node| span(node).contains(&at))
|
||||||
|
.collect();
|
||||||
|
let defined = |identifier: &str| {
|
||||||
|
nodes.iter().find_map(|node| match node {
|
||||||
|
mdast::Node::Definition(definition) if definition.identifier == identifier => {
|
||||||
|
Some(definition.url.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let written = || {
|
||||||
|
around.iter().rev().find_map(|node| match node {
|
||||||
|
mdast::Node::Link(link) => Some(link.url.clone()),
|
||||||
|
mdast::Node::LinkReference(link) => defined(&link.identifier),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let picture = around
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|node| matches!(node, mdast::Node::Image(_) | mdast::Node::ImageReference(_)));
|
||||||
|
let shown = picture.and_then(|node| {
|
||||||
|
flat.iter().find_map(|kid| match kid {
|
||||||
|
Node::Image { at, url, link, .. } if *at == span(node).start => {
|
||||||
|
Some((url.clone(), link.clone()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let image = match &shown {
|
||||||
|
Some((url, _)) => Some(url.clone()),
|
||||||
|
None => picture.and_then(|node| match node {
|
||||||
|
mdast::Node::Image(picture) => Some(picture.url.clone()),
|
||||||
|
mdast::Node::ImageReference(picture) => defined(&picture.identifier),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let link = match shown {
|
||||||
|
Some((_, link)) => link,
|
||||||
|
None => drawn_link(&flat, text, at).unwrap_or_else(written),
|
||||||
|
};
|
||||||
|
let code = around.iter().find_map(|node| match node {
|
||||||
|
mdast::Node::Code(code) => Some(code.value.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
DocumentTarget {
|
||||||
|
at,
|
||||||
|
block,
|
||||||
|
link,
|
||||||
|
image,
|
||||||
|
in_table: around
|
||||||
|
.iter()
|
||||||
|
.any(|node| matches!(node, mdast::Node::Table(_))),
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nodes` and all they hold, depth first.
|
||||||
|
fn flatten<'a>(nodes: &'a [Node], out: &mut Vec<&'a Node>) {
|
||||||
|
for node in nodes {
|
||||||
|
out.push(node);
|
||||||
|
match node {
|
||||||
|
Node::Quote(body) | Node::Footnote { body, .. } | Node::Alert { body, .. } => {
|
||||||
|
flatten(body, out)
|
||||||
|
}
|
||||||
|
Node::List { items, .. } => items.iter().for_each(|(_, body)| flatten(body, out)),
|
||||||
|
Node::Table { head, rows, .. } => {
|
||||||
|
(head.iter().chain(rows.iter().flatten())).for_each(|cell| flatten(cell, out))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link drawn at source byte `at`; none if undrawn.
|
||||||
|
fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option<Option<String>> {
|
||||||
|
let inline = nodes.iter().find_map(|node| match node {
|
||||||
|
Node::Paragraph(inline) | Node::Heading(_, inline)
|
||||||
|
if (inline.sources.iter()).any(|(_, span, _)| span.contains(&at)) =>
|
||||||
|
{
|
||||||
|
Some(inline)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})?;
|
||||||
|
let drawn = inline.drawn_at(text, at)?;
|
||||||
|
let link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn));
|
||||||
|
Some(link.map(|(_, url)| url.clone()))
|
||||||
}
|
}
|
||||||
+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 |"));
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use web_time::Instant;
|
|||||||
|
|
||||||
use super::{open, opened, press, text};
|
use super::{open, opened, press, text};
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
DocumentEvent, FormatRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
DocumentEvent, EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
||||||
document::{
|
document::{
|
||||||
format::{toggle, toggle_tree},
|
format::{toggle, toggle_tree},
|
||||||
visual,
|
visual,
|
||||||
@@ -17,7 +17,7 @@ use crate::documents::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection.
|
/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection.
|
||||||
fn toggled(marked: &str, format: InlineFormat) -> Result<String, FormatRefusal> {
|
fn toggled(marked: &str, format: InlineFormat) -> Result<String, EditRefusal> {
|
||||||
let caret = marked.find('‸');
|
let caret = marked.find('‸');
|
||||||
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||||
let selection = match caret {
|
let selection = match caret {
|
||||||
@@ -109,7 +109,7 @@ fn clear_takes_every_mark_off_the_touched_blocks() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn code_math_html_and_front_matter_refuse() {
|
fn code_math_html_and_front_matter_refuse() {
|
||||||
use FormatRefusal as Why;
|
use EditRefusal as Why;
|
||||||
use InlineFormat::*;
|
use InlineFormat::*;
|
||||||
assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code));
|
assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code));
|
||||||
assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code));
|
assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code));
|
||||||
@@ -126,12 +126,9 @@ fn code_math_html_and_front_matter_refuse() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_selection_across_a_link_or_into_its_address_refuses() {
|
fn a_selection_across_a_link_or_into_its_address_refuses() {
|
||||||
use InlineFormat::*;
|
use InlineFormat::*;
|
||||||
assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(FormatRefusal::Across));
|
assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(EditRefusal::Across));
|
||||||
assert_eq!(
|
assert_eq!(toggled("[a](ht‸tp://x)", Strong), Err(EditRefusal::Across));
|
||||||
toggled("[a](ht‸tp://x)", Strong),
|
assert_eq!(toggled("a\n\n> ‸", Strong), Err(EditRefusal::Outside));
|
||||||
Err(FormatRefusal::Across)
|
|
||||||
);
|
|
||||||
assert_eq!(toggled("a\n\n> ‸", Strong), Err(FormatRefusal::Outside));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
@@ -185,14 +182,14 @@ fn the_source_takes_a_format_as_one_step(cx: &mut TestAppContext) {
|
|||||||
fn preview_refuses_a_format(cx: &mut TestAppContext) {
|
fn preview_refuses_a_format(cx: &mut TestAppContext) {
|
||||||
let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx);
|
let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx);
|
||||||
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||||
assert_eq!(refused, Err(FormatRefusal::Preview));
|
assert_eq!(refused, Err(EditRefusal::Preview));
|
||||||
assert_eq!(text(&document, cx), "alpha\n");
|
assert_eq!(text(&document, cx), "alpha\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_end_in_marks_or_an_address_refuses() {
|
fn an_end_in_marks_or_an_address_refuses() {
|
||||||
use InlineFormat::*;
|
use InlineFormat::*;
|
||||||
let across = Err(FormatRefusal::Across);
|
let across = Err(EditRefusal::Across);
|
||||||
assert_eq!(toggled("a *‸*bold** z", Strong), across);
|
assert_eq!(toggled("a *‸*bold** z", Strong), across);
|
||||||
assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across);
|
assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across);
|
||||||
assert_eq!(toggled("a *⟨*b** c⟩", Strong), across);
|
assert_eq!(toggled("a *⟨*b** c⟩", Strong), across);
|
||||||
@@ -280,15 +277,15 @@ fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The refusals a document sends by event.
|
/// The refusals a document sends by event.
|
||||||
fn refusals(
|
pub(super) fn refusals(
|
||||||
document: &Entity<MarkdownDocument>,
|
document: &Entity<MarkdownDocument>,
|
||||||
cx: &mut VisualTestContext,
|
cx: &mut VisualTestContext,
|
||||||
) -> Rc<RefCell<Vec<FormatRefusal>>> {
|
) -> Rc<RefCell<Vec<EditRefusal>>> {
|
||||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||||
let kept = heard.clone();
|
let kept = heard.clone();
|
||||||
cx.update(|_, cx| {
|
cx.update(|_, cx| {
|
||||||
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
|
||||||
if let DocumentEvent::FormatRefused(why) = event {
|
if let DocumentEvent::EditRefused(why) = event {
|
||||||
kept.borrow_mut().push(*why);
|
kept.borrow_mut().push(*why);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -308,13 +305,13 @@ fn a_lapse_comes_by_event(cx: &mut TestAppContext) {
|
|||||||
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
.update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx));
|
||||||
});
|
});
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
assert_eq!(*heard.borrow(), [FormatRefusal::Changed]);
|
assert_eq!(*heard.borrow(), [EditRefusal::Changed]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unwrap_refuses_an_end_in_nested_marks() {
|
fn an_unwrap_refuses_an_end_in_nested_marks() {
|
||||||
use InlineFormat::*;
|
use InlineFormat::*;
|
||||||
let across = Err(FormatRefusal::Across);
|
let across = Err(EditRefusal::Across);
|
||||||
assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across);
|
assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across);
|
||||||
assert_eq!(toggled("**~~a~‸~**", Strong), across);
|
assert_eq!(toggled("**~~a~‸~**", Strong), across);
|
||||||
assert_eq!(toggled("<u>[a](ht‸tps://x)</u>", Underline), across);
|
assert_eq!(toggled("<u>[a](ht‸tps://x)</u>", Underline), across);
|
||||||
@@ -359,7 +356,7 @@ fn a_second_format_refuses_while_one_runs(cx: &mut TestAppContext) {
|
|||||||
document.format(InlineFormat::Strong, cx).expect("taken");
|
document.format(InlineFormat::Strong, cx).expect("taken");
|
||||||
document.format(InlineFormat::Emphasis, cx)
|
document.format(InlineFormat::Emphasis, cx)
|
||||||
});
|
});
|
||||||
assert_eq!(second, Err(FormatRefusal::Busy));
|
assert_eq!(second, Err(EditRefusal::Busy));
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
document.update(cx, |document, cx| {
|
document.update(cx, |document, cx| {
|
||||||
document.format(InlineFormat::Emphasis, cx).expect("taken")
|
document.format(InlineFormat::Emphasis, cx).expect("taken")
|
||||||
@@ -386,7 +383,7 @@ fn a_refusal_comes_by_event(cx: &mut TestAppContext) {
|
|||||||
});
|
});
|
||||||
assert_eq!(taken, Ok(()));
|
assert_eq!(taken, Ok(()));
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
assert_eq!(*heard.borrow(), [FormatRefusal::Code]);
|
assert_eq!(*heard.borrow(), [EditRefusal::Code]);
|
||||||
assert_eq!(text(&document, cx), source);
|
assert_eq!(text(&document, cx), source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,14 +397,14 @@ fn a_format_drops_once_preview_shows(cx: &mut TestAppContext) {
|
|||||||
});
|
});
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
assert!(text(&document, cx).starts_with("xx"));
|
assert!(text(&document, cx).starts_with("xx"));
|
||||||
assert_eq!(*heard.borrow(), [FormatRefusal::Preview]);
|
assert_eq!(*heard.borrow(), [EditRefusal::Preview]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) {
|
fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) {
|
||||||
let (document, cx) = word(cx, LARGEST_READ + 1);
|
let (document, cx) = word(cx, LARGEST_READ + 1);
|
||||||
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
|
||||||
assert_eq!(refused, Err(FormatRefusal::TooLong));
|
assert_eq!(refused, Err(EditRefusal::TooLong));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -366,14 +368,20 @@ 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 format;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod lend;
|
||||||
mod links;
|
mod links;
|
||||||
mod look;
|
mod look;
|
||||||
|
mod open_look;
|
||||||
mod place;
|
mod place;
|
||||||
mod point;
|
mod point;
|
||||||
mod presses;
|
mod presses;
|
||||||
mod reading;
|
mod reading;
|
||||||
|
mod secondary;
|
||||||
mod toc;
|
mod toc;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ pub(super) fn target(at: usize, block: TargetBlock) -> DocumentTarget {
|
|||||||
link: None,
|
link: None,
|
||||||
image: None,
|
image: None,
|
||||||
in_table: false,
|
in_table: false,
|
||||||
in_code: false,
|
code: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +89,25 @@ fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_past_a_crlf_line_types_before_its_end(cx: &mut TestAppContext) {
|
||||||
|
let source = "one\r\ntwo\r\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 3, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), "one".len());
|
||||||
|
cx.simulate_input("X");
|
||||||
|
cx.simulate_keystrokes("escape");
|
||||||
|
assert_eq!(super::text(&document, cx), "oneX\r\ntwo\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_press_in_code_keeping_a_crlf_lands_on_its_character(cx: &mut TestAppContext) {
|
||||||
|
let source = "`one\r\ntwo`\n";
|
||||||
|
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||||
|
press_on(0, 2, MouseButton::Left, cx);
|
||||||
|
assert_eq!(opened_caret(&document, cx), "`on".len());
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
|
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
|
||||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||||
@@ -120,7 +139,7 @@ fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) {
|
|||||||
let heard = heard(&document, cx);
|
let heard = heard(&document, cx);
|
||||||
press_on(0, 0, MouseButton::Right, cx);
|
press_on(0, 0, MouseButton::Right, cx);
|
||||||
let expected = DocumentTarget {
|
let expected = DocumentTarget {
|
||||||
in_code: true,
|
code: Some("let x = 1;".into()),
|
||||||
..target("```rust\n".len(), TargetBlock::Code)
|
..target("```rust\n".len(), TargetBlock::Code)
|
||||||
};
|
};
|
||||||
assert_eq!(*heard.borrow(), [expected]);
|
assert_eq!(*heard.borrow(), [expected]);
|
||||||
@@ -197,8 +216,14 @@ fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
|
|||||||
let events = heard(&document, cx);
|
let events = heard(&document, cx);
|
||||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||||
let (body, _) = sizes(cx);
|
let (body, _) = sizes(cx);
|
||||||
let x = bounds.left() + px(31.0) + body * 0.6 * 7.25;
|
// "beta" draws on its own line.
|
||||||
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
let x = bounds.left() + px(31.0) + body * 0.6 * 1.25;
|
||||||
|
press_at(
|
||||||
|
x,
|
||||||
|
bounds.top() + px(10.0) + body * 1.6,
|
||||||
|
MouseButton::Right,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
let at = source.find("eta").unwrap();
|
let at = source.find("eta").unwrap();
|
||||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||||
}
|
}
|
||||||
@@ -245,8 +270,9 @@ fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
|
|||||||
let events = heard(&document, cx);
|
let events = heard(&document, cx);
|
||||||
press_lines(1, 1, MouseButton::Right, cx);
|
press_lines(1, 1, MouseButton::Right, cx);
|
||||||
let at = source.find("eta").unwrap();
|
let at = source.find("eta").unwrap();
|
||||||
|
let eol = if source.contains('\r') { "\r\n" } else { "\n" };
|
||||||
let expected = DocumentTarget {
|
let expected = DocumentTarget {
|
||||||
in_code: true,
|
code: Some(format!("alpha{eol}beta")),
|
||||||
..target(at, TargetBlock::Code)
|
..target(at, TargetBlock::Code)
|
||||||
};
|
};
|
||||||
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
||||||
@@ -304,7 +330,7 @@ beta
|
|||||||
press_lines(2, 0, MouseButton::Right, cx);
|
press_lines(2, 0, MouseButton::Right, cx);
|
||||||
let at = source.find("pha").unwrap();
|
let at = source.find("pha").unwrap();
|
||||||
let expected = DocumentTarget {
|
let expected = DocumentTarget {
|
||||||
in_code: true,
|
code: Some("alpha\nbeta".into()),
|
||||||
..target(at, TargetBlock::Code)
|
..target(at, TargetBlock::Code)
|
||||||
};
|
};
|
||||||
assert_eq!(*events.borrow(), [expected]);
|
assert_eq!(*events.borrow(), [expected]);
|
||||||
@@ -392,3 +418,22 @@ fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) {
|
|||||||
let at = source.find("eta").unwrap();
|
let at = source.find("eta").unwrap();
|
||||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
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,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"));
|
||||||
|
}
|
||||||
@@ -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,19 +1,19 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement,
|
AnyElement, App, ClickEvent, Context, Div, ExternalPaths, FontWeight, InteractiveElement,
|
||||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||||
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
||||||
prelude::FluentBuilder as _,
|
prelude::FluentBuilder as _, relative,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{MarkdownDocument, edit::Landing};
|
use super::{MarkdownDocument, edit::Landing, parse::Block};
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::{
|
documents::{
|
||||||
MarkdownMode,
|
MarkdownMode, TargetBlock,
|
||||||
render::{Hooks, Pointed, draw_nodes, point_lines},
|
render::{HEAVY, Hooks, OpenCell, Pointed, draw_nodes, heading_step, point_lines},
|
||||||
},
|
},
|
||||||
forms::{self, Down, Up},
|
forms::{self, Down, Up},
|
||||||
primitives::FocusRing as _,
|
primitives::FocusRing as _,
|
||||||
theme::{ActiveTheme, Radius, TextSize},
|
theme::{ActiveTheme, Radius, TextSize, Theme},
|
||||||
};
|
};
|
||||||
|
|
||||||
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
||||||
@@ -23,6 +23,44 @@ const CONTEXT: &str = "MarkdownDocument";
|
|||||||
/// How far a faded block recedes.
|
/// How far a faded block recedes.
|
||||||
const FADED: f32 = 0.35;
|
const FADED: f32 = 0.35;
|
||||||
|
|
||||||
|
/// The open block's text box: its closed font and panel.
|
||||||
|
pub(super) fn open_text(kind: Option<(TargetBlock, bool)>, scale: f32, theme: &Theme) -> Div {
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let body = theme.text_size(TextSize::Base) * scale;
|
||||||
|
let mono = theme.text_size(TextSize::Sm) * scale;
|
||||||
|
let text = div()
|
||||||
|
.debug_selector(|| "markdown-open-text".to_string())
|
||||||
|
.text_size(body)
|
||||||
|
.line_height(body * 1.6)
|
||||||
|
.text_color(colors.fg);
|
||||||
|
match kind {
|
||||||
|
Some((TargetBlock::Heading(level), true)) => text
|
||||||
|
.pt_2()
|
||||||
|
.text_size(body * heading_step(level))
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.font_weight(HEAVY)
|
||||||
|
.when(level == 6, |text| text.text_color(colors.fg_muted)),
|
||||||
|
// Code, math and front matter keep their panel.
|
||||||
|
Some((kind @ (TargetBlock::Code | TargetBlock::Math | TargetBlock::FrontMatter), true)) => {
|
||||||
|
text.p_3()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(colors.sunken)
|
||||||
|
.when(kind != TargetBlock::Math, |text| {
|
||||||
|
text.font_family(theme.mono_family.clone()).text_size(mono)
|
||||||
|
})
|
||||||
|
.when(kind == TargetBlock::FrontMatter, |text| {
|
||||||
|
text.text_color(colors.fg_muted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Written as is: mono and muted.
|
||||||
|
Some((_, false)) => text
|
||||||
|
.font_family(theme.mono_family.clone())
|
||||||
|
.line_height(gpui::phi())
|
||||||
|
.text_color(colors.fg_muted),
|
||||||
|
_ => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Modes with a reader's block to keep.
|
/// Modes with a reader's block to keep.
|
||||||
fn editing_mode(mode: MarkdownMode) -> bool {
|
fn editing_mode(mode: MarkdownMode) -> bool {
|
||||||
mode == MarkdownMode::Visual
|
mode == MarkdownMode::Visual
|
||||||
@@ -72,28 +110,34 @@ impl MarkdownDocument {
|
|||||||
// Focus fades all but the reader's block.
|
// Focus fades all but the reader's block.
|
||||||
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
||||||
let open = self.open.as_ref();
|
let open = self.open.as_ref();
|
||||||
let field = |open: &super::Open| {
|
|
||||||
div()
|
|
||||||
.text_size(body)
|
|
||||||
.px_3()
|
|
||||||
.py_2()
|
|
||||||
.rounded(radius)
|
|
||||||
.bg(colors.sunken)
|
|
||||||
.child(open.input.clone())
|
|
||||||
.into_any_element()
|
|
||||||
};
|
|
||||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||||
return match (open, editing) {
|
return match (open, editing) {
|
||||||
(Some(open), _) if open.append => field(open),
|
(Some(open), _) if open.append => self.field(open, None, cx),
|
||||||
(_, true) => self
|
(_, true) => self
|
||||||
.pressable(div().id("more").min_h_8(), None, cx)
|
.pressable(div().id("more").min_h_8(), None, cx)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
(_, false) => div().into_any_element(),
|
(_, false) => div().into_any_element(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if let Some(open) = open.filter(|open| open.range == block.range) {
|
if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) {
|
||||||
return field(open);
|
return self.field(open, Some(block), cx);
|
||||||
}
|
}
|
||||||
|
// A cell open in this block, typed in place.
|
||||||
|
let cell = open.and_then(|open| {
|
||||||
|
let inside = block.range.start <= open.range.start && open.range.end <= block.range.end;
|
||||||
|
Some(OpenCell {
|
||||||
|
at: open.cell.filter(|_| inside)?,
|
||||||
|
field: std::cell::RefCell::new(Some(
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "markdown-open-cell".to_string())
|
||||||
|
.on_mouse_down(MouseButton::Right, Self::point_in_open(cx))
|
||||||
|
.rounded(radius)
|
||||||
|
.bg(colors.sunken)
|
||||||
|
.child(open.input.clone())
|
||||||
|
.into_any_element(),
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
});
|
||||||
let shown = match &block.nodes {
|
let shown = match &block.nodes {
|
||||||
Some(nodes) => div()
|
Some(nodes) => div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -110,6 +154,7 @@ impl MarkdownDocument {
|
|||||||
on_link: &self.on_link,
|
on_link: &self.on_link,
|
||||||
on_heading: &self.on_heading,
|
on_heading: &self.on_heading,
|
||||||
on_point: &on_point,
|
on_point: &on_point,
|
||||||
|
cell: cell.as_ref(),
|
||||||
},
|
},
|
||||||
self.look.scale,
|
self.look.scale,
|
||||||
cx,
|
cx,
|
||||||
@@ -148,6 +193,38 @@ impl MarkdownDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The open block in its closed frame, size and weight.
|
||||||
|
fn field(
|
||||||
|
&self,
|
||||||
|
open: &super::Open,
|
||||||
|
block: Option<&Block>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let point = Self::point_in_open(cx);
|
||||||
|
let kind = block.map(|block| (block.kind, block.nodes.is_some()));
|
||||||
|
let text = open_text(kind, self.look.scale, cx.theme());
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "markdown-open".to_string())
|
||||||
|
.on_mouse_down(MouseButton::Right, point)
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(gpui::transparent_black())
|
||||||
|
.child(text.child(open.input.clone()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A right press in the open field points there.
|
||||||
|
fn point_in_open(
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static {
|
||||||
|
cx.listener(|document, event: &MouseDownEvent, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
document.point_open(Some(event.position), cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `element` opens block `ix`; a right press points.
|
/// `element` opens block `ix`; a right press points.
|
||||||
fn pressable(
|
fn pressable(
|
||||||
&self,
|
&self,
|
||||||
@@ -195,9 +272,11 @@ impl MarkdownDocument {
|
|||||||
|
|
||||||
impl Render for MarkdownDocument {
|
impl Render for MarkdownDocument {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
self.take_orphan(window, cx);
|
||||||
// A top line asked before its text was read.
|
// A top line asked before its text was read.
|
||||||
self.place_top(window, cx);
|
self.place_top(window, cx);
|
||||||
self.show_waiting(window, cx);
|
self.show_waiting(window, cx);
|
||||||
|
self.open_waiting(window, cx);
|
||||||
let colors = cx.theme().colors.clone();
|
let colors = cx.theme().colors.clone();
|
||||||
let root = div()
|
let root = div()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -273,7 +352,13 @@ impl Render for MarkdownDocument {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.on_action(cx.listener(|document, _: &Paste, _, cx| {
|
.on_action(cx.listener(|document, _: &Paste, _, cx| {
|
||||||
document.paste_picture(cx);
|
if document.paste_picture(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||||
|
Some(text) => _ = document.insert(&text, cx),
|
||||||
|
None => log::info!("markdown document: nothing to paste"),
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
.capture_action(cx.listener(|document, _: &forms::Enter, _, cx| {
|
||||||
if document.block_newline(cx) {
|
if document.block_newline(cx) {
|
||||||
|
|||||||
+41
-11
@@ -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, DocumentTarget, FormatRefusal,
|
Attachment, BlockCommand, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget,
|
||||||
InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal, TargetBlock,
|
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};
|
||||||
|
|||||||
+155
-154
@@ -1,11 +1,10 @@
|
|||||||
use std::{path::Path, rc::Rc, sync::Arc};
|
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
|
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
||||||
UnderlineStyle, Window, div, prelude::*,
|
StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
|
||||||
};
|
};
|
||||||
use markdown::mdast::AlignKind;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
contents::anchor,
|
contents::anchor,
|
||||||
@@ -17,14 +16,17 @@ use crate::{
|
|||||||
editor::{code_colors, code_colors_with},
|
editor::{code_colors, code_colors_with},
|
||||||
navigation::{Anchor, Sections},
|
navigation::{Anchor, Sections},
|
||||||
primitives::FocusRing as _,
|
primitives::FocusRing as _,
|
||||||
theme::{ActiveTheme, Radius, TextSize},
|
theme::{ActiveTheme, Palette, Radius, TextSize},
|
||||||
typography::{Latex, failed},
|
typography::{Latex, failed},
|
||||||
};
|
};
|
||||||
|
|
||||||
mod blocks;
|
mod blocks;
|
||||||
|
mod math_text;
|
||||||
mod point;
|
mod point;
|
||||||
|
mod table;
|
||||||
|
|
||||||
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
|
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
|
||||||
|
pub(crate) use table::{CellAt, OpenCell};
|
||||||
|
|
||||||
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||||
/// Reveals the heading whose source starts at a byte.
|
/// Reveals the heading whose source starts at a byte.
|
||||||
@@ -96,6 +98,113 @@ pub(crate) struct Hooks<'a> {
|
|||||||
pub on_link: &'a Option<OnLink>,
|
pub on_link: &'a Option<OnLink>,
|
||||||
pub on_heading: &'a Option<OnHeading>,
|
pub on_heading: &'a Option<OnHeading>,
|
||||||
pub on_point: &'a Option<OnPoint>,
|
pub on_point: &'a Option<OnPoint>,
|
||||||
|
/// The cell typed in, drawn in its place.
|
||||||
|
pub cell: Option<&'a OpenCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Draws nodes under one owner's ids and hooks.
|
||||||
@@ -106,12 +215,16 @@ struct Draw<'a> {
|
|||||||
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>,
|
||||||
|
/// 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 open around the node drawn.
|
||||||
lists: std::cell::Cell<usize>,
|
lists: std::cell::Cell<usize>,
|
||||||
/// Drawing a table's cells.
|
/// Drawing a table's cells.
|
||||||
table: std::cell::Cell<bool>,
|
table: std::cell::Cell<bool>,
|
||||||
|
/// Tables drawn so far.
|
||||||
|
tables: std::cell::Cell<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Draw<'_> {
|
impl Draw<'_> {
|
||||||
@@ -122,83 +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()
|
}
|
||||||
},
|
let on_link = self.hooks.on_link.clone();
|
||||||
Style::Emphasis => HighlightStyle {
|
let (drawn, at) =
|
||||||
font_style: Some(FontStyle::Italic),
|
math_text::MathText::new(self.key("math"), inline.clone(), links, on_link);
|
||||||
..HighlightStyle::default()
|
self.point_text(drawn.into_any_element(), at, inline)
|
||||||
},
|
|
||||||
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()
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(range.clone(), highlight)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let styled =
|
|
||||||
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
|
||||||
let layout = styled.layout().clone();
|
|
||||||
let drawn = match inline.links.is_empty() {
|
|
||||||
true => styled.into_any_element(),
|
|
||||||
false => {
|
|
||||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
|
||||||
let on_link = self.hooks.on_link.clone();
|
|
||||||
InteractiveText::new(self.key("links"), styled)
|
|
||||||
.on_click(ranges, move |ix, window, cx| {
|
|
||||||
open_link(&on_link, &urls[ix], window, cx)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.point_text(drawn, layout, inline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||||
@@ -222,24 +273,22 @@ impl Draw<'_> {
|
|||||||
.child(self.inline(inline, cx))
|
.child(self.inline(inline, cx))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Heading(level, inline) => {
|
Node::Heading(level, inline) => {
|
||||||
let size = match level {
|
// Every level bold.
|
||||||
1 => TextSize::Xxl,
|
let size = theme.text_size(self.body) * heading_step(*level) * self.scale;
|
||||||
2 => TextSize::Xl,
|
|
||||||
3 => TextSize::Lg,
|
|
||||||
_ => TextSize::Md,
|
|
||||||
};
|
|
||||||
let heading = div()
|
|
||||||
.pt_2()
|
|
||||||
.text_size(theme.text_size(size) * self.scale)
|
|
||||||
.font_weight(if *level <= 2 {
|
|
||||||
FontWeight::SEMIBOLD
|
|
||||||
} else {
|
|
||||||
FontWeight::MEDIUM
|
|
||||||
})
|
|
||||||
.text_color(colors.fg)
|
|
||||||
.child(self.inline(inline, cx));
|
|
||||||
let ordinal = self.headings.get();
|
let ordinal = self.headings.get();
|
||||||
self.headings.set(ordinal + 1);
|
self.headings.set(ordinal + 1);
|
||||||
|
let heading = div()
|
||||||
|
.debug_selector(move || format!("markdown-heading-{ordinal}"))
|
||||||
|
.pt_2()
|
||||||
|
.text_size(size)
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.font_weight(HEAVY)
|
||||||
|
.text_color(if *level == 6 {
|
||||||
|
colors.fg_muted
|
||||||
|
} else {
|
||||||
|
colors.fg
|
||||||
|
})
|
||||||
|
.child(self.inline(inline, cx));
|
||||||
match self.sections {
|
match self.sections {
|
||||||
Some(sections) => Anchor::new(sections, anchor(ordinal))
|
Some(sections) => Anchor::new(sections, anchor(ordinal))
|
||||||
.child(heading)
|
.child(heading)
|
||||||
@@ -264,7 +313,7 @@ impl Draw<'_> {
|
|||||||
} => {
|
} => {
|
||||||
if language.as_deref() == Some("mermaid") {
|
if language.as_deref() == Some("mermaid") {
|
||||||
let diagram = Diagram::new(self.key("diagram"), text.clone());
|
let diagram = Diagram::new(self.key("diagram"), text.clone());
|
||||||
return self.point_code(diagram.into_any_element(), None, *from);
|
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(
|
||||||
@@ -299,63 +348,10 @@ impl Draw<'_> {
|
|||||||
}))
|
}))
|
||||||
.child(code)
|
.child(code)
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
self.point_code(block, Some((layout, text)), *from)
|
self.point_code(block, Some(layout), text, *from)
|
||||||
}
|
}
|
||||||
Node::List { start, items } => self.list(start, items, cx),
|
Node::List { start, items } => self.list(start, items, cx),
|
||||||
Node::Table { aligns, head, rows } => {
|
Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx),
|
||||||
let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| {
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.px_3()
|
|
||||||
.py_1p5()
|
|
||||||
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
|
|
||||||
cell.justify_end()
|
|
||||||
})
|
|
||||||
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
|
|
||||||
cell.justify_center()
|
|
||||||
})
|
|
||||||
.when(header, |cell| {
|
|
||||||
cell.font_weight(FontWeight::MEDIUM)
|
|
||||||
.text_color(colors.fg_muted)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.max_w_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.children(self.nodes(blocks, cx)),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.rounded(theme.radius(Radius::Md))
|
|
||||||
.border_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(
|
|
||||||
div().flex().bg(colors.hover).children(
|
|
||||||
head.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(column, blocks)| cell(blocks, column, true, cx)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.children(rows.iter().map(|row| {
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.border_t_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.children(
|
|
||||||
row.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(column, blocks)| cell(blocks, column, false, cx)),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
Node::Rule => div()
|
Node::Rule => div()
|
||||||
.debug_selector(|| "markdown-rule".into())
|
.debug_selector(|| "markdown-rule".into())
|
||||||
.my_2()
|
.my_2()
|
||||||
@@ -440,9 +436,11 @@ pub(crate) fn draw_nodes(
|
|||||||
sections: &None,
|
sections: &None,
|
||||||
headings: std::cell::Cell::new(0),
|
headings: std::cell::Cell::new(0),
|
||||||
code: &None,
|
code: &None,
|
||||||
|
body: TextSize::Base,
|
||||||
scale,
|
scale,
|
||||||
lists: std::cell::Cell::new(0),
|
lists: std::cell::Cell::new(0),
|
||||||
table: std::cell::Cell::new(false),
|
table: std::cell::Cell::new(false),
|
||||||
|
tables: std::cell::Cell::new(0),
|
||||||
};
|
};
|
||||||
draw.nodes(nodes, cx)
|
draw.nodes(nodes, cx)
|
||||||
}
|
}
|
||||||
@@ -458,14 +456,17 @@ impl RenderOnce for MarkdownRenderer {
|
|||||||
on_link: &self.on_link,
|
on_link: &self.on_link,
|
||||||
on_heading: &None,
|
on_heading: &None,
|
||||||
on_point: &None,
|
on_point: &None,
|
||||||
|
cell: None,
|
||||||
},
|
},
|
||||||
next: std::cell::Cell::new(0),
|
next: std::cell::Cell::new(0),
|
||||||
sections: &self.sections,
|
sections: &self.sections,
|
||||||
headings: std::cell::Cell::new(0),
|
headings: std::cell::Cell::new(0),
|
||||||
code: &self.code,
|
code: &self.code,
|
||||||
|
body: self.size,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
lists: std::cell::Cell::new(0),
|
lists: std::cell::Cell::new(0),
|
||||||
table: std::cell::Cell::new(false),
|
table: std::cell::Cell::new(false),
|
||||||
|
tables: std::cell::Cell::new(0),
|
||||||
};
|
};
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent,
|
AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent,
|
||||||
ParentElement as _, TextLayout, Window, div,
|
ParentElement as _, TextLayout, Window, div,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Draw;
|
use super::{Draw, table::CellAt};
|
||||||
use crate::documents::tree::Inline;
|
use crate::documents::tree::Inline;
|
||||||
|
|
||||||
/// Where a press fell, as the drawing knows it.
|
/// Where a press fell, as the drawing knows it.
|
||||||
@@ -20,6 +20,8 @@ pub(crate) enum Place {
|
|||||||
from: usize,
|
from: usize,
|
||||||
at: usize,
|
at: usize,
|
||||||
},
|
},
|
||||||
|
/// A table cell, pressed past its text.
|
||||||
|
Cell(CellAt),
|
||||||
/// Somewhere the block holds no byte for.
|
/// Somewhere the block holds no byte for.
|
||||||
Block,
|
Block,
|
||||||
}
|
}
|
||||||
@@ -29,7 +31,8 @@ pub(crate) struct Pointed {
|
|||||||
pub place: Place,
|
pub place: Place,
|
||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub code: bool,
|
/// The code's text, pressed in a code block.
|
||||||
|
pub code: Option<Rc<str>>,
|
||||||
pub table: bool,
|
pub table: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ impl Pointed {
|
|||||||
place,
|
place,
|
||||||
link: None,
|
link: None,
|
||||||
image: None,
|
image: None,
|
||||||
code: false,
|
code: None,
|
||||||
table: false,
|
table: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,19 @@ fn pointable(
|
|||||||
table: bool,
|
table: bool,
|
||||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
match on_point {
|
||||||
|
Some(_) => pressed(div().child(element), on_point, table, point).into_any_element(),
|
||||||
|
None => element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `element` itself takes the presses `pointable` hands on.
|
||||||
|
fn pressed<E: InteractiveElement>(
|
||||||
|
element: E,
|
||||||
|
on_point: &Option<OnPoint>,
|
||||||
|
table: bool,
|
||||||
|
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||||
|
) -> E {
|
||||||
let Some(on_point) = on_point.clone() else {
|
let Some(on_point) = on_point.clone() else {
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
@@ -78,11 +94,9 @@ fn pointable(
|
|||||||
on_point(pointed, button, window, cx);
|
on_point(pointed, button, window, cx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
div()
|
element
|
||||||
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||||
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||||
.child(element)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drawn `text`, its lines written from source byte `from`.
|
/// Drawn `text`, its lines written from source byte `from`.
|
||||||
@@ -100,7 +114,7 @@ pub(crate) fn point_lines(
|
|||||||
.index_for_position(event.position)
|
.index_for_position(event.position)
|
||||||
.unwrap_or_else(|near| near);
|
.unwrap_or_else(|near| near);
|
||||||
Pointed {
|
Pointed {
|
||||||
code,
|
code: code.then(|| text.clone()),
|
||||||
..Pointed::at(Place::Lines {
|
..Pointed::at(Place::Lines {
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
from,
|
from,
|
||||||
@@ -119,11 +133,18 @@ impl Draw<'_> {
|
|||||||
pointable(element, self.hooks.on_point, self.table.get(), point)
|
pointable(element, self.hooks.on_point, self.table.get(), point)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drawn `inline`; a press finds its byte through `layout`.
|
/// A table cell taking its presses, past its text too.
|
||||||
|
pub(super) fn pressed_cell(&self, cell: Div, at: CellAt) -> Div {
|
||||||
|
pressed(cell, self.hooks.on_point, true, move |_| {
|
||||||
|
Pointed::at(Place::Cell(at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawn `inline`; a press finds its byte through `at`.
|
||||||
pub(super) fn point_text(
|
pub(super) fn point_text(
|
||||||
&self,
|
&self,
|
||||||
element: AnyElement,
|
element: AnyElement,
|
||||||
layout: TextLayout,
|
at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
|
||||||
inline: &Inline,
|
inline: &Inline,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if self.hooks.on_point.is_none() {
|
if self.hooks.on_point.is_none() {
|
||||||
@@ -131,9 +152,7 @@ impl Draw<'_> {
|
|||||||
}
|
}
|
||||||
let inline = Rc::new(inline.clone());
|
let inline = Rc::new(inline.clone());
|
||||||
self.pointable(element, move |event| {
|
self.pointable(element, move |event| {
|
||||||
let at = layout
|
let at = at(event.position);
|
||||||
.index_for_position(event.position)
|
|
||||||
.unwrap_or_else(|near| near);
|
|
||||||
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||||
Pointed {
|
Pointed {
|
||||||
link: link.map(|(_, url)| url.clone()),
|
link: link.map(|(_, url)| url.clone()),
|
||||||
@@ -146,12 +165,13 @@ impl Draw<'_> {
|
|||||||
pub(super) fn point_code(
|
pub(super) fn point_code(
|
||||||
&self,
|
&self,
|
||||||
element: AnyElement,
|
element: AnyElement,
|
||||||
drawn: Option<(TextLayout, &str)>,
|
layout: Option<TextLayout>,
|
||||||
|
text: &str,
|
||||||
from: usize,
|
from: usize,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let table = self.table.get();
|
let table = self.table.get();
|
||||||
match drawn {
|
match layout {
|
||||||
Some((layout, text)) => point_lines(
|
Some(layout) => point_lines(
|
||||||
element,
|
element,
|
||||||
layout,
|
layout,
|
||||||
text,
|
text,
|
||||||
@@ -159,10 +179,13 @@ impl Draw<'_> {
|
|||||||
self.hooks.on_point,
|
self.hooks.on_point,
|
||||||
(table, true),
|
(table, true),
|
||||||
),
|
),
|
||||||
None => self.pointable(element, |_| Pointed {
|
None => {
|
||||||
code: true,
|
let text: Rc<str> = Rc::from(text);
|
||||||
..Pointed::block()
|
self.pointable(element, move |_| Pointed {
|
||||||
}),
|
code: Some(text.clone()),
|
||||||
|
..Pointed::block()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px};
|
use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px, rems};
|
||||||
|
|
||||||
use super::MarkdownRenderer;
|
use super::{HEAVY, MarkdownRenderer, Style, highlight};
|
||||||
use crate::theme::Theme;
|
use crate::theme::{ActiveTheme, TextSize, Theme};
|
||||||
|
|
||||||
/// `source` drawn in a 400-point column.
|
/// `source` drawn in a 400-point column.
|
||||||
struct Page(&'static str);
|
struct Page(&'static str);
|
||||||
@@ -51,3 +51,103 @@ fn alerts_lists_of_headings_and_front_matter_draw(cx: &mut TestAppContext) {
|
|||||||
);
|
);
|
||||||
assert!(two.top() > one.top(), "{one:?} {two:?}");
|
assert!(two.top() > one.top(), "{one:?} {two:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One heading of every level.
|
||||||
|
const HEADINGS: &str = "# a
|
||||||
|
|
||||||
|
## a
|
||||||
|
|
||||||
|
### a
|
||||||
|
|
||||||
|
#### a
|
||||||
|
|
||||||
|
##### a
|
||||||
|
|
||||||
|
###### a
|
||||||
|
";
|
||||||
|
|
||||||
|
/// `source` in a 400-point column, body at `size`.
|
||||||
|
struct Sized(&'static str, TextSize);
|
||||||
|
|
||||||
|
impl Render for Sized {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.w(px(400.0))
|
||||||
|
.child(MarkdownRenderer::new("page", self.0).text_size(self.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn headings_step_up_from_the_body_by_typoras_ratios(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
for body in [TextSize::Base, TextSize::Lg] {
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Sized(HEADINGS, body));
|
||||||
|
cx.run_until_parked();
|
||||||
|
let (base, pad) = cx.update(|window, cx| {
|
||||||
|
let rem = window.rem_size();
|
||||||
|
(
|
||||||
|
cx.theme().text_size(body).to_pixels(rem),
|
||||||
|
rems(0.5).to_pixels(rem),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for (ordinal, step) in [2.25, 1.75, 1.5, 1.25, 1.0, 1.0].into_iter().enumerate() {
|
||||||
|
let selector = Box::leak(format!("markdown-heading-{ordinal}").into_boxed_str());
|
||||||
|
let high = cx.debug_bounds(selector).expect("drawn").size.height;
|
||||||
|
let off = high - pad - base * step * 1.3;
|
||||||
|
assert!(
|
||||||
|
off.abs() < px(0.5),
|
||||||
|
"{body:?} level {}: {high:?}",
|
||||||
|
ordinal + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn strong_text_in_a_heading_keeps_its_weight(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| {
|
||||||
|
Page(
|
||||||
|
"### Plain **Strong** plain
|
||||||
|
",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(cx.debug_bounds("markdown-heading-0").is_some(), "drawn");
|
||||||
|
let strong = cx.update(|_, cx| {
|
||||||
|
let theme = cx.theme();
|
||||||
|
highlight(&Style::Strong, &theme.colors, theme.underline_thickness()).font_weight
|
||||||
|
});
|
||||||
|
assert_eq!(strong, Some(HEAVY), "strong never lightens a heading");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_zoomed_document_scales_its_headings(cx: &mut TestAppContext) {
|
||||||
|
use crate::documents::{DocumentLook, MarkdownDocument, MarkdownMode};
|
||||||
|
cx.update(crate::init_for_tests);
|
||||||
|
let (document, cx) = cx.add_window_view(|window, cx| {
|
||||||
|
MarkdownDocument::new("### a\n", MarkdownMode::Preview, window, cx)
|
||||||
|
});
|
||||||
|
document.update(cx, |document, cx| {
|
||||||
|
let look = DocumentLook {
|
||||||
|
scale: 1.5,
|
||||||
|
..DocumentLook::default()
|
||||||
|
};
|
||||||
|
document.set_look(look, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let (base, pad) = cx.update(|window, cx| {
|
||||||
|
let rem = window.rem_size();
|
||||||
|
(
|
||||||
|
cx.theme().text_size(TextSize::Base).to_pixels(rem),
|
||||||
|
rems(0.5).to_pixels(rem),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let high = cx
|
||||||
|
.debug_bounds("markdown-heading-0")
|
||||||
|
.expect("drawn")
|
||||||
|
.size
|
||||||
|
.height;
|
||||||
|
let off = high - pad - base * 1.5 * 1.5 * 1.3;
|
||||||
|
assert!(off.abs() < px(0.5), "{high:?}");
|
||||||
|
}
|
||||||
@@ -194,7 +194,7 @@ mod tests {
|
|||||||
let bold = 2..3;
|
let bold = 2..3;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(line.text.as_str(), &line.styles[..]),
|
(line.text.as_str(), &line.styles[..]),
|
||||||
("a b c", &[(bold, Style::Strong)][..])
|
("a b\nc", &[(bold, Style::Strong)][..])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ mod tests {
|
|||||||
other => panic!("a paragraph, not {other:?}"),
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(lines, ["<script> first", "second </script>"]);
|
assert_eq!(lines, ["<script> first", "second\n</script>"]);
|
||||||
let inline = html("a <script>b</script> c").expect("text");
|
let inline = html("a <script>b</script> c").expect("text");
|
||||||
assert_eq!(inline.text, "a <script>b</script> c");
|
assert_eq!(inline.text, "a <script>b</script> c");
|
||||||
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
|
||||||
|
|||||||
+36
-10
@@ -6,7 +6,7 @@ use super::markdown::{span, tree, walk};
|
|||||||
|
|
||||||
mod blocks;
|
mod blocks;
|
||||||
mod emoji;
|
mod emoji;
|
||||||
mod html;
|
pub(crate) mod html;
|
||||||
mod html_block;
|
mod html_block;
|
||||||
mod sources;
|
mod sources;
|
||||||
mod stack;
|
mod stack;
|
||||||
@@ -101,6 +101,8 @@ pub struct Reader<'a> {
|
|||||||
notes: HashMap<String, usize>,
|
notes: HashMap<String, usize>,
|
||||||
/// Every heading, as `[toc]` lists them.
|
/// Every heading, as `[toc]` lists them.
|
||||||
pub headings: Vec<DocumentHeading>,
|
pub headings: Vec<DocumentHeading>,
|
||||||
|
/// Soft breaks as lines, as the screen draws them.
|
||||||
|
lines: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An open element, as HTML names it.
|
/// An open element, as HTML names it.
|
||||||
@@ -122,6 +124,8 @@ struct Run {
|
|||||||
verbatim: bool,
|
verbatim: bool,
|
||||||
/// In a table cell, whose code reads `\|` as `|`.
|
/// In a table cell, whose code reads `\|` as `|`.
|
||||||
table: bool,
|
table: bool,
|
||||||
|
/// Soft breaks as lines, not spaces.
|
||||||
|
lines: bool,
|
||||||
/// The current DOM text node's shortcodes.
|
/// The current DOM text node's shortcodes.
|
||||||
shortcodes: emoji::Shortcodes,
|
shortcodes: emoji::Shortcodes,
|
||||||
/// White space owed, and the open elements before it.
|
/// White space owed, and the open elements before it.
|
||||||
@@ -159,8 +163,9 @@ impl Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn push_plain(&mut self, shown: &str) {
|
fn push_plain(&mut self, shown: &str) {
|
||||||
let soft = shown.replace("\r\n", " ").replace(['\r', '\n'], " ");
|
let soft = if self.lines { "\n" } else { " " };
|
||||||
self.text.text.push_str(&soft);
|
let shown = shown.replace("\r\n", soft).replace(['\r', '\n'], soft);
|
||||||
|
self.text.text.push_str(&shown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +180,17 @@ pub fn parse(markdown: &str) -> Vec<Node> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Reader<'a> {
|
impl<'a> Reader<'a> {
|
||||||
/// A reader of `root`, parsed from `source`.
|
/// Reads `root`, parsed from `source`, as drawn on screen.
|
||||||
pub fn new(source: &'a str, root: &mdast::Node) -> Self {
|
pub fn new(source: &'a str, root: &mdast::Node) -> Self {
|
||||||
|
Self::reading(source, root, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, soft breaks joined as GFM output joins them.
|
||||||
|
pub fn joined(source: &'a str, root: &mdast::Node) -> Self {
|
||||||
|
Self::reading(source, root, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reading(source: &'a str, root: &mdast::Node, lines: bool) -> Self {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
walk(root, &mut nodes);
|
walk(root, &mut nodes);
|
||||||
let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
|
let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new());
|
||||||
@@ -199,6 +213,7 @@ impl<'a> Reader<'a> {
|
|||||||
urls,
|
urls,
|
||||||
notes,
|
notes,
|
||||||
headings: Vec::new(),
|
headings: Vec::new(),
|
||||||
|
lines: false,
|
||||||
};
|
};
|
||||||
reader.headings = headings
|
reader.headings = headings
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -208,6 +223,8 @@ impl<'a> Reader<'a> {
|
|||||||
at,
|
at,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
// Titles read on one line; the body as asked.
|
||||||
|
reader.lines = lines;
|
||||||
reader
|
reader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +253,7 @@ impl<'a> Reader<'a> {
|
|||||||
match node {
|
match node {
|
||||||
mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶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));
|
||||||
}
|
}
|
||||||
@@ -301,16 +318,18 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text split at its images; lone `$$` math is block.
|
/// Text splits at images; a lone formula is block.
|
||||||
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
||||||
if let [mdast::Node::InlineMath(math)] = children
|
if let [mdast::Node::InlineMath(math)] = children
|
||||||
&& self.source[span(node)].starts_with("$$")
|
&& ["$$", "\\["]
|
||||||
|
.iter()
|
||||||
|
.any(|open| self.source[span(&children[0])].starts_with(open))
|
||||||
{
|
{
|
||||||
return out.push(Node::Math(math.value.clone()));
|
return out.push(Node::Math(math.value.trim().to_string()));
|
||||||
}
|
}
|
||||||
let mut run = Run {
|
let mut run = Run {
|
||||||
table: matches!(node, mdast::Node::TableCell(_)),
|
table: matches!(node, mdast::Node::TableCell(_)),
|
||||||
..Run::default()
|
..self.run()
|
||||||
};
|
};
|
||||||
for child in children {
|
for child in children {
|
||||||
self.inline_into(child, &mut run, &mut Images::Split(out));
|
self.inline_into(child, &mut run, &mut Images::Split(out));
|
||||||
@@ -318,6 +337,13 @@ impl<'a> Reader<'a> {
|
|||||||
text_block(run.cut(), out);
|
text_block(run.cut(), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run(&self) -> Run {
|
||||||
|
Run {
|
||||||
|
lines: self.lines,
|
||||||
|
..Run::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A footnote's number, by its first reference.
|
/// A footnote's number, by its first reference.
|
||||||
pub fn note(&self, identifier: &str) -> Option<usize> {
|
pub fn note(&self, identifier: &str) -> Option<usize> {
|
||||||
self.notes.get(identifier).copied()
|
self.notes.get(identifier).copied()
|
||||||
@@ -336,7 +362,7 @@ impl<'a> Reader<'a> {
|
|||||||
|
|
||||||
/// Inline text where images draw as their alt text.
|
/// Inline text where images draw as their alt text.
|
||||||
pub fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
pub fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
||||||
let mut run = Run::default();
|
let mut run = self.run();
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
self.inline_into(node, &mut run, &mut Images::Alt);
|
self.inline_into(node, &mut run, &mut Images::Alt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escape
|
|||||||
Some(match node {
|
Some(match node {
|
||||||
Node::Text(_) => (whole, Escapes::Markdown),
|
Node::Text(_) => (whole, Escapes::Markdown),
|
||||||
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
||||||
Node::InlineMath(_) => (inside(source, whole, '$'), Escapes::Plain),
|
Node::InlineMath(_) => (
|
||||||
|
crate::documents::markdown::math_inside(source, whole),
|
||||||
|
Escapes::Plain,
|
||||||
|
),
|
||||||
_ => (whole, Escapes::Plain),
|
_ => (whole, Escapes::Plain),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,25 @@ impl Inline {
|
|||||||
// Unreadable text lands at its start.
|
// Unreadable text lands at its start.
|
||||||
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
|
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last drawn byte written by source byte `at`.
|
||||||
|
pub(crate) fn drawn_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||||
|
(0..self.sources.len()).rev().find_map(|piece| {
|
||||||
|
let (start, span, escapes) = &self.sources[piece];
|
||||||
|
if span.start > at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let end = (self.sources.get(piece + 1)).map_or(self.text.len(), |(next, ..)| *next);
|
||||||
|
let drawn = self.text.get(*start..end.max(*start))?;
|
||||||
|
let written = source.get(span.clone())?;
|
||||||
|
// Unreadable text lands at its start.
|
||||||
|
let mut places = aligned(drawn, written, *escapes).into_iter();
|
||||||
|
(drawn.char_indices().rev())
|
||||||
|
.map(|(byte, _)| (byte, places.next().map_or(0, |(_, place)| place)))
|
||||||
|
.find(|(_, place)| span.start + place <= at)
|
||||||
|
.map(|(byte, _)| start + byte)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A source char as read, and where it was written.
|
/// A source char as read, and where it was written.
|
||||||
@@ -285,19 +307,31 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
|
|||||||
if at >= drawn.len() {
|
if at >= drawn.len() {
|
||||||
return Some(written.len());
|
return Some(written.len());
|
||||||
}
|
}
|
||||||
|
(aligned(drawn, written, escapes).into_iter())
|
||||||
|
.find(|(byte, _)| *byte == at)
|
||||||
|
.map(|(_, place)| place)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawn chars from the end and where each was written.
|
||||||
|
fn aligned(drawn: &str, written: &str, escapes: Escapes) -> Vec<(usize, usize)> {
|
||||||
let source = read(written, escapes);
|
let source = read(written, escapes);
|
||||||
let mut j = source.len();
|
let mut j = source.len();
|
||||||
|
let mut out = Vec::new();
|
||||||
for (byte, d) in drawn.char_indices().rev() {
|
for (byte, d) in drawn.char_indices().rev() {
|
||||||
let found = loop {
|
let found = loop {
|
||||||
let s = source[..j].last()?;
|
let Some(s) = source[..j].last() else {
|
||||||
if d == s.ch {
|
return out;
|
||||||
|
};
|
||||||
|
if d == s.ch || (d.is_whitespace() && s.ends_line()) {
|
||||||
j -= 1;
|
j -= 1;
|
||||||
break s.at;
|
// A CRLF drawn as one char is one end.
|
||||||
}
|
let collapsed = !drawn[..byte].ends_with('\r');
|
||||||
// A join: a line's end drawn as white space.
|
if collapsed
|
||||||
if d.is_whitespace() && s.ends_line() {
|
&& s.ch == '\n'
|
||||||
j -= 1;
|
&& j > 0
|
||||||
if s.ch == '\n' && j > 0 && source[j - 1].ch == '\r' && source[j - 1].raw == s.raw {
|
&& source[j - 1].ch == '\r'
|
||||||
|
&& source[j - 1].raw == s.raw
|
||||||
|
{
|
||||||
j -= 1;
|
j -= 1;
|
||||||
}
|
}
|
||||||
break source[j].at;
|
break source[j].at;
|
||||||
@@ -307,29 +341,54 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
|
|||||||
j -= 1;
|
j -= 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return None;
|
return out;
|
||||||
};
|
};
|
||||||
if byte == at {
|
out.push((byte, found));
|
||||||
return Some(found);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::{Reader, tree};
|
use super::super::{Inline, Reader, tree};
|
||||||
|
|
||||||
/// The source byte under drawn text in the first paragraph.
|
/// The first paragraph, as drawn.
|
||||||
fn under(source: &str, drawn: &str) -> usize {
|
fn first(source: &str) -> Inline {
|
||||||
let root = tree(source);
|
let root = tree(source);
|
||||||
let reader = Reader::new(source, &root);
|
let reader = Reader::new(source, &root);
|
||||||
let paragraph = &root.children().expect("blocks")[0];
|
let paragraph = &root.children().expect("blocks")[0];
|
||||||
let inline = reader.inline(paragraph.children().expect("inlines"));
|
reader.inline(paragraph.children().expect("inlines"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source byte under drawn text in the first paragraph.
|
||||||
|
fn under(source: &str, drawn: &str) -> usize {
|
||||||
|
let inline = first(source);
|
||||||
let at = inline.text.find(drawn).expect("drawn");
|
let at = inline.text.find(drawn).expect("drawn");
|
||||||
inline.source_at(source, at).expect("a piece")
|
inline.source_at(source, at).expect("a piece")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_byte_reads_back_to_its_drawn_byte() {
|
||||||
|
let source = "Say **bold** and \\*x";
|
||||||
|
let inline = first(source);
|
||||||
|
let bold = source.find("bold").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, bold), inline.text.find("bold"));
|
||||||
|
let mark = source.find("**").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, mark), inline.text.find(' '));
|
||||||
|
let star = source.find("*x").unwrap();
|
||||||
|
assert_eq!(inline.drawn_at(source, star), inline.text.find('*'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_paragraph_reads_back_in_one_pass() {
|
||||||
|
let source = "x".repeat(40_000);
|
||||||
|
let inline = first(&source);
|
||||||
|
let started = web_time::Instant::now();
|
||||||
|
assert_eq!(inline.drawn_at(&source, 5), Some(5));
|
||||||
|
let took = started.elapsed();
|
||||||
|
assert!(took.as_millis() < 500, "took {took:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_word_lands_where_it_was_written() {
|
fn a_word_lands_where_it_was_written() {
|
||||||
let source = "Say **bold** and `code` here\nnext line";
|
let source = "Say **bold** and `code` here\nnext line";
|
||||||
@@ -339,6 +398,22 @@ mod tests {
|
|||||||
assert_eq!(under(source, "next"), source.find("next").unwrap());
|
assert_eq!(under(source, "next"), source.find("next").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drawn_crlf_reads_back_to_its_start() {
|
||||||
|
let source = "one\r\ntwo";
|
||||||
|
let inline = first(source);
|
||||||
|
let end = inline.text.find('\n').expect("a drawn line end");
|
||||||
|
assert_eq!(inline.source_at(source, end), Some(3));
|
||||||
|
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kept_crlf_reads_back_char_for_char() {
|
||||||
|
let source = "`one\r\ntwo`\n";
|
||||||
|
assert_eq!(under(source, "ne"), source.find("ne").unwrap());
|
||||||
|
assert_eq!(under(source, "two"), source.find("two").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn joins_escapes_and_indents_read_back() {
|
fn joins_escapes_and_indents_read_back() {
|
||||||
let source = "a \\*star\\* x\n y";
|
let source = "a \\*star\\* x\n y";
|
||||||
|
|||||||
@@ -68,20 +68,31 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() {
|
fn a_soft_break_draws_as_a_line_and_joins_for_output() {
|
||||||
for (source, shown) in [
|
for source in [
|
||||||
("one\ntwo \nthree", "one two\nthree"),
|
"one\ntwo \nthree",
|
||||||
("one\r\ntwo \r\nthree", "one two\nthree"),
|
"one\r\ntwo \r\nthree",
|
||||||
("one\rtwo \rthree", "one two\nthree"),
|
"one\rtwo \rthree",
|
||||||
] {
|
] {
|
||||||
let nodes = parse(source);
|
let nodes = parse(source);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&nodes[0], Node::Paragraph(text) if text.text == shown),
|
matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"),
|
||||||
"{source:?} reads {nodes:?}"
|
"{source:?} draws {nodes:?}"
|
||||||
);
|
);
|
||||||
|
let root = super::super::markdown::tree(source);
|
||||||
|
let paragraph = &root.children().expect("blocks")[0];
|
||||||
|
let joined = Reader::joined(source, &root).inline(paragraph.children().expect("inlines"));
|
||||||
|
assert_eq!(joined.text, "one two\nthree", "{source:?} joins");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_title_reads_on_one_line() {
|
||||||
|
let source = "one\ntwo\n===\n";
|
||||||
|
let root = super::super::markdown::tree(source);
|
||||||
|
assert_eq!(Reader::new(source, &root).headings[0].title, "one two");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn definitions_nested_in_quotes_and_lists_resolve() {
|
fn definitions_nested_in_quotes_and_lists_resolve() {
|
||||||
for source in [
|
for source in [
|
||||||
@@ -385,9 +396,9 @@ fn the_first_shortcode_decides_its_text_node() {
|
|||||||
("a.:x.y: :smile:", "a.:x.y: 😄"),
|
("a.:x.y: :smile:", "a.:x.y: 😄"),
|
||||||
("x\t:smile: y:smile:", "x\t:smile: y:smile:"),
|
("x\t:smile: y:smile:", "x\t:smile: y:smile:"),
|
||||||
("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"),
|
("x\u{a0}:smile: y:smile:", "x\u{a0}:smile: y:smile:"),
|
||||||
("x\n:smile: y:smile:", "x :smile: y:smile:"),
|
("x\n:smile: y:smile:", "x\n:smile: y:smile:"),
|
||||||
("*x*:smile: :smile:", "x😄 😄"),
|
("*x*:smile: :smile:", "x😄 😄"),
|
||||||
("*x*\n:smile: y:smile:", "x 😄 y😄"),
|
("*x*\n:smile: y:smile:", "x\n😄 y😄"),
|
||||||
("x:smile: `c` :smile:", "x:smile: c 😄"),
|
("x:smile: `c` :smile:", "x:smile: c 😄"),
|
||||||
("x:smile:<!-- c --> :smile:", "x:smile: 😄"),
|
("x:smile:<!-- c --> :smile:", "x:smile: 😄"),
|
||||||
("x:smile:<span>s</span> :smile:", "x:smile:s 😄"),
|
("x:smile:<span>s</span> :smile:", "x:smile:s 😄"),
|
||||||
@@ -405,3 +416,47 @@ fn the_first_shortcode_decides_its_text_node() {
|
|||||||
assert_eq!(line(source).text, shown, "{source:?}");
|
assert_eq!(line(source).text, shown, "{source:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_read_as_math() {
|
||||||
|
assert_eq!(parse(r"\[c = 1\]"), [Node::Math("c = 1".into())]);
|
||||||
|
assert_eq!(parse("\\[\nc = 1\n\\]"), [Node::Math("c = 1".into())]);
|
||||||
|
for indent in [" ", " ", " "] {
|
||||||
|
let source = format!(r"{indent}\[c = 1\]");
|
||||||
|
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||||
|
let source = format!("{indent}$$c = 1$$");
|
||||||
|
assert_eq!(parse(&source), [Node::Math("c = 1".into())], "{source:?}");
|
||||||
|
}
|
||||||
|
let Node::Paragraph(inline) = &parse(r"so \(c\) holds")[0] else {
|
||||||
|
panic!("inline math stays in its paragraph")
|
||||||
|
};
|
||||||
|
assert_eq!(inline.text, "so c holds");
|
||||||
|
assert_eq!(inline.styles, [(3..4, Style::Math)]);
|
||||||
|
let Node::Paragraph(alone) = &parse(r"\(c\)")[0] else {
|
||||||
|
panic!("a lone formula stays inline")
|
||||||
|
};
|
||||||
|
assert_eq!(alone.styles, [(0..1, Style::Math)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_leave_code_escapes_and_open_pairs() {
|
||||||
|
let text = |source: &str| match &parse(source)[0] {
|
||||||
|
Node::Paragraph(inline) => (inline.text.clone(), inline.styles.clone()),
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(text(r"`\(c\)`").1, [(0..5, Style::Code)], "code stays code");
|
||||||
|
assert_eq!(
|
||||||
|
text(r"\\(c\\)"),
|
||||||
|
(r"\(c\)".into(), Vec::new()),
|
||||||
|
"escaped backslashes"
|
||||||
|
);
|
||||||
|
assert_eq!(text(r"a \(c").0, "a (c", "no closing, no formula");
|
||||||
|
let split = parse("a \\(c\n\nd\\) e");
|
||||||
|
assert!(
|
||||||
|
split.iter().all(|node| match node {
|
||||||
|
Node::Paragraph(inline) => inline.styles.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
}),
|
||||||
|
"a blank line ends the formula: {split:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-3
@@ -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;
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ pub(crate) struct Metrics {
|
|||||||
pub top: Pixels,
|
pub top: Pixels,
|
||||||
pub advance: Pixels,
|
pub advance: Pixels,
|
||||||
pub line: Pixels,
|
pub line: Pixels,
|
||||||
|
/// The code font's size.
|
||||||
|
pub size: Pixels,
|
||||||
/// The view's height.
|
/// The view's height.
|
||||||
pub height: Pixels,
|
pub height: Pixels,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, Left, Paste, Redo, Right, Submit,
|
Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
|
||||||
Undo, Up, bind_keys, from_utf16, to_utf16,
|
from_utf16, to_utf16,
|
||||||
};
|
};
|
||||||
pub use text::{Highlight, History, InputEvent, TextInput};
|
pub use text::{Copy, Cut, Highlight, History, InputEvent, Paste, SelectAll, TextInput};
|
||||||
pub use transfer::TransferList;
|
pub use transfer::TransferList;
|
||||||
pub use unit::UnitInput;
|
pub use unit::UnitInput;
|
||||||
pub use upload::{Upload, UploadList, UploadState};
|
pub use upload::{Upload, UploadList, UploadState};
|
||||||
+7
-5
@@ -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, Left, Paste, Redo, Right, Submit,
|
Backspace, Delete, DeleteWordLeft, Down, Enter, Left, Redo, Right, Submit, Undo, Up, bind_keys,
|
||||||
Undo, Up, bind_keys,
|
|
||||||
};
|
};
|
||||||
|
pub use actions::{Copy, Cut, Paste, SelectAll};
|
||||||
pub use edit::History;
|
pub use edit::History;
|
||||||
use edit::Snapshot;
|
use edit::Snapshot;
|
||||||
pub(crate) use edit::{from_utf16, to_utf16};
|
pub(crate) use edit::{from_utf16, to_utf16};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-19
@@ -1,13 +1,14 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
||||||
MouseButton, ParentElement, Role, SharedString, Stateful, Styled, accesskit::Action, div,
|
MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action,
|
||||||
prelude::*,
|
canvas, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
menu::{Open, at_depth, choose, mark, measure},
|
aim,
|
||||||
|
menu::{Open, at_depth, choose},
|
||||||
model::{Entry, Kind, Menu, MenuItem},
|
model::{Entry, Kind, Menu, MenuItem},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -193,14 +194,18 @@ pub(super) fn panel(
|
|||||||
choose(&click, &menu, depth, (ix, column), &close, window, cx)
|
choose(&click, &menu, depth, (ix, column), &close, window, cx)
|
||||||
});
|
});
|
||||||
let drawn = button(item, (ix, column), current, press, cx);
|
let drawn = button(item, (ix, column), current, press, cx);
|
||||||
|
let left = state.clone();
|
||||||
|
let here = (depth, (ix, column));
|
||||||
drawn.when(!item.disabled, |button| {
|
drawn.when(!item.disabled, |button| {
|
||||||
button.on_mouse_move(move |_, _, cx| {
|
button
|
||||||
let open = hover.read(cx);
|
.on_mouse_move(move |event, _, cx| {
|
||||||
let marked = open.levels.get(depth).map(|l| (l.at, l.column));
|
aim::hover(&hover, &hover_menu, here, false, event.position, cx)
|
||||||
if marked != Some((Some(ix), column)) {
|
})
|
||||||
mark(&hover, &hover_menu, depth, (ix, column), false, cx);
|
.on_hover(move |hovered, _, cx| {
|
||||||
}
|
if !hovered {
|
||||||
})
|
aim::leave(&left, here, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
@@ -210,16 +215,24 @@ pub(super) fn panel(
|
|||||||
let (hover, click, close, menu) =
|
let (hover, click, close, menu) =
|
||||||
(state.clone(), state.clone(), close.clone(), menu.clone());
|
(state.clone(), state.clone(), close.clone(), menu.clone());
|
||||||
let hover_menu = menu.clone();
|
let hover_menu = menu.clone();
|
||||||
|
let label = item.label.clone();
|
||||||
row(item, ix, marked, cx)
|
row(item, ix, marked, cx)
|
||||||
|
.debug_selector(move || format!("menu-row-{label}"))
|
||||||
.when(!item.disabled, |row| {
|
.when(!item.disabled, |row| {
|
||||||
row.on_mouse_move(move |_, _, cx| {
|
let left = state.clone();
|
||||||
let open = hover.read(cx);
|
row.on_mouse_move(move |event, _, cx| {
|
||||||
let Some(level) = open.levels.get(depth) else {
|
aim::hover(
|
||||||
return;
|
&hover,
|
||||||
};
|
&hover_menu,
|
||||||
let shown = open.levels.len() > depth + 1;
|
(depth, (ix, 0)),
|
||||||
if level.at != Some(ix) || (sub && !shown) {
|
sub,
|
||||||
mark(&hover, &hover_menu, depth, (ix, 0), true, cx);
|
event.position,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_hover(move |hovered, _, cx| {
|
||||||
|
if !hovered {
|
||||||
|
aim::leave(&left, (depth, (ix, 0)), cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_mouse_down(
|
.on_mouse_down(
|
||||||
@@ -244,7 +257,10 @@ pub(super) fn panel(
|
|||||||
});
|
});
|
||||||
surface((id.clone(), format!("panel-{depth}")), cx)
|
surface((id.clone(), format!("panel-{depth}")), cx)
|
||||||
.role(Role::Menu)
|
.role(Role::Menu)
|
||||||
|
.debug_selector(move || format!("menu-panel-{depth}"))
|
||||||
.relative()
|
.relative()
|
||||||
|
// Rows under this panel take no hover.
|
||||||
|
.occlude()
|
||||||
// Presses here are the menu's, not its host's.
|
// Presses here are the menu's, not its host's.
|
||||||
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
||||||
window.prevent_default();
|
window.prevent_default();
|
||||||
@@ -263,3 +279,23 @@ pub(super) fn panel(
|
|||||||
changed
|
changed
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the host's, a marked row's or a panel's box.
|
||||||
|
pub(super) fn measure(
|
||||||
|
state: Entity<Open>,
|
||||||
|
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
canvas(
|
||||||
|
move |bounds, window, cx| {
|
||||||
|
if state.update(cx, |open, _| write(open, bounds)) {
|
||||||
|
state.update(cx, |_, cx| cx.notify());
|
||||||
|
window.request_animation_frame();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_, _, _, _| {},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.left_0()
|
||||||
|
.size_full()
|
||||||
|
}
|
||||||
+49
-41
@@ -1,13 +1,13 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
|
Anchor, Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity,
|
||||||
IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Window, anchored, canvas, div,
|
InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task,
|
||||||
prelude::*,
|
Window, anchored, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
draw,
|
draw::{self, measure},
|
||||||
model::{Kind, Menu},
|
model::{Kind, Menu},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -80,6 +80,12 @@ pub(crate) struct Open {
|
|||||||
host: Bounds<Pixels>,
|
host: Bounds<Pixels>,
|
||||||
anchor: Bounds<Pixels>,
|
anchor: Bounds<Pixels>,
|
||||||
takeover: Option<Entity<Takeover>>,
|
takeover: Option<Entity<Takeover>>,
|
||||||
|
/// The pointer's last place over a row.
|
||||||
|
pub(super) pointer: Option<Point<Pixels>>,
|
||||||
|
/// The row under the pointer: depth, index and column.
|
||||||
|
pub(super) over: Option<(usize, (usize, usize))>,
|
||||||
|
/// A delayed mark; dropping it cancels.
|
||||||
|
pub(super) pending: Option<Task<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Open {
|
impl Open {
|
||||||
@@ -99,19 +105,20 @@ impl Open {
|
|||||||
|
|
||||||
/// Opens at `spot`, with the first row marked when `marked`.
|
/// Opens at `spot`, with the first row marked when `marked`.
|
||||||
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
||||||
let first = menu.step(None, 1).filter(|_| marked);
|
state.update(cx, |open, _| open.spot = Some(spot));
|
||||||
state.update(cx, |open, cx| {
|
Self::begin(state, menu, menu.step(None, 1).filter(|_| marked), cx);
|
||||||
open.spot = Some(spot);
|
|
||||||
open.levels = vec![first_of(menu, first)];
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts over on new rows with the first marked; the menu stays where it hangs.
|
/// Starts over on new rows with the first marked; the menu stays where it hangs.
|
||||||
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
|
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
|
||||||
let first = menu.step(None, 1);
|
Self::begin(state, menu, menu.step(None, 1), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh levels marking `first`; the old hover is dropped.
|
||||||
|
fn begin(state: &Entity<Open>, menu: &Menu, first: Option<usize>, cx: &mut App) {
|
||||||
state.update(cx, |open, cx| {
|
state.update(cx, |open, cx| {
|
||||||
open.levels = vec![first_of(menu, first)];
|
open.levels = vec![first_of(menu, first)];
|
||||||
|
(open.pointer, open.over, open.pending) = (None, None, None);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,6 +128,7 @@ impl Open {
|
|||||||
let takeover = state.update(cx, |open, cx| {
|
let takeover = state.update(cx, |open, cx| {
|
||||||
open.spot = None;
|
open.spot = None;
|
||||||
open.levels.clear();
|
open.levels.clear();
|
||||||
|
(open.pointer, open.over, open.pending) = (None, None, None);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
open.takeover.take()
|
open.takeover.take()
|
||||||
});
|
});
|
||||||
@@ -204,6 +212,7 @@ pub(super) fn choose(
|
|||||||
if item.disabled {
|
if item.disabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
state.update(cx, |open, _| open.pending = None);
|
||||||
if let Kind::Sub(sub) = &item.kind {
|
if let Kind::Sub(sub) = &item.kind {
|
||||||
let first = first_of(sub, sub.step(None, 1));
|
let first = first_of(sub, sub.step(None, 1));
|
||||||
mark(state, menu, depth, (at, column), false, cx);
|
mark(state, menu, depth, (at, column), false, cx);
|
||||||
@@ -236,6 +245,7 @@ fn keys(
|
|||||||
let Some(here) = at_depth(menu, &levels, depth) else {
|
let Some(here) = at_depth(menu, &levels, depth) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
state.update(cx, |open, _| open.pending = None);
|
||||||
let (at, column) = (levels[depth].at, levels[depth].column);
|
let (at, column) = (levels[depth].at, levels[depth].column);
|
||||||
let sub = at
|
let sub = at
|
||||||
.and_then(|at| here.item_at(at, column))
|
.and_then(|at| here.item_at(at, column))
|
||||||
@@ -278,26 +288,6 @@ fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
|
|||||||
.is_some_and(|(here, ix)| here.is_buttons(ix))
|
.is_some_and(|(here, ix)| here.is_buttons(ix))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
|
|
||||||
pub(super) fn measure(
|
|
||||||
state: Entity<Open>,
|
|
||||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
canvas(
|
|
||||||
move |bounds, window, cx| {
|
|
||||||
if state.update(cx, |open, _| write(open, bounds)) {
|
|
||||||
state.update(cx, |_, cx| cx.notify());
|
|
||||||
window.request_animation_frame();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.size_full()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
|
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
|
||||||
pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement {
|
pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement {
|
||||||
measure(state, move |open, bounds| {
|
measure(state, move |open, bounds| {
|
||||||
@@ -464,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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use gpui::{
|
|||||||
Window, div, point, prelude::*, px,
|
Window, div, point, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod aim;
|
||||||
mod buttons;
|
mod buttons;
|
||||||
mod more;
|
mod more;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ impl RenderOnce for FontSizeControl {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
IconButton::new((self.id, "reset"), IconName::RotateCcw)
|
IconButton::new((self.id, "reset"), IconName::RotateCcw)
|
||||||
.tooltip("Back to the default size")
|
.tooltip(i18n::text(cx, "theme.size.reset", &[]))
|
||||||
.disabled(self.size == default)
|
.disabled(self.size == default)
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
reset(default, window, cx);
|
reset(default, window, cx);
|
||||||
@@ -237,7 +237,7 @@ impl RenderOnce for FontSizeControl {
|
|||||||
.min_w_0()
|
.min_w_0()
|
||||||
.text_size(Pixels::from(self.size))
|
.text_size(Pixels::from(self.size))
|
||||||
.text_color(theme.colors.fg)
|
.text_color(theme.colors.fg)
|
||||||
.child("The quick brown fox jumps over the lazy dog."),
|
.child(i18n::text(cx, "theme.size.sample", &[])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-35
@@ -102,11 +102,13 @@ impl WindowControls {
|
|||||||
|
|
||||||
impl RenderOnce for WindowControls {
|
impl RenderOnce for WindowControls {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
match self.platform.unwrap_or(cx.theme().platform) {
|
let controls = match self.platform.unwrap_or(cx.theme().platform) {
|
||||||
Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
|
Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
|
||||||
Platform::Windows => caption_buttons(self.id, self.on_close, window, cx),
|
Platform::Windows => caption_buttons(self.id, self.on_close, window, cx),
|
||||||
Platform::Linux => round_buttons(self.id, self.on_close, window, cx),
|
Platform::Linux => round_buttons(self.id, self.on_close, window, cx),
|
||||||
}
|
};
|
||||||
|
// Windows takes a drag region under controls as caption.
|
||||||
|
controls.occlude()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ fn traffic_lights(
|
|||||||
on_close: Option<OnClose>,
|
on_close: Option<OnClose>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> Stateful<Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let active = window.is_window_active();
|
let active = window.is_window_active();
|
||||||
@@ -154,7 +156,6 @@ fn traffic_lights(
|
|||||||
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
|
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
|
||||||
.child(glyph)
|
.child(glyph)
|
||||||
}))
|
}))
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn caption_buttons(
|
fn caption_buttons(
|
||||||
@@ -162,7 +163,7 @@ fn caption_buttons(
|
|||||||
on_close: Option<OnClose>,
|
on_close: Option<OnClose>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> Stateful<Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let maximize = if window.is_maximized() {
|
let maximize = if window.is_maximized() {
|
||||||
@@ -207,7 +208,6 @@ fn caption_buttons(
|
|||||||
.group_hover_color(group, glyph),
|
.group_hover_color(group, glyph),
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round_buttons(
|
fn round_buttons(
|
||||||
@@ -215,7 +215,7 @@ fn round_buttons(
|
|||||||
on_close: Option<OnClose>,
|
on_close: Option<OnClose>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> Stateful<Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let offered = window.window_controls();
|
let offered = window.window_controls();
|
||||||
@@ -224,32 +224,38 @@ fn round_buttons(
|
|||||||
(Control::Maximize, IconName::Square, offered.maximize),
|
(Control::Maximize, IconName::Square, offered.maximize),
|
||||||
(Control::Close, IconName::X, true),
|
(Control::Close, IconName::X, true),
|
||||||
];
|
];
|
||||||
div()
|
div().id(id).flex().items_center().gap_2().children(
|
||||||
.id(id)
|
buttons
|
||||||
.flex()
|
.into_iter()
|
||||||
.items_center()
|
.filter(|(_, _, shown)| *shown)
|
||||||
.gap_2()
|
.map(|(control, icon, _)| {
|
||||||
.children(
|
let on_close = on_close.clone();
|
||||||
buttons
|
div()
|
||||||
.into_iter()
|
.id(SharedString::from(format!("{control:?}")))
|
||||||
.filter(|(_, _, shown)| *shown)
|
.flex()
|
||||||
.map(|(control, icon, _)| {
|
.items_center()
|
||||||
let on_close = on_close.clone();
|
.justify_center()
|
||||||
div()
|
.size(theme.control_height(ControlSize::Sm))
|
||||||
.id(SharedString::from(format!("{control:?}")))
|
.rounded_full()
|
||||||
.flex()
|
.bg(colors.hover)
|
||||||
.items_center()
|
.hover(|style| style.bg(colors.active))
|
||||||
.justify_center()
|
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||||
.size(theme.control_height(ControlSize::Sm))
|
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
|
||||||
.rounded_full()
|
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
|
||||||
.bg(colors.hover)
|
}),
|
||||||
.hover(|style| style.bg(colors.active))
|
)
|
||||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
}
|
||||||
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
|
|
||||||
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
|
/// A slot's controls, occluding the drag region beneath.
|
||||||
}),
|
fn slot(items: SmallVec<[AnyElement; 2]>) -> Option<Div> {
|
||||||
)
|
(!items.is_empty()).then(|| {
|
||||||
.into_any_element()
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.occlude()
|
||||||
|
.children(items)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the system draws the window controls, and whose manner they take.
|
/// Whether the system draws the window controls, and whose manner they take.
|
||||||
@@ -404,7 +410,7 @@ impl RenderOnce for TitleBar {
|
|||||||
false => start.pl_3(),
|
false => start.pl_3(),
|
||||||
})
|
})
|
||||||
.children(leading)
|
.children(leading)
|
||||||
.children(self.leading),
|
.children(slot(self.leading)),
|
||||||
);
|
);
|
||||||
let end = div().flex().justify_end().child(
|
let end = div().flex().justify_end().child(
|
||||||
div()
|
div()
|
||||||
@@ -412,7 +418,7 @@ impl RenderOnce for TitleBar {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when(centered, |end| end.pr_2())
|
.when(centered, |end| end.pr_2())
|
||||||
.children(self.actions)
|
.children(slot(self.actions))
|
||||||
.children(trailing.map(|controls| {
|
.children(trailing.map(|controls| {
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
|||||||
+217
-32
@@ -1,22 +1,54 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, DefiniteLength, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce,
|
AnyElement, App, Axis, DefiniteLength, Div, DragMoveEvent, ElementId, EmptyView, EntityId,
|
||||||
|
FontWeight, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, RenderOnce,
|
||||||
SharedString, Styled, Window, div, prelude::*,
|
SharedString, Styled, Window, div, prelude::*,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close};
|
use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close};
|
||||||
use crate::{
|
use crate::{
|
||||||
layout::on_axis,
|
layout::{on_axis, resize_handle},
|
||||||
theme::{ActiveTheme, ControlSize, Platform, TextSize},
|
theme::{ActiveTheme, ControlSize, Platform, TextSize},
|
||||||
typography::Ellipsis,
|
typography::Ellipsis,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OnResize = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// A width the owner keeps, which a drag on one border moves.
|
||||||
|
struct Resize {
|
||||||
|
width: Pixels,
|
||||||
|
min: Pixels,
|
||||||
|
max: Pixels,
|
||||||
|
on_resize: OnResize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The border a column's width is dragged by.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Side {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct BorderDrag {
|
||||||
|
owner: EntityId,
|
||||||
|
side: Side,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a press on a border landed; once it moves sideways, the width it sets off from and the owner's then.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Press {
|
||||||
|
at: Pixels,
|
||||||
|
start: Option<(Pixels, Pixels)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One column of a `ColumnShell`: a header that drags the window, then its body.
|
/// One column of a `ColumnShell`: a header that drags the window, then its body.
|
||||||
pub struct ShellColumn {
|
pub struct ShellColumn {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
width: Option<DefiniteLength>,
|
width: Option<DefiniteLength>,
|
||||||
|
resize: Option<Resize>,
|
||||||
leading: SmallVec<[AnyElement; 2]>,
|
leading: SmallVec<[AnyElement; 2]>,
|
||||||
title: Option<SharedString>,
|
title: Option<SharedString>,
|
||||||
actions: SmallVec<[AnyElement; 2]>,
|
actions: SmallVec<[AnyElement; 2]>,
|
||||||
@@ -29,6 +61,7 @@ impl ShellColumn {
|
|||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
width: None,
|
width: None,
|
||||||
|
resize: None,
|
||||||
leading: SmallVec::new(),
|
leading: SmallVec::new(),
|
||||||
title: None,
|
title: None,
|
||||||
actions: SmallVec::new(),
|
actions: SmallVec::new(),
|
||||||
@@ -36,12 +69,35 @@ impl ShellColumn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A set width instead of the rest; it yields before a header's fixed parts.
|
/// A set width instead of the rest; it holds its header's fixed parts and yields when the shell runs short.
|
||||||
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
|
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
|
||||||
self.width = Some(width.into());
|
self.width = Some(width.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A set width the owner keeps: a drag on the border toward the filling column moves it within `min` and `max`, and the owner hears each new width.
|
||||||
|
pub fn resizable(
|
||||||
|
mut self,
|
||||||
|
width: Pixels,
|
||||||
|
min: Pixels,
|
||||||
|
max: Pixels,
|
||||||
|
on_resize: impl Fn(Pixels, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
assert!(
|
||||||
|
min <= max,
|
||||||
|
"shell column {:?}: limits {min:?} to {max:?} run backwards",
|
||||||
|
self.id
|
||||||
|
);
|
||||||
|
self.width = Some(width.into());
|
||||||
|
self.resize = Some(Resize {
|
||||||
|
width,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
on_resize: Rc::new(on_resize),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Header content before the title.
|
/// Header content before the title.
|
||||||
pub fn leading(mut self, item: impl IntoElement) -> Self {
|
pub fn leading(mut self, item: impl IntoElement) -> Self {
|
||||||
self.leading.push(item.into_any_element());
|
self.leading.push(item.into_any_element());
|
||||||
@@ -73,6 +129,7 @@ pub struct ColumnShell {
|
|||||||
platform: Option<Platform>,
|
platform: Option<Platform>,
|
||||||
on_close: Option<OnClose>,
|
on_close: Option<OnClose>,
|
||||||
columns: Vec<ShellColumn>,
|
columns: Vec<ShellColumn>,
|
||||||
|
glass: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColumnShell {
|
impl ColumnShell {
|
||||||
@@ -83,6 +140,7 @@ impl ColumnShell {
|
|||||||
platform: None,
|
platform: None,
|
||||||
on_close: None,
|
on_close: None,
|
||||||
columns: Vec::new(),
|
columns: Vec::new(),
|
||||||
|
glass: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +160,12 @@ impl ColumnShell {
|
|||||||
self.on_close = Some(Rc::new(handler));
|
self.on_close = Some(Rc::new(handler));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Grounds the columns in the palette's glass, so a blurred window shows through them.
|
||||||
|
pub fn glass(mut self) -> Self {
|
||||||
|
self.glass = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for ColumnShell {
|
impl RenderOnce for ColumnShell {
|
||||||
@@ -124,9 +188,19 @@ impl RenderOnce for ColumnShell {
|
|||||||
.child(controls)
|
.child(controls)
|
||||||
};
|
};
|
||||||
let room = system && !window.is_fullscreen();
|
let room = system && !window.is_fullscreen();
|
||||||
|
// Each border lies toward the column that fills, so a drag on it moves one width.
|
||||||
|
let fill = self
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.position(|column| column.width.is_none());
|
||||||
let mut columns = Vec::with_capacity(count);
|
let mut columns = Vec::with_capacity(count);
|
||||||
for (ix, column) in self.columns.into_iter().enumerate() {
|
for (ix, column) in self.columns.into_iter().enumerate() {
|
||||||
let (first, last) = (ix == 0, ix + 1 == count);
|
let (first, last) = (ix == 0, ix + 1 == count);
|
||||||
|
let side = match fill {
|
||||||
|
Some(fill) if ix > fill => Some(Side::Left),
|
||||||
|
Some(fill) if ix == fill => None,
|
||||||
|
_ => (!last).then_some(Side::Right),
|
||||||
|
};
|
||||||
let lead = (!system && first && style == Platform::Mac).then(controls);
|
let lead = (!system && first && style == Platform::Mac).then(controls);
|
||||||
let trail = (!system && last && style != Platform::Mac).then(controls);
|
let trail = (!system && last && style != Platform::Mac).then(controls);
|
||||||
let flush = trail.is_some() && style == Platform::Windows;
|
let flush = trail.is_some() && style == Platform::Windows;
|
||||||
@@ -141,7 +215,7 @@ impl RenderOnce for ColumnShell {
|
|||||||
items(
|
items(
|
||||||
column.id.clone(),
|
column.id.clone(),
|
||||||
column.leading,
|
column.leading,
|
||||||
title(column.title, cx),
|
title(column.id.clone(), column.title, window, cx),
|
||||||
column.actions,
|
column.actions,
|
||||||
)
|
)
|
||||||
.min_w(cx.theme().control_height(ControlSize::Lg))
|
.min_w(cx.theme().control_height(ControlSize::Lg))
|
||||||
@@ -149,42 +223,145 @@ impl RenderOnce for ColumnShell {
|
|||||||
)
|
)
|
||||||
.children(trail);
|
.children(trail);
|
||||||
let colors = &cx.theme().colors;
|
let colors = &cx.theme().colors;
|
||||||
columns.push(
|
let box_ = div()
|
||||||
div()
|
.relative()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.h_full()
|
.h_full()
|
||||||
.map(|box_| match column.width {
|
// A basis, not a width, so the header's fixed parts set the least width.
|
||||||
Some(width) => box_.w(width),
|
.map(|box_| match column.width {
|
||||||
None => box_.flex_1(),
|
Some(width) => box_.flex_basis(width),
|
||||||
})
|
None => box_.flex_1(),
|
||||||
.when(!last, |box_| box_.border_r_1().border_color(colors.border))
|
})
|
||||||
.debug_selector(move || format!("column-shell-{ix}"))
|
// A resizing column's handle draws its border.
|
||||||
.child(header)
|
.map(|box_| match (side, column.resize.is_some()) {
|
||||||
.child(
|
(Some(Side::Left), false) => box_.border_l_1(),
|
||||||
// Absolute, so only the header sets the least width.
|
(Some(Side::Right), false) => box_.border_r_1(),
|
||||||
div().flex_1().min_h_0().relative().child(
|
_ => box_,
|
||||||
div()
|
})
|
||||||
.absolute()
|
.border_color(colors.border)
|
||||||
.inset_0()
|
.debug_selector(move || format!("column-shell-{ix}"))
|
||||||
.flex()
|
.child(header)
|
||||||
.flex_col()
|
.child(
|
||||||
.children(column.body),
|
// Absolute, so only the header sets the least width.
|
||||||
),
|
div().flex_1().min_h_0().relative().child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.children(column.body),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
columns.push(match column.resize {
|
||||||
|
Some(resize) => {
|
||||||
|
let side = side.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"column shell {:?}: column {ix} resizes, but no border lies toward a filling column",
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
});
|
||||||
|
resizing(box_, column.id, ix, side, resize, window, cx)
|
||||||
|
}
|
||||||
|
None => box_,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
let ground = if self.glass {
|
||||||
|
theme.colors.glass
|
||||||
|
} else {
|
||||||
|
theme.colors.bg
|
||||||
|
};
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.h_full()
|
.h_full()
|
||||||
.debug_selector(|| "column-shell".into())
|
.debug_selector(|| "column-shell".into())
|
||||||
.bg(theme.colors.bg)
|
.bg(ground)
|
||||||
.text_color(theme.colors.fg)
|
.text_color(theme.colors.fg)
|
||||||
.children(columns)
|
.children(columns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `box_` with a handle on its `side` border, whose drag moves its width.
|
||||||
|
fn resizing(
|
||||||
|
box_: Div,
|
||||||
|
id: ElementId,
|
||||||
|
ix: usize,
|
||||||
|
side: Side,
|
||||||
|
resize: Resize,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Div {
|
||||||
|
let Resize {
|
||||||
|
width,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
on_resize,
|
||||||
|
} = resize;
|
||||||
|
let pressed = window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::<Press>);
|
||||||
|
let owner = pressed.entity_id();
|
||||||
|
let drag = BorderDrag { owner, side };
|
||||||
|
let at = pressed.clone();
|
||||||
|
let handle = resize_handle((id, "border"), Axis::Horizontal, cx)
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.map(|handle| match side {
|
||||||
|
Side::Left => handle.left_0().justify_start(),
|
||||||
|
Side::Right => handle.right_0().justify_end(),
|
||||||
|
})
|
||||||
|
// Over a header, so a press drags the border, not the window.
|
||||||
|
.occlude()
|
||||||
|
.debug_selector(move || format!("column-shell-border-{ix}"))
|
||||||
|
.on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| {
|
||||||
|
at.update(cx, |at, _| {
|
||||||
|
*at = Some(Press {
|
||||||
|
at: event.position.x,
|
||||||
|
start: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView));
|
||||||
|
box_.on_drag_move(move |event: &DragMoveEvent<BorderDrag>, window, cx| {
|
||||||
|
let BorderDrag {
|
||||||
|
owner: dragged,
|
||||||
|
side,
|
||||||
|
} = *event.drag(cx);
|
||||||
|
if dragged != owner {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(press) = *pressed.read(cx) else {
|
||||||
|
log::error!("column shell: a drag on column {ix} with no press behind it");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let moved = event.event.position.x - press.at;
|
||||||
|
let (start, kept) = match press.start {
|
||||||
|
Some(start) => start,
|
||||||
|
// A drag that has not moved sideways yet changes nothing.
|
||||||
|
None if moved == Pixels::ZERO => return,
|
||||||
|
// A header's parts may hold a column wider, a short shell squeeze it narrower.
|
||||||
|
None => {
|
||||||
|
let start = (event.bounds.size.width.max(width), width);
|
||||||
|
pressed.update(cx, |held, _| {
|
||||||
|
*held = Some(Press {
|
||||||
|
start: Some(start),
|
||||||
|
..press
|
||||||
|
})
|
||||||
|
});
|
||||||
|
start
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let next = match side {
|
||||||
|
_ if moved == Pixels::ZERO => kept,
|
||||||
|
Side::Left => (start - moved).clamp(min, max),
|
||||||
|
Side::Right => (start + moved).clamp(min, max),
|
||||||
|
};
|
||||||
|
if next != width {
|
||||||
|
on_resize(next, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(handle)
|
||||||
|
}
|
||||||
|
|
||||||
/// A header strip at the title bar's height that drags the window.
|
/// A header strip at the title bar's height that drags the window.
|
||||||
fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gpui::Div> {
|
fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gpui::Div> {
|
||||||
drag_region((id, "header"), window, cx)
|
drag_region((id, "header"), window, cx)
|
||||||
@@ -199,7 +376,7 @@ fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gp
|
|||||||
fn items(
|
fn items(
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
leading: SmallVec<[AnyElement; 2]>,
|
leading: SmallVec<[AnyElement; 2]>,
|
||||||
title: gpui::Div,
|
title: gpui::Stateful<gpui::Div>,
|
||||||
actions: SmallVec<[AnyElement; 2]>,
|
actions: SmallVec<[AnyElement; 2]>,
|
||||||
) -> gpui::Div {
|
) -> gpui::Div {
|
||||||
let group = || div().flex_none().flex().items_center().gap_2();
|
let group = || div().flex_none().flex().items_center().gap_2();
|
||||||
@@ -211,16 +388,24 @@ fn items(
|
|||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.overflow_x_scroll()
|
.overflow_x_scroll()
|
||||||
|
// Windows takes a drag region under controls as caption.
|
||||||
|
.occlude()
|
||||||
.child(group().children(leading))
|
.child(group().children(leading))
|
||||||
.child(title)
|
.child(title)
|
||||||
.child(group().children(actions)),
|
.child(group().children(actions)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The title, adding nothing to the least width; a spacer without one.
|
/// Title or spacer: drags the window, adds no width.
|
||||||
fn title(title: Option<SharedString>, cx: &App) -> gpui::Div {
|
fn title(
|
||||||
|
id: ElementId,
|
||||||
|
title: Option<SharedString>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> gpui::Stateful<gpui::Div> {
|
||||||
|
let region = drag_region((id, "title"), window, cx);
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
div().flex_1().min_w_0().h_full().relative().child(
|
region.flex_1().min_w_0().h_full().relative().child(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0()
|
.inset_0()
|
||||||
|
|||||||
+12
-2
@@ -37,12 +37,19 @@ fn cursor(edge: ResizeEdge) -> CursorStyle {
|
|||||||
#[derive(IntoElement, Default)]
|
#[derive(IntoElement, Default)]
|
||||||
pub struct ResizeBorder {
|
pub struct ResizeBorder {
|
||||||
children: SmallVec<[AnyElement; 2]>,
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
|
glass: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResizeBorder {
|
impl ResizeBorder {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Leaves a see-through window's pane to its content: no ground, nor the shadow gpui draws under it.
|
||||||
|
pub fn glass(mut self) -> Self {
|
||||||
|
self.glass = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParentElement for ResizeBorder {
|
impl ParentElement for ResizeBorder {
|
||||||
@@ -61,6 +68,7 @@ impl RenderOnce for ResizeBorder {
|
|||||||
window.set_client_inset(inset);
|
window.set_client_inset(inset);
|
||||||
let radius = theme.radius(Radius::Lg);
|
let radius = theme.radius(Radius::Lg);
|
||||||
let floating = !tiling.is_tiled();
|
let floating = !tiling.is_tiled();
|
||||||
|
let glass = self.glass;
|
||||||
div()
|
div()
|
||||||
.id("resize-border")
|
.id("resize-border")
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -100,12 +108,14 @@ impl RenderOnce for ResizeBorder {
|
|||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.cursor(CursorStyle::Arrow)
|
.cursor(CursorStyle::Arrow)
|
||||||
.bg(theme.colors.bg)
|
.when(!glass, |pane| pane.bg(theme.colors.bg))
|
||||||
.when(floating, |pane| {
|
.when(floating, |pane| {
|
||||||
pane.rounded(radius)
|
pane.rounded(radius)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.colors.border_strong)
|
.border_color(theme.colors.border_strong)
|
||||||
.shadow(theme.elevation(Elevation::Modal))
|
})
|
||||||
|
.when(floating && !glass, |pane| {
|
||||||
|
pane.shadow(theme.elevation(Elevation::Modal))
|
||||||
})
|
})
|
||||||
.on_mouse_move(|_, _, cx| cx.stop_propagation())
|
.on_mouse_move(|_, _, cx| cx.stop_propagation())
|
||||||
.children(self.children),
|
.children(self.children),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod border;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke,
|
AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke,
|
||||||
ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size,
|
ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size,
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
use gpui::{
|
||||||
|
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
|
||||||
|
TestAppContext, VisualTestContext, Window, div, point, prelude::*, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::shell::{ColumnShell, ShellColumn};
|
||||||
|
use crate::theme::{Platform, Theme};
|
||||||
|
|
||||||
|
/// Two set columns round a filling one; this view keeps their widths.
|
||||||
|
struct Sided {
|
||||||
|
platform: Platform,
|
||||||
|
width: Pixels,
|
||||||
|
widths: [Pixels; 2],
|
||||||
|
heard: Vec<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Sided {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
let side = |id: &'static str, ix: usize| {
|
||||||
|
let view = view.clone();
|
||||||
|
ShellColumn::new(id).title(id).resizable(
|
||||||
|
self.widths[ix],
|
||||||
|
px(100.0),
|
||||||
|
px(400.0),
|
||||||
|
move |width, _, cx| {
|
||||||
|
view.update(cx, |view, cx| {
|
||||||
|
view.widths[ix] = width;
|
||||||
|
view.heard.push(width);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
div().w(self.width).h(px(400.0)).flex().flex_col().child(
|
||||||
|
ColumnShell::new("shell")
|
||||||
|
.platform(self.platform)
|
||||||
|
.column(side("left", 0))
|
||||||
|
.column(ShellColumn::new("main").title("main"))
|
||||||
|
.column(side("right", 1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sided(
|
||||||
|
platform: Platform,
|
||||||
|
widths: [Pixels; 2],
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) -> (Entity<Sided>, &mut VisualTestContext) {
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Sided {
|
||||||
|
platform,
|
||||||
|
width: px(1000.0),
|
||||||
|
widths,
|
||||||
|
heard: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
(view, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column(ix: usize, cx: &mut VisualTestContext) -> gpui::Bounds<Pixels> {
|
||||||
|
let name: &'static str = ["column-shell-0", "column-shell-1", "column-shell-2"][ix];
|
||||||
|
cx.debug_bounds(name).expect("a column")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presses at `from`, moves past gpui's drag threshold, then to each offset `path` names, and lets go.
|
||||||
|
fn drag_path(from: Point<Pixels>, path: &[Pixels], cx: &mut VisualTestContext) {
|
||||||
|
let none = Modifiers::none();
|
||||||
|
cx.simulate_mouse_down(from, MouseButton::Left, none);
|
||||||
|
let set_off = from + point(px(0.0), px(4.0));
|
||||||
|
cx.simulate_mouse_move(set_off, Some(MouseButton::Left), none);
|
||||||
|
cx.run_until_parked();
|
||||||
|
let mut to = set_off;
|
||||||
|
for by in path {
|
||||||
|
to = set_off + point(*by, px(0.0));
|
||||||
|
cx.simulate_mouse_move(to, Some(MouseButton::Left), none);
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
cx.simulate_mouse_up(to, MouseButton::Left, none);
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drag(from: Point<Pixels>, by: Pixels, cx: &mut VisualTestContext) {
|
||||||
|
drag_path(from, &[by], cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heard(view: &Entity<Sided>, cx: &mut VisualTestContext) -> Option<Pixels> {
|
||||||
|
view.read_with(cx, |view, _| view.heard.last().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A column before the filling one drags by its right border, one after it by its left.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_border_toward_the_filling_column_drags_its_width(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx);
|
||||||
|
let left = cx.debug_bounds("column-shell-border-0").expect("a border");
|
||||||
|
let right = cx.debug_bounds("column-shell-border-2").expect("a border");
|
||||||
|
assert_eq!(left.right(), column(0, cx).right());
|
||||||
|
assert_eq!(right.left(), column(2, cx).left());
|
||||||
|
drag(left.center(), px(50.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(250.0)));
|
||||||
|
assert_eq!(column(0, cx).size.width, px(250.0));
|
||||||
|
drag(right.center(), px(-50.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(250.0)));
|
||||||
|
let (main, panel) = (column(1, cx), column(2, cx));
|
||||||
|
assert_eq!((panel.size.width, panel.right()), (px(250.0), px(1000.0)));
|
||||||
|
assert_eq!((main.left(), main.right()), (px(250.0), px(750.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag stops at the column's least and greatest width.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_border_drag_keeps_within_the_limits(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx);
|
||||||
|
let border = |cx: &mut VisualTestContext| {
|
||||||
|
cx.debug_bounds("column-shell-border-0")
|
||||||
|
.expect("a border")
|
||||||
|
.center()
|
||||||
|
};
|
||||||
|
drag(border(cx), px(900.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(400.0)));
|
||||||
|
drag(border(cx), px(-900.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(100.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In a header the border takes the press, so no system's drag moves the window, which panics here.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_border_drag_in_a_header_moves_no_window(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
for platform in [Platform::Windows, Platform::Mac, Platform::Linux] {
|
||||||
|
let (view, cx) = sided(platform, [px(200.0), px(200.0)], cx);
|
||||||
|
for (ix, by) in [(0, px(30.0)), (2, px(-30.0))] {
|
||||||
|
let border = cx
|
||||||
|
.debug_bounds(["column-shell-border-0", "", "column-shell-border-2"][ix])
|
||||||
|
.expect("a border");
|
||||||
|
let header = column(ix, cx).top() + px(4.0);
|
||||||
|
drag(point(border.center().x, header), by, cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(230.0)), "{platform:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header's own parts hold a column past the owner's width; a drag sets off from the drawn one.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_border_drag_sets_off_from_the_drawn_width(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (view, cx) = sided(Platform::Windows, [px(200.0), px(100.0)], cx);
|
||||||
|
let (panel, close) = (column(2, cx), cx.debug_bounds("caption-Close"));
|
||||||
|
let close = close.expect("a close button");
|
||||||
|
assert!(close.right() <= panel.right(), "{close:?} in {panel:?}");
|
||||||
|
let drawn = panel.size.width;
|
||||||
|
assert!(drawn > px(100.0), "{drawn:?} holds the caption buttons");
|
||||||
|
let border = cx.debug_bounds("column-shell-border-2").expect("a border");
|
||||||
|
drag(border.center(), px(-10.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(drawn + px(10.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two set columns and nothing that fills.
|
||||||
|
struct Unfilled;
|
||||||
|
|
||||||
|
impl Render for Unfilled {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let column = |id: &'static str| ShellColumn::new(id).width(px(200.0));
|
||||||
|
ColumnShell::new("shell")
|
||||||
|
.platform(Platform::Linux)
|
||||||
|
.column(column("first"))
|
||||||
|
.column(column("last").resizable(px(200.0), px(100.0), px(400.0), |_, _, _| {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last column of a shell that nothing fills has no border to drag.
|
||||||
|
#[gpui::test]
|
||||||
|
#[should_panic(expected = "no border lies toward a filling column")]
|
||||||
|
fn a_resizing_column_needs_a_border_toward_a_filling_one(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Unfilled);
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short shell squeezes both columns; a drag moves a border with the pointer, from the owner's width.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_squeezed_column_drags_from_the_owner_s_width(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Sided {
|
||||||
|
platform: Platform::Linux,
|
||||||
|
width: px(700.0),
|
||||||
|
widths: [px(350.0), px(350.0)],
|
||||||
|
heard: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let left = column(0, cx);
|
||||||
|
assert!(left.size.width < px(350.0), "{left:?} is squeezed");
|
||||||
|
let border = |ix: usize, cx: &mut VisualTestContext| {
|
||||||
|
let name = ["column-shell-border-0", "", "column-shell-border-2"][ix];
|
||||||
|
cx.debug_bounds(name).expect("a border").center()
|
||||||
|
};
|
||||||
|
drag(border(0, cx), px(0.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), None, "a drag with no sideways move");
|
||||||
|
drag(border(0, cx), px(20.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(370.0)));
|
||||||
|
assert!(
|
||||||
|
column(0, cx).right() > left.right(),
|
||||||
|
"the left border moves right"
|
||||||
|
);
|
||||||
|
let right = column(2, cx);
|
||||||
|
drag(border(2, cx), px(-20.0), cx);
|
||||||
|
assert_eq!(heard(&view, cx), Some(px(370.0)));
|
||||||
|
assert!(
|
||||||
|
column(2, cx).left() < right.left(),
|
||||||
|
"the right border moves left"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag that comes back where it set off gives the width the owner had, a held or squeezed one too.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_drag_back_where_it_set_off_gives_the_width_it_had(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Platform::Linux,
|
||||||
|
px(1000.0),
|
||||||
|
[px(200.0), px(200.0)],
|
||||||
|
0,
|
||||||
|
px(50.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Platform::Linux,
|
||||||
|
px(1000.0),
|
||||||
|
[px(200.0), px(200.0)],
|
||||||
|
2,
|
||||||
|
px(-50.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Platform::Windows,
|
||||||
|
px(1000.0),
|
||||||
|
[px(200.0), px(100.0)],
|
||||||
|
2,
|
||||||
|
px(-10.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Platform::Linux,
|
||||||
|
px(700.0),
|
||||||
|
[px(350.0), px(350.0)],
|
||||||
|
0,
|
||||||
|
px(20.0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (platform, width, widths, ix, by) in cases {
|
||||||
|
let (view, cx) = cx.add_window_view(move |_, _| Sided {
|
||||||
|
platform,
|
||||||
|
width,
|
||||||
|
widths,
|
||||||
|
heard: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let name = ["column-shell-border-0", "", "column-shell-border-2"][ix];
|
||||||
|
let border = cx.debug_bounds(name).expect("a border").center();
|
||||||
|
let drawn = column(ix, cx);
|
||||||
|
drag_path(border, &[by, px(0.0)], cx);
|
||||||
|
let heard = view.read_with(cx, |view, _| view.heard.clone());
|
||||||
|
assert_eq!(heard.len(), 2, "{platform:?} {ix}: out and back, {heard:?}");
|
||||||
|
assert_eq!(heard[1], widths[ix / 2], "{platform:?} {ix}");
|
||||||
|
assert_eq!(column(ix, cx), drawn, "{platform:?} {ix}: drawn as before");
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-1
@@ -3,7 +3,7 @@ use gpui::{
|
|||||||
ParentElement, Point, Render, TestAppContext, Window, div, point, prelude::*, px,
|
ParentElement, Point, Render, TestAppContext, Window, div, point, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::TitleBar;
|
use super::{ColumnShell, ShellColumn, TitleBar, WindowControls};
|
||||||
use crate::theme::{Platform, Theme};
|
use crate::theme::{Platform, Theme};
|
||||||
|
|
||||||
struct Bar {
|
struct Bar {
|
||||||
@@ -71,3 +71,104 @@ fn a_wobbling_press_on_the_bar_moves_the_window(cx: &mut TestAppContext) {
|
|||||||
.expect("drawn controls");
|
.expect("drawn controls");
|
||||||
wobble(point(controls.left() - px(200.0), controls.center().y), cx);
|
wobble(point(controls.left() - px(200.0), controls.center().y), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Counts presses that reach the box under the controls.
|
||||||
|
struct Under {
|
||||||
|
platform: Platform,
|
||||||
|
presses: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Under {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
div()
|
||||||
|
.id("under")
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.w_full()
|
||||||
|
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
||||||
|
view.update(cx, |under, _| under.presses += 1)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "controls".into())
|
||||||
|
.child(WindowControls::new("controls").platform(self.platform)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each style's controls hide what lies under them.
|
||||||
|
#[gpui::test]
|
||||||
|
fn window_controls_occlude_what_lies_under(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
for platform in [Platform::Windows, Platform::Mac, Platform::Linux] {
|
||||||
|
let (view, cx) = cx.add_window_view(move |_, _| Under {
|
||||||
|
platform,
|
||||||
|
presses: 0,
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let controls = cx.debug_bounds("controls").expect("drawn controls");
|
||||||
|
cx.simulate_event(MouseDownEvent {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
position: controls.center(),
|
||||||
|
modifiers: Modifiers::none(),
|
||||||
|
click_count: 1,
|
||||||
|
first_mouse: false,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
view.read_with(cx, |under, _| under.presses),
|
||||||
|
0,
|
||||||
|
"{platform:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title bar and column header with inert slot items.
|
||||||
|
struct Slotted;
|
||||||
|
|
||||||
|
impl Render for Slotted {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let item = |name: &'static str| div().debug_selector(move || name.into()).size_6();
|
||||||
|
let column = ShellColumn::new("main")
|
||||||
|
.leading(item("shell-lead"))
|
||||||
|
.action(item("shell-act"));
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(
|
||||||
|
TitleBar::new("bar")
|
||||||
|
.platform(Platform::Windows)
|
||||||
|
.child(item("bar-lead"))
|
||||||
|
.action(item("bar-act")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
ColumnShell::new("shell")
|
||||||
|
.platform(Platform::Windows)
|
||||||
|
.column(column),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press on a slot item never moves the window.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_wobbling_press_on_a_slot_moves_no_window(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Slotted);
|
||||||
|
cx.run_until_parked();
|
||||||
|
for name in ["bar-lead", "bar-act", "shell-lead", "shell-act"] {
|
||||||
|
let item = cx.debug_bounds(name).expect("a slot item");
|
||||||
|
wobble(item.center(), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A column's title still moves the window; a move panics.
|
||||||
|
#[gpui::test]
|
||||||
|
#[should_panic(expected = "not implemented")]
|
||||||
|
fn a_wobbling_press_on_a_column_title_moves_the_window(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Slotted);
|
||||||
|
cx.run_until_parked();
|
||||||
|
let items = cx.debug_bounds("column-shell-items-0").expect("the items");
|
||||||
|
wobble(items.center(), cx);
|
||||||
|
}
|
||||||
+11
-3
@@ -212,7 +212,7 @@ impl MiniWindow {
|
|||||||
|
|
||||||
impl Render for MiniWindow {
|
impl Render for MiniWindow {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let bar = drag_region("mini-bar", window, cx);
|
let drag = drag_region("mini-bar", window, cx);
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let back = self.back;
|
let back = self.back;
|
||||||
div()
|
div()
|
||||||
@@ -225,7 +225,8 @@ impl Render for MiniWindow {
|
|||||||
.font_family(theme.font_family.clone())
|
.font_family(theme.font_family.clone())
|
||||||
.child(self.view.clone())
|
.child(self.view.clone())
|
||||||
.child(
|
.child(
|
||||||
bar.absolute()
|
div()
|
||||||
|
.absolute()
|
||||||
.top_0()
|
.top_0()
|
||||||
.left_0()
|
.left_0()
|
||||||
.right_0()
|
.right_0()
|
||||||
@@ -241,7 +242,14 @@ impl Render for MiniWindow {
|
|||||||
.text_color(theme.colors.fg_muted)
|
.text_color(theme.colors.fg_muted)
|
||||||
.invisible()
|
.invisible()
|
||||||
.group_hover("mini-window", |style| style.visible())
|
.group_hover("mini-window", |style| style.visible())
|
||||||
.child(div().flex_1().child(self.title.clone()))
|
// Only the title drags: Windows takes the region as caption.
|
||||||
|
.child(
|
||||||
|
drag.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.child(self.title.clone()),
|
||||||
|
)
|
||||||
.when_some(back, |bar, back| {
|
.when_some(back, |bar, back| {
|
||||||
bar.child(
|
bar.child(
|
||||||
IconButton::new("mini-expand", IconName::Maximize2)
|
IconButton::new("mini-expand", IconName::Maximize2)
|
||||||
|
|||||||
+48
-33
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{FilterRule, SortKey, Test};
|
use super::{FilterRule, SortKey, Test};
|
||||||
|
use crate::i18n;
|
||||||
use crate::{
|
use crate::{
|
||||||
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
|
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
|
||||||
forms::{Choice, Input, InputEvent, Select, TextInput},
|
forms::{Choice, Input, InputEvent, Select, TextInput},
|
||||||
@@ -151,7 +152,7 @@ impl RenderOnce for FilterBuilder {
|
|||||||
let emit = Rc::new(emit);
|
let emit = Rc::new(emit);
|
||||||
let tests: Vec<Choice> = Test::ALL
|
let tests: Vec<Choice> = Test::ALL
|
||||||
.iter()
|
.iter()
|
||||||
.map(|test| Choice::new(test.label(), test.label()))
|
.map(|test| Choice::new(test.key(), i18n::text(cx, test.key(), &[])))
|
||||||
.collect();
|
.collect();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone());
|
let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone());
|
||||||
@@ -186,13 +187,13 @@ impl RenderOnce for FilterBuilder {
|
|||||||
.child(
|
.child(
|
||||||
div().w(theme.label_width() * 0.75).child(
|
div().w(theme.label_width() * 0.75).child(
|
||||||
Select::new((id.clone(), format!("test-{ix}")), tests.clone())
|
Select::new((id.clone(), format!("test-{ix}")), tests.clone())
|
||||||
.selected(rule.test.label())
|
.selected(rule.test.key())
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.on_change(move |label, window, cx| {
|
.on_change(move |label, window, cx| {
|
||||||
let mut next = for_test.clone();
|
let mut next = for_test.clone();
|
||||||
next[ix].test = *Test::ALL
|
next[ix].test = *Test::ALL
|
||||||
.iter()
|
.iter()
|
||||||
.find(|test| test.label() == label.as_ref())
|
.find(|test| test.key() == label.as_ref())
|
||||||
.expect("a listed test");
|
.expect("a listed test");
|
||||||
test(next, any, window, cx)
|
test(next, any, window, cx)
|
||||||
}),
|
}),
|
||||||
@@ -205,7 +206,7 @@ impl RenderOnce for FilterBuilder {
|
|||||||
IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X)
|
IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X)
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.tooltip("Remove")
|
.tooltip(i18n::text(cx, "builder.remove", &[]))
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
let mut next = for_remove.clone();
|
let mut next = for_remove.clone();
|
||||||
next.remove(ix);
|
next.remove(ix);
|
||||||
@@ -223,18 +224,21 @@ impl RenderOnce for FilterBuilder {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.child(
|
.child(
|
||||||
Button::new((self.id.clone(), "add"), "Add rule")
|
Button::new(
|
||||||
.icon(IconName::Plus)
|
(self.id.clone(), "add"),
|
||||||
.variant(ButtonVariant::Ghost)
|
i18n::text(cx, "builder.add_rule", &[]),
|
||||||
.size(ControlSize::Sm)
|
)
|
||||||
.on_click(move |_, window, cx| {
|
.icon(IconName::Plus)
|
||||||
let mut next = to_add.clone();
|
.variant(ButtonVariant::Ghost)
|
||||||
next.push(FilterRule {
|
.size(ControlSize::Sm)
|
||||||
column: first.clone(),
|
.on_click(move |_, window, cx| {
|
||||||
..FilterRule::default()
|
let mut next = to_add.clone();
|
||||||
});
|
next.push(FilterRule {
|
||||||
add(next, any, window, cx)
|
column: first.clone(),
|
||||||
}),
|
..FilterRule::default()
|
||||||
|
});
|
||||||
|
add(next, any, window, cx)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.when(self.rules.len() > 1, |footer| {
|
.when(self.rules.len() > 1, |footer| {
|
||||||
footer.child(
|
footer.child(
|
||||||
@@ -242,8 +246,8 @@ impl RenderOnce for FilterBuilder {
|
|||||||
(self.id.clone(), "join"),
|
(self.id.clone(), "join"),
|
||||||
if any { "any" } else { "all" },
|
if any { "any" } else { "all" },
|
||||||
)
|
)
|
||||||
.segment("all", "Match all", None)
|
.segment("all", i18n::text(cx, "builder.all", &[]), None)
|
||||||
.segment("any", "Match any", None)
|
.segment("any", i18n::text(cx, "builder.any", &[]), None)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.on_change(move |value, window, cx| {
|
.on_change(move |value, window, cx| {
|
||||||
join(to_join.clone(), value.as_ref() == "any", window, cx)
|
join(to_join.clone(), value.as_ref() == "any", window, cx)
|
||||||
@@ -364,8 +368,16 @@ impl RenderOnce for SortBuilder {
|
|||||||
(id.clone(), format!("sort-way-{ix}")),
|
(id.clone(), format!("sort-way-{ix}")),
|
||||||
if sort.rising { "up" } else { "down" },
|
if sort.rising { "up" } else { "down" },
|
||||||
)
|
)
|
||||||
.segment("up", "Ascending", Some(IconName::ArrowUp))
|
.segment(
|
||||||
.segment("down", "Descending", Some(IconName::ArrowDown))
|
"up",
|
||||||
|
i18n::text(cx, "builder.up", &[]),
|
||||||
|
Some(IconName::ArrowUp),
|
||||||
|
)
|
||||||
|
.segment(
|
||||||
|
"down",
|
||||||
|
i18n::text(cx, "builder.down", &[]),
|
||||||
|
Some(IconName::ArrowDown),
|
||||||
|
)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.on_change(move |way, window, cx| {
|
.on_change(move |way, window, cx| {
|
||||||
let mut next = for_flip.clone();
|
let mut next = for_flip.clone();
|
||||||
@@ -377,7 +389,7 @@ impl RenderOnce for SortBuilder {
|
|||||||
IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X)
|
IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X)
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.tooltip("Remove")
|
.tooltip(i18n::text(cx, "builder.remove", &[]))
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
let mut next = for_remove.clone();
|
let mut next = for_remove.clone();
|
||||||
next.remove(ix);
|
next.remove(ix);
|
||||||
@@ -394,18 +406,21 @@ impl RenderOnce for SortBuilder {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.child(list)
|
.child(list)
|
||||||
.children(unused.map(|column| {
|
.children(unused.map(|column| {
|
||||||
Button::new((self.id.clone(), "add"), "Add sort")
|
Button::new(
|
||||||
.icon(IconName::Plus)
|
(self.id.clone(), "add"),
|
||||||
.variant(ButtonVariant::Ghost)
|
i18n::text(cx, "builder.add_sort", &[]),
|
||||||
.size(ControlSize::Sm)
|
)
|
||||||
.on_click(move |_, window, cx| {
|
.icon(IconName::Plus)
|
||||||
let mut next = add.1.clone();
|
.variant(ButtonVariant::Ghost)
|
||||||
next.push(SortKey {
|
.size(ControlSize::Sm)
|
||||||
column: column.clone(),
|
.on_click(move |_, window, cx| {
|
||||||
rising: true,
|
let mut next = add.1.clone();
|
||||||
});
|
next.push(SortKey {
|
||||||
(add.0)(&next, window, cx)
|
column: column.clone(),
|
||||||
})
|
rising: true,
|
||||||
|
});
|
||||||
|
(add.0)(&next, window, cx)
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-7
@@ -26,14 +26,15 @@ impl Test {
|
|||||||
Test::Empty,
|
Test::Empty,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
/// Its catalog key, which also names it.
|
||||||
|
pub fn key(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Contains => "contains",
|
Self::Contains => "builder.test.contains",
|
||||||
Self::Equals => "is",
|
Self::Equals => "builder.test.equals",
|
||||||
Self::NotEquals => "is not",
|
Self::NotEquals => "builder.test.not_equals",
|
||||||
Self::Above => "is above",
|
Self::Above => "builder.test.above",
|
||||||
Self::Below => "is below",
|
Self::Below => "builder.test.below",
|
||||||
Self::Empty => "is empty",
|
Self::Empty => "builder.test.empty",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! The sort and filter builders read in Chinese under zh-CN.
|
||||||
|
|
||||||
|
use gpui::{IntoElement, Render, TestAppContext, Window, div, prelude::*};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
i18n::{self, I18n},
|
||||||
|
tables::{FilterBuilder, FilterRule, SortBuilder, SortKey, Test},
|
||||||
|
theme::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Builders;
|
||||||
|
|
||||||
|
impl Render for Builders {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
let columns = [("a", "第一列"), ("b", "第二列")];
|
||||||
|
let rule = FilterRule {
|
||||||
|
column: "a".into(),
|
||||||
|
..FilterRule::default()
|
||||||
|
};
|
||||||
|
let key = SortKey {
|
||||||
|
column: "a".into(),
|
||||||
|
rising: true,
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.child(FilterBuilder::new("filter", columns).rules([rule.clone(), rule], true))
|
||||||
|
.child(SortBuilder::new("sort", columns).keys([key]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_builders_read_in_chinese(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
cx.set_global(I18n::new("zh-CN").catalog("zh-CN", &[]));
|
||||||
|
});
|
||||||
|
cx.update(|cx| {
|
||||||
|
for test in Test::ALL {
|
||||||
|
let shown = i18n::text(cx, test.key(), &[]);
|
||||||
|
assert!(
|
||||||
|
!shown.chars().any(|ch| ch.is_ascii_alphabetic()),
|
||||||
|
"{test:?}: {shown}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in [
|
||||||
|
"builder.add_rule",
|
||||||
|
"builder.all",
|
||||||
|
"builder.any",
|
||||||
|
"builder.add_sort",
|
||||||
|
"builder.up",
|
||||||
|
"builder.down",
|
||||||
|
] {
|
||||||
|
let shown = i18n::text(cx, key, &[]);
|
||||||
|
assert!(
|
||||||
|
!shown.chars().any(|ch| ch.is_ascii_alphabetic()),
|
||||||
|
"{key}: {shown}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Builders);
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use gpui::{
|
|||||||
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod chinese;
|
||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod columns;
|
mod columns;
|
||||||
mod stale;
|
mod stale;
|
||||||
|
|||||||
+40
-22
@@ -96,7 +96,7 @@ struct Formula {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A typeset formula: its picture and logical size.
|
/// A typeset formula: its picture and logical size.
|
||||||
type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
pub(crate) type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
||||||
|
|
||||||
enum Typesetter {}
|
enum Typesetter {}
|
||||||
|
|
||||||
@@ -166,7 +166,9 @@ fn laid(formula: &Formula) -> Result<(String, f32, f32), MathError> {
|
|||||||
(list.width * font, (list.height + list.depth) * font, svg)
|
(list.width * font, (list.height + list.depth) * font, svg)
|
||||||
})?;
|
})?;
|
||||||
let (width, height, svg) = typeset;
|
let (width, height, svg) = typeset;
|
||||||
Ok((svg, width as f32, height as f32))
|
let (width, height) = (width as f32, height as f32);
|
||||||
|
// RaTeX sizes its svg in pt; px keeps one unit.
|
||||||
|
Ok((wrapped(&svg, [0.0, 0.0, width, height])?, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs RaTeX's layout; a panic becomes an error.
|
/// Runs RaTeX's layout; a panic becomes an error.
|
||||||
@@ -187,41 +189,57 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>),
|
|||||||
let top = ink.top().min(0.0);
|
let top = ink.top().min(0.0);
|
||||||
let right = ink.right().max(width);
|
let right = ink.right().max(width);
|
||||||
let bottom = ink.bottom().max(height);
|
let bottom = ink.bottom().max(height);
|
||||||
|
let (w, h) = (right - left, bottom - top);
|
||||||
|
Ok((wrapped(svg, [left, top, w, h])?, size(px(w), px(h))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `svg` in a px root viewing `[left, top, w, h]`.
|
||||||
|
fn wrapped(svg: &str, [left, top, w, h]: [f32; 4]) -> Result<String, MathError> {
|
||||||
let open = svg
|
let open = svg
|
||||||
.find('>')
|
.find('>')
|
||||||
.ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?;
|
.ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?;
|
||||||
let close = svg
|
let close = svg
|
||||||
.rfind("</svg>")
|
.rfind("</svg>")
|
||||||
.ok_or_else(|| MathError::said("RaTeX left the svg open"))?;
|
.ok_or_else(|| MathError::said("RaTeX left the svg open"))?;
|
||||||
let (w, h) = (right - left, bottom - top);
|
Ok(format!(
|
||||||
let reframed = format!(
|
|
||||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {w} {h}\" width=\"{w}\" height=\"{h}\">{}</svg>",
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {w} {h}\" width=\"{w}\" height=\"{h}\">{}</svg>",
|
||||||
&svg[open + 1..close]
|
&svg[open + 1..close]
|
||||||
);
|
))
|
||||||
Ok((reframed, size(px(w), px(h))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Latex {
|
/// The asset key for `source` at `font` in `window`.
|
||||||
/// The asset key this formula draws from in `window`.
|
fn formula(
|
||||||
fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) {
|
(source, display): (&SharedString, bool),
|
||||||
let theme = cx.theme();
|
font: Pixels,
|
||||||
let font = theme.text_size(self.size).to_pixels(window.rem_size());
|
window: &Window,
|
||||||
let formula = Formula {
|
cx: &App,
|
||||||
source: self.source.clone(),
|
) -> Formula {
|
||||||
display: self.display,
|
Formula {
|
||||||
rgba: u32::from(theme.colors.fg.to_rgb()),
|
source: source.clone(),
|
||||||
font: (font.as_f32() * 100.0).round() as u32,
|
display,
|
||||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
rgba: u32::from(cx.theme().colors.fg.to_rgb()),
|
||||||
};
|
font: (font.as_f32() * 100.0).round() as u32,
|
||||||
(formula, font)
|
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `source` typeset at `font` in `window`; None while it loads.
|
||||||
|
pub(crate) fn typeset(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
(source, display): (&SharedString, bool),
|
||||||
|
font: Pixels,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Option<Typeset> {
|
||||||
|
let formula = formula((source, display), font, window, cx);
|
||||||
|
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
||||||
|
hold::<Typesetter>(id, &formula, picture, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
impl RenderOnce for Latex {
|
impl RenderOnce for Latex {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let (formula, font) = self.formula(window, cx);
|
let font = cx.theme().text_size(self.size).to_pixels(window.rem_size());
|
||||||
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
match typeset(self.id, (&self.source, self.display), font, window, cx) {
|
||||||
match hold::<Typesetter>(self.id, &formula, picture, window, cx) {
|
|
||||||
Some(Ok((picture, shape))) => img(picture)
|
Some(Ok((picture, shape))) => img(picture)
|
||||||
.w(shape.width)
|
.w(shape.width)
|
||||||
.h(shape.height)
|
.h(shape.height)
|
||||||
|
|||||||
@@ -114,8 +114,11 @@ fn flush(cx: &mut gpui::VisualTestContext) {
|
|||||||
/// Whether `source`'s picture sits in the asset cache.
|
/// Whether `source`'s picture sits in the asset cache.
|
||||||
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
let latex = Latex::parse("probe", source).expect("parses");
|
let font = cx
|
||||||
let (formula, _) = latex.formula(window, cx);
|
.theme()
|
||||||
|
.text_size(TextSize::Lg)
|
||||||
|
.to_pixels(window.rem_size());
|
||||||
|
let formula = super::formula((&source.to_string().into(), false), font, window, cx);
|
||||||
cx.has_asset::<Typesetter>(&formula)
|
cx.has_asset::<Typesetter>(&formula)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,3 +256,18 @@ fn drawn_fallback_text_stays_inside_the_frame() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_spans_its_formula_not_a_point_rescale() {
|
||||||
|
let formula = super::Formula {
|
||||||
|
source: "x^2+y^2=r^2".into(),
|
||||||
|
display: false,
|
||||||
|
rgba: 0xff,
|
||||||
|
font: 1600,
|
||||||
|
scale: 100,
|
||||||
|
};
|
||||||
|
let (_, width, _) = super::laid(&formula).expect("laid");
|
||||||
|
let (_, shape) = super::svg(&formula).expect("framed");
|
||||||
|
let ratio = shape.width.as_f32() / width;
|
||||||
|
assert!((0.95..1.05).contains(&ratio), "{} for {width}", shape.width);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ pub use formatted::{
|
|||||||
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
||||||
pub use link::{ExternalLink, Link};
|
pub use link::{ExternalLink, Link};
|
||||||
pub(crate) use math::failed;
|
pub(crate) use math::failed;
|
||||||
|
pub(crate) use math::typeset;
|
||||||
pub use math::{Latex, MathError, formula_svg};
|
pub use math::{Latex, MathError, formula_svg};
|
||||||
pub use rolling::AnimatedNumber;
|
pub use rolling::AnimatedNumber;
|
||||||
pub use select::SelectableText;
|
pub use select::SelectableText;
|
||||||
|
|||||||
Reference in new issue
Block a user