Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4099435175 | |||
| a8428f9dde | |||
| cd427f74dc | |||
| 11b31d2621 | |||
| adcc4f2656 | |||
| 2357b5be46 | |||
| e2f33519d1 | |||
| b58fb085a3 | |||
| 00628e0efd |
@@ -1,12 +1,13 @@
|
|||||||
- [ ] git gutter flashes on every keystroke/change; this should be fixed.
|
- [x] add option to highlight trailing whitespace (off by default)
|
||||||
- [ ] make all buffers (including pickers) scrolls the window when the cursor reaches a certain threshold. setting could something like "distance from frame edges", and the value could be an integer or none:
|
- [x] add option to put characters at certain whitespace characters (off by default)
|
||||||
- "none" would mean "always keep the cursor in the center of the frame" (i.e. scroll the viewport whenever the cursor moves to a different line)
|
- whitespace kind -> character map should be configurable.
|
||||||
- an integer value would be the distance from the edge of the viewport that the cursor must hit before the viewport begins to scroll. a value of 0 means the viewport scrolls only right before the cursor would otherwise have left the viewport; a value greater than half the width of the viewport behaves similarly to "none" (at least until the viewport is resized to support that range in a meaningful way).
|
- [x] diff view: use two-column diagonal hatching for missing counterparts (45° with 2:1 terminal cells)
|
||||||
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
|
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
|
||||||
- github pr picker (floating window)
|
- github pr picker (floating window)
|
||||||
- three tabs: open, closed, all
|
- three tabs: open, closed, all
|
||||||
- search under the given category
|
- search under the given category
|
||||||
- selecting a pr opens the diff viewer for that pr using the branch merge base (standard github diff view on prs; already implemented as default diff viewer behavior in vamp)
|
- selecting a pr opens the diff viewer for that pr using the branch merge base (standard github diff view on prs; already implemented as default diff viewer behavior in vamp)
|
||||||
|
- [ ] cut down on the amount of documentation in the project (readme file is needlessly detailed and filled with irrelevant information).
|
||||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Comparison views reserve no separate metadata header or native-buffer help foote
|
|||||||
|
|
||||||
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
||||||
|
|
||||||
|
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
|
||||||
|
|
||||||
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
||||||
|
|
||||||
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
|
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
|
||||||
@@ -86,7 +88,7 @@ Ratatui's bounded layout cache is enabled: unchanged geometry and constraints re
|
|||||||
|
|
||||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
|
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
|
||||||
|
|
||||||
Repository gutter work runs off the render/editor thread. Revision/path-validated marker vectors belong to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
|
Repository gutter work runs off the render/editor thread. Revision/path-validated worker results update marker vectors belonging to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. After text edits, the last completed markers for the same path remain visible until a current-revision result replaces them, rather than disappearing during each asynchronous refresh. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
|
||||||
|
|
||||||
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
|
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -15,6 +15,10 @@ Vamp.configure do |config|
|
|||||||
config.clipboard_mode = "system"
|
config.clipboard_mode = "system"
|
||||||
config.git_blame = true # false disables current-line attribution
|
config.git_blame = true # false disables current-line attribution
|
||||||
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
||||||
|
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||||
|
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||||
|
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||||
|
config.whitespace_scope = "all" # or "leading" for indentation only
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
@@ -47,6 +51,22 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||||
|
|
||||||
|
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
|
||||||
|
|
||||||
|
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
|
||||||
|
|
||||||
|
`config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration.
|
||||||
|
|
||||||
|
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact.
|
||||||
|
|
||||||
|
`config.whitespace_scope` defaults to `"all"`. Use `"leading"` to mark only the initial whitespace prefix of each source line; a whitespace-only line is entirely leading, but wrapping and horizontal scrolling do not create new indentation. Only the strings `"all"` and `"leading"` are accepted; other values reject the configuration.
|
||||||
|
|
||||||
|
A `"\n"` map entry marks actual line breaks independently of `whitespace_scope`, including blank lines and a file's final newline when present. Soft wraps, missing final newlines, and the virtual empty EOF row do not gain markers. The glyph occupies the existing end-of-line caret cell on the final wrapped segment; a full-width line uses the existing extra caret segment. In unwrapped text, an offscreen end-of-line marker is clipped like other source cells. Newline markers inside syntax-recognized strings are suppressed along with horizontal whitespace markers.
|
||||||
|
|
||||||
|
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
|
||||||
|
|
||||||
|
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
|
||||||
|
|
||||||
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||||
|
|
||||||
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
|
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
|
||||||
@@ -165,7 +185,7 @@ With a decimal prefix, `g` instead jumps immediately to that one-based source li
|
|||||||
|
|
||||||
Normal `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans within each caret's current logical line, preserving newlines. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
|
Normal `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans within each caret's current logical line, preserving newlines. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
|
||||||
|
|
||||||
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal `r` waits for a literal replacement character through `config.core.replace_character`; `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
|
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal and Visual `r` wait for a literal replacement character through `config.core.replace_character`: Normal honors the count; Visual replaces each selected grapheme without replacing newlines. Normal `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
|
||||||
|
|
||||||
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
||||||
|
|
||||||
@@ -177,7 +197,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
|||||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||||
| Normal | `copy_line`, `delete_line`, `change_line` |
|
| Normal | `copy_line`, `delete_line`, `change_line` |
|
||||||
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `replace_character`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
|
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
|
||||||
|
| Normal, Visual | `replace_character` |
|
||||||
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
|
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
|
||||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||||
| Normal | `follow_link` |
|
| Normal | `follow_link` |
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Diff hatching: two-column diagonal feasibility
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
**Yes: Unicode has the exact two line segments needed.** Repeat the following pair, in this order, with each character occupying one terminal column:
|
||||||
|
|
||||||
|
| Position | Character | Code point | Official Unicode name |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Left cell | | U+1FBD0 | BOX DRAWINGS LIGHT DIAGONAL MIDDLE RIGHT TO LOWER LEFT |
|
||||||
|
| Right cell | | U+1FBD1 | BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO MIDDLE LEFT |
|
||||||
|
|
||||||
|
These are listed under **Character cell diagonals** on page 6 of the [official Unicode 17.0 Symbols for Legacy Computing chart](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf); the exact names are also in [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt). They were introduced in **Unicode 16.0** (`1FBCB..1FBEF` in [DerivedAge.txt](https://www.unicode.org/Public/17.0.0/ucd/DerivedAge.txt)). The names specify endpoints, not a drawing direction: together they join the pair's bottom-left corner to the shared edge's midpoint, then to the pair's top-right corner.
|
||||||
|
|
||||||
|
Sample (glyphs may be missing or differently rendered in the Markdown viewer):
|
||||||
|
|
||||||
|
```text
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent escaped sequence: `\u{1FBD0}\u{1FBD1}`. This is two separate characters/cells, not one double-width character.
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
For physical cell width `W` and height `H`, the ideal joined stroke has horizontal run `2W` and vertical rise `H`, so its angle above horizontal is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
θ = atan(H / (2W))
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **45° iff `H = 2W`**. The current full-cell `╱` has ideal angle `atan(H / W)`; at `H = 2W`, that is approximately 63.4°. Thus the pair is shallower, but the actual angle depends on the terminal's cell dimensions, including line spacing. These are geometric deductions from the chart's endpoints, not measured rendering results.
|
||||||
|
|
||||||
|
Do not substitute U+1FBA0–U+1FBAE: their centre/cell-edge endpoints describe different segments (see page 5 of the same chart). In particular, a side midpoint to top/bottom centre has run `W/2` and rise `H/2`, preserving the full-cell diagonal's slope rather than halving it.
|
||||||
|
|
||||||
|
## Line segments, not filled mosaics
|
||||||
|
|
||||||
|
U+1FBD0/U+1FBD1 are **light box-drawing strokes**. The chart separately labels U+1FB3C–U+1FB6F as **Smooth mosaic terminal graphic characters**: their `BLOCK DIAGONAL` names describe filled regions bounded by diagonals, not thin lines. For example, U+1FB48 is LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO LOWER MIDDLE RIGHT and U+1FB42 is LOWER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER RIGHT ([chart, pages 3–4](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf), [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt)). Those are not equivalent replacements for light hatching.
|
||||||
|
|
||||||
|
## Support limits and current code
|
||||||
|
|
||||||
|
- Unicode 16.0 repertoire support is a prerequisite; the existence of these characters does not establish coverage in the selected terminal/font. Missing glyphs or fallback glyphs remain possible, and fallback geometry must be checked in the intended renderer. Unicode explicitly says its chart glyph shapes are **not prescriptive** and actual fonts may vary ([chart, page 1](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf)). Pixel-perfect joins or 45° rendering cannot be promised.
|
||||||
|
- The pair has East Asian Width **Neutral (`N`)**, from `1FB94..1FBEF` in [EastAsianWidth.txt](https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt). This is not a terminal-width guarantee: [UAX #11, §2 and §4](https://www.unicode.org/reports/tr11/) explicitly requires terminal-specific tailoring and distinguishes font/layout width from the property. Both the application and terminal must agree on one column per glyph.
|
||||||
|
- Rendering now alternates `` and ``, starting at each pane's left content edge, with `DarkGray` foreground and reset background in [src/tui.rs:3308–3316](../src/tui.rs#L3308-L3316). The phase stays fixed during scrolling; odd widths clip the final pair. Actual empty source lines remain unhatched. See [docs/editing.md:426](editing.md#L426).
|
||||||
|
|
||||||
|
## Terminal support
|
||||||
|
|
||||||
|
- **Ghostty 1.3.1:** The [released drawing source](https://github.com/ghostty-org/ghostty/blob/v1.3.1/src/font/sprite/draw/symbols_for_legacy_computing.zig#L1007-L1031) explicitly implements both segments with the required endpoints. Ghostty's [codepoint resolver](https://github.com/ghostty-org/ghostty/blob/main/src/font/CodepointResolver.zig#L120-L145) normally selects procedural sprite rendering before ordinary font lookup, so this does not require font coverage; explicit codepoint font overrides take precedence.
|
||||||
|
- **Kitty:** [Current font dispatch](https://github.com/kovidgoyal/kitty/blob/master/kitty/fonts.c#L838-L886) routes this range to native box drawing, and [drawing source](https://github.com/kovidgoyal/kitty/blob/master/kitty/decorations.c#L2759-L2774) explicitly implements the two segments. This establishes support in inspected upstream source, not a minimum released version.
|
||||||
|
- The local environment reported Herdr 0.9.3; installed binaries reported Ghostty 1.3.1 and Kitty 0.45.0. Herdr [runs inside an outer terminal](https://github.com/herdrdev/herdr), so its presence and these installations do not prove which renderer displays this session. Visual verification through the actual terminal/multiplexer remains necessary.
|
||||||
|
|
||||||
|
**Recommendation:** Use the two-character text tile if it looks correct in the target terminal. No image protocol or new dependency is needed. Older/other terminals may lack glyph coverage; exact 45° remains conditional on cell geometry.
|
||||||
|
|
||||||
|
## Runtime evidence
|
||||||
|
|
||||||
|
An executed disposable `rustc` smoke program used the repository's actual compiled `ratatui` and `unicode-width` dependencies, a fixed viewport, `Terminal<CrosstermBackend<Vec<u8>>>`, and the same per-cell `set_symbol`/style logic as the current renderer. Both U+1FBD0 and U+1FBD1 returned width `Some(1)`. At three rows high, widths 1, 2, 3, and 12 emitted exactly 3, 6, 9, and 36 glyphs respectively: no dropped or double-width cells. This establishes application-side width/emission behavior, **not** actual terminal glyph coverage, pixel joins, or the visible angle; those were not visually verified. The disposable program and binary were removed.
|
||||||
|
|
||||||
|
Implemented with no new dependency or graphics protocol. Verification: `cargo test diff_` (94 passing checks), `cargo build`, and `cargo fmt --package vamp --check`. A live PTY session opened `:diff` against a disposable jj baseline with two removed rows and emitted paired hatching at 100, 101, and 77 terminal columns; no U+2571 was emitted, the editor exited cleanly, and fixture content stayed unchanged. The fixture was removed. Terminal pixel joins remain visually unverified. The TODO item is complete.
|
||||||
+12
-3
@@ -8,6 +8,8 @@ Ordinary documents show repository changes beside their hybrid line numbers by d
|
|||||||
|
|
||||||
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
|
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
|
||||||
|
|
||||||
|
Indicators refresh asynchronously. Typing and undo/redo retain the last completed indicators until the updated comparison arrives, avoiding flashes; indicator values and row positions can briefly lag edits.
|
||||||
|
|
||||||
## Default editing keys
|
## Default editing keys
|
||||||
|
|
||||||
| Mode | Keys |
|
| Mode | Keys |
|
||||||
@@ -33,7 +35,8 @@ The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include
|
|||||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||||
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
|
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
|
||||||
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
|
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
|
||||||
| Normal | `r<char>` replaces the current grapheme; `s` deletes it and enters Insert |
|
| Normal, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual |
|
||||||
|
| Normal | `s` deletes the current grapheme and enters Insert |
|
||||||
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
||||||
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
|
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
|
||||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||||
@@ -124,6 +127,8 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps
|
|||||||
|
|
||||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
|
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
|
||||||
|
|
||||||
|
Visual `r<char>` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo.
|
||||||
|
|
||||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
|
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
|
||||||
|
|
||||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||||
@@ -200,6 +205,10 @@ Live file buffers automatically check disk metadata every 250 ms, including buff
|
|||||||
|
|
||||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||||
|
|
||||||
|
## Cursor-following scrolling
|
||||||
|
|
||||||
|
[Configure `scroll_distance`](configuration.md#preferences) to reserve space between the primary cursor or picker selection and the viewport edges. `0` (default) scrolls only to keep it visible; an integer reserves that many visual rows, and columns for unwrapped document text. `nil`, or a margin too large for the current viewport, centers it as far as the start of content permits. This policy applies to documents, historical/diff panes, file explorers, palettes, completion lists, and revision pickers. Reversing direction inside the margin does not move the viewport; resizing recalculates the usable margin.
|
||||||
|
|
||||||
## Mouse wheel scrolling
|
## Mouse wheel scrolling
|
||||||
|
|
||||||
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
|
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
|
||||||
@@ -384,7 +393,7 @@ Text panes use normal buffer interaction regardless of writability: motions and
|
|||||||
|
|
||||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||||
|
|
||||||
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. The next motion does not recenter; failed or superseded definitions preserve the current logical viewport.
|
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. Subsequent motions resume the configured `scroll_distance` policy; failed or superseded definitions preserve the current logical viewport.
|
||||||
|
|
||||||
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||||
|
|
||||||
@@ -414,7 +423,7 @@ Character-wise highlighting is enabled automatically within paired changed lines
|
|||||||
|
|
||||||
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
|
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
|
||||||
|
|
||||||
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column, with the same terminal `DarkGray` foreground as ordinary gutter text. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
Missing counterparts use repeated muted `` diagonals (U+1FBD0/U+1FBD1), one glyph per column, on the normal terminal background with the same terminal `DarkGray` foreground as ordinary gutter text. Each pair joins the bottom-left corner to the top-right corner across two columns, giving a 45° angle when cells are twice as tall as they are wide. The pattern starts at each pane's left content edge, stays fixed during scrolling, and clips the final pair at odd widths. These Unicode 16 characters need a supporting terminal or font; Ghostty 1.3.1 draws them natively. Pixel-perfect joins and the exact angle depend on terminal cell geometry and rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
||||||
|
|
||||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ Vamp.configure do |config|
|
|||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||||
config.git_blame = true # false disables current-line inline blame
|
config.git_blame = true # false disables current-line inline blame
|
||||||
|
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||||
|
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||||
|
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||||
|
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
|
|||||||
@@ -1140,6 +1140,15 @@ impl DiffState {
|
|||||||
.is_some_and(|file| file.loaded)
|
.is_some_and(|file| file.loaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Source rows without the editor's virtual terminal row, preserving real empty lines.
|
||||||
|
pub(crate) fn after_source_lines<'a>(&self, file: &'a ChangedFile) -> &'a [String] {
|
||||||
|
if self.live_files.contains(&self.file) {
|
||||||
|
live_content(&file.after, file.after_eof)
|
||||||
|
} else {
|
||||||
|
&file.after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_selected_file(&mut self) {
|
fn prepare_selected_file(&mut self) {
|
||||||
if self.refresh_pending() {
|
if self.refresh_pending() {
|
||||||
self.align_file();
|
self.align_file();
|
||||||
|
|||||||
+165
-21
@@ -25,6 +25,14 @@ use unicode_width::UnicodeWidthStr;
|
|||||||
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
|
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
|
||||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WhitespaceScope {
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
Leading,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum Input {
|
pub enum Input {
|
||||||
Char(char),
|
Char(char),
|
||||||
@@ -317,6 +325,10 @@ enum EditStart {
|
|||||||
target: char,
|
target: char,
|
||||||
count: usize,
|
count: usize,
|
||||||
},
|
},
|
||||||
|
ReplaceSelection {
|
||||||
|
target: char,
|
||||||
|
selector: SelectionIntent,
|
||||||
|
},
|
||||||
Delete,
|
Delete,
|
||||||
Join,
|
Join,
|
||||||
Paste {
|
Paste {
|
||||||
@@ -551,7 +563,7 @@ core_actions![
|
|||||||
(
|
(
|
||||||
"core.replace_character",
|
"core.replace_character",
|
||||||
Action::ReplaceCharacter,
|
Action::ReplaceCharacter,
|
||||||
&[Mode::Normal]
|
&[Mode::Normal, Mode::Visual]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"core.substitute_character",
|
"core.substitute_character",
|
||||||
@@ -1157,7 +1169,6 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
|||||||
('C', ChangeToLineEnd),
|
('C', ChangeToLineEnd),
|
||||||
('D', DeleteToLineEnd),
|
('D', DeleteToLineEnd),
|
||||||
('Y', CopyToLineEnd),
|
('Y', CopyToLineEnd),
|
||||||
('r', ReplaceCharacter),
|
|
||||||
('s', SubstituteCharacter),
|
('s', SubstituteCharacter),
|
||||||
('u', Undo),
|
('u', Undo),
|
||||||
('U', Redo),
|
('U', Redo),
|
||||||
@@ -1210,6 +1221,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
|||||||
('v', Select),
|
('v', Select),
|
||||||
('V', SelectLinewise),
|
('V', SelectLinewise),
|
||||||
('.', RepeatLastEdit),
|
('.', RepeatLastEdit),
|
||||||
|
('r', ReplaceCharacter),
|
||||||
] {
|
] {
|
||||||
bindings
|
bindings
|
||||||
.entry(mode)
|
.entry(mode)
|
||||||
@@ -1762,6 +1774,10 @@ pub struct Editor {
|
|||||||
bottom_bar: ChromeLine,
|
bottom_bar: ChromeLine,
|
||||||
popup: PopupConfig,
|
popup: PopupConfig,
|
||||||
labels: BTreeMap<String, String>,
|
labels: BTreeMap<String, String>,
|
||||||
|
scroll_distance: Option<usize>,
|
||||||
|
highlight_trailing_whitespace: bool,
|
||||||
|
whitespace_characters: BTreeMap<char, char>,
|
||||||
|
whitespace_scope: WhitespaceScope,
|
||||||
battery_charge: Option<u8>,
|
battery_charge: Option<u8>,
|
||||||
battery_label: String,
|
battery_label: String,
|
||||||
git_blame: bool,
|
git_blame: bool,
|
||||||
@@ -1863,6 +1879,10 @@ impl Editor {
|
|||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
popup: PopupConfig::default(),
|
popup: PopupConfig::default(),
|
||||||
labels: BTreeMap::new(),
|
labels: BTreeMap::new(),
|
||||||
|
scroll_distance: Some(0),
|
||||||
|
highlight_trailing_whitespace: false,
|
||||||
|
whitespace_characters: BTreeMap::new(),
|
||||||
|
whitespace_scope: WhitespaceScope::All,
|
||||||
battery_charge: None,
|
battery_charge: None,
|
||||||
battery_label: String::new(),
|
battery_label: String::new(),
|
||||||
git_blame: true,
|
git_blame: true,
|
||||||
@@ -2601,6 +2621,38 @@ impl Editor {
|
|||||||
.map_or(self.active.view.word_wrap, |diff| diff.word_wrap)
|
.map_or(self.active.view.word_wrap, |diff| diff.word_wrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn scroll_distance(&self) -> Option<usize> {
|
||||||
|
self.scroll_distance
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_scroll_distance(&mut self, distance: Option<usize>) {
|
||||||
|
self.scroll_distance = distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn highlight_trailing_whitespace(&self) -> bool {
|
||||||
|
self.highlight_trailing_whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_highlight_trailing_whitespace(&mut self, enabled: bool) {
|
||||||
|
self.highlight_trailing_whitespace = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn whitespace_characters(&self) -> &BTreeMap<char, char> {
|
||||||
|
&self.whitespace_characters
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap<char, char>) {
|
||||||
|
self.whitespace_characters = characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn whitespace_scope(&self) -> WhitespaceScope {
|
||||||
|
self.whitespace_scope
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_whitespace_scope(&mut self, scope: WhitespaceScope) {
|
||||||
|
self.whitespace_scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
|
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
|
||||||
self.diff_context_options = (context_lines, expand_lines);
|
self.diff_context_options = (context_lines, expand_lines);
|
||||||
if let Some(diff) = &mut self.diff {
|
if let Some(diff) = &mut self.diff {
|
||||||
@@ -4932,9 +4984,8 @@ impl Editor {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let changes = self.active.gutter_changes.as_ref()?;
|
let changes = self.active.gutter_changes.as_ref()?;
|
||||||
if changes.revision != self.active.text_revision
|
// Keep the last completed markers visible while the worker recomputes after edits.
|
||||||
|| Some(changes.path.as_path()) != self.path()
|
if Some(changes.path.as_path()) != self.path() {
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
changes.lines.get(row).copied().flatten()
|
changes.lines.get(row).copied().flatten()
|
||||||
@@ -6170,7 +6221,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
if let Some((operator, prefix)) = self.line_pending.take() {
|
if let Some((operator, prefix)) = self.line_pending.take() {
|
||||||
if operator == 'r' {
|
if operator == 'r' {
|
||||||
if self.active.view.mode == Mode::Normal
|
if matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
|
||||||
&& let Input::Char(target) = input
|
&& let Input::Char(target) = input
|
||||||
&& (!target.is_control() || target == '\t')
|
&& (!target.is_control() || target == '\t')
|
||||||
{
|
{
|
||||||
@@ -6178,14 +6229,24 @@ impl Editor {
|
|||||||
self.set_message("Diff pane is read-only");
|
self.set_message("Diff pane is read-only");
|
||||||
} else {
|
} else {
|
||||||
let before = self.snapshot();
|
let before = self.snapshot();
|
||||||
self.replace_characters(target, prefix);
|
let (ranges, start) = if self.active.view.mode == Mode::Visual {
|
||||||
self.record_native(
|
let ranges = self.selections();
|
||||||
before,
|
let selector = self.selection_intent(&ranges);
|
||||||
EditRecipe {
|
(ranges, EditStart::ReplaceSelection { target, selector })
|
||||||
start: EditStart::Replace {
|
} else {
|
||||||
|
(
|
||||||
|
self.normal_ranges(Action::SubstituteCharacter, prefix),
|
||||||
|
EditStart::Replace {
|
||||||
target,
|
target,
|
||||||
count: prefix,
|
count: prefix,
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.replace_characters(target, &ranges);
|
||||||
|
self.record_native(
|
||||||
|
before,
|
||||||
|
EditRecipe {
|
||||||
|
start,
|
||||||
steps: Vec::new(),
|
steps: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -8415,8 +8476,7 @@ impl Editor {
|
|||||||
self.reveal_caret();
|
self.reveal_caret();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_characters(&mut self, replacement: char, count: usize) {
|
fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) {
|
||||||
let ranges = self.normal_ranges(Action::SubstituteCharacter, count);
|
|
||||||
let offsets = line_offsets(&self.active.lines);
|
let offsets = line_offsets(&self.active.lines);
|
||||||
let mut bytes = [0; 4];
|
let mut bytes = [0; 4];
|
||||||
let replacement: &str = replacement.encode_utf8(&mut bytes);
|
let replacement: &str = replacement.encode_utf8(&mut bytes);
|
||||||
@@ -8424,20 +8484,52 @@ impl Editor {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, range)| range.start != range.end)
|
.filter(|(_, range)| range.start != range.end)
|
||||||
.map(|(target, range)| Edit {
|
.map(|(target, range)| {
|
||||||
target: Some(target),
|
let start = cursor_offset(&self.active.lines, &offsets, range.start);
|
||||||
start: cursor_offset(&self.active.lines, &offsets, range.start),
|
let end = cursor_offset(&self.active.lines, &offsets, range.end);
|
||||||
end: cursor_offset(&self.active.lines, &offsets, range.end),
|
let text = if range.start.row == range.end.row {
|
||||||
text: if range.end.column - range.start.column == 1 {
|
let count = range.end.column - range.start.column;
|
||||||
|
if count == 1 {
|
||||||
Cow::Borrowed(replacement)
|
Cow::Borrowed(replacement)
|
||||||
} else {
|
} else {
|
||||||
Cow::Owned(replacement.repeat(range.end.column - range.start.column))
|
Cow::Owned(replacement.repeat(count))
|
||||||
},
|
}
|
||||||
|
} else {
|
||||||
|
let mut text = String::with_capacity(end - start);
|
||||||
|
for row in range.start.row..=range.end.row {
|
||||||
|
let first = if row == range.start.row {
|
||||||
|
range.start.column
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let last = if row == range.end.row {
|
||||||
|
range.end.column
|
||||||
|
} else {
|
||||||
|
grapheme_count(&self.active.lines[row])
|
||||||
|
};
|
||||||
|
text.reserve((last - first) * replacement.len());
|
||||||
|
for _ in first..last {
|
||||||
|
text.push_str(replacement);
|
||||||
|
}
|
||||||
|
if row < range.end.row {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cow::Owned(text)
|
||||||
|
};
|
||||||
|
Edit {
|
||||||
|
target: Some(target),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
text,
|
||||||
caret: 0,
|
caret: 0,
|
||||||
round_up: false,
|
round_up: false,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.apply_edits(edits);
|
self.apply_edits(edits);
|
||||||
|
self.active.view.visual_motions = None;
|
||||||
|
self.active.view.linewise = false;
|
||||||
self.active.view.mode = Mode::Normal;
|
self.active.view.mode = Mode::Normal;
|
||||||
self.normalize_targets();
|
self.normalize_targets();
|
||||||
}
|
}
|
||||||
@@ -8974,10 +9066,18 @@ impl Editor {
|
|||||||
EditStart::Selection {
|
EditStart::Selection {
|
||||||
selector: SelectionIntent::Spans(spans),
|
selector: SelectionIntent::Spans(spans),
|
||||||
..
|
..
|
||||||
|
}
|
||||||
|
| EditStart::ReplaceSelection {
|
||||||
|
selector: SelectionIntent::Spans(spans),
|
||||||
|
..
|
||||||
} if self.active.view.mode != Mode::Visual => Some(spans.len()),
|
} if self.active.view.mode != Mode::Visual => Some(spans.len()),
|
||||||
EditStart::Selection {
|
EditStart::Selection {
|
||||||
selector: SelectionIntent::Rows(rows),
|
selector: SelectionIntent::Rows(rows),
|
||||||
..
|
..
|
||||||
|
}
|
||||||
|
| EditStart::ReplaceSelection {
|
||||||
|
selector: SelectionIntent::Rows(rows),
|
||||||
|
..
|
||||||
} if self.active.view.mode != Mode::Visual => Some(rows.len()),
|
} if self.active.view.mode != Mode::Visual => Some(rows.len()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -9070,7 +9170,14 @@ impl Editor {
|
|||||||
self.normalize_targets();
|
self.normalize_targets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditStart::Replace { target, count } => self.replace_characters(*target, *count),
|
EditStart::Replace { target, count } => {
|
||||||
|
let ranges = self.normal_ranges(Action::SubstituteCharacter, *count);
|
||||||
|
self.replace_characters(*target, &ranges);
|
||||||
|
}
|
||||||
|
EditStart::ReplaceSelection { target, selector } => {
|
||||||
|
let ranges = self.replay_ranges(selector);
|
||||||
|
self.replace_characters(*target, &ranges);
|
||||||
|
}
|
||||||
EditStart::Lines { change, count } => {
|
EditStart::Lines { change, count } => {
|
||||||
let ranges = self.line_selections(*count);
|
let ranges = self.line_selections(*count);
|
||||||
clipboard = Some((self.selection_fragments(&ranges), true));
|
clipboard = Some((self.selection_fragments(&ranges), true));
|
||||||
@@ -19890,6 +19997,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_visual_replacement_preserves_snapshot_selections_and_history() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut editor = editor();
|
||||||
|
historical_diff(
|
||||||
|
&mut editor,
|
||||||
|
&directory.0,
|
||||||
|
vec!["one two three".into()],
|
||||||
|
vec!["four five six".into()],
|
||||||
|
);
|
||||||
|
for focus in [Focus::Before, Focus::After] {
|
||||||
|
if editor.diff().unwrap().focus != focus {
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
}
|
||||||
|
type_text(&mut editor, "vl");
|
||||||
|
let text = editor.context().text;
|
||||||
|
let targets = editor.active.view.targets.clone();
|
||||||
|
let undo = editor.active.undo.len();
|
||||||
|
let clipboard = editor.clipboard.clone();
|
||||||
|
for pending in [false, true] {
|
||||||
|
if pending {
|
||||||
|
editor.line_pending = Some(('r', 1));
|
||||||
|
} else {
|
||||||
|
editor.handle(Input::Char('r'));
|
||||||
|
}
|
||||||
|
editor.handle(Input::Char('X'));
|
||||||
|
assert_eq!(editor.context().text, text);
|
||||||
|
assert_eq!(editor.active.view.targets, targets);
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.line_operator_pending(), None);
|
||||||
|
assert_eq!(editor.clipboard, clipboard);
|
||||||
|
assert_eq!(editor.active.undo.len(), undo);
|
||||||
|
}
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
||||||
editor.active.view.mode = Mode::Normal;
|
editor.active.view.mode = Mode::Normal;
|
||||||
editor.active.view.visual_motions = None;
|
editor.active.view.visual_motions = None;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ pub struct FilePicker {
|
|||||||
tree_for_node: Vec<usize>,
|
tree_for_node: Vec<usize>,
|
||||||
tree_roots: Vec<usize>,
|
tree_roots: Vec<usize>,
|
||||||
rows: Vec<VisibleRow>,
|
rows: Vec<VisibleRow>,
|
||||||
|
viewport_revision: u64,
|
||||||
expanded: HashSet<usize>,
|
expanded: HashSet<usize>,
|
||||||
filter_collapsed: HashSet<usize>,
|
filter_collapsed: HashSet<usize>,
|
||||||
saved_selection: [Option<usize>; 2],
|
saved_selection: [Option<usize>; 2],
|
||||||
@@ -298,6 +299,7 @@ impl FilePicker {
|
|||||||
tree: Vec::new(),
|
tree: Vec::new(),
|
||||||
tree_roots: Vec::new(),
|
tree_roots: Vec::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
|
viewport_revision: 0,
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
filter_collapsed: HashSet::new(),
|
filter_collapsed: HashSet::new(),
|
||||||
saved_selection: [None, None],
|
saved_selection: [None, None],
|
||||||
@@ -346,6 +348,10 @@ impl FilePicker {
|
|||||||
tree
|
tree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn viewport_revision(&self) -> u64 {
|
||||||
|
self.viewport_revision
|
||||||
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
|
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
|
||||||
self.rows.iter().map(|row| {
|
self.rows.iter().map(|row| {
|
||||||
let node = &self.nodes[row.node];
|
let node = &self.nodes[row.node];
|
||||||
@@ -542,6 +548,8 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn project(&mut self, selected: Option<usize>, select_match: bool) {
|
fn project(&mut self, selected: Option<usize>, select_match: bool) {
|
||||||
|
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
let query = self.query.to_lowercase();
|
let query = self.query.to_lowercase();
|
||||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
|||||||
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
||||||
Esc keeps only the primary caret
|
Esc keeps only the primary caret
|
||||||
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||||
|
r<char> replaces every selected grapheme, preserving newlines; one undo action
|
||||||
I/A insert at each selected line's first nonblank/end
|
I/A insert at each selected line's first nonblank/end
|
||||||
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
|
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
|
||||||
v/V switches selection shape; same selector or Esc cancels retaining carets
|
v/V switches selection shape; same selector or Esc cancels retaining carets
|
||||||
|
|||||||
+278
-3
@@ -44,6 +44,10 @@ pub struct Config {
|
|||||||
pub bottom_bar: ChromeLine,
|
pub bottom_bar: ChromeLine,
|
||||||
pub popup: PopupConfig,
|
pub popup: PopupConfig,
|
||||||
pub labels: BTreeMap<String, String>,
|
pub labels: BTreeMap<String, String>,
|
||||||
|
pub scroll_distance: Option<usize>,
|
||||||
|
pub highlight_trailing_whitespace: bool,
|
||||||
|
pub whitespace_characters: BTreeMap<char, char>,
|
||||||
|
pub whitespace_scope: crate::editor::WhitespaceScope,
|
||||||
pub diff_context_lines: usize,
|
pub diff_context_lines: usize,
|
||||||
pub diff_expand_lines: usize,
|
pub diff_expand_lines: usize,
|
||||||
pub diff_view_mode: crate::diff::ViewMode,
|
pub diff_view_mode: crate::diff::ViewMode,
|
||||||
@@ -89,6 +93,10 @@ impl Default for Config {
|
|||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
popup: PopupConfig::default(),
|
popup: PopupConfig::default(),
|
||||||
labels: BTreeMap::new(),
|
labels: BTreeMap::new(),
|
||||||
|
scroll_distance: Some(0),
|
||||||
|
highlight_trailing_whitespace: false,
|
||||||
|
whitespace_characters: BTreeMap::new(),
|
||||||
|
whitespace_scope: crate::editor::WhitespaceScope::All,
|
||||||
diff_context_lines: 10,
|
diff_context_lines: 10,
|
||||||
diff_expand_lines: 15,
|
diff_expand_lines: 15,
|
||||||
diff_view_mode: crate::diff::ViewMode::Split,
|
diff_view_mode: crate::diff::ViewMode::Split,
|
||||||
@@ -121,6 +129,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
bottom_bar,
|
bottom_bar,
|
||||||
popup,
|
popup,
|
||||||
labels,
|
labels,
|
||||||
|
scroll_distance,
|
||||||
|
highlight_trailing_whitespace,
|
||||||
|
whitespace_characters,
|
||||||
|
whitespace_scope,
|
||||||
diff_context_lines,
|
diff_context_lines,
|
||||||
diff_expand_lines,
|
diff_expand_lines,
|
||||||
diff_view_mode,
|
diff_view_mode,
|
||||||
@@ -154,6 +166,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.bottom_bar = bottom_bar;
|
config.bottom_bar = bottom_bar;
|
||||||
config.popup = popup;
|
config.popup = popup;
|
||||||
config.labels = labels;
|
config.labels = labels;
|
||||||
|
config.scroll_distance = scroll_distance;
|
||||||
|
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
|
||||||
|
config.whitespace_characters = whitespace_characters;
|
||||||
|
config.whitespace_scope = whitespace_scope;
|
||||||
config.diff_context_lines = diff_context_lines;
|
config.diff_context_lines = diff_context_lines;
|
||||||
config.diff_expand_lines = diff_expand_lines;
|
config.diff_expand_lines = diff_expand_lines;
|
||||||
config.diff_view_mode = diff_view_mode;
|
config.diff_view_mode = diff_view_mode;
|
||||||
@@ -450,6 +466,17 @@ fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Resul
|
|||||||
diff_line_count(deserializer, "diff_expand_lines", 1)
|
diff_line_count(deserializer, "diff_expand_lines", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scroll_distance<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<usize>, D::Error> {
|
||||||
|
Option::<usize>::deserialize(deserializer).map_err(|error| {
|
||||||
|
serde::de::Error::custom(format!(
|
||||||
|
"scroll_distance must be nil or an Integer between 0 and {}: {error}",
|
||||||
|
usize::MAX
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Startup-only configuration does not inflate every queued plugin response.
|
// Startup-only configuration does not inflate every queued plugin response.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -474,6 +501,12 @@ struct ConfigFrame {
|
|||||||
popup: PopupConfig,
|
popup: PopupConfig,
|
||||||
#[serde(deserialize_with = "crate::chrome::labels")]
|
#[serde(deserialize_with = "crate::chrome::labels")]
|
||||||
labels: BTreeMap<String, String>,
|
labels: BTreeMap<String, String>,
|
||||||
|
#[serde(deserialize_with = "scroll_distance")]
|
||||||
|
scroll_distance: Option<usize>,
|
||||||
|
highlight_trailing_whitespace: bool,
|
||||||
|
#[serde(deserialize_with = "whitespace_characters")]
|
||||||
|
whitespace_characters: BTreeMap<char, char>,
|
||||||
|
whitespace_scope: crate::editor::WhitespaceScope,
|
||||||
#[serde(deserialize_with = "diff_context_lines")]
|
#[serde(deserialize_with = "diff_context_lines")]
|
||||||
diff_context_lines: usize,
|
diff_context_lines: usize,
|
||||||
#[serde(deserialize_with = "diff_expand_lines")]
|
#[serde(deserialize_with = "diff_expand_lines")]
|
||||||
@@ -483,6 +516,30 @@ struct ConfigFrame {
|
|||||||
diff_follow_working_copy: bool,
|
diff_follow_working_copy: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<BTreeMap<char, char>, D::Error> {
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
|
||||||
|
for (&whitespace, &marker) in &characters {
|
||||||
|
if !whitespace.is_whitespace()
|
||||||
|
|| (!matches!(whitespace, '\t' | '\n')
|
||||||
|
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
|
||||||
|
{
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"whitespace_characters keys must be visible-width whitespace characters, tabs, or newlines",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if marker.is_control() || marker.width() != Some(1) {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"whitespace_characters values must be printable single-cell characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(characters)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "type", deny_unknown_fields)]
|
#[serde(tag = "type", deny_unknown_fields)]
|
||||||
enum Response {
|
enum Response {
|
||||||
@@ -1217,9 +1274,9 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
for frame in [
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||||
] {
|
] {
|
||||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
@@ -1295,6 +1352,10 @@ end
|
|||||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||||
"git_gutter": "bar",
|
"git_gutter": "bar",
|
||||||
|
"scroll_distance": 0,
|
||||||
|
"highlight_trailing_whitespace": false,
|
||||||
|
"whitespace_characters": {},
|
||||||
|
"whitespace_scope": "all",
|
||||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
@@ -1313,6 +1374,151 @@ end
|
|||||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_whitespace_configuration_rejects_non_booleans() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for value in ["nil", "0", "\"true\"", "[]", "{}"] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
load_config(&path)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("highlight_trailing_whitespace must be true or false")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_characters_validate_real_ruby_configuration() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for value in [
|
||||||
|
"nil",
|
||||||
|
"false",
|
||||||
|
"[]",
|
||||||
|
r#"{ space: "·" }"#,
|
||||||
|
r#"{ " " => 1 }"#,
|
||||||
|
r#"{ "" => "." }"#,
|
||||||
|
r#"{ " " => "." }"#,
|
||||||
|
r#"{ " " => ".." }"#,
|
||||||
|
r#"{ "x" => "." }"#,
|
||||||
|
r#"{ "\u000b" => "." }"#,
|
||||||
|
r#"{ "\r" => "." }"#,
|
||||||
|
r#"{ "\u000c" => "." }"#,
|
||||||
|
r#"{ "\u0085" => "." }"#,
|
||||||
|
r#"{ "\u200b" => "." }"#,
|
||||||
|
r#"{ " " => "\t" }"#,
|
||||||
|
r#"{ " " => "\n" }"#,
|
||||||
|
r#"{ " " => "\u0301" }"#,
|
||||||
|
r#"{ " " => "界" }"#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(load_config(&path).is_err(), "{value}");
|
||||||
|
}
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
load_config(&path).unwrap().whitespace_characters,
|
||||||
|
BTreeMap::from([
|
||||||
|
(' ', '·'),
|
||||||
|
('\t', '→'),
|
||||||
|
('\n', '↵'),
|
||||||
|
('\u{a0}', '⍽'),
|
||||||
|
('\u{3000}', '_'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() {
|
||||||
|
use crate::editor::WhitespaceScope;
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file("config.rb", "Vamp.configure {}");
|
||||||
|
assert_eq!(
|
||||||
|
load_config(&path).unwrap().whitespace_scope,
|
||||||
|
WhitespaceScope::All
|
||||||
|
);
|
||||||
|
for (value, expected) in [
|
||||||
|
("all", WhitespaceScope::All),
|
||||||
|
("leading", WhitespaceScope::Leading),
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#),
|
||||||
|
);
|
||||||
|
assert_eq!(load_config(&path).unwrap().whitespace_scope, expected);
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
"nil",
|
||||||
|
"false",
|
||||||
|
"0",
|
||||||
|
"[]",
|
||||||
|
"{}",
|
||||||
|
":all",
|
||||||
|
r#""""#,
|
||||||
|
r#""unknown""#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(load_config(&path).is_err(), "{value}");
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!(
|
||||||
|
r#"Vamp.configure do |c|
|
||||||
|
c.whitespace_scope = "leading"
|
||||||
|
begin
|
||||||
|
c.whitespace_scope = {value}
|
||||||
|
raise "invalid scope accepted"
|
||||||
|
rescue TypeError, ArgumentError
|
||||||
|
end
|
||||||
|
end"#,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
load_config(&path).unwrap().whitespace_scope,
|
||||||
|
WhitespaceScope::Leading
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut frame = serde_json::json!({
|
||||||
|
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||||
|
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||||
|
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||||
|
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
|
||||||
|
"whitespace_characters": {}, "whitespace_scope": "all",
|
||||||
|
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||||
|
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
|
"diff_follow_working_copy": true
|
||||||
|
});
|
||||||
|
for value in ["all", "leading"] {
|
||||||
|
frame["whitespace_scope"] = serde_json::json!(value);
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
serde_json::Value::Null,
|
||||||
|
serde_json::json!(false),
|
||||||
|
serde_json::json!(0),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!(""),
|
||||||
|
serde_json::json!("unknown"),
|
||||||
|
] {
|
||||||
|
frame["whitespace_scope"] = value;
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
|
}
|
||||||
|
frame.as_object_mut().unwrap().remove("whitespace_scope");
|
||||||
|
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn working_diff_tracking_rejects_non_boolean_configuration() {
|
fn working_diff_tracking_rejects_non_boolean_configuration() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1545,6 +1751,10 @@ end
|
|||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
|
"scroll_distance": 0,
|
||||||
|
"highlight_trailing_whitespace": false,
|
||||||
|
"whitespace_characters": {},
|
||||||
|
"whitespace_scope": "all",
|
||||||
"popup": {"command": {}, "search": {}},
|
"popup": {"command": {}, "search": {}},
|
||||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
@@ -1585,6 +1795,67 @@ end
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let frame = serde_json::json!({
|
||||||
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||||||
|
"highlight_trailing_whitespace": false,
|
||||||
|
"whitespace_characters": {},
|
||||||
|
"whitespace_scope": "all",
|
||||||
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
|
"popup": {"command": {}, "search": {}},
|
||||||
|
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
|
"diff_follow_working_copy": true, "scroll_distance": 0
|
||||||
|
});
|
||||||
|
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
||||||
|
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||||||
|
);
|
||||||
|
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
|
||||||
|
let mut valid_frame = frame.clone();
|
||||||
|
valid_frame["scroll_distance"] = serde_json::json!(distance);
|
||||||
|
let response = serde_json::from_value::<Response>(valid_frame).unwrap();
|
||||||
|
let Response::Config(config) = response else {
|
||||||
|
panic!("expected a configuration frame");
|
||||||
|
};
|
||||||
|
assert_eq!(config.scroll_distance, distance);
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
"-1",
|
||||||
|
"1.5",
|
||||||
|
"\"3\"",
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"18446744073709551616",
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||||||
|
);
|
||||||
|
let error = load_config(&path).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.contains("scroll_distance") && error.contains("must be"),
|
||||||
|
"{value}: {error}"
|
||||||
|
);
|
||||||
|
let mut invalid_frame = frame.clone();
|
||||||
|
invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap();
|
||||||
|
let error = serde_json::from_value::<Response>(invalid_frame)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("scroll_distance"),
|
||||||
|
"{value}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
|
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1962,6 +2233,10 @@ end"#,
|
|||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
"git_blame": true,
|
"git_blame": true,
|
||||||
"git_gutter": "bar",
|
"git_gutter": "bar",
|
||||||
|
"scroll_distance": 0,
|
||||||
|
"highlight_trailing_whitespace": false,
|
||||||
|
"whitespace_characters": {},
|
||||||
|
"whitespace_scope": "all",
|
||||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
"popup": {"command": {}, "search": {}},
|
"popup": {"command": {}, "search": {}},
|
||||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ module Vamp
|
|||||||
@bar = BarConfig.new
|
@bar = BarConfig.new
|
||||||
@popup = PopupConfig.new
|
@popup = PopupConfig.new
|
||||||
@labels = {}
|
@labels = {}
|
||||||
|
@scroll_distance = 0
|
||||||
|
@highlight_trailing_whitespace = false
|
||||||
|
@whitespace_characters = {}
|
||||||
|
@whitespace_scope = "all"
|
||||||
@diff_context_lines = 10
|
@diff_context_lines = 10
|
||||||
@diff_expand_lines = 15
|
@diff_expand_lines = 15
|
||||||
@diff_view_mode = "split"
|
@diff_view_mode = "split"
|
||||||
@@ -214,6 +218,42 @@ module Vamp
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
|
||||||
|
# @return [Integer, nil]
|
||||||
|
def scroll_distance=(distance)
|
||||||
|
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param enabled [Boolean]
|
||||||
|
# @return [Boolean]
|
||||||
|
def highlight_trailing_whitespace=(enabled)
|
||||||
|
raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled)
|
||||||
|
|
||||||
|
@highlight_trailing_whitespace = enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param characters [Hash{String => String}] whitespace character to single-cell marker
|
||||||
|
# @return [Hash{String => String}]
|
||||||
|
def whitespace_characters=(characters)
|
||||||
|
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
|
||||||
|
|
||||||
|
characters.each do |whitespace, marker|
|
||||||
|
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
|
||||||
|
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@whitespace_characters = characters.dup
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param scope [String] "all" or "leading"
|
||||||
|
# @return [String]
|
||||||
|
def whitespace_scope=(scope)
|
||||||
|
scope = Vamp.string!(scope, "whitespace_scope")
|
||||||
|
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
|
||||||
|
|
||||||
|
@whitespace_scope = scope
|
||||||
|
end
|
||||||
|
|
||||||
# @param lines [Integer] nonnegative context lines around each diff change
|
# @param lines [Integer] nonnegative context lines around each diff change
|
||||||
# @return [Integer]
|
# @return [Integer]
|
||||||
def diff_context_lines=(lines)
|
def diff_context_lines=(lines)
|
||||||
@@ -285,6 +325,10 @@ module Vamp
|
|||||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||||
"git_blame" => @git_blame,
|
"git_blame" => @git_blame,
|
||||||
"git_gutter" => @git_gutter,
|
"git_gutter" => @git_gutter,
|
||||||
|
"scroll_distance" => @scroll_distance,
|
||||||
|
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
||||||
|
"whitespace_characters" => @whitespace_characters,
|
||||||
|
"whitespace_scope" => @whitespace_scope,
|
||||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||||
|
|||||||
+22
-1
@@ -256,6 +256,10 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
editor.set_git_blame(config.git_blame);
|
editor.set_git_blame(config.git_blame);
|
||||||
editor.set_gutter_style(config.git_gutter);
|
editor.set_gutter_style(config.git_gutter);
|
||||||
|
editor.set_scroll_distance(config.scroll_distance);
|
||||||
|
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
|
||||||
|
editor.set_whitespace_characters(config.whitespace_characters);
|
||||||
|
editor.set_whitespace_scope(config.whitespace_scope);
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
@@ -1610,17 +1614,34 @@ mod tests {
|
|||||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
session.editor.handle(Input::Char('i'));
|
session.editor.handle(Input::Char('i'));
|
||||||
session.editor.handle(Input::Char('x'));
|
session.editor.handle(Input::Char('x'));
|
||||||
assert_eq!(session.editor.gutter_change(1), None);
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
|
session.editor.handle(Input::Char('y'));
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
|
session.editor.handle(Input::Backspace);
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
|
session.editor.handle(Input::Escape);
|
||||||
|
session.editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
|
session.editor.handle(Input::Char('U'));
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
assert!(!session.apply_gutter_result(
|
assert!(!session.apply_gutter_result(
|
||||||
second_request,
|
second_request,
|
||||||
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
|
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
|
||||||
));
|
));
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
session.poll_gutter_at(now);
|
session.poll_gutter_at(now);
|
||||||
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||||
assert!(session.apply_gutter_result(
|
assert!(session.apply_gutter_result(
|
||||||
session.gutter_request_id,
|
session.gutter_request_id,
|
||||||
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
|
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
|
||||||
));
|
));
|
||||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
|
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
|
||||||
|
session.poll_gutter_at(now + Duration::from_secs(1));
|
||||||
|
assert!(session.apply_gutter_result(
|
||||||
|
session.gutter_request_id,
|
||||||
|
vec![(first_buffer, Ok(vec![None, None]))],
|
||||||
|
));
|
||||||
|
assert_eq!(session.editor.gutter_change(1), None);
|
||||||
session.editor.execute(&format!("buffer {second_buffer}"));
|
session.editor.execute(&format!("buffer {second_buffer}"));
|
||||||
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
|
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-20
@@ -24,7 +24,7 @@ pub enum SyntaxKind {
|
|||||||
Markup,
|
Markup,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A syntax foreground range in the original line's bytes, excluding its newline.
|
/// A syntax range in original line bytes; string coverage may include its newline.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct SyntaxSpan {
|
pub struct SyntaxSpan {
|
||||||
pub start_byte: usize,
|
pub start_byte: usize,
|
||||||
@@ -37,12 +37,14 @@ pub struct SyntaxSpan {
|
|||||||
pub struct SyntaxCache {
|
pub struct SyntaxCache {
|
||||||
revision: Option<u64>,
|
revision: Option<u64>,
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
|
final_newline: bool,
|
||||||
syntax: Option<&'static LanguageSpec>,
|
syntax: Option<&'static LanguageSpec>,
|
||||||
source: String,
|
source: String,
|
||||||
next_source: String,
|
next_source: String,
|
||||||
line_starts: Vec<usize>,
|
line_starts: Vec<usize>,
|
||||||
lines: Vec<Vec<SyntaxSpan>>,
|
lines: Vec<Vec<SyntaxSpan>>,
|
||||||
highlighted: Vec<bool>,
|
highlighted: Vec<bool>,
|
||||||
|
string_spans: Vec<Vec<SyntaxSpan>>,
|
||||||
context: Option<LayerContext>,
|
context: Option<LayerContext>,
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
tree: Option<Tree>,
|
tree: Option<Tree>,
|
||||||
@@ -88,6 +90,7 @@ impl SyntaxCache {
|
|||||||
path,
|
path,
|
||||||
revision,
|
revision,
|
||||||
std::slice::from_ref(&(0..lines.len())),
|
std::slice::from_ref(&(0..lines.len())),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,26 @@ impl SyntaxCache {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
visible: &[std::ops::Range<usize>],
|
visible: &[std::ops::Range<usize>],
|
||||||
) {
|
) {
|
||||||
self.update_rows(lines, path, revision, visible);
|
self.update_rows(lines, path, revision, visible, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff sources store a final line break separately instead of a trailing empty row.
|
||||||
|
pub(crate) fn update_diff(
|
||||||
|
&mut self,
|
||||||
|
lines: &[String],
|
||||||
|
path: Option<&Path>,
|
||||||
|
revision: u64,
|
||||||
|
final_newline: bool,
|
||||||
|
visible: Option<&[std::ops::Range<usize>]>,
|
||||||
|
) {
|
||||||
|
let all_rows = 0..lines.len();
|
||||||
|
self.update_rows(
|
||||||
|
lines,
|
||||||
|
path,
|
||||||
|
revision,
|
||||||
|
visible.unwrap_or(std::slice::from_ref(&all_rows)),
|
||||||
|
final_newline && !lines.is_empty(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_rows(
|
fn update_rows(
|
||||||
@@ -109,9 +131,11 @@ impl SyntaxCache {
|
|||||||
path: Option<&Path>,
|
path: Option<&Path>,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
visible: &[std::ops::Range<usize>],
|
visible: &[std::ops::Range<usize>],
|
||||||
|
final_newline: bool,
|
||||||
) {
|
) {
|
||||||
let path_changed = self.path.as_deref() != path;
|
let path_changed = self.path.as_deref() != path;
|
||||||
let revision_changed = self.revision != Some(revision);
|
let revision_changed =
|
||||||
|
self.revision != Some(revision) || self.final_newline != final_newline;
|
||||||
let syntax = if path_changed || revision_changed {
|
let syntax = if path_changed || revision_changed {
|
||||||
languages::detect(path, lines.first().map(String::as_str))
|
languages::detect(path, lines.first().map(String::as_str))
|
||||||
} else {
|
} else {
|
||||||
@@ -119,6 +143,7 @@ impl SyntaxCache {
|
|||||||
};
|
};
|
||||||
let language_changed = !same_language(self.syntax, syntax);
|
let language_changed = !same_language(self.syntax, syntax);
|
||||||
self.revision = Some(revision);
|
self.revision = Some(revision);
|
||||||
|
self.final_newline = final_newline;
|
||||||
if path_changed {
|
if path_changed {
|
||||||
self.path = path.map(Path::to_path_buf);
|
self.path = path.map(Path::to_path_buf);
|
||||||
}
|
}
|
||||||
@@ -127,8 +152,9 @@ impl SyntaxCache {
|
|||||||
let mut edit = None;
|
let mut edit = None;
|
||||||
if revision_changed {
|
if revision_changed {
|
||||||
self.next_source.clear();
|
self.next_source.clear();
|
||||||
let length =
|
let length = lines.iter().map(String::len).sum::<usize>()
|
||||||
lines.iter().map(String::len).sum::<usize>() + lines.len().saturating_sub(1);
|
+ lines.len().saturating_sub(1)
|
||||||
|
+ usize::from(final_newline);
|
||||||
self.next_source.reserve(length);
|
self.next_source.reserve(length);
|
||||||
for (row, line) in lines.iter().enumerate() {
|
for (row, line) in lines.iter().enumerate() {
|
||||||
if row > 0 {
|
if row > 0 {
|
||||||
@@ -136,6 +162,9 @@ impl SyntaxCache {
|
|||||||
}
|
}
|
||||||
self.next_source.push_str(line);
|
self.next_source.push_str(line);
|
||||||
}
|
}
|
||||||
|
if final_newline {
|
||||||
|
self.next_source.push('\n');
|
||||||
|
}
|
||||||
if self.source != self.next_source {
|
if self.source != self.next_source {
|
||||||
edit = Some(minimal_edit(&self.source, &self.next_source));
|
edit = Some(minimal_edit(&self.source, &self.next_source));
|
||||||
std::mem::swap(&mut self.source, &mut self.next_source);
|
std::mem::swap(&mut self.source, &mut self.next_source);
|
||||||
@@ -146,11 +175,15 @@ impl SyntaxCache {
|
|||||||
self.line_starts.push(start);
|
self.line_starts.push(start);
|
||||||
start += line.len() + 1;
|
start += line.len() + 1;
|
||||||
}
|
}
|
||||||
|
if final_newline {
|
||||||
|
self.line_starts.push(self.source.len());
|
||||||
|
}
|
||||||
self.lines.resize_with(lines.len(), Vec::new);
|
self.lines.resize_with(lines.len(), Vec::new);
|
||||||
|
self.string_spans.resize_with(lines.len(), Vec::new);
|
||||||
self.highlighted.resize(lines.len(), false);
|
self.highlighted.resize(lines.len(), false);
|
||||||
}
|
}
|
||||||
if edit.is_some() || language_changed {
|
if edit.is_some() || language_changed {
|
||||||
for spans in &mut self.lines {
|
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||||
spans.clear();
|
spans.clear();
|
||||||
}
|
}
|
||||||
self.highlighted.fill(false);
|
self.highlighted.fill(false);
|
||||||
@@ -183,6 +216,8 @@ impl SyntaxCache {
|
|||||||
start_point: Point::new(start, 0),
|
start_point: Point::new(start, 0),
|
||||||
end_point: if row < lines.len() {
|
end_point: if row < lines.len() {
|
||||||
Point::new(row, 0)
|
Point::new(row, 0)
|
||||||
|
} else if final_newline {
|
||||||
|
Point::new(lines.len(), 0)
|
||||||
} else {
|
} else {
|
||||||
Point::new(
|
Point::new(
|
||||||
lines.len().saturating_sub(1),
|
lines.len().saturating_sub(1),
|
||||||
@@ -209,7 +244,7 @@ impl SyntaxCache {
|
|||||||
self.injections.clear();
|
self.injections.clear();
|
||||||
self.context = None;
|
self.context = None;
|
||||||
self.highlighted.fill(false);
|
self.highlighted.fill(false);
|
||||||
for spans in &mut self.lines {
|
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||||
spans.clear();
|
spans.clear();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -225,6 +260,12 @@ impl SyntaxCache {
|
|||||||
self.lines.get(row).map_or(&[], Vec::as_slice)
|
self.lines.get(row).map_or(&[], Vec::as_slice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// String coverage before foreground overrides, including embedded-language highlighting.
|
||||||
|
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
|
||||||
|
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
|
||||||
|
self.string_spans.get(row).map_or(&[], Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bundled grammar name, or `None` for unknown languages and plain text.
|
/// Bundled grammar name, or `None` for unknown languages and plain text.
|
||||||
pub fn language(&self) -> Option<&str> {
|
pub fn language(&self) -> Option<&str> {
|
||||||
self.syntax.map(|syntax| syntax.name)
|
self.syntax.map(|syntax| syntax.name)
|
||||||
@@ -415,6 +456,7 @@ impl SyntaxCache {
|
|||||||
&self.source,
|
&self.source,
|
||||||
&self.line_starts,
|
&self.line_starts,
|
||||||
&mut self.lines,
|
&mut self.lines,
|
||||||
|
&mut self.string_spans,
|
||||||
&self.highlights,
|
&self.highlights,
|
||||||
&mut self.events,
|
&mut self.events,
|
||||||
visible,
|
visible,
|
||||||
@@ -1089,6 +1131,7 @@ fn render_spans(
|
|||||||
source: &str,
|
source: &str,
|
||||||
starts: &[usize],
|
starts: &[usize],
|
||||||
lines: &mut [Vec<SyntaxSpan>],
|
lines: &mut [Vec<SyntaxSpan>],
|
||||||
|
strings: &mut [Vec<SyntaxSpan>],
|
||||||
highlights: &[Highlight],
|
highlights: &[Highlight],
|
||||||
events: &mut Vec<(usize, bool, usize)>,
|
events: &mut Vec<(usize, bool, usize)>,
|
||||||
visible: &[Range],
|
visible: &[Range],
|
||||||
@@ -1111,24 +1154,34 @@ fn render_spans(
|
|||||||
};
|
};
|
||||||
let mut active: BTreeSet<(_, usize)> = BTreeSet::new();
|
let mut active: BTreeSet<(_, usize)> = BTreeSet::new();
|
||||||
let mut previous = 0;
|
let mut previous = 0;
|
||||||
|
let mut active_strings = 0;
|
||||||
for &(byte, entering, index) in events.iter() {
|
for &(byte, entering, index) in events.iter() {
|
||||||
if previous < byte
|
if previous < byte {
|
||||||
&& let Some((_, current)) = active.last()
|
let kind = active
|
||||||
&& let Some(kind) = highlights[*current].kind
|
.last()
|
||||||
{
|
.and_then(|(_, current)| highlights[*current].kind);
|
||||||
|
if kind.is_some() || active_strings > 0 {
|
||||||
let start = visible.partition_point(|range| range.end_byte <= previous);
|
let start = visible.partition_point(|range| range.end_byte <= previous);
|
||||||
for range in visible[start..]
|
for range in visible[start..]
|
||||||
.iter()
|
.iter()
|
||||||
.take_while(|range| range.start_byte < byte)
|
.take_while(|range| range.start_byte < byte)
|
||||||
{
|
{
|
||||||
append_global(
|
let low = previous.max(range.start_byte);
|
||||||
source,
|
let high = byte.min(range.end_byte);
|
||||||
starts,
|
if let Some(kind) = kind {
|
||||||
lines,
|
append_global(source, starts, lines, low, high, kind, false);
|
||||||
previous.max(range.start_byte),
|
}
|
||||||
byte.min(range.end_byte),
|
if active_strings > 0 {
|
||||||
kind,
|
append_global(source, starts, strings, low, high, SyntaxKind::String, true);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if highlights[index].kind == Some(SyntaxKind::String) {
|
||||||
|
if entering {
|
||||||
|
active_strings += 1;
|
||||||
|
} else {
|
||||||
|
active_strings -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entering {
|
if entering {
|
||||||
@@ -1147,6 +1200,7 @@ fn append_global(
|
|||||||
mut start: usize,
|
mut start: usize,
|
||||||
end: usize,
|
end: usize,
|
||||||
kind: SyntaxKind,
|
kind: SyntaxKind,
|
||||||
|
include_newline: bool,
|
||||||
) {
|
) {
|
||||||
if starts.is_empty() {
|
if starts.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -1156,7 +1210,7 @@ fn append_global(
|
|||||||
.saturating_sub(1);
|
.saturating_sub(1);
|
||||||
while start < end && row < lines.len() {
|
while start < end && row < lines.len() {
|
||||||
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
|
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
|
||||||
let high = end.min(line_end);
|
let high = end.min(line_end + usize::from(include_newline && row + 1 < starts.len()));
|
||||||
if start < high {
|
if start < high {
|
||||||
let spans = &mut lines[row];
|
let spans = &mut lines[row];
|
||||||
let low = start - starts[row];
|
let low = start - starts[row];
|
||||||
@@ -1196,6 +1250,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn string_newlines_remain_literal_across_visible_row_caching_and_edits() {
|
||||||
|
let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n";
|
||||||
|
let input = lines(source);
|
||||||
|
let path = Path::new("strings.py");
|
||||||
|
let mut cache = SyntaxCache::new();
|
||||||
|
let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| {
|
||||||
|
cache
|
||||||
|
.string_spans(row)
|
||||||
|
.iter()
|
||||||
|
.any(|span| span.start_byte <= length && span.end_byte > length)
|
||||||
|
};
|
||||||
|
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1)));
|
||||||
|
assert!(includes_newline(&cache, 0, input[0].len()));
|
||||||
|
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4)));
|
||||||
|
assert!(includes_newline(&cache, 0, input[0].len()));
|
||||||
|
assert!(includes_newline(&cache, 1, input[1].len()));
|
||||||
|
assert!(!includes_newline(&cache, 2, input[2].len()));
|
||||||
|
assert!(!includes_newline(&cache, 3, input[3].len()));
|
||||||
|
for (row, line) in input.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.line(row)
|
||||||
|
.iter()
|
||||||
|
.all(|span| span.end_byte <= line.len())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1);
|
||||||
|
assert!(!includes_newline(&cache, 0, 3));
|
||||||
|
assert!(!includes_newline(&cache, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
|
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -1893,6 +1979,7 @@ mod tests {
|
|||||||
source,
|
source,
|
||||||
&[0],
|
&[0],
|
||||||
&mut spans,
|
&mut spans,
|
||||||
|
&mut vec![Vec::new(); 1],
|
||||||
&highlights,
|
&highlights,
|
||||||
&mut events,
|
&mut events,
|
||||||
&[source_range(source)],
|
&[source_range(source)],
|
||||||
@@ -1963,6 +2050,7 @@ mod tests {
|
|||||||
source,
|
source,
|
||||||
&starts,
|
&starts,
|
||||||
&mut spans,
|
&mut spans,
|
||||||
|
&mut vec![Vec::new(); 3],
|
||||||
&highlights,
|
&highlights,
|
||||||
&mut Vec::new(),
|
&mut Vec::new(),
|
||||||
&[source_range(source)],
|
&[source_range(source)],
|
||||||
@@ -2004,6 +2092,7 @@ mod tests {
|
|||||||
source,
|
source,
|
||||||
&starts,
|
&starts,
|
||||||
&mut visible_spans,
|
&mut visible_spans,
|
||||||
|
&mut vec![Vec::new(); 3],
|
||||||
&visible_highlights,
|
&visible_highlights,
|
||||||
&mut Vec::new(),
|
&mut Vec::new(),
|
||||||
&window,
|
&window,
|
||||||
|
|||||||
+1568
-109
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,89 @@ fn keys(editor: &mut Editor, text: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let source = "ae\u{301}👩🚀z\nrest\n";
|
||||||
|
let mut editor = fixture.editor(source);
|
||||||
|
keys(&mut editor, "lv2l");
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
keys(&mut editor, "r界");
|
||||||
|
assert_eq!(editor.context().text, "a界界界\nrest\n");
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
keys(&mut editor, "u");
|
||||||
|
assert_eq!(editor.context().text, source);
|
||||||
|
assert!(!editor.dirty());
|
||||||
|
keys(&mut editor, "U");
|
||||||
|
assert_eq!(editor.context().text, "a界界界\nrest\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_r_preserves_selected_newlines_in_both_directions_and_shapes() {
|
||||||
|
for (source, selection, expected) in [
|
||||||
|
("ae\u{301}👩🚀z\n\nbc\nlast\n", "v2j", "QQQQ\n\nQc\nlast\n"),
|
||||||
|
("ae\u{301}👩🚀z\n\nbc\nlast\n", "2jv2k", "QQQQ\n\nQc\nlast\n"),
|
||||||
|
("ae\u{301}👩🚀z\n\nbc\nlast\n", "V2j", "QQQQ\n\nQQ\nlast\n"),
|
||||||
|
("ab\ncd", "Vj", "QQ\nQQ"),
|
||||||
|
("\n\n", "Vj", "\n\n"),
|
||||||
|
] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor(source);
|
||||||
|
keys(&mut editor, selection);
|
||||||
|
keys(&mut editor, "rQ");
|
||||||
|
assert_eq!(editor.context().text, expected, "{selection}");
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert_eq!(editor.dirty(), source != expected);
|
||||||
|
keys(&mut editor, "u");
|
||||||
|
assert_eq!(editor.context().text, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_r_replaces_every_selection_and_restores_carets_on_undo() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let source = "abcd\nwxyz\n";
|
||||||
|
let mut editor = fixture.editor(source);
|
||||||
|
keys(&mut editor, "vl");
|
||||||
|
editor.handle(Input::Ctrl('e'));
|
||||||
|
let carets = editor.carets();
|
||||||
|
assert_eq!(editor.selection_count(), 2);
|
||||||
|
keys(&mut editor, "r界");
|
||||||
|
assert_eq!(editor.context().text, "界界cd\n界界yz\n");
|
||||||
|
assert_eq!(editor.selection_count(), 2);
|
||||||
|
keys(&mut editor, "u");
|
||||||
|
assert_eq!(editor.context().text, source);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_r_repeats_selection_intent_without_overwriting_the_clipboard() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("abc\nuvwxyz\n");
|
||||||
|
keys(&mut editor, "yyvlrQj0.");
|
||||||
|
assert_eq!(editor.context().text, "QQc\nQQwxyz\n");
|
||||||
|
keys(&mut editor, "4lv.");
|
||||||
|
assert_eq!(editor.context().text, "QQc\nQQwxQz\n");
|
||||||
|
keys(&mut editor, "P");
|
||||||
|
assert_eq!(editor.context().text, "QQc\nabc\nQQwxQz\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_and_identical_visual_replacements_preserve_redo() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("abc\n");
|
||||||
|
keys(&mut editor, "rQulvr");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.line_operator_pending(), None);
|
||||||
|
keys(&mut editor, "rb");
|
||||||
|
assert_eq!(editor.context().text, "abc\n");
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert!(!editor.dirty());
|
||||||
|
keys(&mut editor, "U");
|
||||||
|
assert_eq!(editor.context().text, "Qbc\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
||||||
for operation in ["C", "D", "9C", "9D"] {
|
for operation in ["C", "D", "9C", "9D"] {
|
||||||
|
|||||||
Reference in New Issue
Block a user