Compare commits

..

3 Commits

8 changed files with 298 additions and 36 deletions
+2 -3
View File
@@ -1,14 +1,13 @@
- [x] add option to highlight trailing whitespace (off by default) - [x] add option to highlight trailing whitespace (off by default)
- [x] add option to put characters at certain whitespace characters (off by default) - [x] add option to put characters at certain whitespace characters (off by default)
- whitespace kind -> character map should be configurable. - whitespace kind -> character map should be configurable.
- [ ] diff view - design question: - [x] diff view: use two-column diagonal hatching for missing counterparts (45° with 2:1 terminal cells)
- is it possible to angle the hatchings for empty lines more?
- if a character like the current one exists with a 45degree angle instead (and a top and bottom), we could use that across two columns. that would give us a perfectly 45 degree hatching.
- [ ] 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.
+3 -2
View File
@@ -185,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.
@@ -197,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` |
+57
View File
@@ -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.
+5 -2
View File
@@ -35,7 +35,8 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| 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 |
@@ -126,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.
@@ -420,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.
+115 -18
View File
@@ -325,6 +325,10 @@ enum EditStart {
target: char, target: char,
count: usize, count: usize,
}, },
ReplaceSelection {
target: char,
selector: SelectionIntent,
},
Delete, Delete,
Join, Join,
Paste { Paste {
@@ -559,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",
@@ -1165,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),
@@ -1218,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)
@@ -6217,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')
{ {
@@ -6225,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(),
}, },
); );
@@ -8462,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);
@@ -8471,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();
} }
@@ -9021,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,
}; };
@@ -9117,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));
@@ -19937,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;
+1
View File
@@ -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
+29 -8
View File
@@ -3310,7 +3310,7 @@ fn draw_diff_pane(
let buffer = frame.buffer_mut(); let buffer = frame.buffer_mut();
for x in 0..content.width { for x in 0..content.width {
buffer[(content.x + x, row_area.y)] buffer[(content.x + x, row_area.y)]
.set_symbol("╱") .set_symbol(["🯐", "🯑"][usize::from(x % 2)])
.set_style(style); .set_style(style);
} }
continue; continue;
@@ -5353,7 +5353,7 @@ mod tests {
if scope == WhitespaceScope::All { 8 } else { 4 }, if scope == WhitespaceScope::All { 8 } else { 4 },
); );
if mode == ViewMode::Split { if mode == ViewMode::Split {
assert!(buffer.content.iter().any(|cell| cell.symbol() == "╱")); assert!(buffer.content.iter().any(|cell| cell.symbol() == "🯐"));
} }
} }
} }
@@ -6479,7 +6479,7 @@ mod tests {
pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),) pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),)
.contains(if added { "editable" } else { "read-only" }) .contains(if added { "editable" } else { "read-only" })
); );
assert!(!text.contains('╱')); assert!(!text.contains(['🯐', '🯑']));
} }
let mut empty_added = deletion_file(); let mut empty_added = deletion_file();
empty_added.status = FileStatus::Added; empty_added.status = FileStatus::Added;
@@ -6541,7 +6541,7 @@ mod tests {
.contains("old revision → Working checkout") .contains("old revision → Working checkout")
); );
assert!(!text.contains("unchanged rename content")); assert!(!text.contains("unchanged rename content"));
assert!(!text.contains('╱')); assert!(!text.contains(['🯐', '🯑']));
let mut modified = diff_comparison(vec![deletion_file()]); let mut modified = diff_comparison(vec![deletion_file()]);
modified.files_visible = false; modified.files_visible = false;
@@ -6802,7 +6802,14 @@ mod tests {
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
.map(|x| { .map(|x| {
let cell = &buffer[(x, y)]; let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), "╱"); assert_eq!(
cell.symbol(),
if (x - after.x - 1) % 2 == 0 {
"🯐"
} else {
"🯑"
}
);
assert_eq!(cell.bg, Color::Reset); assert_eq!(cell.bg, Color::Reset);
assert_eq!(cell.fg, buffer[(after.x + 1, y + 1)].fg); assert_eq!(cell.fg, buffer[(after.x + 1, y + 1)].fg);
cell.symbol().to_owned() cell.symbol().to_owned()
@@ -6858,7 +6865,14 @@ mod tests {
let y = before.y + 1 + offset; let y = before.y + 1 + offset;
for x in before.x + 1..before.right() - 1 { for x in before.x + 1..before.right() - 1 {
let cell = &buffer[(x, y)]; let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), "╱"); assert_eq!(
cell.symbol(),
if (x - before.x - 1) % 2 == 0 {
"🯐"
} else {
"🯑"
}
);
assert_eq!(cell.bg, Color::Reset); assert_eq!(cell.bg, Color::Reset);
} }
assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string()); assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string());
@@ -6876,7 +6890,14 @@ mod tests {
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
for x in before.x + 1..before.right() - 1 { for x in before.x + 1..before.right() - 1 {
assert_eq!(buffer[(x, before.y + 1)].symbol(), "╱"); assert_eq!(
buffer[(x, before.y + 1)].symbol(),
if (x - before.x - 1) % 2 == 0 {
"🯐"
} else {
"🯑"
}
);
} }
assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│"); assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│");
assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│"); assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│");
@@ -10751,7 +10772,7 @@ end
vec!["3", "3", "end"] vec!["3", "3", "end"]
] ]
); );
assert!(!lines.iter().any(|line| line.contains('╱'))); assert!(!lines.iter().any(|line| line.contains(['🯐', '🯑'])));
editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t'));
render_native_diff(&mut terminal, &mut views, &mut editor); render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap(); let geometry = views.panels.diff.as_ref().unwrap();
+83
View File
@@ -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"] {