Compare commits
4 Commits
971866388c
...
bb5c247ab2
| Author | SHA1 | Date | |
|---|---|---|---|
| bb5c247ab2 | |||
| 1cd56cf3f6 | |||
| 2260817fc0 | |||
| 5e6b602127 |
@@ -12,6 +12,7 @@
|
|||||||
- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
|
- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
|
||||||
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||||
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
|
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
|
||||||
|
- [x] better syntax highlighting for markdown files. color `-` and `1.` from lists.
|
||||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||||
- [ ] 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.
|
||||||
|
|||||||
+11
-7
@@ -57,16 +57,16 @@ Vamp.configure do |config|
|
|||||||
config.bar.top = {
|
config.bar.top = {
|
||||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||||
center: [], right: [:language, :lsp, :position],
|
center: [], right: [:language, :lsp, :position],
|
||||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
|
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||||
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
config.bar.bottom = {
|
config.bar.bottom = {
|
||||||
left: [:tabs], center: [], right: [:battery],
|
left: [:tabs], center: [], right: [:battery],
|
||||||
tabs: {
|
tabs: {
|
||||||
title: "{index} {view}{name}{modified}", padding: 1,
|
title: "{index} {view}{name}{modified}", padding: 1,
|
||||||
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
|
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||||
},
|
},
|
||||||
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
@@ -81,7 +81,7 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
|
|||||||
|
|
||||||
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
|
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
|
||||||
|
|
||||||
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▒▓"` / `"▓▒░"` edges; mode has only the trailing `"▓▒░"` edge, position and battery only the leading `"░▒▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
||||||
|
|
||||||
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||||
|
|
||||||
@@ -119,13 +119,17 @@ For command-capable actions, canonical Ex names derive automatically from the ac
|
|||||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||||
|
|
||||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. Modified Backspace accepts `alt+backspace` / `option+backspace` and `cmd+backspace` / `super+backspace`.
|
||||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||||
|
|
||||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab is also supported. Cmd/Super, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||||
|
|
||||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
||||||
|
|
||||||
|
`config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys.
|
||||||
|
|
||||||
|
Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. Native modifier events must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Ghostty's default Command+Backspace encoding is Ctrl+u (`\x15`), which invokes the same line-deletion action without a Ghostty override. It remains a distinct Ctrl+u binding, not a reconstructed Command modifier. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation.
|
||||||
|
|
||||||
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
||||||
|
|
||||||
Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab actions have no default open/close binding; for example, `config.bind "leader t", config.core.tab.new` and `config.bind "leader x", config.core.tab.close`. See [tabs](editing.md#tabs) for layout preservation and close guards.
|
Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab actions have no default open/close binding; for example, `config.bind "leader t", config.core.tab.new` and `config.bind "leader x", config.core.tab.close`. See [tabs](editing.md#tabs) for layout preservation and close guards.
|
||||||
|
|||||||
+9
-3
@@ -38,11 +38,17 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
|||||||
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
|
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
|
||||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||||
|
| Insert, Command, Search | Ctrl+u deletes backward to the logical line start |
|
||||||
|
| Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start |
|
||||||
|
|
||||||
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
||||||
|
|
||||||
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
|
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
|
||||||
|
|
||||||
|
On macOS, these modified Backspace defaults also edit prompt and filter queries, including file lists, the command palette, and revision selectors. Option+Backspace uses Unicode word boundaries, first skipping trailing whitespace; Command+Backspace removes only the prefix before the caret on the current logical line, preserving its suffix and newline. Buffer deletion runs at every caret, merges overlapping ranges, shares the Insert undo group, and dot repeats the boundary operation rather than the old deleted length. Within an active snippet, both operations stay confined to the current argument and update its mirrors. These modified Backspace defaults remain macOS-only; Ctrl+u invokes the same line-deletion action on every platform.
|
||||||
|
|
||||||
|
The terminal may deliver a native modifier or a conventional control sequence: Option may use legacy ESC+DEL, and Kitty CSI-u represents Command as **Super**, not Meta. Ghostty defaults to encoding Command+Backspace as Ctrl+u (`\x15`), which invokes the same backward line deletion without a Ghostty configuration override. Ctrl+u remains a separate, configurable binding; vamp cannot distinguish it from Command+Backspace when both send the same bytes. Vamp does not negotiate an enhanced keyboard protocol or support generic Command-modified character keys.
|
||||||
|
|
||||||
## Manual LSP completion
|
## Manual LSP completion
|
||||||
|
|
||||||
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
|
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
|
||||||
@@ -227,7 +233,7 @@ Use `:tab.new` to open the current buffer in a new, focused tab with one pane. E
|
|||||||
| `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes |
|
| `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes |
|
||||||
| `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab |
|
| `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab |
|
||||||
|
|
||||||
By default, multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▒▓` / `▓▒░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals keep the active title visible. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
By default, multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▓` / `▓░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals keep the active title visible. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
||||||
|
|
||||||
The [top and bottom bars](configuration.md#top-and-bottom-bars) accept the same builtin and custom segments, anchored left, truly centered on the full row, or anchored right. Either bar can show tabs or status information, with independent title templates, internal padding, and styled edge glyphs. Defaults keep tabs and battery above, with home-abbreviated paths on the bottom left and language/ready LSP names/position on the bottom right. Floating input and operation notifications overlay the workspace without reserving a footer. Empty content consumes no row, and a bar whose only visible content is tabs is hidden with a single tab in either position. Custom named values are session-wide and can update through plugins without editing buffers; empty values hide their segments. Both bars share collision handling: narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped.
|
The [top and bottom bars](configuration.md#top-and-bottom-bars) accept the same builtin and custom segments, anchored left, truly centered on the full row, or anchored right. Either bar can show tabs or status information, with independent title templates, internal padding, and styled edge glyphs. Defaults keep tabs and battery above, with home-abbreviated paths on the bottom left and language/ready LSP names/position on the bottom right. Floating input and operation notifications overlay the workspace without reserving a footer. Empty content consumes no row, and a bar whose only visible content is tabs is hidden with a single tab in either position. Custom named values are session-wide and can update through plugins without editing buffers; empty values hide their segments. Both bars share collision handling: narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped.
|
||||||
|
|
||||||
@@ -356,7 +362,7 @@ Binary data, missing final newlines, permission/type changes, symlink targets, a
|
|||||||
|
|
||||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||||
|
|
||||||
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme; Ctrl+u clears the query without editing the underlying buffer. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||||
|
|
||||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
|
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
|
||||||
|
|
||||||
@@ -398,6 +404,6 @@ Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.i
|
|||||||
|
|
||||||
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
|
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
|
||||||
|
|
||||||
Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. Plain-text hashes and other punctuation keep their existing styling.
|
Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. List markers (`-`, `+`, `*`, and ordered prefixes such as `1.` or `1)`) also use cyan markup, including nested and blockquoted lists. Plain-text hashes and other punctuation keep their existing styling.
|
||||||
|
|
||||||
An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing.
|
An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing.
|
||||||
|
|||||||
+4
-4
@@ -13,16 +13,16 @@ Vamp.configure do |config|
|
|||||||
config.bar.top = {
|
config.bar.top = {
|
||||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||||
center: [], right: [:language, :lsp, :position],
|
center: [], right: [:language, :lsp, :position],
|
||||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
|
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||||
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
config.bar.bottom = {
|
config.bar.bottom = {
|
||||||
left: [:tabs], center: [], right: [:battery],
|
left: [:tabs], center: [], right: [:battery],
|
||||||
tabs: {
|
tabs: {
|
||||||
title: "{index} {view}{name}{modified}", padding: 1,
|
title: "{index} {view}{name}{modified}", padding: 1,
|
||||||
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
|
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||||
},
|
},
|
||||||
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||||
|
|||||||
+2
-3
@@ -199,10 +199,10 @@ impl ChromeLine {
|
|||||||
fn default_segment(name: &str) -> SegmentConfig {
|
fn default_segment(name: &str) -> SegmentConfig {
|
||||||
let mut segment = SegmentConfig::default();
|
let mut segment = SegmentConfig::default();
|
||||||
if matches!(name, "tabs" | "position" | "battery") {
|
if matches!(name, "tabs" | "position" | "battery") {
|
||||||
segment.leading_edge = "░▒▓".into();
|
segment.leading_edge = "░▓".into();
|
||||||
}
|
}
|
||||||
if matches!(name, "tabs" | "mode") {
|
if matches!(name, "tabs" | "mode") {
|
||||||
segment.trailing_edge = "▓▒░".into();
|
segment.trailing_edge = "▓░".into();
|
||||||
}
|
}
|
||||||
if name == "tabs" {
|
if name == "tabs" {
|
||||||
segment.title = "{index} {view}{name}{modified}".into();
|
segment.title = "{index} {view}{name}{modified}".into();
|
||||||
@@ -430,7 +430,6 @@ mod tests {
|
|||||||
"bottom": {"left": [], "center": ["workspace"]}
|
"bottom": {"left": [], "center": ["workspace"]}
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
assert_eq!(lines.top.left, ["tabs"]);
|
assert_eq!(lines.top.left, ["tabs"]);
|
||||||
assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓");
|
|
||||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||||
assert!(lines.bottom.left.is_empty());
|
assert!(lines.bottom.left.is_empty());
|
||||||
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
||||||
|
|||||||
+52
-4
@@ -1,4 +1,4 @@
|
|||||||
use crate::editor::Input;
|
use crate::editor::{BackspaceKind, Input};
|
||||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
@@ -506,9 +506,11 @@ impl DiffState {
|
|||||||
Input::Home => self.selected = 0,
|
Input::Home => self.selected = 0,
|
||||||
Input::End => self.selected = self.visible.len().saturating_sub(1),
|
Input::End => self.selected = self.visible.len().saturating_sub(1),
|
||||||
Input::Enter if !self.loading => self.choose(),
|
Input::Enter if !self.loading => self.choose(),
|
||||||
Input::Backspace => {
|
input if BackspaceKind::from_input(&input).is_some() => {
|
||||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
if BackspaceKind::from_input(&input)
|
||||||
self.query.truncate(byte);
|
.expect("guard checked backspace input")
|
||||||
|
.truncate(&mut self.query)
|
||||||
|
{
|
||||||
self.refilter();
|
self.refilter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2113,6 +2115,52 @@ mod tests {
|
|||||||
assert_eq!(editor.diff().unwrap().selected, 1);
|
assert_eq!(editor.diff().unwrap().selected, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_word_and_line_deletion_preserve_source_in_both_stages() {
|
||||||
|
for after in [false, true] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("source text".into())])
|
||||||
|
.unwrap();
|
||||||
|
let source = editor.context().text;
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
let carets = editor.carets();
|
||||||
|
open(&mut editor);
|
||||||
|
if after {
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
}
|
||||||
|
let phase = if after { Phase::After } else { Phase::Before };
|
||||||
|
text(&mut editor, "FIX missing");
|
||||||
|
assert_eq!(editor.diff().unwrap().entries().len(), 1);
|
||||||
|
editor.handle(Input::AltBackspace);
|
||||||
|
assert_eq!(editor.diff().unwrap().query, "FIX ");
|
||||||
|
assert_eq!(editor.diff().unwrap().entries().len(), 2);
|
||||||
|
editor.handle(Input::SuperBackspace);
|
||||||
|
assert!(editor.diff().unwrap().query.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.entries()
|
||||||
|
.map(|entry| entry.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"Working checkout",
|
||||||
|
"abcdef123",
|
||||||
|
"fedcba321",
|
||||||
|
"Load history from origin"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||||
|
editor.handle(input);
|
||||||
|
}
|
||||||
|
assert_eq!(editor.diff().unwrap().phase, phase);
|
||||||
|
assert_eq!(editor.context().text, source);
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loader_preserves_after_step_query_category_and_selection_then_compares() {
|
fn loader_preserves_after_step_query_category_and_selection_then_compares() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
+406
-42
@@ -34,6 +34,8 @@ pub enum Input {
|
|||||||
Escape,
|
Escape,
|
||||||
Enter,
|
Enter,
|
||||||
Backspace,
|
Backspace,
|
||||||
|
AltBackspace,
|
||||||
|
SuperBackspace,
|
||||||
Delete,
|
Delete,
|
||||||
BackTab,
|
BackTab,
|
||||||
Left,
|
Left,
|
||||||
@@ -44,6 +46,46 @@ pub enum Input {
|
|||||||
End,
|
End,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum BackspaceKind {
|
||||||
|
Grapheme,
|
||||||
|
Word,
|
||||||
|
Line,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackspaceKind {
|
||||||
|
pub(crate) fn from_input(input: &Input) -> Option<Self> {
|
||||||
|
match input {
|
||||||
|
Input::Backspace => Some(Self::Grapheme),
|
||||||
|
Input::AltBackspace => Some(Self::Word),
|
||||||
|
Input::SuperBackspace => Some(Self::Line),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(self, prefix: &str) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Grapheme => prefix
|
||||||
|
.grapheme_indices(true)
|
||||||
|
.next_back()
|
||||||
|
.map_or(0, |(byte, _)| byte),
|
||||||
|
Self::Word => prefix
|
||||||
|
.split_word_bound_indices()
|
||||||
|
.rev()
|
||||||
|
.find(|(_, word)| !word.chars().all(char::is_whitespace))
|
||||||
|
.map_or(0, |(byte, _)| byte),
|
||||||
|
Self::Line => prefix.rfind('\n').map_or(0, |byte| byte + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn truncate(self, text: &mut String) -> bool {
|
||||||
|
let start = self.start(text);
|
||||||
|
let changed = start != text.len();
|
||||||
|
text.truncate(start);
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
@@ -295,7 +337,7 @@ enum InsertStep {
|
|||||||
Bulk(String),
|
Bulk(String),
|
||||||
Move(Action, usize),
|
Move(Action, usize),
|
||||||
Delete(Vec<bool>),
|
Delete(Vec<bool>),
|
||||||
Backspace(Vec<bool>),
|
Backspace(Vec<bool>, BackspaceKind),
|
||||||
Completion {
|
Completion {
|
||||||
primary_edit: usize,
|
primary_edit: usize,
|
||||||
edits: Vec<RelativeCompletionEdit>,
|
edits: Vec<RelativeCompletionEdit>,
|
||||||
@@ -583,6 +625,16 @@ core_actions![
|
|||||||
Action::Backspace,
|
Action::Backspace,
|
||||||
&[Mode::Insert, Mode::Command, Mode::Search],
|
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"core.backspace_word",
|
||||||
|
Action::BackspaceWord,
|
||||||
|
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.backspace_line",
|
||||||
|
Action::BackspaceLine,
|
||||||
|
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"core.submit",
|
"core.submit",
|
||||||
Action::Submit,
|
Action::Submit,
|
||||||
@@ -785,10 +837,19 @@ impl Action {
|
|||||||
| Self::BufferDelete
|
| Self::BufferDelete
|
||||||
| Self::BufferForceDelete
|
| Self::BufferForceDelete
|
||||||
| Self::LspCompletion
|
| Self::LspCompletion
|
||||||
) || (self == Self::Backspace && mode == Mode::Insert)
|
) || (self.backspace_kind().is_some() && mode == Mode::Insert)
|
||||||
|| (self == Self::SnippetNext && mode == Mode::Insert)
|
|| (self == Self::SnippetNext && mode == Mode::Insert)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn backspace_kind(self) -> Option<BackspaceKind> {
|
||||||
|
match self {
|
||||||
|
Self::Backspace => Some(BackspaceKind::Grapheme),
|
||||||
|
Self::BackspaceWord => Some(BackspaceKind::Word),
|
||||||
|
Self::BackspaceLine => Some(BackspaceKind::Line),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||||
CORE_ACTIONS
|
CORE_ACTIONS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -871,6 +932,16 @@ fn parse_key(key: &str) -> Result<Input, String> {
|
|||||||
(Some(modifier), Input::Char('\t')) if modifier.eq_ignore_ascii_case("shift") => {
|
(Some(modifier), Input::Char('\t')) if modifier.eq_ignore_ascii_case("shift") => {
|
||||||
Ok(Input::BackTab)
|
Ok(Input::BackTab)
|
||||||
}
|
}
|
||||||
|
(Some(modifier), Input::Backspace)
|
||||||
|
if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") =>
|
||||||
|
{
|
||||||
|
Ok(Input::AltBackspace)
|
||||||
|
}
|
||||||
|
(Some(modifier), Input::Backspace)
|
||||||
|
if modifier.eq_ignore_ascii_case("cmd") || modifier.eq_ignore_ascii_case("super") =>
|
||||||
|
{
|
||||||
|
Ok(Input::SuperBackspace)
|
||||||
|
}
|
||||||
(Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => {
|
(Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => {
|
||||||
if modifier.eq_ignore_ascii_case("ctrl") {
|
if modifier.eq_ignore_ascii_case("ctrl") {
|
||||||
Ok(control_key(ch))
|
Ok(control_key(ch))
|
||||||
@@ -916,6 +987,8 @@ fn append_key(display: &mut String, input: &Input) {
|
|||||||
Input::Escape => display.push_str("Esc"),
|
Input::Escape => display.push_str("Esc"),
|
||||||
Input::Enter => display.push_str("Enter"),
|
Input::Enter => display.push_str("Enter"),
|
||||||
Input::Backspace => display.push_str("Backspace"),
|
Input::Backspace => display.push_str("Backspace"),
|
||||||
|
Input::AltBackspace => display.push_str("Alt+Backspace"),
|
||||||
|
Input::SuperBackspace => display.push_str("Cmd+Backspace"),
|
||||||
Input::Delete => display.push_str("Delete"),
|
Input::Delete => display.push_str("Delete"),
|
||||||
Input::Left => display.push_str("Left"),
|
Input::Left => display.push_str("Left"),
|
||||||
Input::Right => display.push_str("Right"),
|
Input::Right => display.push_str("Right"),
|
||||||
@@ -1066,6 +1139,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
|||||||
for (key, action) in [
|
for (key, action) in [
|
||||||
(Input::Escape, Normal),
|
(Input::Escape, Normal),
|
||||||
(Input::Backspace, Backspace),
|
(Input::Backspace, Backspace),
|
||||||
|
(Input::Ctrl('u'), BackspaceLine),
|
||||||
(
|
(
|
||||||
Input::Enter,
|
Input::Enter,
|
||||||
if mode == Mode::Insert {
|
if mode == Mode::Insert {
|
||||||
@@ -1088,6 +1162,16 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
|||||||
.or_default()
|
.or_default()
|
||||||
.insert(key, Binding::Action(action));
|
.insert(key, Binding::Action(action));
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
for (key, action) in [
|
||||||
|
(Input::AltBackspace, BackspaceWord),
|
||||||
|
(Input::SuperBackspace, BackspaceLine),
|
||||||
|
] {
|
||||||
|
bindings
|
||||||
|
.entry(mode)
|
||||||
|
.or_default()
|
||||||
|
.insert(key, Binding::Action(action));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for mode in [
|
for mode in [
|
||||||
Mode::Normal,
|
Mode::Normal,
|
||||||
@@ -3857,7 +3941,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only the primary argument and its declared mirrors participate in these edits.
|
// Only the primary argument and its declared mirrors participate in these edits.
|
||||||
fn edit_snippet(&mut self, text: &str, deletion: Option<bool>) -> bool {
|
fn edit_snippet(&mut self, text: &str, deletion: Option<Action>) -> bool {
|
||||||
let Some(mut state) = self.snippet.take() else {
|
let Some(mut state) = self.snippet.take() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -3873,13 +3957,9 @@ impl Editor {
|
|||||||
let position = caret - argument.start;
|
let position = caret - argument.start;
|
||||||
let (start, end) = if state.selected {
|
let (start, end) = if state.selected {
|
||||||
(0, value.len())
|
(0, value.len())
|
||||||
} else if let Some(backwards) = deletion {
|
} else if let Some(action) = deletion {
|
||||||
if backwards {
|
if let Some(kind) = action.backspace_kind() {
|
||||||
let start = value[..position]
|
(kind.start(&value[..position]), position)
|
||||||
.grapheme_indices(true)
|
|
||||||
.next_back()
|
|
||||||
.map_or(position, |(byte, _)| byte);
|
|
||||||
(start, position)
|
|
||||||
} else {
|
} else {
|
||||||
let end = value[position..]
|
let end = value[position..]
|
||||||
.graphemes(true)
|
.graphemes(true)
|
||||||
@@ -4953,6 +5033,11 @@ impl Editor {
|
|||||||
self.palette = None;
|
self.palette = None;
|
||||||
self.pane_close_picker_origin = None;
|
self.pane_close_picker_origin = None;
|
||||||
}
|
}
|
||||||
|
Input::Ctrl('u') => {
|
||||||
|
if BackspaceKind::Line.truncate(&mut palette.query) {
|
||||||
|
self.refresh_palette();
|
||||||
|
}
|
||||||
|
}
|
||||||
Input::Enter => {
|
Input::Enter => {
|
||||||
let entry = palette
|
let entry = palette
|
||||||
.entries
|
.entries
|
||||||
@@ -4982,9 +5067,11 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
self.active.view.mode = Mode::Command;
|
self.active.view.mode = Mode::Command;
|
||||||
}
|
}
|
||||||
Input::Backspace => {
|
input if BackspaceKind::from_input(&input).is_some() => {
|
||||||
if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() {
|
if BackspaceKind::from_input(&input)
|
||||||
palette.query.truncate(byte);
|
.expect("backspace input")
|
||||||
|
.truncate(&mut palette.query)
|
||||||
|
{
|
||||||
self.refresh_palette();
|
self.refresh_palette();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5568,6 +5655,8 @@ impl Editor {
|
|||||||
| Action::SnippetPrevious
|
| Action::SnippetPrevious
|
||||||
| Action::Newline
|
| Action::Newline
|
||||||
| Action::Backspace
|
| Action::Backspace
|
||||||
|
| Action::BackspaceWord
|
||||||
|
| Action::BackspaceLine
|
||||||
| Action::Delete
|
| Action::Delete
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -5707,11 +5796,11 @@ impl Editor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::Delete => {
|
Action::Delete => {
|
||||||
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(false)) {
|
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot());
|
let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot());
|
||||||
let mask = self.deletion_mask(false);
|
let mask = self.deletion_mask(None);
|
||||||
self.delete_forward(&mask);
|
self.delete_forward(&mask);
|
||||||
if let Some(before) = before {
|
if let Some(before) = before {
|
||||||
self.record_native(
|
self.record_native(
|
||||||
@@ -5789,24 +5878,19 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Newline => self.type_native("\n"),
|
Action::Newline => self.type_native("\n"),
|
||||||
Action::Backspace => {
|
Action::Backspace | Action::BackspaceWord | Action::BackspaceLine => {
|
||||||
|
let kind = action.backspace_kind().expect("backspace action");
|
||||||
if self.active.view.mode == Mode::Insert {
|
if self.active.view.mode == Mode::Insert {
|
||||||
if self.edit_snippet("", Some(true)) {
|
if self.edit_snippet("", Some(action)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mask = self.deletion_mask(true);
|
let mask = self.deletion_mask(Some(kind));
|
||||||
self.backspace(&mask);
|
self.backspace(&mask, kind);
|
||||||
if mask.iter().any(|active| *active) {
|
if mask.iter().any(|active| *active) {
|
||||||
self.push_insert_step(InsertStep::Backspace(mask));
|
self.push_insert_step(InsertStep::Backspace(mask, kind));
|
||||||
}
|
}
|
||||||
} else if let Some((byte, _)) = self
|
} else {
|
||||||
.active
|
kind.truncate(&mut self.active.view.command_line);
|
||||||
.view
|
|
||||||
.command_line
|
|
||||||
.grapheme_indices(true)
|
|
||||||
.next_back()
|
|
||||||
{
|
|
||||||
self.active.view.command_line.truncate(byte);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Submit => {
|
Action::Submit => {
|
||||||
@@ -7685,7 +7769,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deletion_mask(&self, backwards: bool) -> Vec<bool> {
|
fn deletion_mask(&self, backwards: Option<BackspaceKind>) -> Vec<bool> {
|
||||||
let mut mask: Vec<_> = self
|
let mut mask: Vec<_> = self
|
||||||
.active
|
.active
|
||||||
.view
|
.view
|
||||||
@@ -7693,8 +7777,8 @@ impl Editor {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|target| {
|
.map(|target| {
|
||||||
let cursor = target.active;
|
let cursor = target.active;
|
||||||
if backwards {
|
if let Some(kind) = backwards {
|
||||||
cursor.row != 0 || cursor.column != 0
|
cursor.column != 0 || (kind != BackspaceKind::Line && cursor.row != 0)
|
||||||
} else {
|
} else {
|
||||||
cursor.column < grapheme_count(&self.active.lines[cursor.row])
|
cursor.column < grapheme_count(&self.active.lines[cursor.row])
|
||||||
|| (self.active.view.mode == Mode::Insert
|
|| (self.active.view.mode == Mode::Insert
|
||||||
@@ -7886,16 +7970,16 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
InsertStep::Bulk(text) => self.insert_text(text),
|
InsertStep::Bulk(text) => self.insert_text(text),
|
||||||
InsertStep::Move(action, count) => self.move_targets(*action, *count),
|
InsertStep::Move(action, count) => self.move_targets(*action, *count),
|
||||||
InsertStep::Delete(mask) | InsertStep::Backspace(mask) => {
|
InsertStep::Delete(mask) | InsertStep::Backspace(mask, _) => {
|
||||||
// Earlier native steps may have merged carets. Refuse the entire transaction
|
// Earlier native steps may have merged carets. Refuse the entire transaction
|
||||||
// rather than pairing a recorded partial deletion with different targets.
|
// rather than pairing a recorded partial deletion with different targets.
|
||||||
if mask.len() != 1 && mask.len() != self.active.view.targets.len() {
|
if mask.len() != 1 && mask.len() != self.active.view.targets.len() {
|
||||||
return Err("Repeat deletion count does not match target count");
|
return Err("Repeat deletion count does not match target count");
|
||||||
}
|
}
|
||||||
if matches!(step, InsertStep::Delete(_)) {
|
if let InsertStep::Backspace(_, kind) = step {
|
||||||
self.delete_forward(mask);
|
self.backspace(mask, *kind);
|
||||||
} else {
|
} else {
|
||||||
self.backspace(mask);
|
self.delete_forward(mask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InsertStep::Completion {
|
InsertStep::Completion {
|
||||||
@@ -8555,9 +8639,25 @@ impl Editor {
|
|||||||
self.apply_edits(edits);
|
self.apply_edits(edits);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backspace(&mut self, mask: &[bool]) {
|
fn backspace_destination(&self, mut cursor: Cursor, kind: BackspaceKind) -> Cursor {
|
||||||
|
if kind == BackspaceKind::Grapheme {
|
||||||
|
return self.previous_boundary(cursor);
|
||||||
|
}
|
||||||
|
let mut prefix = &self.active.lines[cursor.row]
|
||||||
|
[..grapheme_byte(&self.active.lines[cursor.row], cursor.column)];
|
||||||
|
if kind == BackspaceKind::Word {
|
||||||
|
while cursor.row > 0 && prefix.chars().all(char::is_whitespace) {
|
||||||
|
cursor.row -= 1;
|
||||||
|
prefix = &self.active.lines[cursor.row];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.column = prefix[..kind.start(prefix)].graphemes(true).count();
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backspace(&mut self, mask: &[bool], kind: BackspaceKind) {
|
||||||
let offsets = line_offsets(&self.active.lines);
|
let offsets = line_offsets(&self.active.lines);
|
||||||
let edits = self
|
let mut edits: Vec<_> = self
|
||||||
.active
|
.active
|
||||||
.view
|
.view
|
||||||
.targets
|
.targets
|
||||||
@@ -8568,11 +8668,11 @@ impl Editor {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let cursor = item.active;
|
let cursor = item.active;
|
||||||
if cursor.column == 0 && cursor.row == 0 {
|
let destination = self.backspace_destination(cursor, kind);
|
||||||
|
if destination == cursor {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let start =
|
let start = cursor_offset(&self.active.lines, &offsets, destination);
|
||||||
cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor));
|
|
||||||
let end = cursor_offset(&self.active.lines, &offsets, cursor);
|
let end = cursor_offset(&self.active.lines, &offsets, cursor);
|
||||||
Some(Edit {
|
Some(Edit {
|
||||||
target: Some(target),
|
target: Some(target),
|
||||||
@@ -8584,6 +8684,20 @@ impl Editor {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
// Backward ranges follow document-ordered carets; overlapping ranges delete once.
|
||||||
|
let mut group = 0;
|
||||||
|
for next in 1..=edits.len() {
|
||||||
|
if next < edits.len() && edits[next].start < edits[next - 1].end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = edits[group].start;
|
||||||
|
let end = edits[next - 1].end;
|
||||||
|
for edit in &mut edits[group..next] {
|
||||||
|
edit.start = start;
|
||||||
|
edit.end = end;
|
||||||
|
}
|
||||||
|
group = next;
|
||||||
|
}
|
||||||
self.apply_edits(edits);
|
self.apply_edits(edits);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14894,7 +15008,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_clear_respects_navigation_bindings_and_does_not_clear_palette_or_idle_dock() {
|
fn filter_clear_respects_navigation_bindings_and_preserves_idle_dock_queries() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
for name in ["a.txt", "b.txt"] {
|
for name in ["a.txt", "b.txt"] {
|
||||||
fs::write(directory.path(name), "text").unwrap();
|
fs::write(directory.path(name), "text").unwrap();
|
||||||
@@ -14937,7 +15051,7 @@ mod tests {
|
|||||||
editor.handle(Input::Ctrl('p'));
|
editor.handle(Input::Ctrl('p'));
|
||||||
type_text(&mut editor, "core");
|
type_text(&mut editor, "core");
|
||||||
editor.handle(Input::Ctrl('u'));
|
editor.handle(Input::Ctrl('u'));
|
||||||
assert_eq!(editor.palette().unwrap().query, "core");
|
assert!(editor.palette().unwrap().query.is_empty());
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "txt");
|
assert_eq!(editor.file_picker().unwrap().query, "txt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16001,6 +16115,215 @@ mod tests {
|
|||||||
assert_eq!(editor.palette().unwrap().query, "cor");
|
assert_eq!(editor.palette().unwrap().query, "cor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_backspace_preserves_suffixes_and_uses_unicode_boundaries() {
|
||||||
|
for (key, action, prefix, suffix, expected) in [
|
||||||
|
(
|
||||||
|
"option+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"alpha βeta ",
|
||||||
|
"SUFFIX\nlast",
|
||||||
|
"alpha SUFFIX\nlast",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alt+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"foo don't",
|
||||||
|
"!",
|
||||||
|
"foo !",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alt+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"alpha e\u{301}",
|
||||||
|
" tail",
|
||||||
|
"alpha tail",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alt+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"alpha 👩🚀",
|
||||||
|
" tail",
|
||||||
|
"alpha tail",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alt+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"one word\n ",
|
||||||
|
"tail",
|
||||||
|
"one tail",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cmd+backspace",
|
||||||
|
"core.backspace_line",
|
||||||
|
"first\n βeta ",
|
||||||
|
"SUFFIX\nlast",
|
||||||
|
"first\nSUFFIX\nlast",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"super+backspace",
|
||||||
|
"core.backspace_line",
|
||||||
|
"first\n",
|
||||||
|
"tail",
|
||||||
|
"first\ntail",
|
||||||
|
),
|
||||||
|
("alt+backspace", "core.backspace_word", "", "tail", "tail"),
|
||||||
|
] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.bind("insert", key, Some(action.into())).unwrap();
|
||||||
|
let original = format!("{prefix}{suffix}");
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace(original.clone())])
|
||||||
|
.unwrap();
|
||||||
|
let row = prefix.bytes().filter(|&byte| byte == b'\n').count();
|
||||||
|
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
||||||
|
set_carets(&mut editor, &[(row, column)], 0);
|
||||||
|
let undo = editor.active.undo.len();
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(parse_key(key).unwrap());
|
||||||
|
assert_eq!(editor.context().text, expected, "{prefix:?}");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
if original == expected {
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
assert_eq!(editor.active.undo.len(), undo);
|
||||||
|
} else {
|
||||||
|
assert_eq!(editor.active.undo.len(), undo + 1);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
assert_eq!(editor.cursor(), Cursor { row, column });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_backspace_merges_overlapping_carets_and_repeats_new_boundaries() {
|
||||||
|
for (key, action, expected) in [
|
||||||
|
(
|
||||||
|
"alt+backspace",
|
||||||
|
"core.backspace_word",
|
||||||
|
"foo tail\nkeep row",
|
||||||
|
),
|
||||||
|
("cmd+backspace", "core.backspace_line", " tail\n row"),
|
||||||
|
] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.bind("insert", key, Some(action.into())).unwrap();
|
||||||
|
let original = "foo elephant tail\nkeep second row";
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace(original.into())])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 5), (0, 12), (1, 11)], 1);
|
||||||
|
let carets = editor.carets();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(parse_key(key).unwrap());
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.context().text, expected);
|
||||||
|
assert_eq!(editor.selection_count(), 2);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
|
||||||
|
let next = "foo hippopotamus tail\nkeep longer row";
|
||||||
|
editor.apply(vec![Effect::Replace(next.into())]).unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 5), (0, 16), (1, 11)], 1);
|
||||||
|
editor.handle(Input::Char('.'));
|
||||||
|
assert_eq!(editor.context().text, expected);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_backspace_queries_never_edit_the_underlying_buffer() {
|
||||||
|
for opening in [Input::Char(':'), Input::Char('/'), Input::Ctrl('p')] {
|
||||||
|
let mut editor = editor();
|
||||||
|
for mode in ["command", "search"] {
|
||||||
|
editor
|
||||||
|
.bind(mode, "alt+backspace", Some("core.backspace_word".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor
|
||||||
|
.bind(mode, "cmd+backspace", Some("core.backspace_line".into()))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("buffer text".into())])
|
||||||
|
.unwrap();
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
let carets = editor.carets();
|
||||||
|
editor.handle(opening);
|
||||||
|
type_text(&mut editor, "discard this query");
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.palette()
|
||||||
|
.map_or(editor.command_line(), |palette| palette.query.as_str()),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
type_text(&mut editor, "alpha βeta ");
|
||||||
|
editor.handle(Input::AltBackspace);
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.palette()
|
||||||
|
.map_or(editor.command_line(), |palette| palette.query.as_str()),
|
||||||
|
"alpha "
|
||||||
|
);
|
||||||
|
editor.handle(Input::SuperBackspace);
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.palette()
|
||||||
|
.map_or(editor.command_line(), |palette| palette.query.as_str()),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assert_eq!(editor.context().text, "buffer text");
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_deletes_line_prefixes_and_honors_disabled_and_remapped_bindings() {
|
||||||
|
let mut editor = editor();
|
||||||
|
let original = "first\nalpha βeta tail\nlast";
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace(original.into())])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(1, 11)], 0);
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(editor.context().text, "first\ntail\nlast");
|
||||||
|
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
|
||||||
|
editor.bind("insert", "ctrl+u", None).unwrap();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
editor
|
||||||
|
.bind("insert", "ctrl+u", Some("core.backspace_word".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(editor.context().text, "first\nalpha tail\nlast");
|
||||||
|
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
for (opening, mode) in [(':', "command"), ('/', "search")] {
|
||||||
|
editor.bind(mode, "ctrl+u", None).unwrap();
|
||||||
|
editor.handle(Input::Char(opening));
|
||||||
|
type_text(&mut editor, "alpha beta");
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(editor.command_line(), "alpha beta");
|
||||||
|
editor
|
||||||
|
.bind(mode, "ctrl+u", Some("core.backspace_word".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert_eq!(editor.command_line(), "alpha ");
|
||||||
|
assert_eq!(editor.context().text, "first\nalpha tail\nlast");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() {
|
fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -21291,6 +21614,47 @@ mod tests {
|
|||||||
assert!(editor.snippet_selections().is_empty());
|
assert!(editor.snippet_selections().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_backspace_snippets_stay_inside_arguments_and_update_mirrors() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.bind(
|
||||||
|
"insert",
|
||||||
|
"alt+backspace",
|
||||||
|
Some("core.backspace_word".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
editor
|
||||||
|
.bind(
|
||||||
|
"insert",
|
||||||
|
"cmd+backspace",
|
||||||
|
Some("core.backspace_line".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor
|
||||||
|
.show_completion(vec![snippet_item(
|
||||||
|
(0, 0),
|
||||||
|
(0, 0),
|
||||||
|
"f(one, one)",
|
||||||
|
vec![(1, vec![2..5, 7..10]), (0, vec![11..11])],
|
||||||
|
)])
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "alpha βeta");
|
||||||
|
editor.handle(Input::AltBackspace);
|
||||||
|
assert_eq!(editor.context().text, "f(alpha , alpha )");
|
||||||
|
editor.handle(Input::SuperBackspace);
|
||||||
|
assert_eq!(editor.context().text, "f(, )");
|
||||||
|
editor.handle(Input::SuperBackspace);
|
||||||
|
editor.handle(Input::AltBackspace);
|
||||||
|
assert_eq!(editor.context().text, "f(, )");
|
||||||
|
type_text(&mut editor, "keep");
|
||||||
|
assert_eq!(editor.context().text, "f(keep, keep)");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() {
|
fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
+52
-14
@@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum FilePresentation {
|
pub enum FilePresentation {
|
||||||
@@ -385,10 +384,20 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
|
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
|
||||||
use crate::editor::Input;
|
use crate::editor::{BackspaceKind, Input};
|
||||||
match input {
|
match input {
|
||||||
Input::Ctrl('u') => self.clear_filter(),
|
Input::Ctrl('u') => self.clear_filter(),
|
||||||
Input::Backspace if self.view == FileView::List || self.searching => self.backspace(),
|
input
|
||||||
|
if BackspaceKind::from_input(input).is_some()
|
||||||
|
&& (self.view == FileView::List || self.searching) =>
|
||||||
|
{
|
||||||
|
if BackspaceKind::from_input(input)
|
||||||
|
.expect("guard checked backspace input")
|
||||||
|
.truncate(&mut self.query)
|
||||||
|
{
|
||||||
|
self.refilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
|
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
|
||||||
self.start_search()
|
self.start_search()
|
||||||
}
|
}
|
||||||
@@ -604,13 +613,6 @@ impl FilePicker {
|
|||||||
self.clear_error();
|
self.clear_error();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn backspace(&mut self) {
|
|
||||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
|
||||||
self.query.truncate(byte);
|
|
||||||
self.refilter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||||
let count = self.rows.len();
|
let count = self.rows.len();
|
||||||
self.selected = if count == 0 {
|
self.selected = if count == 0 {
|
||||||
@@ -932,11 +934,11 @@ mod tests {
|
|||||||
picker.refilter();
|
picker.refilter();
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||||
picker.query = "e\u{301}👩💻".into();
|
picker.query = "e\u{301}👩💻".into();
|
||||||
picker.backspace();
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
assert_eq!(picker.query, "e\u{301}");
|
assert_eq!(picker.query, "e\u{301}");
|
||||||
picker.backspace();
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
assert_eq!(picker.query, "");
|
assert_eq!(picker.query, "");
|
||||||
picker.backspace();
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
assert_eq!(picker.entries().len(), 3);
|
assert_eq!(picker.entries().len(), 3);
|
||||||
picker.query = "no match".into();
|
picker.query = "no match".into();
|
||||||
picker.refilter();
|
picker.refilter();
|
||||||
@@ -947,12 +949,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn word_and_line_deletion_refilter_only_editable_queries() {
|
||||||
|
use crate::editor::Input;
|
||||||
|
|
||||||
|
for view in [FileView::List, FileView::Tree] {
|
||||||
|
let mut picker = FilePicker::from_paths(
|
||||||
|
PathBuf::new(),
|
||||||
|
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate(),
|
||||||
|
);
|
||||||
|
picker.set_view(view);
|
||||||
|
picker.query = "alpha missing".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(picker.entries().len(), 0);
|
||||||
|
if view == FileView::Tree {
|
||||||
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||||
|
assert!(!picker.handle_filter_input(&input));
|
||||||
|
assert_eq!(picker.query, "alpha missing");
|
||||||
|
}
|
||||||
|
picker.start_search();
|
||||||
|
}
|
||||||
|
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
||||||
|
assert_eq!(picker.query, "alpha ");
|
||||||
|
assert_eq!(picker.entries().len(), 2);
|
||||||
|
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
||||||
|
assert_eq!(picker.query, "");
|
||||||
|
assert_eq!(picker.entries().len(), 3);
|
||||||
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||||
|
assert!(picker.handle_filter_input(&input));
|
||||||
|
assert_eq!(picker.entries().len(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
let mut picker = directory.scan();
|
let mut picker = directory.scan();
|
||||||
picker.navigate(false);
|
picker.navigate(false);
|
||||||
picker.backspace();
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
assert_eq!(picker.entries().len(), 0);
|
assert_eq!(picker.entries().len(), 0);
|
||||||
directory.put("new.txt", "text");
|
directory.put("new.txt", "text");
|
||||||
picker.query = "new".into();
|
picker.query = "new".into();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
|
|||||||
Candidates replace text live; Original text restores it; other keys close and act normally
|
Candidates replace text live; Original text restores it; other keys close and act normally
|
||||||
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
||||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||||
|
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
|
||||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||||
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||||
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
||||||
|
|||||||
@@ -1220,7 +1220,6 @@ end
|
|||||||
"T{index}:{path}{modified}"
|
"T{index}:{path}{modified}"
|
||||||
);
|
);
|
||||||
assert_eq!(loaded.top_bar.segment("tabs").padding, 0);
|
assert_eq!(loaded.top_bar.segment("tabs").padding, 0);
|
||||||
assert_eq!(loaded.top_bar.segment("tabs").trailing_edge, "▓▒░");
|
|
||||||
assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界");
|
assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.top_bar.segment("workspace"),
|
loaded.top_bar.segment("workspace"),
|
||||||
|
|||||||
@@ -1730,6 +1730,35 @@ mod tests {
|
|||||||
assert_eq!(kind_at(&cache, 10, 6), None);
|
assert_eq!(kind_at(&cache, 10, 6), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_list_markers_color_bullets_and_numbers_without_coloring_plain_text() {
|
||||||
|
let source = "- item\n - nested\n 12. ordered\n\n+ item\n\n* item\n\n1. ordered\n2. next\n\n3) ordered\n\n> - quoted\n> 4. ordered\n\nplain - text and 1. text\n\n\\- escaped";
|
||||||
|
let mut cache = self::cache(source, "lists.md");
|
||||||
|
for (row, marker) in [
|
||||||
|
(0, "-"),
|
||||||
|
(1, "-"),
|
||||||
|
(2, "12."),
|
||||||
|
(4, "+"),
|
||||||
|
(6, "*"),
|
||||||
|
(8, "1."),
|
||||||
|
(9, "2."),
|
||||||
|
(11, "3)"),
|
||||||
|
(13, "-"),
|
||||||
|
(14, "4."),
|
||||||
|
] {
|
||||||
|
assert_token(&cache, source, row, marker, SyntaxKind::Markup);
|
||||||
|
}
|
||||||
|
assert_eq!(kind_at(&cache, 0, 2), None);
|
||||||
|
assert_eq!(kind_at(&cache, 16, 6), None);
|
||||||
|
assert_eq!(kind_at(&cache, 16, 17), None);
|
||||||
|
assert_ne!(kind_at(&cache, 18, 1), Some(SyntaxKind::Markup));
|
||||||
|
|
||||||
|
let edited = source.replacen("- item", "plain - item", 1);
|
||||||
|
cache.update(&lines(&edited), Some(Path::new("lists.md")), 1);
|
||||||
|
assert_eq!(kind_at(&cache, 0, 6), None);
|
||||||
|
assert_fresh(&cache, &edited, "lists.md");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn markdown_inline_and_fences_inject_known_languages_and_update_incrementally() {
|
fn markdown_inline_and_fences_inject_known_languages_and_update_incrementally() {
|
||||||
let original = "A **bold** and `code` link.\n\n```rust\nfn main() { let n = 42; }\n```\n\n```python\ndef hello():\n return 7\n```\n\n```not-a-language\nfn not_rust() { 99; }\n```";
|
let original = "A **bold** and `code` link.\n\n```rust\nfn main() { let n = 42; }\n```\n\n```python\ndef hello():\n return 7\n```\n\n```not-a-language\nfn not_rust() { 99; }\n```";
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ static MD_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
|||||||
(setext_h1_underline)
|
(setext_h1_underline)
|
||||||
(setext_h2_underline)
|
(setext_h2_underline)
|
||||||
] @text.title
|
] @text.title
|
||||||
|
|
||||||
|
[
|
||||||
|
(list_marker_minus)
|
||||||
|
(list_marker_plus)
|
||||||
|
(list_marker_star)
|
||||||
|
(list_marker_dot)
|
||||||
|
(list_marker_parenthesis)
|
||||||
|
] @markup.list
|
||||||
",
|
",
|
||||||
]
|
]
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|||||||
+85
-11
@@ -882,6 +882,14 @@ fn typing_ready(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn translate(key: KeyEvent) -> Option<Input> {
|
fn translate(key: KeyEvent) -> Option<Input> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if key.code == KeyCode::Backspace {
|
||||||
|
match key.modifiers {
|
||||||
|
KeyModifiers::ALT => return Some(Input::AltBackspace),
|
||||||
|
KeyModifiers::SUPER => return Some(Input::SuperBackspace),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
if key
|
if key
|
||||||
.modifiers
|
.modifiers
|
||||||
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
||||||
@@ -7137,7 +7145,7 @@ mod tests {
|
|||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||||
assert!(title.ends_with("░▒▓ bat 27% "), "{title}");
|
assert!(title.trim_end().ends_with("bat 27%"), "{title}");
|
||||||
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||||
|
|
||||||
editor
|
editor
|
||||||
@@ -7151,11 +7159,7 @@ mod tests {
|
|||||||
assert!(title.contains("電") && title.contains("池"), "{title}");
|
assert!(title.contains("電") && title.contains("池"), "{title}");
|
||||||
assert!(title.contains("e\u{301}�90%"), "{title}");
|
assert!(title.contains("e\u{301}�90%"), "{title}");
|
||||||
assert!(!title.contains("bat 27%"), "{title}");
|
assert!(!title.contains("bat 27%"), "{title}");
|
||||||
let start = 80 - (editor.label("battery").width() + 5) as u16;
|
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] {
|
|
||||||
assert_eq!(buffer[(start + offset, 0)].symbol(), glyph);
|
|
||||||
}
|
|
||||||
assert_eq!(buffer[(78, 0)].symbol(), "%");
|
assert_eq!(buffer[(78, 0)].symbol(), "%");
|
||||||
assert_eq!(buffer[(79, 0)].symbol(), " ");
|
assert_eq!(buffer[(79, 0)].symbol(), " ");
|
||||||
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||||
@@ -7167,14 +7171,18 @@ mod tests {
|
|||||||
terminal
|
terminal
|
||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live "));
|
assert!(
|
||||||
|
pane_screen(&terminal, Rect::new(0, 0, 80, 1))
|
||||||
|
.trim_end()
|
||||||
|
.ends_with("live")
|
||||||
|
);
|
||||||
assert!(!screen_text(&terminal).contains("90%"));
|
assert!(!screen_text(&terminal).contains("90%"));
|
||||||
editor.set_label("battery".into(), String::new()).unwrap();
|
editor.set_label("battery".into(), String::new()).unwrap();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||||
assert!(title.ends_with("░▒▓ bat 28% "), "{title}");
|
assert!(title.trim_end().ends_with("bat 28%"), "{title}");
|
||||||
assert!(!title.contains("live"), "{title}");
|
assert!(!title.contains("live"), "{title}");
|
||||||
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||||
assert!(editor.set_battery_charge(None));
|
assert!(editor.set_battery_charge(None));
|
||||||
@@ -7200,6 +7208,13 @@ mod tests {
|
|||||||
let line = if at_top { top } else { bottom };
|
let line = if at_top { top } else { bottom };
|
||||||
[&mut line.left, &mut line.center, &mut line.right][anchor]
|
[&mut line.left, &mut line.center, &mut line.right][anchor]
|
||||||
.push("battery".into());
|
.push("battery".into());
|
||||||
|
line.segments.insert(
|
||||||
|
"battery".into(),
|
||||||
|
SegmentConfig {
|
||||||
|
padding: 0,
|
||||||
|
..SegmentConfig::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap();
|
Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap();
|
||||||
@@ -7210,9 +7225,9 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let y = if at_top { 0 } else { 5 };
|
let y = if at_top { 0 } else { 5 };
|
||||||
let row = pane_screen(&terminal, Rect::new(0, y, 48, 1));
|
let row = pane_screen(&terminal, Rect::new(0, y, 48, 1));
|
||||||
assert_eq!(row.trim(), "░▒▓ bat 27%");
|
assert_eq!(row.trim(), "bat 27%");
|
||||||
let x = [0, 18, 36][anchor];
|
let x = [0, 20, 41][anchor];
|
||||||
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "░");
|
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "b");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
configure_chrome(&mut editor, |top, bottom| {
|
configure_chrome(&mut editor, |top, bottom| {
|
||||||
@@ -7243,6 +7258,12 @@ mod tests {
|
|||||||
"edit {}",
|
"edit {}",
|
||||||
directory.file("active.txt", "ACTIVE").display()
|
directory.file("active.txt", "ACTIVE").display()
|
||||||
));
|
));
|
||||||
|
configure_chrome(&mut editor, |top, _| {
|
||||||
|
let tabs = top.segments.get_mut("tabs").unwrap();
|
||||||
|
tabs.leading_edge = "<<<".into();
|
||||||
|
tabs.trailing_edge = ">>>".into();
|
||||||
|
top.segments.get_mut("battery").unwrap().leading_edge = "<<<".into();
|
||||||
|
});
|
||||||
editor.set_label("battery".into(), "90%".into()).unwrap();
|
editor.set_label("battery".into(), "90%".into()).unwrap();
|
||||||
for width in [28, 29, 80] {
|
for width in [28, 29, 80] {
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
|
||||||
@@ -7261,7 +7282,7 @@ mod tests {
|
|||||||
if width == 28 {
|
if width == 28 {
|
||||||
assert!(!title.contains("90%"), "{title}");
|
assert!(!title.contains("90%"), "{title}");
|
||||||
} else {
|
} else {
|
||||||
assert!(title.ends_with(" ░▒▓ 90% "), "{title}");
|
assert!(title.ends_with(" <<< 90% "), "{title}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor
|
editor
|
||||||
@@ -10025,6 +10046,59 @@ end
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn macos_modified_backspace_edits_words_and_line_prefix_from_terminal_events() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.file("input.txt", "before\nalpha βeta SUFFIX\nlast");
|
||||||
|
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
|
session.handle(Input::Down);
|
||||||
|
session.handle(Input::Home);
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
for _ in 0..13 {
|
||||||
|
session.handle(Input::Right);
|
||||||
|
}
|
||||||
|
session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)).unwrap());
|
||||||
|
assert_eq!(session.editor().lines(), ["before", "alpha SUFFIX", "last"]);
|
||||||
|
session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER)).unwrap());
|
||||||
|
assert_eq!(session.editor().lines(), ["before", "SUFFIX", "last"]);
|
||||||
|
|
||||||
|
for modifiers in [
|
||||||
|
KeyModifiers::META,
|
||||||
|
KeyModifiers::HYPER,
|
||||||
|
KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||||
|
KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||||
|
KeyModifiers::SUPER | KeyModifiers::CONTROL,
|
||||||
|
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
||||||
|
KeyModifiers::SUPER | KeyModifiers::ALT,
|
||||||
|
KeyModifiers::SUPER | KeyModifiers::META,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
translate(KeyEvent::new(KeyCode::Backspace, modifiers)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
translate(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
#[test]
|
||||||
|
fn modified_backspace_keeps_non_macos_terminal_defaults() {
|
||||||
|
for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] {
|
||||||
|
assert_eq!(
|
||||||
|
translate(KeyEvent::new(KeyCode::Backspace, modifiers)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)),
|
||||||
|
Some(Input::Backspace)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() {
|
fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user