Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4099435175 | |||
| a8428f9dde | |||
| cd427f74dc | |||
| 11b31d2621 | |||
| adcc4f2656 | |||
| 2357b5be46 | |||
| e2f33519d1 | |||
| b58fb085a3 | |||
| 00628e0efd | |||
| 142b00118a | |||
| 9526876b04 | |||
| e90d1c3945 | |||
| fd59c05f80 | |||
| 55cdee1a13 | |||
| e84d70463f | |||
| 983f43e710 | |||
| 99fa5a2aff | |||
| e3e087f16f | |||
| 33942b2671 | |||
| 0eec3fc3b9 | |||
| 9d6a0eaeca | |||
| f5ba61624f | |||
| bfdb7c544f | |||
| b16232aa1d | |||
| 8f87b8d3d1 | |||
| 38fcf86195 | |||
| c912abaf17 | |||
| c4f72a45ba | |||
| e36c28c981 | |||
| 7a3c27c30e | |||
| 5382307fcc | |||
| 002f071b0f | |||
| 9f33104cb2 | |||
| b94739183d | |||
| 8c5d218c11 | |||
| d7fbf9a144 | |||
| 6c254f9b0d | |||
| fafec2a6b0 | |||
| d1855dbdb7 | |||
| c35abe0f03 | |||
| d85998e1cb | |||
| 298d586c3e | |||
| bb5c247ab2 | |||
| 1cd56cf3f6 | |||
| 2260817fc0 | |||
| 5e6b602127 | |||
| 971866388c | |||
| 526e243a5f | |||
| 1fd5f98256 | |||
| d64704d4eb | |||
| 9f1ea04f4d | |||
| 054225686e | |||
| 6b7cfdea20 | |||
| 1b8b669ca0 | |||
| 6eb99a792a | |||
| 406f45c6fb | |||
| 7bc5eab22f | |||
| 684ba46a2c | |||
| 52915ffacf | |||
| 8c6a050171 | |||
| 0d3fbc01f8 | |||
| 4cfd068f1c | |||
| d55b7b65d2 | |||
| 05d62aa4a5 | |||
| 393791ab7e | |||
| 9dd6a666d0 | |||
| f85f84a5d1 | |||
| b733446c8b | |||
| d92140afea | |||
| fc382529c5 | |||
| b72a3e206f | |||
| 9f4488c771 | |||
| b41db31831 | |||
| 5bf1f1df59 | |||
| ffdd42fcce | |||
| d6f6203fb6 | |||
| a80f97309b | |||
| a69372fb6e | |||
| 812152fd96 | |||
| aaaa3875aa | |||
| 2756b79f89 | |||
| 503342bff0 | |||
| f595455479 | |||
| ad9b214b8f | |||
| 0b7d4d2c1f | |||
| 1f625a69c4 | |||
| 2e05725a38 | |||
| 7b4bc80538 | |||
| 165930fc1e | |||
| 843e4b9852 | |||
| 0d4601b612 | |||
| 0ca0d89b9e | |||
| 5186714ef5 | |||
| f43a43fd53 | |||
| fc960ea0bf | |||
| 976cfc9e62 | |||
| 86d22d4fa2 | |||
| 54bb196192 | |||
| 9a34669c08 | |||
| 704dce2539 | |||
| 5e0700bb8c | |||
| de47bf6370 | |||
| 1611af2901 | |||
| db5d417a17 | |||
| 2f595ca4ca | |||
| 83c37f5a19 | |||
| 81a2bf9a02 | |||
| f6da7922a1 | |||
| 3f05287d79 | |||
| 4e9378816e | |||
| 232be6a032 | |||
| b6059d5ed9 | |||
| 9d3b25e9d3 | |||
| 287865735f | |||
| 27b94744f0 | |||
| dc0bdd60d0 | |||
| 99028daa9d | |||
| 6152ae6d36 | |||
| d8e2425c65 | |||
| 455108a909 | |||
| 05c044bd02 | |||
| d310be6e6e | |||
| 9a96706c36 | |||
| 2264dcb6e9 |
+6
-1
@@ -23,10 +23,15 @@ The presentation and input experience through which a user interacts with an edi
|
||||
**Capability**:
|
||||
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
|
||||
|
||||
**Canonical Ex command**:
|
||||
The full command name of a command-capable core action, without its `core.` prefix. For example, `core.quit` names the canonical `quit` command.
|
||||
|
||||
**Command alias**:
|
||||
A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation.
|
||||
|
||||
## Configuration interfaces
|
||||
|
||||
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
|
||||
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names and eligibility. Ruby action methods and nested namespaces are generated from its startup catalog; add or rename actions there rather than maintaining a second Ruby list.
|
||||
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
|
||||
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
||||
|
||||
|
||||
Generated
+114
-2
@@ -79,6 +79,9 @@ name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -180,6 +183,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -208,18 +217,24 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.13.2",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"filedescriptor",
|
||||
"futures-core",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"temp-env",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -427,6 +442,17 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -804,6 +830,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numtoa"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1053,6 +1085,7 @@ dependencies = [
|
||||
"ratatui-core",
|
||||
"ratatui-crossterm",
|
||||
"ratatui-termina",
|
||||
"ratatui-termion",
|
||||
"ratatui-termwiz",
|
||||
"ratatui-widgets",
|
||||
"serde",
|
||||
@@ -1066,6 +1099,7 @@ checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"compact_str",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
@@ -1102,6 +1136,17 @@ dependencies = [
|
||||
"termina",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-termion"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
"termion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-termwiz"
|
||||
version = "0.1.2"
|
||||
@@ -1213,12 +1258,27 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -1269,6 +1329,32 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1317,6 +1403,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -1420,6 +1512,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "temp-env"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termina"
|
||||
version = "0.3.3"
|
||||
@@ -1445,6 +1546,16 @@ dependencies = [
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termion"
|
||||
version = "4.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"numtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.3.3"
|
||||
@@ -1804,6 +1915,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"similar",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-c",
|
||||
|
||||
+10
-1
@@ -6,9 +6,10 @@ edition = "2024"
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
ignore = "0.4"
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "underline-color"] }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
similar = { version = "2", default-features = false }
|
||||
tree-sitter = "0.27.1"
|
||||
tree-sitter-bash = "0.25.1"
|
||||
tree-sitter-c = "0.24.2"
|
||||
@@ -28,3 +29,11 @@ tree-sitter-typescript = "0.23.2"
|
||||
tree-sitter-yaml = "0.7.2"
|
||||
unicode-segmentation = "1.12"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[patch.crates-io]
|
||||
# Remove once upstream Crossterm decodes DEC 2031 color-scheme notifications.
|
||||
crossterm = { path = "vendor/crossterm" }
|
||||
|
||||
[workspace]
|
||||
members = ["vendor/crossterm"]
|
||||
default-members = ["."]
|
||||
|
||||
@@ -15,19 +15,30 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
||||
|
||||
## Getting started
|
||||
|
||||
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:w` and Enter to save, or `:w path` to name an unnamed buffer. Type `:q` and Enter to quit; it refuses unsaved changes in any open buffer. `:q!` explicitly discards changes and quits.
|
||||
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:save` (alias `:w`) and Enter to save, or `:save path` to name an unnamed buffer. Type `:quit` (alias `:q`) and Enter to quit; it refuses unsaved changes in any open buffer. `:force_quit` (alias `:q!`) explicitly discards changes and quits.
|
||||
|
||||
| Task | Default keys |
|
||||
| --- | --- |
|
||||
| Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words |
|
||||
| First/last line in Normal or Visual | `gt` / `gb`, at first nonblank; Visual extends the selection |
|
||||
| Enter/leave Insert | `i` / Esc |
|
||||
| Manual LSP completion in Insert | Ctrl+n starts/cycles next; Ctrl+j/k cycles next/previous, replacing text live |
|
||||
| Edit completion snippet arguments | Type to replace the highlighted argument; Tab / Shift+Tab moves next/previous |
|
||||
| Save in Normal | Space then `w`, or `:w` then Enter |
|
||||
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
||||
| Split panes in Normal | `_` above/below; `\|` left/right; Option/Alt+h/j/k/l changes focus |
|
||||
| Close a pane | `:pane.close`; `:pane.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||
| Open/switch tabs in Normal | `:tab.new` opens the current buffer; Ctrl+Option/Alt+h/l switches previous/next |
|
||||
| Close a tab | `:tab.close`; `:tab.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||
| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) |
|
||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||
| Select/copy/cut | `v` in Normal, move to extend, then `y`/`d` in Visual |
|
||||
| Paste in Normal | `p`/`P` after/before the caret; system clipboard by default |
|
||||
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
||||
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
|
||||
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
|
||||
| Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels |
|
||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
||||
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison |
|
||||
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
|
||||
|
||||
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
||||
|
||||
@@ -41,9 +52,9 @@ Ruby configuration and plugins run with your account's filesystem/process permis
|
||||
|
||||
## Current limits
|
||||
|
||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise Visual selections, but no line/block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, and automatic semantic highlighting—not completion, rename, formatting, or code actions.
|
||||
- Terminal frontend only; no graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted.
|
||||
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
|
||||
|
||||
Performance and subprocess limits are documented in [development](docs/development.md#performance-and-process-limits).
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
- [x] add option to highlight trailing whitespace (off by default)
|
||||
- [x] add option to put characters at certain whitespace characters (off by default)
|
||||
- whitespace kind -> character map should be configurable.
|
||||
- [x] diff view: use two-column diagonal hatching for missing counterparts (45° with 2:1 terminal cells)
|
||||
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
|
||||
- github pr picker (floating window)
|
||||
- three tabs: open, closed, all
|
||||
- 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)
|
||||
- [ ] 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: 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.
|
||||
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
|
||||
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
- selector should be searchable
|
||||
- selected pr description is opened in a separate buffer/tab.
|
||||
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
||||
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
|
||||
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
|
||||
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
|
||||
- mount historical source read-only and provide isolated writable scratch directories for caches, indexes, and build output; keep historical navigation targets read-only.
|
||||
- enforce an OS-level sandbox on the LSP process and every child it spawns, including build scripts and procedural macros. A temporary checkout and read-only editor buffers are not security isolation.
|
||||
- deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval.
|
||||
- fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process.
|
||||
- retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes.
|
||||
+77
-18
@@ -6,57 +6,116 @@ Contributor-facing module boundaries and implementation contracts. Project termi
|
||||
|
||||
## Module ownership
|
||||
|
||||
One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers, and versioned asynchronous update contracts. Semantic token updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI inside a terminal guard covering startup, rendering, session teardown, and executable handoff. On restart, it drops the entire `EditorSession`, including workers and plugin hosts, then briefly disables raw mode and input reporting without leaving the alternate screen before relaunching the same executable with the original CLI arguments. Unix replaces the process with `exec`; other platforms spawn a fresh process and wait for it. Startup rereads configuration and the original startup file, or creates an unnamed buffer; current buffers, tabs, views, and session state are not restored.
|
||||
|
||||
## Frontend interface
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
||||
|
||||
`Editor::should_quit()` tells the frontend to exit its input/render loop for either quit or restart; `Editor::should_restart()` distinguishes a requested executable relaunch from an ordinary exit. Normal-mode `core.restart` / `:restart` first closes clean buffers across all tabs, panes, and historical views. Remaining dirty buffers retain unsaved text and undo/redo history, block the exit/relaunch request with a warning, and leave the editor interactive; already closed clean buffers remain closed. `core.force_restart` / `:force_restart` (alias `:restart!`) requests relaunch without saving, discarding unsaved text. Both commands take no arguments and have no default binding. Force restart uses palette confirmation, while configured action bindings execute directly.
|
||||
|
||||
Session polling schedules synchronous `Editor::refresh_files()` metadata scans every 250 ms; the editor method itself is unthrottled and reports whether a redraw is needed. Scans include active and parked live buffers across inactive tabs/panes, but never immutable historical snapshots, and read content only after metadata changes. Valid changed disk text replaces only clean buffers, updates the saved baseline and text revision, clears stale undo/redo, preserves/clamps every associated view, invalidates annotations and stale requests, and refreshes live comparisons. Dirty buffers keep edits; missing, unreadable, or invalid changed files preserve text and report a warning.
|
||||
|
||||
Every save independently re-reads disk content and compares its LF-normalized text with the saved baseline, rather than trusting polling or modification timestamps. Normal writes refuse external changes/deletion; newly opened nonexistent paths can be created normally. Typed `core.force_save` / `core.force_save_and_quit` expose canonical `:force_save [path]` / `:force_save_and_quit [path]` and aliases `:w!` / `:wq!`. Force bypasses only the external-change check, retaining snapshot read-only, target-symlink, and another-buffer path-ownership protections. Both save-and-quit variants quit only after a successful save and only when all buffers are clean.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` is true only for the whole-row shape in `Mode::Visual`; it changes neither binding modes nor Ruby mode names. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` exposes inclusive selected row ranges only in linewise Visual, including an empty final row without changing `selections()` cardinality. The TUI highlights the copied text on those rows and one cell for each copied newline, never unused viewport space or the gutter, and labels their mode VISUAL LINE. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
|
||||
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
|
||||
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
|
||||
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `count()` for the optional pending numeric prefix; parsing, counted motion dispatch, and counted Normal line actions remain editor-owned.
|
||||
Frontends inspect `sequence_pending()` for the cached display of a pending key sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Symbolic leader routes and explicit key prefixes share the same binding storage and dispatcher; explicit prefixes do not change when the leader is reconfigured.
|
||||
Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
|
||||
Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser.
|
||||
Editor-owned jump history is a bounded 100-location `VecDeque` of stable tab/pane identities, buffer IDs, grapheme cursors, and optional comparison identity/file/focus/binding metadata, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal resolves a valid destination view before switching buffers, restores comparison binding and exact cursor visibility, and truncates forward entries only on a distinct new jump. Closed or replaced views fall back only to safe existing ordinary panes; history cannot resurrect a closed comparison or replace an unrelated one. Unavailable destinations are skipped without corrupting branch position. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text.
|
||||
Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated ordered left/center/right segment groups and effective segment styles, and `label(name)` for session-wide custom values. The TUI owns terminal-cell widths, full-row center anchoring, clipping, and accent styling. Label effects carry only a name and plain text; they are global presentation updates, not buffer/revision-bound results or render callbacks.
|
||||
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
||||
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
||||
|
||||
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
|
||||
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport.
|
||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
|
||||
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves hardware cursor ownership; wheel input over the list is consumed without dispatching edits.
|
||||
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
|
||||
|
||||
## Terminal lifecycle and layout
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active.
|
||||
|
||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen, and the guard stays armed if relaunch fails.
|
||||
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
|
||||
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
|
||||
|
||||
`terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. An unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (18% row, 38% character), propagating them to active, parked, and newly created panes.
|
||||
|
||||
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
||||
|
||||
The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges. Floating Command/Search input owns the hardware cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
|
||||
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
|
||||
|
||||
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
||||
|
||||
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
|
||||
|
||||
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
||||
|
||||
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
|
||||
|
||||
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
|
||||
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
|
||||
|
||||
Repository gutter work runs off the render/editor thread. Revision/path-validated worker results update marker vectors belonging to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. After text edits, the last completed markers for the same path remain visible until a current-revision result replaces them, rather than disappearing during each asynchronous refresh. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
|
||||
|
||||
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
|
||||
|
||||
## Syntax caches
|
||||
|
||||
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
|
||||
|
||||
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
|
||||
|
||||
## Clipboard providers
|
||||
|
||||
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group.
|
||||
macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group.
|
||||
|
||||
## Native LSP
|
||||
|
||||
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||
|
||||
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
|
||||
Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable After, whether Working or a checkout-matched pinned endpoint.
|
||||
|
||||
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
|
||||
|
||||
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
|
||||
|
||||
Manual completion dispatches the Insert-only `core.lsp.completion` action directly to the session, without the Ex execution path's mode/history transitions. The native worker sends the primary caret as UTF-16 and normalizes results into `CompletionItem { label, detail, primary_edit, edits, snippet }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Snippet primary text is expanded and newline-normalized before byte-relative metadata is assigned; additional edits remain plain text. Primary and additional edits form one atomic candidate. Session freshness includes the latest request, buffer/revision, primary caret, Insert source mode, and absence of modal overlays; any intervening input invalidates a pending reply. Decoding/validation errors remain completion-local and preserve healthy diagnostics/semantic tokens. Server commands, indentation adjustment, resolve requests, and automatic triggers are not supported; both frames and aggregate decoded completion text remain bounded.
|
||||
|
||||
## Ruby action catalog
|
||||
|
||||
Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports.
|
||||
`src/editor.rs`'s `CORE_ACTIONS` catalog owns core action names, eligible modes, optional Ex aliases, and prompt policies. It generates the Rust Action enum and protected core command registration. Typed keys and textual Ex aliases share Action-backed execution; the palette has one row per core action and searches its alias without adding duplicate command rows.
|
||||
|
||||
Rust supplies name/Ex-alias descriptors before loading configuration or plugin scripts. Ruby generates `config.core` methods and a frozen built-in alias lookup from those descriptors, rejecting built-in string binding targets without a second command list. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen.
|
||||
|
||||
+222
-20
@@ -13,14 +13,26 @@ Rejected configuration opens a centered floating **Configuration error** window,
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system"
|
||||
config.git_blame = true # false disables current-line attribution
|
||||
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
||||
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||
config.whitespace_scope = "all" # or "leading" for indentation only
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
config.diff_context_lines = 10
|
||||
config.diff_expand_lines = 15
|
||||
config.diff_view_mode = "split" # or "unified"
|
||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||
config.diff_follow_working_copy = true
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "leader q", config.core.quit
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "shift+j", config.core.join_lines
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
@@ -35,25 +47,147 @@ end
|
||||
|
||||
## Preferences
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or a whitespace-separated key sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Sequences require Normal/Visual. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||
|
||||
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
|
||||
|
||||
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
|
||||
|
||||
`config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration.
|
||||
|
||||
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact.
|
||||
|
||||
`config.whitespace_scope` defaults to `"all"`. Use `"leading"` to mark only the initial whitespace prefix of each source line; a whitespace-only line is entirely leading, but wrapping and horizontal scrolling do not create new indentation. Only the strings `"all"` and `"leading"` are accepted; other values reject the configuration.
|
||||
|
||||
A `"\n"` map entry marks actual line breaks independently of `whitespace_scope`, including blank lines and a file's final newline when present. Soft wraps, missing final newlines, and the virtual empty EOF row do not gain markers. The glyph occupies the existing end-of-line caret cell on the final wrapped segment; a full-width line uses the existing extra caret segment. In unwrapped text, an offscreen end-of-line marker is clipped like other source cells. Newline markers inside syntax-recognized strings are suppressed along with horizontal whitespace markers.
|
||||
|
||||
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
|
||||
|
||||
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
|
||||
|
||||
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||
|
||||
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
|
||||
|
||||
In `"line_numbers"` mode, color precedence is **current-line cyan/bold → highest-severity diagnostic → repository change → default number color**. In bar/symbol modes, diagnostic number colors and repository markers remain independent.
|
||||
|
||||
Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files.
|
||||
|
||||
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
||||
|
||||
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
||||
|
||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+b", config.core.diff.mode.toggle
|
||||
config.bind "leader v", config.core.diff.view.toggle
|
||||
config.bind "leader s", config.core.diff.orientation.toggle
|
||||
```
|
||||
|
||||
### Top and bottom bars
|
||||
|
||||
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||
|
||||
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
||||
|
||||
Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.bar.top = {
|
||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||
center: [], right: [:language, :lsp, :position],
|
||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
},
|
||||
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
|
||||
|
||||
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. 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. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row.
|
||||
|
||||
Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` when the tab owns a comparison or revision selector, otherwise `""`. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
|
||||
|
||||
`:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated.
|
||||
|
||||
`: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.
|
||||
|
||||
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column and no trailing margin. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
|
||||
|
||||
`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, margins, 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.
|
||||
|
||||
`:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage.
|
||||
|
||||
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
|
||||
|
||||
### Command and search popup anchors
|
||||
|
||||
Configure the two input windows independently:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.popup.command = { anchor: :top_right }
|
||||
config.popup.search = { anchor: :center }
|
||||
end
|
||||
```
|
||||
|
||||
Both default to `:bottom_left`. Supported anchors are `:top_left`, `:top_center`, `:top_right`, `:center_left`, `:center`, `:center_right`, `:bottom_left`, `:bottom_center`, and `:bottom_right`. String values and string hash keys are also accepted.
|
||||
|
||||
Anchors refer to the chrome-free workspace, not the whole terminal or focused pane. Center positions use the full workspace width/height. The windows remain bounded to 72 columns by 3 rows, preserving titles, internal padding, cursor clipping, and modal behavior. This setting does not move revision selectors or the operation notification.
|
||||
|
||||
Only the `anchor` option is supported; unknown anchors/options, callbacks, nulls, and wrong types reject configuration. Omitting a window or its anchor keeps its default. Use the two setters separately; a combined `config.popup = { ... }` assignment is not supported.
|
||||
|
||||
## Actions and modes
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
||||
|
||||
For command-capable actions, canonical Ex names derive automatically from the action name without `core.`: `config.core.quit` corresponds to `:quit`, while `config.core.diff.close` corresponds to `:diff.close`. The single Rust alias table targets typed actions, so shorthand names such as `:q` follow the canonical name without repeating its spelling. Both canonical names and aliases are reserved against plugin registration; the Ruby ownership map comes from the same catalog and alias table. Native-only action eligibility is unchanged.
|
||||
|
||||
## Key notation
|
||||
|
||||
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`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `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.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, 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.
|
||||
|
||||
`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 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.
|
||||
|
||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
||||
|
||||
With a decimal prefix, `g` instead jumps immediately to that one-based source line: `42g` goes to line 42, clamped at EOF, at first nonblank. Normal moves all carets; Visual extends selections. In diff text panes, explicit line jumps reveal folded source lines rather than counting only displayed excerpts. `g` without a count still begins `gt`, `gb`, or `gf`; a count followed by `g` is complete and does not await a suffix. Rebind the Normal/Visual native action `config.core.go_to_line` to use the numeric prefix with another key. Existing `g` bindings and prefixes override this default route.
|
||||
|
||||
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 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.
|
||||
|
||||
## Action reference
|
||||
|
||||
@@ -62,12 +196,28 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `copy_line`, `delete_line`, `change_line` |
|
||||
| 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 | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `git_blame.toggle` |
|
||||
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal | `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Visual | `document_start`, `document_end`, `go_to_line` |
|
||||
| Normal, Visual | `seek_forward`, `seek_backward` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
@@ -80,6 +230,20 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
||||
| Insert | `newline` |
|
||||
| Command, Search | `submit` |
|
||||
|
||||
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
|
||||
|
||||
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. Both re-read disk text and compare its LF-normalized content with the saved baseline, refusing external changes or deletion with a warning. `core.force_save` and `core.force_save_and_quit` explicitly bypass that external-change check, but never historical-snapshot read-only protection, target-symlink rejection, or another loaded buffer's path ownership. Their canonical commands are `:force_save [path]` and `:force_save_and_quit [path]`, with aliases `:w!` and `:wq!`; guarded `:save [path]` / `:w` and `:save_and_quit [path]` / `:wq` remain unchanged. A new buffer opened at a nonexistent path may save normally. Configured force-save actions act immediately, without confirmation.
|
||||
|
||||
Live clean buffers reload external changes automatically, including inactive tabs/panes, using 250 ms metadata checks and reading content only when metadata changes. Historical snapshots are excluded; dirty buffers keep their edits. Reload clears stale undo/redo, preserves and clamps view positions, and invalidates outdated annotations/requests. Missing, unreadable, or invalid changed files preserve text and report a warning. This polling does not replace the actual-content recheck on save.
|
||||
|
||||
`core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
|
||||
|
||||
`config.core.buffers` opens the searchable buffer picker in Normal mode, also available as `:buffers` and default **Space b l**. Its navigation uses `config.modes.selection`, like the command palette. For example, `config.bind "leader b b", config.core.buffers` adds another opening shortcut. Search, acceptance, and cancellation are described under [Buffers](editing.md#buffers).
|
||||
|
||||
`core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly.
|
||||
|
||||
Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
|
||||
|
||||
## Binding overrides and failures
|
||||
|
||||
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
||||
@@ -93,13 +257,13 @@ Vamp.configure do |config|
|
||||
end
|
||||
```
|
||||
|
||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous leader prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and leader bindings, without restoring any default keys they replaced.
|
||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous sequence prefixes, or unknown command names leave that binding unchanged and appear in the operation notification; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and sequence bindings, without restoring any default keys they replaced.
|
||||
|
||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
||||
|
||||
## Selection-list bindings
|
||||
|
||||
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||
The palette, floating file picker, focused docked explorer, and diff selectors/navigation share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item (or scrolls aligned diff rows); `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||
@@ -116,19 +280,44 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree
|
||||
config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
```
|
||||
|
||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
|
||||
|
||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
||||
|
||||
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||
|
||||
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
|
||||
|
||||
```ruby
|
||||
config.bind "leader d q", config.core.diff.close
|
||||
```
|
||||
|
||||
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
||||
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently.
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle.
|
||||
|
||||
## Leader key
|
||||
## Key sequences and leader
|
||||
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", config.core.buffer_next`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
|
||||
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
|
||||
Explicit prefixes use the same notation and do not change the leader:
|
||||
|
||||
Esc cancels a pending sequence and count without leaving Visual or collapsing carets. Unknown suffixes consume the key and cancel safely, never falling through to an edit. Paste, external command execution, buffer switching, and binding/leader changes clear pending state. A count before a leader motion applies normally; digits inside a sequence are literal suffix keys. Plugins execute once. Outside Normal/Visual, the leader has no prefix behavior, so Space still types normally in Insert and prompts.
|
||||
```ruby
|
||||
config.bind "ctrl+w h", config.core.pane.focus_left
|
||||
config.bind "ctrl+w j", config.core.pane.focus_down
|
||||
config.bind "ctrl+w k", config.core.pane.focus_up
|
||||
config.bind "ctrl+w l", config.core.pane.focus_right
|
||||
```
|
||||
|
||||
Leader bindings require Normal/Visual and the target's existing mode eligibility; Ex/plugin targets remain Normal-only. A sequence cannot be both a complete binding and another binding's prefix: ambiguous declarations are refused atomically. Disabled leaves remain reserved. Only leader-prefixed sequences are supported—not arbitrary multi-key mappings, timers, or macros.
|
||||
Press Ctrl+w, release it, then press the direction key. Modified suffixes and deeper nesting also work, for example `"ctrl+w ctrl+j"` or `"ctrl+w p j"`. Explicit prefixes must be unbound in that mode and cannot be Escape, an ASCII digit, or the current leader; use `leader` for symbolic leader routes. The prefix remains reserved while any of its leaves exists, including disabled leaves. Tree/selection navigation overrides retain priority while exploring files; configured control/special-key prefixes can start there, and pending suffixes then belong to the sequence rather than tree navigation.
|
||||
|
||||
Set `config.leader = ","` to change the trigger for every default and configured leader route. Explicit prefixes do not move. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already bound or reserved as a sequence prefix in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
|
||||
|
||||
Esc cancels a pending sequence and count without leaving Visual or collapsing carets. Unknown suffixes consume the key and cancel safely, never falling through to an edit. Paste, external command execution, buffer switching, and binding/leader changes clear pending state. A count before a sequence motion applies normally; digits inside a sequence are literal suffix keys. Plugins execute once. Outside Normal/Visual, sequences have no prefix behavior, so ordinary text still types normally in Insert and prompts.
|
||||
|
||||
Sequences require Normal/Visual and the target's existing mode eligibility; Ex/plugin targets remain Normal-only. A sequence cannot be both a complete binding and another binding's prefix: ambiguous declarations are refused atomically, including collisions between a single-key binding and an explicit prefix. Disabled leaves remain reserved. Sequences wait without a timeout; timers and macros are not supported.
|
||||
|
||||
## Native LSP
|
||||
|
||||
@@ -149,6 +338,7 @@ Vamp.configure do |config|
|
||||
config.bind "leader d", config.core.lsp.definition
|
||||
config.bind "leader D", config.core.lsp.diagnostics
|
||||
config.bind "K", config.core.lsp.hover
|
||||
config.bind "ctrl+n", config.core.lsp.completion, mode: config.modes.insert
|
||||
end
|
||||
```
|
||||
|
||||
@@ -160,7 +350,7 @@ Install Solargraph yourself with `gem install solargraph` in the project's Ruby
|
||||
|
||||
**Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp.
|
||||
|
||||
Use `servers` to customize the stack independently; order selects the hover/definition server:
|
||||
Use `servers` to customize the stack independently; order selects the hover/definition/completion server:
|
||||
|
||||
```ruby
|
||||
config.lsp["python"] = {
|
||||
@@ -179,24 +369,36 @@ config.lsp["python"] = {
|
||||
|
||||
**uv remains optional project tooling.** Run project setup/synchronization yourself; [ty discovers an existing `.venv`](https://docs.astral.sh/ty/reference/configuration/#python) without a uv launcher. Vamp neither runs `uv sync` nor changes dependencies/lockfiles. The default ty preset sets `experimental.useUv: "off"` so inherited `TY_UV` cannot enable ty's [experimental environment-management integration](https://docs.astral.sh/ty/reference/editor-settings/). Ruff's initialization options default to `{}`. When replacing ty's initialization options, retain that flag if you want the same policy.
|
||||
|
||||
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. All matching servers in that language run, and the first handles hover/definition, semantic highlighting, and the diagnostic-status command without fallback to sibling servers. Each server/project root gets one worker, with at most 16 configured servers and 16 server/project keys; Python uses two slots. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
|
||||
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. All matching servers in that language run, and the first handles hover/definition/completion, semantic highlighting, and the diagnostic-status command without fallback to sibling servers. Each server/project root gets one worker, with at most 16 configured servers and 16 server/project keys; Python uses two slots. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
|
||||
|
||||
See [native LSP architecture](architecture.md#native-lsp) for synchronization and worker behavior.
|
||||
|
||||
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
|
||||
|
||||
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration.
|
||||
Successful definition jumps center the destination caret's visual row once in its actual text pane, excluding bars and docks. This applies to ordinary buffers, new destination tabs, wrapped text, and split/unified diff panes. Near the beginning of the file, centering clamps to the first line; near EOF, blank space can remain below the centered target. Subsequent cursor motion resumes ordinary scrolling. Failed or stale requests do not request centering; Markdown links, search, and jump-history navigation retain their existing behavior.
|
||||
|
||||
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects `definition`, `diagnostics`, and `hover`, plus the **Insert-only** `completion` Action. Normal actions route to their corresponding Ex commands and have no default keys. Completion defaults to Insert Ctrl+n; override or disable it using an Insert binding. It requests directly without leaving Insert or committing pending typing; there is no Normal `:lsp.completion` command. These actions are also available in the command palette according to mode eligibility. The namespace is distinct from `config.lsp[language_id]` server configuration.
|
||||
|
||||
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
||||
|
||||
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
||||
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic can also appear in the bottom-right notification popup. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
||||
|
||||
Insert mode hides all diagnostic gutter colors, underlines, counts, and caret messages. Incoming results are still stored; leaving Insert reveals only the latest results matching the current text revision. Editing invalidates earlier results, so stale annotations never reappear on exit. Document synchronization stays live for completion and semantic highlighting; this display policy does not stop server-side diagnostic analysis.
|
||||
|
||||
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
|
||||
|
||||
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
|
||||
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
|
||||
|
||||
Completion is manual and uses only the primary insertion caret. Ctrl+n requests it; the first candidate replaces text immediately. While the list is open Ctrl+n/Ctrl+j selects next and Ctrl+k previous, including an Original text row that restores the original document and carets. Other keys close the list with no accept key. Snippets expand the whole template and highlight the first argument: typing replaces it and its mirrors, Tab/Shift+Tab moves next/previous, and the final `$0` ends navigation. Outside a snippet Tab inserts a tab; Escape returns to Normal keeping the displayed text. Secondary carets are translated, not completed independently. Cycling and argument edits belong to the pending Insert undo group; repeat records resolved text and caret placement. Rebind the Insert-only `config.core.snippet.next` / `config.core.snippet.previous` actions normally. See [editing behavior](editing.md#manual-lsp-completion).
|
||||
|
||||
The first assigned server must advertise a completion provider. Vamp handles item arrays and CompletionList results, plain text, TextEdit, InsertReplaceEdit (using its replacement range), supported edit-range/format/mode defaults, and atomic additionalTextEdits in the same document. Insert-only fallback text replaces the identifier prefix before the caret; `filterText` and stable `sortText`/label ordering are honored. All edit coordinates are validated at UTF-16 and grapheme boundaries against the immutable request document; malformed or overlapping edits report a completion error without retiring a healthy server. Aggregate completion text is bounded to 4 MiB. Pending replies cannot reopen completion after intervening input, source changes, superseding requests, or modal overlays.
|
||||
|
||||
Supported [LSP snippet syntax](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax) includes numbered tabstops, nested placeholders/defaults, mirrors, escapes, choices (first option), variable defaults, and `TM_SELECTED_TEXT`, `TM_CURRENT_LINE`, `TM_CURRENT_WORD`, `TM_LINE_INDEX`, `TM_LINE_NUMBER`, `TM_FILEPATH`, `TM_FILENAME`, `TM_FILENAME_BASE`, and `TM_DIRECTORY`. Insert has no selected text, so `TM_SELECTED_TEXT` is empty unless a default is supplied. Unsupported regex transforms, unknown variables without defaults, repeated/nonempty final `$0`, or final stops nested inside arguments omit that candidate. Malformed templates report a completion-local error. Each template and expansion is bounded to 256 KiB, with 4096 parsed nodes, 1024 placeholder occurrences, 32 nesting levels, and a bounded rendering traversal.
|
||||
|
||||
There are no automatic triggers, commit characters, server commands, indentation adjustment, or completion-item resolve requests. Items requiring commands or indentation adjustment are omitted rather than partly applied. Server-specific availability depends on the installed server's advertised capabilities; configuring a preset does not guarantee completion support.
|
||||
|
||||
Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives.
|
||||
|
||||
rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/properties use yellow. Diagnostics, search, selections, and carets retain their existing styling precedence.
|
||||
rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/parameters/properties use italics without changing their role's color. Diagnostics, search, selections, and carets retain their existing styling precedence.
|
||||
|
||||
Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, and semantic highlighting—not completion, rename, formatting, code actions, or workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented.
|
||||
Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, manual completion, and semantic highlighting—not rename, formatting, code actions, or general workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented.
|
||||
|
||||
+33
-4
@@ -8,14 +8,18 @@ Local verification and implementation constraints. See [architecture](architectu
|
||||
|
||||
```sh
|
||||
cargo test
|
||||
cargo test -p crossterm color_scheme_notifications
|
||||
cargo test -p crossterm --doc
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo fmt --package vamp --check
|
||||
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
|
||||
ruby -c "$file" || exit
|
||||
done
|
||||
rubocop
|
||||
```
|
||||
|
||||
The default workspace member is the editor. `vendor/crossterm` is included to test its minimal DEC 2031 notification decoder patch; the rest of its source retains upstream formatting. The decoder checks cover fragmented reports and keyboard recovery, and its doctests verify the added event does not break upstream examples.
|
||||
|
||||
## Ruby types
|
||||
|
||||
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
|
||||
@@ -33,17 +37,42 @@ Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/r
|
||||
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs).
|
||||
|
||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
|
||||
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
|
||||
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
|
||||
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics, including original panic-payload propagation.
|
||||
- For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks.
|
||||
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
|
||||
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
|
||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
||||
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
||||
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
|
||||
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state.
|
||||
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
|
||||
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
|
||||
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
|
||||
|
||||
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
|
||||
|
||||
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||
|
||||
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
||||
|
||||
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
|
||||
|
||||
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
|
||||
|
||||
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
|
||||
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
|
||||
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
|
||||
|
||||
## Performance and process limits
|
||||
|
||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||
- Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw.
|
||||
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
|
||||
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
|
||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
|
||||
- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops.
|
||||
|
||||
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
||||
|
||||
@@ -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.
|
||||
+333
-32
@@ -4,33 +4,77 @@
|
||||
|
||||
Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp).
|
||||
|
||||
Ordinary documents show repository changes beside their hybrid line numbers by default: green `▌` for additions, red for removals, and yellow for modifications. [Configure `git_gutter`](configuration.md) to use `+` / `-` / `~`, colored numbers, or no change indicators. Bar/symbol gutters keep the order **number, blank, marker, blank, text**; number-only modes use no extra column. Horizontal scrolling never moves indicators, and wrapped continuation rows do not repeat them.
|
||||
|
||||
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
|
||||
|
||||
Indicators refresh asynchronously. Typing and undo/redo retain the last completed indicators until the updated comparison arrives, avoiding flashes; indicator values and row positions can briefly lag edits.
|
||||
|
||||
## Default editing keys
|
||||
|
||||
| Mode | Keys |
|
||||
| --- | --- |
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, opens the buffer picker, or closes buffers |
|
||||
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
|
||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `_` splits above/below; `\|` splits left/right; the new pane inherits the current buffer and view |
|
||||
| Normal | Option/Alt+h/j/k/l focuses the neighboring pane left/down/up/right |
|
||||
| Normal | Ctrl+Option/Alt+h/l switches to the previous/next tab, wrapping at either end |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
|
||||
| Normal, Visual | `<line>g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections |
|
||||
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| 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 | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
|
||||
| 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 | `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 | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selections; `p`/`P` paste the configured clipboard after/before each caret (system by default); Esc keeps only the primary caret |
|
||||
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels |
|
||||
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets |
|
||||
| Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
`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
|
||||
|
||||
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.
|
||||
|
||||
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and literal Tab behavior; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
|
||||
|
||||
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the hardware cursor. Wheel input over it is consumed without choosing a candidate.
|
||||
|
||||
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab inserts a literal tab. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
|
||||
|
||||
For example, `call(${1:argument}, ${2:count})$0` previews `call(argument, count)`. Type `value`, Tab, `3`, Tab to produce `call(value, 3)` with the caret after the closing parenthesis. Argument edits remain in the same Insert undo group, and dot repeats the resolved text and final caret rather than re-expanding placeholders.
|
||||
|
||||
Numbered tabstops, nested defaults, mirrors, escapes, variable defaults, and context variables are supported. Choices insert their first option, which remains editable. Regex transforms, unknown variables without defaults, repeated/nonempty final `$0`, and final stops nested inside arguments are omitted rather than inserted partly or literally. See [protocol limits](configuration.md#native-lsp).
|
||||
|
||||
## Word motions
|
||||
|
||||
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
|
||||
@@ -39,27 +83,55 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu
|
||||
|
||||
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight.
|
||||
|
||||
## Character seeks
|
||||
|
||||
`f<char>` / `F<char>` moves every caret to the next / previous matching grapheme on its current line, excluding the current position. A count seeks that occurrence independently for each caret: `2fx` finds the second following `x`, and `2Fx` the second preceding `x`. With too few matches, that caret stays unchanged. Seeks never wrap or cross lines, and Unicode targets land on whole graphemes, including combining sequences and ZWJ emoji.
|
||||
|
||||
Normal and both Visual shapes support these motions, including read-only diff panes. Visual keeps its anchors; native edit replay retains the seek's direction, target, and count rather than its original distance. The following character is literal even if it is a digit, leader key, remapped key, or diff expansion bracket. Escape cancels only the pending seek without moving or collapsing selections. `f`/`F` remain literal text in Insert and command/search prompts; `gf` retains its Markdown-link behavior.
|
||||
|
||||
Bind `config.core.seek_forward` / `config.core.seek_backward` to another Normal or Visual key or leader sequence to start the same target-character prompt.
|
||||
|
||||
## Motion counts
|
||||
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches.
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f<char>`/`F<char>`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Character seeks require the requested occurrence on the current line or leave that caret unchanged. Search counts wrap through the available matches.
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and editing prefixes retain the count until their action resolves. Line/word deletion, character replacement, and substitution use counts; suffix actions and other non-motion commands consume it and execute once. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history; only the dedicated operators described below are supported.
|
||||
|
||||
## Normal editing actions
|
||||
|
||||
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
|
||||
|
||||
`yy` copies the entire current line into the configured linewise clipboard without moving any caret, changing text, or replacing undo/redo and dot-repeat history. Counts before and after `y` multiply (`2yy`, `y2y`, `2y3y`), selecting rows downward and clamping at EOF. Empty and unterminated final rows retain linewise shape. `p` pastes these complete rows below the current line; `P` pastes above it. The caret lands on the first nonblank of the first inserted row. Characterwise yanks still paste after/before the current grapheme. Bind `config.core.copy_line` for another Normal-mode route; historical read-only buffers permit yanking but reject paste.
|
||||
|
||||
The first `d`, `c`, or `y` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`, `w`, or `b`; `y` accepts `y`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, `2c3b` cuts six backward spans and enters Insert, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
|
||||
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
|
||||
|
||||
`D` cuts from each caret through its logical line end; `C` cuts the same suffix and enters Insert. `Y` copies that suffix characterwise without moving the carets. These actions leave line separators intact and ignore counts. `C` and subsequent typed replacement form one undo group.
|
||||
|
||||
`r<char>` replaces one grapheme per caret and stays in Normal; `s` cuts one grapheme and enters Insert. Counts select that many graphemes, clamped to the current line. Combining sequences and ZWJ emoji are never split. Replacement accepts a printable character or Tab literally; Escape or other non-character input cancels it without editing. Empty-line replacement is a no-op; substitution can still enter Insert. `s` and replacement typing form one undo group.
|
||||
|
||||
`dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`, but confined to that caret's current logical line. Counts repeat the boundary motion, stopping at the line end/start without deleting its newline or touching neighboring lines; ordinary `w` / `b` navigation still crosses lines. Backward spans exclude the original caret character. Empty spans do not edit text. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat.
|
||||
|
||||
`cw` / `cb` cut exactly the same line-local spans as `dw` / `db`, including intervening whitespace, then enter Insert at each cut's start. Counts before and after `c` multiply (`2cw`, `c2w`, `2c3b`); excess counts stop at the current line's boundary. Empty spans still enter Insert, including on empty lines and for `cb` at column zero. The cut and subsequent replacement typing form one undo group; dot repeat recomputes the requested spans at the current carets and replays the replacement text. Rebind the Normal-only `config.core.change_word_forward` / `config.core.change_word_backward` actions for another route.
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
|
||||
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
||||
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
|
||||
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 empty final-row selections enters Insert without overwriting either clipboard.
|
||||
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.
|
||||
|
||||
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-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||
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.
|
||||
|
||||
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 empty final-row selections does not overwrite either register. 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.
|
||||
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
|
||||
|
||||
@@ -73,19 +145,19 @@ config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
|
||||
config.bind "ctrl+g", config.core.paste_system_before
|
||||
```
|
||||
|
||||
For other applications, multi-selection exports join fragments with LF. Vamp retains their original boundaries privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
For other applications, characterwise multi-selection exports join fragments with LF; linewise exports concatenate whole-row fragments without extra separators because each fragment already ends in LF. Vamp retains their original boundaries and shape privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one characterwise literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
|
||||
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||
|
||||
System clipboard access supports macOS through `osascript`, Linux Wayland through `wl-paste`/`wl-copy`, and Linux X11 through `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
|
||||
macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
|
||||
|
||||
## Repeat last edit
|
||||
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands.
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, counted Normal `dd`/`cc`, Normal character deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once; a stored `dd`/`cc` recipe retains its own requested row count.
|
||||
|
||||
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
||||
In Normal, characterwise Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If characterwise targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Visual linewise targeting uses signed relative row spans, preserving whole-row shape rather than replaying motions. Equal spans broadcast; unequal spans require matching target counts. In Visual, current explicit ranges and selection shape replace stored Visual targeting. Native `dd`/`cc` recipes instead always select their requested rows from each current active caret. Target-building commands themselves do not recreate old cursors.
|
||||
|
||||
Clipboard paste captures the fragments actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
Clipboard paste captures the fragments and their characterwise/linewise shape actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
|
||||
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
||||
|
||||
@@ -99,37 +171,159 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
|
||||
|
||||
## Commands, saving, and pasted input
|
||||
|
||||
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
|
||||
`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable.
|
||||
|
||||
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
|
||||
The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar.
|
||||
|
||||
Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
|
||||
|
||||
Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:restart`, `:force_restart`, `:save_and_quit [path]`, `:force_save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules:
|
||||
|
||||
| Alias | Canonical command |
|
||||
| --- | --- |
|
||||
| `:w` | `:save` |
|
||||
| `:w!` | `:force_save` |
|
||||
| `:q` | `:quit` |
|
||||
| `:q!` | `:force_quit` |
|
||||
| `:restart!` | `:force_restart` |
|
||||
| `:wq` | `:save_and_quit` |
|
||||
| `:wq!` | `:force_save_and_quit` |
|
||||
| `:bnext` | `:buffer_next` |
|
||||
| `:bprev` | `:buffer_previous` |
|
||||
| `:bdelete` | `:buffer_delete` |
|
||||
| `:bdelete!` | `:buffer_force_delete` |
|
||||
|
||||
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean; `:wq!` applies the same quit rule after a force save. The typed actions are `core.save`, `core.force_save`, `core.save_and_quit`, and `core.force_save_and_quit`. Normal saves re-read actual disk content and compare it with the saved baseline after CRLF/CR-to-LF normalization, independently of polling or timestamps. External changes or deletion refuse the write with a warning; a new buffer opened at a nonexistent path may save normally. Use `:w!` / `:force_save [path]` or `:wq!` / `:force_save_and_quit [path]` to explicitly overwrite external changes. Force bypasses only the external-change check: historical snapshots remain read-only, target symlinks are rejected, and another loaded buffer's path cannot be overwritten.
|
||||
|
||||
`:restart` closes clean buffers across all tabs, panes, and historical views. If any dirty buffer remains, it preserves that buffer's unsaved text and undo/redo history, warns, and keeps the editor interactive; clean buffers already closed stay closed. With no dirty buffers, it restarts the executable. `:force_restart` (alias `:restart!`) restarts regardless of dirty buffers, discarding unsaved text without writing it. Both commands take no arguments; their Normal-mode actions are `core.restart` and `core.force_restart`, with no default keybindings. Selecting force restart in the command palette requires confirmation; an explicitly configured force-restart binding executes directly.
|
||||
|
||||
Restart tears down the entire editor session, including workers and plugins, before relaunching the same executable with its original CLI arguments. It briefly resets raw mode and input reporting but retains the alternate screen across the process handoff; the new process resumes input modes and clears and repaints that screen atomically in its first frame. The new process rereads configuration and reopens the original startup file from disk, or starts with an unnamed buffer if no file was supplied. Current buffers, tabs, views, and other session state are not restored. Final quit, errors, and panics still restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
|
||||
|
||||
All saves use a same-directory temporary file and rename and preserve existing permissions. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
|
||||
|
||||
Live file buffers automatically check disk metadata every 250 ms, including buffers in inactive tabs/panes and parked views; historical snapshots are excluded. Content is read only when metadata changes. A clean buffer reloads validated changed text, clears stale undo/redo, preserves caret/selection and view positions where possible, and clamps them to the new text. Reloads invalidate annotations and stale asynchronous requests and refresh live diffs. Dirty buffers retain their edits. Missing, unreadable, or invalid changed files preserve buffer text and report a warning rather than replacing it.
|
||||
|
||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||
|
||||
## Cursor-following scrolling
|
||||
|
||||
[Configure `scroll_distance`](configuration.md#preferences) to reserve space between the primary cursor or picker selection and the viewport edges. `0` (default) scrolls only to keep it visible; an integer reserves that many visual rows, and columns for unwrapped document text. `nil`, or a margin too large for the current viewport, centers it as far as the start of content permits. This policy applies to documents, historical/diff panes, file explorers, palettes, completion lists, and revision pickers. Reversing direction inside the margin does not move the viewport; resizing recalculates the usable margin.
|
||||
|
||||
## Mouse wheel scrolling
|
||||
|
||||
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
|
||||
|
||||
Mouse-wheel left/right scrolls three display columns per event when wrapping is off. **Shift+wheel up/down** provides the same left/right scrolling when the terminal does not send horizontal wheel events. Tabs and wide Unicode characters use terminal-cell widths; scrolling stops at the longest line and remains detached from the caret until navigation or editing resumes. Wrapped text consumes horizontal wheel input without panning.
|
||||
|
||||
Vertical wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Horizontal wheel input over these panels is consumed without panning the document. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions.
|
||||
|
||||
## Word wrap
|
||||
|
||||
Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary pane retains its own setting for each buffer; toggling does not edit text or enter undo history.
|
||||
|
||||
Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text.
|
||||
|
||||
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
|
||||
|
||||
## Current-line Git blame
|
||||
|
||||
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution.
|
||||
|
||||
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
|
||||
|
||||
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
|
||||
|
||||
## Panes and splits
|
||||
|
||||
Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes.
|
||||
|
||||
| Canonical command | Typed action | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `:pane.split_horizontal` | `config.core.pane.split_horizontal` | Split above/below (`_` in Normal) |
|
||||
| `:pane.split_vertical` | `config.core.pane.split_vertical` | Split left/right (`\|` in Normal) |
|
||||
| `:pane.focus_left` / `:pane.focus_down` / `:pane.focus_up` / `:pane.focus_right` | Corresponding `config.core.pane.focus_*` action | Directional focus (Option/Alt+h/j/k/l in Normal) |
|
||||
| `:pane.close` | `config.core.pane.close` | Close a focused docked file list, otherwise close the focused editor pane subject to unsaved-change protection |
|
||||
| `:pane.force_close` | `config.core.pane.force_close` | Close a focused docked list without discarding data; otherwise explicitly discard exclusively displayed edits and close the editor pane |
|
||||
|
||||
Closing a pane gives its space to its sibling:
|
||||
|
||||
| Situation | Close pane | Force close pane |
|
||||
| --- | --- | --- |
|
||||
| Focused docked explorer or diff file list | Close only the list and return document focus; retain buffers, edits, tabs, and comparison | Same |
|
||||
| Clean buffer displayed only here | Close pane and buffer | Same |
|
||||
| Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer |
|
||||
| Buffer also displayed in another pane or tab | Close only this pane; retain the buffer and edits | Same |
|
||||
| Last pane in a tab, with other tabs open | Close the tab using the same shared-buffer and unsaved-change rules | Same, discarding only exclusively displayed edits |
|
||||
| Last pane in the last tab | Quit only if every open buffer is clean, including hidden buffers | Quit without saving |
|
||||
|
||||
There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings.
|
||||
|
||||
A focused docked list is a close-action target even when it is the only visible side panel; closing it never quits or closes its buffer. A visible but unfocused dock does not intercept pane-close. The command palette remembers a dock that had focus when it opened for these two close actions, including force-close confirmation and whitespace edits to that command; cancelling or choosing another action clears that target.
|
||||
|
||||
Floating pickers are not pane-close targets. A resolved close/force-close action passes through to the underlying editor pane/tab with its existing dirty-buffer guards; it is not ignored and does not merely dismiss the picker. A surviving tab-owned floating picker remains open until its ordinary Escape/close behavior or owner closure removes it. Printable bindings remain query text while a filter owns input; control-key or control-prefix bindings can invoke pane-close without altering the query.
|
||||
|
||||
Pane-focus bindings also work while the ordinary file explorer or picker has focus. Moving to a neighboring pane returns keyboard focus to its document; a floating picker closes, while a docked explorer remains visible. Explicit `tree` or `selection` navigation overrides and disabled keys take precedence over pane-focus bindings.
|
||||
|
||||
Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge.
|
||||
|
||||
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
|
||||
|
||||
## Tabs
|
||||
|
||||
Use `:tab.new` to open the current buffer in a new, focused tab with one pane. Each tab preserves its split layout, focused pane, explorer, and comparison. Buffers and undo/redo history remain shared; carets, selections, searches, wrapping, and scroll positions are independent views. Opening a historical diff buffer in a tab keeps it read-only, even after the source comparison closes.
|
||||
|
||||
| Canonical command | Typed action | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `:tab.new` | `config.core.tab.new` | Open the current buffer in a new tab |
|
||||
| `:tab.previous` / `:tab.next` | `config.core.tab.previous` / `config.core.tab.next` | Switch tabs, wrapping at either end; Ctrl+Option/Alt+h/l in Normal |
|
||||
| `: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 |
|
||||
|
||||
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.
|
||||
|
||||
At very small heights, both bars yield to a document row; floating input still accepts text on a one-row terminal. If only one bar row fits while both have content, the top bar receives it.
|
||||
|
||||
Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving.
|
||||
|
||||
## Buffers
|
||||
|
||||
One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer.
|
||||
Each buffer retains its shared text, path, saved state, and undo/redo history. View state is retained separately for each pane and buffer. Caret-following adjusts that view's offsets as needed; mouse-wheel scrolling can temporarily detach it from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the focused pane's active buffer.
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
||||
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
||||
| `:buffers` | Open the searchable buffer picker; stable IDs and paths, `*` before the active buffer ID, `+` dirty |
|
||||
| `:buffer N` | Switch to buffer ID `N` |
|
||||
| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer |
|
||||
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
| `:buffer_force_delete [N]` | Explicitly discard changes and close the active or named buffer |
|
||||
|
||||
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
||||
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save or discard hidden edits; automatic disk checks also cover hidden clean buffers. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
||||
|
||||
Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
|
||||
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
|
||||
|
||||
The picker uses the file explorer's centered floating-panel geometry, a cyan border, padded search input, and dimmed controls. Buffer IDs and status columns align across rows: IDs use the normal text foreground, a cyan `*` before the ID marks the active buffer, and a two-column gap separates the ID from the filename. Unsaved buffers show a yellow `+` in the first gap column; the selected row is blue. Paths under the home directory display as `~/…`, using the same component-aware abbreviation as the status bar; searching and file operations retain the original paths. Narrow or short terminals drop padding/borders as needed while keeping the query cursor and selected result visible whenever a result row fits.
|
||||
|
||||
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
|
||||
|
||||
## File picker and explorer
|
||||
|
||||
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
|
||||
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
|
||||
|
||||
While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available.
|
||||
|
||||
The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it.
|
||||
|
||||
The dock defaults to the right of the editor workspace. See [configuration preferences](configuration.md#preferences) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||
|
||||
The explorer root path wraps within the pane width in floating, docked, and diff file views; file rows and keybinding help do not wrap. The path header grows to fit where space permits, reserving at least one file row. Very short panes clip the remaining path rather than hiding file navigation.
|
||||
|
||||
Both presentations default to Tree; [configuration preferences](configuration.md#preferences) can select List independently for each. Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row.
|
||||
|
||||
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
||||
@@ -156,19 +350,126 @@ Clearing returns to the full catalog and reveals the selected file's ancestry in
|
||||
|
||||
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
|
||||
|
||||
## Repository diff
|
||||
|
||||
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
||||
|
||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
||||
|
||||
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
||||
|
||||
| Selector input | Effect |
|
||||
| --- | --- |
|
||||
| Tab / Shift+Tab | Next / previous category |
|
||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) |
|
||||
| Enter | Choose the entry |
|
||||
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
||||
|
||||
Printable input belongs to the focused query, including `:`, `_`, and `|`; Normal-mode command or pane bindings cannot steal it. Explicit selection-navigation bindings still take priority. Use Ctrl+p to select an action, or configure a control-key action binding, when you need an editor action while the selector is focused.
|
||||
|
||||
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||
|
||||
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
|
||||
|
||||
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
|
||||
|
||||
**Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility.
|
||||
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving the Working endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
||||
|
||||
**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and writable After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view.
|
||||
|
||||
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
|
||||
|
||||
For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions.
|
||||
|
||||
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below.
|
||||
|
||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||
|
||||
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. Subsequent motions resume the configured `scroll_distance` policy; failed or superseded definitions preserve the current logical viewport.
|
||||
|
||||
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||
|
||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||
|
||||
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
|
||||
|
||||
Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut.
|
||||
|
||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
||||
|
||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
|
||||
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
||||
|
||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Ordinary motions, search, and caret restoration do not reveal omitted context; Normal-mode `[`/`]` expands existing excerpts. LSP definition routing can additionally reveal the exact selection of a symbol whose range overlaps changes. Edits still recompute changed regions and retain previously expanded context.
|
||||
|
||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||
|
||||
Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle` switches both panes together, including read-only snapshots, without changing ordinary-buffer settings. Each aligned source row uses the taller side's wrapped height; shorter-side continuation padding stays blank so subsequent source lines remain aligned. Excerpts and explicit expansion still use source-line counts.
|
||||
|
||||
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
|
||||
|
||||
Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
|
||||
|
||||
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 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.
|
||||
|
||||
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds.
|
||||
|
||||
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
||||
|
||||
## Command palette
|
||||
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/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 command name, human-readable label, or owner. 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. Accepting an Ex/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. Choosing `q!` therefore does not immediately discard changes.
|
||||
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`, `core.pane.force_close`, `core.force_save`, and `core.force_save_and_quit` actions also open canonical Ex prompts, requiring another Enter before discarding or overwriting 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.
|
||||
|
||||
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||
|
||||
## Leader sequences
|
||||
## Key sequences
|
||||
|
||||
Space begins a leader sequence in Normal/Visual by default. Press the suffix keys in order; nested prefixes wait without a timeout. The status bar shows the pending sequence. Esc cancels the sequence and pending count without leaving Visual or collapsing carets. See [leader configuration](configuration.md#leader-key) for remapping and declaration rules.
|
||||
Space begins a leader sequence in Normal/Visual by default. Explicit sequences such as `ctrl+w j` can coexist without changing that leader. Press the keys in order; nested prefixes wait without a timeout. The status bar shows the pending keys before the file path, without a prefix label. Esc cancels the sequence and pending count without leaving Visual or collapsing carets. See [sequence configuration](configuration.md#key-sequences-and-leader) for remapping and declaration rules.
|
||||
|
||||
## Jump history
|
||||
|
||||
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action outside comparisons while Insert Tab inserts text outside snippets. In a comparison, Ctrl+i/Tab retains its diff-side focus behavior; use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
|
||||
|
||||
The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
|
||||
|
||||
Moving away from a visited point before traversing saves the actual departure position for the return trip. A successful new jump after going back discards the forward branch; failed/no-op jumps preserve it. Closed buffers are skipped rather than reopened. An unavailable or replaced comparison is never resurrected or overwritten: history uses a safe existing ordinary pane for an available buffer, retaining snapshot read-only identity, or skips the destination if none exists. It never creates a replacement tab. Stored row/grapheme coordinates clamp to current text after edits; text, dirty state, undo/redo, and file contents remain unchanged. History navigation returns to Normal with one primary caret.
|
||||
|
||||
The typed actions are `config.core.jump_back` and `config.core.jump_forward`; custom bindings can use any supported single key or leader sequence. History is in memory for this session only.
|
||||
|
||||
## Markdown links
|
||||
|
||||
Normal `gf` or `:follow_link` follows the link under the primary caret, without an LSP. Inline links and full/collapsed/shortcut reference links are supported; reference definitions can appear elsewhere in the document. The label or destination can be under the caret. Images, code blocks, and ordinary text are not file links.
|
||||
|
||||
Local paths resolve relative to the source file, not the working directory; percent-encoded UTF-8 paths and Markdown-escaped punctuation are decoded. Targets can be any supported text file. Existing buffers are reused, including unsaved text and files removed from disk. Missing files, undefined references, invalid destinations, or missing headings report an error without switching buffers or moving carets.
|
||||
|
||||
`[Section](#section)` stays in the current document; `[Section](other.md#section)` opens the target and moves to its heading. ATX and Setext headings use GitHub-style slugs derived from visible inline text: lowercase, punctuation removed, whitespace replaced by hyphens, and duplicate suffixes `-1`, `-2`, etc. Heading lookup uses unsaved target text. Relative file links require a named source; same-document fragments also work in unnamed buffers.
|
||||
|
||||
External URL schemes/network paths are rejected; this action never launches a browser or shell. Numeric HTML entities and common named entities (`amp`, `lt`, `gt`, `quot`, `apos`, `nbsp` and their supported aliases) are decoded; other recognized named entities report an unsupported-entity error rather than guessing. Unicode slugs/reference matching use standard-library case conversion, not a complete Unicode case-fold/category table.
|
||||
|
||||
`g` waits for `f` in Normal, or `t`/`b` in Normal and Visual, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured `g` binding overrides this built-in prefix in its mode; choosing `g` as leader also takes precedence. Custom bindings use the generated action:
|
||||
|
||||
```ruby
|
||||
config.bind "leader f", config.core.follow_link
|
||||
```
|
||||
|
||||
## Syntax highlighting
|
||||
|
||||
@@ -176,6 +477,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.
|
||||
|
||||
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. Variables/parameters use light cyan, properties blue, and readonly variables/properties yellow; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. 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.
|
||||
|
||||
+27
-3
@@ -22,7 +22,7 @@ Callbacks receive an immutable primary-only context snapshot: `editor.text`, `ed
|
||||
|
||||
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
||||
|
||||
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
||||
`editor.insert(text)`, `editor.replace(text)`, `editor.message(text)`, and `editor.label(name, text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
||||
|
||||
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
|
||||
|
||||
@@ -40,15 +40,39 @@ A feature declares all capabilities it needs. Registration is atomic: if any req
|
||||
|
||||
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
||||
|
||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, `label`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||
|
||||
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
||||
|
||||
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
||||
|
||||
## Named label updates
|
||||
|
||||
`editor.label(name, text)` sets a session-wide named value initialized by `config.labels`. Place that name in a [top or bottom bar anchor group](configuration.md#top-and-bottom-bars) to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
|
||||
|
||||
```ruby
|
||||
Vamp.plugin "build_label"
|
||||
|
||||
# @param editor [Vamp::Editor]
|
||||
# @param args [String]
|
||||
Vamp.command "build_label" do |editor, args|
|
||||
editor.label("build_status", args)
|
||||
end
|
||||
```
|
||||
|
||||
Place `:build_status` in a bar and load this plugin with `config.plugin`, then use `:build_label passed`; `:build_label` clears the value and hides its segment. For asynchronous data, an `async: true` plugin's worker publishes a named value without a request ID:
|
||||
|
||||
```ruby
|
||||
Vamp.publish([{ type: "label", name: "build_status", text: "passed" }])
|
||||
```
|
||||
|
||||
Collect custom data and choose its refresh interval in the plugin; the builtin `:battery` already refreshes from native OS data. There are no render callbacks. Stop worker threads in `Vamp.on_shutdown`. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.
|
||||
|
||||
Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.
|
||||
|
||||
## Messaging
|
||||
|
||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options, bindings, `top_bar` and `bottom_bar` chrome maps, `popup` anchor options, and named labels). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`, `label`). A `label` effect carries `name` and `text` strings. Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||
|
||||
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
||||
|
||||
|
||||
@@ -4,9 +4,34 @@
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||
config.git_blame = true # false disables current-line inline blame
|
||||
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
config.diff_view_mode = "split" # or "unified"
|
||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
|
||||
# config.popup.command = { anchor: :bottom_left }
|
||||
# config.popup.search = { anchor: :center }
|
||||
# Status above, tabs and live battery below; either bar accepts all segments.
|
||||
config.bar.top = {
|
||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||
center: [], right: [:language, :lsp, :position],
|
||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
},
|
||||
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||
config.plugin "plugins/greeting.rb"
|
||||
@@ -23,6 +48,8 @@ Vamp.configure do |config|
|
||||
# config.bind "K", config.core.lsp.hover
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "leader q", config.core.quit
|
||||
config.bind "leader d", config.core.diff
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "leader r", config.core.repeat_last_edit
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
sjdalkjdas dsasda sdklam dalkmsd adadkajsdlakj
|
||||
o
|
||||
aaklsjdad
|
||||
|
||||
ads dasdkaljdalkjsda
|
||||
swwdalksdjaldskjadkjsla
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
mpsc::{self, Receiver, Sender},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use std::{sync::mpsc::RecvTimeoutError, time::Duration};
|
||||
|
||||
pub(crate) struct Battery {
|
||||
results: Arc<AtomicU8>,
|
||||
wake: Option<Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Battery {
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn start() -> io::Result<Self> {
|
||||
Self::start_with(read_charge, |wake| {
|
||||
matches!(
|
||||
wake.recv_timeout(Duration::from_secs(30)),
|
||||
Ok(()) | Err(RecvTimeoutError::Timeout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
mut read: impl FnMut() -> Option<u8> + Send + 'static,
|
||||
mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let results = Arc::new(AtomicU8::new(0));
|
||||
let latest = Arc::clone(&results);
|
||||
let (wake, requests) = mpsc::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-battery".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let charge = read().filter(|charge| *charge <= 100);
|
||||
// A one-slot latest-result mailbox: 0 = no new sample,
|
||||
// 1 = no battery, 2..=102 = charge. Publishing never blocks.
|
||||
latest.store(charge.map_or(1, |charge| charge + 2), Ordering::Relaxed);
|
||||
if !wait(&requests) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
results,
|
||||
wake: Some(wake),
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<Option<u8>> {
|
||||
match self.results.swap(0, Ordering::Relaxed) {
|
||||
0 => None,
|
||||
1 => Some(None),
|
||||
charge => Some(Some(charge - 2)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Battery {
|
||||
fn drop(&mut self) {
|
||||
// Disconnecting interrupts recv_timeout instead of waiting for its deadline.
|
||||
self.wake.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn read_charge() -> Option<u8> {
|
||||
// ponytail: first internal battery on macOS/Linux; aggregate compatible capacities for multi-battery systems.
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::read_charge();
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::read_charge(std::path::Path::new("/sys/class/power_supply"));
|
||||
#[cfg(windows)]
|
||||
return windows::read_charge();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
fn percentage(current: u64, full: u64) -> Option<u8> {
|
||||
if full == 0 || current > full {
|
||||
return None;
|
||||
}
|
||||
Some(((current as u128 * 100) / full as u128) as u8)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::ffi::{CStr, c_char, c_void};
|
||||
|
||||
type Object = *const c_void;
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[link(name = "IOKit", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn IOPSCopyPowerSourcesInfo() -> Object;
|
||||
fn IOPSCopyPowerSourcesList(info: Object) -> Object;
|
||||
fn IOPSGetPowerSourceDescription(info: Object, source: Object) -> Object;
|
||||
}
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFRelease(object: Object);
|
||||
fn CFGetTypeID(object: Object) -> usize;
|
||||
#[cfg(not(test))]
|
||||
fn CFEqual(left: Object, right: Object) -> u8;
|
||||
#[cfg(not(test))]
|
||||
fn CFArrayGetTypeID() -> usize;
|
||||
#[cfg(not(test))]
|
||||
fn CFArrayGetCount(array: Object) -> isize;
|
||||
#[cfg(not(test))]
|
||||
fn CFArrayGetValueAtIndex(array: Object, index: isize) -> Object;
|
||||
#[cfg(not(test))]
|
||||
fn CFDictionaryGetTypeID() -> usize;
|
||||
#[cfg(not(test))]
|
||||
fn CFDictionaryGetValue(dictionary: Object, key: Object) -> Object;
|
||||
#[cfg(not(test))]
|
||||
fn CFStringGetTypeID() -> usize;
|
||||
fn CFStringCreateWithCString(
|
||||
allocator: Object,
|
||||
string: *const c_char,
|
||||
encoding: u32,
|
||||
) -> Object;
|
||||
#[cfg(not(test))]
|
||||
fn CFBooleanGetTypeID() -> usize;
|
||||
#[cfg(not(test))]
|
||||
fn CFBooleanGetValue(boolean: Object) -> u8;
|
||||
fn CFNumberGetTypeID() -> usize;
|
||||
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
||||
#[cfg(test)]
|
||||
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
||||
}
|
||||
|
||||
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
||||
struct Owned(Object);
|
||||
|
||||
impl Owned {
|
||||
fn new(object: Object) -> Option<Self> {
|
||||
(!object.is_null()).then(|| Self(object))
|
||||
}
|
||||
|
||||
fn string(value: &CStr) -> Option<Self> {
|
||||
// kCFStringEncodingUTF8, from CFString.h; a null allocator is the default.
|
||||
Self::new(unsafe {
|
||||
CFStringCreateWithCString(std::ptr::null(), value.as_ptr(), 0x0800_0100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Owned {
|
||||
fn drop(&mut self) {
|
||||
unsafe { CFRelease(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn is_type(object: Object, kind: usize) -> bool {
|
||||
!object.is_null() && unsafe { CFGetTypeID(object) == kind }
|
||||
}
|
||||
|
||||
unsafe fn number(object: Object) -> Option<u64> {
|
||||
if !unsafe { is_type(object, CFNumberGetTypeID()) } {
|
||||
return None;
|
||||
}
|
||||
let mut value: i64 = 0;
|
||||
// kCFNumberSInt64Type = 4. A lossy/out-of-range conversion returns false.
|
||||
let converted = unsafe { CFNumberGetValue(object, 4, (&mut value as *mut i64).cast()) };
|
||||
(converted != 0).then_some(value)?.try_into().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(super) fn read_charge() -> Option<u8> {
|
||||
// The opaque snapshot is only passed to IOPS accessors. All owned values
|
||||
// are released on every return path, including failed type checks.
|
||||
unsafe {
|
||||
let info = Owned::new(IOPSCopyPowerSourcesInfo())?;
|
||||
let sources = Owned::new(IOPSCopyPowerSourcesList(info.0))?;
|
||||
if !is_type(sources.0, CFArrayGetTypeID()) {
|
||||
return None;
|
||||
}
|
||||
let type_key = Owned::string(c"Type")?;
|
||||
let internal = Owned::string(c"InternalBattery")?;
|
||||
let present_key = Owned::string(c"Is Present")?;
|
||||
let current_key = Owned::string(c"Current Capacity")?;
|
||||
let full_key = Owned::string(c"Max Capacity")?;
|
||||
for index in 0..CFArrayGetCount(sources.0) {
|
||||
let source = CFArrayGetValueAtIndex(sources.0, index);
|
||||
if source.is_null() {
|
||||
continue;
|
||||
}
|
||||
let description = IOPSGetPowerSourceDescription(info.0, source);
|
||||
if !is_type(description, CFDictionaryGetTypeID()) {
|
||||
continue;
|
||||
}
|
||||
let kind = CFDictionaryGetValue(description, type_key.0);
|
||||
if !is_type(kind, CFStringGetTypeID()) || CFEqual(kind, internal.0) == 0 {
|
||||
continue;
|
||||
}
|
||||
let present = CFDictionaryGetValue(description, present_key.0);
|
||||
if !is_type(present, CFBooleanGetTypeID()) || CFBooleanGetValue(present) == 0 {
|
||||
continue;
|
||||
}
|
||||
let current = number(CFDictionaryGetValue(description, current_key.0))?;
|
||||
let full = number(CFDictionaryGetValue(description, full_key.0))?;
|
||||
return super::percentage(current, full);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
||||
assert!(Owned::new(std::ptr::null()).is_none());
|
||||
unsafe {
|
||||
assert_eq!(number(std::ptr::null()), None);
|
||||
let text = Owned::string(c"27").unwrap();
|
||||
assert_eq!(number(text.0), None);
|
||||
for value in [-1_i64, 0, 100] {
|
||||
let object = Owned::new(CFNumberCreate(
|
||||
std::ptr::null(),
|
||||
4,
|
||||
(&value as *const i64).cast(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(number(object.0), u64::try_from(value).ok());
|
||||
}
|
||||
let fraction = 1.5_f64;
|
||||
let object = Owned::new(CFNumberCreate(
|
||||
std::ptr::null(),
|
||||
6,
|
||||
(&fraction as *const f64).cast(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(number(object.0), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux {
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
pub(super) fn read_charge(root: &Path) -> Option<u8> {
|
||||
for entry in fs::read_dir(root).ok()? {
|
||||
let path = entry.ok()?.path();
|
||||
if fs::read_to_string(path.join("type"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
!= Some("Battery")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match fs::read_to_string(path.join("scope")) {
|
||||
Ok(scope) if matches!(scope.trim(), "System" | "Unknown") => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
_ => continue,
|
||||
}
|
||||
if fs::read_to_string(path.join("present"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
!= Some("1")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return read_capacity(&path);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_capacity(path: &Path) -> Option<u8> {
|
||||
match fs::read_to_string(path.join("capacity")) {
|
||||
Ok(capacity) => {
|
||||
return capacity
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.ok()
|
||||
.filter(|value| *value <= 100);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
// Never mix energy and charge units or use design capacity as full capacity.
|
||||
let (current, full) = match fs::read_to_string(path.join("energy_now")) {
|
||||
Ok(current) => (current, fs::read_to_string(path.join("energy_full")).ok()?),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => (
|
||||
fs::read_to_string(path.join("charge_now")).ok()?,
|
||||
fs::read_to_string(path.join("charge_full")).ok()?,
|
||||
),
|
||||
Err(_) => return None,
|
||||
};
|
||||
super::percentage(current.trim().parse().ok()?, full.trim().parse().ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_charge(percent: u8, flags: u8) -> Option<u8> {
|
||||
(percent <= 100 && flags & 128 == 0).then_some(percent)
|
||||
}
|
||||
|
||||
#[cfg(all(windows, not(test)))]
|
||||
mod windows {
|
||||
#[repr(C)]
|
||||
struct SystemPowerStatus {
|
||||
ac_line_status: u8,
|
||||
battery_flag: u8,
|
||||
battery_life_percent: u8,
|
||||
system_status_flag: u8,
|
||||
battery_life_time: u32,
|
||||
battery_full_life_time: u32,
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32;
|
||||
}
|
||||
|
||||
pub(super) fn read_charge() -> Option<u8> {
|
||||
let mut status = std::mem::MaybeUninit::uninit();
|
||||
if unsafe { GetSystemPowerStatus(status.as_mut_ptr()) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let status = unsafe { status.assume_init() };
|
||||
super::windows_charge(status.battery_life_percent, status.battery_flag)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
||||
assert_eq!(percentage(0, 100), Some(0));
|
||||
assert_eq!(percentage(100, 100), Some(100));
|
||||
assert_eq!(percentage(1, 3), Some(33));
|
||||
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
||||
assert_eq!(percentage(0, 0), None);
|
||||
assert_eq!(percentage(101, 100), None);
|
||||
assert_eq!(windows_charge(0, 4), Some(0));
|
||||
assert_eq!(windows_charge(100, 8), Some(100));
|
||||
assert_eq!(windows_charge(101, 0), None);
|
||||
assert_eq!(windows_charge(255, 0), None);
|
||||
assert_eq!(windows_charge(50, 128), None);
|
||||
assert_eq!(windows_charge(50, 255), None);
|
||||
}
|
||||
|
||||
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
||||
struct ReaderDropped(Arc<AtomicBool>);
|
||||
impl Drop for ReaderDropped {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let reader = ReaderDropped(Arc::clone(&dropped));
|
||||
let mut samples = samples.into_iter();
|
||||
let (ready, published) = mpsc::channel();
|
||||
let battery = Battery::start_with(
|
||||
move || {
|
||||
let _ = &reader;
|
||||
samples
|
||||
.next()
|
||||
.expect("test provides every requested sample")
|
||||
},
|
||||
move |wake| {
|
||||
ready.send(()).unwrap();
|
||||
// Explicit channel messages replace the production clock entirely.
|
||||
wake.recv().is_ok()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(battery, published, dropped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
||||
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(Some(0)));
|
||||
assert_eq!(battery.poll(), None);
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(None));
|
||||
assert_eq!(battery.poll(), None);
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(None));
|
||||
drop(battery);
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
||||
let (battery, published, dropped) = worker(vec![Some(50)]);
|
||||
published.recv().unwrap();
|
||||
drop(battery);
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-battery-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
for (key, value) in fields {
|
||||
fs::write(path.join(key), value).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Option<u8> {
|
||||
linux::read_charge(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
||||
let fixture = Fixture::new();
|
||||
assert_eq!(fixture.read(), None);
|
||||
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
||||
fixture.source(
|
||||
"mouse",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("scope", "Device"),
|
||||
("present", "1"),
|
||||
("capacity", "100"),
|
||||
],
|
||||
);
|
||||
fixture.source(
|
||||
"absent",
|
||||
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
||||
);
|
||||
assert_eq!(fixture.read(), None);
|
||||
fixture.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery\n"),
|
||||
("scope", "System\n"),
|
||||
("present", "1\n"),
|
||||
("capacity", "27\n"),
|
||||
],
|
||||
);
|
||||
assert_eq!(fixture.read(), Some(27));
|
||||
fixture.source("BAT0", &[("capacity", "0")]);
|
||||
assert_eq!(fixture.read(), Some(0));
|
||||
fixture.source("BAT0", &[("capacity", "100")]);
|
||||
assert_eq!(fixture.read(), Some(100));
|
||||
for invalid in ["101", "-1", "255", "garbage", ""] {
|
||||
fixture.source("BAT0", &[("capacity", invalid)]);
|
||||
assert_eq!(fixture.read(), None);
|
||||
}
|
||||
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
||||
assert_eq!(fixture.read(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
||||
let energy = Fixture::new();
|
||||
energy.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("energy_now", "27"),
|
||||
("energy_full", "100"),
|
||||
],
|
||||
);
|
||||
assert_eq!(energy.read(), Some(27));
|
||||
energy.source("BAT0", &[("energy_full", "0")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
energy.source("BAT0", &[("energy_full", "26")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
energy.source("BAT0", &[("energy_full", "garbage")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
let charge = Fixture::new();
|
||||
charge.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("charge_now", "15"),
|
||||
("charge_full", "20"),
|
||||
],
|
||||
);
|
||||
assert_eq!(charge.read(), Some(75));
|
||||
charge.source("BAT0", &[("energy_now", "5")]);
|
||||
assert_eq!(charge.read(), None);
|
||||
let design = Fixture::new();
|
||||
design.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("energy_now", "20"),
|
||||
("energy_full_design", "100"),
|
||||
],
|
||||
);
|
||||
assert_eq!(design.read(), None);
|
||||
}
|
||||
}
|
||||
+881
@@ -0,0 +1,881 @@
|
||||
//! Read-only file attribution; repository commands never touch the editor thread.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const LIMIT: usize = 16 * 1024 * 1024;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
|
||||
type Request = (u64, u64, PathBuf, String);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LineBlame {
|
||||
pub(crate) commit: String,
|
||||
pub(crate) author: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
result: Option<(u64, ResultFile)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
wake: Condvar,
|
||||
generation: AtomicU64,
|
||||
stopped: AtomicBool,
|
||||
}
|
||||
pub(crate) struct BlameWorker {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl BlameWorker {
|
||||
pub(crate) fn start() -> io::Result<Self> {
|
||||
let shared = Arc::new(Shared::default());
|
||||
let worker = Arc::clone(&shared);
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-blame".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
||||
let Ok(next) = worker.wake.wait(state) else {
|
||||
return;
|
||||
};
|
||||
state = next;
|
||||
}
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let (generation, id, path, contents) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
|| worker.generation.load(Ordering::Acquire) != generation
|
||||
};
|
||||
let result = read_file(&path, &contents, &cancelled);
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if !cancelled() {
|
||||
state.result = Some((id, result));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
|
||||
if contents.len() > LIMIT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Blame input exceeds 16 MiB",
|
||||
));
|
||||
}
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("Blame worker stopped"))?;
|
||||
let generation = self
|
||||
.shared
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, path, contents));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultFile)> {
|
||||
self.shared.state.lock().ok()?.result.take()
|
||||
}
|
||||
}
|
||||
impl Drop for BlameWorker {
|
||||
fn drop(&mut self) {
|
||||
let state = self.shared.state.lock();
|
||||
self.shared.stopped.store(true, Ordering::Release);
|
||||
self.shared.wake.notify_one();
|
||||
drop(state);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Result<Self, String> {
|
||||
for _ in 0..100 {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-blame-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(Self(path)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(_) => return Err("Cannot create private blame directory".into()),
|
||||
}
|
||||
}
|
||||
Err("Cannot create private blame directory".into())
|
||||
}
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
|
||||
pub(crate) fn run(
|
||||
command: Command,
|
||||
input: &[u8],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let (code, bytes) = run_status(command, input, cancelled)?;
|
||||
Ok((code == Some(0)).then_some(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn run_status(
|
||||
mut command: Command,
|
||||
input: &[u8],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<(Option<i32>, Vec<u8>), String> {
|
||||
if cancelled() {
|
||||
return Err("Blame cancelled".into());
|
||||
}
|
||||
for key in [
|
||||
"GIT_DIR",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
] {
|
||||
command.env_remove(key);
|
||||
}
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
return Err("Blame requires Unix process-group cancellation".into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
let mut child = command.spawn().map_err(|_| "Cannot start blame command")?;
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
thread::scope(|scope| {
|
||||
let output = scope.spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stdout
|
||||
.take(LIMIT as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "Cannot read blame output")?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Blame output exceeds 16 MiB");
|
||||
}
|
||||
Ok(bytes)
|
||||
});
|
||||
let writer = scope.spawn(move || stdin.write_all(input));
|
||||
let started = Instant::now();
|
||||
let mut status = None;
|
||||
let failure = loop {
|
||||
if cancelled() {
|
||||
break Some("Blame cancelled");
|
||||
}
|
||||
if started.elapsed() >= TIMEOUT {
|
||||
break Some("Blame command timed out");
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(value) => status = value,
|
||||
Err(_) => break Some("Cannot wait for blame command"),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_finished() && writer.is_finished() {
|
||||
break None;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
if failure.is_some() {
|
||||
unsafe {
|
||||
unsafe extern "C" {
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
kill(-(child.id() as i32), 9);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
let bytes = output.join().map_err(|_| "Blame reader stopped")?;
|
||||
let written = writer.join().map_err(|_| "Blame writer stopped")?;
|
||||
if let Some(error) = failure {
|
||||
return Err(error.into());
|
||||
}
|
||||
let bytes = bytes.map_err(str::to_owned)?;
|
||||
let status = status.unwrap();
|
||||
if status.success() {
|
||||
written.map_err(|_| "Cannot supply blame contents")?;
|
||||
}
|
||||
Ok((status.code(), bytes))
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn git(root: &Path) -> Command {
|
||||
let mut command = git_without_literal_pathspecs(root);
|
||||
command.arg("--literal-pathspecs");
|
||||
command
|
||||
}
|
||||
pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.args([
|
||||
"--no-pager",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
])
|
||||
.arg("-C")
|
||||
.arg(root);
|
||||
command
|
||||
}
|
||||
fn oid(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(
|
||||
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
|
||||
))
|
||||
}
|
||||
}
|
||||
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
|
||||
if contents.len() > LIMIT {
|
||||
return Err("Blame input exceeds 16 MiB".into());
|
||||
}
|
||||
// The editor has a final empty row after a newline; Git has no corresponding source line.
|
||||
let rows = contents.split_inclusive('\n').count();
|
||||
if rows == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = fs::canonicalize(path).map_err(|_| "Cannot resolve blame file")?;
|
||||
let Some(root) = path.parent().and_then(|parent| {
|
||||
parent
|
||||
.ancestors()
|
||||
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let relative = path.strip_prefix(root).map_err(|_| "Invalid blame path")?;
|
||||
if relative.components().any(|part| {
|
||||
!matches!(part, Component::Normal(_))
|
||||
|| part.as_os_str() == ".git"
|
||||
|| part.as_os_str() == ".jj"
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut temporary = None;
|
||||
let mut command = git(root);
|
||||
if root.join(".jj").exists() {
|
||||
let mut jj = Command::new("jj");
|
||||
jj.arg("--repository")
|
||||
.arg(root)
|
||||
.args([
|
||||
"--ignore-working-copy",
|
||||
"--at-operation=@",
|
||||
"--no-pager",
|
||||
"--color=never",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id ++ \"\\n\"",
|
||||
])
|
||||
.current_dir(root);
|
||||
let bytes = run(jj, &[], cancelled)?.ok_or("Cannot resolve Jujutsu blame parent")?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu blame metadata")?;
|
||||
let mut parents = text.lines();
|
||||
let Some(parent) = parents.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A merge has no single committed baseline; never pick an arbitrary parent.
|
||||
if parents.next().is_some() || parent.bytes().all(|byte| byte == b'0') {
|
||||
return Ok(None);
|
||||
}
|
||||
if !oid(parent) {
|
||||
return Err("Invalid Jujutsu blame metadata".into());
|
||||
}
|
||||
let mut repo = root.join(".jj/repo");
|
||||
if repo.is_file() {
|
||||
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
|
||||
repo = root
|
||||
.join(".jj")
|
||||
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
|
||||
}
|
||||
let store = repo.join("store");
|
||||
let target = fs::read(store.join("git_target"))
|
||||
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
|
||||
let store = fs::canonicalize(
|
||||
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
|
||||
)
|
||||
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
|
||||
let private = Temporary::new()?;
|
||||
fs::create_dir(private.0.join("refs"))
|
||||
.map_err(|_| "Cannot prepare private blame repository")?;
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
|
||||
.map_err(|_| "Cannot link blame objects")?;
|
||||
#[cfg(not(unix))]
|
||||
return Err("Jujutsu blame requires Unix object links".into());
|
||||
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
|
||||
.map_err(|_| "Cannot prepare blame parent")?;
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(&private.0)
|
||||
.arg("--work-tree")
|
||||
.arg(root);
|
||||
temporary = Some(private);
|
||||
}
|
||||
let mut present = if let Some(private) = &temporary {
|
||||
let mut command = git(root);
|
||||
command.arg("--git-dir").arg(&private.0);
|
||||
command
|
||||
} else {
|
||||
git(root)
|
||||
};
|
||||
let mut object = std::ffi::OsString::from("HEAD:");
|
||||
object.push(relative);
|
||||
present.args(["cat-file", "-e"]).arg(object);
|
||||
if run(present, &[], cancelled)?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
command
|
||||
.args([
|
||||
"blame",
|
||||
"--incremental",
|
||||
"--no-textconv",
|
||||
"--contents",
|
||||
"-",
|
||||
"--",
|
||||
])
|
||||
.arg(relative);
|
||||
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
|
||||
parse(&output, rows).map(Some)
|
||||
}
|
||||
fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, String> {
|
||||
let malformed = || "Invalid Git blame metadata".to_owned();
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
|
||||
let mut lines = text.lines();
|
||||
let mut commits = HashMap::<&str, Arc<LineBlame>>::new();
|
||||
let mut result = vec![None; rows];
|
||||
while let Some(line) = lines.next() {
|
||||
let mut header = line.split(' ');
|
||||
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
|
||||
let original = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let start = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let count = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let (Some(original), Some(start), Some(count)) = (original, start, count) else {
|
||||
return Err(malformed());
|
||||
};
|
||||
if original == 0 || start == 0 || count == 0 || header.next().is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
original.checked_add(count - 1).ok_or_else(malformed)?;
|
||||
let start = start - 1;
|
||||
let end = start
|
||||
.checked_add(count)
|
||||
.filter(|&end| end <= rows)
|
||||
.ok_or_else(malformed)?;
|
||||
let mut author = None;
|
||||
let mut summary = None;
|
||||
let mut filename = false;
|
||||
for line in lines.by_ref() {
|
||||
if let Some(value) = line.strip_prefix("filename ") {
|
||||
if value.is_empty() {
|
||||
return Err(malformed());
|
||||
}
|
||||
filename = true;
|
||||
break;
|
||||
}
|
||||
if line.is_empty() || line.starts_with('\t') || line.split(' ').next().is_some_and(oid)
|
||||
{
|
||||
return Err(malformed());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("author ") {
|
||||
if author.replace(value).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("summary ") {
|
||||
if summary.replace(value).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !filename {
|
||||
return Err(malformed());
|
||||
}
|
||||
let blame = if let Some(blame) = commits.get(commit) {
|
||||
if author.is_some() || summary.is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
Arc::clone(blame)
|
||||
} else {
|
||||
let author = author.ok_or_else(malformed)?;
|
||||
let summary = summary.ok_or_else(malformed)?;
|
||||
let uncommitted = commit.bytes().all(|byte| byte == b'0');
|
||||
let blame = Arc::new(LineBlame {
|
||||
commit: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
commit.into()
|
||||
},
|
||||
author: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
author.into()
|
||||
},
|
||||
summary: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
summary.into()
|
||||
},
|
||||
uncommitted,
|
||||
});
|
||||
commits.insert(commit, Arc::clone(&blame));
|
||||
blame
|
||||
};
|
||||
for row in &mut result[start..end] {
|
||||
if row.replace(Arc::clone(&blame)).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.iter().any(Option::is_none) {
|
||||
return Err(malformed());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
]);
|
||||
command.args([
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn fixture() -> (Temporary, PathBuf, String) {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "blame@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file].txt");
|
||||
fs::write(&path, "first\nsecond\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Original summary"],
|
||||
);
|
||||
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
(temp, path, commit)
|
||||
}
|
||||
#[test]
|
||||
fn committed_line_has_native_author_summary_and_commit() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let rows = read_file(&path, "first\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
**rows[1].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit,
|
||||
author: "Blame Author".into(),
|
||||
summary: "Original summary".into(),
|
||||
uncommitted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||
let (_temp, path, _) = fixture();
|
||||
let rows = read_file(&path, "changed\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
**rows[0].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit: String::new(),
|
||||
author: String::new(),
|
||||
summary: String::new(),
|
||||
uncommitted: true
|
||||
}
|
||||
);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
||||
}
|
||||
#[test]
|
||||
fn inserted_line_preserves_following_original_attribution() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let contents = "inserted\nfirst\nsecond\n";
|
||||
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
|
||||
let (temp, path, original) = fixture();
|
||||
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
|
||||
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
let rows = read_file(
|
||||
&path,
|
||||
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
|
||||
&|| false,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, original);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[1].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(rows[3].as_ref().unwrap().uncommitted);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(rows[4].as_ref().unwrap().commit, added);
|
||||
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).unwrap(),
|
||||
"first\nmiddle\nsecond\ntail\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let path = temp.0.join("file");
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
fs::write(&path, "text").unwrap();
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
let (_committed, tracked, _) = fixture();
|
||||
let untracked = tracked.parent().unwrap().join("untracked");
|
||||
fs::write(&untracked, "text").unwrap();
|
||||
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
|
||||
let link = temp.0.join("link");
|
||||
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
let path = temp.0.join("file");
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"describe",
|
||||
"-m",
|
||||
"Jj summary",
|
||||
],
|
||||
);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"new",
|
||||
],
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
||||
let line = rows[0].as_ref().unwrap();
|
||||
assert_eq!(line.commit, parent);
|
||||
assert_eq!(line.author, "Jj Author");
|
||||
assert_eq!(line.summary, "Jj summary");
|
||||
fs::write(&path, "dirty on disk\n").unwrap();
|
||||
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
||||
assert!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.uncommitted
|
||||
);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
assert!(!temp.0.join(".git").exists());
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
||||
let first = "1111111111111111111111111111111111111111";
|
||||
let second = "2222222222222222222222222222222222222222";
|
||||
let output = format!(
|
||||
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
|
||||
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
|
||||
{first} 1 1 1\nfilename old-file\n"
|
||||
);
|
||||
let rows = parse(output.as_bytes(), 4).unwrap();
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, first);
|
||||
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, second);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[2].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_incremental_groups_never_echo_metadata() {
|
||||
let commit = "1111111111111111111111111111111111111111";
|
||||
let metadata = "author secret\nsummary secret\nfilename secret\n";
|
||||
let valid = format!("{commit} 1 1 1\n{metadata}");
|
||||
for output in [
|
||||
"secret metadata".into(),
|
||||
"\u{fffd}secret metadata".into(),
|
||||
format!("{commit} 1 2 1\n{metadata}"),
|
||||
format!("{commit} 1 1 2\n{metadata}"),
|
||||
format!("{commit} 0 1 1\n{metadata}"),
|
||||
format!("{commit} 1 1 0\n{metadata}"),
|
||||
format!("{commit} 1 1 1 extra\n{metadata}"),
|
||||
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
|
||||
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse(output.as_bytes(), 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
parse(valid.as_bytes(), 2).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(b"\xffsecret metadata", 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let ready = temp.0.join("ready");
|
||||
let cancellation = Arc::new(AtomicBool::new(false));
|
||||
let stop = Arc::clone(&cancellation);
|
||||
let gate = ready.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
"sleep 600 & printf ready > \"$1\"; wait",
|
||||
"vamp-blame-test",
|
||||
])
|
||||
.arg(gate);
|
||||
run(command, &[], &|| stop.load(Ordering::Acquire))
|
||||
});
|
||||
while !ready.exists() && !worker.is_finished() {
|
||||
thread::yield_now();
|
||||
}
|
||||
assert!(ready.exists());
|
||||
cancellation.store(true, Ordering::Release);
|
||||
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
||||
}
|
||||
#[test]
|
||||
fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(id, path.clone(), "first\nsecond\n".into())
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
assert_eq!(id, 19);
|
||||
let rows = result.unwrap().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, path, "x".repeat(LIMIT + 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn subprocess_output_is_bounded() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
||||
assert_eq!(
|
||||
run(command, &[], &|| false).unwrap_err(),
|
||||
"Blame output exceeds 16 MiB"
|
||||
);
|
||||
}
|
||||
}
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PopupAnchor {
|
||||
TopLeft,
|
||||
TopCenter,
|
||||
TopRight,
|
||||
CenterLeft,
|
||||
Center,
|
||||
CenterRight,
|
||||
#[default]
|
||||
BottomLeft,
|
||||
BottomCenter,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PopupOptions {
|
||||
pub anchor: PopupAnchor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PopupConfig {
|
||||
pub command: PopupOptions,
|
||||
pub search: PopupOptions,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PopupOptions {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Options {
|
||||
#[serde(default)]
|
||||
anchor: PopupAnchor,
|
||||
}
|
||||
let options: Options = deserialize_object(deserializer)?;
|
||||
Ok(Self {
|
||||
anchor: options.anchor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PopupConfig {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Options {
|
||||
#[serde(default)]
|
||||
command: PopupOptions,
|
||||
#[serde(default)]
|
||||
search: PopupOptions,
|
||||
}
|
||||
let options: Options = deserialize_object(deserializer)?;
|
||||
Ok(Self {
|
||||
command: options.command,
|
||||
search: options.search,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Struct derives also accept sequences; popup configuration accepts only objects.
|
||||
fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<T, D::Error> {
|
||||
struct ObjectVisitor<T>(std::marker::PhantomData<T>);
|
||||
|
||||
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor<T> {
|
||||
type Value = T;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a popup configuration object")
|
||||
}
|
||||
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(self, map: M) -> Result<T, M::Error> {
|
||||
T::deserialize(serde::de::value::MapAccessDeserializer::new(map))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(ObjectVisitor(std::marker::PhantomData))
|
||||
}
|
||||
|
||||
const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"lsp",
|
||||
"position",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ChromeLine {
|
||||
pub left: Vec<String>,
|
||||
pub center: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
pub segments: BTreeMap<String, SegmentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SegmentConfig {
|
||||
pub title: String,
|
||||
pub padding: usize,
|
||||
pub margin: [usize; 2],
|
||||
pub leading_edge: String,
|
||||
pub trailing_edge: String,
|
||||
pub first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SegmentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
padding: 1,
|
||||
margin: [0; 2],
|
||||
leading_edge: String::new(),
|
||||
trailing_edge: String::new(),
|
||||
first_leading_edge: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChromeLine {
|
||||
pub fn top() -> Self {
|
||||
Self {
|
||||
left: vec!["tabs".into()],
|
||||
center: Vec::new(),
|
||||
right: vec!["battery".into()],
|
||||
segments: BTreeMap::from([
|
||||
("tabs".into(), default_segment("tabs")),
|
||||
("battery".into(), default_segment("battery")),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bottom() -> Self {
|
||||
Self {
|
||||
left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 3]
|
||||
.iter()
|
||||
.map(|name| (*name).into())
|
||||
.collect(),
|
||||
center: Vec::new(),
|
||||
right: vec!["language".into(), "lsp".into(), "position".into()],
|
||||
segments: DEFAULT_BOTTOM_SEGMENTS
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let mut segment = default_segment(name);
|
||||
if *name == "position" {
|
||||
segment.margin[0] = 1;
|
||||
}
|
||||
((*name).into(), segment)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles are populated for every referenced name during configuration validation.
|
||||
pub fn segment(&self, name: &str) -> &SegmentConfig {
|
||||
self.segments.get(name).expect("validated chrome segment")
|
||||
}
|
||||
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
for name in self
|
||||
.left
|
||||
.iter()
|
||||
.chain(&self.center)
|
||||
.chain(&self.right)
|
||||
.chain(self.segments.keys())
|
||||
{
|
||||
validate_name(name)?;
|
||||
}
|
||||
for (name, segment) in &self.segments {
|
||||
for (option, value) in [
|
||||
("padding", segment.padding),
|
||||
("leading_margin", segment.margin[0]),
|
||||
("trailing_margin", segment.margin[1]),
|
||||
] {
|
||||
if value > 64 {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} {option} must be between 0 and 64"
|
||||
));
|
||||
}
|
||||
}
|
||||
for (option, text) in [
|
||||
("title", Some(&segment.title)),
|
||||
("leading_edge", Some(&segment.leading_edge)),
|
||||
("trailing_edge", Some(&segment.trailing_edge)),
|
||||
("first_leading_edge", segment.first_leading_edge.as_ref()),
|
||||
] {
|
||||
if text.is_some_and(|text| text.chars().any(char::is_control)) {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} {option} must be single-line text without controls"
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_title(&segment.title)
|
||||
.map_err(|error| format!("Chrome segment {name:?}: {error}"))?;
|
||||
}
|
||||
for name in self.left.iter().chain(&self.center).chain(&self.right) {
|
||||
if !self.segments.contains_key(name) {
|
||||
self.segments.insert(name.clone(), default_segment(name));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_segment(name: &str) -> SegmentConfig {
|
||||
let mut segment = SegmentConfig::default();
|
||||
if matches!(name, "tabs" | "position" | "battery") {
|
||||
segment.leading_edge = "░▓".into();
|
||||
}
|
||||
if matches!(name, "tabs" | "mode") {
|
||||
segment.trailing_edge = "▓░".into();
|
||||
}
|
||||
if name == "tabs" {
|
||||
segment.title = "{index} {view}{name}{modified}".into();
|
||||
segment.first_leading_edge = Some(String::new());
|
||||
}
|
||||
segment
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||
let mut bytes = name.bytes();
|
||||
if !bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(format!(
|
||||
"Label/segment name {name:?} must match [A-Za-z_][A-Za-z0-9_]*"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_title(title: &str) -> Result<(), String> {
|
||||
let mut rest = title;
|
||||
while let Some(index) = rest.find(['{', '}']) {
|
||||
rest = &rest[index..];
|
||||
if !rest.starts_with('{') {
|
||||
return Err("title contains an unmatched '}'".into());
|
||||
}
|
||||
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
|
||||
let placeholder = &rest[1..end];
|
||||
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") {
|
||||
return Err(format!(
|
||||
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified"
|
||||
));
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LineOptions {
|
||||
left: Option<Vec<String>>,
|
||||
center: Option<Vec<String>>,
|
||||
right: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
segments: BTreeMap<String, SegmentOptions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SegmentOptions {
|
||||
title: Option<String>,
|
||||
padding: Option<usize>,
|
||||
margin: Option<usize>,
|
||||
leading_margin: Option<usize>,
|
||||
trailing_margin: Option<usize>,
|
||||
leading_edge: Option<String>,
|
||||
trailing_edge: Option<String>,
|
||||
first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_line<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
mut line: ChromeLine,
|
||||
) -> Result<ChromeLine, D::Error> {
|
||||
// Decode through a value so explicit null is rejected rather than treated as an omitted option.
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| serde::de::Error::custom("chrome line must be an object"))?;
|
||||
for (name, value) in object {
|
||||
if value.is_null()
|
||||
|| value
|
||||
.as_object()
|
||||
.is_some_and(|options| options.values().any(serde_json::Value::is_null))
|
||||
{
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"Chrome option {name:?} must not be null"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let options: LineOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||
if let Some(left) = options.left {
|
||||
line.left = left;
|
||||
}
|
||||
if let Some(center) = options.center {
|
||||
line.center = center;
|
||||
}
|
||||
if let Some(right) = options.right {
|
||||
line.right = right;
|
||||
}
|
||||
for (name, options) in options.segments {
|
||||
let segment = line
|
||||
.segments
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| default_segment(&name));
|
||||
if let Some(title) = options.title {
|
||||
segment.title = title;
|
||||
}
|
||||
if let Some(padding) = options.padding {
|
||||
segment.padding = padding;
|
||||
}
|
||||
if let Some(margin) = options.margin {
|
||||
segment.margin = [margin; 2];
|
||||
}
|
||||
if let Some(margin) = options.leading_margin {
|
||||
segment.margin[0] = margin;
|
||||
}
|
||||
if let Some(margin) = options.trailing_margin {
|
||||
segment.margin[1] = margin;
|
||||
}
|
||||
if let Some(edge) = options.leading_edge {
|
||||
segment.leading_edge = edge;
|
||||
}
|
||||
if let Some(edge) = options.trailing_edge {
|
||||
segment.trailing_edge = edge;
|
||||
}
|
||||
if let Some(edge) = options.first_leading_edge {
|
||||
segment.first_leading_edge = Some(edge);
|
||||
}
|
||||
}
|
||||
line.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
pub fn top<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, ChromeLine::top())
|
||||
}
|
||||
|
||||
pub fn bottom<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, ChromeLine::bottom())
|
||||
}
|
||||
|
||||
pub fn labels<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<String, String>, D::Error> {
|
||||
let labels = BTreeMap::<String, String>::deserialize(deserializer)?;
|
||||
for name in labels.keys() {
|
||||
validate_name(name).map_err(serde::de::Error::custom)?;
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn popup_partial_options_keep_command_and_search_independent() {
|
||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||
"command": {"anchor": "top_right"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
|
||||
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
|
||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||
"command": {}, "search": {"anchor": "center_left"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
|
||||
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_popup_options_are_rejected_at_the_boundary() {
|
||||
for value in [
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!(7),
|
||||
serde_json::json!({"selector": {}}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
for options in [
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!("center"),
|
||||
serde_json::json!({"anchor": null}),
|
||||
serde_json::json!({"anchor": 7}),
|
||||
serde_json::json!({"anchor": true}),
|
||||
serde_json::json!({"anchor": []}),
|
||||
serde_json::json!({"anchor": {}}),
|
||||
serde_json::json!({"anchor": "middle"}),
|
||||
serde_json::json!({"anchor": "TopLeft"}),
|
||||
serde_json::json!({"width": 72}),
|
||||
serde_json::json!({"anchor": "center", "callback": "run"}),
|
||||
] {
|
||||
for mode in ["command", "search"] {
|
||||
let mut popup = serde_json::json!({});
|
||||
popup[mode] = options.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<PopupConfig>(popup).is_err(),
|
||||
"{mode}: {options}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Lines {
|
||||
#[serde(deserialize_with = "top")]
|
||||
top: ChromeLine,
|
||||
#[serde(deserialize_with = "bottom")]
|
||||
bottom: ChromeLine,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||
"bottom": {"left": [], "center": ["workspace"]}
|
||||
})).unwrap();
|
||||
assert_eq!(lines.top.left, ["tabs"]);
|
||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||
assert!(lines.bottom.left.is_empty());
|
||||
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
||||
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_positions_accept_all_builtins_and_custom_styles() {
|
||||
let names = [
|
||||
"tabs",
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"lsp",
|
||||
"position",
|
||||
"battery",
|
||||
"workspace",
|
||||
];
|
||||
let options = serde_json::json!({
|
||||
"left": names,
|
||||
"center": ["language"],
|
||||
"right": ["tabs"],
|
||||
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
|
||||
"mode": {"leading_edge": "<", "trailing_edge": ">"},
|
||||
"workspace": {"first_leading_edge": ""}
|
||||
});
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": options,
|
||||
"bottom": options,
|
||||
}))
|
||||
.unwrap();
|
||||
for line in [&lines.top, &lines.bottom] {
|
||||
assert_eq!(line.left, names);
|
||||
assert_eq!(line.center, ["language"]);
|
||||
assert_eq!(line.right, ["tabs"]);
|
||||
assert_eq!(line.segment("tabs").padding, 0);
|
||||
assert_eq!(line.segment("mode").leading_edge, "<");
|
||||
assert_eq!(
|
||||
line.segment("workspace").first_leading_edge.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_placeholder_is_valid_in_every_segment_and_bar() {
|
||||
for name in [
|
||||
"tabs",
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"lsp",
|
||||
"position",
|
||||
"battery",
|
||||
"workspace",
|
||||
] {
|
||||
let options = serde_json::json!({
|
||||
"left": [name],
|
||||
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
|
||||
});
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": options,
|
||||
"bottom": options,
|
||||
}))
|
||||
.unwrap();
|
||||
for line in [&lines.top, &lines.bottom] {
|
||||
assert_eq!(
|
||||
line.segment(name).title,
|
||||
"{index}:{view}{name}:{path}{modified}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||
for options in [
|
||||
serde_json::json!({"left": ["bad-name"]}),
|
||||
serde_json::json!({"left": null}),
|
||||
serde_json::json!({"tabs": {"padding": 65}}),
|
||||
serde_json::json!({"tabs": {"padding": null}}),
|
||||
serde_json::json!({"tabs": {"margin": 65}}),
|
||||
serde_json::json!({"tabs": {"margin": -1}}),
|
||||
serde_json::json!({"tabs": {"margin": null}}),
|
||||
serde_json::json!({"tabs": {"margin": "1"}}),
|
||||
serde_json::json!({"tabs": {"margin": 1.5}}),
|
||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||
] {
|
||||
for position in ["top", "bottom"] {
|
||||
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
|
||||
frame[position] = options.clone();
|
||||
assert!(serde_json::from_value::<Lines>(frame).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-36
@@ -15,20 +15,6 @@ function run() {
|
||||
}
|
||||
"#;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MAC_WRITE: &str = r#"
|
||||
ObjC.import('AppKit');
|
||||
function run() {
|
||||
var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
|
||||
var text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
|
||||
if (text.isNil()) throw Error('Clipboard input is not UTF-8');
|
||||
var pasteboard = $.NSPasteboard.generalPasteboard;
|
||||
pasteboard.clearContents;
|
||||
if (!pasteboard.setStringForType(text, $.NSPasteboardTypeString))
|
||||
throw Error('Cannot write plain-text clipboard');
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClipboardMode {
|
||||
System,
|
||||
@@ -42,7 +28,7 @@ struct ClipboardCommand {
|
||||
|
||||
struct Commands {
|
||||
reader: ClipboardCommand,
|
||||
writer: ClipboardCommand,
|
||||
writer: Option<ClipboardCommand>,
|
||||
}
|
||||
|
||||
pub struct SystemClipboard {
|
||||
@@ -72,10 +58,10 @@ impl SystemClipboard {
|
||||
program: reader,
|
||||
args: &[],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program: writer,
|
||||
args: &[],
|
||||
},
|
||||
}),
|
||||
})),
|
||||
timeout: COMMAND_TIMEOUT,
|
||||
}
|
||||
@@ -95,8 +81,13 @@ impl SystemClipboard {
|
||||
}
|
||||
let timeout = self.timeout;
|
||||
let commands = self.commands.get_or_insert_with(select_commands);
|
||||
let command = &commands.as_ref().map_err(Clone::clone)?.writer;
|
||||
run(command, Some(text.as_bytes()), timeout).map(|_| ())
|
||||
match &commands.as_ref().map_err(Clone::clone)?.writer {
|
||||
Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()),
|
||||
#[cfg(target_os = "macos")]
|
||||
None => macos::write(text),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
None => Err("System clipboard is unsupported on this platform".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,19 +108,13 @@ fn executable(name: &str) -> Option<PathBuf> {
|
||||
fn select_commands() -> Result<Commands, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(program) = executable("osascript") {
|
||||
return Ok(Commands {
|
||||
reader: ClipboardCommand {
|
||||
program: program.clone(),
|
||||
args: &["-l", "JavaScript", "-e", MAC_READ],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
program,
|
||||
args: &["-l", "JavaScript", "-e", MAC_WRITE],
|
||||
},
|
||||
});
|
||||
}
|
||||
Err("System clipboard requires osascript with JXA/AppKit on PATH".into())
|
||||
Ok(Commands {
|
||||
reader: ClipboardCommand {
|
||||
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
|
||||
args: &["-l", "JavaScript", "-e", MAC_READ],
|
||||
},
|
||||
writer: None,
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -140,10 +125,10 @@ fn select_commands() -> Result<Commands, String> {
|
||||
program: reader,
|
||||
args: &["--no-newline", "--type", "text"],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program: writer,
|
||||
args: &["--type", "text/plain;charset=utf-8"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,10 +139,10 @@ fn select_commands() -> Result<Commands, String> {
|
||||
program: program.clone(),
|
||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program,
|
||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,6 +154,173 @@ fn select_commands() -> Result<Commands, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::ffi::{c_char, c_void};
|
||||
use std::ptr;
|
||||
|
||||
type Object = *const c_void;
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFStringCreateWithCString(
|
||||
allocator: Object,
|
||||
bytes: *const c_char,
|
||||
encoding: u32,
|
||||
) -> Object;
|
||||
fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object;
|
||||
fn CFRelease(object: Object);
|
||||
}
|
||||
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
static kUTTypeUTF8PlainText: Object;
|
||||
}
|
||||
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32;
|
||||
fn PasteboardClear(pasteboard: Object) -> i32;
|
||||
fn PasteboardPutItemFlavor(
|
||||
pasteboard: Object,
|
||||
item: usize,
|
||||
flavor: Object,
|
||||
data: Object,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
struct Owned(Object);
|
||||
impl Owned {
|
||||
fn new(object: Object) -> Result<Self, String> {
|
||||
if object.is_null() {
|
||||
Err("Cannot allocate system clipboard data".into())
|
||||
} else {
|
||||
Ok(Self(object))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Owned {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: each handle owns one Core Foundation reference.
|
||||
unsafe { CFRelease(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn check(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("System clipboard publication failed ({status})"))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to(name: Object, text: &str) -> Result<(), String> {
|
||||
// SAFETY: framework constants and byte slices live throughout the calls.
|
||||
unsafe {
|
||||
let data = Owned::new(CFDataCreate(
|
||||
ptr::null(),
|
||||
text.as_ptr(),
|
||||
text.len() as isize,
|
||||
))?;
|
||||
let mut board = ptr::null();
|
||||
check(PasteboardCreate(name, &mut board))?;
|
||||
let board = Owned::new(board)?;
|
||||
// Prepare all allocations before changing clipboard contents.
|
||||
check(PasteboardClear(board.0))?;
|
||||
check(PasteboardPutItemFlavor(
|
||||
board.0,
|
||||
1,
|
||||
kUTTypeUTF8PlainText,
|
||||
data.0,
|
||||
0,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write(text: &str) -> Result<(), String> {
|
||||
// SAFETY: the clipboard name is a process-lifetime, null-terminated literal.
|
||||
let name = unsafe {
|
||||
Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"com.apple.pasteboard.clipboard".as_ptr(),
|
||||
0x0800_0100,
|
||||
))?
|
||||
};
|
||||
write_to(name.0, text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
unsafe extern "C" {
|
||||
fn PasteboardSynchronize(pasteboard: Object) -> u32;
|
||||
fn PasteboardGetItemIdentifier(
|
||||
pasteboard: Object,
|
||||
index: isize,
|
||||
item: *mut usize,
|
||||
) -> i32;
|
||||
fn PasteboardCopyItemFlavorData(
|
||||
pasteboard: Object,
|
||||
item: usize,
|
||||
flavor: Object,
|
||||
data: *mut Object,
|
||||
) -> i32;
|
||||
fn CFDataGetBytePtr(data: Object) -> *const u8;
|
||||
fn CFDataGetLength(data: Object) -> isize;
|
||||
}
|
||||
#[test]
|
||||
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
||||
// A private pasteboard leaves the user's clipboard untouched.
|
||||
let name = unsafe {
|
||||
Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"vamp-native-clipboard-regression".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let board = unsafe {
|
||||
let mut board = ptr::null();
|
||||
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
||||
Owned::new(board).unwrap()
|
||||
};
|
||||
for text in [
|
||||
"",
|
||||
"é e\u{301} 👨💻\n\0tail",
|
||||
"{\\rtf1 plain text}",
|
||||
"%!PS-Adobe-3.0\nplain",
|
||||
] {
|
||||
write_to(name.0, text).unwrap();
|
||||
unsafe {
|
||||
PasteboardSynchronize(board.0);
|
||||
let mut item = 0;
|
||||
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
|
||||
let flavor = Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"public.utf8-plain-text".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap();
|
||||
let mut data = ptr::null();
|
||||
check(PasteboardCopyItemFlavorData(
|
||||
board.0, item, flavor.0, &mut data,
|
||||
))
|
||||
.unwrap();
|
||||
let data = Owned::new(data).unwrap();
|
||||
let length = CFDataGetLength(data.0) as usize;
|
||||
let bytes = if length == 0 {
|
||||
&[]
|
||||
} else {
|
||||
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
|
||||
};
|
||||
assert_eq!(bytes, text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result<Vec<u8>, String> {
|
||||
Err("System clipboard is unsupported on this platform".into())
|
||||
@@ -514,6 +666,8 @@ mod tests {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.writer
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.program = fixture.command(
|
||||
"noisy",
|
||||
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
|
||||
|
||||
+4079
File diff suppressed because it is too large
Load Diff
+15089
-896
File diff suppressed because it is too large
Load Diff
+656
-65
@@ -1,9 +1,10 @@
|
||||
use crate::diff::FileStatus;
|
||||
use crate::repository::WorkingFileChange;
|
||||
use ignore::WalkBuilder;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FilePresentation {
|
||||
@@ -38,20 +39,30 @@ pub struct FileRow<'a> {
|
||||
/// The actual project-relative OS path, never reconstructed from the label.
|
||||
pub path: &'a Path,
|
||||
pub label: &'a str,
|
||||
/// Original virtual-catalog input index; absent for scanned entries and directories.
|
||||
pub catalog_index: Option<usize>,
|
||||
pub status: Option<FileStatus>,
|
||||
pub old_path: Option<&'a Path>,
|
||||
pub is_dir: bool,
|
||||
pub depth: usize,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Node {
|
||||
path: PathBuf,
|
||||
label: String,
|
||||
search_label: String,
|
||||
catalog_index: Option<usize>,
|
||||
is_dir: bool,
|
||||
status: Option<FileStatus>,
|
||||
old_path: Option<PathBuf>,
|
||||
virtual_entry: bool,
|
||||
parent: Option<usize>,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TreeNode {
|
||||
node: usize,
|
||||
label: String,
|
||||
@@ -59,6 +70,7 @@ struct TreeNode {
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct VisibleRow {
|
||||
node: usize,
|
||||
tree: Option<usize>,
|
||||
@@ -66,6 +78,7 @@ struct VisibleRow {
|
||||
expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FilePicker {
|
||||
pub root: PathBuf,
|
||||
pub query: String,
|
||||
@@ -80,12 +93,16 @@ pub struct FilePicker {
|
||||
tree_for_node: Vec<usize>,
|
||||
tree_roots: Vec<usize>,
|
||||
rows: Vec<VisibleRow>,
|
||||
viewport_revision: u64,
|
||||
expanded: HashSet<usize>,
|
||||
filter_collapsed: HashSet<usize>,
|
||||
saved_selection: [Option<usize>; 2],
|
||||
filtered_selection: [Option<usize>; 2],
|
||||
tree_searching: bool,
|
||||
warning: Option<String>,
|
||||
repository_changes: Vec<WorkingFileChange>,
|
||||
has_repository_changes: bool,
|
||||
has_virtual_entries: bool,
|
||||
}
|
||||
|
||||
fn label(path: &Path) -> String {
|
||||
@@ -95,6 +112,28 @@ fn label(path: &Path) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn safe_repository_path(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& path.components().all(|component| {
|
||||
matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj")
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_repository_path(root: &Path, path: &Path) -> bool {
|
||||
// A deleted leaf may be absent with all its parents, but never walk through symlinks.
|
||||
let mut current = root.to_owned();
|
||||
for component in path.components() {
|
||||
current.push(component);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.is_dir() => {}
|
||||
Ok(_) => return false,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return true,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
|
||||
if !fs::metadata(&root)?.is_dir() {
|
||||
@@ -149,15 +188,64 @@ impl FilePicker {
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index: None,
|
||||
status: None,
|
||||
old_path: None,
|
||||
virtual_entry: false,
|
||||
is_dir: entry.file_type().unwrap().is_dir(),
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
|
||||
}
|
||||
|
||||
/// Builds a changed-path catalog, retaining each path's comparison index.
|
||||
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = (usize, PathBuf)>) -> Self {
|
||||
let mut catalog = HashMap::new();
|
||||
for (index, path) in paths {
|
||||
for parent in path
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
{
|
||||
catalog.insert((parent.to_owned(), true), None);
|
||||
}
|
||||
catalog.entry((path, false)).or_insert(Some(index));
|
||||
}
|
||||
let nodes = catalog
|
||||
.into_iter()
|
||||
.map(|((path, is_dir), catalog_index)| {
|
||||
let label = label(&path);
|
||||
Node {
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index,
|
||||
status: None,
|
||||
old_path: None,
|
||||
virtual_entry: false,
|
||||
is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None)
|
||||
}
|
||||
|
||||
fn from_nodes(
|
||||
root: PathBuf,
|
||||
mut nodes: Vec<Node>,
|
||||
presentation: FilePresentation,
|
||||
view: FileView,
|
||||
warning: Option<String>,
|
||||
) -> Self {
|
||||
nodes.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let by_path: HashMap<_, _> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, node)| node.is_dir)
|
||||
.map(|(index, node)| (node.path.clone(), index))
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
@@ -206,16 +294,20 @@ impl FilePicker {
|
||||
focused: true,
|
||||
error: warning.clone(),
|
||||
tree_for_node: vec![0; nodes.len()],
|
||||
has_virtual_entries: nodes.iter().any(|node| node.virtual_entry),
|
||||
nodes,
|
||||
tree: Vec::new(),
|
||||
tree_roots: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
viewport_revision: 0,
|
||||
expanded: HashSet::new(),
|
||||
filter_collapsed: HashSet::new(),
|
||||
saved_selection: [None, None],
|
||||
filtered_selection: [None, None],
|
||||
tree_searching: false,
|
||||
warning,
|
||||
repository_changes: Vec::new(),
|
||||
has_repository_changes: false,
|
||||
};
|
||||
for root in roots {
|
||||
let tree = picker.compact(root, None);
|
||||
@@ -223,7 +315,7 @@ impl FilePicker {
|
||||
}
|
||||
picker.project(None, false);
|
||||
picker.remember_selection(false);
|
||||
Ok(picker)
|
||||
picker
|
||||
}
|
||||
|
||||
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
|
||||
@@ -256,6 +348,10 @@ impl FilePicker {
|
||||
tree
|
||||
}
|
||||
|
||||
pub(crate) fn viewport_revision(&self) -> u64 {
|
||||
self.viewport_revision
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
|
||||
self.rows.iter().map(|row| {
|
||||
let node = &self.nodes[row.node];
|
||||
@@ -265,6 +361,9 @@ impl FilePicker {
|
||||
.tree
|
||||
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
|
||||
is_dir: node.is_dir,
|
||||
catalog_index: node.catalog_index,
|
||||
status: node.status,
|
||||
old_path: node.old_path.as_deref(),
|
||||
depth: row.depth,
|
||||
expanded: row.expanded,
|
||||
}
|
||||
@@ -321,6 +420,47 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_path(&mut self, path: &Path) {
|
||||
if let Some(node) = self
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|node| node.path == path && !node.is_dir)
|
||||
{
|
||||
self.reveal(node);
|
||||
self.project(Some(node), false);
|
||||
self.remember_selection(true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
match input {
|
||||
Input::Ctrl('u') => self.clear_filter(),
|
||||
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 => {
|
||||
self.start_search()
|
||||
}
|
||||
Input::Char(ch)
|
||||
if !ch.is_control() && (self.view == FileView::List || self.searching) =>
|
||||
{
|
||||
self.query.push(*ch);
|
||||
self.refilter();
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_view(&mut self, view: FileView) {
|
||||
if self.view == view {
|
||||
return;
|
||||
@@ -408,6 +548,8 @@ impl FilePicker {
|
||||
}
|
||||
|
||||
fn project(&mut self, selected: Option<usize>, select_match: bool) {
|
||||
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
||||
@@ -522,13 +664,6 @@ impl FilePicker {
|
||||
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) {
|
||||
let count = self.rows.len();
|
||||
self.selected = if count == 0 {
|
||||
@@ -638,67 +773,271 @@ impl FilePicker {
|
||||
pub(crate) fn refresh(&mut self) {
|
||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||
Ok(mut replacement) => {
|
||||
let by_path: HashMap<_, _> = replacement
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| (node.path.clone(), index))
|
||||
.collect();
|
||||
let surviving = |node: usize| {
|
||||
let mut path = self.nodes[node].path.as_path();
|
||||
loop {
|
||||
if let Some(&node) = by_path.get(path) {
|
||||
return Some(node);
|
||||
}
|
||||
path = path.parent()?;
|
||||
}
|
||||
};
|
||||
replacement.expanded = self
|
||||
.expanded
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.filter_collapsed = self
|
||||
.filter_collapsed
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.saved_selection =
|
||||
self.saved_selection.map(|node| node.and_then(surviving));
|
||||
replacement.filtered_selection =
|
||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.view = self.view;
|
||||
replacement.searching = self.searching;
|
||||
replacement.tree_searching = self.tree_searching;
|
||||
let selected = self
|
||||
.selected_node()
|
||||
.or(self.filtered_selection[self.view.index()])
|
||||
.or(self.saved_selection[self.view.index()])
|
||||
.and_then(surviving);
|
||||
replacement.project(selected, false);
|
||||
replacement.remember_selection(false);
|
||||
*self = replacement;
|
||||
replacement.set_repository_changes(self.repository_changes.clone());
|
||||
self.replace_catalog(replacement);
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_repository_changes(&self) -> bool {
|
||||
self.has_repository_changes
|
||||
}
|
||||
|
||||
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
|
||||
if !self.has_virtual_entries && changes == self.repository_changes {
|
||||
return false;
|
||||
}
|
||||
if self.nodes.iter().any(|node| {
|
||||
node.virtual_entry
|
||||
&& !node.is_dir
|
||||
&& fs::symlink_metadata(self.root.join(&node.path))
|
||||
.is_ok_and(|metadata| metadata.is_file())
|
||||
}) {
|
||||
// Reuse the scanner's ignore/symlink rules when a deleted file returns.
|
||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||
Ok(mut replacement) => {
|
||||
replacement.set_repository_changes(changes);
|
||||
replacement.error = self.error.clone().or(replacement.error);
|
||||
self.replace_catalog(replacement);
|
||||
return true;
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = changes
|
||||
.into_iter()
|
||||
.filter(|change| safe_repository_path(&change.path))
|
||||
.map(|mut change| {
|
||||
change.old_path = change.old_path.filter(|path| safe_repository_path(path));
|
||||
change
|
||||
})
|
||||
.collect();
|
||||
let by_path: HashMap<_, _> = changes
|
||||
.iter()
|
||||
.map(|change| (change.path.as_path(), change))
|
||||
.collect();
|
||||
let scanned: HashSet<_> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.virtual_entry)
|
||||
.map(|node| (node.path.as_path(), node.is_dir))
|
||||
.collect();
|
||||
let mut virtual_files = HashSet::new();
|
||||
let mut virtual_dirs = HashSet::new();
|
||||
for change in &changes {
|
||||
if change.status != FileStatus::Removed
|
||||
|| scanned.contains(&(change.path.as_path(), false))
|
||||
|| !missing_repository_path(&self.root, &change.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
virtual_files.insert(change.path.clone());
|
||||
for parent in change.path.ancestors().skip(1) {
|
||||
if parent.as_os_str().is_empty() {
|
||||
break;
|
||||
}
|
||||
if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) {
|
||||
virtual_dirs.insert(parent.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count()
|
||||
!= virtual_files.len() + virtual_dirs.len()
|
||||
|| self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.virtual_entry)
|
||||
.any(|node| {
|
||||
!(if node.is_dir {
|
||||
&virtual_dirs
|
||||
} else {
|
||||
&virtual_files
|
||||
})
|
||||
.contains(node.path.as_path())
|
||||
});
|
||||
let mut metadata_changed = false;
|
||||
for node in &mut self.nodes {
|
||||
let change = (!node.is_dir && node.catalog_index.is_none())
|
||||
.then(|| by_path.get(node.path.as_path()))
|
||||
.flatten();
|
||||
let status = change.map(|change| change.status);
|
||||
let old_path = change.and_then(|change| change.old_path.as_ref());
|
||||
metadata_changed |= node.status != status || node.old_path.as_ref() != old_path;
|
||||
node.status = status;
|
||||
if node.old_path.as_ref() != old_path {
|
||||
node.old_path = old_path.cloned();
|
||||
}
|
||||
}
|
||||
if topology_changed {
|
||||
let mut nodes: Vec<_> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.virtual_entry)
|
||||
.map(|node| Node {
|
||||
path: node.path.clone(),
|
||||
label: node.label.clone(),
|
||||
search_label: node.search_label.clone(),
|
||||
catalog_index: node.catalog_index,
|
||||
status: node.status,
|
||||
old_path: node.old_path.clone(),
|
||||
virtual_entry: false,
|
||||
is_dir: node.is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
nodes.extend(
|
||||
virtual_files
|
||||
.into_iter()
|
||||
.map(|path| (path, false))
|
||||
.chain(virtual_dirs.into_iter().map(|path| (path, true)))
|
||||
.map(|(path, is_dir)| {
|
||||
let label = label(&path);
|
||||
let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten();
|
||||
Node {
|
||||
status: change.map(|change| change.status),
|
||||
old_path: change.and_then(|change| change.old_path.clone()),
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index: None,
|
||||
virtual_entry: true,
|
||||
is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}),
|
||||
);
|
||||
let mut replacement = Self::from_nodes(
|
||||
self.root.clone(),
|
||||
nodes,
|
||||
self.presentation,
|
||||
self.view,
|
||||
self.warning.clone(),
|
||||
);
|
||||
replacement.error = self.error.clone();
|
||||
self.replace_catalog(replacement);
|
||||
}
|
||||
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
|
||||
self.repository_changes = changes;
|
||||
topology_changed || metadata_changed
|
||||
}
|
||||
|
||||
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
|
||||
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
|
||||
}
|
||||
|
||||
fn replace_catalog(&mut self, mut replacement: Self) {
|
||||
replacement.presentation = self.presentation;
|
||||
let by_path: HashMap<_, _> = replacement
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| (node.path.clone(), index))
|
||||
.collect();
|
||||
let surviving = |node: usize| {
|
||||
let mut path = self.nodes[node].path.as_path();
|
||||
loop {
|
||||
if let Some(&node) = by_path.get(path) {
|
||||
return Some(node);
|
||||
}
|
||||
path = path.parent()?;
|
||||
}
|
||||
};
|
||||
replacement.expanded = self
|
||||
.expanded
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.filter_collapsed = self
|
||||
.filter_collapsed
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.saved_selection = self.saved_selection.map(|node| node.and_then(surviving));
|
||||
replacement.filtered_selection =
|
||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.view = self.view;
|
||||
replacement.searching = self.searching;
|
||||
replacement.tree_searching = self.tree_searching;
|
||||
let selected = self
|
||||
.selected_node()
|
||||
.or(self.filtered_selection[self.view.index()])
|
||||
.or(self.saved_selection[self.view.index()])
|
||||
.and_then(surviving);
|
||||
replacement.project(selected, false);
|
||||
replacement.remember_selection(false);
|
||||
*self = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||
let mut picker = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.enumerate(),
|
||||
);
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.map(|row| row.label.to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
["src/deep", "x", "x"]
|
||||
);
|
||||
picker.reveal_path(Path::new("x"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
||||
picker.reveal_path(Path::new("x/y"));
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
||||
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
||||
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
||||
let expected_index = |row: &FileRow<'_>| {
|
||||
if row.is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(match row.path.to_str().unwrap() {
|
||||
"x" => 0,
|
||||
"x/y" => 1,
|
||||
"src/deep/deleted.rs" => 2,
|
||||
path => panic!("unexpected path {path}"),
|
||||
})
|
||||
}
|
||||
};
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "x", "deleted"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert_eq!(row.catalog_index, expected_index(&row));
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -732,6 +1071,222 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||
let directory = Directory::new();
|
||||
directory.put("nested/file.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
picker.reveal_path(Path::new("nested/file.txt"));
|
||||
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_change(
|
||||
path: &str,
|
||||
status: FileStatus,
|
||||
old_path: Option<&str>,
|
||||
) -> WorkingFileChange {
|
||||
WorkingFileChange {
|
||||
path: path.into(),
|
||||
status,
|
||||
old_path: old_path.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
||||
let directory = Directory::new();
|
||||
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
||||
directory.put(name, "");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.presentation = FilePresentation::Docked;
|
||||
picker.set_view(FileView::Tree);
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
picker.query = "src".into();
|
||||
picker.refilter();
|
||||
picker.focused = false;
|
||||
let changes = vec![
|
||||
repository_change("src/modified.rs", FileStatus::Modified, None),
|
||||
repository_change("added.rs", FileStatus::Added, None),
|
||||
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
||||
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
||||
];
|
||||
assert!(picker.set_repository_changes(changes.clone()));
|
||||
assert!(!picker.set_repository_changes(changes));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert_eq!(picker.query, "src");
|
||||
assert!(!picker.focused);
|
||||
assert_eq!(picker.presentation, FilePresentation::Docked);
|
||||
assert!(picker.has_repository_changes());
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert!(row.catalog_index.is_none());
|
||||
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
} else {
|
||||
let status = match row.path.to_str().unwrap() {
|
||||
"src/modified.rs" => FileStatus::Modified,
|
||||
"added.rs" => FileStatus::Added,
|
||||
"renamed.rs" => FileStatus::Renamed,
|
||||
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
||||
other => panic!("unexpected {other}"),
|
||||
};
|
||||
assert_eq!(row.status, Some(status));
|
||||
assert_eq!(
|
||||
row.old_path,
|
||||
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
if query.is_empty() || query == "src" {
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|row| row.path == Path::new("src/clean.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
picker.set_view(FileView::List);
|
||||
picker.clear_filter();
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert!(!picker.has_repository_changes());
|
||||
assert_eq!(picker.entries().len(), 4);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
assert!(!picker.set_repository_changes(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
||||
let directory = Directory::new();
|
||||
directory.put("keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let deleted = Path::new("lost/deep/deleted.txt");
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.set_view(FileView::Tree);
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| row.is_dir).unwrap().label,
|
||||
"lost/deep"
|
||||
);
|
||||
picker.reveal_path(deleted);
|
||||
picker.query = "deleted".into();
|
||||
picker.refilter();
|
||||
picker.start_search();
|
||||
picker.focused = false;
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.searching && !picker.focused);
|
||||
assert_eq!(picker.query, "deleted");
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert!(!directory.0.join(deleted).exists());
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
directory.put("lost/deep/deleted.txt", "restored");
|
||||
assert!(picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]));
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
||||
"restored"
|
||||
);
|
||||
assert!(picker.entries().all(|row| row.status.is_none()));
|
||||
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
||||
picker.refresh();
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.reveal_path(deleted);
|
||||
picker.set_repository_changes(Vec::new());
|
||||
assert!(picker.entries().len() == 0);
|
||||
picker.clear_filter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "ignored/\n");
|
||||
directory.put("ignored/hidden.txt", "");
|
||||
directory.put("directory/keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let mut changes: Vec<_> = [
|
||||
"",
|
||||
".",
|
||||
"../outside.txt",
|
||||
"directory/../../outside.txt",
|
||||
".git/secret",
|
||||
"nested/.jj/secret",
|
||||
"/outside.txt",
|
||||
"directory",
|
||||
"ignored/hidden.txt",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|path| repository_change(path, FileStatus::Removed, None))
|
||||
.collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
||||
changes.push(repository_change(
|
||||
"link/missing.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
));
|
||||
}
|
||||
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
||||
picker.set_repository_changes(changes);
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().filter(|row| row.status.is_some()).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().last().unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
assert!(!directory.0.join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
@@ -782,11 +1337,11 @@ mod tests {
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||
picker.query = "e\u{301}👩💻".into();
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "e\u{301}");
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "");
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
picker.query = "no match".into();
|
||||
picker.refilter();
|
||||
@@ -797,12 +1352,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]
|
||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
directory.put("new.txt", "text");
|
||||
picker.query = "new".into();
|
||||
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
//! Read-only source-line changes against Git HEAD or a single Jujutsu @- parent.
|
||||
use crate::{
|
||||
blame::{git, git_without_literal_pathspecs, run, run_status},
|
||||
diff::line_hunks,
|
||||
editor::normalize_newlines,
|
||||
repository::{
|
||||
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
const FILE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
|
||||
const FILE_COUNT_LIMIT: usize = 1024;
|
||||
type Files = Vec<(usize, PathBuf, Arc<str>)>;
|
||||
type FileResult = Result<Vec<Option<Change>>, String>;
|
||||
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
|
||||
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
|
||||
type Request = (u64, u64, Files, Option<PickerRequest>);
|
||||
|
||||
pub(crate) struct PickerChanges {
|
||||
pub(crate) root: PathBuf,
|
||||
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GutterStyle {
|
||||
#[default]
|
||||
Bar,
|
||||
Symbols,
|
||||
LineNumbers,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Change {
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
wake: Condvar,
|
||||
generation: AtomicU64,
|
||||
stopped: AtomicBool,
|
||||
}
|
||||
pub(crate) struct Worker {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl Worker {
|
||||
pub(crate) fn start() -> io::Result<Self> {
|
||||
let shared = Arc::new(Shared::default());
|
||||
let worker = Arc::clone(&shared);
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-gutter".into())
|
||||
.spawn(move || {
|
||||
let mut picker_reader = WorkingFileReader::default();
|
||||
loop {
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
||||
let Ok(next) = worker.wake.wait(state) else {
|
||||
return;
|
||||
};
|
||||
state = next;
|
||||
}
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (generation, id, files, picker) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
|| worker.generation.load(Ordering::Acquire) != generation
|
||||
};
|
||||
let mut results = Vec::with_capacity(files.len());
|
||||
for (buffer, path, contents) in files {
|
||||
if cancelled() {
|
||||
break;
|
||||
}
|
||||
results.push((buffer, read_file(&path, &contents, &cancelled)));
|
||||
}
|
||||
let picker = picker.map(|(root, buffers)| PickerChanges {
|
||||
result: picker_reader.read(&root, &buffers, &cancelled),
|
||||
root,
|
||||
});
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if !cancelled() {
|
||||
state.result = Some((id, results, picker));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(
|
||||
&mut self,
|
||||
id: u64,
|
||||
files: Files,
|
||||
picker: Option<PickerRequest>,
|
||||
) -> io::Result<()> {
|
||||
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
});
|
||||
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
|
||||
buffers
|
||||
.iter()
|
||||
.try_fold(root.as_os_str().len(), |size, (path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
})
|
||||
});
|
||||
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
|
||||
if count > FILE_COUNT_LIMIT
|
||||
|| size
|
||||
.zip(picker_size)
|
||||
.and_then(|(files, picker)| files.checked_add(picker))
|
||||
.is_none_or(|size| size > BATCH_LIMIT)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Gutter batch exceeds 64 MiB or 1024 files",
|
||||
));
|
||||
}
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("Gutter worker stopped"))?;
|
||||
let generation = self
|
||||
.shared
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, files, picker));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
|
||||
self.shared.state.lock().ok()?.result.take()
|
||||
}
|
||||
}
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let state = self.shared.state.lock();
|
||||
self.shared.stopped.store(true, Ordering::Release);
|
||||
self.shared.wake.notify_one();
|
||||
drop(state);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
|
||||
run(command, &[], cancelled)?.ok_or_else(|| "Cannot read gutter repository metadata".into())
|
||||
}
|
||||
|
||||
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> FileResult {
|
||||
if contents.len() > FILE_LIMIT {
|
||||
return Err("Gutter file exceeds 16 MiB".into());
|
||||
}
|
||||
if contents.as_bytes().contains(&0) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if !metadata.file_type().is_file() => return Ok(vec![]),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
// Check the lexical path before canonicalizing: metadata files and symlinked parents
|
||||
// must not acquire the markers of their target outside the checkout.
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join(path)
|
||||
};
|
||||
if absolute
|
||||
.components()
|
||||
.any(|part| part.as_os_str() == ".git" || part.as_os_str() == ".jj")
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Some(root) = absolute.parent().and_then(|parent| {
|
||||
parent
|
||||
.ancestors()
|
||||
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
||||
}) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if absolute
|
||||
.ancestors()
|
||||
.take_while(|part| *part != root)
|
||||
.any(|part| {
|
||||
fs::symlink_metadata(part).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
})
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let relative = absolute.strip_prefix(root).map_err(|e| e.to_string())?;
|
||||
if safe_path(relative).is_err() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
// Resolve ancestors above the checkout, preserving a missing named-buffer tail.
|
||||
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
||||
let baseline = if root.join(".jj").exists() {
|
||||
jj_baseline(&root, relative, cancelled)?
|
||||
} else {
|
||||
git_baseline(&root, relative, cancelled)?
|
||||
};
|
||||
let Some(baseline) = baseline else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if baseline.contains(&0) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Ok(baseline) = std::str::from_utf8(&baseline) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if cancelled() {
|
||||
return Err("Gutter cancelled".into());
|
||||
}
|
||||
Ok(changes(baseline, contents))
|
||||
}
|
||||
|
||||
fn eligible(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
store: Option<&Path>,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<bool, String> {
|
||||
let command = |literal_pathspecs| {
|
||||
let mut command = if literal_pathspecs {
|
||||
git(root)
|
||||
} else {
|
||||
git_without_literal_pathspecs(root)
|
||||
};
|
||||
if let Some(store) = store {
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(store)
|
||||
.arg("--work-tree")
|
||||
.arg(root);
|
||||
}
|
||||
command
|
||||
};
|
||||
let mut tracked = command(true);
|
||||
tracked
|
||||
.args(["ls-files", "--cached", "-z", "--"])
|
||||
.arg(relative);
|
||||
let bytes = checked(tracked, cancelled)?;
|
||||
if bytes
|
||||
.split(|byte| *byte == 0)
|
||||
.any(|entry| entry == relative.as_os_str().as_encoded_bytes())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let mut ignored = command(false);
|
||||
ignored
|
||||
.args(["check-ignore", "--no-index", "-q", "--"])
|
||||
.arg(relative);
|
||||
match run_status(ignored, &[], cancelled)?.0 {
|
||||
Some(0) => Ok(false),
|
||||
Some(1) => Ok(true),
|
||||
_ => Err("Cannot read gutter ignore rules".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_baseline(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut head = git(root);
|
||||
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
||||
let (code, head) = run_status(head, &[], cancelled)?;
|
||||
if code == Some(1) {
|
||||
// An unborn repository still has an index and ignore rules.
|
||||
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
||||
}
|
||||
if code != Some(0) {
|
||||
return Err("Cannot resolve Git gutter HEAD".into());
|
||||
}
|
||||
let head = std::str::from_utf8(&head)
|
||||
.map_err(|_| "Invalid Git gutter HEAD")?
|
||||
.trim();
|
||||
let mut tree = git(root);
|
||||
tree.args(["ls-tree", "-z", "--full-tree", head, "--"])
|
||||
.arg(relative);
|
||||
let entries = checked(tree, cancelled)?;
|
||||
let Some(entry) = entries
|
||||
.split(|byte| *byte == 0)
|
||||
.find(|entry| !entry.is_empty())
|
||||
else {
|
||||
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
||||
};
|
||||
let tab = entry
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\t')
|
||||
.ok_or("Invalid Git gutter tree")?;
|
||||
if &entry[tab + 1..] != relative.as_os_str().as_encoded_bytes() {
|
||||
return Err("Unexpected Git gutter path".into());
|
||||
}
|
||||
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid Git gutter tree")?;
|
||||
let mut fields = fields.split(' ');
|
||||
if !matches!(fields.next(), Some("100644" | "100755")) || fields.next() != Some("blob") {
|
||||
return Ok(None);
|
||||
}
|
||||
let object = fields.next().ok_or("Missing Git gutter blob")?;
|
||||
let mut cat = git(root);
|
||||
cat.args(["cat-file", "blob", object]);
|
||||
checked(cat, cancelled).map(Some)
|
||||
}
|
||||
|
||||
fn jj_file_kind(
|
||||
root: &Path,
|
||||
revision: &str,
|
||||
pattern: &str,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut command = jj_command(root);
|
||||
command.args([
|
||||
"file",
|
||||
"list",
|
||||
"-r",
|
||||
revision,
|
||||
"-T",
|
||||
"json(file_type) ++ \"\\n\"",
|
||||
"--",
|
||||
pattern,
|
||||
]);
|
||||
let bytes = checked(command, cancelled)?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let kind =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "Invalid Jujutsu gutter file metadata")?;
|
||||
Ok(Some(kind))
|
||||
}
|
||||
|
||||
fn jj_baseline(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let Ok(store) = store_for_jj(root) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(path) = relative.to_str() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut parents = jj_command(root);
|
||||
parents.args([
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id ++ \"\\n\"",
|
||||
]);
|
||||
let bytes = checked(parents, cancelled)?;
|
||||
let ids = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu gutter parents")?;
|
||||
let mut ids = ids.lines();
|
||||
let Some(parent) = ids.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// ponytail: no merged-parent materializer exists here; hide markers on merges
|
||||
// rather than report changes against an arbitrary first parent.
|
||||
if ids.next().is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pattern = jj_file_pattern(path);
|
||||
let baseline_kind = if parent.bytes().all(|byte| byte == b'0') {
|
||||
None
|
||||
} else {
|
||||
jj_file_kind(root, parent, &pattern, cancelled)?
|
||||
};
|
||||
if let Some(kind) = baseline_kind {
|
||||
if !matches!(kind.as_str(), "file" | "conflict") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut show = jj_command(root);
|
||||
show.args(["file", "show", "-r", parent, "-T", "", "--", &pattern]);
|
||||
return checked(show, cancelled).map(Some);
|
||||
}
|
||||
if let Some(kind) = jj_file_kind(root, "@", &pattern, cancelled)? {
|
||||
return Ok(matches!(kind.as_str(), "file" | "conflict").then(Vec::new));
|
||||
}
|
||||
Ok(eligible(root, relative, Some(&store), cancelled)?.then(Vec::new))
|
||||
}
|
||||
|
||||
fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
|
||||
fn source(text: &str) -> (Vec<String>, bool) {
|
||||
(
|
||||
text.split_terminator('\n').map(str::to_owned).collect(),
|
||||
text.ends_with('\n'),
|
||||
)
|
||||
}
|
||||
let before = normalize_newlines(before);
|
||||
let after = normalize_newlines(after);
|
||||
if before == after {
|
||||
return vec![None; after.split('\n').count()];
|
||||
}
|
||||
let (before, before_eof) = source(&before);
|
||||
let (after_lines, after_eof) = source(&after);
|
||||
let mut markers = vec![None; after.split('\n').count()];
|
||||
for hunk in line_hunks(&before, before_eof, &after_lines, after_eof) {
|
||||
if hunk.after_len == 0 {
|
||||
let anchor = hunk.after_start.min(markers.len() - 1);
|
||||
// A deletion sharing a row with a replacement remains modified.
|
||||
if markers[anchor].is_none() {
|
||||
markers[anchor] = Some(Change::Removed);
|
||||
}
|
||||
} else {
|
||||
for offset in 0..hunk.after_len {
|
||||
markers[hunk.after_start + offset] = Some(if offset < hunk.before_len {
|
||||
Change::Modified
|
||||
} else {
|
||||
Change::Added
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
|
||||
struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-gutter-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Gutter Author\"",
|
||||
"--config=user.email=\"gutter@example.test\"",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn git_fixture() -> (Temporary, PathBuf) {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "gutter@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file]\n\t\".txt");
|
||||
fs::write(&path, "first\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
(temp, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
|
||||
use Change::*;
|
||||
for (before, after, expected) in [
|
||||
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\n",
|
||||
"first\nsecond\n",
|
||||
vec![None, None, None],
|
||||
),
|
||||
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\nthird\r\n",
|
||||
"first\nchanged\nthird\n",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nsecond\nthird\n",
|
||||
"first\rchanged\rthird\r",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
|
||||
(
|
||||
"old\n",
|
||||
"changed\nsurplus\n",
|
||||
vec![Some(Modified), Some(Added), None],
|
||||
),
|
||||
(
|
||||
"old\nextra\nlast\n",
|
||||
"changed\nlast\n",
|
||||
vec![Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nremoved\nlast\n",
|
||||
"first\nlast\n",
|
||||
vec![None, Some(Removed), None],
|
||||
),
|
||||
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
|
||||
("first\nremoved", "first", vec![Some(Modified)]),
|
||||
("first\nremoved", "first\n", vec![None, Some(Removed)]),
|
||||
("removed\n", "", vec![Some(Removed)]),
|
||||
("", "new\n", vec![Some(Added), None]),
|
||||
("", "", vec![None]),
|
||||
("same", "same\n", vec![Some(Modified), None]),
|
||||
] {
|
||||
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_crlf_baseline_matches_normalized_editor_rows() {
|
||||
let (temp, _) = git_fixture();
|
||||
let path = temp.0.join("crlf");
|
||||
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "crlf"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "CRLF baseline"],
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
|
||||
vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
|
||||
let (temp, path) = git_fixture();
|
||||
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
|
||||
let index = fs::read(temp.0.join(".git/index")).unwrap();
|
||||
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
|
||||
vec![
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
|
||||
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
|
||||
fs::remove_file(&path).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let untracked = temp.0.join("untracked");
|
||||
fs::write(&untracked, "unsaved\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
|
||||
let temp = Temporary::new();
|
||||
let path = temp.0.join("new/named");
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let disk = temp.0.join("untracked");
|
||||
fs::write(&disk, "disk\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&disk, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(
|
||||
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
read_file(&disk, "binary\0text", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("link"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_non_utf8_filename_is_literal() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let (temp, _) = git_fixture();
|
||||
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
|
||||
let path = temp.0.join(&name);
|
||||
match fs::write(&path, "old\n") {
|
||||
Ok(()) => {}
|
||||
// APFS rejects non-UTF-8 names before repository code can read them.
|
||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
|
||||
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
|
||||
}
|
||||
let mut add = git(&temp.0);
|
||||
add.args(["add", "--"]).arg(&name);
|
||||
checked(add, &|| false).unwrap();
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "changed\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Modified), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
|
||||
for colocate in ["--colocate", "--no-colocate"] {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "jj", &["git", "init", colocate, "."]);
|
||||
let name = "-literal [file]\n\t\".txt";
|
||||
let path = temp.0.join(name);
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("ignored"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let store = store_for_jj(&temp.0).unwrap();
|
||||
let refs = fs::read(store.join("HEAD")).unwrap();
|
||||
fs::write(&path, "disk edit\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None, None]
|
||||
);
|
||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert!(
|
||||
read_file(&path, "original\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
|
||||
let (_temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(
|
||||
id,
|
||||
vec![
|
||||
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
||||
(8, path.clone(), Arc::clone(&oversized)),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result, picker)) = worker.poll() {
|
||||
assert!(picker.is_none());
|
||||
assert_eq!(id, 19);
|
||||
assert_eq!(result[0].0, 5);
|
||||
assert_eq!(
|
||||
result[0].1.as_ref().unwrap(),
|
||||
&vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
assert_eq!(result[1].0, 8);
|
||||
assert_eq!(
|
||||
result[1].1.as_ref().unwrap_err(),
|
||||
"Gutter file exceeds 16 MiB"
|
||||
);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(
|
||||
20,
|
||||
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
||||
None
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
||||
let (temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
vec![],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path.clone(), "dirty buffer\n".into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
assert!(lines.is_empty());
|
||||
let picker = picker.unwrap();
|
||||
assert_eq!(picker.root, temp.0);
|
||||
let changes = picker.result.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
||||
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
worker
|
||||
.request(
|
||||
2,
|
||||
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 2);
|
||||
assert_eq!(lines[0].0, 4);
|
||||
assert!(lines[0].1.is_ok());
|
||||
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -1,10 +1,21 @@
|
||||
mod battery;
|
||||
mod blame;
|
||||
pub mod chrome;
|
||||
pub mod clipboard;
|
||||
pub mod diff;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod gutter;
|
||||
pub mod lsp;
|
||||
mod lsp_config;
|
||||
mod markdown;
|
||||
pub mod panes;
|
||||
pub mod plugin;
|
||||
pub mod repository;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
mod terminal_colors;
|
||||
pub mod tui;
|
||||
mod wrap;
|
||||
|
||||
+633
-71
@@ -18,10 +18,11 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use crate::{
|
||||
editor::Context,
|
||||
plugin::{
|
||||
Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot,
|
||||
PluginUpdate, SemanticToken, SemanticTokenKind, SemanticTokenModifier, TextPosition,
|
||||
UpdateEffect,
|
||||
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
|
||||
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
||||
SemanticTokenModifier, TextPosition, UpdateEffect, Utf16Range,
|
||||
},
|
||||
snippet,
|
||||
};
|
||||
|
||||
const MAX_BODY: usize = 4 * 1024 * 1024;
|
||||
@@ -59,6 +60,7 @@ struct Contribution {
|
||||
}
|
||||
|
||||
enum Output {
|
||||
Ready(String),
|
||||
Update(PluginUpdate),
|
||||
Diagnostics {
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
@@ -76,6 +78,7 @@ struct Worker {
|
||||
failure: Receiver<String>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
retired: bool,
|
||||
name: Option<String>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -94,6 +97,7 @@ enum Input {
|
||||
enum PositionKind {
|
||||
Definition,
|
||||
Hover,
|
||||
Completion,
|
||||
}
|
||||
|
||||
impl PositionKind {
|
||||
@@ -101,6 +105,7 @@ impl PositionKind {
|
||||
match self {
|
||||
Self::Definition => "definition",
|
||||
Self::Hover => "hover",
|
||||
Self::Completion => "completion",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +147,20 @@ impl Lsp {
|
||||
!self.configs.is_empty()
|
||||
}
|
||||
|
||||
/// Ready servers assigned to this document, in configured stack order.
|
||||
pub fn active_server_names(&self, buffer_id: usize) -> impl Iterator<Item = &str> {
|
||||
self.documents
|
||||
.get(&buffer_id)
|
||||
.into_iter()
|
||||
.flat_map(|doc| &doc.servers)
|
||||
.filter_map(|key| {
|
||||
self.servers
|
||||
.get(key)
|
||||
.filter(|worker| !worker.retired)
|
||||
.and_then(|worker| worker.name.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
@@ -265,6 +284,10 @@ impl Lsp {
|
||||
self.position(context, request_id, PositionKind::Hover)
|
||||
}
|
||||
|
||||
pub fn completion(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
|
||||
self.position(context, request_id, PositionKind::Completion)
|
||||
}
|
||||
|
||||
fn position(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -327,6 +350,7 @@ impl Lsp {
|
||||
break;
|
||||
};
|
||||
match output {
|
||||
Output::Ready(name) => worker.name = Some(name),
|
||||
Output::Update(update) => self.updates.push(update),
|
||||
Output::Diagnostics { snapshot, items } => {
|
||||
let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| {
|
||||
@@ -478,6 +502,7 @@ impl Lsp {
|
||||
failure,
|
||||
cancelled,
|
||||
retired: false,
|
||||
name: None,
|
||||
thread,
|
||||
},
|
||||
);
|
||||
@@ -545,6 +570,7 @@ impl Lsp {
|
||||
return;
|
||||
}
|
||||
worker.retired = true;
|
||||
worker.name = None;
|
||||
worker.cancelled.store(true, Ordering::Release);
|
||||
for (id, doc) in documents {
|
||||
if doc.servers.first() == Some(key) {
|
||||
@@ -598,8 +624,9 @@ struct SyncOptions {
|
||||
|
||||
enum Request {
|
||||
Initialize,
|
||||
Definition(Source),
|
||||
Hover(Source),
|
||||
Definition(Source, Arc<DocumentSnapshot>),
|
||||
Hover(Source, Arc<DocumentSnapshot>),
|
||||
Completion(Source, Arc<DocumentSnapshot>),
|
||||
SemanticTokens(Arc<DocumentSnapshot>),
|
||||
Shutdown,
|
||||
}
|
||||
@@ -620,6 +647,7 @@ struct Runtime {
|
||||
ready: bool,
|
||||
definition: bool,
|
||||
hover: bool,
|
||||
completion: bool,
|
||||
semantic: Option<SemanticOptions>,
|
||||
sync: SyncOptions,
|
||||
}
|
||||
@@ -649,6 +677,7 @@ impl Runtime {
|
||||
ready: false,
|
||||
definition: false,
|
||||
hover: false,
|
||||
completion: false,
|
||||
semantic: None,
|
||||
sync: SyncOptions::default(),
|
||||
};
|
||||
@@ -670,6 +699,19 @@ impl Runtime {
|
||||
"definition": {"dynamicRegistration": false, "linkSupport": true},
|
||||
"hover": {"dynamicRegistration": false,
|
||||
"contentFormat": ["plaintext", "markdown"]},
|
||||
"completion": {
|
||||
"dynamicRegistration": false, "contextSupport": true,
|
||||
"insertTextMode": 1,
|
||||
"completionItem": {
|
||||
"snippetSupport": true, "commitCharactersSupport": false,
|
||||
"insertReplaceSupport": true,
|
||||
"insertTextModeSupport": {"valueSet": [1]},
|
||||
"resolveSupport": {"properties": []}
|
||||
},
|
||||
"completionList": {
|
||||
"itemDefaults": ["editRange", "insertTextFormat", "insertTextMode"]
|
||||
}
|
||||
},
|
||||
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true},
|
||||
"semanticTokens": {
|
||||
"dynamicRegistration": false,
|
||||
@@ -942,17 +984,26 @@ impl Runtime {
|
||||
"textDocument/hover",
|
||||
"server does not support hover",
|
||||
),
|
||||
PositionKind::Completion => (
|
||||
self.completion,
|
||||
"textDocument/completion",
|
||||
"server does not support completion",
|
||||
),
|
||||
};
|
||||
if !supported {
|
||||
return self.emit(message(unsupported, Some(source.request_id)));
|
||||
}
|
||||
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
|
||||
let params = json!({
|
||||
let mut params = json!({
|
||||
"textDocument": {"uri": doc.uri}, "position": position
|
||||
});
|
||||
if matches!(kind, PositionKind::Completion) {
|
||||
params["context"] = json!({"triggerKind": 1});
|
||||
}
|
||||
let request = match kind {
|
||||
PositionKind::Definition => Request::Definition(source),
|
||||
PositionKind::Hover => Request::Hover(source),
|
||||
PositionKind::Definition => Request::Definition(source, Arc::clone(&doc.snapshot)),
|
||||
PositionKind::Hover => Request::Hover(source, Arc::clone(&doc.snapshot)),
|
||||
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
|
||||
};
|
||||
self.request(method, Some(params), request)
|
||||
}
|
||||
@@ -986,6 +1037,37 @@ impl Runtime {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn completion_result(
|
||||
&self,
|
||||
source: Source,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
result: Result<&Value, String>,
|
||||
) -> Result<(), String> {
|
||||
if !self
|
||||
.documents
|
||||
.get(&source.buffer_id)
|
||||
.is_some_and(|doc| doc.primary && Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
match result.and_then(|value| {
|
||||
decode_completion(&snapshot.text, value, &source, snapshot.path.as_deref())
|
||||
}) {
|
||||
Ok(items) => self.emit(PluginUpdate {
|
||||
request_id: Some(source.request_id),
|
||||
effects: vec![UpdateEffect::Completion {
|
||||
buffer_id: source.buffer_id,
|
||||
revision: source.revision,
|
||||
items,
|
||||
}],
|
||||
}),
|
||||
Err(reason) => self.emit(message(
|
||||
&format!("completion failed: {reason}"),
|
||||
Some(source.request_id),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_result(
|
||||
&mut self,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
@@ -1071,9 +1153,14 @@ impl Runtime {
|
||||
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
|
||||
if object.contains_key("result") == object.contains_key("error") {
|
||||
return match context {
|
||||
Request::Hover(source) => {
|
||||
self.hover_result(source, Err("unexpected LSP response".into()))
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Err("unexpected LSP response".into()))
|
||||
}
|
||||
Request::Completion(source, snapshot) => self.completion_result(
|
||||
source,
|
||||
snapshot,
|
||||
Err("unexpected LSP response".into()),
|
||||
),
|
||||
Request::SemanticTokens(snapshot) => {
|
||||
self.semantic_result(snapshot, Err("unexpected LSP response".into()))
|
||||
}
|
||||
@@ -1086,9 +1173,16 @@ impl Runtime {
|
||||
.filter(|_| error["code"].as_i64().is_some());
|
||||
let Some(text) = text else {
|
||||
return match context {
|
||||
Request::Hover(source) => {
|
||||
self.hover_result(source, Err("invalid LSP error response".into()))
|
||||
}
|
||||
Request::Hover(source, snapshot) => self.hover_result(
|
||||
source,
|
||||
snapshot,
|
||||
Err("invalid LSP error response".into()),
|
||||
),
|
||||
Request::Completion(source, snapshot) => self.completion_result(
|
||||
source,
|
||||
snapshot,
|
||||
Err("invalid LSP error response".into()),
|
||||
),
|
||||
Request::SemanticTokens(snapshot) => {
|
||||
self.semantic_result(snapshot, Err("invalid LSP error response".into()))
|
||||
}
|
||||
@@ -1097,11 +1191,15 @@ impl Runtime {
|
||||
};
|
||||
match context {
|
||||
Request::Initialize => Err(format!("initialization failed: {text}")),
|
||||
Request::Definition(source) => self.emit(message(
|
||||
&format!("definition failed: {text}"),
|
||||
Some(source.request_id),
|
||||
)),
|
||||
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
|
||||
Request::Definition(source, snapshot) => {
|
||||
self.definition_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
Request::Completion(source, snapshot) => {
|
||||
self.completion_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
Request::SemanticTokens(snapshot) => {
|
||||
self.semantic_result(snapshot, Err(text.to_owned()))
|
||||
}
|
||||
@@ -1110,8 +1208,15 @@ impl Runtime {
|
||||
} else {
|
||||
match context {
|
||||
Request::Initialize => self.initialized(&frame["result"]),
|
||||
Request::Definition(source) => self.definition_result(source, &frame["result"]),
|
||||
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
|
||||
Request::Definition(source, snapshot) => {
|
||||
self.definition_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
Request::Completion(source, snapshot) => {
|
||||
self.completion_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
Request::SemanticTokens(snapshot) => {
|
||||
self.semantic_result(snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
@@ -1138,6 +1243,9 @@ impl Runtime {
|
||||
self.hover = capabilities
|
||||
.get("hoverProvider")
|
||||
.is_some_and(|value| value == true || value.is_object());
|
||||
self.completion = capabilities
|
||||
.get("completionProvider")
|
||||
.is_some_and(Value::is_object);
|
||||
match semantic_options(capabilities.get("semanticTokensProvider")) {
|
||||
Ok(options) => self.semantic = options,
|
||||
Err(reason) => {
|
||||
@@ -1150,6 +1258,18 @@ impl Runtime {
|
||||
Some(json!({"settings": self.config.settings})),
|
||||
)?;
|
||||
self.ready = true;
|
||||
let name = result["serverInfo"]["name"]
|
||||
.as_str()
|
||||
.filter(|name| !name.chars().any(char::is_control))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(&self.config.command[0])
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&self.config.command[0])
|
||||
});
|
||||
self.send(Output::Ready(clean(name)))?;
|
||||
let documents = std::mem::take(&mut self.documents);
|
||||
for (id, mut doc) in documents {
|
||||
self.open(&mut doc)?;
|
||||
@@ -1281,16 +1401,20 @@ impl Runtime {
|
||||
self.emit_diagnostics(&doc.snapshot, diagnostics)
|
||||
}
|
||||
|
||||
fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> {
|
||||
fn definition_result(
|
||||
&self,
|
||||
source: Source,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
result: Result<&Value, String>,
|
||||
) -> Result<(), String> {
|
||||
if self
|
||||
.documents
|
||||
.get(&source.buffer_id)
|
||||
.is_none_or(|doc| doc.snapshot.revision != source.revision)
|
||||
.is_none_or(|doc| !Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let result = self.definition_effects(&source, result);
|
||||
match result {
|
||||
match result.and_then(|result| Self::definition_effects(&source, result)) {
|
||||
Ok(effects) => self.emit(PluginUpdate {
|
||||
request_id: Some(source.request_id),
|
||||
effects,
|
||||
@@ -1302,11 +1426,16 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
|
||||
fn hover_result(
|
||||
&self,
|
||||
source: Source,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
result: Result<&Value, String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(doc) = self
|
||||
.documents
|
||||
.get(&source.buffer_id)
|
||||
.filter(|doc| doc.snapshot.revision == source.revision)
|
||||
.filter(|doc| Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1327,11 +1456,7 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_effects(
|
||||
&self,
|
||||
source: &Source,
|
||||
result: &Value,
|
||||
) -> Result<Vec<UpdateEffect>, String> {
|
||||
fn definition_effects(source: &Source, result: &Value) -> Result<Vec<UpdateEffect>, String> {
|
||||
let (location, count) = match result {
|
||||
Value::Null => return Ok(message("no definition found", None).effects),
|
||||
Value::Array(locations) if locations.is_empty() => {
|
||||
@@ -1349,53 +1474,25 @@ impl Runtime {
|
||||
.as_str()
|
||||
.ok_or("invalid definition URI")?,
|
||||
)?;
|
||||
let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let cached = self.snapshots.values().find(|doc| {
|
||||
doc.path.as_ref().is_some_and(|candidate| {
|
||||
let candidate = absolute(Path::new(candidate), &self.root);
|
||||
std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity
|
||||
})
|
||||
});
|
||||
let disk;
|
||||
let text = if let Some(doc) = cached {
|
||||
&doc.text
|
||||
} else {
|
||||
if !std::fs::metadata(&path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("definition target must be a regular file".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(&path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.take((MAX_BODY + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if bytes.len() > MAX_BODY {
|
||||
return Err("definition target exceeds 4 MiB".into());
|
||||
}
|
||||
disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?;
|
||||
&disk
|
||||
};
|
||||
let text = normalized(text);
|
||||
if link {
|
||||
range(&text, &location["targetRange"])?;
|
||||
}
|
||||
let (start, _) = range(
|
||||
&text,
|
||||
// Keep the server's document identity and UTF-16 coordinates together.
|
||||
// The editor resolves historical/working text, including lazily loaded
|
||||
// comparison files, before converting the target position.
|
||||
let target_range = link
|
||||
.then(|| range_coordinates(&location["targetRange"]))
|
||||
.transpose()?;
|
||||
let range = range_coordinates(
|
||||
&location[if link {
|
||||
"targetSelectionRange"
|
||||
} else {
|
||||
"range"
|
||||
}],
|
||||
)?;
|
||||
let mut effects = vec![UpdateEffect::Navigate {
|
||||
let mut effects = vec![UpdateEffect::NavigateUtf16 {
|
||||
buffer_id: source.buffer_id,
|
||||
revision: source.revision,
|
||||
path,
|
||||
row: start.row,
|
||||
column: start.column,
|
||||
range,
|
||||
target_range,
|
||||
}];
|
||||
if count > 1 {
|
||||
effects.push(UpdateEffect::Message {
|
||||
@@ -2364,6 +2461,379 @@ fn normalized(text: &str) -> std::borrow::Cow<'_, str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
|
||||
value
|
||||
.get(field)
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("invalid completion {field}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn completion_text(text: &str, multiline: bool) -> Result<(), String> {
|
||||
if text
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() && !(multiline && matches!(ch, '\n' | '\r' | '\t')))
|
||||
{
|
||||
return Err("unsupported control character in completion".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn completion_position(lines: &[&str], value: &Value) -> Result<TextPosition, String> {
|
||||
let (row, units) = coordinates(value)?;
|
||||
let line = lines
|
||||
.get(row)
|
||||
.ok_or("completion line is outside the document")?;
|
||||
let mut offset = 0;
|
||||
let mut column = 0;
|
||||
for cluster in line.graphemes(true) {
|
||||
if units == offset {
|
||||
return Ok(TextPosition { row, column });
|
||||
}
|
||||
offset += cluster.encode_utf16().count();
|
||||
if units < offset {
|
||||
return Err("completion position splits a grapheme".into());
|
||||
}
|
||||
column += 1;
|
||||
}
|
||||
if units != offset {
|
||||
return Err("completion position is outside the line".into());
|
||||
}
|
||||
Ok(TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn completion_range(lines: &[&str], value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||
let start = completion_position(lines, &value["start"])?;
|
||||
let end = completion_position(lines, &value["end"])?;
|
||||
if (start.row, start.column) > (end.row, end.column) {
|
||||
return Err("reversed completion range".into());
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn completion_primary_range(
|
||||
lines: &[&str],
|
||||
value: &Value,
|
||||
source: &Source,
|
||||
) -> Result<(TextPosition, TextPosition), String> {
|
||||
let (start, end) = completion_range(lines, value)?;
|
||||
if start.row != source.row
|
||||
|| end.row != source.row
|
||||
|| start.column > source.column
|
||||
|| end.column < source.column
|
||||
{
|
||||
return Err(
|
||||
"completion primary range must be single-line and contain the request position".into(),
|
||||
);
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn completion_replacement(
|
||||
lines: &[&str],
|
||||
value: &Value,
|
||||
source: &Source,
|
||||
) -> Result<(TextPosition, TextPosition), String> {
|
||||
if value.get("insert").is_some() || value.get("replace").is_some() {
|
||||
if value.get("range").is_some() || value.get("start").is_some() {
|
||||
return Err("ambiguous completion edit range".into());
|
||||
}
|
||||
let (start, end) = completion_primary_range(lines, &value["replace"], source)?;
|
||||
let (insert_start, insert_end) = completion_primary_range(lines, &value["insert"], source)?;
|
||||
if insert_start != start || insert_end.column > end.column {
|
||||
return Err("completion insert range is not a prefix of replace range".into());
|
||||
}
|
||||
Ok((start, end))
|
||||
} else {
|
||||
completion_primary_range(lines, value.get("range").unwrap_or(value), source)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_completion(
|
||||
text: &str,
|
||||
result: &Value,
|
||||
source: &Source,
|
||||
path: Option<&str>,
|
||||
) -> Result<Vec<CompletionItem>, String> {
|
||||
if result.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (items, defaults) = if let Some(items) = result.as_array() {
|
||||
(items, None)
|
||||
} else {
|
||||
if !result["isIncomplete"].is_boolean() {
|
||||
return Err("invalid completion list isIncomplete".into());
|
||||
}
|
||||
let defaults = result.get("itemDefaults");
|
||||
if defaults.is_some_and(|value| !value.is_object()) {
|
||||
return Err("invalid completion itemDefaults".into());
|
||||
}
|
||||
if let Some(defaults) = defaults {
|
||||
for key in defaults.as_object().unwrap().keys() {
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"editRange" | "insertTextFormat" | "insertTextMode"
|
||||
) {
|
||||
return Err("unsupported completion item default".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
result["items"]
|
||||
.as_array()
|
||||
.ok_or("invalid completion items")?,
|
||||
defaults,
|
||||
)
|
||||
};
|
||||
let text = normalized(text);
|
||||
let lines: Vec<_> = text.split('\n').collect();
|
||||
let line = lines
|
||||
.get(source.row)
|
||||
.ok_or("completion request line is outside the document")?;
|
||||
let mut prefix_start = 0;
|
||||
let mut prefix_byte = 0;
|
||||
let mut caret_byte = line.len();
|
||||
let mut columns = 0;
|
||||
for (column, (byte, cluster)) in line.grapheme_indices(true).enumerate() {
|
||||
if column == source.column {
|
||||
caret_byte = byte;
|
||||
break;
|
||||
}
|
||||
if !cluster
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
{
|
||||
prefix_start = column + 1;
|
||||
prefix_byte = byte + cluster.len();
|
||||
}
|
||||
columns = column + 1;
|
||||
}
|
||||
if source.column > columns {
|
||||
return Err("completion request column is outside the line".into());
|
||||
}
|
||||
let prefix = &line[prefix_byte..caret_byte];
|
||||
// The prefix is the only guessed range; explicit server edits remain authoritative.
|
||||
if let Some(defaults) = defaults {
|
||||
if let Some(range) = defaults.get("editRange") {
|
||||
completion_replacement(&lines, range, source)?;
|
||||
}
|
||||
for name in ["insertTextFormat", "insertTextMode"] {
|
||||
if defaults
|
||||
.get(name)
|
||||
.is_some_and(|value| !matches!(value.as_u64(), Some(1 | 2)))
|
||||
{
|
||||
return Err(format!("invalid completion default {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let word_end = caret_byte
|
||||
+ line[caret_byte..]
|
||||
.graphemes(true)
|
||||
.take_while(|cluster| {
|
||||
cluster
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
})
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
let mut decoded = Vec::with_capacity(items.len());
|
||||
let mut bytes = 0usize;
|
||||
for item in items {
|
||||
if !item.is_object() {
|
||||
return Err("invalid completion item".into());
|
||||
}
|
||||
let label = completion_string(item, "label")?.ok_or("missing completion label")?;
|
||||
completion_text(label, false)?;
|
||||
let detail = completion_string(item, "detail")?;
|
||||
let sort = completion_string(item, "sortText")?.unwrap_or(label);
|
||||
let filter = completion_string(item, "filterText")?;
|
||||
let insert = completion_string(item, "insertText")?;
|
||||
let edit_text = completion_string(item, "textEditText")?;
|
||||
if let Some(detail) = detail {
|
||||
completion_text(detail, true)?;
|
||||
}
|
||||
completion_text(sort, false)?;
|
||||
if let Some(filter) = filter {
|
||||
completion_text(filter, false)?;
|
||||
}
|
||||
let property = |name: &str| {
|
||||
item.get(name)
|
||||
.or_else(|| defaults.and_then(|value| value.get(name)))
|
||||
};
|
||||
let format = property("insertTextFormat")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.filter(|value| matches!(value, 1 | 2))
|
||||
.ok_or("invalid completion insertTextFormat")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(1);
|
||||
let mode = property("insertTextMode")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.filter(|value| matches!(value, 1 | 2))
|
||||
.ok_or("invalid completion insertTextMode")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(1);
|
||||
// No indentation adjustment, server commands or lazy edit resolution.
|
||||
if mode == 2 || item.get("command").is_some() {
|
||||
continue;
|
||||
}
|
||||
let explicit = item.get("textEdit");
|
||||
let default_range = defaults.and_then(|value| value.get("editRange"));
|
||||
let (start, end, new_text) = if let Some(edit) = explicit {
|
||||
if !edit.is_object() {
|
||||
return Err("invalid completion textEdit".into());
|
||||
}
|
||||
let (start, end) = completion_replacement(&lines, edit, source)?;
|
||||
let new_text =
|
||||
completion_string(edit, "newText")?.ok_or("missing completion newText")?;
|
||||
(start, end, new_text)
|
||||
} else if let Some(range) = default_range {
|
||||
let (start, end) = completion_replacement(&lines, range, source)?;
|
||||
(start, end, edit_text.or(insert).unwrap_or(label))
|
||||
} else {
|
||||
(
|
||||
TextPosition {
|
||||
row: source.row,
|
||||
column: prefix_start,
|
||||
},
|
||||
TextPosition {
|
||||
row: source.row,
|
||||
column: source.column,
|
||||
},
|
||||
insert.unwrap_or(label),
|
||||
)
|
||||
};
|
||||
let filter_prefix = if explicit.is_some() || default_range.is_some() {
|
||||
let byte = line
|
||||
.grapheme_indices(true)
|
||||
.nth(start.column)
|
||||
.map_or(line.len(), |(byte, _)| byte);
|
||||
&line[byte..caret_byte]
|
||||
} else {
|
||||
prefix
|
||||
};
|
||||
completion_text(new_text, true)?;
|
||||
let (new_text, snippet) = if format == 2 {
|
||||
match snippet::expand(new_text, |name| {
|
||||
completion_variable(name, line, &line[prefix_byte..word_end], source.row, path)
|
||||
}) {
|
||||
Ok((text, snippet)) => (text, Some(snippet)),
|
||||
Err(snippet::Error::Unsupported) => continue,
|
||||
Err(snippet::Error::Invalid) => return Err("invalid completion snippet".into()),
|
||||
Err(snippet::Error::Limit) => {
|
||||
return Err("completion snippet exceeds expansion limits".into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(normalized(new_text).into_owned(), None)
|
||||
};
|
||||
completion_text(&new_text, true)?;
|
||||
let mut edits = vec![CompletionEdit {
|
||||
start,
|
||||
end,
|
||||
text: new_text,
|
||||
}];
|
||||
if let Some(additional) = item.get("additionalTextEdits") {
|
||||
for edit in additional
|
||||
.as_array()
|
||||
.ok_or("invalid completion additionalTextEdits")?
|
||||
{
|
||||
let (start, end) = completion_range(&lines, &edit["range"])?;
|
||||
let new_text =
|
||||
completion_string(edit, "newText")?.ok_or("missing additional edit newText")?;
|
||||
completion_text(new_text, true)?;
|
||||
edits.push(CompletionEdit {
|
||||
start,
|
||||
end,
|
||||
text: normalized(new_text).into_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
edits.sort_unstable_by_key(|edit| {
|
||||
(
|
||||
(edit.start.row, edit.start.column),
|
||||
(edit.end.row, edit.end.column),
|
||||
)
|
||||
});
|
||||
if edits.windows(2).any(|pair| {
|
||||
(pair[0].end.row, pair[0].end.column) > (pair[1].start.row, pair[1].start.column)
|
||||
|| pair[0].start == pair[1].start
|
||||
}) {
|
||||
return Err("overlapping completion edits".into());
|
||||
}
|
||||
let size = label.len()
|
||||
+ detail.map_or(0, str::len)
|
||||
+ edits.iter().map(|edit| edit.text.len()).sum::<usize>();
|
||||
bytes = bytes
|
||||
.checked_add(size)
|
||||
.filter(|bytes| *bytes <= MAX_BODY)
|
||||
.ok_or("completion text exceeds 4 MiB")?;
|
||||
if (filter.is_some() || (explicit.is_none() && default_range.is_none()))
|
||||
&& !filter.unwrap_or(label).starts_with(filter_prefix)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let primary_edit = edits
|
||||
.iter()
|
||||
.position(|edit| edit.start == start && edit.end == end)
|
||||
.unwrap();
|
||||
decoded.push((
|
||||
sort,
|
||||
CompletionItem {
|
||||
label: label.to_owned(),
|
||||
detail: detail.map(str::to_owned),
|
||||
primary_edit,
|
||||
edits,
|
||||
snippet,
|
||||
},
|
||||
));
|
||||
}
|
||||
decoded.sort_by(|a, b| a.0.cmp(b.0));
|
||||
Ok(decoded.into_iter().map(|(_, item)| item).collect())
|
||||
}
|
||||
|
||||
fn completion_variable(
|
||||
name: &str,
|
||||
line: &str,
|
||||
word: &str,
|
||||
row: usize,
|
||||
path: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let value = match name {
|
||||
"TM_SELECTED_TEXT" => "",
|
||||
"TM_CURRENT_LINE" => line,
|
||||
"TM_CURRENT_WORD" => word,
|
||||
"TM_LINE_INDEX" => return Some(row.to_string()),
|
||||
"TM_LINE_NUMBER" => return Some((row + 1).to_string()),
|
||||
"TM_FILEPATH" => path.unwrap_or(""),
|
||||
"TM_FILENAME" => path
|
||||
.and_then(|path| Path::new(path).file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
"TM_FILENAME_BASE" => path
|
||||
.and_then(|path| Path::new(path).file_stem())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
"TM_DIRECTORY" => path
|
||||
.and_then(|path| Path::new(path).parent())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
_ => return None,
|
||||
};
|
||||
// Context values have the same per-expansion bound as template text.
|
||||
(value.len() <= 256 * 1024).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn coordinates(value: &Value) -> Result<(usize, usize), String> {
|
||||
let line = value["line"]
|
||||
.as_u64()
|
||||
@@ -2401,10 +2871,17 @@ fn position(text: &str, value: &Value, round_up: bool) -> Result<TextPosition, S
|
||||
Ok(TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||
if coordinates(&value["start"])? > coordinates(&value["end"])? {
|
||||
fn range_coordinates(value: &Value) -> Result<Utf16Range, String> {
|
||||
let start = coordinates(&value["start"])?;
|
||||
let end = coordinates(&value["end"])?;
|
||||
if start > end {
|
||||
return Err("reversed LSP range".into());
|
||||
}
|
||||
Ok(Utf16Range { start, end })
|
||||
}
|
||||
|
||||
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||
range_coordinates(value)?;
|
||||
Ok((
|
||||
position(text, &value["start"], false)?,
|
||||
position(text, &value["end"], true)?,
|
||||
@@ -2516,6 +2993,64 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
||||
let source = Source {
|
||||
buffer_id: 7,
|
||||
revision: 3,
|
||||
row: 0,
|
||||
column: 0,
|
||||
request_id: 11,
|
||||
};
|
||||
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
||||
let span = json!({
|
||||
"start": {"line": 4, "character": 5},
|
||||
"end": {"line": 4, "character": 8}
|
||||
});
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": span}),
|
||||
] {
|
||||
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
||||
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
||||
start: (4, 5),
|
||||
end: (4, 8),
|
||||
});
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[UpdateEffect::NavigateUtf16 {
|
||||
buffer_id: 7, revision: 3, path: target,
|
||||
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
||||
}] if target == path && *target_range == expected_target_range
|
||||
));
|
||||
}
|
||||
for invalid in [
|
||||
json!({"start": {"line": 4, "character": 8},
|
||||
"end": {"line": 4, "character": 5}}),
|
||||
json!({"start": {"line": -1, "character": 0},
|
||||
"end": {"line": 0, "character": 0}}),
|
||||
] {
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": invalid, "targetSelectionRange": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": invalid}),
|
||||
] {
|
||||
assert!(Runtime::definition_effects(&source, &location).is_err());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateEffect>(json!({
|
||||
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
||||
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
||||
"target_range": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
||||
let options = semantic_options(Some(&json!({
|
||||
@@ -2673,4 +3208,31 @@ mod tests {
|
||||
assert!(hover_text(&value, source).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
|
||||
let source = Source {
|
||||
buffer_id: 1,
|
||||
revision: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
request_id: 1,
|
||||
};
|
||||
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
|
||||
assert_eq!(
|
||||
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
|
||||
.text
|
||||
.len(),
|
||||
MAX_BODY - 1
|
||||
);
|
||||
let over_limit = json!([
|
||||
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
|
||||
{"label": "y"}
|
||||
]);
|
||||
assert!(
|
||||
decode_completion("", &over_limit, &source, None)
|
||||
.unwrap_err()
|
||||
.contains("4 MiB")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+111
-13
@@ -1,7 +1,10 @@
|
||||
use std::{env, path::PathBuf};
|
||||
use std::{env, io, path::PathBuf};
|
||||
|
||||
use vamp::{session::EditorSession, tui};
|
||||
|
||||
// Only the first argument can be this process-to-process terminal handoff marker.
|
||||
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
|
||||
|
||||
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
|
||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
@@ -9,7 +12,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
Default keys (configurable with config.bind):
|
||||
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
|
||||
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
|
||||
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
|
||||
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
|
||||
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
|
||||
@@ -17,21 +20,67 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
||||
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
|
||||
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
|
||||
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
||||
Esc keeps only the primary caret
|
||||
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
|
||||
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
|
||||
v/Esc returns to Normal retaining carets
|
||||
Insert: Esc returns to Normal retaining carets
|
||||
v/V switches selection shape; same selector or Esc cancels retaining carets
|
||||
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
|
||||
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
|
||||
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)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
||||
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
|
||||
:wq=save_and_quit, :wq!=force_save_and_quit
|
||||
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
|
||||
Normal saves recheck LF-normalized disk text; external changes/deletion require force
|
||||
Force never bypasses snapshot read-only, symlink, or another-buffer path protection
|
||||
Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes
|
||||
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
|
||||
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
||||
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
|
||||
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
||||
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
|
||||
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
|
||||
:force_restart / :restart! discards unsaved text without writing
|
||||
Actions: core.restart, core.force_restart (no default binding)
|
||||
Relaunches the executable with original CLI args/config/startup file; tabs/views reset
|
||||
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
||||
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
||||
Switching Before/After preserves the aligned cursor row and display column
|
||||
Selector queries own printable keys, including :; text navigation skips omitted lines
|
||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
||||
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
||||
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
@@ -49,8 +98,8 @@ struct Options {
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Option<Options>, String> {
|
||||
let mut args = env::args_os().skip(1);
|
||||
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
|
||||
let mut args = env::args_os().skip(1 + usize::from(inherited_screen));
|
||||
let mut file = None;
|
||||
let mut config = None;
|
||||
let mut config_selected = false;
|
||||
@@ -92,7 +141,25 @@ fn options() -> Result<Option<Options>, String> {
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let Some(options) = options()? else {
|
||||
let inherited_screen = env::args_os()
|
||||
.nth(1)
|
||||
.is_some_and(|arg| arg == INHERITED_SCREEN);
|
||||
if inherited_screen {
|
||||
return tui::TerminalSession::run(true, || {
|
||||
run_editor(options(true).map_err(io::Error::other)?, true)
|
||||
})
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
let options = options(false)?;
|
||||
if options.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
tui::TerminalSession::run(false, || run_editor(options, false))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn run_editor(options: Option<Options>, inherited_screen: bool) -> io::Result<()> {
|
||||
let Some(options) = options else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut session = EditorSession::open(
|
||||
@@ -100,8 +167,39 @@ fn run() -> Result<(), String> {
|
||||
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
|
||||
options.config.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("cannot open file: {error}"))?;
|
||||
tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))
|
||||
.map_err(|error| io::Error::other(format!("cannot open file: {error}")))?;
|
||||
tui::run(&mut session, inherited_screen)?;
|
||||
let restart = session.editor().should_restart();
|
||||
drop(session);
|
||||
if restart {
|
||||
tui::TerminalSession::prepare_restart()?;
|
||||
restart_editor(inherited_screen)
|
||||
.map_err(|error| io::Error::other(format!("Restart failed: {error}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restart_editor(inherited_screen: bool) -> io::Result<()> {
|
||||
let executable = env::current_exe()?;
|
||||
let mut command = std::process::Command::new(executable);
|
||||
command.arg(INHERITED_SCREEN);
|
||||
command.args(env::args_os().skip(1 + usize::from(inherited_screen)));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
Err(command.exec())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let status = command.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"Restarted editor exited with {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tree_sitter::{Node, Parser, Range, Tree};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::plugin::TextPosition;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct LinkTarget {
|
||||
pub path: Option<PathBuf>,
|
||||
pub fragment: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn link_at(text: &str, position: TextPosition) -> Result<LinkTarget, String> {
|
||||
let byte = position_byte(text, position)?;
|
||||
let blocks = block_tree(text)?;
|
||||
let mut node = blocks
|
||||
.root_node()
|
||||
.descendant_for_byte_range(byte, byte)
|
||||
.ok_or_else(no_link)?;
|
||||
loop {
|
||||
if node.kind() == "link_reference_definition" {
|
||||
return destination(child(node, "link_destination"), text);
|
||||
}
|
||||
if matches!(node.kind(), "inline" | "pipe_table_cell") {
|
||||
break;
|
||||
}
|
||||
node = node.parent().ok_or_else(no_link)?;
|
||||
}
|
||||
let (inline, ranges) = inline_tree(text, node)?;
|
||||
let mut node = inline
|
||||
.root_node()
|
||||
.descendant_for_byte_range(byte, byte)
|
||||
.ok_or_else(no_link)?;
|
||||
let mut link = None;
|
||||
loop {
|
||||
if !(node.start_byte() <= byte && byte < node.end_byte()) {
|
||||
return Err(no_link());
|
||||
}
|
||||
match node.kind() {
|
||||
"image" => return Err("Markdown images are not navigation links".into()),
|
||||
"uri_autolink" | "email_autolink" => {
|
||||
return Err("External Markdown URLs are not supported".into());
|
||||
}
|
||||
"inline_link"
|
||||
| "full_reference_link"
|
||||
| "collapsed_reference_link"
|
||||
| "shortcut_link" => {
|
||||
link = Some(node);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(parent) = node.parent() else { break };
|
||||
node = parent;
|
||||
}
|
||||
let link = link.ok_or_else(no_link)?;
|
||||
if link.kind() == "inline_link" {
|
||||
return destination(child(link, "link_destination"), text);
|
||||
}
|
||||
let label = reference_label(link, text, &ranges).ok_or_else(no_link)?;
|
||||
let definitions = definitions(blocks.root_node(), text);
|
||||
let target = definitions
|
||||
.get(&label)
|
||||
.ok_or_else(|| format!("Markdown reference [{label}] is not defined"))?;
|
||||
destination(Some(*target), text)
|
||||
}
|
||||
|
||||
pub(crate) fn heading_row(text: &str, fragment: &str) -> Result<usize, String> {
|
||||
if fragment.is_empty() {
|
||||
return Err("Markdown heading fragment is empty".into());
|
||||
}
|
||||
let blocks = block_tree(text)?;
|
||||
let definitions = definitions(blocks.root_node(), text);
|
||||
let mut slugs = HashMap::<String, usize>::new();
|
||||
let mut result = None;
|
||||
let mut error = None;
|
||||
walk(blocks.root_node(), |heading| {
|
||||
if result.is_some() || !matches!(heading.kind(), "atx_heading" | "setext_heading") {
|
||||
return;
|
||||
}
|
||||
let mut title = String::new();
|
||||
let mut heading_error = None;
|
||||
walk(heading, |node| {
|
||||
if node.kind() != "inline" {
|
||||
return;
|
||||
}
|
||||
match inline_tree(text, node) {
|
||||
Ok((tree, ranges)) => {
|
||||
if let Err(err) =
|
||||
visible_text(tree.root_node(), text, &ranges, &definitions, &mut title)
|
||||
{
|
||||
heading_error = Some(err);
|
||||
}
|
||||
}
|
||||
Err(err) => heading_error = Some(err),
|
||||
}
|
||||
});
|
||||
if let Some(err) = heading_error {
|
||||
error = Some(err);
|
||||
return;
|
||||
}
|
||||
// The block grammar leaves optional closing ATX hashes in the inline node.
|
||||
if heading.kind() == "atx_heading"
|
||||
&& let Some(content) = heading.child_by_field_name("heading_content")
|
||||
{
|
||||
let raw = source(content, text).trim_end();
|
||||
let without_hashes = raw.trim_end_matches('#');
|
||||
if without_hashes.len() < raw.len()
|
||||
&& without_hashes
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
let hashes = raw.len() - without_hashes.len();
|
||||
title.truncate(title.trim_end().len().saturating_sub(hashes));
|
||||
}
|
||||
}
|
||||
let base = slug(title.trim());
|
||||
let mut suffix = *slugs.get(&base).unwrap_or(&0);
|
||||
let mut candidate = base.clone();
|
||||
while slugs.contains_key(&candidate) {
|
||||
suffix += 1;
|
||||
candidate = format!("{base}-{suffix}");
|
||||
}
|
||||
slugs.insert(base, suffix);
|
||||
slugs.entry(candidate.clone()).or_insert(0);
|
||||
if candidate == fragment {
|
||||
result = Some(heading.start_position().row);
|
||||
}
|
||||
});
|
||||
if let Some(row) = result {
|
||||
return Ok(row);
|
||||
}
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
Err(format!("Markdown heading #{fragment} was not found"))
|
||||
}
|
||||
|
||||
fn no_link() -> String {
|
||||
"No Markdown link under the cursor".into()
|
||||
}
|
||||
|
||||
fn position_byte(text: &str, position: TextPosition) -> Result<usize, String> {
|
||||
let mut start = 0;
|
||||
for (row, line) in text.split_inclusive('\n').enumerate() {
|
||||
if row == position.row {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
return line
|
||||
.grapheme_indices(true)
|
||||
.nth(position.column)
|
||||
.map(|(offset, _)| start + offset)
|
||||
.ok_or_else(no_link);
|
||||
}
|
||||
start += line.len();
|
||||
}
|
||||
Err(no_link())
|
||||
}
|
||||
|
||||
fn block_tree(text: &str) -> Result<Tree, String> {
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&tree_sitter_md::LANGUAGE.into())
|
||||
.map_err(|error| format!("Cannot load Markdown grammar: {error}"))?;
|
||||
parser
|
||||
.parse(text, None)
|
||||
.ok_or_else(|| "Cannot parse Markdown document".into())
|
||||
}
|
||||
|
||||
fn inline_tree(text: &str, node: Node<'_>) -> Result<(Tree, Vec<Range>), String> {
|
||||
let mut ranges = Vec::new();
|
||||
let mut remaining = node.range();
|
||||
let mut cursor = node.walk();
|
||||
for continuation in node.named_children(&mut cursor) {
|
||||
if continuation.kind() != "block_continuation" {
|
||||
continue;
|
||||
}
|
||||
if remaining.start_byte < continuation.start_byte() {
|
||||
ranges.push(Range {
|
||||
end_byte: continuation.start_byte(),
|
||||
end_point: continuation.start_position(),
|
||||
..remaining
|
||||
});
|
||||
}
|
||||
remaining.start_byte = continuation.end_byte();
|
||||
remaining.start_point = continuation.end_position();
|
||||
}
|
||||
if remaining.start_byte < remaining.end_byte {
|
||||
ranges.push(remaining);
|
||||
}
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&tree_sitter_md::INLINE_LANGUAGE.into())
|
||||
.map_err(|error| format!("Cannot load Markdown inline grammar: {error}"))?;
|
||||
parser
|
||||
.set_included_ranges(&ranges)
|
||||
.map_err(|error| format!("Cannot parse Markdown inline ranges: {error}"))?;
|
||||
let tree = parser
|
||||
.parse(text, None)
|
||||
.ok_or_else(|| "Cannot parse Markdown inline content".to_string())?;
|
||||
Ok((tree, ranges))
|
||||
}
|
||||
|
||||
fn walk<'a>(root: Node<'a>, mut visit: impl FnMut(Node<'a>)) {
|
||||
let mut cursor = root.walk();
|
||||
loop {
|
||||
visit(cursor.node());
|
||||
if cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
while !cursor.goto_next_sibling() {
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source<'a>(node: Node<'_>, text: &'a str) -> &'a str {
|
||||
&text[node.byte_range()]
|
||||
}
|
||||
|
||||
fn child<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
|
||||
let mut cursor = node.walk();
|
||||
node.named_children(&mut cursor)
|
||||
.find(|child| child.kind() == kind)
|
||||
}
|
||||
|
||||
fn definitions<'a>(root: Node<'a>, text: &str) -> HashMap<String, Node<'a>> {
|
||||
let mut definitions = HashMap::new();
|
||||
walk(root, |node| {
|
||||
if node.kind() == "link_reference_definition"
|
||||
&& let (Some(label), Some(destination)) =
|
||||
(child(node, "link_label"), child(node, "link_destination"))
|
||||
{
|
||||
let raw = source(label, text);
|
||||
let mut label_text = String::with_capacity(raw.len());
|
||||
let mut offset = label.start_byte() + 1;
|
||||
let mut cursor = label.walk();
|
||||
for continuation in label.named_children(&mut cursor) {
|
||||
if continuation.kind() == "block_continuation" {
|
||||
label_text.push_str(&text[offset..continuation.start_byte()]);
|
||||
offset = continuation.end_byte();
|
||||
}
|
||||
}
|
||||
label_text.push_str(&text[offset..label.end_byte() - 1]);
|
||||
definitions
|
||||
.entry(normalize_label(&label_text))
|
||||
.or_insert(destination);
|
||||
}
|
||||
});
|
||||
definitions
|
||||
}
|
||||
|
||||
fn normalize_label(label: &str) -> String {
|
||||
// Normalize Unicode case, including ß/ẞ/SS and final sigma.
|
||||
let mut normalized = String::with_capacity(label.len());
|
||||
for word in label.split_whitespace() {
|
||||
if !normalized.is_empty() {
|
||||
normalized.push(' ');
|
||||
}
|
||||
normalized.extend(
|
||||
word.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(char::to_uppercase),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn reference_label(node: Node<'_>, text: &str, ranges: &[Range]) -> Option<String> {
|
||||
let label = child(
|
||||
node,
|
||||
if node.kind() == "full_reference_link" {
|
||||
"link_label"
|
||||
} else {
|
||||
"link_text"
|
||||
},
|
||||
)?;
|
||||
let mut raw = String::new();
|
||||
let range = if node.kind() == "full_reference_link" {
|
||||
label.start_byte() + 1..label.end_byte() - 1
|
||||
} else {
|
||||
label.byte_range()
|
||||
};
|
||||
append_source(text, ranges, range, &mut raw);
|
||||
Some(normalize_label(&raw))
|
||||
}
|
||||
|
||||
fn append_source(
|
||||
text: &str,
|
||||
ranges: &[Range],
|
||||
source: std::ops::Range<usize>,
|
||||
output: &mut String,
|
||||
) {
|
||||
for range in ranges {
|
||||
let start = range.start_byte.max(source.start);
|
||||
let end = range.end_byte.min(source.end);
|
||||
if start < end {
|
||||
output.push_str(&text[start..end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn destination(node: Option<Node<'_>>, text: &str) -> Result<LinkTarget, String> {
|
||||
let raw = node
|
||||
.map(|node| decoded_text(node, text))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let angled = node.is_some_and(|node| source(node, text).starts_with('<'));
|
||||
let raw = if angled {
|
||||
&raw[1..raw.len() - 1]
|
||||
} else {
|
||||
raw.as_str()
|
||||
};
|
||||
let (path, fragment) = raw
|
||||
.split_once('#')
|
||||
.map_or((raw, None), |(path, fragment)| (path, Some(fragment)));
|
||||
let path = percent_decode(path)?;
|
||||
let fragment = fragment.map(percent_decode).transpose()?;
|
||||
if path.starts_with("//") || path.starts_with("\\\\") || has_scheme(&path) {
|
||||
return Err("External Markdown URLs are not supported; use a local file path".into());
|
||||
}
|
||||
if path.contains('\0')
|
||||
|| fragment
|
||||
.as_ref()
|
||||
.is_some_and(|fragment| fragment.contains('\0'))
|
||||
{
|
||||
return Err("Markdown link contains a NUL character".into());
|
||||
}
|
||||
if fragment.as_deref() == Some("") {
|
||||
return Err("Markdown heading fragment is empty".into());
|
||||
}
|
||||
Ok(LinkTarget {
|
||||
path: (!path.is_empty()).then(|| PathBuf::from(path)),
|
||||
fragment,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_scheme(path: &str) -> bool {
|
||||
let Some((scheme, _)) = path.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
scheme
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(u8::is_ascii_alphabetic)
|
||||
&& scheme
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
fn percent_decode(value: &str) -> Result<String, String> {
|
||||
if !value.contains('%') {
|
||||
return Ok(value.to_owned());
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(value.len());
|
||||
let raw = value.as_bytes();
|
||||
let mut index = 0;
|
||||
while index < raw.len() {
|
||||
if raw[index] == b'%' {
|
||||
let hex = raw
|
||||
.get(index + 1..index + 3)
|
||||
.and_then(|hex| std::str::from_utf8(hex).ok())
|
||||
.and_then(|hex| u8::from_str_radix(hex, 16).ok())
|
||||
.ok_or_else(|| "Invalid percent escape in Markdown link".to_string())?;
|
||||
bytes.push(hex);
|
||||
index += 3;
|
||||
} else {
|
||||
bytes.push(raw[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| "Markdown link percent escapes are not valid UTF-8".into())
|
||||
}
|
||||
|
||||
fn append_unescaped(value: &str, result: &mut String) {
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(character) = chars.next() {
|
||||
if character == '\\' && chars.peek().is_some_and(char::is_ascii_punctuation) {
|
||||
result.push(chars.next().unwrap());
|
||||
} else {
|
||||
result.push(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decoded_text(node: Node<'_>, text: &str) -> Result<String, String> {
|
||||
let mut output = String::with_capacity(node.end_byte() - node.start_byte());
|
||||
append_decoded(node, text, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn append_decoded(node: Node<'_>, text: &str, output: &mut String) -> Result<(), String> {
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"entity_reference" | "numeric_character_reference"
|
||||
) {
|
||||
return decode_entity(source(node, text), output);
|
||||
}
|
||||
let mut offset = node.start_byte();
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
append_unescaped(&text[offset..child.start_byte()], output);
|
||||
append_decoded(child, text, output)?;
|
||||
offset = child.end_byte();
|
||||
}
|
||||
append_unescaped(&text[offset..node.end_byte()], output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_entity(entity: &str, output: &mut String) -> Result<(), String> {
|
||||
let name = &entity[1..entity.len() - 1];
|
||||
let value = match name {
|
||||
"amp" | "AMP" => "&",
|
||||
"lt" | "LT" => "<",
|
||||
"gt" | "GT" => ">",
|
||||
"quot" | "QUOT" => "\"",
|
||||
"apos" => "'",
|
||||
"nbsp" | "NonBreakingSpace" => "\u{a0}",
|
||||
_ => {
|
||||
if let Some(number) = name.strip_prefix('#') {
|
||||
let (number, radix) = number
|
||||
.strip_prefix(['x', 'X'])
|
||||
.map_or((number, 10), |number| (number, 16));
|
||||
let value = u32::from_str_radix(number, radix)
|
||||
.ok()
|
||||
.filter(|value| *value != 0)
|
||||
.and_then(char::from_u32)
|
||||
.unwrap_or('\u{fffd}');
|
||||
output.push(value);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"Unsupported Markdown HTML entity {entity}; use literal Unicode or a numeric entity"
|
||||
));
|
||||
}
|
||||
};
|
||||
output.push_str(value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn visible_text(
|
||||
node: Node<'_>,
|
||||
text: &str,
|
||||
ranges: &[Range],
|
||||
definitions: &HashMap<String, Node<'_>>,
|
||||
output: &mut String,
|
||||
) -> Result<(), String> {
|
||||
match node.kind() {
|
||||
"html_tag" | "link_destination" | "link_title" | "block_continuation" => return Ok(()),
|
||||
kind if kind.ends_with("_delimiter") => return Ok(()),
|
||||
"entity_reference" | "numeric_character_reference" => {
|
||||
return decode_entity(source(node, text), output);
|
||||
}
|
||||
"backslash_escape" => {
|
||||
output.push_str(
|
||||
source(node, text)
|
||||
.strip_prefix('\\')
|
||||
.unwrap_or(source(node, text)),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
"code_span" => {
|
||||
let mut raw = String::new();
|
||||
append_source(text, ranges, node.byte_range(), &mut raw);
|
||||
let delimiter = raw.bytes().take_while(|byte| *byte == b'`').count();
|
||||
let mut code = String::with_capacity(raw.len() - delimiter * 2);
|
||||
let mut chars = raw[delimiter..raw.len() - delimiter].chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\r' {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
code.push(' ');
|
||||
} else {
|
||||
code.push(if ch == '\n' { ' ' } else { ch });
|
||||
}
|
||||
}
|
||||
if code.starts_with(' ') && code.ends_with(' ') && code.chars().any(|ch| ch != ' ') {
|
||||
output.push_str(&code[1..code.len() - 1]);
|
||||
} else {
|
||||
output.push_str(&code);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"inline_link" => {
|
||||
if let Some(label) = child(node, "link_text") {
|
||||
visible_text(label, text, ranges, definitions, output)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"image" => {
|
||||
let mut cursor = node.walk();
|
||||
let inline_image = node.children(&mut cursor).any(|child| child.kind() == "(");
|
||||
let label = child(node, "image_description");
|
||||
if inline_image {
|
||||
if let Some(label) = label {
|
||||
visible_text(label, text, ranges, definitions, output)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let reference = if let Some(reference) = child(node, "link_label") {
|
||||
let raw = source(reference, text);
|
||||
Some(normalize_label(&raw[1..raw.len() - 1]))
|
||||
} else {
|
||||
label.map(|label| {
|
||||
let mut raw = String::new();
|
||||
append_source(text, ranges, label.byte_range(), &mut raw);
|
||||
normalize_label(&raw)
|
||||
})
|
||||
};
|
||||
if reference.is_some_and(|label| definitions.contains_key(&label)) {
|
||||
if let Some(label) = label {
|
||||
visible_text(label, text, ranges, definitions, output)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
"full_reference_link" | "collapsed_reference_link" | "shortcut_link" => {
|
||||
if reference_label(node, text, ranges)
|
||||
.is_some_and(|label| definitions.contains_key(&label))
|
||||
{
|
||||
if let Some(label) = child(node, "link_text") {
|
||||
visible_text(label, text, ranges, definitions, output)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut offset = node.start_byte();
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
append_source(text, ranges, offset..child.start_byte(), output);
|
||||
visible_text(child, text, ranges, definitions, output)?;
|
||||
offset = child.end_byte();
|
||||
}
|
||||
append_source(text, ranges, offset..node.end_byte(), output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn slug(title: &str) -> String {
|
||||
let lower = title.to_lowercase();
|
||||
let mut slug = String::with_capacity(lower.len());
|
||||
for grapheme in lower.graphemes(true) {
|
||||
if grapheme.chars().next().is_some_and(char::is_whitespace) {
|
||||
slug.push('-');
|
||||
} else if grapheme
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
// Keep combining marks attached to Unicode letters, not just ASCII words.
|
||||
slug.push_str(grapheme);
|
||||
}
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
|
||||
let byte = text.find(needle).unwrap();
|
||||
let prefix = &text[..byte];
|
||||
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
|
||||
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
||||
link_at(text, TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
|
||||
LinkTarget {
|
||||
path: path.map(PathBuf::from),
|
||||
fragment: fragment.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_inline_labels_destinations_and_grapheme_columns() {
|
||||
let text = "👩💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
|
||||
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
|
||||
assert_eq!(at(text, "there").unwrap(), expected);
|
||||
assert_eq!(at(text, "My%20").unwrap(), expected);
|
||||
assert_eq!(
|
||||
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
|
||||
target(Some("/Tmp/Note.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[here](#title)", "here").unwrap(),
|
||||
target(None, Some("title"))
|
||||
);
|
||||
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
|
||||
assert_eq!(
|
||||
at("[here](#title)", "[here").unwrap(),
|
||||
target(None, Some("title"))
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](literal%23name.md)", "file").unwrap(),
|
||||
target(Some("literal#name.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](My\\[Notes\\].md)", "file").unwrap(),
|
||||
target(Some("My[Notes].md"), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_all_reference_forms_and_multiline_block_definitions() {
|
||||
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
|
||||
let expected = target(Some("My Notes.md"), Some("title"));
|
||||
for needle in ["full", " A B ", "a b][]", "A B]"] {
|
||||
assert_eq!(at(text, needle).unwrap(), expected);
|
||||
}
|
||||
assert_eq!(
|
||||
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
|
||||
target(Some("file.md"), None)
|
||||
);
|
||||
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
|
||||
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_images_code_plaintext_and_external_urls() {
|
||||
for (text, needle) in [
|
||||
("", "image"),
|
||||
("`[code](file.md)`", "code"),
|
||||
("```md\n[code](file.md)\n```", "file"),
|
||||
(" [code](file.md)", "code"),
|
||||
("plain file.md text", "file"),
|
||||
("[web](https://example.com)", "web"),
|
||||
("[web](//example.com/a)", "web"),
|
||||
("[web](mailto:a@example.com)", "web"),
|
||||
("<https://example.com>", "example"),
|
||||
] {
|
||||
assert!(at(text, needle).is_err(), "{text}");
|
||||
}
|
||||
assert!(
|
||||
at("[missing]", "missing")
|
||||
.unwrap_err()
|
||||
.contains("not defined")
|
||||
);
|
||||
assert!(
|
||||
at("[bad](file%ZZ.md)", "bad")
|
||||
.unwrap_err()
|
||||
.contains("percent")
|
||||
);
|
||||
assert!(
|
||||
at("[bad](file%FF.md)", "bad")
|
||||
.unwrap_err()
|
||||
.contains("UTF-8")
|
||||
);
|
||||
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
|
||||
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ \n\n```md\n# Hidden\n```\n";
|
||||
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
|
||||
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
|
||||
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
|
||||
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
|
||||
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
|
||||
assert!(heading_row(text, "hidden").is_err());
|
||||
assert!(heading_row(text, "destinationmd").is_err());
|
||||
assert!(heading_row(text, "").is_err());
|
||||
assert_eq!(
|
||||
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
|
||||
assert_eq!(
|
||||
at("[file](a&b c.md)", "file").unwrap(),
|
||||
target(Some("a&b c.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](<name>)", "file").unwrap(),
|
||||
target(Some("<name>"), None)
|
||||
);
|
||||
assert!(
|
||||
at("[file](a©b.md)", "file")
|
||||
.unwrap_err()
|
||||
.contains("Unsupported Markdown HTML entity")
|
||||
);
|
||||
assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0);
|
||||
assert!(
|
||||
heading_row("# ©\n", "copy")
|
||||
.unwrap_err()
|
||||
.contains("Unsupported Markdown HTML entity")
|
||||
);
|
||||
assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1);
|
||||
assert_eq!(
|
||||
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
//! Frontend-independent binary pane layout and spatial focus navigation.
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SplitAxis {
|
||||
/// Above/below.
|
||||
Horizontal,
|
||||
/// Left/right.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PaneDirection {
|
||||
Left,
|
||||
Down,
|
||||
Up,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PaneRect {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl PaneRect {
|
||||
fn is_empty(self) -> bool {
|
||||
self.width == 0 || self.height == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Partition a rectangle into balanced halves, with the odd cell in the second.
|
||||
pub fn split_rect(bounds: PaneRect, axis: SplitAxis) -> [PaneRect; 2] {
|
||||
let mut first = bounds;
|
||||
let mut second = bounds;
|
||||
match axis {
|
||||
SplitAxis::Horizontal => {
|
||||
first.height = bounds.height / 2;
|
||||
second.y = bounds.y.saturating_add(first.height);
|
||||
second.height -= first.height;
|
||||
}
|
||||
SplitAxis::Vertical => {
|
||||
first.width = bounds.width / 2;
|
||||
second.x = bounds.x.saturating_add(first.width);
|
||||
second.width -= first.width;
|
||||
}
|
||||
}
|
||||
[first, second]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Node {
|
||||
Leaf(usize),
|
||||
Split {
|
||||
axis: SplitAxis,
|
||||
children: Box<[Node; 2]>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn contains(&self, id: usize) -> bool {
|
||||
match self {
|
||||
Self::Leaf(leaf) => *leaf == id,
|
||||
Self::Split { children, .. } => children.iter().any(|child| child.contains(id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_id(&self) -> usize {
|
||||
match self {
|
||||
Self::Leaf(id) => *id,
|
||||
Self::Split { children, .. } => children[0].first_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool {
|
||||
match self {
|
||||
Self::Leaf(leaf) if *leaf == id => {
|
||||
*self = Self::Split {
|
||||
axis,
|
||||
children: Box::new([Self::Leaf(id), Self::Leaf(new_id)]),
|
||||
};
|
||||
true
|
||||
}
|
||||
Self::Leaf(_) => false,
|
||||
Self::Split { children, .. } => children
|
||||
.iter_mut()
|
||||
.any(|child| child.split(id, new_id, axis)),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&mut self, id: usize) -> Option<usize> {
|
||||
let Self::Split { children, .. } = self else {
|
||||
return None;
|
||||
};
|
||||
for index in 0..2 {
|
||||
if matches!(children[index], Self::Leaf(leaf) if leaf == id) {
|
||||
let sibling = 1 - index;
|
||||
let focus = children[sibling].first_id();
|
||||
*self = std::mem::replace(&mut children[sibling], Self::Leaf(id));
|
||||
return Some(focus);
|
||||
}
|
||||
}
|
||||
children.iter_mut().find_map(|child| child.close(id))
|
||||
}
|
||||
|
||||
fn visit_rects(&self, bounds: PaneRect, visitor: &mut impl FnMut(usize, PaneRect)) {
|
||||
if bounds.is_empty() {
|
||||
return;
|
||||
}
|
||||
match self {
|
||||
Self::Leaf(id) => visitor(*id, bounds),
|
||||
Self::Split { axis, children } => {
|
||||
let halves = split_rect(bounds, *axis);
|
||||
children[0].visit_rects(halves[0], visitor);
|
||||
children[1].visit_rects(halves[1], visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PaneTree {
|
||||
root: Node,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl PaneTree {
|
||||
pub fn new(id: usize) -> Self {
|
||||
Self {
|
||||
root: Node::Leaf(id),
|
||||
len: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `id` first and insert `new_id` second. IDs must be unique.
|
||||
pub fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool {
|
||||
if self.contains(new_id) || !self.root.split(id, new_id, axis) {
|
||||
return false;
|
||||
}
|
||||
self.len += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Close a leaf and expand its sibling; return the sibling's first leaf.
|
||||
/// Unknown IDs and closing the last leaf leave the tree unchanged.
|
||||
pub fn close(&mut self, id: usize) -> Option<usize> {
|
||||
let focus = self.root.close(id)?;
|
||||
self.len -= 1;
|
||||
Some(focus)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: usize) -> bool {
|
||||
self.root.contains(id)
|
||||
}
|
||||
|
||||
/// Visit nonempty leaves in tree order, without allocating.
|
||||
pub fn visit_rects(&self, bounds: PaneRect, mut visitor: impl FnMut(usize, PaneRect)) {
|
||||
self.root.visit_rects(bounds, &mut visitor);
|
||||
}
|
||||
|
||||
pub fn neighbor(&self, id: usize, direction: PaneDirection, bounds: PaneRect) -> Option<usize> {
|
||||
let mut current = None;
|
||||
self.visit_rects(bounds, |leaf, rect| {
|
||||
if leaf == id {
|
||||
current = Some(rect);
|
||||
}
|
||||
});
|
||||
let current = current?;
|
||||
let mut best = None;
|
||||
self.visit_rects(bounds, |leaf, rect| {
|
||||
if leaf != id {
|
||||
consider_neighbor(current, direction, leaf, rect, &mut best);
|
||||
}
|
||||
});
|
||||
best.map(|(_, leaf)| leaf)
|
||||
}
|
||||
}
|
||||
|
||||
// Score: prefer perpendicular overlap, then the nearest forward edge,
|
||||
// perpendicular gap, center alignment, and finally the stable ID.
|
||||
type NeighborScore = (bool, u32, u32, u32, usize);
|
||||
|
||||
fn neighbor_score(
|
||||
current: PaneRect,
|
||||
direction: PaneDirection,
|
||||
id: usize,
|
||||
candidate: PaneRect,
|
||||
) -> Option<NeighborScore> {
|
||||
if current.is_empty() || candidate.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let edges = |rect: PaneRect| {
|
||||
let x = u32::from(rect.x);
|
||||
let y = u32::from(rect.y);
|
||||
(x, y, x + u32::from(rect.width), y + u32::from(rect.height))
|
||||
};
|
||||
let (x, y, right, bottom) = edges(current);
|
||||
let (cx, cy, cright, cbottom) = edges(candidate);
|
||||
let (distance, start, end, cstart, cend) = match direction {
|
||||
PaneDirection::Left if cright <= x => (x - cright, y, bottom, cy, cbottom),
|
||||
PaneDirection::Right if cx >= right => (cx - right, y, bottom, cy, cbottom),
|
||||
PaneDirection::Up if cbottom <= y => (y - cbottom, x, right, cx, cright),
|
||||
PaneDirection::Down if cy >= bottom => (cy - bottom, x, right, cx, cright),
|
||||
_ => return None,
|
||||
};
|
||||
let overlap = start.max(cstart) < end.min(cend);
|
||||
let gap = start.max(cstart).saturating_sub(end.min(cend));
|
||||
let alignment = (start + end).abs_diff(cstart + cend);
|
||||
Some((!overlap, distance, gap, alignment, id))
|
||||
}
|
||||
|
||||
fn consider_neighbor(
|
||||
current: PaneRect,
|
||||
direction: PaneDirection,
|
||||
id: usize,
|
||||
candidate: PaneRect,
|
||||
best: &mut Option<(NeighborScore, usize)>,
|
||||
) {
|
||||
if let Some(score) = neighbor_score(current, direction, id, candidate)
|
||||
&& best.as_ref().is_none_or(|(previous, _)| score < *previous)
|
||||
{
|
||||
*best = Some((score, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a forward rectangle, favoring overlap and the closest edge.
|
||||
/// Empty rectangles are ignored; ties are independent of iterator order.
|
||||
pub fn directional_neighbor(
|
||||
current: PaneRect,
|
||||
direction: PaneDirection,
|
||||
candidates: impl Iterator<Item = (usize, PaneRect)>,
|
||||
) -> Option<usize> {
|
||||
let mut best = None;
|
||||
for (id, candidate) in candidates {
|
||||
consider_neighbor(current, direction, id, candidate, &mut best);
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
|
||||
PaneRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
|
||||
let mut leaves = Vec::new();
|
||||
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
|
||||
leaves
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_layout_partitions_bounds_and_preserves_ids() {
|
||||
let bounds = rect(3, 5, 11, 9);
|
||||
let mut tree = PaneTree::new(10);
|
||||
assert!(tree.split(10, 20, SplitAxis::Vertical));
|
||||
assert!(tree.split(20, 30, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
|
||||
assert_eq!(tree.len(), 3);
|
||||
let panes = leaves(&tree, bounds);
|
||||
assert_eq!(
|
||||
panes,
|
||||
vec![
|
||||
(10, rect(3, 5, 5, 9)),
|
||||
(20, rect(8, 5, 6, 4)),
|
||||
(30, rect(8, 9, 6, 5))
|
||||
]
|
||||
);
|
||||
let mut cells = std::collections::HashSet::new();
|
||||
for (_, pane) in panes {
|
||||
for y in pane.y..pane.y + pane.height {
|
||||
for x in pane.x..pane.x + pane.width {
|
||||
assert!(cells.insert((x, y)), "overlapping pane cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
usize::from(bounds.width) * usize::from(bounds.height)
|
||||
);
|
||||
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
|
||||
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_expands_nested_sibling_and_refuses_last() {
|
||||
let bounds = rect(0, 0, 20, 12);
|
||||
let mut tree = PaneTree::new(1);
|
||||
tree.split(1, 2, SplitAxis::Vertical);
|
||||
tree.split(2, 3, SplitAxis::Horizontal);
|
||||
assert_eq!(tree.close(1), Some(2));
|
||||
assert_eq!(
|
||||
leaves(&tree, bounds),
|
||||
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
|
||||
);
|
||||
assert_eq!(tree.close(99), None);
|
||||
assert_eq!(tree.close(2), Some(3));
|
||||
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
|
||||
assert_eq!(tree.close(3), None);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert!(!tree.contains(1));
|
||||
assert!(tree.contains(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_focus_handles_unequal_nested_regions() {
|
||||
let bounds = rect(0, 0, 20, 12);
|
||||
let mut tree = PaneTree::new(1);
|
||||
tree.split(1, 2, SplitAxis::Vertical);
|
||||
tree.split(2, 3, SplitAxis::Horizontal);
|
||||
tree.split(3, 4, SplitAxis::Horizontal);
|
||||
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
|
||||
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
|
||||
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
|
||||
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
|
||||
assert_eq!(
|
||||
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
|
||||
let current = rect(10, 10, 10, 10);
|
||||
let candidates = [
|
||||
(1, rect(20, 0, 5, 5)),
|
||||
(2, rect(25, 10, 5, 10)),
|
||||
(4, rect(22, 10, 5, 10)),
|
||||
(3, rect(22, 10, 5, 10)),
|
||||
(0, rect(20, 10, 0, 10)),
|
||||
];
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(
|
||||
current,
|
||||
PaneDirection::Up,
|
||||
[(7, rect(10, 0, 10, 5))].into_iter()
|
||||
),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,48 @@ pub struct SemanticToken {
|
||||
pub modifiers: u16,
|
||||
}
|
||||
|
||||
/// A completion replacement in zero-based, end-exclusive grapheme coordinates.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionEdit {
|
||||
pub start: TextPosition,
|
||||
pub end: TextPosition,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Expanded snippet tabstops, in byte offsets relative to the primary edit text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionSnippet {
|
||||
/// Positive tabstops in ascending order, followed by the final zero tabstop.
|
||||
pub tabstops: Vec<SnippetTabstop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SnippetTabstop {
|
||||
pub index: u32,
|
||||
/// Repeated occurrences of the same tabstop are edited together.
|
||||
pub ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Enclosing numbered stops per occurrence, outermost first; parallel to ranges.
|
||||
pub ancestors: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// Protocol-independent completion text and its atomic document edits.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionItem {
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
/// Index of the primary replacement; other edits never determine caret placement.
|
||||
pub primary_edit: usize,
|
||||
pub edits: Vec<CompletionEdit>,
|
||||
pub snippet: Option<CompletionSnippet>,
|
||||
}
|
||||
|
||||
/// Native LSP range; each coordinate is (zero-based row, UTF-16 column).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Utf16Range {
|
||||
pub start: (usize, usize),
|
||||
pub end: (usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UpdateEffect {
|
||||
@@ -127,14 +169,33 @@ pub enum UpdateEffect {
|
||||
row: usize,
|
||||
column: usize,
|
||||
},
|
||||
/// Native LSP targets retain UTF-16 coordinates until their source text is resolved.
|
||||
#[serde(skip_deserializing)]
|
||||
NavigateUtf16 {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
path: PathBuf,
|
||||
range: Utf16Range,
|
||||
target_range: Option<Utf16Range>,
|
||||
},
|
||||
Hover {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
text: String,
|
||||
},
|
||||
#[serde(skip_deserializing)]
|
||||
Completion {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
items: Vec<CompletionItem>,
|
||||
},
|
||||
Message {
|
||||
text: String,
|
||||
},
|
||||
Label {
|
||||
name: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+3080
File diff suppressed because it is too large
Load Diff
+983
-50
File diff suppressed because it is too large
Load Diff
+276
-7
@@ -1,11 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Vamp
|
||||
# @param name [String, Symbol]
|
||||
# @return [String]
|
||||
def self.chrome_name!(name)
|
||||
name = name.to_s if name.is_a?(Symbol)
|
||||
name = string!(name, "label/segment name")
|
||||
unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
|
||||
raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*"
|
||||
end
|
||||
name
|
||||
end
|
||||
|
||||
# @param value [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def self.chrome_hash!(value)
|
||||
raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash)
|
||||
value.each_with_object({}) do |(key, item), result|
|
||||
key = key.to_s if key.is_a?(Symbol)
|
||||
key = string!(key, "chrome key")
|
||||
raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key)
|
||||
result[key] = item
|
||||
end
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def self.chrome_line!(options)
|
||||
chrome_hash!(options).to_h do |name, value|
|
||||
if %w[left center right].include?(name)
|
||||
raise TypeError, "#{name} must be an Array" unless value.is_a?(Array)
|
||||
[name, value.map { |item| chrome_name!(item) }]
|
||||
else
|
||||
chrome_name!(name)
|
||||
style = chrome_hash!(value).to_h do |option, item|
|
||||
case option
|
||||
when "padding", "margin", "leading_margin", "trailing_margin"
|
||||
unless item.is_a?(Integer) && item.between?(0, 64)
|
||||
raise ArgumentError, "#{option} must be an Integer between 0 and 64"
|
||||
end
|
||||
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
|
||||
item = string!(item, option)
|
||||
else
|
||||
raise ArgumentError, "unknown chrome style option: #{option}"
|
||||
end
|
||||
[option, item]
|
||||
end
|
||||
[name, style]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Collects options for the independently configured top and bottom bars.
|
||||
class BarConfig
|
||||
# @return [Hash{String => Object}]
|
||||
attr_reader :top, :bottom
|
||||
|
||||
# @return [void]
|
||||
def initialize
|
||||
@top = {}
|
||||
@bottom = {}
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def top=(options)
|
||||
@top = Vamp.chrome_line!(options)
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def bottom=(options)
|
||||
@bottom = Vamp.chrome_line!(options)
|
||||
end
|
||||
end
|
||||
|
||||
# Collects anchor options for the independently positioned command and search popups.
|
||||
class PopupConfig
|
||||
# @return [Hash{String => String}]
|
||||
attr_reader :command, :search
|
||||
|
||||
# @return [void]
|
||||
def initialize
|
||||
@command = {}
|
||||
@search = {}
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
||||
# @return [Hash{String => String}]
|
||||
def command=(options)
|
||||
@command = options!(options)
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
||||
# @return [Hash{String => String}]
|
||||
def search=(options)
|
||||
@search = options!(options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param options [Hash{String, Symbol => String, Symbol}]
|
||||
# @return [Hash{String => String}]
|
||||
def options!(options)
|
||||
Vamp.chrome_hash!(options).to_h do |option, value|
|
||||
raise ArgumentError, "unknown popup option: #{option}" unless option == "anchor"
|
||||
value = value.to_s if value.is_a?(Symbol)
|
||||
value = Vamp.string!(value, "popup anchor")
|
||||
unless %w[top_left top_center top_right center_left center center_right bottom_left bottom_center bottom_right].include?(value)
|
||||
raise ArgumentError, "unknown popup anchor: #{value}"
|
||||
end
|
||||
[option, value]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Collects and validates the Ruby startup configuration DSL.
|
||||
class Config
|
||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||
attr_reader :lsp
|
||||
|
||||
# @return [Vamp::BarConfig]
|
||||
attr_reader :bar
|
||||
|
||||
# @return [Vamp::PopupConfig]
|
||||
attr_reader :popup
|
||||
|
||||
# @return [void]
|
||||
def initialize
|
||||
@plugins = []
|
||||
@@ -16,6 +136,20 @@ module Vamp
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
@git_blame = true
|
||||
@git_gutter = "bar"
|
||||
@bar = BarConfig.new
|
||||
@popup = PopupConfig.new
|
||||
@labels = {}
|
||||
@scroll_distance = 0
|
||||
@highlight_trailing_whitespace = false
|
||||
@whitespace_characters = {}
|
||||
@whitespace_scope = "all"
|
||||
@diff_context_lines = 10
|
||||
@diff_expand_lines = 15
|
||||
@diff_view_mode = "split"
|
||||
@diff_split_orientation = "vertical"
|
||||
@diff_follow_working_copy = true
|
||||
end
|
||||
|
||||
# @param path [String] relative to the configuration file
|
||||
@@ -60,6 +194,104 @@ module Vamp
|
||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def git_blame=(enabled)
|
||||
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@git_blame = enabled
|
||||
end
|
||||
|
||||
# @param style [String] "bar", "symbols", "line_numbers", or "none"
|
||||
def git_gutter=(style)
|
||||
style = nonempty_string!(style, "git gutter")
|
||||
raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style)
|
||||
|
||||
@git_gutter = style
|
||||
end
|
||||
|
||||
# @param values [Hash{String, Symbol => String}]
|
||||
# @return [Hash]
|
||||
def labels=(values)
|
||||
@labels = Vamp.chrome_hash!(values).to_h do |name, text|
|
||||
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
|
||||
end
|
||||
end
|
||||
|
||||
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
|
||||
# @return [Integer, nil]
|
||||
def scroll_distance=(distance)
|
||||
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def highlight_trailing_whitespace=(enabled)
|
||||
raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@highlight_trailing_whitespace = enabled
|
||||
end
|
||||
|
||||
# @param characters [Hash{String => String}] whitespace character to single-cell marker
|
||||
# @return [Hash{String => String}]
|
||||
def whitespace_characters=(characters)
|
||||
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
|
||||
|
||||
characters.each do |whitespace, marker|
|
||||
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
|
||||
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
|
||||
end
|
||||
end
|
||||
@whitespace_characters = characters.dup
|
||||
end
|
||||
|
||||
# @param scope [String] "all" or "leading"
|
||||
# @return [String]
|
||||
def whitespace_scope=(scope)
|
||||
scope = Vamp.string!(scope, "whitespace_scope")
|
||||
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
|
||||
|
||||
@whitespace_scope = scope
|
||||
end
|
||||
|
||||
# @param lines [Integer] nonnegative context lines around each diff change
|
||||
# @return [Integer]
|
||||
def diff_context_lines=(lines)
|
||||
@diff_context_lines = diff_line_count!(lines, "diff_context_lines", 0)
|
||||
end
|
||||
|
||||
# @param lines [Integer] positive lines revealed per diff expansion
|
||||
# @return [Integer]
|
||||
def diff_expand_lines=(lines)
|
||||
@diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1)
|
||||
end
|
||||
|
||||
# @param mode [String] "split" or "unified"
|
||||
# @return [String]
|
||||
def diff_view_mode=(mode)
|
||||
mode = nonempty_string!(mode, "diff view mode")
|
||||
raise ArgumentError, "diff view mode must be split or unified" unless %w[split unified].include?(mode)
|
||||
@diff_view_mode = mode
|
||||
end
|
||||
|
||||
# @param orientation [String] "vertical" or "horizontal"
|
||||
# @return [String]
|
||||
def diff_split_orientation=(orientation)
|
||||
orientation = nonempty_string!(orientation, "diff split orientation")
|
||||
unless %w[vertical horizontal].include?(orientation)
|
||||
raise ArgumentError, "diff split orientation must be vertical or horizontal"
|
||||
end
|
||||
@diff_split_orientation = orientation
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def diff_follow_working_copy=(enabled)
|
||||
raise TypeError, "diff_follow_working_copy must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@diff_follow_working_copy = enabled
|
||||
end
|
||||
|
||||
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
||||
def core
|
||||
Core
|
||||
@@ -90,10 +322,34 @@ module Vamp
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode }
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||
"git_blame" => @git_blame,
|
||||
"git_gutter" => @git_gutter,
|
||||
"scroll_distance" => @scroll_distance,
|
||||
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
||||
"whitespace_characters" => @whitespace_characters,
|
||||
"whitespace_scope" => @whitespace_scope,
|
||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
||||
"diff_follow_working_copy" => @diff_follow_working_copy }
|
||||
end
|
||||
|
||||
private
|
||||
# @param value [Integer]
|
||||
# @param label [String]
|
||||
# @param minimum [Integer]
|
||||
# @return [Integer]
|
||||
def diff_line_count!(value, label, minimum)
|
||||
raise TypeError, "#{label} must be an Integer" unless value.is_a?(Integer)
|
||||
maximum = (1 << 64) - 1
|
||||
unless value.between?(minimum, maximum)
|
||||
raise ArgumentError, "#{label} must be between #{minimum} and #{maximum}"
|
||||
end
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
# @param value [String]
|
||||
# @param label [String]
|
||||
@@ -124,7 +380,9 @@ module Vamp
|
||||
# @return [String]
|
||||
def external_command(command)
|
||||
name = Vamp.string!(command, "binding command")
|
||||
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
|
||||
if name.start_with?("core.") || Vamp.builtin_command?(name)
|
||||
raise TypeError, "use config.core for built-in actions"
|
||||
end
|
||||
|
||||
name
|
||||
end
|
||||
@@ -172,7 +430,7 @@ module Vamp
|
||||
attr_reader :path
|
||||
# @return [Integer] positive invocation ID
|
||||
attr_reader :request_id
|
||||
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
||||
# @return [Array<Hash{String => String}>] queued text and presentation effects
|
||||
attr_reader :effects
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||
@@ -206,6 +464,14 @@ module Vamp
|
||||
queue("message", text)
|
||||
end
|
||||
|
||||
# @param name [String, Symbol]
|
||||
# @param text [String] named session-wide plain text; empty clears it
|
||||
# @return [nil]
|
||||
def label(name, text)
|
||||
name = Vamp.chrome_name!(name)
|
||||
queue("label", text, name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}]
|
||||
@@ -250,15 +516,18 @@ module Vamp
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
|
||||
# @param type [String] insert, replace, or message
|
||||
# @param type [String] insert, replace, message, or label
|
||||
# @param text [String]
|
||||
# @param name [String, nil]
|
||||
# @return [nil]
|
||||
def queue(type, text)
|
||||
def queue(type, text, name = nil)
|
||||
text = Vamp.string!(text, "effect text")
|
||||
@effect_bytes += text.bytesize
|
||||
@effect_bytes += text.bytesize + (name ? name.bytesize : 0)
|
||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||
|
||||
@effects << { "type" => type, "text" => text }
|
||||
effect = { "type" => type, "text" => text }
|
||||
effect["name"] = name if name
|
||||
@effects << effect
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ module Vamp
|
||||
# @param startup [Object] action catalog and options
|
||||
# @return [void]
|
||||
def self.startup!(startup)
|
||||
return if hash_shape?(startup, %w[core_actions options]) && startup["options"].is_a?(Hash)
|
||||
return if hash_shape?(startup, %w[command_aliases core_actions options]) && startup["options"].is_a?(Hash)
|
||||
|
||||
raise TypeError, "invalid Ruby startup catalog/options"
|
||||
end
|
||||
@@ -100,7 +100,7 @@ module Vamp
|
||||
# @return [Proc, Method] the installed sender
|
||||
def self.setup(mode, startup, sender)
|
||||
FrameValidation.startup!(startup)
|
||||
install_actions(startup["core_actions"])
|
||||
install_actions(startup["core_actions"], startup["command_aliases"])
|
||||
@mode = mode
|
||||
reset_registrations
|
||||
@options = deep_freeze(json_value!(startup["options"]))
|
||||
@@ -168,6 +168,7 @@ module Vamp
|
||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||
|
||||
name = registration_name!(name, "command")
|
||||
raise ArgumentError, "built-in command #{name} cannot be replaced" if Vamp.builtin_command?(name)
|
||||
raise ArgumentError, "command requires a block" unless callback
|
||||
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
||||
|
||||
|
||||
+40
-14
@@ -58,16 +58,41 @@ module Vamp
|
||||
# @type [Module<Vamp::Core>]
|
||||
Core = Module.new
|
||||
|
||||
# @param names [Array<String>] dotted core action names
|
||||
# @return [Array<Module>] frozen action namespaces
|
||||
def self.install_actions(names)
|
||||
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||
# @param entries [Array<Hash{String => String, nil}>] action name and optional canonical Ex name
|
||||
# @param command_aliases [Hash{String => String}] shorthand to canonical commands
|
||||
# @return [Array<Module, Vamp::Action>] frozen action namespaces and leaves
|
||||
def self.install_actions(entries, command_aliases)
|
||||
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
|
||||
raise TypeError, "command aliases must be a Hash" unless command_aliases.is_a?(Hash)
|
||||
|
||||
namespaces = [Core]
|
||||
names.each { |name| install_action(name, namespaces) }
|
||||
commands = {}
|
||||
entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry|
|
||||
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
|
||||
|
||||
install_action(entry.fetch("name"), namespaces)
|
||||
ex = entry.fetch("ex")
|
||||
canonical = string!(ex, "core Ex command").freeze unless ex.nil?
|
||||
commands[canonical] = canonical unless ex.nil?
|
||||
end
|
||||
canonical_commands = commands.keys
|
||||
command_aliases.each do |name, canonical|
|
||||
name = string!(name, "command alias").freeze
|
||||
canonical = string!(canonical, "canonical command").freeze
|
||||
raise ArgumentError, "unknown canonical command: #{canonical}" unless canonical_commands.include?(canonical)
|
||||
|
||||
commands[name] = canonical
|
||||
end
|
||||
@builtin_commands = commands.freeze
|
||||
namespaces.each(&:freeze)
|
||||
end
|
||||
|
||||
# @param name [String] binding command name
|
||||
# @return [Boolean] whether the catalog owns this canonical command or shorthand
|
||||
def self.builtin_command?(name)
|
||||
@builtin_commands.key?(name)
|
||||
end
|
||||
|
||||
# Frozen binding mode values, distinct from a buffer's editing state.
|
||||
module Modes
|
||||
# @!method self.normal
|
||||
@@ -125,7 +150,7 @@ module Vamp
|
||||
end
|
||||
|
||||
# @param name [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @return [Symbol] the installed action method name
|
||||
def self.install_action(name, namespaces)
|
||||
name = action_name(name)
|
||||
@@ -133,7 +158,8 @@ module Vamp
|
||||
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
||||
leaf = parts.last
|
||||
available_action!(namespace, leaf, name)
|
||||
action = Action.send(:new, name.freeze).freeze
|
||||
action = Action.send(:new, name.freeze)
|
||||
namespaces << action
|
||||
namespace.define_singleton_method(leaf) { action }
|
||||
end
|
||||
|
||||
@@ -146,23 +172,23 @@ module Vamp
|
||||
name
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param parent [Module, Vamp::Action]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @param name [String]
|
||||
# @return [Module]
|
||||
# @return [Module, Vamp::Action]
|
||||
def self.action_namespace(parent, part, namespaces, name)
|
||||
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
||||
|
||||
child = parent.public_send(part)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) || child.is_a?(Action)
|
||||
|
||||
child
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param parent [Module, Vamp::Action]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @return [Module]
|
||||
def self.new_action_namespace(parent, part, namespaces)
|
||||
child = Module.new
|
||||
@@ -171,7 +197,7 @@ module Vamp
|
||||
child
|
||||
end
|
||||
|
||||
# @param namespace [Module]
|
||||
# @param namespace [Module, Vamp::Action]
|
||||
# @param leaf [String]
|
||||
# @param name [String]
|
||||
# @return [nil]
|
||||
|
||||
+1988
-62
File diff suppressed because it is too large
Load Diff
+485
@@ -0,0 +1,485 @@
|
||||
use std::{collections::BTreeMap, ops::Range};
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::plugin::{CompletionSnippet, SnippetTabstop};
|
||||
|
||||
const MAX_BYTES: usize = 256 * 1024;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const MAX_STOPS: usize = 1024;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_WORK: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Error {
|
||||
Invalid,
|
||||
Unsupported,
|
||||
Limit,
|
||||
}
|
||||
|
||||
enum Node<'a> {
|
||||
Text(&'a str),
|
||||
Value(String),
|
||||
Stop(u32, Option<Vec<usize>>),
|
||||
Default(Vec<usize>),
|
||||
}
|
||||
|
||||
struct Parser<'a, F> {
|
||||
text: &'a str,
|
||||
cursor: usize,
|
||||
nodes: Vec<Node<'a>>,
|
||||
value_bytes: usize,
|
||||
variable: F,
|
||||
}
|
||||
|
||||
/// Parse normalized snippet text before assigning any byte offsets. Unsupported
|
||||
/// constructs omit the entire candidate, never leaking template syntax into text.
|
||||
pub(crate) fn expand(
|
||||
text: &str,
|
||||
variable: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<(String, CompletionSnippet), Error> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let normalized = if text.contains('\r') {
|
||||
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(text)
|
||||
};
|
||||
let mut parser = Parser {
|
||||
text: &normalized,
|
||||
cursor: 0,
|
||||
nodes: Vec::new(),
|
||||
value_bytes: 0,
|
||||
variable,
|
||||
};
|
||||
let roots = parser.sequence(false, 0)?;
|
||||
let mut definitions = BTreeMap::new();
|
||||
let mut pending: Vec<_> = roots.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
match &parser.nodes[id] {
|
||||
Node::Stop(index, Some(children)) => {
|
||||
definitions.entry(*index).or_insert(id);
|
||||
pending.extend(children.iter().rev().copied());
|
||||
}
|
||||
Node::Default(children) => pending.extend(children.iter().rev().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut rendered = Render {
|
||||
nodes: &parser.nodes,
|
||||
definitions: &definitions,
|
||||
text: String::new(),
|
||||
stops: BTreeMap::new(),
|
||||
ancestors: Vec::new(),
|
||||
occurrences: 0,
|
||||
work: 0,
|
||||
};
|
||||
rendered.sequence(&roots, 0)?;
|
||||
if !rendered.stops.contains_key(&0) {
|
||||
if rendered.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
rendered.stops.insert(
|
||||
0,
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: std::iter::once(rendered.text.len()..rendered.text.len()).collect(),
|
||||
ancestors: vec![Vec::new()],
|
||||
},
|
||||
);
|
||||
}
|
||||
let final_stop = rendered.stops.get(&0).unwrap();
|
||||
if final_stop.ranges.len() != 1
|
||||
|| !final_stop.ranges[0].is_empty()
|
||||
|| !final_stop.ancestors[0].is_empty()
|
||||
|| rendered.stops.values().any(|stop| {
|
||||
stop.ancestors
|
||||
.iter()
|
||||
.any(|ancestors| ancestors.contains(&0))
|
||||
})
|
||||
{
|
||||
return Err(Error::Unsupported);
|
||||
}
|
||||
let boundaries: Vec<_> = rendered
|
||||
.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(offset, _)| offset)
|
||||
.chain(std::iter::once(rendered.text.len()))
|
||||
.collect();
|
||||
for stop in rendered.stops.values() {
|
||||
let mut previous: Option<&Range<usize>> = None;
|
||||
for range in &stop.ranges {
|
||||
if boundaries.binary_search(&range.start).is_err()
|
||||
|| boundaries.binary_search(&range.end).is_err()
|
||||
|| previous.is_some_and(|old| {
|
||||
old.end > range.start
|
||||
|| (old.start == range.start && !(old.is_empty() && range.is_empty()))
|
||||
})
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
previous = Some(range);
|
||||
}
|
||||
}
|
||||
let final_stop = rendered.stops.remove(&0).unwrap();
|
||||
let mut tabstops: Vec<_> = rendered.stops.into_values().collect();
|
||||
tabstops.push(final_stop);
|
||||
Ok((rendered.text, CompletionSnippet { tabstops }))
|
||||
}
|
||||
|
||||
impl<'a, F: Fn(&str) -> Option<String>> Parser<'a, F> {
|
||||
fn node(&mut self, mut node: Node<'a>) -> Result<usize, Error> {
|
||||
if self.nodes.len() == MAX_NODES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if let Node::Value(value) = &mut node {
|
||||
if value.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if value.contains('\r') {
|
||||
*value = value.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
self.value_bytes = self
|
||||
.value_bytes
|
||||
.checked_add(value.len())
|
||||
.filter(|size| *size <= MAX_BYTES)
|
||||
.ok_or(Error::Limit)?;
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(node);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn byte(&self) -> Option<u8> {
|
||||
self.text.as_bytes().get(self.cursor).copied()
|
||||
}
|
||||
|
||||
fn sequence(&mut self, nested: bool, depth: usize) -> Result<Vec<usize>, Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
while let Some(byte) = self.byte() {
|
||||
if nested && byte == b'}' {
|
||||
self.cursor += 1;
|
||||
return Ok(nodes);
|
||||
}
|
||||
if byte == b'$' {
|
||||
nodes.push(self.construct(depth)?);
|
||||
} else if byte == b'\\' {
|
||||
self.cursor += 1;
|
||||
let start = self.cursor;
|
||||
if !matches!(self.byte(), Some(b'$' | b'}' | b'\\')) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
} else {
|
||||
let start = self.cursor;
|
||||
while let Some(next) = self.byte() {
|
||||
if next == b'$' || next == b'\\' || (nested && next == b'}') {
|
||||
break;
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
}
|
||||
}
|
||||
if nested {
|
||||
Err(Error::Invalid)
|
||||
} else {
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
fn construct(&mut self, depth: usize) -> Result<usize, Error> {
|
||||
self.cursor += 1;
|
||||
let braced = self.byte() == Some(b'{');
|
||||
self.cursor += usize::from(braced);
|
||||
let start = self.cursor;
|
||||
let numbered = self.byte().is_some_and(|byte| byte.is_ascii_digit());
|
||||
if numbered {
|
||||
while self.byte().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
} else {
|
||||
if !self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
while self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
let name = &self.text[start..self.cursor];
|
||||
let mut children = None;
|
||||
if braced {
|
||||
match self.byte() {
|
||||
Some(b'}') => self.cursor += 1,
|
||||
Some(b':') => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.sequence(true, depth + 1)?);
|
||||
}
|
||||
Some(b'|') if numbered => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.choice()?);
|
||||
}
|
||||
Some(b'/') => return Err(Error::Unsupported),
|
||||
_ => return Err(Error::Invalid),
|
||||
}
|
||||
}
|
||||
if numbered {
|
||||
let index = name.parse::<u32>().map_err(|_| Error::Limit)?;
|
||||
self.node(Node::Stop(index, children))
|
||||
} else {
|
||||
match (self.variable)(name) {
|
||||
Some(value) if !value.is_empty() => self.node(Node::Value(value)),
|
||||
value => match children {
|
||||
Some(children) => self.node(Node::Default(children)),
|
||||
None if value.is_some() => self.node(Node::Text("")),
|
||||
None => Err(Error::Unsupported),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choice(&mut self) -> Result<Vec<usize>, Error> {
|
||||
let mut first = String::new();
|
||||
let mut keep = true;
|
||||
loop {
|
||||
match self.byte().ok_or(Error::Invalid)? {
|
||||
b'|' => {
|
||||
self.cursor += 1;
|
||||
if self.byte() != Some(b'}') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
return Ok(vec![self.node(Node::Value(first))?]);
|
||||
}
|
||||
b',' => {
|
||||
keep = false;
|
||||
self.cursor += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
self.cursor += 1;
|
||||
let byte = self.byte().ok_or(Error::Invalid)?;
|
||||
if !matches!(byte, b'$' | b'}' | b'\\' | b',' | b'|') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if keep {
|
||||
first.push(byte as char);
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
_ => {
|
||||
let ch = self.text[self.cursor..].chars().next().unwrap();
|
||||
if keep {
|
||||
first.push(ch);
|
||||
}
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Render<'a> {
|
||||
nodes: &'a [Node<'a>],
|
||||
definitions: &'a BTreeMap<u32, usize>,
|
||||
text: String,
|
||||
stops: BTreeMap<u32, SnippetTabstop>,
|
||||
ancestors: Vec<u32>,
|
||||
occurrences: usize,
|
||||
work: usize,
|
||||
}
|
||||
|
||||
impl Render<'_> {
|
||||
fn sequence(&mut self, nodes: &[usize], depth: usize) -> Result<(), Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let arena = self.nodes;
|
||||
for &id in nodes {
|
||||
self.work += 1;
|
||||
if self.work > MAX_WORK {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
match &arena[id] {
|
||||
Node::Text(text) => self.append(text)?,
|
||||
Node::Value(text) => self.append(text)?,
|
||||
Node::Default(children) => self.sequence(children, depth + 1)?,
|
||||
Node::Stop(index, _) => {
|
||||
if self.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.occurrences += 1;
|
||||
let start = self.text.len();
|
||||
let ancestors = self.ancestors.clone();
|
||||
if let Some(&definition) = self.definitions.get(index) {
|
||||
if self.ancestors.contains(index) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.ancestors.push(*index);
|
||||
if let Node::Stop(_, Some(children)) = &arena[definition] {
|
||||
self.sequence(children, depth + 1)?;
|
||||
}
|
||||
self.ancestors.pop();
|
||||
}
|
||||
let stop = self.stops.entry(*index).or_insert_with(|| SnippetTabstop {
|
||||
index: *index,
|
||||
ranges: Vec::new(),
|
||||
ancestors: Vec::new(),
|
||||
});
|
||||
stop.ranges.push(start..self.text.len());
|
||||
stop.ancestors.push(ancestors);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append(&mut self, text: &str) -> Result<(), Error> {
|
||||
if self
|
||||
.text
|
||||
.len()
|
||||
.checked_add(text.len())
|
||||
.is_none_or(|size| size > MAX_BYTES)
|
||||
{
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.text.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
||||
let (text, snippet) = expand(
|
||||
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
||||
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..8, 10..13],
|
||||
ancestors: vec![vec![], vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..8, 12..13],
|
||||
ancestors: vec![vec![1], vec![1]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 3,
|
||||
ranges: vec![15..18],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![23..23],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
||||
assert_eq!(text, "call(, )");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..5],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..7],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![8..8],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expand("${KNOWN:${1:hidden}}$1", |name| {
|
||||
(name == "KNOWN").then(|| "shown".to_owned())
|
||||
})
|
||||
.unwrap()
|
||||
.0,
|
||||
"shown"
|
||||
);
|
||||
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
||||
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(a\nb\nc)");
|
||||
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
||||
assert_eq!(
|
||||
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
||||
"a|b}\\"
|
||||
);
|
||||
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
||||
if input == "${1:a}${1:b}$1" {
|
||||
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
||||
} else {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
||||
}
|
||||
}
|
||||
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
||||
assert!(text.is_empty());
|
||||
assert_eq!(
|
||||
snippet.tabstops[0],
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![0..0, 0..0],
|
||||
ancestors: vec![vec![], vec![]],
|
||||
}
|
||||
);
|
||||
for input in [
|
||||
"$UNKNOWN",
|
||||
"${TM_FILENAME/(.*)/$1/}",
|
||||
"${1/a/b/}",
|
||||
"${1:a$0b}",
|
||||
"$0$0",
|
||||
"${0:arg}",
|
||||
"${0:$1}",
|
||||
] {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
||||
}
|
||||
assert_eq!(
|
||||
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
assert_eq!(
|
||||
expand(
|
||||
&format!(
|
||||
"{}x{}",
|
||||
"${1:".repeat(MAX_DEPTH + 1),
|
||||
"}".repeat(MAX_DEPTH + 1)
|
||||
),
|
||||
|_| None
|
||||
),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
}
|
||||
}
|
||||
+749
-137
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,14 @@ static MD_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
||||
(setext_h1_underline)
|
||||
(setext_h2_underline)
|
||||
] @text.title
|
||||
|
||||
[
|
||||
(list_marker_minus)
|
||||
(list_marker_plus)
|
||||
(list_marker_star)
|
||||
(list_marker_dot)
|
||||
(list_marker_parenthesis)
|
||||
] @markup.list
|
||||
",
|
||||
]
|
||||
.join("\n")
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Write},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
// TODO: Remove periodic polling once a released Herdr fix reliably delivers palette-ready
|
||||
// DEC 2031 notifications, including changes with unchanged light/dark preference.
|
||||
// Verify notification-triggered queries return fresh colors before removing this fallback.
|
||||
// Upstream: https://github.com/herdrdev/herdr/issues/4183
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const ESCAPE_TIMEOUT: Duration = Duration::from_millis(25);
|
||||
const HEADERS: [&[u8]; 3] = [b"11;", b"4;1;", b"4;2;"];
|
||||
const MAX_BODY: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ThemeColors {
|
||||
pub background: [u8; 3],
|
||||
pub red: [u8; 3],
|
||||
pub green: [u8; 3],
|
||||
}
|
||||
|
||||
struct Query {
|
||||
deadline: Instant,
|
||||
colors: [Option<[u8; 3]>; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum Capture {
|
||||
#[default]
|
||||
Idle,
|
||||
Escape(Instant),
|
||||
Report(Instant),
|
||||
Terminator,
|
||||
}
|
||||
|
||||
/// Adapts crossterm's key-shaped OSC replies without reading from stdin itself.
|
||||
/// Only complete color reports are consumed; rejected candidates retain their
|
||||
/// original events, including modifiers, kind, and state.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TerminalColors {
|
||||
pub colors: Option<ThemeColors>,
|
||||
query: Option<Query>,
|
||||
refresh_at: Option<Instant>,
|
||||
capture: Capture,
|
||||
original: Vec<Event>,
|
||||
body: [u8; MAX_BODY],
|
||||
len: usize,
|
||||
ready: VecDeque<Event>,
|
||||
}
|
||||
|
||||
impl TerminalColors {
|
||||
pub fn refresh_due(&self, now: Instant) -> bool {
|
||||
self.query.is_none() && self.refresh_at.is_none_or(|deadline| now >= deadline)
|
||||
}
|
||||
|
||||
pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> {
|
||||
self.expire(now);
|
||||
self.refresh_at = Some(now + REFRESH_INTERVAL);
|
||||
self.query = None;
|
||||
if let Err(error) = output
|
||||
.write_all(b"\x1b]11;?\x1b\\\x1b]4;1;?;2;?\x1b\\")
|
||||
.and_then(|()| output.flush())
|
||||
{
|
||||
self.colors = None;
|
||||
return Err(error);
|
||||
}
|
||||
self.query = Some(Query {
|
||||
deadline: now + QUERY_TIMEOUT,
|
||||
colors: [None; 3],
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push(&mut self, event: Event, now: Instant) {
|
||||
self.expire(now);
|
||||
self.accept(event, now);
|
||||
}
|
||||
|
||||
pub fn expire(&mut self, now: Instant) {
|
||||
if self
|
||||
.query
|
||||
.as_ref()
|
||||
.is_some_and(|query| now >= query.deadline)
|
||||
{
|
||||
self.query = None;
|
||||
self.colors = None;
|
||||
}
|
||||
let ambiguous_expired = match self.capture {
|
||||
Capture::Escape(deadline) => now >= deadline,
|
||||
Capture::Report(deadline) => {
|
||||
now >= deadline
|
||||
&& !HEADERS
|
||||
.iter()
|
||||
.any(|header| self.body[..self.len].starts_with(header))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ambiguous_expired {
|
||||
self.replay();
|
||||
}
|
||||
// Retain a bounded, recognized OSC candidate across query expiry: a
|
||||
// late remainder must still be swallowed, not inserted into the editor.
|
||||
// Neither late replies nor partial triples can update the palette.
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<Event> {
|
||||
self.ready.pop_front()
|
||||
}
|
||||
|
||||
fn accept(&mut self, event: Event, now: Instant) {
|
||||
let token = token(&event);
|
||||
match self.capture {
|
||||
Capture::Idle => match token {
|
||||
Token::Start => {
|
||||
self.original.push(event);
|
||||
self.capture = Capture::Report(now + QUERY_TIMEOUT);
|
||||
}
|
||||
Token::Escape => {
|
||||
self.original.push(event);
|
||||
self.capture = Capture::Escape(now + ESCAPE_TIMEOUT);
|
||||
}
|
||||
_ => self.ready.push_back(event),
|
||||
},
|
||||
Capture::Escape(_) if matches!(token, Token::Byte(b']')) => {
|
||||
self.original.push(event);
|
||||
self.capture = Capture::Report(now + QUERY_TIMEOUT);
|
||||
}
|
||||
Capture::Report(_) if matches!(token, Token::Escape) => {
|
||||
self.original.push(event);
|
||||
self.capture = Capture::Terminator;
|
||||
}
|
||||
Capture::Report(_) if matches!(token, Token::End) => {
|
||||
self.original.push(event);
|
||||
self.finish();
|
||||
}
|
||||
Capture::Terminator if matches!(token, Token::Byte(b'\\')) => {
|
||||
self.original.push(event);
|
||||
self.finish();
|
||||
}
|
||||
Capture::Report(_) => {
|
||||
if let Token::Byte(byte) = token
|
||||
&& self.len < MAX_BODY
|
||||
{
|
||||
self.body[self.len] = byte;
|
||||
if report_prefix(&self.body[..self.len + 1]) {
|
||||
self.len += 1;
|
||||
self.original.push(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.replay();
|
||||
self.accept(event, now);
|
||||
}
|
||||
_ => {
|
||||
self.replay();
|
||||
self.accept(event, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
let Some((slot, color)) = parse_report(&self.body[..self.len]) else {
|
||||
self.replay();
|
||||
return;
|
||||
};
|
||||
self.original.clear();
|
||||
self.len = 0;
|
||||
self.capture = Capture::Idle;
|
||||
let Some(query) = self.query.as_mut() else {
|
||||
return;
|
||||
};
|
||||
query.colors[slot] = Some(color);
|
||||
if let [Some(background), Some(red), Some(green)] = query.colors {
|
||||
self.colors = Some(ThemeColors {
|
||||
background,
|
||||
red,
|
||||
green,
|
||||
});
|
||||
self.query = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn replay(&mut self) {
|
||||
self.ready.extend(self.original.drain(..));
|
||||
self.len = 0;
|
||||
self.capture = Capture::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Token {
|
||||
Start,
|
||||
End,
|
||||
Escape,
|
||||
Byte(u8),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn token(event: &Event) -> Token {
|
||||
let Event::Key(key) = event else {
|
||||
return Token::Other;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press || key.state != KeyEventState::NONE {
|
||||
return Token::Other;
|
||||
}
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char(']'), KeyModifiers::ALT) => Token::Start,
|
||||
(KeyCode::Char('\\'), KeyModifiers::ALT) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => {
|
||||
Token::End
|
||||
}
|
||||
(KeyCode::Esc, KeyModifiers::NONE) => Token::Escape,
|
||||
(KeyCode::Char(ch), modifiers)
|
||||
if ch.is_ascii()
|
||||
&& (modifiers == KeyModifiers::NONE
|
||||
|| (ch.is_ascii_uppercase() && modifiers == KeyModifiers::SHIFT)) =>
|
||||
{
|
||||
Token::Byte(ch as u8)
|
||||
}
|
||||
_ => Token::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn report_prefix(body: &[u8]) -> bool {
|
||||
HEADERS.iter().any(|header| {
|
||||
header.starts_with(body) || body.strip_prefix(*header).is_some_and(color_prefix)
|
||||
})
|
||||
}
|
||||
|
||||
fn color_prefix(color: &[u8]) -> bool {
|
||||
if b"rgb:".starts_with(color) {
|
||||
return true;
|
||||
}
|
||||
if let Some(hex) = color.strip_prefix(b"#") {
|
||||
return hex.len() <= 6 && hex.iter().all(u8::is_ascii_hexdigit);
|
||||
}
|
||||
let Some(rgb) = color.strip_prefix(b"rgb:") else {
|
||||
return false;
|
||||
};
|
||||
let mut components = rgb.split(|&byte| byte == b'/');
|
||||
for index in 0..3 {
|
||||
let Some(component) = components.next() else {
|
||||
return true;
|
||||
};
|
||||
if component.len() > 4 || !component.iter().all(u8::is_ascii_hexdigit) {
|
||||
return false;
|
||||
}
|
||||
if component.is_empty() && (index < 2 && components.clone().next().is_some()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
components.next().is_none()
|
||||
}
|
||||
|
||||
fn parse_report(body: &[u8]) -> Option<(usize, [u8; 3])> {
|
||||
for (slot, header) in HEADERS.iter().enumerate() {
|
||||
if let Some(color) = body.strip_prefix(*header) {
|
||||
return parse_color(color).map(|color| (slot, color));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_color(color: &[u8]) -> Option<[u8; 3]> {
|
||||
let mut result = [0; 3];
|
||||
if let Some(hex) = color.strip_prefix(b"#") {
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
for (channel, component) in result.iter_mut().zip(hex.chunks_exact(2)) {
|
||||
*channel = hex_value(component)? as u8;
|
||||
}
|
||||
return Some(result);
|
||||
}
|
||||
let mut components = color.strip_prefix(b"rgb:")?.split(|&byte| byte == b'/');
|
||||
for channel in &mut result {
|
||||
let component = components.next()?;
|
||||
if component.is_empty() || component.len() > 4 {
|
||||
return None;
|
||||
}
|
||||
let value = hex_value(component)?;
|
||||
let maximum = (1_u32 << (component.len() * 4)) - 1;
|
||||
*channel = ((value * 255 + maximum / 2) / maximum) as u8;
|
||||
}
|
||||
components.next().is_none().then_some(result)
|
||||
}
|
||||
|
||||
fn hex_value(component: &[u8]) -> Option<u32> {
|
||||
component.iter().try_fold(0, |value, &byte| {
|
||||
(byte as char).to_digit(16).map(|digit| value * 16 + digit)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
|
||||
|
||||
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
|
||||
Event::Key(KeyEvent::new(code, modifiers))
|
||||
}
|
||||
|
||||
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
|
||||
for ch in text.chars() {
|
||||
let modifiers = if ch.is_ascii_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
KeyModifiers::NONE
|
||||
};
|
||||
collector.push(key(KeyCode::Char(ch), modifiers), now);
|
||||
}
|
||||
}
|
||||
|
||||
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
|
||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
|
||||
bytes(collector, body, now);
|
||||
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
|
||||
}
|
||||
|
||||
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
|
||||
std::iter::from_fn(|| collector.pop()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
|
||||
collector.push(
|
||||
key(KeyCode::Char(']'), KeyModifiers::NONE),
|
||||
now + Duration::from_millis(1),
|
||||
);
|
||||
bytes(
|
||||
&mut collector,
|
||||
"11;rgb:0/12/345",
|
||||
now + Duration::from_millis(2),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Esc, KeyModifiers::NONE),
|
||||
now + Duration::from_millis(3),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||
now + Duration::from_millis(4),
|
||||
);
|
||||
assert_eq!(collector.colors, None);
|
||||
report(
|
||||
&mut collector,
|
||||
"4;2;rgb:f/ffff/ABCD",
|
||||
now + Duration::from_millis(5),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
now + Duration::from_millis(6),
|
||||
);
|
||||
bytes(
|
||||
&mut collector,
|
||||
"4;1;#FF1234",
|
||||
now + Duration::from_millis(7),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::ALT),
|
||||
now + Duration::from_millis(8),
|
||||
);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: [0, 18, 52],
|
||||
red: [255, 18, 52],
|
||||
green: [255, 255, 171],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
let palettes = [
|
||||
("11;#FDF6E3", [253, 246, 227]),
|
||||
("11;rgb:0000/0000/0000", [0, 0, 0]),
|
||||
];
|
||||
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
|
||||
let time = now + Duration::from_secs(cycle as u64);
|
||||
let focus = Event::FocusGained;
|
||||
collector.push(focus.clone(), time);
|
||||
assert_eq!(collector.pop(), Some(focus));
|
||||
let previous = collector.colors;
|
||||
collector.query(&mut Vec::new(), time).unwrap();
|
||||
report(&mut collector, "4;1;rgb:ff/00/00", time);
|
||||
report(&mut collector, background, time);
|
||||
assert_eq!(collector.colors, previous);
|
||||
report(&mut collector, "4;2;rgb:00/ff/00", time);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: expected,
|
||||
red: [255, 0, 0],
|
||||
green: [0, 255, 0],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
let mut output = Vec::new();
|
||||
assert!(collector.refresh_due(now));
|
||||
collector.query(&mut output, now).unwrap();
|
||||
report(&mut collector, "11;#101010", now);
|
||||
report(&mut collector, "4;1;#FF0000", now);
|
||||
report(&mut collector, "4;2;#00FF00", now);
|
||||
let previous = collector.colors;
|
||||
assert!(previous.is_some());
|
||||
|
||||
// The terminal changes its palette without emitting a notification.
|
||||
let refresh = now + Duration::from_secs(1);
|
||||
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
|
||||
assert!(collector.refresh_due(refresh));
|
||||
collector.query(&mut output, refresh).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", refresh);
|
||||
report(&mut collector, "4;1;#AA1122", refresh);
|
||||
assert_eq!(collector.colors, previous);
|
||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
collector.push(typed.clone(), refresh);
|
||||
assert_eq!(drain(&mut collector), vec![typed]);
|
||||
|
||||
// Even an overdue probe must wait until the incomplete query expires.
|
||||
let retry = refresh + Duration::from_secs(1);
|
||||
assert!(!collector.refresh_due(retry));
|
||||
collector.expire(retry);
|
||||
assert_eq!(collector.colors, None);
|
||||
assert!(collector.refresh_due(retry));
|
||||
collector.query(&mut output, retry).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", retry);
|
||||
report(&mut collector, "4;1;#AA1122", retry);
|
||||
assert_eq!(collector.colors, None);
|
||||
report(&mut collector, "4;2;#22BB33", retry);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: [238, 238, 238],
|
||||
red: [170, 17, 34],
|
||||
green: [34, 187, 51],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
|
||||
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
report(&mut collector, "11;#101010", now);
|
||||
report(&mut collector, "4;1;#FF0000", now);
|
||||
report(&mut collector, "4;2;#00FF00", now);
|
||||
assert!(collector.colors.is_some());
|
||||
let refresh = now + Duration::from_secs(1);
|
||||
collector.query(&mut Vec::new(), refresh).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", refresh);
|
||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
|
||||
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
|
||||
collector.expire(refresh + QUERY_TIMEOUT);
|
||||
assert_eq!(collector.colors, None);
|
||||
let late = refresh + Duration::from_secs(1);
|
||||
bytes(&mut collector, "0000/0000", late);
|
||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
|
||||
collector.expire(late + Duration::from_secs(1));
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||
late + Duration::from_secs(1),
|
||||
);
|
||||
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
|
||||
assert_eq!(collector.colors, None);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
collector.push(typed.clone(), late + Duration::from_secs(2));
|
||||
assert_eq!(collector.pop(), Some(typed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_events_and_rejected_candidates_replay_exactly() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
let events = vec![
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
key(KeyCode::Char('4'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char(';'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char('3'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
|
||||
Event::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Char(']'),
|
||||
KeyModifiers::ALT,
|
||||
KeyEventKind::Release,
|
||||
)),
|
||||
Event::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Char('A'),
|
||||
KeyModifiers::SHIFT,
|
||||
KeyEventKind::Repeat,
|
||||
)),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::CAPS_LOCK,
|
||||
}),
|
||||
Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: 2,
|
||||
row: 3,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
}),
|
||||
Event::FocusLost,
|
||||
Event::Resize(80, 24),
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
|
||||
Event::FocusGained,
|
||||
];
|
||||
for event in &events {
|
||||
collector.push(event.clone(), now);
|
||||
}
|
||||
assert_eq!(drain(&mut collector), events);
|
||||
assert_eq!(collector.colors, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
for body in [
|
||||
"11;rgb:12345/0/0",
|
||||
"11;rgb:1//3",
|
||||
"11;#12345",
|
||||
"11;rgb:1/2/3/4",
|
||||
] {
|
||||
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
|
||||
expected.extend(
|
||||
body.chars()
|
||||
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
|
||||
);
|
||||
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
|
||||
for event in &expected {
|
||||
collector.push(event.clone(), now);
|
||||
}
|
||||
assert_eq!(drain(&mut collector), expected);
|
||||
}
|
||||
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
|
||||
collector.push(escape.clone(), now);
|
||||
assert_eq!(collector.pop(), None);
|
||||
collector.expire(now + ESCAPE_TIMEOUT);
|
||||
assert_eq!(collector.pop(), Some(escape));
|
||||
assert_eq!(collector.pop(), None);
|
||||
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
|
||||
collector.push(start.clone(), now + Duration::from_secs(1));
|
||||
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
|
||||
assert_eq!(collector.pop(), Some(start));
|
||||
assert_eq!(collector.pop(), None);
|
||||
}
|
||||
}
|
||||
+13848
-723
File diff suppressed because it is too large
Load Diff
+201
@@ -0,0 +1,201 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::tui::TAB_WIDTH;
|
||||
|
||||
/// Terminal-cell coordinates in the original source line, including its tab stops.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct Segment {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WrapCache {
|
||||
key: Option<(usize, u64)>,
|
||||
width: usize,
|
||||
rows: BTreeMap<usize, Vec<Segment>>,
|
||||
}
|
||||
|
||||
impl WrapCache {
|
||||
pub(crate) fn prepare(&mut self, key: (usize, u64), width: usize) {
|
||||
let width = width.max(1);
|
||||
if self.key != Some(key) || self.width != width {
|
||||
self.rows.clear();
|
||||
self.key = Some(key);
|
||||
self.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn segments(&mut self, row: usize, text: &str) -> &[Segment] {
|
||||
let width = self.width.max(1);
|
||||
self.rows.entry(row).or_insert_with(|| {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut column = 0;
|
||||
let mut word_boundary = 0;
|
||||
for grapheme in text.graphemes(true) {
|
||||
let cells = if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
};
|
||||
if column + cells - start > width && column > start {
|
||||
let end = if word_boundary > start {
|
||||
word_boundary
|
||||
} else {
|
||||
column
|
||||
};
|
||||
segments.push(Segment { start, end });
|
||||
start = end;
|
||||
if column + cells - start > width && column > start {
|
||||
segments.push(Segment { start, end: column });
|
||||
start = column;
|
||||
}
|
||||
}
|
||||
if grapheme == "\t" {
|
||||
// Tabs are expanded cells, so even a one-column pane preserves them.
|
||||
while column + cells - start > width {
|
||||
let end = start + width;
|
||||
segments.push(Segment { start, end });
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
column += cells;
|
||||
if grapheme.chars().all(char::is_whitespace) {
|
||||
word_boundary = column;
|
||||
}
|
||||
}
|
||||
segments.push(Segment { start, end: column });
|
||||
// A full final segment has no spare cell for the insertion caret.
|
||||
if column - start >= width {
|
||||
segments.push(Segment {
|
||||
start: column,
|
||||
end: column,
|
||||
});
|
||||
}
|
||||
segments
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize {
|
||||
segments
|
||||
.partition_point(|segment| segment.start <= column)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn layout(text: &str, width: usize) -> Vec<Segment> {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((1, 0), width);
|
||||
cache.segments(0, text).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_long_words_preserve_every_source_cell() {
|
||||
assert_eq!(
|
||||
layout("one two three", 8),
|
||||
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
|
||||
);
|
||||
let segments = layout("abcdefgh", 3);
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
Segment { start: 0, end: 3 },
|
||||
Segment { start: 3, end: 6 },
|
||||
Segment { start: 6, end: 8 },
|
||||
]
|
||||
);
|
||||
assert_eq!(caret_segment(&segments, 3), 1);
|
||||
assert_eq!(caret_segment(&segments, 8), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
|
||||
assert_eq!(
|
||||
layout("ab\t界e\u{301} z", 4),
|
||||
vec![
|
||||
Segment { start: 0, end: 4 },
|
||||
Segment { start: 4, end: 8 },
|
||||
Segment { start: 8, end: 9 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("界界界", 3),
|
||||
vec![
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 6 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(" \t ", 3),
|
||||
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
|
||||
assert_eq!(
|
||||
layout("\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 5 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("a\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 1 },
|
||||
Segment { start: 1, end: 3 },
|
||||
Segment { start: 3, end: 5 },
|
||||
Segment { start: 5, end: 5 },
|
||||
]
|
||||
);
|
||||
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
|
||||
let segments = layout(source, 1);
|
||||
assert!(
|
||||
segments
|
||||
.iter()
|
||||
.all(|segment| segment.end - segment.start <= 1)
|
||||
);
|
||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||
let end = source.graphemes(true).fold(0, |column, grapheme| {
|
||||
column
|
||||
+ if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(
|
||||
cache.segments(90, "abcd"),
|
||||
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
|
||||
);
|
||||
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
cache.segments(90, "abcd");
|
||||
cache.prepare((7, 3), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
hello
|
||||
@@ -0,0 +1,437 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Editor, Feature, Input};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-commands-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, name: &str, text: &str) -> Editor {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, text).unwrap();
|
||||
Editor::open(Some(path), vec!["text".into()]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(editor: &mut Editor, text: &str) {
|
||||
for input in [Input::Char('i'), Input::Paste(text.into()), Input::Escape] {
|
||||
editor.handle(input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_w_preserve_spaced_path_arguments_and_write_current_contents() {
|
||||
for command in ["save", "w"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("original file.txt", "original\n");
|
||||
insert(&mut editor, "changed ");
|
||||
let destination = fixture.0.join("saved file with spaces.txt");
|
||||
|
||||
editor.execute(&format!(":{command} {}", destination.display()));
|
||||
|
||||
assert_eq!(editor.path(), Some(destination.as_path()), "{command}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&destination).unwrap(),
|
||||
"changed original\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("original file.txt")).unwrap(),
|
||||
"original\n"
|
||||
);
|
||||
assert!(!editor.dirty(), "{command}");
|
||||
assert!(!editor.should_quit(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_and_q_refuse_dirty_buffers_then_exit_when_clean() {
|
||||
for command in ["quit", "q"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("file.txt", "original\n");
|
||||
insert(&mut editor, "changed ");
|
||||
|
||||
editor.execute(command);
|
||||
|
||||
assert!(!editor.should_quit(), "{command}");
|
||||
assert!(editor.dirty(), "{command}");
|
||||
assert_eq!(editor.context().text, "changed original\n");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
|
||||
"original\n"
|
||||
);
|
||||
|
||||
editor.handle(Input::Char('u'));
|
||||
assert!(!editor.dirty(), "{command}");
|
||||
editor.execute(command);
|
||||
assert!(editor.should_quit(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_quit_and_q_bang_validate_arguments_before_discarding_changes() {
|
||||
for command in ["force_quit", "q!"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("file.txt", "original\n");
|
||||
insert(&mut editor, "changed ");
|
||||
|
||||
editor.execute(&format!("{command} unexpected argument"));
|
||||
|
||||
assert!(!editor.should_quit(), "{command}");
|
||||
assert!(editor.dirty(), "{command}");
|
||||
assert_eq!(editor.context().text, "changed original\n");
|
||||
|
||||
editor.execute(command);
|
||||
assert!(editor.should_quit(), "{command}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
|
||||
"original\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_next_and_bnext_preserve_each_buffers_dirty_text_and_undo() {
|
||||
for command in ["buffer_next", "bnext"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("first.txt", "first\n");
|
||||
let first_id = editor.buffer_id();
|
||||
insert(&mut editor, "changed ");
|
||||
let second_path = fixture.0.join("second file.txt");
|
||||
fs::write(&second_path, "second\n").unwrap();
|
||||
editor.execute(&format!("edit {}", second_path.display()));
|
||||
let second_id = editor.buffer_id();
|
||||
assert_ne!(first_id, second_id);
|
||||
insert(&mut editor, "edited ");
|
||||
|
||||
editor.execute(command);
|
||||
|
||||
assert_eq!(editor.buffer_id(), first_id, "{command}");
|
||||
assert_eq!(editor.context().text, "changed first\n");
|
||||
assert!(editor.dirty(), "{command}");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "first\n");
|
||||
assert!(!editor.dirty(), "{command}");
|
||||
|
||||
editor.execute(command);
|
||||
|
||||
assert_eq!(editor.buffer_id(), second_id, "{command}");
|
||||
assert_eq!(editor.context().text, "edited second\n");
|
||||
assert!(editor.dirty(), "{command}");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "second\n");
|
||||
assert!(!editor.dirty(), "{command}");
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("first.txt")).unwrap(),
|
||||
"first\n"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&second_path).unwrap(), "second\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_closes_clean_buffers_across_tabs_and_panes_but_preserves_dirty_history() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("first.txt", "first\n");
|
||||
let first_id = editor.buffer_id();
|
||||
insert(&mut editor, "changed ");
|
||||
editor.handle(Input::Char('0'));
|
||||
insert(&mut editor, "extra ");
|
||||
let first_redo_text = editor.context().text;
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "changed first\n");
|
||||
|
||||
editor.execute("pane.split_vertical");
|
||||
let clean_path = fixture.0.join("clean.txt");
|
||||
fs::write(&clean_path, "clean\n").unwrap();
|
||||
editor.execute(&format!("edit {}", clean_path.display()));
|
||||
let clean_id = editor.buffer_id();
|
||||
editor.execute("tab.new");
|
||||
let second_path = fixture.0.join("second.txt");
|
||||
fs::write(&second_path, "second\n").unwrap();
|
||||
editor.execute(&format!("edit {}", second_path.display()));
|
||||
let second_id = editor.buffer_id();
|
||||
insert(&mut editor, "edited ");
|
||||
editor.execute("pane.split_horizontal");
|
||||
let active_clean_path = fixture.0.join("active-clean.txt");
|
||||
fs::write(&active_clean_path, "active clean\n").unwrap();
|
||||
editor.execute(&format!("edit {}", active_clean_path.display()));
|
||||
let active_clean_id = editor.buffer_id();
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
assert_eq!(editor.pane_layout().len(), 2);
|
||||
|
||||
editor.execute(":restart");
|
||||
|
||||
assert!(!editor.should_restart());
|
||||
assert!(!editor.should_quit());
|
||||
assert!(editor.message().to_lowercase().contains("unsaved"));
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert!(!editor.has_buffer(clean_id));
|
||||
assert!(!editor.has_buffer(active_clean_id));
|
||||
assert!(editor.has_buffer(first_id));
|
||||
assert!(editor.has_buffer(second_id));
|
||||
editor.execute(&format!("buffer {first_id}"));
|
||||
assert_eq!(editor.buffer_id(), first_id);
|
||||
assert_eq!(editor.context().text, "changed first\n");
|
||||
assert!(editor.dirty());
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, first_redo_text);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "changed first\n");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "first\n");
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, "changed first\n");
|
||||
editor.execute("save");
|
||||
editor.execute(&format!("buffer {second_id}"));
|
||||
assert_eq!(editor.buffer_id(), second_id);
|
||||
assert_eq!(editor.context().text, "edited second\n");
|
||||
assert!(editor.dirty());
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "second\n");
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, "edited second\n");
|
||||
editor.execute("save");
|
||||
|
||||
editor.execute(":restart");
|
||||
|
||||
assert!(editor.should_restart());
|
||||
assert!(editor.should_quit());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("first.txt")).unwrap(),
|
||||
"changed first\n"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(second_path).unwrap(), "edited second\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_commands_validate_arguments_before_closing_or_discarding_buffers() {
|
||||
for command in ["restart", "force_restart", "restart!"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("dirty.txt", "original\n");
|
||||
let dirty_id = editor.buffer_id();
|
||||
insert(&mut editor, "changed ");
|
||||
let clean_path = fixture.0.join("clean.txt");
|
||||
fs::write(&clean_path, "clean\n").unwrap();
|
||||
editor.execute(&format!("edit {}", clean_path.display()));
|
||||
let clean_id = editor.buffer_id();
|
||||
|
||||
editor.execute(&format!(":{command} unexpected argument"));
|
||||
|
||||
assert!(!editor.should_restart(), "{command}");
|
||||
assert!(!editor.should_quit(), "{command}");
|
||||
assert_eq!(editor.buffer_count(), 2, "{command}");
|
||||
assert_eq!(editor.buffer_id(), clean_id, "{command}");
|
||||
assert!(editor.has_buffer(dirty_id), "{command}");
|
||||
editor.execute(&format!("buffer {dirty_id}"));
|
||||
assert_eq!(editor.context().text, "changed original\n", "{command}");
|
||||
assert!(editor.dirty(), "{command}");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "original\n", "{command}");
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, "changed original\n", "{command}");
|
||||
|
||||
if command != "restart" {
|
||||
editor.execute(&format!(":{command}"));
|
||||
assert!(editor.should_restart(), "{command}");
|
||||
assert!(editor.should_quit(), "{command}");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("dirty.txt")).unwrap(),
|
||||
"original\n",
|
||||
"{command}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(clean_path).unwrap(), "clean\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_restart_bindings_execute_directly_without_force_confirmation() {
|
||||
for action in ["core.restart", "core.force_restart"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("file.txt", "original\n");
|
||||
editor.bind("normal", "x", Some(action.into())).unwrap();
|
||||
if action == "core.force_restart" {
|
||||
insert(&mut editor, "changed ");
|
||||
}
|
||||
|
||||
editor.handle(Input::Char('x'));
|
||||
|
||||
assert!(editor.should_restart(), "{action}");
|
||||
assert!(editor.should_quit(), "{action}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
|
||||
"original\n",
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() {
|
||||
for reserved in ["save", "w", "quit", "q"] {
|
||||
let mut editor = Editor::open(None, vec!["text".into()]).unwrap();
|
||||
let feature = |commands: Vec<String>| Feature {
|
||||
name: "example".into(),
|
||||
required_capabilities: vec!["text".into()],
|
||||
commands,
|
||||
core: false,
|
||||
};
|
||||
|
||||
assert!(
|
||||
editor
|
||||
.register(feature(vec!["example.run".into(), reserved.into()]))
|
||||
.is_err(),
|
||||
"{reserved}"
|
||||
);
|
||||
assert!(editor.execute("example.run").is_none(), "{reserved}");
|
||||
|
||||
editor
|
||||
.register(feature(vec!["example.run".into()]))
|
||||
.unwrap();
|
||||
let invocation = editor.execute("example.run remaining args").unwrap();
|
||||
assert_eq!(invocation.plugin, "example");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_restart_keeps_the_alternate_screen_and_restores_terminal_on_exit() {
|
||||
for scenario in ["quit", "startup-error"] {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.0.join("startup file.txt");
|
||||
fs::write(&path, "SCREEN_HANDOFF_DOCUMENT\n").unwrap();
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
||||
.args([
|
||||
"-rpty",
|
||||
"-rtimeout",
|
||||
"-rio/console",
|
||||
"-e",
|
||||
r#"
|
||||
scenario, binary, path = ARGV
|
||||
captured = +"".b
|
||||
status = nil
|
||||
PTY.spawn({"TERM" => "xterm-256color"}, binary, "--no-config", "--", path) do |output, input, pid|
|
||||
phase = 0
|
||||
begin
|
||||
input.winsize = [24, 100]
|
||||
Timeout.timeout(20) do
|
||||
loop do
|
||||
if IO.select([output], nil, nil, 0.05)
|
||||
begin
|
||||
chunk = output.read_nonblock(65536)
|
||||
captured << chunk
|
||||
STDOUT.write(chunk)
|
||||
rescue IO::WaitReadable
|
||||
rescue EOFError, Errno::EIO
|
||||
end
|
||||
end
|
||||
unless status
|
||||
waited = Process.waitpid2(pid, Process::WNOHANG)
|
||||
status = waited[1] if waited
|
||||
end
|
||||
if status
|
||||
# waitpid can win the race with the final output read.
|
||||
loop do
|
||||
begin
|
||||
chunk = output.read_nonblock(65536)
|
||||
captured << chunk
|
||||
STDOUT.write(chunk)
|
||||
rescue IO::WaitReadable, EOFError, Errno::EIO
|
||||
break
|
||||
end
|
||||
end
|
||||
break
|
||||
end
|
||||
|
||||
# A fresh process re-enables input reporting before its first repaint.
|
||||
# Command-overlay frames must not be mistaken for a relaunched editor.
|
||||
sessions = captured.split("\e[?2004h", -1)
|
||||
next unless sessions.length == phase + 2
|
||||
frame = sessions.last
|
||||
next unless frame.include?("\e[?2026l")
|
||||
visible = frame.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "")
|
||||
next unless visible.include?("SCREEN_HANDOFF_DOCUMENT")
|
||||
raise "alternate screen re-entered during restart" unless captured.scan("\e[?1049h").length == 1
|
||||
raise "normal screen exposed during restart" if captured.include?("\e[?1049l")
|
||||
|
||||
if scenario == "startup-error"
|
||||
File.binwrite(path, "\xff".b)
|
||||
input.write(":restart\r")
|
||||
else
|
||||
input.write([":restart\r", ":restart!\r", ":q\r"].fetch(phase))
|
||||
end
|
||||
input.flush
|
||||
phase += 1
|
||||
end
|
||||
|
||||
expected_phases = scenario == "quit" ? 3 : 1
|
||||
raise "exited before requested restart/quit" unless phase == expected_phases
|
||||
raise "unexpected exit status: #{status.inspect}" unless status.success? == (scenario == "quit")
|
||||
# Inspect the master: macOS revokes slave descriptors when their session exits.
|
||||
raise "terminal echo was not restored" unless input.echo?
|
||||
settings = IO.popen(["stty", "-a"], in: input, &:read)
|
||||
raise "terminal remained noncanonical: #{settings}" unless settings.match?(/(?:^|[;\s])icanon(?:[;\s]|$)/)
|
||||
end
|
||||
ensure
|
||||
unless status
|
||||
begin
|
||||
Process.kill("KILL", pid)
|
||||
rescue Errno::ESRCH
|
||||
end
|
||||
begin
|
||||
Process.waitpid(pid)
|
||||
rescue Errno::ECHILD
|
||||
end
|
||||
end
|
||||
input.close
|
||||
end
|
||||
end
|
||||
"#,
|
||||
"--",
|
||||
scenario,
|
||||
env!("CARGO_BIN_EXE_vamp"),
|
||||
])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("Ruby with stdlib PTY and io/console is required (set VAMP_RUBY)");
|
||||
let captured = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{scenario}: {}\nterminal output: {captured:?}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
assert_eq!(
|
||||
captured.matches("\x1b[?1049h").count(),
|
||||
1,
|
||||
"{scenario}: {captured:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
captured.matches("\x1b[?1049l").count(),
|
||||
1,
|
||||
"{scenario}: {captured:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{ComparisonMode, Phase},
|
||||
editor::Input,
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-diff-session-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(root.join("repo")).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.0.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Diff Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Diff Fixture")
|
||||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready(session) {
|
||||
session.poll();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"diff state: {:?}",
|
||||
session.editor().diff()
|
||||
);
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn type_query(session: &mut EditorSession, text: &str) {
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let source = fixture.0.join("repo/buffer.rs");
|
||||
fs::write(&source, "before\n").unwrap();
|
||||
fixture.git(&["add", "buffer.rs"]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fs::write(&source, "after\n").unwrap();
|
||||
let config = fixture.0.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
for input in [Input::Char('i'), Input::Char('u'), Input::Escape] {
|
||||
session.handle(input);
|
||||
}
|
||||
let cursor = session.editor().cursor();
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('d'));
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
type_query(&mut session, &base);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
let file = &comparison.files[0];
|
||||
assert_eq!(file.path, Path::new("buffer.rs"));
|
||||
assert_eq!(file.before, ["before"]);
|
||||
assert_eq!(file.after, ["uafter", ""]);
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().focus,
|
||||
vamp::diff::Focus::Before
|
||||
);
|
||||
for input in [
|
||||
Input::Char('i'),
|
||||
Input::Char('x'),
|
||||
Input::Paste("overwrite".into()),
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
assert!(session.editor().diff().is_some());
|
||||
assert_eq!(session.editor().context().text, "before\n");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
||||
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().focus,
|
||||
vamp::diff::Focus::After
|
||||
);
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
|
||||
session.handle(input);
|
||||
}
|
||||
assert_eq!(session.editor().context().text, "xuafter\n");
|
||||
assert!(session.editor().diff().is_some());
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
session.handle(Input::Char('U'));
|
||||
assert_eq!(session.editor().context().text, "xuafter\n");
|
||||
session.execute("w");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
|
||||
assert!(!session.editor().dirty());
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
assert!(session.editor().dirty());
|
||||
|
||||
session.execute("diff.close");
|
||||
assert!(session.editor().diff().is_none());
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "after\n");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
|
||||
session.execute(":diff");
|
||||
assert!(session.editor().diff().is_some());
|
||||
session.execute("diff.close");
|
||||
session.poll();
|
||||
assert!(session.editor().diff().is_none());
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
|
||||
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_keep_repository_workers_and_hidden_comparisons_independent() {
|
||||
let first = Fixture::new();
|
||||
let second = Fixture::new();
|
||||
for (fixture, before, after) in [
|
||||
(&first, "first before\n", "first after\n"),
|
||||
(&second, "second before\n", "second after\n"),
|
||||
] {
|
||||
fixture.git(&["init", "-q"]);
|
||||
fs::write(fixture.0.join("repo/source.txt"), before).unwrap();
|
||||
fixture.git(&["add", "source.txt"]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
fs::write(fixture.0.join("repo/source.txt"), after).unwrap();
|
||||
}
|
||||
let first_path = first.0.join("repo/source.txt");
|
||||
let second_path = second.0.join("repo/source.txt");
|
||||
let config = first.0.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(first_path.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.execute("diff");
|
||||
session.handle(Input::CtrlAlt('n'));
|
||||
session.execute(&format!("edit {}", second_path.display()));
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
session.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(session.editor().tab_index(), 0);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
type_query(&mut session, &first.git(&["rev-parse", "HEAD"]));
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::CtrlAlt('l'));
|
||||
assert_eq!(session.editor().tab_index(), 1);
|
||||
type_query(&mut session, &second.git(&["rev-parse", "HEAD"]));
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
comparison.root,
|
||||
second.0.join("repo").canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(comparison.files[0].before, ["second before"]);
|
||||
assert_eq!(comparison.files[0].after, ["second after", ""]);
|
||||
session.handle(Input::CtrlAlt('h'));
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
comparison.root,
|
||||
first.0.join("repo").canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(comparison.files[0].before, ["first before"]);
|
||||
assert_eq!(comparison.files[0].after, ["first after", ""]);
|
||||
session.handle(Input::CtrlAlt('l'));
|
||||
session.handle(Input::CtrlAlt('w'));
|
||||
assert_eq!(session.editor().tab_count(), 1);
|
||||
assert!(session.editor().diff().is_some());
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().path(), Some(first_path.as_path()));
|
||||
assert_eq!(session.editor().lines(), ["first after", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||
use vamp::diff::DisplayRow;
|
||||
|
||||
let fixture = Fixture::new();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let source = fixture.0.join("repo/context.txt");
|
||||
let mut lines = (1..=100)
|
||||
.map(|line| format!("line_{line:03} words"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[49].clear(); // Hidden short lines must not clamp the visible caret column.
|
||||
fs::write(&source, format!("{}\n", lines.join("\n"))).unwrap();
|
||||
fixture.git(&["add", "context.txt"]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "changed_020 words".into();
|
||||
lines[79] = "changed_080 words".into();
|
||||
let text = format!("{}\n", lines.join("\n"));
|
||||
fs::write(&source, &text).unwrap();
|
||||
let config = fixture.0.join("config.rb");
|
||||
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
type_query(&mut session, &base);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::BackTab);
|
||||
let shown = |session: &EditorSession| {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
diff.display_rows
|
||||
.iter()
|
||||
.filter_map(|row| match *row {
|
||||
DisplayRow::Line(index) => diff.rows[index].after,
|
||||
DisplayRow::Fold { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let excerpts = vec![18, 19, 20, 78, 79, 80];
|
||||
assert_eq!(shown(&session), excerpts);
|
||||
assert_eq!(session.editor().cursor().row, 18);
|
||||
type_query(&mut session, "5l");
|
||||
for (keys, row) in [("2j", 20), ("j", 78), ("k", 20), ("3j", 80), ("5k", 18)] {
|
||||
type_query(&mut session, keys);
|
||||
assert_eq!(session.editor().cursor().row, row, "{keys}");
|
||||
assert_eq!(session.editor().cursor().column, 5, "{keys}");
|
||||
assert_eq!(shown(&session), excerpts, "{keys}");
|
||||
}
|
||||
for (keys, row) in [
|
||||
("gb", 80),
|
||||
("gt", 18),
|
||||
("2j$w", 78),
|
||||
("b", 20),
|
||||
("$2w", 78),
|
||||
("0B", 20),
|
||||
] {
|
||||
type_query(&mut session, keys);
|
||||
assert_eq!(session.editor().cursor().row, row, "{keys}");
|
||||
assert_eq!(shown(&session), excerpts, "{keys}");
|
||||
}
|
||||
session.handle(Input::Ctrl('f'));
|
||||
assert_eq!(session.editor().cursor().row, 78);
|
||||
session.handle(Input::Ctrl('b'));
|
||||
assert_eq!(session.editor().cursor().row, 20);
|
||||
type_query(&mut session, "Vj");
|
||||
assert_eq!(session.editor().cursor().row, 78);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Ctrl('k'));
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.carets()
|
||||
.iter()
|
||||
.map(|caret| caret.row)
|
||||
.collect::<Vec<_>>(),
|
||||
[20, 78]
|
||||
);
|
||||
type_query(&mut session, "j");
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.carets()
|
||||
.iter()
|
||||
.map(|caret| caret.row)
|
||||
.collect::<Vec<_>>(),
|
||||
[78, 79]
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
type_query(&mut session, "gt2j$v");
|
||||
session.handle(Input::Ctrl('a'));
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.carets()
|
||||
.iter()
|
||||
.map(|caret| caret.row)
|
||||
.collect::<Vec<_>>(),
|
||||
excerpts
|
||||
);
|
||||
assert_eq!(shown(&session), excerpts);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Escape);
|
||||
type_query(&mut session, "/line_040");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().cursor().row, 20);
|
||||
type_query(&mut session, "/line_079");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().cursor().row, 78);
|
||||
assert_eq!(shown(&session), excerpts);
|
||||
type_query(&mut session, "gt2ji");
|
||||
session.handle(Input::Down);
|
||||
assert_eq!(session.editor().cursor().row, 78);
|
||||
session.handle(Input::Up);
|
||||
assert_eq!(session.editor().cursor().row, 20);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(shown(&session), excerpts);
|
||||
assert_eq!(session.editor().context().text, text);
|
||||
assert!(!session.editor().dirty());
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), text);
|
||||
type_query(&mut session, "[");
|
||||
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 78, 79, 80]);
|
||||
type_query(&mut session, "]");
|
||||
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let root = fixture.0.join("repo");
|
||||
for name in ["first", "second"] {
|
||||
let mut lines = (1..=40)
|
||||
.map(|row| format!("// {name}_stable_{row:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[19] = format!("// {name}_before");
|
||||
fs::write(root.join(format!("{name}.rs")), lines.join("\n") + "\n").unwrap();
|
||||
}
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
for name in ["first", "second"] {
|
||||
let path = root.join(format!("{name}.rs"));
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
fs::write(path, text.replace("_before", "_after")).unwrap();
|
||||
}
|
||||
let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
for input in [Input::Down, Input::Enter, Input::Enter] {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
let first_origin = session.editor().cursor();
|
||||
type_query(&mut session, "/first_before");
|
||||
session.handle(Input::Enter);
|
||||
for input in [
|
||||
Input::Char(' '),
|
||||
Input::Char('e'),
|
||||
Input::Home,
|
||||
Input::Down,
|
||||
Input::Enter,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let second_origin = session.editor().cursor();
|
||||
type_query(&mut session, "/second_before");
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(session.editor().cursor(), second_origin);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(session.editor().diff().unwrap().file, 0);
|
||||
assert_eq!(session.editor().cursor(), first_origin);
|
||||
assert!(session.editor().context().text.contains("first_before"));
|
||||
assert!(!session.editor().context().text.contains("second_before"));
|
||||
session.execute("diff.close");
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
fs::read_to_string(root.join("first.rs")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.0.join("repo");
|
||||
fixture.git(&["init", "-q"]);
|
||||
fs::write(root.join("common"), "common\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "common"]);
|
||||
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fs::write(root.join("base-only"), "base branch\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "base only"]);
|
||||
let before = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fixture.git(&["checkout", "-qb", "feature", &base]);
|
||||
fs::write(root.join("feature-only"), "feature branch\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "feature only"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
let config = fixture.0.join("config.rb");
|
||||
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::BranchChanges
|
||||
);
|
||||
session.handle(Input::Ctrl('b'));
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
session.handle(Input::Ctrl('p'));
|
||||
type_query(&mut session, "diff.mode.toggle");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::BranchChanges
|
||||
);
|
||||
for id in [&before, &after] {
|
||||
type_query(&mut session, id);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, Path::new("feature-only"));
|
||||
assert_eq!(session.editor().context().text, "feature branch\n");
|
||||
assert!(diff.after_editable());
|
||||
assert_eq!(
|
||||
comparison.before,
|
||||
format!("merge base of {before} and {after} · {base}")
|
||||
);
|
||||
assert_eq!(diff.before.as_ref().unwrap().label, before);
|
||||
|
||||
session.execute("diff.mode.toggle");
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(comparison.before, before);
|
||||
assert_eq!(comparison.after, after);
|
||||
assert_eq!(
|
||||
comparison
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.path.as_path())
|
||||
.collect::<Vec<_>>(),
|
||||
[Path::new("base-only"), Path::new("feature-only")]
|
||||
);
|
||||
|
||||
// Superseding an in-flight recomparison must not restore the old mode's result.
|
||||
session.execute("diff.mode.toggle");
|
||||
session.execute("diff.mode.toggle");
|
||||
session.execute("diff.mode.toggle");
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges);
|
||||
assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1);
|
||||
assert_eq!(diff.before.as_ref().unwrap().label, before);
|
||||
assert_eq!(diff.after.as_ref().unwrap().label, after);
|
||||
session.execute("diff.close");
|
||||
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
session.handle(Input::Enter);
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(diff.phase, Phase::After);
|
||||
assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots);
|
||||
session.execute("diff.mode.toggle");
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().message(),
|
||||
"Working checkout requires Snapshots"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{Category, Phase, RevisionTarget},
|
||||
editor::Input,
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture {
|
||||
root: PathBuf,
|
||||
target: String,
|
||||
decoy: String,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-diff-search-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(root.join("repo")).unwrap();
|
||||
let mut fixture = Self {
|
||||
root,
|
||||
target: String::new(),
|
||||
decoy: String::new(),
|
||||
};
|
||||
fixture.git(&["init", "-q", "--initial-branch=fixture-main"]);
|
||||
fixture.commit("foundation", "base");
|
||||
fixture.target = fixture.commit("Über parser target", "target");
|
||||
fixture.decoy = fixture.commit(
|
||||
&format!("release archaeology mentioning {}", fixture.target),
|
||||
"decoy",
|
||||
);
|
||||
for name in [
|
||||
"a-r_e_l_e_a_s_e",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"tie-a",
|
||||
"tie-b",
|
||||
] {
|
||||
fixture.git(&["branch", name, &fixture.decoy]);
|
||||
fixture.git(&["tag", name, &fixture.decoy]);
|
||||
}
|
||||
for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] {
|
||||
fixture.git(&["branch", name, &fixture.target]);
|
||||
fixture.git(&["tag", name, &fixture.target]);
|
||||
}
|
||||
fixture
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.root.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_AUTHOR_NAME", "Diff Search Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Diff Search Fixture")
|
||||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str, content: &str) -> String {
|
||||
fs::write(self.root.join("repo/source.txt"), content).unwrap();
|
||||
self.git(&["add", "source.txt"]);
|
||||
self.git(&["commit", "-qm", message]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn session(&self) -> EditorSession {
|
||||
let config = self.root.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = EditorSession::open(
|
||||
Some(self.root.join("repo/source.txt")),
|
||||
vec![],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.execute(":diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready(session) {
|
||||
session.poll();
|
||||
assert!(Instant::now() < deadline, "{:?}", session.editor().diff());
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn query(session: &mut EditorSession, text: &str) {
|
||||
session.handle(Input::Ctrl('u'));
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn category(session: &mut EditorSession, category: Category) {
|
||||
query(session, "");
|
||||
for _ in 0..3 {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category {
|
||||
return;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
panic!("category unavailable: {category:?}");
|
||||
}
|
||||
|
||||
fn labels(session: &EditorSession) -> Vec<String> {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(
|
||||
diff.entries().next().unwrap().target,
|
||||
RevisionTarget::Working
|
||||
);
|
||||
diff.entries()
|
||||
.skip(1)
|
||||
.map(|entry| entry.label.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
for kind in [Category::Branches, Category::Tags] {
|
||||
category(&mut session, kind);
|
||||
let catalogue_order = labels(&session);
|
||||
query(&mut session, " ");
|
||||
assert_eq!(labels(&session), catalogue_order);
|
||||
|
||||
query(&mut session, "release");
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[
|
||||
"release",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"a-r_e_l_e_a_s_e"
|
||||
]
|
||||
);
|
||||
query(&mut session, "ftprw");
|
||||
assert_eq!(labels(&session), ["feature/parser-rewrite"]);
|
||||
query(&mut session, "tie");
|
||||
let ties: Vec<_> = catalogue_order
|
||||
.iter()
|
||||
.filter(|label| label.starts_with("tie-"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(&labels(&session)[..ties.len()], ties);
|
||||
query(&mut session, "no-such-reference-anywhere");
|
||||
assert!(labels(&session).is_empty());
|
||||
assert_eq!(session.editor().diff().unwrap().selected, 0);
|
||||
}
|
||||
if phase == Phase::Before {
|
||||
// Even an unmatched query leaves Working selectable and pinned first.
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
category(&mut session, Category::Branches);
|
||||
for text in [
|
||||
format!("{} ÜBPR", &fixture.target[..10]),
|
||||
format!("übpr {}", &fixture.target[..10]),
|
||||
] {
|
||||
query(&mut session, &text);
|
||||
assert_eq!(labels(&session), ["feature/Über-parser"]);
|
||||
}
|
||||
|
||||
category(&mut session, Category::Commits);
|
||||
for text in [&fixture.target[..], &fixture.target[..10]] {
|
||||
query(&mut session, text);
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[fixture.target.clone(), fixture.decoy.clone()]
|
||||
);
|
||||
}
|
||||
query(&mut session, "ÜB PRSR");
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
// The ID and description can satisfy separate tokens, but not one token
|
||||
// that would require a subsequence crossing between unrelated fields.
|
||||
query(&mut session, &format!("{} übpr", &fixture.target[..10]));
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
query(&mut session, &format!("{}über", fixture.target));
|
||||
assert!(labels(&session).is_empty());
|
||||
if phase == Phase::Before {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() {
|
||||
let fixture = Fixture::new();
|
||||
for ranked_phase in [Phase::Before, Phase::After] {
|
||||
let mut session = fixture.session();
|
||||
if ranked_phase == Phase::After {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
category(&mut session, Category::Branches);
|
||||
query(&mut session, "release");
|
||||
assert_eq!(labels(&session)[0], "release");
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
if ranked_phase == Phase::Before {
|
||||
// Working selects snapshot mode rather than merge-base mode.
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
let ranked = if ranked_phase == Phase::Before {
|
||||
diff.before.as_ref().unwrap()
|
||||
} else {
|
||||
diff.after.as_ref().unwrap()
|
||||
};
|
||||
assert_eq!(ranked.label, "release");
|
||||
assert_eq!(
|
||||
ranked.target,
|
||||
RevisionTarget::Commit(fixture.target.clone())
|
||||
);
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, Path::new("source.txt"));
|
||||
let (before, after) = if ranked_phase == Phase::Before {
|
||||
(["target"], ["decoy"])
|
||||
} else {
|
||||
(["decoy"], ["target"])
|
||||
};
|
||||
assert_eq!(comparison.files[0].before, before);
|
||||
assert_eq!(comparison.files[0].after, after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use vamp::editor::{Cursor, Editor, Effect, Input, Mode};
|
||||
fn editor() -> Editor {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
"first\n e\u{301}second\n\tthird".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor
|
||||
}
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for key in text.chars() {
|
||||
editor.handle(Input::Char(key));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn counted_g_is_one_based_clamps_and_leaves_bare_prefixes_intact() {
|
||||
let mut editor = editor();
|
||||
let revision = editor.text_revision();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
|
||||
assert_eq!(editor.count(), None);
|
||||
keys(&mut editor, "999999999999999999999999999999999g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
|
||||
keys(&mut editor, "1g");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
keys(&mut editor, "gb");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
keys(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
}
|
||||
#[test]
|
||||
fn counted_g_extends_visual_selection_and_respects_g_overrides() {
|
||||
let mut editor = editor();
|
||||
keys(&mut editor, "v3g");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
|
||||
assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 0 });
|
||||
editor.handle(Input::Escape);
|
||||
keys(&mut editor, "gt");
|
||||
editor.bind("normal", "g", None).unwrap();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
editor
|
||||
.bind("normal", "g", Some("core.down".into()))
|
||||
.unwrap();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use vamp::editor::{Editor, Effect, Input};
|
||||
#[test]
|
||||
fn yy_copies_current_whole_line_for_paste_below_it() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("first\nsecond\n".into())])
|
||||
.unwrap();
|
||||
for key in "llyy".chars() {
|
||||
editor.handle(Input::Char(key));
|
||||
}
|
||||
assert_eq!(editor.cursor().column, 2);
|
||||
assert_eq!(editor.context().text, "first\nsecond\n");
|
||||
editor.handle(Input::Char('p'));
|
||||
assert_eq!(editor.context().text, "first\nfirst\nsecond\n");
|
||||
}
|
||||
+2536
-8
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{DisplayRow, Focus, Phase},
|
||||
editor::{Cursor, Editor, Input, Mode, Selection},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-navigation-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let mut editor =
|
||||
Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
|
||||
fn session(&self, text: &str, bindings: &str) -> EditorSession {
|
||||
let source = self.file("buffer.txt", text);
|
||||
let config = self.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n"
|
||||
),
|
||||
);
|
||||
let session =
|
||||
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.0.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Navigation Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Navigation Fixture")
|
||||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn session_keys(session: &mut EditorSession, text: &str) {
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn palette_seek(session: &mut EditorSession, action: &str, target: char) {
|
||||
session.handle(Input::Ctrl('p'));
|
||||
session_keys(session, action);
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor().palette().is_none());
|
||||
session.handle(Input::Char(target));
|
||||
}
|
||||
|
||||
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready(session) {
|
||||
session.poll();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"diff state: {:?}",
|
||||
session.editor().diff()
|
||||
);
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_character_seeks_move_every_native_caret_independently() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "xaxbx tail\nx---x----x tail\nx only\n";
|
||||
let mut editor = fixture.editor(text);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.selection_count(), 3);
|
||||
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 4 },
|
||||
Cursor { row: 1, column: 9 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
keys(&mut editor, "2Fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 0 },
|
||||
Cursor { row: 1, column: 0 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() {
|
||||
for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("a x xx tail\n");
|
||||
editor.handle(Input::Char(mode));
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
|
||||
assert_eq!(editor.visual_linewise(), mode == 'V');
|
||||
assert_eq!(
|
||||
editor.selections(),
|
||||
[Selection {
|
||||
start: Cursor { row: 0, column: 0 },
|
||||
end: if mode == 'V' {
|
||||
Cursor { row: 1, column: 0 }
|
||||
} else {
|
||||
Cursor { row: 0, column: 5 }
|
||||
},
|
||||
}]
|
||||
);
|
||||
editor.handle(Input::Char('y'));
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
|
||||
let destination = fixture.file("destination.txt", "");
|
||||
editor.execute(&format!("edit {}", destination.display()));
|
||||
editor.handle(Input::Char('P'));
|
||||
assert_eq!(editor.context().text, expected, "{mode}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"a x xx tail\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "a x x tail\n";
|
||||
let mut session = fixture.session(
|
||||
text,
|
||||
r#" config.bind 'leader s', config.core.seek_forward
|
||||
config.bind 'leader r', config.core.seek_backward
|
||||
config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual
|
||||
config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#,
|
||||
);
|
||||
session_keys(&mut session, "2 sx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
session_keys(&mut session, " rx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
|
||||
session_keys(&mut session, "v sx");
|
||||
let expanded = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 5 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
session_keys(&mut session, " rx");
|
||||
let contracted = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 3 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().mode(), Mode::Visual);
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
|
||||
session.handle(Input::Escape);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(session.editor().context().text, text);
|
||||
assert!(!session.editor().dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session(
|
||||
"a x x tail\nlong x gap x tail\n",
|
||||
" config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual",
|
||||
);
|
||||
session_keys(&mut session, "v2 sxc!");
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
|
||||
session_keys(&mut session, "j0.");
|
||||
assert_eq!(session.editor().context().text, "! tail\n! tail\n");
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() {
|
||||
let fixture = Fixture::new();
|
||||
fs::create_dir_all(fixture.0.join("repo")).unwrap();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let source = fixture.0.join("repo/context.txt");
|
||||
let mut lines = (1..=40)
|
||||
.map(|row| format!("stable_{row:03} [x] [x]"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[19] = "before x x tail".into();
|
||||
let before_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &before_text).unwrap();
|
||||
fixture.git(&["add", "context.txt"]);
|
||||
fixture.git(&["commit", "-qm", "before"]);
|
||||
let before = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "after x x tail".into();
|
||||
let after_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &after_text).unwrap();
|
||||
fixture.git(&["commit", "-qam", "after"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fixture.git(&["checkout", "--detach", &before]);
|
||||
lines[19] = "working x x tail".into();
|
||||
let working_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &working_text).unwrap();
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n",
|
||||
);
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session_keys(&mut session, "iunsaved ");
|
||||
session.handle(Input::Escape);
|
||||
let unsaved_text = session.editor().context().text;
|
||||
let original_cursor = session.editor().cursor();
|
||||
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
for revision in [&before, &after] {
|
||||
session_keys(&mut session, revision);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let excerpts = session.editor().diff().unwrap().display_rows.clone();
|
||||
assert!(
|
||||
excerpts
|
||||
.iter()
|
||||
.any(|row| matches!(row, DisplayRow::Fold { .. }))
|
||||
);
|
||||
|
||||
for (focus, text, first_x, second_x) in [
|
||||
(Focus::Before, &before_text, 7, 9),
|
||||
(Focus::After, &after_text, 6, 8),
|
||||
] {
|
||||
for _ in 0..3 {
|
||||
if session.editor().diff().unwrap().focus == focus {
|
||||
break;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
assert_eq!(session.editor().diff().unwrap().focus, focus);
|
||||
let revision = session.editor().text_revision();
|
||||
session_keys(&mut session, "gt02f[");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 15
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "F[f]");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 13
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "j02fx");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "Fxvfx");
|
||||
assert_eq!(
|
||||
session.editor().selections(),
|
||||
[Selection {
|
||||
start: Cursor {
|
||||
row: 19,
|
||||
column: first_x
|
||||
},
|
||||
end: Cursor {
|
||||
row: 19,
|
||||
column: second_x + 1
|
||||
},
|
||||
}]
|
||||
);
|
||||
session_keys(&mut session, "yVfx");
|
||||
assert!(session.editor().visual_linewise());
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().context().text, *text);
|
||||
assert_eq!(session.editor().text_revision(), revision);
|
||||
assert!(!session.editor().dirty());
|
||||
assert_eq!(session.editor().diff().unwrap().display_rows, excerpts);
|
||||
}
|
||||
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().context().text, unsaved_text);
|
||||
assert_eq!(session.editor().cursor(), original_cursor);
|
||||
assert!(session.editor().dirty());
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before);
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Cursor, Editor, Input, Mode};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-normal-editing-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let path = self.0.join("buffer.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
||||
for operation in ["C", "D", "9C", "9D"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "2l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "ae\u{301}\nnext\n", "{operation}");
|
||||
if operation.ends_with('C') {
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "replacement");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "ae\u{301}replacement\nnext\n");
|
||||
} else {
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_commands_at_last_grapheme_and_empty_lines_never_remove_newlines() {
|
||||
for operation in ["C", "D"] {
|
||||
for source in ["a👩🚀\nnext\n", "a👩🚀\nnext"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "$");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source.replacen("a👩🚀", "a", 1));
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(
|
||||
editor.mode(),
|
||||
if operation == "C" {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
}
|
||||
);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_line_suffix_is_characterwise_and_does_not_create_an_undo_entry() {
|
||||
for operation in ["Y", "8Y"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "a👩🚀e\u{301}tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j0P");
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"a👩🚀e\u{301}tail\n👩🚀e\u{301}tailnext\n"
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_and_cancelled_replace_preserve_the_previous_repeatable_edit() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc\nxyz\n");
|
||||
keys(&mut editor, "xY3r");
|
||||
editor.handle(Input::Escape);
|
||||
keys(&mut editor, "j0.");
|
||||
assert_eq!(editor.context().text, "bc\nyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "bc\nxyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "abc\nxyz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_consumes_a_literal_character_and_replaces_whole_counted_graphemes() {
|
||||
for (command, expected) in [
|
||||
("rx", "ax👩🚀z\nnext\n"),
|
||||
("r2", "a2👩🚀z\nnext\n"),
|
||||
("r界", "a界👩🚀z\nnext\n"),
|
||||
("r\t", "a\t👩🚀z\nnext\n"),
|
||||
("2rQ", "aQQz\nnext\n"),
|
||||
("9rQ", "aQQQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command:?}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, "rQ");
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo() {
|
||||
for (command, after_delete, expected) in [
|
||||
("s", "a👩🚀z\nnext\n", "aQ👩🚀z\nnext\n"),
|
||||
("2s", "az\nnext\n", "aQz\nnext\n"),
|
||||
("9s", "a\nnext\n", "aQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, after_delete, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_cut_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
let source = "one two three four five six seven eight nine\n";
|
||||
for (position, command, expected) in [
|
||||
("", "dw", "two three four five six seven eight nine\n"),
|
||||
("", "d2w", "three four five six seven eight nine\n"),
|
||||
("", "2dw", "three four five six seven eight nine\n"),
|
||||
("", "2d3w", "seven eight nine\n"),
|
||||
("2w", "db", "one three four five six seven eight nine\n"),
|
||||
("4w", "d2b", "one two five six seven eight nine\n"),
|
||||
("4w", "2db", "one two five six seven eight nine\n"),
|
||||
("6w", "2d3b", "seven eight nine\n"),
|
||||
] {
|
||||
for operator in ["d", "c"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
let command = command.replacen('d', operator, 1);
|
||||
keys(&mut editor, &command);
|
||||
assert_eq!(editor.context().text, expected, "{position} {command}");
|
||||
assert_eq!(
|
||||
editor.mode(),
|
||||
if operator == "c" {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
}
|
||||
);
|
||||
if operator == "c" {
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_changes_preserve_line_boundaries_and_group_replacement_with_undo() {
|
||||
for (source, position, command, after_cut, expected) in [
|
||||
("one two\nnext\n", "", "cw", "two\nnext\n", "Qtwo\nnext\n"),
|
||||
("one two\nnext\n", "w", "cb", "two\nnext\n", "Qtwo\nnext\n"),
|
||||
("one\ntwo\n", "", "c9w", "\ntwo\n", "Q\ntwo\n"),
|
||||
("one\ntwo\n", "j0", "c9b", "one\ntwo\n", "one\nQtwo\n"),
|
||||
("one\n\ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "cb", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one\n \ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one \nnext\n", "", "cw", "\nnext\n", "Q\nnext\n"),
|
||||
("e\u{301}lan 世界 fin\n", "", "c2w", "fin\n", "Qfin\n"),
|
||||
("e\u{301}lan 世界", "$", "c9b", "界", "Q界"),
|
||||
("👩🚀!!! next\n", "", "cw", "next\n", "Qnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, after_cut, "{position} {command}");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected, "{position} {command}");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "U");
|
||||
assert_eq!(editor.context().text, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation() {
|
||||
for (source, command, expected) in [
|
||||
("e\u{301}lan 世界 fin\n", "d2w", "fin\n"),
|
||||
("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"),
|
||||
("alpha... beta gamma\n", "d2w", "beta gamma\n"),
|
||||
("alpha... beta gamma\n", "2wd2b", "beta gamma\n"),
|
||||
("👩🚀!!! next\n", "dw", "next\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command} on {source:?}");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_stops_at_current_line_boundaries() {
|
||||
for (source, position, command, expected) in [
|
||||
("one\ntwo three\n", "", "dw", "\ntwo three\n"),
|
||||
("one\ntwo three\n", "", "d2w", "\ntwo three\n"),
|
||||
("one two\nthree four\n", "w", "2d3w", "one \nthree four\n"),
|
||||
("one\ntwo three\n", "2w", "d2b", "one\nthree\n"),
|
||||
("one\n two three\n", "jw", "2d3b", "one\ntwo three\n"),
|
||||
("one \ntwo\n", "", "d9w", "\ntwo\n"),
|
||||
("one\n \ntwo\n", "j", "dw", "one\n\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "d9w", "one\n\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "d9b", "one\n\ntwo\n"),
|
||||
("one\ntwo\n", "j0", "db", "one\ntwo\n"),
|
||||
("one\n two\n", "jl", "db", "one\n two\n"),
|
||||
(
|
||||
"one\ne\u{301}lan 世界\nnext\n",
|
||||
"j0",
|
||||
"d9w",
|
||||
"one\n\nnext\n",
|
||||
),
|
||||
(
|
||||
"one\ne\u{301}lan 世界\nnext\n",
|
||||
"j$",
|
||||
"d9b",
|
||||
"one\n界\nnext\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"{position} {command} on {source:?}"
|
||||
);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
if source == expected {
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
} else {
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
|
||||
let source = "e\u{301}lan 世界";
|
||||
for (command, expected) in [
|
||||
("d2w".to_owned(), ""),
|
||||
(format!("{}d2w", usize::MAX), ""),
|
||||
("$d3b".to_owned(), "界"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, &command);
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_operator_and_replace_cancellation_clear_counts_without_editing() {
|
||||
for prefix in ["3d", "d2", "2d3", "3c", "c2", "2c3", "3r"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "abcd\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, prefix);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, source, "{prefix}");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "x");
|
||||
assert_eq!(editor.context().text, "bcd\nnext\n", "{prefix}");
|
||||
}
|
||||
for input in [
|
||||
Input::Enter,
|
||||
Input::Char('\n'),
|
||||
Input::Paste("ignored".into()),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abcd\nnext\n");
|
||||
keys(&mut editor, "3r");
|
||||
editor.handle(input);
|
||||
keys(&mut editor, "lx");
|
||||
assert_eq!(editor.context().text, "acd\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes() {
|
||||
for (source, first, after_first, position, expected) in [
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lCnew",
|
||||
"prefix new\nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix new\nlongprefix new\n",
|
||||
),
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lD",
|
||||
"prefix \nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix \nlongprefix \n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"rQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"sQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"d2w",
|
||||
"keep\nthree four stay\n",
|
||||
"j0",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"2wd2b",
|
||||
"keep\nthree four stay\n",
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
("one\nthree four\n", "d9w", "\nthree four\n", "j0", "\n\n"),
|
||||
(
|
||||
"one keep\nthree four stay\n",
|
||||
"wd9b",
|
||||
"keep\nthree four stay\n",
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"c2wQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j0",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"2wc2bQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j02w",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
(
|
||||
"one\nthree four\n",
|
||||
"c9wQ",
|
||||
"Q\nthree four\n",
|
||||
"j0",
|
||||
"Q\nQ\n",
|
||||
),
|
||||
(
|
||||
"one keep\nthree four stay\n",
|
||||
"wc9bQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j02w",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, first);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, after_first, "{first}");
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, ".");
|
||||
assert_eq!(editor.context().text, expected, "{first} then {position}.");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, after_first);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_and_cc_keep_linewise_behavior_with_counts_after_the_operator() {
|
||||
let source = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
|
||||
for (command, remaining) in [
|
||||
("dd", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2dd", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("d2d", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2d3d", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, remaining, "{command}");
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, source, "{command} linewise paste");
|
||||
}
|
||||
for (command, remaining) in [
|
||||
("cc", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2cc", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("c2c", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2c3c", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.context().text, format!("\n{remaining}"), "{command}");
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, format!("Q\n{remaining}"));
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments() {
|
||||
let source = "a👩🚀tail\nbe\u{301}rest\n";
|
||||
for (command, expected) in [
|
||||
("D", "a\nb\n"),
|
||||
("CQ", "aQ\nbQ\n"),
|
||||
("rQ", "aQtail\nbQrest\n"),
|
||||
("sQ", "aQtail\nbQrest\n"),
|
||||
("YP", "a👩🚀tail👩🚀tail\nbe\u{301}reste\u{301}rest\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(carets.len(), 2);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
for (command, expected) in [
|
||||
("d2w", "keep\nstay\n"),
|
||||
("2wd2b", "keep\nstay\n"),
|
||||
("d9w", "\n\n"),
|
||||
("2wd9b", "keep\nstay\n"),
|
||||
("c2wQ", "Qkeep\nQstay\n"),
|
||||
("2wc2bQ", "Qkeep\nQstay\n"),
|
||||
("c9wQ", "Q\nQ\n"),
|
||||
("2wc9bQ", "Qkeep\nQstay\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "one two keep\nthree four stay\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics() {
|
||||
for (action, original_key, position, command, expected) in [
|
||||
("core.change_to_line_end", "C", "l", "qX", "aX\nnext\n"),
|
||||
("core.delete_to_line_end", "D", "l", "q", "a\nnext\n"),
|
||||
(
|
||||
"core.copy_to_line_end",
|
||||
"Y",
|
||||
"l",
|
||||
"qj0P",
|
||||
"abc def ghi\nbc def ghinext\n",
|
||||
),
|
||||
(
|
||||
"core.replace_character",
|
||||
"r",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
(
|
||||
"core.substitute_character",
|
||||
"s",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"),
|
||||
("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"),
|
||||
("core.change_word_forward", "c", "", "2qQ", "Qghi\nnext\n"),
|
||||
(
|
||||
"core.change_word_backward",
|
||||
"c",
|
||||
"2w",
|
||||
"2qQ",
|
||||
"Qghi\nnext\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc def ghi\nnext\n");
|
||||
editor.bind("normal", original_key, None).unwrap();
|
||||
editor.bind("normal", "q", Some(action.into())).unwrap();
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{action}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Cursor, Editor, Input, Mode};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-paste-shapes-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let path = self.0.join("buffer.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
|
||||
let source = " \t界e\u{301}👩🚀\nother\nlast\n";
|
||||
for (paste, expected, row) in [
|
||||
('p', " \t界e\u{301}👩🚀\nother\n \t界e\u{301}👩🚀\nlast\n", 2),
|
||||
('P', " \t界e\u{301}👩🚀\n \t界e\u{301}👩🚀\nother\nlast\n", 1),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "3l");
|
||||
let yank_caret = editor.cursor();
|
||||
keys(&mut editor, "yy");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), yank_caret);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j2l");
|
||||
let paste_caret = editor.cursor();
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(editor.context().text, expected, "{paste}");
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 2 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), paste_caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_line_paste_handles_unterminated_rows_empty_rows_and_the_final_empty_row() {
|
||||
for (source, position, paste, expected, row) in [
|
||||
("top\n last", "j2l", 'p', "top\n last\n last", 2),
|
||||
("top\n last", "j2l", 'P', "top\n last\n last", 1),
|
||||
("top\n last\n", "j2l", 'p', "top\n last\n last\n", 2),
|
||||
("top\n last\n", "j2l", 'P', "top\n last\n last\n", 1),
|
||||
("top\n\nend", "j", 'p', "top\n\n\nend", 2),
|
||||
("top\n\nend", "j", 'P', "top\n\n\nend", 1),
|
||||
("top\n", "j", 'p', "top\n\n", 2),
|
||||
("top\n", "j", 'P', "top\n\n", 1),
|
||||
("", "", 'p', "\n", 1),
|
||||
("", "", 'P', "\n", 0),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, "yy");
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"{source:?} {position}yy{paste}"
|
||||
);
|
||||
assert_eq!(
|
||||
editor.cursor(),
|
||||
Cursor {
|
||||
row,
|
||||
column: caret.column
|
||||
}
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_line_yanks_multiply_prefixes_and_clamp_at_the_last_row() {
|
||||
let source = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
|
||||
for (command, expected) in [
|
||||
("2yy", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
|
||||
("y2y", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
|
||||
(
|
||||
"2y3y",
|
||||
"one\none\ntwo\nthree\nfour\nfive\nsix\ntwo\nthree\nfour\nfive\nsix\nseven",
|
||||
),
|
||||
(
|
||||
"9yy",
|
||||
"one\none\ntwo\nthree\nfour\nfive\nsix\nseven\ntwo\nthree\nfour\nfive\nsix\nseven",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, source, "{command}");
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, expected, "{command}p");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_character_yank_pastes_before_or_after_whole_extended_graphemes() {
|
||||
let source = "ae\u{301}👩🚀z\nx👨👩👧👦q\n";
|
||||
for (paste, expected, column) in [
|
||||
('p', "ae\u{301}👩🚀z\nx👨👩👧👦e\u{301}👩🚀q\n", 3),
|
||||
('P', "ae\u{301}👩🚀z\nxe\u{301}👩🚀👨👩👧👦q\n", 2),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "lvly");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j0l");
|
||||
let caret = editor.cursor();
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(editor.context().text, expected, "{paste}");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"register survives undo and paste"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_keeps_the_pasted_line_shape_without_replacing_the_new_yank_register() {
|
||||
let fixture = Fixture::new();
|
||||
let source = " a\nb\nc";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "yyp");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "jyy.");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\n a\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nb\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multicarets_yank_matching_whole_lines_and_paste_in_one_undo_step() {
|
||||
let fixture = Fixture::new();
|
||||
let source = " a\n\tb\nc";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(carets.len(), 2);
|
||||
keys(&mut editor, "yy");
|
||||
assert_eq!(editor.carets(), carets);
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
vec![Cursor { row: 1, column: 1 }, Cursor { row: 3, column: 1 }],
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visual_yank_ending_in_a_newline_stays_characterwise() {
|
||||
let fixture = Fixture::new();
|
||||
let source = "\nxy";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "vyj0p");
|
||||
assert_eq!(editor.context().text, "\nx\ny");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, "\n\nxy");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
+1931
File diff suppressed because it is too large
Load Diff
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
[package]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
authors = ["T. Post"]
|
||||
description = "A crossplatform terminal library for manipulating terminals."
|
||||
repository = "https://github.com/crossterm-rs/crossterm"
|
||||
documentation = "https://docs.rs/crossterm/"
|
||||
license = "MIT"
|
||||
keywords = ["event", "color", "cli", "input", "terminal"]
|
||||
exclude = ["target", "Cargo.lock"]
|
||||
readme = false
|
||||
edition = "2021"
|
||||
rust-version = "1.63.0"
|
||||
categories = ["command-line-interface", "command-line-utilities"]
|
||||
|
||||
[lib]
|
||||
name = "crossterm"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
default = ["bracketed-paste", "events", "windows", "derive-more"]
|
||||
|
||||
#! ### Default features
|
||||
## Enables triggering [`Event::Paste`](event::Event::Paste) when pasting text into the terminal.
|
||||
bracketed-paste = []
|
||||
|
||||
## Enables reading input/events from the system using the [`event`] module.
|
||||
events = ["dep:mio", "dep:signal-hook", "dep:signal-hook-mio"]
|
||||
|
||||
## Enables windows specific crates.
|
||||
windows = ["dep:winapi", "dep:crossterm_winapi"]
|
||||
|
||||
#! ### Optional Features
|
||||
|
||||
## Enables the [EventStream](event::EventStream) struct for async event reading.
|
||||
event-stream = ["dep:futures-core", "events"]
|
||||
|
||||
## Enables [`serde`] for various types.
|
||||
serde = ["dep:serde", "bitflags/serde"]
|
||||
|
||||
## Enables raw file descriptor polling / selecting instead of mio.
|
||||
use-dev-tty = ["filedescriptor", "rustix/process"]
|
||||
|
||||
## Enables `is_*` helper functions for event enums.
|
||||
derive-more = ["dep:derive_more"]
|
||||
|
||||
## Enables interacting with a host clipboard via [`clipboard`](clipboard/index.html)
|
||||
osc52 = ["dep:base64"]
|
||||
|
||||
[dependencies]
|
||||
base64 = { version = "0.22", optional = true }
|
||||
bitflags = { version = "2.9" }
|
||||
derive_more = { version = "2.0.0", features = ["is_variant"], optional = true }
|
||||
document-features = "0.2.11"
|
||||
futures-core = { version = "0.3", optional = true, default-features = false }
|
||||
parking_lot = "0.12"
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
|
||||
# Windows dependencies
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
crossterm_winapi = { version = "0.9.1", optional = true }
|
||||
winapi = { version = "0.3.9", optional = true, features = ["winuser", "winerror"] }
|
||||
|
||||
# UNIX dependencies
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
filedescriptor = { version = "0.8", optional = true }
|
||||
# Default to using rustix for UNIX systems, but provide an option to use libc for backwards
|
||||
# compatibility.
|
||||
libc = { version = "0.2", default-features = false, optional = true }
|
||||
mio = { version = "1.0", features = ["os-poll"], optional = true }
|
||||
rustix = { version = "1", default-features = false, features = ["std", "stdio", "termios"] }
|
||||
signal-hook = { version = "0.3.17", optional = true }
|
||||
signal-hook-mio = { version = "0.2.4", features = ["support-v1_0"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0"
|
||||
serial_test = "3.0.0"
|
||||
temp-env = "0.3.6"
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Timon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crossterm_winapi::{ConsoleMode, Handle};
|
||||
use parking_lot::Once;
|
||||
use winapi::um::wincon::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
|
||||
|
||||
/// Enable virtual terminal processing.
|
||||
///
|
||||
/// This method attempts to enable virtual terminal processing for this
|
||||
/// console. If there was a problem enabling it, then an error returned.
|
||||
/// On success, the caller may assume that enabling it was successful.
|
||||
///
|
||||
/// When virtual terminal processing is enabled, characters emitted to the
|
||||
/// console are parsed for VT100 and similar control character sequences
|
||||
/// that control color and other similar operations.
|
||||
fn enable_vt_processing() -> std::io::Result<()> {
|
||||
let mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING;
|
||||
|
||||
let console_mode = ConsoleMode::from(Handle::current_out_handle()?);
|
||||
let old_mode = console_mode.mode()?;
|
||||
|
||||
if old_mode & mask == 0 {
|
||||
console_mode.set_mode(old_mode | mask)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static SUPPORTS_ANSI_ESCAPE_CODES: AtomicBool = AtomicBool::new(false);
|
||||
static INITIALIZER: Once = Once::new();
|
||||
|
||||
/// Checks if the current terminal supports ANSI escape sequences
|
||||
pub fn supports_ansi() -> bool {
|
||||
INITIALIZER.call_once(|| {
|
||||
// Some terminals on Windows like GitBash can't use WinAPI calls directly
|
||||
// so when we try to enable the ANSI-flag for Windows this won't work.
|
||||
// Because of that we should check first if the TERM-variable is set
|
||||
// and see if the current terminal is a terminal who does support ANSI.
|
||||
let supported = enable_vt_processing().is_ok()
|
||||
|| std::env::var("TERM").map_or(false, |term| term != "dumb");
|
||||
|
||||
SUPPORTS_ANSI_ESCAPE_CODES.store(supported, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
SUPPORTS_ANSI_ESCAPE_CODES.load(Ordering::SeqCst)
|
||||
}
|
||||
Vendored
+308
@@ -0,0 +1,308 @@
|
||||
//! # Clipboard
|
||||
//!
|
||||
//! The `clipboard` module provides functionality to work with a host clipboard.
|
||||
//!
|
||||
//! ## Implemented operations:
|
||||
//!
|
||||
//! - Copy: [`CopyToClipboard`](struct.CopyToClipboard.html)
|
||||
use base64::prelude::{Engine, BASE64_STANDARD};
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{osc, Command};
|
||||
|
||||
/// Different clipboard types
|
||||
///
|
||||
/// Some operating systems and desktop environments support multiple buffers
|
||||
/// for copy/cut/paste. Their details differ between operating systems.
|
||||
/// See <https://specifications.freedesktop.org/clipboard-spec/latest/>
|
||||
/// for a detailed survey of supported types based on the X window system.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClipboardType {
|
||||
/// Default clipboard when using Ctrl+C or Ctrl+V
|
||||
Clipboard,
|
||||
|
||||
/// Clipboard on Linux/X/Wayland when using selection and middle mouse button
|
||||
Primary,
|
||||
|
||||
/// Other clipboard type not explicitly supported by crossterm
|
||||
///
|
||||
/// See
|
||||
/// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands)
|
||||
/// for potential values.
|
||||
///
|
||||
/// Note that support for these in terminal emulators is very limited.
|
||||
Other(char),
|
||||
}
|
||||
|
||||
impl From<&ClipboardType> for char {
|
||||
fn from(val: &ClipboardType) -> Self {
|
||||
match val {
|
||||
ClipboardType::Clipboard => 'c',
|
||||
ClipboardType::Primary => 'p',
|
||||
ClipboardType::Other(other) => *other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for ClipboardType {
|
||||
fn from(value: char) -> Self {
|
||||
match value {
|
||||
'c' => ClipboardType::Clipboard,
|
||||
'p' => ClipboardType::Primary,
|
||||
other => ClipboardType::Other(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sequence of clipboard types
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClipboardSelection(
|
||||
/// An ordered list of clipboards which will be the destination for the copied selection.
|
||||
///
|
||||
/// Order matters due to implementations deviating from the
|
||||
/// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands)
|
||||
/// reference. Some terminal emulators may only interpret the first character of this
|
||||
/// parameter. For differences, see
|
||||
/// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support).
|
||||
pub Vec<ClipboardType>,
|
||||
);
|
||||
|
||||
impl ClipboardSelection {
|
||||
/// Returns a String corresponsing to the "Pc" parameter of the OSC52
|
||||
/// sequence.
|
||||
fn to_osc52_pc(&self) -> String {
|
||||
self.0.iter().map(Into::<char>::into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClipboardSelection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_osc52_pc())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ClipboardSelection {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(ClipboardSelection(
|
||||
s.chars().map(From::<char>::from).collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that copies to clipboard
|
||||
///
|
||||
/// This command uses OSC control sequence `Pr = 5 2` (See
|
||||
/// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) )
|
||||
/// to copy data to the terminal host clipboard.
|
||||
///
|
||||
/// This only works if it is enabled on the user's terminal emulator. If a terminal multiplexer
|
||||
/// is used, the multiplexer must support it, too.
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::execute;
|
||||
/// use crossterm::clipboard::CopyToClipboard;
|
||||
/// // Copy foo to clipboard
|
||||
/// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo"));
|
||||
/// // Copy bar to primary
|
||||
/// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("bar"));
|
||||
/// ```
|
||||
///
|
||||
/// See also examples/copy-to-clipboard.rs.
|
||||
///
|
||||
/// # Terminal Support
|
||||
///
|
||||
/// The following table shows what destinations are filled by different terminal emulators when
|
||||
/// asked to copy to different destination sequences.
|
||||
///
|
||||
/// | Terminal (Version) | dest '' | dest 'c' | dest 'p' | dest 'cp' | dest'pc' |
|
||||
/// | --------------------- | --------- | --------- | -------- | ------------- | ------------- |
|
||||
/// | xterm (397) *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. |
|
||||
/// | Alacritty (0.15.1) *3 | clipboard | clipboard | primary | clipb. | prim. |
|
||||
/// | Wezterm (*1) *3 | clipboard | clipboard | primary | clipb. | clipb. |
|
||||
/// | Konsole (24.12.3) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. |
|
||||
/// | Kitty (0.40.0) *3 | clipboard | clipboard | primary | clipb. | clipb. |
|
||||
/// | foot (1.20.2) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. |
|
||||
/// | tmux (3.5a) *2 *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. |
|
||||
///
|
||||
/// Asterisks:
|
||||
/// 1. 20240203-110809-5046fc22
|
||||
/// 2. set-clipboard set to [external](https://github.com/tmux/tmux/wiki/Clipboard#how-it-works),
|
||||
/// i.e. this is OSC52 pass-through.
|
||||
/// 3. This was tested on wayland with the
|
||||
/// [primary selection protocol](https://wayland.app/protocols/primary-selection-unstable-v1)
|
||||
/// enabled.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CopyToClipboard<T> {
|
||||
/// Content to be copied
|
||||
pub content: T,
|
||||
/// Sequence of copy destinations
|
||||
///
|
||||
/// Not all sequences are equally supported by terminal emulators. See
|
||||
/// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support).
|
||||
pub destination: ClipboardSelection,
|
||||
}
|
||||
|
||||
impl<T: AsRef<[u8]>> Command for CopyToClipboard<T> {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
osc!("52;{destination};{encoded_text}"),
|
||||
destination = self.destination.to_osc52_pc(),
|
||||
encoded_text = BASE64_STANDARD.encode(&self.content)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
use std::io;
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Copying is not implemented for the Windows API.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsRef<[u8]>> CopyToClipboard<T> {
|
||||
/// Construct a [`CopyToClipboard`] that writes content into the
|
||||
/// "clipboard" (or 'c') clipboard selection.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::{execute, Command};
|
||||
/// use crossterm::clipboard::CopyToClipboard;
|
||||
/// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo"));
|
||||
/// ```
|
||||
pub fn to_clipboard_from(content: T) -> CopyToClipboard<T> {
|
||||
CopyToClipboard {
|
||||
content,
|
||||
destination: ClipboardSelection(vec![ClipboardType::Clipboard]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a [`CopyToClipboard`] that writes content into the "primary"
|
||||
/// (or 'p') clipboard selection.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::execute;
|
||||
/// use crossterm::clipboard::CopyToClipboard;
|
||||
/// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("foo"));
|
||||
/// ```
|
||||
pub fn to_primary_from(content: T) -> CopyToClipboard<T> {
|
||||
CopyToClipboard {
|
||||
content,
|
||||
destination: ClipboardSelection(vec![ClipboardType::Primary]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_string_to_selection() {
|
||||
assert_eq!(
|
||||
ClipboardSelection::from_str("p").unwrap(),
|
||||
ClipboardSelection(vec![ClipboardType::Primary])
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection::from_str("").unwrap(),
|
||||
ClipboardSelection(vec![])
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection::from_str("cp").unwrap(),
|
||||
ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_clipboard_selection_to_osc52_pc() {
|
||||
assert_eq!(ClipboardSelection(vec![]).to_osc52_pc(), "");
|
||||
assert_eq!(
|
||||
ClipboardSelection(vec![ClipboardType::Clipboard]).to_osc52_pc(),
|
||||
"c"
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection(vec![ClipboardType::Primary]).to_osc52_pc(),
|
||||
"p"
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard])
|
||||
.to_osc52_pc(),
|
||||
"pc"
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary])
|
||||
.to_osc52_pc(),
|
||||
"cp"
|
||||
);
|
||||
assert_eq!(
|
||||
ClipboardSelection(vec![ClipboardType::Other('s')]).to_osc52_pc(),
|
||||
"s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_copy_string_osc52() {
|
||||
let mut buffer = String::new();
|
||||
super::CopyToClipboard {
|
||||
content: "foo",
|
||||
destination: ClipboardSelection(vec![ClipboardType::Clipboard]),
|
||||
}
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\");
|
||||
|
||||
buffer.clear();
|
||||
super::CopyToClipboard {
|
||||
content: "foo",
|
||||
destination: ClipboardSelection(vec![ClipboardType::Primary]),
|
||||
}
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\");
|
||||
|
||||
buffer.clear();
|
||||
super::CopyToClipboard {
|
||||
content: "foo",
|
||||
destination: ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard]),
|
||||
}
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;pc;Zm9v\x1b\\");
|
||||
|
||||
buffer.clear();
|
||||
super::CopyToClipboard {
|
||||
content: "foo",
|
||||
destination: ClipboardSelection(vec![]),
|
||||
}
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;;Zm9v\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_copy_string_osc52_constructor() {
|
||||
let mut buffer = String::new();
|
||||
super::CopyToClipboard::to_clipboard_from("foo")
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\");
|
||||
|
||||
let mut buffer = String::new();
|
||||
super::CopyToClipboard::to_primary_from("foo")
|
||||
.write_ansi(&mut buffer)
|
||||
.unwrap();
|
||||
assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\");
|
||||
}
|
||||
}
|
||||
Vendored
+295
@@ -0,0 +1,295 @@
|
||||
use std::fmt;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
|
||||
|
||||
/// An interface for a command that performs an action on the terminal.
|
||||
///
|
||||
/// Crossterm provides a set of commands,
|
||||
/// and there is no immediate reason to implement a command yourself.
|
||||
/// In order to understand how to use and execute commands,
|
||||
/// it is recommended that you take a look at [Command API](./index.html#command-api) chapter.
|
||||
pub trait Command {
|
||||
/// Write an ANSI representation of this command to the given writer.
|
||||
/// An ANSI code can manipulate the terminal by writing it to the terminal buffer.
|
||||
/// However, only Windows 10 and UNIX systems support this.
|
||||
///
|
||||
/// This method does not need to be accessed manually, as it is used by the crossterm's [Command API](./index.html#command-api)
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result;
|
||||
|
||||
/// Execute this command.
|
||||
///
|
||||
/// Windows versions lower than windows 10 do not support ANSI escape codes,
|
||||
/// therefore a direct WinAPI call is made.
|
||||
///
|
||||
/// This method does not need to be accessed manually, as it is used by the crossterm's [Command API](./index.html#command-api)
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()>;
|
||||
|
||||
/// Returns whether the ANSI code representation of this command is supported by windows.
|
||||
///
|
||||
/// A list of supported ANSI escape codes
|
||||
/// can be found [here](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences).
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
super::ansi_support::supports_ansi()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Command + ?Sized> Command for &T {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
(**self).write_ansi(f)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
T::execute_winapi(self)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
T::is_ansi_code_supported(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An interface for types that can queue commands for further execution.
|
||||
pub trait QueueableCommand {
|
||||
/// Queues the given command for further execution.
|
||||
fn queue(&mut self, command: impl Command) -> io::Result<&mut Self>;
|
||||
}
|
||||
|
||||
/// An interface for types that can directly execute commands.
|
||||
pub trait ExecutableCommand {
|
||||
/// Executes the given command directly.
|
||||
fn execute(&mut self, command: impl Command) -> io::Result<&mut Self>;
|
||||
}
|
||||
|
||||
impl<T: Write + ?Sized> QueueableCommand for T {
|
||||
/// Queues the given command for further execution.
|
||||
///
|
||||
/// Queued commands will be executed in the following cases:
|
||||
///
|
||||
/// * When `flush` is called manually on the given type implementing `io::Write`.
|
||||
/// * The terminal will `flush` automatically if the buffer is full.
|
||||
/// * Each line is flushed in case of `stdout`, because it is line buffered.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - [Command](./trait.Command.html)
|
||||
///
|
||||
/// The command that you want to queue for later execution.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io::{self, Write};
|
||||
/// use crossterm::{QueueableCommand, style::Print};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// let mut stdout = io::stdout();
|
||||
///
|
||||
/// // `Print` will executed executed when `flush` is called.
|
||||
/// stdout
|
||||
/// .queue(Print("foo 1\n".to_string()))?
|
||||
/// .queue(Print("foo 2".to_string()))?;
|
||||
///
|
||||
/// // some other code (no execution happening here) ...
|
||||
///
|
||||
/// // when calling `flush` on `stdout`, all commands will be written to the stdout and therefore executed.
|
||||
/// stdout.flush()?;
|
||||
///
|
||||
/// Ok(())
|
||||
///
|
||||
/// // ==== Output ====
|
||||
/// // foo 1
|
||||
/// // foo 2
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Have a look over at the [Command API](./index.html#command-api) for more details.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'.
|
||||
/// * In case of Windows versions lower than 10, a direct WinAPI call will be made.
|
||||
/// The reason for this is that Windows versions lower than 10 do not support ANSI codes,
|
||||
/// and can therefore not be written to the given `writer`.
|
||||
/// Therefore, there is no difference between [execute](./trait.ExecutableCommand.html)
|
||||
/// and [queue](./trait.QueueableCommand.html) for those old Windows versions.
|
||||
fn queue(&mut self, command: impl Command) -> io::Result<&mut Self> {
|
||||
#[cfg(windows)]
|
||||
if !command.is_ansi_code_supported() {
|
||||
// There may be queued commands in this writer, but `execute_winapi` will execute the
|
||||
// command immediately. To prevent commands being executed out of order we flush the
|
||||
// writer now.
|
||||
self.flush()?;
|
||||
command.execute_winapi()?;
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
write_command_ansi(self, command)?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Write + ?Sized> ExecutableCommand for T {
|
||||
/// Executes the given command directly.
|
||||
///
|
||||
/// The given command its ANSI escape code will be written and flushed onto `Self`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - [Command](./trait.Command.html)
|
||||
///
|
||||
/// The command that you want to execute directly.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io;
|
||||
/// use crossterm::{ExecutableCommand, style::Print};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// // will be executed directly
|
||||
/// io::stdout()
|
||||
/// .execute(Print("sum:\n".to_string()))?
|
||||
/// .execute(Print(format!("1 + 1= {} ", 1 + 1)))?;
|
||||
///
|
||||
/// Ok(())
|
||||
///
|
||||
/// // ==== Output ====
|
||||
/// // sum:
|
||||
/// // 1 + 1 = 2
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Have a look over at the [Command API](./index.html#command-api) for more details.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'.
|
||||
/// * In case of Windows versions lower than 10, a direct WinAPI call will be made.
|
||||
/// The reason for this is that Windows versions lower than 10 do not support ANSI codes,
|
||||
/// and can therefore not be written to the given `writer`.
|
||||
/// Therefore, there is no difference between [execute](./trait.ExecutableCommand.html)
|
||||
/// and [queue](./trait.QueueableCommand.html) for those old Windows versions.
|
||||
fn execute(&mut self, command: impl Command) -> io::Result<&mut Self> {
|
||||
self.queue(command)?;
|
||||
self.flush()?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An interface for types that support synchronized updates.
|
||||
pub trait SynchronizedUpdate {
|
||||
/// Performs a set of actions against the given type.
|
||||
fn sync_update<T>(&mut self, operations: impl FnOnce(&mut Self) -> T) -> io::Result<T>;
|
||||
}
|
||||
|
||||
impl<W: std::io::Write + ?Sized> SynchronizedUpdate for W {
|
||||
/// Performs a set of actions within a synchronous update.
|
||||
///
|
||||
/// Updates will be suspended in the terminal, the function will be executed against self,
|
||||
/// updates will be resumed, and a flush will be performed.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - Function
|
||||
///
|
||||
/// A function that performs the operations that must execute in a synchronized update.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io;
|
||||
/// use crossterm::{ExecutableCommand, SynchronizedUpdate, style::Print};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// let mut stdout = io::stdout();
|
||||
///
|
||||
/// stdout.sync_update(|stdout| {
|
||||
/// stdout.execute(Print("foo 1\n".to_string()))?;
|
||||
/// stdout.execute(Print("foo 2".to_string()))?;
|
||||
/// // The effects of the print command will not be present in the terminal
|
||||
/// // buffer, but not visible in the terminal.
|
||||
/// std::io::Result::Ok(())
|
||||
/// })?;
|
||||
///
|
||||
/// // The effects of the commands will be visible.
|
||||
///
|
||||
/// Ok(())
|
||||
///
|
||||
/// // ==== Output ====
|
||||
/// // foo 1
|
||||
/// // foo 2
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// This command is performed only using ANSI codes, and will do nothing on terminals that do not support ANSI
|
||||
/// codes, or this specific extension.
|
||||
///
|
||||
/// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and
|
||||
/// renders its current state. With applications updating the screen a at higher frequency this can cause tearing.
|
||||
///
|
||||
/// This mode attempts to mitigate that.
|
||||
///
|
||||
/// When the synchronization mode is enabled following render calls will keep rendering the last rendered state.
|
||||
/// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled
|
||||
/// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect
|
||||
/// by unintentionally rendering in the middle a of an application screen update.
|
||||
///
|
||||
fn sync_update<T>(&mut self, operations: impl FnOnce(&mut Self) -> T) -> io::Result<T> {
|
||||
self.queue(BeginSynchronizedUpdate)?;
|
||||
let result = operations(self);
|
||||
self.execute(EndSynchronizedUpdate)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
/// Writes the ANSI representation of a command to the given writer.
|
||||
fn write_command_ansi<C: Command>(
|
||||
io: &mut (impl io::Write + ?Sized),
|
||||
command: C,
|
||||
) -> io::Result<()> {
|
||||
struct Adapter<T> {
|
||||
inner: T,
|
||||
res: io::Result<()>,
|
||||
}
|
||||
|
||||
impl<T: Write> fmt::Write for Adapter<T> {
|
||||
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||
self.inner.write_all(s.as_bytes()).map_err(|e| {
|
||||
self.res = Err(e);
|
||||
fmt::Error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut adapter = Adapter {
|
||||
inner: io,
|
||||
res: Ok(()),
|
||||
};
|
||||
|
||||
command
|
||||
.write_ansi(&mut adapter)
|
||||
.map_err(|fmt::Error| match adapter.res {
|
||||
Ok(()) => panic!(
|
||||
"<{}>::write_ansi incorrectly errored",
|
||||
std::any::type_name::<C>()
|
||||
),
|
||||
Err(e) => e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Executes the ANSI representation of a command, using the given `fmt::Write`.
|
||||
pub(crate) fn execute_fmt(f: &mut impl fmt::Write, command: impl Command) -> fmt::Result {
|
||||
#[cfg(windows)]
|
||||
if !command.is_ansi_code_supported() {
|
||||
return command.execute_winapi().map_err(|_| fmt::Error);
|
||||
}
|
||||
|
||||
command.write_ansi(f)
|
||||
}
|
||||
Vendored
+504
@@ -0,0 +1,504 @@
|
||||
//! # Cursor
|
||||
//!
|
||||
//! The `cursor` module provides functionality to work with the terminal cursor.
|
||||
//!
|
||||
//! This documentation does not contain a lot of examples. The reason is that it's fairly
|
||||
//! obvious how to use this crate. Although, we do provide
|
||||
//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository
|
||||
//! to demonstrate the capabilities.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! Cursor actions can be performed with commands.
|
||||
//! Please have a look at [command documentation](../index.html#command-api) for a more detailed documentation.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//!
|
||||
//! use crossterm::{
|
||||
//! ExecutableCommand, execute,
|
||||
//! cursor::{DisableBlinking, EnableBlinking, MoveTo, RestorePosition, SavePosition}
|
||||
//! };
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! // with macro
|
||||
//! execute!(
|
||||
//! io::stdout(),
|
||||
//! SavePosition,
|
||||
//! MoveTo(10, 10),
|
||||
//! EnableBlinking,
|
||||
//! DisableBlinking,
|
||||
//! RestorePosition
|
||||
//! );
|
||||
//!
|
||||
//! // with function
|
||||
//! io::stdout()
|
||||
//! .execute(MoveTo(11,11))?
|
||||
//! .execute(RestorePosition);
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! For manual execution control check out [crossterm::queue](../macro.queue.html).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::{csi, impl_display, Command};
|
||||
|
||||
pub(crate) mod sys;
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
pub use sys::position;
|
||||
|
||||
/// A command that moves the terminal cursor to the given position (column, row).
|
||||
///
|
||||
/// # Notes
|
||||
/// * Top left cell is represented as `0,0`.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveTo(pub u16, pub u16);
|
||||
|
||||
impl Command for MoveTo {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{};{}H"), self.1 + 1, self.0 + 1)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_to(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor down the given number of lines,
|
||||
/// and moves it to the first column.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveToNextLine(1)` moves to the next line.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveToNextLine(pub u16);
|
||||
|
||||
impl Command for MoveToNextLine {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}E"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
if self.0 != 0 {
|
||||
sys::move_to_next_line(self.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor up the given number of lines,
|
||||
/// and moves it to the first column.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveToPreviousLine(1)` moves to the previous line.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveToPreviousLine(pub u16);
|
||||
|
||||
impl Command for MoveToPreviousLine {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}F"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
if self.0 != 0 {
|
||||
sys::move_to_previous_line(self.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor to the given column on the current row.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 0 based, meaning 0 is the leftmost column.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveToColumn(pub u16);
|
||||
|
||||
impl Command for MoveToColumn {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}G"), self.0 + 1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_to_column(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor to the given row on the current column.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 0 based, meaning 0 is the topmost row.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveToRow(pub u16);
|
||||
|
||||
impl Command for MoveToRow {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}d"), self.0 + 1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_to_row(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor a given number of rows up.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveUp(1)` moves the cursor up one cell.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveUp(pub u16);
|
||||
|
||||
impl Command for MoveUp {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}A"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_up(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor a given number of columns to the right.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveRight(1)` moves the cursor right one cell.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveRight(pub u16);
|
||||
|
||||
impl Command for MoveRight {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}C"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_right(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor a given number of rows down.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveDown(1)` moves the cursor down one cell.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveDown(pub u16);
|
||||
|
||||
impl Command for MoveDown {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}B"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_down(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that moves the terminal cursor a given number of columns to the left.
|
||||
///
|
||||
/// # Notes
|
||||
/// * This command is 1 based, meaning `MoveLeft(1)` moves the cursor left one cell.
|
||||
/// * Most terminals default 0 argument to 1.
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveLeft(pub u16);
|
||||
|
||||
impl Command for MoveLeft {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}D"), self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::move_left(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that saves the current terminal cursor position.
|
||||
///
|
||||
/// See the [RestorePosition](./struct.RestorePosition.html) command.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - The cursor position is stored globally.
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SavePosition;
|
||||
|
||||
impl Command for SavePosition {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str("\x1B7")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::save_position()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that restores the saved terminal cursor position.
|
||||
///
|
||||
/// See the [SavePosition](./struct.SavePosition.html) command.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - The cursor position is stored globally.
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RestorePosition;
|
||||
|
||||
impl Command for RestorePosition {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str("\x1B8")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::restore_position()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that hides the terminal cursor.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Hide;
|
||||
|
||||
impl Command for Hide {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?25l"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::show_cursor(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that shows the terminal cursor.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Show;
|
||||
|
||||
impl Command for Show {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?25h"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::show_cursor(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that enables blinking of the terminal cursor.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Some Unix terminals (ex: GNOME and Konsole) as well as Windows versions lower than Windows 10 do not support this functionality.
|
||||
/// Use `SetCursorStyle` for better cross-compatibility.
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EnableBlinking;
|
||||
impl Command for EnableBlinking {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?12h"))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that disables blinking of the terminal cursor.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Some Unix terminals (ex: GNOME and Konsole) as well as Windows versions lower than Windows 10 do not support this functionality.
|
||||
/// Use `SetCursorStyle` for better cross-compatibility.
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DisableBlinking;
|
||||
impl Command for DisableBlinking {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?12l"))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets the style of the cursor.
|
||||
/// It uses two types of escape codes, one to control blinking, and the other the shape.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// - Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SetCursorStyle {
|
||||
/// Default cursor shape configured by the user.
|
||||
DefaultUserShape,
|
||||
/// A blinking block cursor shape (■).
|
||||
BlinkingBlock,
|
||||
/// A non blinking block cursor shape (inverse of `BlinkingBlock`).
|
||||
SteadyBlock,
|
||||
/// A blinking underscore cursor shape(_).
|
||||
BlinkingUnderScore,
|
||||
/// A non blinking underscore cursor shape (inverse of `BlinkingUnderScore`).
|
||||
SteadyUnderScore,
|
||||
/// A blinking cursor bar shape (|)
|
||||
BlinkingBar,
|
||||
/// A steady cursor bar shape (inverse of `BlinkingBar`).
|
||||
SteadyBar,
|
||||
}
|
||||
|
||||
impl Command for SetCursorStyle {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
match self {
|
||||
SetCursorStyle::DefaultUserShape => f.write_str("\x1b[0 q"),
|
||||
SetCursorStyle::BlinkingBlock => f.write_str("\x1b[1 q"),
|
||||
SetCursorStyle::SteadyBlock => f.write_str("\x1b[2 q"),
|
||||
SetCursorStyle::BlinkingUnderScore => f.write_str("\x1b[3 q"),
|
||||
SetCursorStyle::SteadyUnderScore => f.write_str("\x1b[4 q"),
|
||||
SetCursorStyle::BlinkingBar => f.write_str("\x1b[5 q"),
|
||||
SetCursorStyle::SteadyBar => f.write_str("\x1b[6 q"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl_display!(for MoveTo);
|
||||
impl_display!(for MoveToColumn);
|
||||
impl_display!(for MoveToRow);
|
||||
impl_display!(for MoveToNextLine);
|
||||
impl_display!(for MoveToPreviousLine);
|
||||
impl_display!(for MoveUp);
|
||||
impl_display!(for MoveDown);
|
||||
impl_display!(for MoveLeft);
|
||||
impl_display!(for MoveRight);
|
||||
impl_display!(for SavePosition);
|
||||
impl_display!(for RestorePosition);
|
||||
impl_display!(for Hide);
|
||||
impl_display!(for Show);
|
||||
impl_display!(for EnableBlinking);
|
||||
impl_display!(for DisableBlinking);
|
||||
impl_display!(for SetCursorStyle);
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "events")]
|
||||
mod tests {
|
||||
use std::io::{self, stdout};
|
||||
|
||||
use crate::execute;
|
||||
|
||||
use super::{
|
||||
sys::position, MoveDown, MoveLeft, MoveRight, MoveTo, MoveUp, RestorePosition, SavePosition,
|
||||
};
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_move_to() {
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
|
||||
execute!(stdout(), MoveTo(saved_x + 1, saved_y + 1)).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x + 1, saved_y + 1));
|
||||
|
||||
execute!(stdout(), MoveTo(saved_x, saved_y)).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x, saved_y));
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_move_right() {
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
execute!(io::stdout(), MoveRight(1)).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x + 1, saved_y));
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_move_left() {
|
||||
execute!(stdout(), MoveTo(2, 0), MoveLeft(2)).unwrap();
|
||||
assert_eq!(position().unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_move_up() {
|
||||
execute!(stdout(), MoveTo(0, 2), MoveUp(2)).unwrap();
|
||||
assert_eq!(position().unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_move_down() {
|
||||
execute!(stdout(), MoveTo(0, 0), MoveDown(2)).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 2));
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_save_restore_position() {
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
|
||||
execute!(
|
||||
stdout(),
|
||||
SavePosition,
|
||||
MoveTo(saved_x + 1, saved_y + 1),
|
||||
RestorePosition
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (x, y) = position().unwrap();
|
||||
|
||||
assert_eq!(x, saved_x);
|
||||
assert_eq!(y, saved_y);
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
//! This module provides platform related functions.
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(feature = "events")]
|
||||
pub use self::unix::position;
|
||||
#[cfg(windows)]
|
||||
#[cfg(feature = "events")]
|
||||
pub use self::windows::position;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use self::windows::{
|
||||
move_down, move_left, move_right, move_to, move_to_column, move_to_next_line,
|
||||
move_to_previous_line, move_to_row, move_up, restore_position, save_position, show_cursor,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(feature = "events")]
|
||||
pub(crate) mod unix;
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
use std::{
|
||||
io::{self, Error, ErrorKind, Write},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
event::{filter::CursorPositionFilter, poll_internal, read_internal, InternalEvent},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, sys::is_raw_mode_enabled},
|
||||
};
|
||||
|
||||
/// Returns the cursor position (column, row).
|
||||
///
|
||||
/// The top left cell is represented as `(0, 0)`.
|
||||
///
|
||||
/// On unix systems, this function will block and possibly time out while
|
||||
/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called.
|
||||
pub fn position() -> io::Result<(u16, u16)> {
|
||||
if is_raw_mode_enabled() {
|
||||
read_position_raw()
|
||||
} else {
|
||||
read_position()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_position() -> io::Result<(u16, u16)> {
|
||||
enable_raw_mode()?;
|
||||
let pos = read_position_raw();
|
||||
disable_raw_mode()?;
|
||||
pos
|
||||
}
|
||||
|
||||
fn read_position_raw() -> io::Result<(u16, u16)> {
|
||||
// Use `ESC [ 6 n` to and retrieve the cursor position.
|
||||
let mut stdout = io::stdout();
|
||||
stdout.write_all(b"\x1B[6n")?;
|
||||
stdout.flush()?;
|
||||
|
||||
loop {
|
||||
match poll_internal(Some(Duration::from_millis(2000)), &CursorPositionFilter) {
|
||||
Ok(true) => {
|
||||
if let Ok(InternalEvent::CursorPosition(x, y)) =
|
||||
read_internal(&CursorPositionFilter)
|
||||
{
|
||||
return Ok((x, y));
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
return Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
"The cursor position could not be read within a normal duration",
|
||||
));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
//! WinAPI related logic to cursor manipulation.
|
||||
|
||||
use std::convert::TryFrom;
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crossterm_winapi::{result, Coord, Handle, HandleType, ScreenBuffer};
|
||||
use winapi::{
|
||||
shared::minwindef::{FALSE, TRUE},
|
||||
um::wincon::{SetConsoleCursorInfo, SetConsoleCursorPosition, CONSOLE_CURSOR_INFO, COORD},
|
||||
};
|
||||
|
||||
/// The position of the cursor, written when you save the cursor's position.
|
||||
///
|
||||
/// This is `u64::MAX` initially. Otherwise, it stores the cursor's x position bit-shifted left 16
|
||||
/// times or-ed with the cursor's y position, where both are `i16`s.
|
||||
static SAVED_CURSOR_POS: AtomicU64 = AtomicU64::new(u64::MAX);
|
||||
|
||||
// The 'y' position of the cursor is not relative to the window but absolute to screen buffer.
|
||||
// We can calculate the relative cursor position by subtracting the top position of the terminal window from the y position.
|
||||
// This results in an 1-based coord zo subtract 1 to make cursor position 0-based.
|
||||
pub fn parse_relative_y(y: i16) -> std::io::Result<i16> {
|
||||
let window = ScreenBuffer::current()?.info()?;
|
||||
|
||||
let window_size = window.terminal_window();
|
||||
let screen_size = window.terminal_size();
|
||||
|
||||
if y <= screen_size.height {
|
||||
Ok(y)
|
||||
} else {
|
||||
Ok(y - window_size.top)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cursor position (column, row).
|
||||
///
|
||||
/// The top left cell is represented `0,0`.
|
||||
pub fn position() -> io::Result<(u16, u16)> {
|
||||
let cursor = ScreenBufferCursor::output()?;
|
||||
let mut position = cursor.position()?;
|
||||
// if position.y != 0 {
|
||||
position.y = parse_relative_y(position.y)?;
|
||||
// }
|
||||
Ok(position.into())
|
||||
}
|
||||
|
||||
pub(crate) fn show_cursor(show_cursor: bool) -> std::io::Result<()> {
|
||||
ScreenBufferCursor::from(Handle::current_out_handle()?).set_visibility(show_cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn move_to(column: u16, row: u16) -> std::io::Result<()> {
|
||||
let cursor = ScreenBufferCursor::output()?;
|
||||
cursor.move_to(column as i16, row as i16)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_up(count: u16) -> std::io::Result<()> {
|
||||
let (column, row) = position()?;
|
||||
move_to(column, row - count)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_right(count: u16) -> std::io::Result<()> {
|
||||
let (column, row) = position()?;
|
||||
move_to(column + count, row)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_down(count: u16) -> std::io::Result<()> {
|
||||
let (column, row) = position()?;
|
||||
move_to(column, row + count)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_left(count: u16) -> std::io::Result<()> {
|
||||
let (column, row) = position()?;
|
||||
move_to(column - count, row)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_column(new_column: u16) -> std::io::Result<()> {
|
||||
let (_, row) = position()?;
|
||||
move_to(new_column, row)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_row(new_row: u16) -> std::io::Result<()> {
|
||||
let (col, _) = position()?;
|
||||
move_to(col, new_row)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_next_line(count: u16) -> std::io::Result<()> {
|
||||
let (_, row) = position()?;
|
||||
move_to(0, row + count)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_previous_line(count: u16) -> std::io::Result<()> {
|
||||
let (_, row) = position()?;
|
||||
move_to(0, row - count)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save_position() -> std::io::Result<()> {
|
||||
ScreenBufferCursor::output()?.save_position()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn restore_position() -> std::io::Result<()> {
|
||||
ScreenBufferCursor::output()?.restore_position()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// WinAPI wrapper over terminal cursor behaviour.
|
||||
struct ScreenBufferCursor {
|
||||
screen_buffer: ScreenBuffer,
|
||||
}
|
||||
|
||||
impl ScreenBufferCursor {
|
||||
fn output() -> std::io::Result<ScreenBufferCursor> {
|
||||
Ok(ScreenBufferCursor {
|
||||
screen_buffer: ScreenBuffer::from(Handle::new(HandleType::CurrentOutputHandle)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn position(&self) -> std::io::Result<Coord> {
|
||||
Ok(self.screen_buffer.info()?.cursor_pos())
|
||||
}
|
||||
|
||||
fn move_to(&self, x: i16, y: i16) -> std::io::Result<()> {
|
||||
if x < 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Argument Out of Range Exception when setting cursor position to X: {x}"),
|
||||
));
|
||||
}
|
||||
|
||||
if y < 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Argument Out of Range Exception when setting cursor position to Y: {y}"),
|
||||
));
|
||||
}
|
||||
|
||||
let position = COORD { X: x, Y: y };
|
||||
|
||||
unsafe {
|
||||
if result(SetConsoleCursorPosition(
|
||||
**self.screen_buffer.handle(),
|
||||
position,
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_visibility(&self, visible: bool) -> std::io::Result<()> {
|
||||
let cursor_info = CONSOLE_CURSOR_INFO {
|
||||
dwSize: 100,
|
||||
bVisible: if visible { TRUE } else { FALSE },
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if result(SetConsoleCursorInfo(
|
||||
**self.screen_buffer.handle(),
|
||||
&cursor_info,
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_position(&self) -> std::io::Result<()> {
|
||||
if let Ok(val) = u32::try_from(SAVED_CURSOR_POS.load(Ordering::Relaxed)) {
|
||||
let x = (val >> 16) as i16;
|
||||
let y = val as i16;
|
||||
self.move_to(x, y)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_position(&self) -> std::io::Result<()> {
|
||||
let position = self.position()?;
|
||||
|
||||
let upper = u32::from(position.x as u16) << 16;
|
||||
let lower = u32::from(position.y as u16);
|
||||
let bits = u64::from(upper | lower);
|
||||
SAVED_CURSOR_POS.store(bits, Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Handle> for ScreenBufferCursor {
|
||||
fn from(handle: Handle) -> Self {
|
||||
ScreenBufferCursor {
|
||||
screen_buffer: ScreenBuffer::from(handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
move_down, move_left, move_right, move_to, move_to_column, move_to_next_line,
|
||||
move_to_previous_line, move_to_row, move_up, position, restore_position, save_position,
|
||||
};
|
||||
use crate::terminal::sys::temp_screen_buffer;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_to_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
|
||||
move_to(saved_x + 1, saved_y + 1).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x + 1, saved_y + 1));
|
||||
|
||||
move_to(saved_x, saved_y).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x, saved_y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_right_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
move_right(1).unwrap();
|
||||
assert_eq!(position().unwrap(), (saved_x + 1, saved_y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_left_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(2, 0).unwrap();
|
||||
|
||||
move_left(2).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_up_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 2).unwrap();
|
||||
|
||||
move_up(2).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_to_next_line_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 2).unwrap();
|
||||
|
||||
move_to_next_line(2).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_to_previous_line_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 2).unwrap();
|
||||
|
||||
move_to_previous_line(2).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_to_column_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 2).unwrap();
|
||||
|
||||
move_to_column(12).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (12, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_to_row_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 2).unwrap();
|
||||
|
||||
move_to_row(5).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_move_down_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
move_to(0, 0).unwrap();
|
||||
|
||||
move_down(2).unwrap();
|
||||
|
||||
assert_eq!(position().unwrap(), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_save_restore_position_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let (saved_x, saved_y) = position().unwrap();
|
||||
|
||||
save_position().unwrap();
|
||||
move_to(saved_x + 1, saved_y + 1).unwrap();
|
||||
restore_position().unwrap();
|
||||
|
||||
let (x, y) = position().unwrap();
|
||||
|
||||
assert_eq!(x, saved_x);
|
||||
assert_eq!(y, saved_y);
|
||||
}
|
||||
}
|
||||
Vendored
+1753
File diff suppressed because it is too large
Load Diff
Vendored
+115
@@ -0,0 +1,115 @@
|
||||
use crate::event::InternalEvent;
|
||||
|
||||
/// Interface for filtering an `InternalEvent`.
|
||||
pub(crate) trait Filter: Send + Sync + 'static {
|
||||
/// Returns whether the given event fulfills the filter.
|
||||
fn eval(&self, event: &InternalEvent) -> bool;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CursorPositionFilter;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Filter for CursorPositionFilter {
|
||||
fn eval(&self, event: &InternalEvent) -> bool {
|
||||
matches!(*event, InternalEvent::CursorPosition(_, _))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct KeyboardEnhancementFlagsFilter;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Filter for KeyboardEnhancementFlagsFilter {
|
||||
fn eval(&self, event: &InternalEvent) -> bool {
|
||||
// This filter checks for either a KeyboardEnhancementFlags response or
|
||||
// a PrimaryDeviceAttributes response. If we receive the PrimaryDeviceAttributes
|
||||
// response but not KeyboardEnhancementFlags, the terminal does not support
|
||||
// progressive keyboard enhancement.
|
||||
matches!(
|
||||
*event,
|
||||
InternalEvent::KeyboardEnhancementFlags(_) | InternalEvent::PrimaryDeviceAttributes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PrimaryDeviceAttributesFilter;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Filter for PrimaryDeviceAttributesFilter {
|
||||
fn eval(&self, event: &InternalEvent) -> bool {
|
||||
matches!(*event, InternalEvent::PrimaryDeviceAttributes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EventFilter;
|
||||
|
||||
impl Filter for EventFilter {
|
||||
#[cfg(unix)]
|
||||
fn eval(&self, event: &InternalEvent) -> bool {
|
||||
matches!(*event, InternalEvent::Event(_))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn eval(&self, _: &InternalEvent) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::{
|
||||
super::Event, CursorPositionFilter, EventFilter, Filter, InternalEvent,
|
||||
KeyboardEnhancementFlagsFilter, PrimaryDeviceAttributesFilter,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalEventFilter;
|
||||
|
||||
impl Filter for InternalEventFilter {
|
||||
fn eval(&self, _: &InternalEvent) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_position_filter_filters_cursor_position() {
|
||||
assert!(!CursorPositionFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
|
||||
assert!(CursorPositionFilter.eval(&InternalEvent::CursorPosition(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keyboard_enhancement_status_filter_filters_keyboard_enhancement_status() {
|
||||
assert!(!KeyboardEnhancementFlagsFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
|
||||
assert!(
|
||||
KeyboardEnhancementFlagsFilter.eval(&InternalEvent::KeyboardEnhancementFlags(
|
||||
crate::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
))
|
||||
);
|
||||
assert!(KeyboardEnhancementFlagsFilter.eval(&InternalEvent::PrimaryDeviceAttributes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_primary_device_attributes_filter_filters_primary_device_attributes() {
|
||||
assert!(!PrimaryDeviceAttributesFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
|
||||
assert!(PrimaryDeviceAttributesFilter.eval(&InternalEvent::PrimaryDeviceAttributes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_filter_filters_events() {
|
||||
assert!(EventFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
|
||||
assert!(!EventFilter.eval(&InternalEvent::CursorPosition(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_filter_filters_internal_events() {
|
||||
assert!(InternalEventFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
|
||||
assert!(InternalEventFilter.eval(&InternalEvent::CursorPosition(0, 0)));
|
||||
}
|
||||
}
|
||||
Vendored
+436
@@ -0,0 +1,436 @@
|
||||
use std::{collections::vec_deque::VecDeque, io, time::Duration};
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::event::source::unix::UnixInternalEventSource;
|
||||
#[cfg(windows)]
|
||||
use crate::event::source::windows::WindowsEventSource;
|
||||
#[cfg(feature = "event-stream")]
|
||||
use crate::event::sys::Waker;
|
||||
use crate::event::{filter::Filter, source::EventSource, timeout::PollTimeout, InternalEvent};
|
||||
|
||||
/// Can be used to read `InternalEvent`s.
|
||||
pub(crate) struct InternalEventReader {
|
||||
events: VecDeque<InternalEvent>,
|
||||
source: Option<Box<dyn EventSource>>,
|
||||
skipped_events: Vec<InternalEvent>,
|
||||
}
|
||||
|
||||
impl Default for InternalEventReader {
|
||||
fn default() -> Self {
|
||||
#[cfg(windows)]
|
||||
let source = WindowsEventSource::new();
|
||||
#[cfg(unix)]
|
||||
let source = UnixInternalEventSource::new();
|
||||
|
||||
let source = source.ok().map(|x| Box::new(x) as Box<dyn EventSource>);
|
||||
|
||||
InternalEventReader {
|
||||
source,
|
||||
events: VecDeque::with_capacity(32),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalEventReader {
|
||||
/// Returns a `Waker` allowing to wake/force the `poll` method to return `Ok(false)`.
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub(crate) fn waker(&self) -> Waker {
|
||||
self.source.as_ref().expect("reader source not set").waker()
|
||||
}
|
||||
|
||||
pub(crate) fn poll<F>(&mut self, timeout: Option<Duration>, filter: &F) -> io::Result<bool>
|
||||
where
|
||||
F: Filter,
|
||||
{
|
||||
for event in &self.events {
|
||||
if filter.eval(event) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
let event_source = match self.source.as_mut() {
|
||||
Some(source) => source,
|
||||
None => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Failed to initialize input reader",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let poll_timeout = PollTimeout::new(timeout);
|
||||
|
||||
loop {
|
||||
let maybe_event = match event_source.try_read(poll_timeout.leftover()) {
|
||||
Ok(None) => None,
|
||||
Ok(Some(event)) => {
|
||||
if filter.eval(&event) {
|
||||
Some(event)
|
||||
} else {
|
||||
self.skipped_events.push(event);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::Interrupted {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if poll_timeout.elapsed() || maybe_event.is_some() {
|
||||
self.events.extend(self.skipped_events.drain(..));
|
||||
|
||||
if let Some(event) = maybe_event {
|
||||
self.events.push_front(event);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read<F>(&mut self, filter: &F) -> io::Result<InternalEvent>
|
||||
where
|
||||
F: Filter,
|
||||
{
|
||||
let mut skipped_events = VecDeque::new();
|
||||
|
||||
loop {
|
||||
while let Some(event) = self.events.pop_front() {
|
||||
if filter.eval(&event) {
|
||||
while let Some(event) = skipped_events.pop_front() {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
|
||||
return Ok(event);
|
||||
} else {
|
||||
// We can not directly write events back to `self.events`.
|
||||
// If we did, we would put our self's into an endless loop
|
||||
// that would enqueue -> dequeue -> enqueue etc.
|
||||
// This happens because `poll` in this function will always return true if there are events in it's.
|
||||
// And because we just put the non-fulfilling event there this is going to be the case.
|
||||
// Instead we can store them into the temporary buffer,
|
||||
// and then when the filter is fulfilled write all events back in order.
|
||||
skipped_events.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.poll(None, filter)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::{collections::VecDeque, time::Duration};
|
||||
|
||||
#[cfg(unix)]
|
||||
use super::super::filter::CursorPositionFilter;
|
||||
use super::{super::Event, EventSource, Filter, InternalEvent, InternalEventReader};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalEventFilter;
|
||||
|
||||
impl Filter for InternalEventFilter {
|
||||
fn eval(&self, _: &InternalEvent) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_fails_without_event_source() {
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert!(reader.poll(None, &InternalEventFilter).is_err());
|
||||
assert!(reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.is_err());
|
||||
assert!(reader
|
||||
.poll(Some(Duration::from_secs(10)), &InternalEventFilter)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_returns_true_for_matching_event_in_queue_at_front() {
|
||||
let mut reader = InternalEventReader {
|
||||
events: vec![InternalEvent::Event(Event::Resize(10, 10))].into(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert!(reader.poll(None, &InternalEventFilter).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_poll_returns_true_for_matching_event_in_queue_at_back() {
|
||||
let mut reader = InternalEventReader {
|
||||
events: vec![
|
||||
InternalEvent::Event(Event::Resize(10, 10)),
|
||||
InternalEvent::CursorPosition(10, 20),
|
||||
]
|
||||
.into(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert!(reader.poll(None, &CursorPositionFilter).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_returns_matching_event_in_queue_at_front() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: vec![EVENT].into(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_read_returns_matching_event_in_queue_at_back() {
|
||||
const CURSOR_EVENT: InternalEvent = InternalEvent::CursorPosition(10, 20);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: vec![InternalEvent::Event(Event::Resize(10, 10)), CURSOR_EVENT].into(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&CursorPositionFilter).unwrap(), CURSOR_EVENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_read_does_not_consume_skipped_event() {
|
||||
const SKIPPED_EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
const CURSOR_EVENT: InternalEvent = InternalEvent::CursorPosition(10, 20);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: vec![SKIPPED_EVENT, CURSOR_EVENT].into(),
|
||||
source: None,
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&CursorPositionFilter).unwrap(), CURSOR_EVENT);
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), SKIPPED_EVENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_timeouts_if_source_has_no_events() {
|
||||
let source = FakeSource::default();
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert!(!reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_returns_true_if_source_has_at_least_one_event() {
|
||||
let source = FakeSource::with_events(&[InternalEvent::Event(Event::Resize(10, 10))]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert!(reader.poll(None, &InternalEventFilter).unwrap());
|
||||
assert!(reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reads_returns_event_if_source_has_at_least_one_event() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let source = FakeSource::with_events(&[EVENT]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_returns_events_if_source_has_events() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let source = FakeSource::with_events(&[EVENT, EVENT, EVENT]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_returns_false_after_all_source_events_are_consumed() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let source = FakeSource::with_events(&[EVENT, EVENT, EVENT]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert!(!reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_propagates_error() {
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(FakeSource::new(&[]))),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.err()
|
||||
.map(|e| format!("{:?}", &e.kind())),
|
||||
Some(format!("{:?}", io::ErrorKind::Other))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_propagates_error() {
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(FakeSource::new(&[]))),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
reader
|
||||
.read(&InternalEventFilter)
|
||||
.err()
|
||||
.map(|e| format!("{:?}", &e.kind())),
|
||||
Some(format!("{:?}", io::ErrorKind::Other))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_continues_after_error() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let source = FakeSource::new(&[EVENT, EVENT]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert!(reader.read(&InternalEventFilter).is_err());
|
||||
assert!(reader
|
||||
.poll(Some(Duration::from_secs(0)), &InternalEventFilter)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_continues_after_error() {
|
||||
const EVENT: InternalEvent = InternalEvent::Event(Event::Resize(10, 10));
|
||||
|
||||
let source = FakeSource::new(&[EVENT, EVENT]);
|
||||
|
||||
let mut reader = InternalEventReader {
|
||||
events: VecDeque::new(),
|
||||
source: Some(Box::new(source)),
|
||||
skipped_events: Vec::with_capacity(32),
|
||||
};
|
||||
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
assert!(reader.read(&InternalEventFilter).is_err());
|
||||
assert_eq!(reader.read(&InternalEventFilter).unwrap(), EVENT);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeSource {
|
||||
events: VecDeque<InternalEvent>,
|
||||
error: Option<io::Error>,
|
||||
}
|
||||
|
||||
impl FakeSource {
|
||||
fn new(events: &[InternalEvent]) -> FakeSource {
|
||||
FakeSource {
|
||||
events: events.to_vec().into(),
|
||||
error: Some(io::Error::new(io::ErrorKind::Other, "")),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_events(events: &[InternalEvent]) -> FakeSource {
|
||||
FakeSource {
|
||||
events: events.to_vec().into(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for FakeSource {
|
||||
fn try_read(&mut self, _timeout: Option<Duration>) -> io::Result<Option<InternalEvent>> {
|
||||
// Return error if set in case there's just one remaining event
|
||||
if self.events.len() == 1 {
|
||||
if let Some(error) = self.error.take() {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Return all events from the queue
|
||||
if let Some(event) = self.events.pop_front() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
|
||||
// Return error if there're no more events
|
||||
if let Some(error) = self.error.take() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Timeout
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
fn waker(&self) -> super::super::sys::Waker {
|
||||
unimplemented!();
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
use std::{io, time::Duration};
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
use super::sys::Waker;
|
||||
use super::InternalEvent;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod unix;
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
|
||||
/// An interface for trying to read an `InternalEvent` within an optional `Duration`.
|
||||
pub(crate) trait EventSource: Sync + Send {
|
||||
/// Tries to read an `InternalEvent` within the given duration.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `timeout` - `None` block indefinitely until an event is available, `Some(duration)` blocks
|
||||
/// for the given timeout
|
||||
///
|
||||
/// Returns `Ok(None)` if there's no event available and timeout expires.
|
||||
fn try_read(&mut self, timeout: Option<Duration>) -> io::Result<Option<InternalEvent>>;
|
||||
|
||||
/// Returns a `Waker` allowing to wake/force the `try_read` method to return `Ok(None)`.
|
||||
#[cfg(feature = "event-stream")]
|
||||
fn waker(&self) -> Waker;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#[cfg(feature = "use-dev-tty")]
|
||||
pub(crate) mod tty;
|
||||
|
||||
#[cfg(not(feature = "use-dev-tty"))]
|
||||
pub(crate) mod mio;
|
||||
|
||||
#[cfg(feature = "use-dev-tty")]
|
||||
pub(crate) use self::tty::UnixInternalEventSource;
|
||||
|
||||
#[cfg(not(feature = "use-dev-tty"))]
|
||||
pub(crate) use self::mio::UnixInternalEventSource;
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
use std::{collections::VecDeque, io, time::Duration};
|
||||
|
||||
use mio::{unix::SourceFd, Events, Interest, Poll, Token};
|
||||
use signal_hook_mio::v1_0::Signals;
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
use crate::event::sys::Waker;
|
||||
use crate::event::{
|
||||
source::EventSource, sys::unix::parse::parse_event, timeout::PollTimeout, Event, InternalEvent,
|
||||
};
|
||||
use crate::terminal::sys::file_descriptor::{tty_fd, FileDesc};
|
||||
|
||||
// Tokens to identify file descriptor
|
||||
const TTY_TOKEN: Token = Token(0);
|
||||
const SIGNAL_TOKEN: Token = Token(1);
|
||||
#[cfg(feature = "event-stream")]
|
||||
const WAKE_TOKEN: Token = Token(2);
|
||||
|
||||
// I (@zrzka) wasn't able to read more than 1_022 bytes when testing
|
||||
// reading on macOS/Linux -> we don't need bigger buffer and 1k of bytes
|
||||
// is enough.
|
||||
const TTY_BUFFER_SIZE: usize = 1_024;
|
||||
|
||||
pub(crate) struct UnixInternalEventSource {
|
||||
poll: Poll,
|
||||
events: Events,
|
||||
parser: Parser,
|
||||
tty_buffer: [u8; TTY_BUFFER_SIZE],
|
||||
tty_fd: FileDesc<'static>,
|
||||
signals: Signals,
|
||||
#[cfg(feature = "event-stream")]
|
||||
waker: Waker,
|
||||
}
|
||||
|
||||
impl UnixInternalEventSource {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
UnixInternalEventSource::from_file_descriptor(tty_fd()?)
|
||||
}
|
||||
|
||||
pub(crate) fn from_file_descriptor(input_fd: FileDesc<'static>) -> io::Result<Self> {
|
||||
let poll = Poll::new()?;
|
||||
let registry = poll.registry();
|
||||
|
||||
let tty_raw_fd = input_fd.raw_fd();
|
||||
let mut tty_ev = SourceFd(&tty_raw_fd);
|
||||
registry.register(&mut tty_ev, TTY_TOKEN, Interest::READABLE)?;
|
||||
|
||||
let mut signals = Signals::new([signal_hook::consts::SIGWINCH])?;
|
||||
registry.register(&mut signals, SIGNAL_TOKEN, Interest::READABLE)?;
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
let waker = Waker::new(registry, WAKE_TOKEN)?;
|
||||
|
||||
Ok(UnixInternalEventSource {
|
||||
poll,
|
||||
events: Events::with_capacity(3),
|
||||
parser: Parser::default(),
|
||||
tty_buffer: [0u8; TTY_BUFFER_SIZE],
|
||||
tty_fd: input_fd,
|
||||
signals,
|
||||
#[cfg(feature = "event-stream")]
|
||||
waker,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for UnixInternalEventSource {
|
||||
fn try_read(&mut self, timeout: Option<Duration>) -> io::Result<Option<InternalEvent>> {
|
||||
if let Some(event) = self.parser.next() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
|
||||
let timeout = PollTimeout::new(timeout);
|
||||
|
||||
loop {
|
||||
if let Err(e) = self.poll.poll(&mut self.events, timeout.leftover()) {
|
||||
// Mio will throw an interrupted error in case of cursor position retrieval. We need to retry until it succeeds.
|
||||
// Previous versions of Mio (< 0.7) would automatically retry the poll call if it was interrupted (if EINTR was returned).
|
||||
// https://docs.rs/mio/0.7.0/mio/struct.Poll.html#notes
|
||||
if e.kind() == io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if self.events.is_empty() {
|
||||
// No readiness events = timeout
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for token in self.events.iter().map(|x| x.token()) {
|
||||
match token {
|
||||
TTY_TOKEN => {
|
||||
loop {
|
||||
match self.tty_fd.read(&mut self.tty_buffer) {
|
||||
Ok(read_count) => {
|
||||
if read_count > 0 {
|
||||
self.parser.advance(
|
||||
&self.tty_buffer[..read_count],
|
||||
read_count == TTY_BUFFER_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// No more data to read at the moment. We will receive another event
|
||||
if e.kind() == io::ErrorKind::WouldBlock {
|
||||
break;
|
||||
}
|
||||
// once more data is available to read.
|
||||
else if e.kind() == io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(event) = self.parser.next() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
SIGNAL_TOKEN => {
|
||||
if self.signals.pending().next() == Some(signal_hook::consts::SIGWINCH) {
|
||||
// TODO Should we remove tput?
|
||||
//
|
||||
// This can take a really long time, because terminal::size can
|
||||
// launch new process (tput) and then it parses its output. It's
|
||||
// not a really long time from the absolute time point of view, but
|
||||
// it's a really long time from the mio, async-std/tokio executor, ...
|
||||
// point of view.
|
||||
let new_size = crate::terminal::size()?;
|
||||
return Ok(Some(InternalEvent::Event(Event::Resize(
|
||||
new_size.0, new_size.1,
|
||||
))));
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "event-stream")]
|
||||
WAKE_TOKEN => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"Poll operation was woken up by `Waker::wake`",
|
||||
));
|
||||
}
|
||||
_ => unreachable!("Synchronize Evented handle registration & token handling"),
|
||||
}
|
||||
}
|
||||
|
||||
// Processing above can take some time, check if timeout expired
|
||||
if timeout.elapsed() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
fn waker(&self) -> Waker {
|
||||
self.waker.clone()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Following `Parser` structure exists for two reasons:
|
||||
//
|
||||
// * mimic anes Parser interface
|
||||
// * move the advancing, parsing, ... stuff out of the `try_read` method
|
||||
//
|
||||
#[derive(Debug)]
|
||||
struct Parser {
|
||||
buffer: Vec<u8>,
|
||||
internal_events: VecDeque<InternalEvent>,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Parser {
|
||||
// This buffer is used for -> 1 <- ANSI escape sequence. Are we
|
||||
// aware of any ANSI escape sequence that is bigger? Can we make
|
||||
// it smaller?
|
||||
//
|
||||
// Probably not worth spending more time on this as "there's a plan"
|
||||
// to use the anes crate parser.
|
||||
buffer: Vec::with_capacity(256),
|
||||
// TTY_BUFFER_SIZE is 1_024 bytes. How many ANSI escape sequences can
|
||||
// fit? What is an average sequence length? Let's guess here
|
||||
// and say that the average ANSI escape sequence length is 8 bytes. Thus
|
||||
// the buffer size should be 1024/8=128 to avoid additional allocations
|
||||
// when processing large amounts of data.
|
||||
//
|
||||
// There's no need to make it bigger, because when you look at the `try_read`
|
||||
// method implementation, all events are consumed before the next TTY_BUFFER
|
||||
// is processed -> events pushed.
|
||||
internal_events: VecDeque::with_capacity(128),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn advance(&mut self, buffer: &[u8], more: bool) {
|
||||
for (idx, byte) in buffer.iter().enumerate() {
|
||||
let more = idx + 1 < buffer.len() || more;
|
||||
|
||||
self.buffer.push(*byte);
|
||||
|
||||
match parse_event(&self.buffer, more) {
|
||||
Ok(Some(ie)) => {
|
||||
self.internal_events.push_back(ie);
|
||||
self.buffer.clear();
|
||||
}
|
||||
Ok(None) => {
|
||||
// Event can't be parsed, because we don't have enough bytes for
|
||||
// the current sequence. Keep the buffer and process next bytes.
|
||||
}
|
||||
Err(_) => {
|
||||
// Event can't be parsed (not enough parameters, parameter is not a number, ...).
|
||||
// Clear the buffer and continue with another sequence.
|
||||
self.buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Parser {
|
||||
type Item = InternalEvent;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.internal_events.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Parser;
|
||||
use crate::event::{Event, InternalEvent, KeyCode};
|
||||
|
||||
#[test]
|
||||
fn color_scheme_notifications_preserve_following_keys() {
|
||||
for report in [b"\x1B[?997;1n", b"\x1B[?997;2n"] {
|
||||
for split in 1..report.len() {
|
||||
let mut parser = Parser::default();
|
||||
parser.advance(&report[..split], true);
|
||||
assert_eq!(parser.next(), None);
|
||||
parser.advance(&report[split..], false);
|
||||
parser.advance(b"x", false);
|
||||
assert_eq!(
|
||||
parser.next(),
|
||||
Some(InternalEvent::Event(Event::ColorSchemeChanged))
|
||||
);
|
||||
assert_eq!(
|
||||
parser.next(),
|
||||
Some(InternalEvent::Event(Event::Key(KeyCode::Char('x').into())))
|
||||
);
|
||||
assert_eq!(parser.next(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_color_scheme_notifications_do_not_trap_keys() {
|
||||
for report in [
|
||||
b"\x1B[?997n".as_slice(),
|
||||
b"\x1B[?997;0n",
|
||||
b"\x1B[?997;3n",
|
||||
b"\x1B[?997;01n",
|
||||
b"\x1B[?997;1;2n",
|
||||
b"\x1B[?997;1A",
|
||||
] {
|
||||
let mut parser = Parser::default();
|
||||
parser.advance(report, false);
|
||||
assert_eq!(parser.next(), None);
|
||||
parser.advance(b"x", false);
|
||||
assert_eq!(
|
||||
parser.next(),
|
||||
Some(InternalEvent::Event(Event::Key(KeyCode::Char('x').into())))
|
||||
);
|
||||
assert_eq!(parser.next(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
#[cfg(feature = "libc")]
|
||||
use std::os::unix::prelude::AsRawFd;
|
||||
use std::{collections::VecDeque, io, os::unix::net::UnixStream, time::Duration};
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
use rustix::fd::{AsFd, AsRawFd};
|
||||
|
||||
use signal_hook::low_level::pipe;
|
||||
|
||||
use crate::event::timeout::PollTimeout;
|
||||
use crate::event::Event;
|
||||
use filedescriptor::{poll, pollfd, POLLIN};
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
use crate::event::sys::Waker;
|
||||
use crate::event::{source::EventSource, sys::unix::parse::parse_event, InternalEvent};
|
||||
use crate::terminal::sys::file_descriptor::{tty_fd, FileDesc};
|
||||
|
||||
/// Holds a prototypical Waker and a receiver we can wait on when doing select().
|
||||
#[cfg(feature = "event-stream")]
|
||||
struct WakePipe {
|
||||
receiver: UnixStream,
|
||||
waker: Waker,
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
impl WakePipe {
|
||||
fn new() -> io::Result<Self> {
|
||||
let (receiver, sender) = nonblocking_unix_pair()?;
|
||||
Ok(WakePipe {
|
||||
receiver,
|
||||
waker: Waker::new(sender),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// I (@zrzka) wasn't able to read more than 1_022 bytes when testing
|
||||
// reading on macOS/Linux -> we don't need bigger buffer and 1k of bytes
|
||||
// is enough.
|
||||
const TTY_BUFFER_SIZE: usize = 1_024;
|
||||
|
||||
pub(crate) struct UnixInternalEventSource {
|
||||
parser: Parser,
|
||||
tty_buffer: [u8; TTY_BUFFER_SIZE],
|
||||
tty: FileDesc<'static>,
|
||||
winch_signal_receiver: UnixStream,
|
||||
#[cfg(feature = "event-stream")]
|
||||
wake_pipe: WakePipe,
|
||||
}
|
||||
|
||||
fn nonblocking_unix_pair() -> io::Result<(UnixStream, UnixStream)> {
|
||||
let (receiver, sender) = UnixStream::pair()?;
|
||||
receiver.set_nonblocking(true)?;
|
||||
sender.set_nonblocking(true)?;
|
||||
Ok((receiver, sender))
|
||||
}
|
||||
|
||||
impl UnixInternalEventSource {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
UnixInternalEventSource::from_file_descriptor(tty_fd()?)
|
||||
}
|
||||
|
||||
pub(crate) fn from_file_descriptor(input_fd: FileDesc<'static>) -> io::Result<Self> {
|
||||
Ok(UnixInternalEventSource {
|
||||
parser: Parser::default(),
|
||||
tty_buffer: [0u8; TTY_BUFFER_SIZE],
|
||||
tty: input_fd,
|
||||
winch_signal_receiver: {
|
||||
let (receiver, sender) = nonblocking_unix_pair()?;
|
||||
// Unregistering is unnecessary because EventSource is a singleton
|
||||
#[cfg(feature = "libc")]
|
||||
pipe::register(libc::SIGWINCH, sender)?;
|
||||
#[cfg(not(feature = "libc"))]
|
||||
pipe::register(rustix::process::Signal::WINCH.as_raw(), sender)?;
|
||||
receiver
|
||||
},
|
||||
#[cfg(feature = "event-stream")]
|
||||
wake_pipe: WakePipe::new()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// read_complete reads from a non-blocking file descriptor
|
||||
/// until the buffer is full or it would block.
|
||||
///
|
||||
/// Similar to `std::io::Read::read_to_end`, except this function
|
||||
/// only fills the given buffer and does not read beyond that.
|
||||
fn read_complete(fd: &FileDesc, buf: &mut [u8]) -> io::Result<usize> {
|
||||
loop {
|
||||
match fd.read(buf) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => match e.kind() {
|
||||
io::ErrorKind::WouldBlock => return Ok(0),
|
||||
io::ErrorKind::Interrupted => continue,
|
||||
_ => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for UnixInternalEventSource {
|
||||
fn try_read(&mut self, timeout: Option<Duration>) -> io::Result<Option<InternalEvent>> {
|
||||
let timeout = PollTimeout::new(timeout);
|
||||
|
||||
fn make_pollfd<F: AsRawFd>(fd: &F) -> pollfd {
|
||||
pollfd {
|
||||
fd: fd.as_raw_fd(),
|
||||
events: POLLIN,
|
||||
revents: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "event-stream"))]
|
||||
let mut fds = [
|
||||
make_pollfd(&self.tty),
|
||||
make_pollfd(&self.winch_signal_receiver),
|
||||
];
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
let mut fds = [
|
||||
make_pollfd(&self.tty),
|
||||
make_pollfd(&self.winch_signal_receiver),
|
||||
make_pollfd(&self.wake_pipe.receiver),
|
||||
];
|
||||
|
||||
while timeout.leftover().map_or(true, |t| !t.is_zero()) {
|
||||
// check if there are buffered events from the last read
|
||||
if let Some(event) = self.parser.next() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
match poll(&mut fds, timeout.leftover()) {
|
||||
Err(filedescriptor::Error::Poll(e)) | Err(filedescriptor::Error::Io(e)) => {
|
||||
match e.kind() {
|
||||
// retry on EINTR
|
||||
io::ErrorKind::Interrupted => continue,
|
||||
_ => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("got unexpected error while polling: {:?}", e),
|
||||
))
|
||||
}
|
||||
Ok(_) => (),
|
||||
};
|
||||
if fds[0].revents & POLLIN != 0 {
|
||||
loop {
|
||||
let read_count = read_complete(&self.tty, &mut self.tty_buffer)?;
|
||||
if read_count > 0 {
|
||||
self.parser.advance(
|
||||
&self.tty_buffer[..read_count],
|
||||
read_count == TTY_BUFFER_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(event) = self.parser.next() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
|
||||
if read_count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if fds[1].revents & POLLIN != 0 {
|
||||
#[cfg(feature = "libc")]
|
||||
let fd = FileDesc::new(self.winch_signal_receiver.as_raw_fd(), false);
|
||||
#[cfg(not(feature = "libc"))]
|
||||
let fd = FileDesc::Borrowed(self.winch_signal_receiver.as_fd());
|
||||
// drain the pipe
|
||||
while read_complete(&fd, &mut [0; 1024])? != 0 {}
|
||||
// TODO Should we remove tput?
|
||||
//
|
||||
// This can take a really long time, because terminal::size can
|
||||
// launch new process (tput) and then it parses its output. It's
|
||||
// not a really long time from the absolute time point of view, but
|
||||
// it's a really long time from the mio, async-std/tokio executor, ...
|
||||
// point of view.
|
||||
let new_size = crate::terminal::size()?;
|
||||
return Ok(Some(InternalEvent::Event(Event::Resize(
|
||||
new_size.0, new_size.1,
|
||||
))));
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
if fds[2].revents & POLLIN != 0 {
|
||||
#[cfg(feature = "libc")]
|
||||
let fd = FileDesc::new(self.wake_pipe.receiver.as_raw_fd(), false);
|
||||
#[cfg(not(feature = "libc"))]
|
||||
let fd = FileDesc::Borrowed(self.wake_pipe.receiver.as_fd());
|
||||
// drain the pipe
|
||||
while read_complete(&fd, &mut [0; 1024])? != 0 {}
|
||||
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"Poll operation was woken up by `Waker::wake`",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
fn waker(&self) -> Waker {
|
||||
self.wake_pipe.waker.clone()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Following `Parser` structure exists for two reasons:
|
||||
//
|
||||
// * mimic anes Parser interface
|
||||
// * move the advancing, parsing, ... stuff out of the `try_read` method
|
||||
//
|
||||
#[derive(Debug)]
|
||||
struct Parser {
|
||||
buffer: Vec<u8>,
|
||||
internal_events: VecDeque<InternalEvent>,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Parser {
|
||||
// This buffer is used for -> 1 <- ANSI escape sequence. Are we
|
||||
// aware of any ANSI escape sequence that is bigger? Can we make
|
||||
// it smaller?
|
||||
//
|
||||
// Probably not worth spending more time on this as "there's a plan"
|
||||
// to use the anes crate parser.
|
||||
buffer: Vec::with_capacity(256),
|
||||
// TTY_BUFFER_SIZE is 1_024 bytes. How many ANSI escape sequences can
|
||||
// fit? What is an average sequence length? Let's guess here
|
||||
// and say that the average ANSI escape sequence length is 8 bytes. Thus
|
||||
// the buffer size should be 1024/8=128 to avoid additional allocations
|
||||
// when processing large amounts of data.
|
||||
//
|
||||
// There's no need to make it bigger, because when you look at the `try_read`
|
||||
// method implementation, all events are consumed before the next TTY_BUFFER
|
||||
// is processed -> events pushed.
|
||||
internal_events: VecDeque::with_capacity(128),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn advance(&mut self, buffer: &[u8], more: bool) {
|
||||
for (idx, byte) in buffer.iter().enumerate() {
|
||||
let more = idx + 1 < buffer.len() || more;
|
||||
|
||||
self.buffer.push(*byte);
|
||||
|
||||
match parse_event(&self.buffer, more) {
|
||||
Ok(Some(ie)) => {
|
||||
self.internal_events.push_back(ie);
|
||||
self.buffer.clear();
|
||||
}
|
||||
Ok(None) => {
|
||||
// Event can't be parsed, because we don't have enough bytes for
|
||||
// the current sequence. Keep the buffer and process next bytes.
|
||||
}
|
||||
Err(_) => {
|
||||
// Event can't be parsed (not enough parameters, parameter is not a number, ...).
|
||||
// Clear the buffer and continue with another sequence.
|
||||
self.buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Parser {
|
||||
type Item = InternalEvent;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.internal_events.pop_front()
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm_winapi::{Console, Handle, InputRecord};
|
||||
|
||||
use crate::event::{
|
||||
sys::windows::{parse::MouseButtonsPressed, poll::WinApiPoll},
|
||||
Event,
|
||||
};
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
use crate::event::sys::Waker;
|
||||
use crate::event::{
|
||||
source::EventSource,
|
||||
sys::windows::parse::{handle_key_event, handle_mouse_event},
|
||||
timeout::PollTimeout,
|
||||
InternalEvent,
|
||||
};
|
||||
|
||||
pub(crate) struct WindowsEventSource {
|
||||
console: Console,
|
||||
poll: WinApiPoll,
|
||||
surrogate_buffer: Option<u16>,
|
||||
mouse_buttons_pressed: MouseButtonsPressed,
|
||||
}
|
||||
|
||||
impl WindowsEventSource {
|
||||
pub(crate) fn new() -> std::io::Result<WindowsEventSource> {
|
||||
let console = Console::from(Handle::current_in_handle()?);
|
||||
Ok(WindowsEventSource {
|
||||
console,
|
||||
|
||||
#[cfg(not(feature = "event-stream"))]
|
||||
poll: WinApiPoll::new(),
|
||||
#[cfg(feature = "event-stream")]
|
||||
poll: WinApiPoll::new()?,
|
||||
|
||||
surrogate_buffer: None,
|
||||
mouse_buttons_pressed: MouseButtonsPressed::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for WindowsEventSource {
|
||||
fn try_read(&mut self, timeout: Option<Duration>) -> std::io::Result<Option<InternalEvent>> {
|
||||
let poll_timeout = PollTimeout::new(timeout);
|
||||
|
||||
loop {
|
||||
if let Some(event_ready) = self.poll.poll(poll_timeout.leftover())? {
|
||||
let number = self.console.number_of_console_input_events()?;
|
||||
if event_ready && number != 0 {
|
||||
let event = match self.console.read_single_input_event()? {
|
||||
InputRecord::KeyEvent(record) => {
|
||||
handle_key_event(record, &mut self.surrogate_buffer)
|
||||
}
|
||||
InputRecord::MouseEvent(record) => {
|
||||
let mouse_event =
|
||||
handle_mouse_event(record, &self.mouse_buttons_pressed);
|
||||
self.mouse_buttons_pressed = MouseButtonsPressed {
|
||||
left: record.button_state.left_button(),
|
||||
right: record.button_state.right_button(),
|
||||
middle: record.button_state.middle_button(),
|
||||
};
|
||||
|
||||
mouse_event
|
||||
}
|
||||
InputRecord::WindowBufferSizeEvent(record) => {
|
||||
// windows starts counting at 0, unix at 1, add one to replicate unix behaviour.
|
||||
Some(Event::Resize(
|
||||
(record.size.x as i32 + 1) as u16,
|
||||
(record.size.y as i32 + 1) as u16,
|
||||
))
|
||||
}
|
||||
InputRecord::FocusEvent(record) => {
|
||||
let event = if record.set_focus {
|
||||
Event::FocusGained
|
||||
} else {
|
||||
Event::FocusLost
|
||||
};
|
||||
Some(event)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(event) = event {
|
||||
return Ok(Some(InternalEvent::Event(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if poll_timeout.elapsed() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
fn waker(&self) -> Waker {
|
||||
self.poll.waker()
|
||||
}
|
||||
}
|
||||
Vendored
+146
@@ -0,0 +1,146 @@
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, SyncSender},
|
||||
Arc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures_core::stream::Stream;
|
||||
|
||||
use crate::event::{
|
||||
filter::EventFilter, lock_internal_event_reader, poll_internal, read_internal, sys::Waker,
|
||||
Event, InternalEvent,
|
||||
};
|
||||
|
||||
/// A stream of `Result<Event>`.
|
||||
///
|
||||
/// **This type is not available by default. You have to use the `event-stream` feature flag
|
||||
/// to make it available.**
|
||||
///
|
||||
/// It implements the [Stream](futures_core::stream::Stream)
|
||||
/// trait and allows you to receive [`Event`]s with [`async-std`](https://crates.io/crates/async-std)
|
||||
/// or [`tokio`](https://crates.io/crates/tokio) crates.
|
||||
///
|
||||
/// Check the [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) folder to see how to use
|
||||
/// it (`event-stream-*`).
|
||||
#[derive(Debug)]
|
||||
pub struct EventStream {
|
||||
poll_internal_waker: Waker,
|
||||
stream_wake_task_executed: Arc<AtomicBool>,
|
||||
stream_wake_task_should_shutdown: Arc<AtomicBool>,
|
||||
task_sender: SyncSender<Task>,
|
||||
}
|
||||
|
||||
impl Default for EventStream {
|
||||
fn default() -> Self {
|
||||
let (task_sender, receiver) = mpsc::sync_channel::<Task>(1);
|
||||
|
||||
thread::spawn(move || {
|
||||
while let Ok(task) = receiver.recv() {
|
||||
loop {
|
||||
if let Ok(true) = poll_internal(None, &EventFilter) {
|
||||
break;
|
||||
}
|
||||
|
||||
if task.stream_wake_task_should_shutdown.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
task.stream_wake_task_executed
|
||||
.store(false, Ordering::SeqCst);
|
||||
task.stream_waker.wake();
|
||||
}
|
||||
});
|
||||
|
||||
EventStream {
|
||||
poll_internal_waker: lock_internal_event_reader().waker(),
|
||||
stream_wake_task_executed: Arc::new(AtomicBool::new(false)),
|
||||
stream_wake_task_should_shutdown: Arc::new(AtomicBool::new(false)),
|
||||
task_sender,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventStream {
|
||||
/// Constructs a new instance of `EventStream`.
|
||||
pub fn new() -> EventStream {
|
||||
EventStream::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct Task {
|
||||
stream_waker: std::task::Waker,
|
||||
stream_wake_task_executed: Arc<AtomicBool>,
|
||||
stream_wake_task_should_shutdown: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// Note to future me
|
||||
//
|
||||
// We need two wakers in order to implement EventStream correctly.
|
||||
//
|
||||
// 1. futures::Stream waker
|
||||
//
|
||||
// Stream::poll_next can return Poll::Pending which means that there's no
|
||||
// event available. We are going to spawn a thread with the
|
||||
// poll_internal(None, &EventFilter) call. This call blocks until an
|
||||
// event is available and then we have to wake up the executor with notification
|
||||
// that the task can be resumed.
|
||||
//
|
||||
// 2. poll_internal waker
|
||||
//
|
||||
// There's no event available, Poll::Pending was returned, stream waker thread
|
||||
// is up and sitting in the poll_internal. User wants to drop the EventStream.
|
||||
// We have to wake up the poll_internal (force it to return Ok(false)) and quit
|
||||
// the thread before we drop.
|
||||
impl Stream for EventStream {
|
||||
type Item = io::Result<Event>;
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let result = match poll_internal(Some(Duration::from_secs(0)), &EventFilter) {
|
||||
Ok(true) => match read_internal(&EventFilter) {
|
||||
Ok(InternalEvent::Event(event)) => Poll::Ready(Some(Ok(event))),
|
||||
Err(e) => Poll::Ready(Some(Err(e))),
|
||||
#[cfg(unix)]
|
||||
_ => unreachable!(),
|
||||
},
|
||||
Ok(false) => {
|
||||
if !self
|
||||
.stream_wake_task_executed
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
// https://github.com/rust-lang/rust/issues/80486#issuecomment-752244166
|
||||
.unwrap_or_else(|x| x)
|
||||
{
|
||||
let stream_waker = cx.waker().clone();
|
||||
let stream_wake_task_executed = self.stream_wake_task_executed.clone();
|
||||
let stream_wake_task_should_shutdown =
|
||||
self.stream_wake_task_should_shutdown.clone();
|
||||
|
||||
stream_wake_task_should_shutdown.store(false, Ordering::SeqCst);
|
||||
|
||||
let _ = self.task_sender.send(Task {
|
||||
stream_waker,
|
||||
stream_wake_task_executed,
|
||||
stream_wake_task_should_shutdown,
|
||||
});
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
Err(e) => Poll::Ready(Some(Err(e))),
|
||||
};
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventStream {
|
||||
fn drop(&mut self) {
|
||||
self.stream_wake_task_should_shutdown
|
||||
.store(true, Ordering::SeqCst);
|
||||
let _ = self.poll_internal_waker.wake();
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
#[cfg(all(unix, feature = "event-stream"))]
|
||||
pub(crate) use unix::waker::Waker;
|
||||
#[cfg(all(windows, feature = "event-stream"))]
|
||||
pub(crate) use windows::waker::Waker;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod unix;
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub(crate) mod waker;
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
pub(crate) mod parse;
|
||||
+1514
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
#[cfg(feature = "use-dev-tty")]
|
||||
pub(crate) mod tty;
|
||||
|
||||
#[cfg(not(feature = "use-dev-tty"))]
|
||||
pub(crate) mod mio;
|
||||
|
||||
#[cfg(feature = "use-dev-tty")]
|
||||
pub(crate) use self::tty::Waker;
|
||||
|
||||
#[cfg(not(feature = "use-dev-tty"))]
|
||||
pub(crate) use self::mio::Waker;
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ::mio::{Registry, Token};
|
||||
|
||||
/// Allows to wake up the `mio::Poll::poll()` method.
|
||||
/// This type wraps `mio::Waker`, for more information see its documentation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Waker {
|
||||
inner: Arc<Mutex<::mio::Waker>>,
|
||||
}
|
||||
|
||||
impl Waker {
|
||||
/// Create a new `Waker`.
|
||||
pub(crate) fn new(registry: &Registry, waker_token: Token) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(mio::Waker::new(registry, waker_token)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wake up the [`Poll`] associated with this `Waker`.
|
||||
///
|
||||
/// Readiness is set to `Ready::readable()`.
|
||||
pub(crate) fn wake(&self) -> std::io::Result<()> {
|
||||
self.inner.lock().unwrap().wake()
|
||||
}
|
||||
|
||||
/// Resets the state so the same waker can be reused.
|
||||
///
|
||||
/// This function is not impl
|
||||
#[allow(dead_code, clippy::clippy::unnecessary_wraps)]
|
||||
pub(crate) fn reset(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
os::unix::net::UnixStream,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// Allows to wake up the EventSource::try_read() method.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Waker {
|
||||
inner: Arc<Mutex<UnixStream>>,
|
||||
}
|
||||
|
||||
impl Waker {
|
||||
/// Create a new `Waker`.
|
||||
pub(crate) fn new(writer: UnixStream) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(writer)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake up the [`Poll`] associated with this `Waker`.
|
||||
///
|
||||
/// Readiness is set to `Ready::readable()`.
|
||||
pub(crate) fn wake(&self) -> io::Result<()> {
|
||||
self.inner.lock().unwrap().write_all(&[0])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
//! This is a WINDOWS specific implementation for input related action.
|
||||
|
||||
use std::convert::TryFrom;
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crossterm_winapi::{ConsoleMode, Handle};
|
||||
|
||||
pub(crate) mod parse;
|
||||
pub(crate) mod poll;
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub(crate) mod waker;
|
||||
|
||||
const ENABLE_MOUSE_MODE: u32 = 0x0010 | 0x0080 | 0x0008;
|
||||
|
||||
/// This is a either `u64::MAX` if it's uninitialized or a valid `u32` that stores the original
|
||||
/// console mode if it's initialized.
|
||||
static ORIGINAL_CONSOLE_MODE: AtomicU64 = AtomicU64::new(u64::MAX);
|
||||
|
||||
/// Initializes the default console color. It will will be skipped if it has already been initialized.
|
||||
fn init_original_console_mode(original_mode: u32) {
|
||||
let _ = ORIGINAL_CONSOLE_MODE.compare_exchange(
|
||||
u64::MAX,
|
||||
u64::from(original_mode),
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the original console color, make sure to call `init_console_color` before calling this function. Otherwise this function will panic.
|
||||
fn original_console_mode() -> std::io::Result<u32> {
|
||||
u32::try_from(ORIGINAL_CONSOLE_MODE.load(Ordering::Relaxed))
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::Other, "Initial console modes not set"))
|
||||
}
|
||||
|
||||
pub(crate) fn enable_mouse_capture() -> std::io::Result<()> {
|
||||
let mode = ConsoleMode::from(Handle::current_in_handle()?);
|
||||
init_original_console_mode(mode.mode()?);
|
||||
mode.set_mode(ENABLE_MOUSE_MODE)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn disable_mouse_capture() -> std::io::Result<()> {
|
||||
let mode = ConsoleMode::from(Handle::current_in_handle()?);
|
||||
mode.set_mode(original_console_mode()?)?;
|
||||
Ok(())
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
use crossterm_winapi::{ControlKeyState, EventFlags, KeyEventRecord, ScreenBuffer};
|
||||
use winapi::um::{
|
||||
wincon::{
|
||||
CAPSLOCK_ON, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED,
|
||||
SHIFT_PRESSED,
|
||||
},
|
||||
winuser::{
|
||||
GetForegroundWindow, GetKeyboardLayout, GetWindowThreadProcessId, ToUnicodeEx, VK_BACK,
|
||||
VK_CONTROL, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F24, VK_HOME, VK_INSERT,
|
||||
VK_LEFT, VK_MENU, VK_NEXT, VK_NUMPAD0, VK_NUMPAD9, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SHIFT,
|
||||
VK_TAB, VK_UP,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::event::{
|
||||
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MouseButtonsPressed {
|
||||
pub(crate) left: bool,
|
||||
pub(crate) right: bool,
|
||||
pub(crate) middle: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mouse_event(
|
||||
mouse_event: crossterm_winapi::MouseEvent,
|
||||
buttons_pressed: &MouseButtonsPressed,
|
||||
) -> Option<Event> {
|
||||
if let Ok(Some(event)) = parse_mouse_event_record(&mouse_event, buttons_pressed) {
|
||||
return Some(Event::Mouse(event));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
enum WindowsKeyEvent {
|
||||
KeyEvent(KeyEvent),
|
||||
Surrogate(u16),
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(
|
||||
key_event: KeyEventRecord,
|
||||
surrogate_buffer: &mut Option<u16>,
|
||||
) -> Option<Event> {
|
||||
let windows_key_event = parse_key_event_record(&key_event)?;
|
||||
match windows_key_event {
|
||||
WindowsKeyEvent::KeyEvent(key_event) => {
|
||||
// Discard any buffered surrogate value if another valid key event comes before the
|
||||
// next surrogate value.
|
||||
*surrogate_buffer = None;
|
||||
Some(Event::Key(key_event))
|
||||
}
|
||||
WindowsKeyEvent::Surrogate(new_surrogate) => {
|
||||
let ch = handle_surrogate(surrogate_buffer, new_surrogate)?;
|
||||
let modifiers = KeyModifiers::from(&key_event.control_key_state);
|
||||
let key_event = KeyEvent::new(KeyCode::Char(ch), modifiers);
|
||||
Some(Event::Key(key_event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_surrogate(surrogate_buffer: &mut Option<u16>, new_surrogate: u16) -> Option<char> {
|
||||
match *surrogate_buffer {
|
||||
Some(buffered_surrogate) => {
|
||||
*surrogate_buffer = None;
|
||||
std::char::decode_utf16([buffered_surrogate, new_surrogate])
|
||||
.next()
|
||||
.unwrap()
|
||||
.ok()
|
||||
}
|
||||
None => {
|
||||
*surrogate_buffer = Some(new_surrogate);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ControlKeyState> for KeyModifiers {
|
||||
fn from(state: &ControlKeyState) -> Self {
|
||||
let shift = state.has_state(SHIFT_PRESSED);
|
||||
let alt = state.has_state(LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED);
|
||||
let control = state.has_state(LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED);
|
||||
|
||||
let mut modifier = KeyModifiers::empty();
|
||||
|
||||
if shift {
|
||||
modifier |= KeyModifiers::SHIFT;
|
||||
}
|
||||
if control {
|
||||
modifier |= KeyModifiers::CONTROL;
|
||||
}
|
||||
if alt {
|
||||
modifier |= KeyModifiers::ALT;
|
||||
}
|
||||
|
||||
modifier
|
||||
}
|
||||
}
|
||||
|
||||
enum CharCase {
|
||||
LowerCase,
|
||||
UpperCase,
|
||||
}
|
||||
|
||||
fn try_ensure_char_case(ch: char, desired_case: CharCase) -> char {
|
||||
match desired_case {
|
||||
CharCase::LowerCase if ch.is_uppercase() => {
|
||||
let mut iter = ch.to_lowercase();
|
||||
// Unwrap is safe; iterator yields one or more chars.
|
||||
let ch_lower = iter.next().unwrap();
|
||||
if iter.next().is_none() {
|
||||
ch_lower
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
}
|
||||
CharCase::UpperCase if ch.is_lowercase() => {
|
||||
let mut iter = ch.to_uppercase();
|
||||
// Unwrap is safe; iterator yields one or more chars.
|
||||
let ch_upper = iter.next().unwrap();
|
||||
if iter.next().is_none() {
|
||||
ch_upper
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
}
|
||||
_ => ch,
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts to return the character for a key event accounting for the user's keyboard layout.
|
||||
// The returned character (if any) is capitalized (if applicable) based on shift and capslock state.
|
||||
// Returns None if the key doesn't map to a character or if it is a dead key.
|
||||
// We use the *currently* active keyboard layout (if it can be determined). This layout may not
|
||||
// correspond to the keyboard layout that was active when the user typed their input, since console
|
||||
// applications get their input asynchronously from the terminal. By the time a console application
|
||||
// can process a key input, the user may have changed the active layout. In this case, the character
|
||||
// returned might not correspond to what the user expects, but there is no way for a console
|
||||
// application to know what the keyboard layout actually was for a key event, so this is our best
|
||||
// effort. If a console application processes input in a timely fashion, then it is unlikely that a
|
||||
// user has time to change their keyboard layout before a key event is processed.
|
||||
fn get_char_for_key(key_event: &KeyEventRecord) -> Option<char> {
|
||||
let virtual_key_code = key_event.virtual_key_code as u32;
|
||||
let virtual_scan_code = key_event.virtual_scan_code as u32;
|
||||
let key_state = [0u8; 256];
|
||||
let mut utf16_buf = [0u16, 16];
|
||||
let dont_change_kernel_keyboard_state = 0x4;
|
||||
|
||||
// Best-effort attempt at determining the currently active keyboard layout.
|
||||
// At the time of writing, this works for a console application running in Windows Terminal, but
|
||||
// doesn't work under a Conhost terminal. For Conhost, the window handle returned by
|
||||
// GetForegroundWindow() does not appear to actually be the foreground window which has the
|
||||
// keyboard layout associated with it (or perhaps it is, but also has special protection that
|
||||
// doesn't allow us to query it).
|
||||
// When this determination fails, the returned keyboard layout handle will be null, which is an
|
||||
// acceptable input for ToUnicodeEx, as that argument is optional. In this case ToUnicodeEx
|
||||
// appears to use the keyboard layout associated with the current thread, which will be the
|
||||
// layout that was inherited when the console application started (or possibly when the current
|
||||
// thread was spawned). This is then unfortunately not updated when the user changes their
|
||||
// keyboard layout in the terminal, but it's what we get.
|
||||
let active_keyboard_layout = unsafe {
|
||||
let foreground_window = GetForegroundWindow();
|
||||
let foreground_thread = GetWindowThreadProcessId(foreground_window, std::ptr::null_mut());
|
||||
GetKeyboardLayout(foreground_thread)
|
||||
};
|
||||
|
||||
let ret = unsafe {
|
||||
ToUnicodeEx(
|
||||
virtual_key_code,
|
||||
virtual_scan_code,
|
||||
key_state.as_ptr(),
|
||||
utf16_buf.as_mut_ptr(),
|
||||
utf16_buf.len() as i32,
|
||||
dont_change_kernel_keyboard_state,
|
||||
active_keyboard_layout,
|
||||
)
|
||||
};
|
||||
|
||||
// -1 indicates a dead key.
|
||||
// 0 indicates no character for this key.
|
||||
if ret < 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut ch_iter = std::char::decode_utf16(utf16_buf.into_iter().take(ret as usize));
|
||||
let mut ch = ch_iter.next()?.ok()?;
|
||||
if ch_iter.next().is_some() {
|
||||
// Key doesn't map to a single char.
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_shift_pressed = key_event.control_key_state.has_state(SHIFT_PRESSED);
|
||||
let is_capslock_on = key_event.control_key_state.has_state(CAPSLOCK_ON);
|
||||
let desired_case = if is_shift_pressed ^ is_capslock_on {
|
||||
CharCase::UpperCase
|
||||
} else {
|
||||
CharCase::LowerCase
|
||||
};
|
||||
ch = try_ensure_char_case(ch, desired_case);
|
||||
Some(ch)
|
||||
}
|
||||
|
||||
fn parse_key_event_record(key_event: &KeyEventRecord) -> Option<WindowsKeyEvent> {
|
||||
let modifiers = KeyModifiers::from(&key_event.control_key_state);
|
||||
let virtual_key_code = key_event.virtual_key_code as i32;
|
||||
|
||||
// We normally ignore all key release events, but we will make an exception for an Alt key
|
||||
// release if it carries a u_char value, as this indicates an Alt code.
|
||||
let is_alt_code = virtual_key_code == VK_MENU && !key_event.key_down && key_event.u_char != 0;
|
||||
if is_alt_code {
|
||||
let utf16 = key_event.u_char;
|
||||
match utf16 {
|
||||
surrogate @ 0xD800..=0xDFFF => {
|
||||
return Some(WindowsKeyEvent::Surrogate(surrogate));
|
||||
}
|
||||
unicode_scalar_value => {
|
||||
// Unwrap is safe: We tested for surrogate values above and those are the only
|
||||
// u16 values that are invalid when directly interpreted as unicode scalar
|
||||
// values.
|
||||
let ch = std::char::from_u32(unicode_scalar_value as u32).unwrap();
|
||||
let key_code = KeyCode::Char(ch);
|
||||
let kind = if key_event.key_down {
|
||||
KeyEventKind::Press
|
||||
} else {
|
||||
KeyEventKind::Release
|
||||
};
|
||||
let key_event = KeyEvent::new_with_kind(key_code, modifiers, kind);
|
||||
return Some(WindowsKeyEvent::KeyEvent(key_event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't generate events for numpad key presses when they're producing Alt codes.
|
||||
let is_numpad_numeric_key = (VK_NUMPAD0..=VK_NUMPAD9).contains(&virtual_key_code);
|
||||
let is_only_alt_modifier = modifiers.contains(KeyModifiers::ALT)
|
||||
&& !modifiers.contains(KeyModifiers::SHIFT | KeyModifiers::CONTROL);
|
||||
if is_only_alt_modifier && is_numpad_numeric_key {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parse_result = match virtual_key_code {
|
||||
VK_SHIFT | VK_CONTROL | VK_MENU => None,
|
||||
VK_BACK => Some(KeyCode::Backspace),
|
||||
VK_ESCAPE => Some(KeyCode::Esc),
|
||||
VK_RETURN => Some(KeyCode::Enter),
|
||||
VK_F1..=VK_F24 => Some(KeyCode::F((key_event.virtual_key_code - 111) as u8)),
|
||||
VK_LEFT => Some(KeyCode::Left),
|
||||
VK_UP => Some(KeyCode::Up),
|
||||
VK_RIGHT => Some(KeyCode::Right),
|
||||
VK_DOWN => Some(KeyCode::Down),
|
||||
VK_PRIOR => Some(KeyCode::PageUp),
|
||||
VK_NEXT => Some(KeyCode::PageDown),
|
||||
VK_HOME => Some(KeyCode::Home),
|
||||
VK_END => Some(KeyCode::End),
|
||||
VK_DELETE => Some(KeyCode::Delete),
|
||||
VK_INSERT => Some(KeyCode::Insert),
|
||||
VK_TAB if modifiers.contains(KeyModifiers::SHIFT) => Some(KeyCode::BackTab),
|
||||
VK_TAB => Some(KeyCode::Tab),
|
||||
_ => {
|
||||
let utf16 = key_event.u_char;
|
||||
match utf16 {
|
||||
0x00..=0x1f => {
|
||||
// Some key combinations generate either no u_char value or generate control
|
||||
// codes. To deliver back a KeyCode::Char(...) event we want to know which
|
||||
// character the key normally maps to on the user's keyboard layout.
|
||||
// The keys that intentionally generate control codes (ESC, ENTER, TAB, etc.)
|
||||
// are handled by their virtual key codes above.
|
||||
get_char_for_key(key_event).map(KeyCode::Char)
|
||||
}
|
||||
surrogate @ 0xD800..=0xDFFF => {
|
||||
return Some(WindowsKeyEvent::Surrogate(surrogate));
|
||||
}
|
||||
unicode_scalar_value => {
|
||||
// Unwrap is safe: We tested for surrogate values above and those are the only
|
||||
// u16 values that are invalid when directly interpreted as unicode scalar
|
||||
// values.
|
||||
let ch = std::char::from_u32(unicode_scalar_value as u32).unwrap();
|
||||
Some(KeyCode::Char(ch))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(key_code) = parse_result {
|
||||
let kind = if key_event.key_down {
|
||||
KeyEventKind::Press
|
||||
} else {
|
||||
KeyEventKind::Release
|
||||
};
|
||||
let key_event = KeyEvent::new_with_kind(key_code, modifiers, kind);
|
||||
return Some(WindowsKeyEvent::KeyEvent(key_event));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// The 'y' position of a mouse event or resize event is not relative to the window but absolute to screen buffer.
|
||||
// This means that when the mouse cursor is at the top left it will be x: 0, y: 2295 (e.g. y = number of cells conting from the absolute buffer height) instead of relative x: 0, y: 0 to the window.
|
||||
pub fn parse_relative_y(y: i16) -> std::io::Result<i16> {
|
||||
let window_size = ScreenBuffer::current()?.info()?.terminal_window();
|
||||
Ok(y - window_size.top)
|
||||
}
|
||||
|
||||
fn parse_mouse_event_record(
|
||||
event: &crossterm_winapi::MouseEvent,
|
||||
buttons_pressed: &MouseButtonsPressed,
|
||||
) -> std::io::Result<Option<MouseEvent>> {
|
||||
let modifiers = KeyModifiers::from(&event.control_key_state);
|
||||
|
||||
let xpos = event.mouse_position.x as u16;
|
||||
let ypos = parse_relative_y(event.mouse_position.y)? as u16;
|
||||
|
||||
let button_state = event.button_state;
|
||||
|
||||
let kind = match event.event_flags {
|
||||
EventFlags::PressOrRelease | EventFlags::DoubleClick => {
|
||||
if button_state.left_button() && !buttons_pressed.left {
|
||||
Some(MouseEventKind::Down(MouseButton::Left))
|
||||
} else if !button_state.left_button() && buttons_pressed.left {
|
||||
Some(MouseEventKind::Up(MouseButton::Left))
|
||||
} else if button_state.right_button() && !buttons_pressed.right {
|
||||
Some(MouseEventKind::Down(MouseButton::Right))
|
||||
} else if !button_state.right_button() && buttons_pressed.right {
|
||||
Some(MouseEventKind::Up(MouseButton::Right))
|
||||
} else if button_state.middle_button() && !buttons_pressed.middle {
|
||||
Some(MouseEventKind::Down(MouseButton::Middle))
|
||||
} else if !button_state.middle_button() && buttons_pressed.middle {
|
||||
Some(MouseEventKind::Up(MouseButton::Middle))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
EventFlags::MouseMoved => {
|
||||
let button = if button_state.right_button() {
|
||||
MouseButton::Right
|
||||
} else if button_state.middle_button() {
|
||||
MouseButton::Middle
|
||||
} else {
|
||||
MouseButton::Left
|
||||
};
|
||||
if button_state.release_button() {
|
||||
Some(MouseEventKind::Moved)
|
||||
} else {
|
||||
Some(MouseEventKind::Drag(button))
|
||||
}
|
||||
}
|
||||
EventFlags::MouseWheeled => {
|
||||
// Vertical scroll
|
||||
// from https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str
|
||||
// if `button_state` is negative then the wheel was rotated backward, toward the user.
|
||||
if button_state.scroll_down() {
|
||||
Some(MouseEventKind::ScrollDown)
|
||||
} else if button_state.scroll_up() {
|
||||
Some(MouseEventKind::ScrollUp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
EventFlags::MouseHwheeled => {
|
||||
if button_state.scroll_left() {
|
||||
Some(MouseEventKind::ScrollLeft)
|
||||
} else if button_state.scroll_right() {
|
||||
Some(MouseEventKind::ScrollRight)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(kind.map(|kind| MouseEvent {
|
||||
kind,
|
||||
column: xpos,
|
||||
row: ypos,
|
||||
modifiers,
|
||||
}))
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm_winapi::Handle;
|
||||
use winapi::{
|
||||
shared::winerror::WAIT_TIMEOUT,
|
||||
um::{
|
||||
synchapi::WaitForMultipleObjects,
|
||||
winbase::{INFINITE, WAIT_ABANDONED_0, WAIT_FAILED, WAIT_OBJECT_0},
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub(crate) use super::waker::Waker;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WinApiPoll {
|
||||
#[cfg(feature = "event-stream")]
|
||||
waker: Waker,
|
||||
}
|
||||
|
||||
impl WinApiPoll {
|
||||
#[cfg(not(feature = "event-stream"))]
|
||||
pub(crate) fn new() -> WinApiPoll {
|
||||
WinApiPoll {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub(crate) fn new() -> std::io::Result<WinApiPoll> {
|
||||
Ok(WinApiPoll {
|
||||
waker: Waker::new()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WinApiPoll {
|
||||
pub fn poll(&mut self, timeout: Option<Duration>) -> std::io::Result<Option<bool>> {
|
||||
let dw_millis = if let Some(duration) = timeout {
|
||||
duration.as_millis() as u32
|
||||
} else {
|
||||
INFINITE
|
||||
};
|
||||
|
||||
let console_handle = Handle::current_in_handle()?;
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
let semaphore = self.waker.semaphore();
|
||||
#[cfg(feature = "event-stream")]
|
||||
let handles = &[*console_handle, **semaphore.handle()];
|
||||
#[cfg(not(feature = "event-stream"))]
|
||||
let handles = &[*console_handle];
|
||||
|
||||
let output =
|
||||
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, dw_millis) };
|
||||
|
||||
match output {
|
||||
output if output == WAIT_OBJECT_0 => {
|
||||
// input handle triggered
|
||||
Ok(Some(true))
|
||||
}
|
||||
#[cfg(feature = "event-stream")]
|
||||
output if output == WAIT_OBJECT_0 + 1 => {
|
||||
// semaphore handle triggered
|
||||
let _ = self.waker.reset();
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"Poll operation was woken up by `Waker::wake`",
|
||||
))
|
||||
}
|
||||
WAIT_TIMEOUT | WAIT_ABANDONED_0 => {
|
||||
// timeout elapsed
|
||||
Ok(None)
|
||||
}
|
||||
WAIT_FAILED => Err(io::Error::last_os_error()),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"WaitForMultipleObjects returned unexpected result.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "event-stream")]
|
||||
pub fn waker(&self) -> Waker {
|
||||
self.waker.clone()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossterm_winapi::Semaphore;
|
||||
|
||||
/// Allows to wake up the `WinApiPoll::poll()` method.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Waker {
|
||||
inner: Arc<Mutex<Semaphore>>,
|
||||
}
|
||||
|
||||
impl Waker {
|
||||
/// Creates a new waker.
|
||||
///
|
||||
/// `Waker` is based on the `Semaphore`. You have to use the semaphore
|
||||
/// handle along with the `WaitForMultipleObjects`.
|
||||
pub(crate) fn new() -> std::io::Result<Self> {
|
||||
let inner = Semaphore::new()?;
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wakes the `WaitForMultipleObjects`.
|
||||
pub(crate) fn wake(&self) -> std::io::Result<()> {
|
||||
self.inner.lock().unwrap().release()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the current semaphore with a new one allowing us to reuse the same `Waker`.
|
||||
pub(crate) fn reset(&self) -> std::io::Result<()> {
|
||||
*self.inner.lock().unwrap() = Semaphore::new()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the semaphore associated with the waker.
|
||||
pub(crate) fn semaphore(&self) -> Semaphore {
|
||||
self.inner.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Keeps track of the elapsed time since the moment the polling started.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PollTimeout {
|
||||
timeout: Option<Duration>,
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl PollTimeout {
|
||||
/// Constructs a new `PollTimeout` with the given optional `Duration`.
|
||||
pub fn new(timeout: Option<Duration>) -> PollTimeout {
|
||||
PollTimeout {
|
||||
timeout,
|
||||
start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the timeout has elapsed.
|
||||
///
|
||||
/// It always returns `false` if the initial timeout was set to `None`.
|
||||
pub fn elapsed(&self) -> bool {
|
||||
self.timeout
|
||||
.map(|timeout| self.start.elapsed() >= timeout)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns the timeout leftover (initial timeout duration - elapsed duration).
|
||||
pub fn leftover(&self) -> Option<Duration> {
|
||||
self.timeout.map(|timeout| {
|
||||
let elapsed = self.start.elapsed();
|
||||
|
||||
if elapsed >= timeout {
|
||||
Duration::from_secs(0)
|
||||
} else {
|
||||
timeout - elapsed
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::PollTimeout;
|
||||
|
||||
#[test]
|
||||
pub fn test_timeout_without_duration_does_not_have_leftover() {
|
||||
let timeout = PollTimeout::new(None);
|
||||
assert_eq!(timeout.leftover(), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_timeout_without_duration_never_elapses() {
|
||||
let timeout = PollTimeout::new(None);
|
||||
assert!(!timeout.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_timeout_elapses() {
|
||||
const TIMEOUT_MILLIS: u64 = 100;
|
||||
|
||||
let timeout = PollTimeout {
|
||||
timeout: Some(Duration::from_millis(TIMEOUT_MILLIS)),
|
||||
start: Instant::now() - Duration::from_millis(2 * TIMEOUT_MILLIS),
|
||||
};
|
||||
|
||||
assert!(timeout.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_elapsed_timeout_has_zero_leftover() {
|
||||
const TIMEOUT_MILLIS: u64 = 100;
|
||||
|
||||
let timeout = PollTimeout {
|
||||
timeout: Some(Duration::from_millis(TIMEOUT_MILLIS)),
|
||||
start: Instant::now() - Duration::from_millis(2 * TIMEOUT_MILLIS),
|
||||
};
|
||||
|
||||
assert!(timeout.elapsed());
|
||||
assert_eq!(timeout.leftover(), Some(Duration::from_millis(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_not_elapsed_timeout_has_positive_leftover() {
|
||||
let timeout = PollTimeout::new(Some(Duration::from_secs(60)));
|
||||
|
||||
assert!(!timeout.elapsed());
|
||||
assert!(timeout.leftover().unwrap() > Duration::from_secs(0));
|
||||
}
|
||||
}
|
||||
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
#![deny(unused_imports, unused_must_use)]
|
||||
|
||||
//! # Cross-platform Terminal Manipulation Library
|
||||
//!
|
||||
//! Crossterm is a pure-rust, terminal manipulation library that makes it possible to write cross-platform text-based interfaces.
|
||||
//!
|
||||
//! This crate supports all UNIX and Windows terminals down to Windows 7 (not all terminals are tested
|
||||
//! see [Tested Terminals](https://github.com/crossterm-rs/crossterm#tested-terminals)
|
||||
//! for more info).
|
||||
//!
|
||||
//! ## Command API
|
||||
//!
|
||||
//! The command API makes the use of `crossterm` much easier and offers more control over when and how a
|
||||
//! command is executed. A command is just an action you can perform on the terminal e.g. cursor movement.
|
||||
//!
|
||||
//! The command API offers:
|
||||
//!
|
||||
//! * Better Performance.
|
||||
//! * Complete control over when to flush.
|
||||
//! * Complete control over where the ANSI escape commands are executed to.
|
||||
//! * Way easier and nicer API.
|
||||
//!
|
||||
//! There are two ways to use the API command:
|
||||
//!
|
||||
//! * Functions can execute commands on types that implement Write. Functions are easier to use and debug.
|
||||
//! There is a disadvantage, and that is that there is a boilerplate code involved.
|
||||
//! * Macros are generally seen as more difficult and aren't always well supported by editors but offer an API with less boilerplate code. If you are
|
||||
//! not afraid of macros, this is a recommendation.
|
||||
//!
|
||||
//! Linux and Windows 10 systems support ANSI escape codes. Those ANSI escape codes are strings or rather a
|
||||
//! byte sequence. When we `write` and `flush` those to the terminal we can perform some action.
|
||||
//! For older windows systems a WinAPI call is made.
|
||||
//!
|
||||
//! ### Supported Commands
|
||||
//!
|
||||
//! - Module [`cursor`](cursor/index.html)
|
||||
//! - Visibility - [`Show`](cursor/struct.Show.html), [`Hide`](cursor/struct.Hide.html)
|
||||
//! - Appearance - [`EnableBlinking`](cursor/struct.EnableBlinking.html),
|
||||
//! [`DisableBlinking`](cursor/struct.DisableBlinking.html),
|
||||
//! [`SetCursorStyle`](cursor/enum.SetCursorStyle.html)
|
||||
//! - Position -
|
||||
//! [`SavePosition`](cursor/struct.SavePosition.html), [`RestorePosition`](cursor/struct.RestorePosition.html),
|
||||
//! [`MoveUp`](cursor/struct.MoveUp.html), [`MoveDown`](cursor/struct.MoveDown.html),
|
||||
//! [`MoveLeft`](cursor/struct.MoveLeft.html), [`MoveRight`](cursor/struct.MoveRight.html),
|
||||
//! [`MoveTo`](cursor/struct.MoveTo.html), [`MoveToColumn`](cursor/struct.MoveToColumn.html),[`MoveToRow`](cursor/struct.MoveToRow.html),
|
||||
//! [`MoveToNextLine`](cursor/struct.MoveToNextLine.html), [`MoveToPreviousLine`](cursor/struct.MoveToPreviousLine.html)
|
||||
//! - Module [`event`](event/index.html)
|
||||
//! - Keyboard events -
|
||||
//! [`PushKeyboardEnhancementFlags`](event/struct.PushKeyboardEnhancementFlags.html),
|
||||
//! [`PopKeyboardEnhancementFlags`](event/struct.PopKeyboardEnhancementFlags.html)
|
||||
//! - Mouse events - [`EnableMouseCapture`](event/struct.EnableMouseCapture.html),
|
||||
//! [`DisableMouseCapture`](event/struct.DisableMouseCapture.html)
|
||||
//! - Module [`style`](style/index.html)
|
||||
//! - Colors - [`SetForegroundColor`](style/struct.SetForegroundColor.html),
|
||||
//! [`SetBackgroundColor`](style/struct.SetBackgroundColor.html),
|
||||
//! [`ResetColor`](style/struct.ResetColor.html), [`SetColors`](style/struct.SetColors.html)
|
||||
//! - Attributes - [`SetAttribute`](style/struct.SetAttribute.html), [`SetAttributes`](style/struct.SetAttributes.html),
|
||||
//! [`PrintStyledContent`](style/struct.PrintStyledContent.html)
|
||||
//! - Module [`terminal`](terminal/index.html)
|
||||
//! - Scrolling - [`ScrollUp`](terminal/struct.ScrollUp.html),
|
||||
//! [`ScrollDown`](terminal/struct.ScrollDown.html)
|
||||
//! - Miscellaneous - [`Clear`](terminal/struct.Clear.html),
|
||||
//! [`SetSize`](terminal/struct.SetSize.html),
|
||||
//! [`SetTitle`](terminal/struct.SetTitle.html),
|
||||
//! [`DisableLineWrap`](terminal/struct.DisableLineWrap.html),
|
||||
//! [`EnableLineWrap`](terminal/struct.EnableLineWrap.html)
|
||||
//! - Alternate screen - [`EnterAlternateScreen`](terminal/struct.EnterAlternateScreen.html),
|
||||
//! [`LeaveAlternateScreen`](terminal/struct.LeaveAlternateScreen.html)
|
||||
//! - Module [`clipboard`](clipboard/index.html) (requires
|
||||
//! [`feature = "osc52"`](#optional-features))
|
||||
//! - Clipboard - [`CopyToClipboard`](clipboard/struct.CopyToClipboard.html)
|
||||
//!
|
||||
//! ### Command Execution
|
||||
//!
|
||||
//! There are two different ways to execute commands:
|
||||
//!
|
||||
//! * [Lazy Execution](#lazy-execution)
|
||||
//! * [Direct Execution](#direct-execution)
|
||||
//!
|
||||
//! #### Lazy Execution
|
||||
//!
|
||||
//! Flushing bytes to the terminal buffer is a heavy system call. If we perform a lot of actions with the terminal,
|
||||
//! we want to do this periodically - like with a TUI editor - so that we can flush more data to the terminal buffer
|
||||
//! at the same time.
|
||||
//!
|
||||
//! Crossterm offers the possibility to do this with `queue`.
|
||||
//! With `queue` you can queue commands, and when you call [Write::flush][flush] these commands will be executed.
|
||||
//!
|
||||
//! You can pass a custom buffer implementing [std::io::Write][write] to this `queue` operation.
|
||||
//! The commands will be executed on that buffer.
|
||||
//! The most common buffer is [std::io::stdout][stdout] however, [std::io::stderr][stderr] is used sometimes as well.
|
||||
//!
|
||||
//! ##### Examples
|
||||
//!
|
||||
//! A simple demonstration that shows the command API in action with cursor commands.
|
||||
//!
|
||||
//! Functions:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{Write, stdout};
|
||||
//! use crossterm::{QueueableCommand, cursor};
|
||||
//!
|
||||
//! let mut stdout = stdout();
|
||||
//! stdout.queue(cursor::MoveTo(5,5));
|
||||
//!
|
||||
//! // some other code ...
|
||||
//!
|
||||
//! stdout.flush();
|
||||
//! ```
|
||||
//!
|
||||
//! The [queue](./trait.QueueableCommand.html) function returns itself, therefore you can use this to queue another
|
||||
//! command. Like `stdout.queue(Goto(5,5)).queue(Clear(ClearType::All))`.
|
||||
//!
|
||||
//! Macros:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{Write, stdout};
|
||||
//! use crossterm::{queue, QueueableCommand, cursor};
|
||||
//!
|
||||
//! let mut stdout = stdout();
|
||||
//! queue!(stdout, cursor::MoveTo(5, 5));
|
||||
//!
|
||||
//! // some other code ...
|
||||
//!
|
||||
//! // move operation is performed only if we flush the buffer.
|
||||
//! stdout.flush();
|
||||
//! ```
|
||||
//!
|
||||
//! You can pass more than one command into the [queue](./macro.queue.html) macro like
|
||||
//! `queue!(stdout, MoveTo(5, 5), Clear(ClearType::All))` and
|
||||
//! they will be executed in the given order from left to right.
|
||||
//!
|
||||
//! #### Direct Execution
|
||||
//!
|
||||
//! For many applications it is not at all important to be efficient with 'flush' operations.
|
||||
//! For this use case there is the `execute` operation.
|
||||
//! This operation executes the command immediately, and calls the `flush` under water.
|
||||
//!
|
||||
//! You can pass a custom buffer implementing [std::io::Write][write] to this `execute` operation.
|
||||
//! The commands will be executed on that buffer.
|
||||
//! The most common buffer is [std::io::stdout][stdout] however, [std::io::stderr][stderr] is used sometimes as well.
|
||||
//!
|
||||
//! ##### Examples
|
||||
//!
|
||||
//! Functions:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{Write, stdout};
|
||||
//! use crossterm::{ExecutableCommand, cursor};
|
||||
//!
|
||||
//! let mut stdout = stdout();
|
||||
//! stdout.execute(cursor::MoveTo(5,5));
|
||||
//! ```
|
||||
//! The [execute](./trait.ExecutableCommand.html) function returns itself, therefore you can use this to queue
|
||||
//! another command. Like `stdout.execute(Goto(5,5))?.execute(Clear(ClearType::All))`.
|
||||
//!
|
||||
//! Macros:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{stdout, Write};
|
||||
//! use crossterm::{execute, ExecutableCommand, cursor};
|
||||
//!
|
||||
//! let mut stdout = stdout();
|
||||
//! execute!(stdout, cursor::MoveTo(5, 5));
|
||||
//! ```
|
||||
//! You can pass more than one command into the [execute](./macro.execute.html) macro like
|
||||
//! `execute!(stdout, MoveTo(5, 5), Clear(ClearType::All))` and they will be executed in the given order from
|
||||
//! left to right.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! Print a rectangle colored with magenta and use both direct execution and lazy execution.
|
||||
//!
|
||||
//! Functions:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//! use crossterm::{
|
||||
//! ExecutableCommand, QueueableCommand,
|
||||
//! terminal, cursor, style::{self, Stylize}
|
||||
//! };
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! let mut stdout = io::stdout();
|
||||
//!
|
||||
//! stdout.execute(terminal::Clear(terminal::ClearType::All))?;
|
||||
//!
|
||||
//! for y in 0..40 {
|
||||
//! for x in 0..150 {
|
||||
//! if (y == 0 || y == 40 - 1) || (x == 0 || x == 150 - 1) {
|
||||
//! // in this loop we are more efficient by not flushing the buffer.
|
||||
//! stdout
|
||||
//! .queue(cursor::MoveTo(x,y))?
|
||||
//! .queue(style::PrintStyledContent( "█".magenta()))?;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! stdout.flush()?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Macros:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//! use crossterm::{
|
||||
//! execute, queue,
|
||||
//! style::{self, Stylize}, cursor, terminal
|
||||
//! };
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! let mut stdout = io::stdout();
|
||||
//!
|
||||
//! execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
|
||||
//!
|
||||
//! for y in 0..40 {
|
||||
//! for x in 0..150 {
|
||||
//! if (y == 0 || y == 40 - 1) || (x == 0 || x == 150 - 1) {
|
||||
//! // in this loop we are more efficient by not flushing the buffer.
|
||||
//! queue!(stdout, cursor::MoveTo(x,y), style::PrintStyledContent( "█".magenta()))?;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! stdout.flush()?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//!```
|
||||
//! ## Feature Flags
|
||||
#![doc = document_features::document_features!()]
|
||||
//!
|
||||
//! [write]: https://doc.rust-lang.org/std/io/trait.Write.html
|
||||
//! [stdout]: https://doc.rust-lang.org/std/io/fn.stdout.html
|
||||
//! [stderr]: https://doc.rust-lang.org/std/io/fn.stderr.html
|
||||
//! [flush]: https://doc.rust-lang.org/std/io/trait.Write.html#tymethod.flush
|
||||
|
||||
pub use crate::command::{Command, ExecutableCommand, QueueableCommand, SynchronizedUpdate};
|
||||
|
||||
/// A module to work with the terminal cursor
|
||||
pub mod cursor;
|
||||
/// A module to read events.
|
||||
#[cfg(feature = "events")]
|
||||
pub mod event;
|
||||
/// A module to apply attributes and colors on your text.
|
||||
pub mod style;
|
||||
/// A module to work with the terminal.
|
||||
pub mod terminal;
|
||||
|
||||
/// A module to query if the current instance is a tty.
|
||||
pub mod tty;
|
||||
|
||||
/// A module for clipboard interaction
|
||||
#[cfg(feature = "osc52")]
|
||||
pub mod clipboard;
|
||||
|
||||
#[cfg(windows)]
|
||||
/// A module that exposes one function to check if the current terminal supports ANSI sequences.
|
||||
pub mod ansi_support;
|
||||
mod command;
|
||||
pub(crate) mod macros;
|
||||
|
||||
#[cfg(all(windows, not(feature = "windows")))]
|
||||
compile_error!("Compiling on Windows with \"windows\" feature disabled. Feature \"windows\" should only be disabled when project will never be compiled on Windows.");
|
||||
Vendored
+378
@@ -0,0 +1,378 @@
|
||||
/// Concatenate string literals while prepending a ANSI control sequence introducer (`"\x1b["`)
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! csi {
|
||||
($( $l:expr ),*) => { concat!("\x1B[", $( $l ),*) };
|
||||
}
|
||||
|
||||
/// Concatenate string literals while prepending a xterm Operating System Commands (OSC)
|
||||
/// introducer (`"\x1b]"`) and appending a BEL (`"\x07"`).
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! osc {
|
||||
($( $l:expr ),*) => { concat!("\x1B]", $( $l ),*, "\x1B\\") };
|
||||
}
|
||||
|
||||
/// Queues one or more command(s) for further execution.
|
||||
///
|
||||
/// Queued commands must be flushed to the underlying device to be executed.
|
||||
/// This generally happens in the following cases:
|
||||
///
|
||||
/// * When `flush` is called manually on the given type implementing `io::Write`.
|
||||
/// * The terminal will `flush` automatically if the buffer is full.
|
||||
/// * Each line is flushed in case of `stdout`, because it is line buffered.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - [std::io::Writer](std::io::Write)
|
||||
///
|
||||
/// ANSI escape codes are written on the given 'writer', after which they are flushed.
|
||||
///
|
||||
/// - [Command](./trait.Command.html)
|
||||
///
|
||||
/// One or more commands
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io::{Write, stdout};
|
||||
/// use crossterm::{queue, style::Print};
|
||||
///
|
||||
/// let mut stdout = stdout();
|
||||
///
|
||||
/// // `Print` will executed executed when `flush` is called.
|
||||
/// queue!(stdout, Print("foo".to_string()));
|
||||
///
|
||||
/// // some other code (no execution happening here) ...
|
||||
///
|
||||
/// // when calling `flush` on `stdout`, all commands will be written to the stdout and therefore executed.
|
||||
/// stdout.flush();
|
||||
///
|
||||
/// // ==== Output ====
|
||||
/// // foo
|
||||
/// ```
|
||||
///
|
||||
/// Have a look over at the [Command API](./index.html#command-api) for more details.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// In case of Windows versions lower than 10, a direct WinAPI call will be made.
|
||||
/// The reason for this is that Windows versions lower than 10 do not support ANSI codes,
|
||||
/// and can therefore not be written to the given `writer`.
|
||||
/// Therefore, there is no difference between [execute](macro.execute.html)
|
||||
/// and [queue](macro.queue.html) for those old Windows versions.
|
||||
///
|
||||
#[macro_export]
|
||||
macro_rules! queue {
|
||||
($writer:expr $(, $command:expr)* $(,)?) => {{
|
||||
use ::std::io::Write;
|
||||
|
||||
// This allows the macro to take both mut impl Write and &mut impl Write.
|
||||
Ok($writer.by_ref())
|
||||
$(.and_then(|writer| $crate::QueueableCommand::queue(writer, $command)))*
|
||||
.map(|_| ())
|
||||
}}
|
||||
}
|
||||
|
||||
/// Executes one or more command(s).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - [std::io::Writer](std::io::Write)
|
||||
///
|
||||
/// ANSI escape codes are written on the given 'writer', after which they are flushed.
|
||||
///
|
||||
/// - [Command](./trait.Command.html)
|
||||
///
|
||||
/// One or more commands
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io::{Write, stdout};
|
||||
/// use crossterm::{execute, style::Print};
|
||||
///
|
||||
/// // will be executed directly
|
||||
/// execute!(stdout(), Print("sum:\n".to_string()));
|
||||
///
|
||||
/// // will be executed directly
|
||||
/// execute!(stdout(), Print("1 + 1 = ".to_string()), Print((1+1).to_string()));
|
||||
///
|
||||
/// // ==== Output ====
|
||||
/// // sum:
|
||||
/// // 1 + 1 = 2
|
||||
/// ```
|
||||
///
|
||||
/// Have a look over at the [Command API](./index.html#command-api) for more details.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * In the case of UNIX and Windows 10, ANSI codes are written to the given 'writer'.
|
||||
/// * In case of Windows versions lower than 10, a direct WinAPI call will be made.
|
||||
/// The reason for this is that Windows versions lower than 10 do not support ANSI codes,
|
||||
/// and can therefore not be written to the given `writer`.
|
||||
/// Therefore, there is no difference between [execute](macro.execute.html)
|
||||
/// and [queue](macro.queue.html) for those old Windows versions.
|
||||
#[macro_export]
|
||||
macro_rules! execute {
|
||||
($writer:expr $(, $command:expr)* $(,)? ) => {{
|
||||
use ::std::io::Write;
|
||||
|
||||
// Queue each command, then flush
|
||||
$crate::queue!($writer $(, $command)*)
|
||||
.and_then(|()| {
|
||||
::std::io::Write::flush($writer.by_ref())
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! impl_display {
|
||||
(for $($t:ty),+) => {
|
||||
$(impl ::std::fmt::Display for $t {
|
||||
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
|
||||
$crate::command::execute_fmt(f, self)
|
||||
}
|
||||
})*
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! impl_from {
|
||||
($from:path, $to:expr) => {
|
||||
impl From<$from> for ErrorKind {
|
||||
fn from(e: $from) -> Self {
|
||||
$to(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::str;
|
||||
|
||||
// Helper for execute tests to confirm flush
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct FakeWrite {
|
||||
buffer: String,
|
||||
flushed: bool,
|
||||
}
|
||||
|
||||
impl io::Write for FakeWrite {
|
||||
fn write(&mut self, content: &[u8]) -> io::Result<usize> {
|
||||
let content = str::from_utf8(content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
self.buffer.push_str(content);
|
||||
self.flushed = false;
|
||||
Ok(content.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.flushed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod unix {
|
||||
use std::fmt;
|
||||
|
||||
use super::FakeWrite;
|
||||
use crate::command::Command;
|
||||
|
||||
pub struct FakeCommand;
|
||||
|
||||
impl Command for FakeCommand {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str("cmd")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_one() {
|
||||
let mut result = FakeWrite::default();
|
||||
queue!(&mut result, FakeCommand).unwrap();
|
||||
assert_eq!(&result.buffer, "cmd");
|
||||
assert!(!result.flushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_many() {
|
||||
let mut result = FakeWrite::default();
|
||||
queue!(&mut result, FakeCommand, FakeCommand).unwrap();
|
||||
assert_eq!(&result.buffer, "cmdcmd");
|
||||
assert!(!result.flushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_trailing_comma() {
|
||||
let mut result = FakeWrite::default();
|
||||
queue!(&mut result, FakeCommand, FakeCommand,).unwrap();
|
||||
assert_eq!(&result.buffer, "cmdcmd");
|
||||
assert!(!result.flushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_one() {
|
||||
let mut result = FakeWrite::default();
|
||||
execute!(&mut result, FakeCommand).unwrap();
|
||||
assert_eq!(&result.buffer, "cmd");
|
||||
assert!(result.flushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_many() {
|
||||
let mut result = FakeWrite::default();
|
||||
execute!(&mut result, FakeCommand, FakeCommand).unwrap();
|
||||
assert_eq!(&result.buffer, "cmdcmd");
|
||||
assert!(result.flushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_trailing_comma() {
|
||||
let mut result = FakeWrite::default();
|
||||
execute!(&mut result, FakeCommand, FakeCommand,).unwrap();
|
||||
assert_eq!(&result.buffer, "cmdcmd");
|
||||
assert!(result.flushed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use std::fmt;
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::FakeWrite;
|
||||
use crate::command::Command;
|
||||
|
||||
// We need to test two different APIs: WinAPI and the write api. We
|
||||
// don't know until runtime which we're supporting (via
|
||||
// Command::is_ansi_code_supported), so we have to test them both. The
|
||||
// CI environment hopefully includes both versions of windows.
|
||||
|
||||
// WindowsEventStream is a place for execute_winapi to push strings,
|
||||
// when called.
|
||||
type WindowsEventStream = Vec<&'static str>;
|
||||
|
||||
struct FakeCommand<'a> {
|
||||
// Need to use a refcell because we want execute_winapi to be able
|
||||
// push to the vector, but execute_winapi take &self.
|
||||
stream: RefCell<&'a mut WindowsEventStream>,
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
impl<'a> FakeCommand<'a> {
|
||||
fn new(stream: &'a mut WindowsEventStream, value: &'static str) -> Self {
|
||||
Self {
|
||||
value,
|
||||
stream: RefCell::new(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Command for FakeCommand<'a> {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(self.value)
|
||||
}
|
||||
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
self.stream.borrow_mut().push(self.value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for running tests against either WinAPI or an
|
||||
// io::Write.
|
||||
//
|
||||
// This function will execute the `test` function, which should
|
||||
// queue some commands against the given FakeWrite and
|
||||
// WindowsEventStream. It will then test that the correct data sink
|
||||
// was populated. It does not currently check is_ansi_code_supported;
|
||||
// for now it simply checks that one of the two streams was correctly
|
||||
// populated.
|
||||
//
|
||||
// If the stream was populated, it tests that the two arrays are equal.
|
||||
// If the writer was populated, it tests that the contents of the
|
||||
// write buffer are equal to the concatenation of `stream_result`.
|
||||
fn test_harness(
|
||||
stream_result: &[&'static str],
|
||||
test: impl FnOnce(&mut FakeWrite, &mut WindowsEventStream) -> std::io::Result<()>,
|
||||
) {
|
||||
let mut stream = WindowsEventStream::default();
|
||||
let mut writer = FakeWrite::default();
|
||||
|
||||
if let Err(err) = test(&mut writer, &mut stream) {
|
||||
panic!("Error returned from test function: {:?}", err);
|
||||
}
|
||||
|
||||
// We need this for type inference, for whatever reason.
|
||||
const EMPTY_RESULT: [&str; 0] = [];
|
||||
|
||||
// TODO: confirm that the correct sink was used, based on
|
||||
// is_ansi_code_supported
|
||||
match (writer.buffer.is_empty(), stream.is_empty()) {
|
||||
(true, true) if stream_result == EMPTY_RESULT => {}
|
||||
(true, true) => panic!(
|
||||
"Neither the event stream nor the writer were populated. Expected {:?}",
|
||||
stream_result
|
||||
),
|
||||
|
||||
// writer is populated
|
||||
(false, true) => {
|
||||
// Concat the stream result to find the string result
|
||||
let result: String = stream_result.iter().copied().collect();
|
||||
assert_eq!(result, writer.buffer);
|
||||
assert_eq!(&stream, &EMPTY_RESULT);
|
||||
}
|
||||
|
||||
// stream is populated
|
||||
(true, false) => {
|
||||
assert_eq!(stream, stream_result);
|
||||
assert_eq!(writer.buffer, "");
|
||||
}
|
||||
|
||||
// Both are populated
|
||||
(false, false) => panic!(
|
||||
"Both the writer and the event stream were written to.\n\
|
||||
Only one should be used, based on is_ansi_code_supported.\n\
|
||||
stream: {stream:?}\n\
|
||||
writer: {writer:?}",
|
||||
stream = stream,
|
||||
writer = writer,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_one() {
|
||||
test_harness(&["cmd1"], |writer, stream| {
|
||||
queue!(writer, FakeCommand::new(stream, "cmd1"))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_some() {
|
||||
test_harness(&["cmd1", "cmd2"], |writer, stream| {
|
||||
queue!(
|
||||
writer,
|
||||
FakeCommand::new(stream, "cmd1"),
|
||||
FakeCommand::new(stream, "cmd2"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_many_queues() {
|
||||
test_harness(&["cmd1", "cmd2", "cmd3"], |writer, stream| {
|
||||
queue!(writer, FakeCommand::new(stream, "cmd1"))?;
|
||||
queue!(writer, FakeCommand::new(stream, "cmd2"))?;
|
||||
queue!(writer, FakeCommand::new(stream, "cmd3"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+621
@@ -0,0 +1,621 @@
|
||||
//! # Style
|
||||
//!
|
||||
//! The `style` module provides a functionality to apply attributes and colors on your text.
|
||||
//!
|
||||
//! This documentation does not contain a lot of examples. The reason is that it's fairly
|
||||
//! obvious how to use this crate. Although, we do provide
|
||||
//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository
|
||||
//! to demonstrate the capabilities.
|
||||
//!
|
||||
//! ## Platform-specific Notes
|
||||
//!
|
||||
//! Not all features are supported on all terminals/platforms. You should always consult
|
||||
//! platform-specific notes of the following types:
|
||||
//!
|
||||
//! * [Color](enum.Color.html#platform-specific-notes)
|
||||
//! * [Attribute](enum.Attribute.html#platform-specific-notes)
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! A few examples of how to use the style module.
|
||||
//!
|
||||
//! ### Colors
|
||||
//!
|
||||
//! How to change the terminal text color.
|
||||
//!
|
||||
//! Command API:
|
||||
//!
|
||||
//! Using the Command API to color text.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//! use crossterm::execute;
|
||||
//! use crossterm::style::{Print, SetForegroundColor, SetBackgroundColor, ResetColor, Color, Attribute};
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! execute!(
|
||||
//! io::stdout(),
|
||||
//! // Blue foreground
|
||||
//! SetForegroundColor(Color::Blue),
|
||||
//! // Red background
|
||||
//! SetBackgroundColor(Color::Red),
|
||||
//! // Print text
|
||||
//! Print("Blue text on Red.".to_string()),
|
||||
//! // Reset to default colors
|
||||
//! ResetColor
|
||||
//! )
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Functions:
|
||||
//!
|
||||
//! Using functions from [`Stylize`](crate::style::Stylize) on a `String` or `&'static str` to color
|
||||
//! it.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use crossterm::style::Stylize;
|
||||
//!
|
||||
//! println!("{}", "Red foreground color & blue background.".red().on_blue());
|
||||
//! ```
|
||||
//!
|
||||
//! ### Attributes
|
||||
//!
|
||||
//! How to apply terminal attributes to text.
|
||||
//!
|
||||
//! Command API:
|
||||
//!
|
||||
//! Using the Command API to set attributes.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//!
|
||||
//! use crossterm::execute;
|
||||
//! use crossterm::style::{Attribute, Print, SetAttribute};
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! execute!(
|
||||
//! io::stdout(),
|
||||
//! // Set to bold
|
||||
//! SetAttribute(Attribute::Bold),
|
||||
//! Print("Bold text here.".to_string()),
|
||||
//! // Reset all attributes
|
||||
//! SetAttribute(Attribute::Reset)
|
||||
//! )
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Functions:
|
||||
//!
|
||||
//! Using [`Stylize`](crate::style::Stylize) functions on a `String` or `&'static str` to set
|
||||
//! attributes to it.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use crossterm::style::Stylize;
|
||||
//!
|
||||
//! println!("{}", "Bold".bold());
|
||||
//! println!("{}", "Underlined".underlined());
|
||||
//! println!("{}", "Negative".negative());
|
||||
//! ```
|
||||
//!
|
||||
//! Displayable:
|
||||
//!
|
||||
//! [`Attribute`](enum.Attribute.html) implements [Display](https://doc.rust-lang.org/beta/std/fmt/trait.Display.html) and therefore it can be formatted like:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use crossterm::style::Attribute;
|
||||
//!
|
||||
//! println!(
|
||||
//! "{} Underlined {} No Underline",
|
||||
//! Attribute::Underlined,
|
||||
//! Attribute::NoUnderline
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
use std::{
|
||||
env,
|
||||
fmt::{self, Display},
|
||||
};
|
||||
|
||||
use crate::command::execute_fmt;
|
||||
use crate::{csi, impl_display, Command};
|
||||
|
||||
pub use self::{
|
||||
attributes::Attributes,
|
||||
content_style::ContentStyle,
|
||||
styled_content::StyledContent,
|
||||
stylize::Stylize,
|
||||
types::{Attribute, Color, Colored, Colors},
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod content_style;
|
||||
mod styled_content;
|
||||
mod stylize;
|
||||
mod sys;
|
||||
mod types;
|
||||
|
||||
/// Creates a `StyledContent`.
|
||||
///
|
||||
/// This could be used to style any type that implements `Display` with colors and text attributes.
|
||||
///
|
||||
/// See [`StyledContent`](struct.StyledContent.html) for more info.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::style::{style, Stylize, Color};
|
||||
///
|
||||
/// let styled_content = style("Blue colored text on yellow background")
|
||||
/// .with(Color::Blue)
|
||||
/// .on(Color::Yellow);
|
||||
///
|
||||
/// println!("{}", styled_content);
|
||||
/// ```
|
||||
pub fn style<D: Display>(val: D) -> StyledContent<D> {
|
||||
ContentStyle::new().apply(val)
|
||||
}
|
||||
|
||||
/// Returns available color count.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// This does not always provide a good result.
|
||||
pub fn available_color_count() -> u16 {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Check if we're running in a pseudo TTY, which supports true color.
|
||||
// Fall back to env vars otherwise for other terminals on Windows.
|
||||
if crate::ansi_support::supports_ansi() {
|
||||
return u16::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT: u16 = 8;
|
||||
env::var("COLORTERM")
|
||||
.or_else(|_| env::var("TERM"))
|
||||
.map_or(DEFAULT, |x| match x {
|
||||
_ if x.contains("24bit") || x.contains("truecolor") => u16::MAX,
|
||||
_ if x.contains("256") => 256,
|
||||
_ => DEFAULT,
|
||||
})
|
||||
}
|
||||
|
||||
/// Forces colored output on or off globally, overriding NO_COLOR.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// crossterm supports NO_COLOR (<https://no-color.org/>) to disabled colored output.
|
||||
///
|
||||
/// This API allows applications to override that behavior and force colorized output
|
||||
/// even if NO_COLOR is set.
|
||||
pub fn force_color_output(enabled: bool) {
|
||||
Colored::set_ansi_color_disabled(!enabled)
|
||||
}
|
||||
|
||||
/// A command that sets the the foreground color.
|
||||
///
|
||||
/// See [`Color`](enum.Color.html) for more info.
|
||||
///
|
||||
/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background
|
||||
/// color in one command.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetForegroundColor(pub Color);
|
||||
|
||||
impl Command for SetForegroundColor {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}m"), Colored::ForegroundColor(self.0))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::windows::set_foreground_color(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets the the background color.
|
||||
///
|
||||
/// See [`Color`](enum.Color.html) for more info.
|
||||
///
|
||||
/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background
|
||||
/// color with one command.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetBackgroundColor(pub Color);
|
||||
|
||||
impl Command for SetBackgroundColor {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}m"), Colored::BackgroundColor(self.0))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::windows::set_background_color(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets the the underline color.
|
||||
///
|
||||
/// See [`Color`](enum.Color.html) for more info.
|
||||
///
|
||||
/// [`SetColors`](struct.SetColors.html) can also be used to set both the foreground and background
|
||||
/// color with one command.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetUnderlineColor(pub Color);
|
||||
|
||||
impl Command for SetUnderlineColor {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}m"), Colored::UnderlineColor(self.0))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"SetUnderlineColor not supported by winapi.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that optionally sets the foreground and/or background color.
|
||||
///
|
||||
/// For example:
|
||||
/// ```no_run
|
||||
/// use std::io::{stdout, Write};
|
||||
///
|
||||
/// use crossterm::execute;
|
||||
/// use crossterm::style::{Color::{Green, Black}, Colors, Print, SetColors};
|
||||
///
|
||||
/// execute!(
|
||||
/// stdout(),
|
||||
/// SetColors(Colors::new(Green, Black)),
|
||||
/// Print("Hello, world!".to_string()),
|
||||
/// ).unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// See [`Colors`](struct.Colors.html) for more info.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetColors(pub Colors);
|
||||
|
||||
impl Command for SetColors {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
// Writing both foreground and background colors in one command resulted in about 20% more
|
||||
// FPS (20 to 24 fps) on a fullscreen (171x51) app that writes every cell with a different
|
||||
// foreground and background color, compared to separately using the SetForegroundColor and
|
||||
// SetBackgroundColor commands (iTerm2, M2 Macbook Pro). `Esc[38;5;<fg>mEsc[48;5;<bg>m` (16
|
||||
// chars) vs `Esc[38;5;<fg>;48;5;<bg>m` (14 chars)
|
||||
match (self.0.foreground, self.0.background) {
|
||||
(Some(fg), Some(bg)) => {
|
||||
write!(
|
||||
f,
|
||||
csi!("{};{}m"),
|
||||
Colored::ForegroundColor(fg),
|
||||
Colored::BackgroundColor(bg)
|
||||
)
|
||||
}
|
||||
(Some(fg), None) => write!(f, csi!("{}m"), Colored::ForegroundColor(fg)),
|
||||
(None, Some(bg)) => write!(f, csi!("{}m"), Colored::BackgroundColor(bg)),
|
||||
(None, None) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
if let Some(color) = self.0.foreground {
|
||||
sys::windows::set_foreground_color(color)?;
|
||||
}
|
||||
if let Some(color) = self.0.background {
|
||||
sys::windows::set_background_color(color)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets an attribute.
|
||||
///
|
||||
/// See [`Attribute`](enum.Attribute.html) for more info.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetAttribute(pub Attribute);
|
||||
|
||||
impl Command for SetAttribute {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("{}m"), self.0.sgr())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
// attributes are not supported by WinAPI.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets several attributes.
|
||||
///
|
||||
/// See [`Attributes`](struct.Attributes.html) for more info.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetAttributes(pub Attributes);
|
||||
|
||||
impl Command for SetAttributes {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
for attr in Attribute::iterator() {
|
||||
if self.0.has(attr) {
|
||||
SetAttribute(attr).write_ansi(f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
// attributes are not supported by WinAPI.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets a style (colors and attributes).
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetStyle(pub ContentStyle);
|
||||
|
||||
impl Command for SetStyle {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
if let Some(bg) = self.0.background_color {
|
||||
execute_fmt(f, SetBackgroundColor(bg)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
if let Some(fg) = self.0.foreground_color {
|
||||
execute_fmt(f, SetForegroundColor(fg)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
if let Some(ul) = self.0.underline_color {
|
||||
execute_fmt(f, SetUnderlineColor(ul)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
if !self.0.attributes.is_empty() {
|
||||
execute_fmt(f, SetAttributes(self.0.attributes)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
panic!("tried to execute SetStyle command using WinAPI, use ANSI instead");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that prints styled content.
|
||||
///
|
||||
/// See [`StyledContent`](struct.StyledContent.html) for more info.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct PrintStyledContent<D: Display>(pub StyledContent<D>);
|
||||
|
||||
impl<D: Display> Command for PrintStyledContent<D> {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
let style = self.0.style();
|
||||
|
||||
let mut reset_background = false;
|
||||
let mut reset_foreground = false;
|
||||
let mut reset = false;
|
||||
|
||||
if let Some(bg) = style.background_color {
|
||||
execute_fmt(f, SetBackgroundColor(bg)).map_err(|_| fmt::Error)?;
|
||||
reset_background = true;
|
||||
}
|
||||
if let Some(fg) = style.foreground_color {
|
||||
execute_fmt(f, SetForegroundColor(fg)).map_err(|_| fmt::Error)?;
|
||||
reset_foreground = true;
|
||||
}
|
||||
if let Some(ul) = style.underline_color {
|
||||
execute_fmt(f, SetUnderlineColor(ul)).map_err(|_| fmt::Error)?;
|
||||
reset_foreground = true;
|
||||
}
|
||||
|
||||
if !style.attributes.is_empty() {
|
||||
execute_fmt(f, SetAttributes(style.attributes)).map_err(|_| fmt::Error)?;
|
||||
reset = true;
|
||||
}
|
||||
|
||||
write!(f, "{}", self.0.content())?;
|
||||
|
||||
if reset {
|
||||
// NOTE: This will reset colors even though self has no colors, hence produce unexpected
|
||||
// resets.
|
||||
// TODO: reset the set attributes only.
|
||||
execute_fmt(f, ResetColor).map_err(|_| fmt::Error)?;
|
||||
} else {
|
||||
// NOTE: Since the above bug, we do not need to reset colors when we reset attributes.
|
||||
if reset_background {
|
||||
execute_fmt(f, SetBackgroundColor(Color::Reset)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
if reset_foreground {
|
||||
execute_fmt(f, SetForegroundColor(Color::Reset)).map_err(|_| fmt::Error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that resets the colors back to default.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResetColor;
|
||||
|
||||
impl Command for ResetColor {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("0m"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
sys::windows::reset()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that prints the given displayable type.
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Print<T: Display>(pub T);
|
||||
|
||||
impl<T: Display> Command for Print<T> {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
panic!("tried to execute Print command using WinAPI, use ANSI instead");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Display> Display for Print<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl_display!(for SetForegroundColor);
|
||||
impl_display!(for SetBackgroundColor);
|
||||
impl_display!(for SetColors);
|
||||
impl_display!(for SetAttribute);
|
||||
impl_display!(for PrintStyledContent<String>);
|
||||
impl_display!(for PrintStyledContent<&'static str>);
|
||||
impl_display!(for ResetColor);
|
||||
|
||||
/// Utility function for ANSI parsing in Color and Colored.
|
||||
/// Gets the next element of `iter` and tries to parse it as a `u8`.
|
||||
fn parse_next_u8<'a>(iter: &mut impl Iterator<Item = &'a str>) -> Option<u8> {
|
||||
iter.next().and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// On Windows many env var tests will fail so we need to conditionally check for ANSI support.
|
||||
// This allows other terminals on Windows to still assert env var support.
|
||||
macro_rules! skip_windows_ansi_supported {
|
||||
() => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if crate::ansi_support::supports_ansi() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, test)]
|
||||
#[cfg(windows)]
|
||||
fn windows_always_truecolor() {
|
||||
// This should always be true on supported Windows 10+,
|
||||
// but downlevel Windows clients and other terminals may fail `cargo test` otherwise.
|
||||
if crate::ansi_support::supports_ansi() {
|
||||
assert_eq!(u16::MAX, available_color_count());
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorterm_overrides_term() {
|
||||
skip_windows_ansi_supported!();
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("COLORTERM", Some("truecolor")),
|
||||
("TERM", Some("xterm-256color")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(u16::MAX, available_color_count());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_24bits() {
|
||||
skip_windows_ansi_supported!();
|
||||
temp_env::with_vars(
|
||||
[("COLORTERM", None), ("TERM", Some("xterm-24bits"))],
|
||||
|| {
|
||||
assert_eq!(u16::MAX, available_color_count());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_256color() {
|
||||
skip_windows_ansi_supported!();
|
||||
temp_env::with_vars(
|
||||
[("COLORTERM", None), ("TERM", Some("xterm-256color"))],
|
||||
|| {
|
||||
assert_eq!(256u16, available_color_count());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_color_count() {
|
||||
skip_windows_ansi_supported!();
|
||||
temp_env::with_vars([("COLORTERM", None::<&str>), ("TERM", None)], || {
|
||||
assert_eq!(8, available_color_count());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_term_colorterm_values() {
|
||||
skip_windows_ansi_supported!();
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("COLORTERM", Some("gibberish")),
|
||||
("TERM", Some("gibberish")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(8u16, available_color_count());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
use std::ops::{BitAnd, BitOr, BitXor};
|
||||
|
||||
use crate::style::Attribute;
|
||||
|
||||
/// a bitset for all possible attributes
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Attributes(u32);
|
||||
|
||||
impl From<Attribute> for Attributes {
|
||||
fn from(attribute: Attribute) -> Self {
|
||||
Self(attribute.bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[Attribute]> for Attributes {
|
||||
fn from(arr: &[Attribute]) -> Self {
|
||||
let mut attributes = Attributes::default();
|
||||
for &attr in arr {
|
||||
attributes.set(attr);
|
||||
}
|
||||
attributes
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAnd<Attribute> for Attributes {
|
||||
type Output = Self;
|
||||
fn bitand(self, rhs: Attribute) -> Self {
|
||||
Self(self.0 & rhs.bytes())
|
||||
}
|
||||
}
|
||||
impl BitAnd for Attributes {
|
||||
type Output = Self;
|
||||
fn bitand(self, rhs: Self) -> Self {
|
||||
Self(self.0 & rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr<Attribute> for Attributes {
|
||||
type Output = Self;
|
||||
fn bitor(self, rhs: Attribute) -> Self {
|
||||
Self(self.0 | rhs.bytes())
|
||||
}
|
||||
}
|
||||
impl BitOr for Attributes {
|
||||
type Output = Self;
|
||||
fn bitor(self, rhs: Self) -> Self {
|
||||
Self(self.0 | rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitXor<Attribute> for Attributes {
|
||||
type Output = Self;
|
||||
fn bitxor(self, rhs: Attribute) -> Self {
|
||||
Self(self.0 ^ rhs.bytes())
|
||||
}
|
||||
}
|
||||
impl BitXor for Attributes {
|
||||
type Output = Self;
|
||||
fn bitxor(self, rhs: Self) -> Self {
|
||||
Self(self.0 ^ rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Attributes {
|
||||
/// Returns the empty bitset.
|
||||
#[inline(always)]
|
||||
pub const fn none() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
/// Returns a copy of the bitset with the given attribute set.
|
||||
/// If it's already set, this returns the bitset unmodified.
|
||||
#[inline(always)]
|
||||
pub const fn with(self, attribute: Attribute) -> Self {
|
||||
Self(self.0 | attribute.bytes())
|
||||
}
|
||||
|
||||
/// Returns a copy of the bitset with the given attribute unset.
|
||||
/// If it's not set, this returns the bitset unmodified.
|
||||
#[inline(always)]
|
||||
pub const fn without(self, attribute: Attribute) -> Self {
|
||||
Self(self.0 & !attribute.bytes())
|
||||
}
|
||||
|
||||
/// Sets the attribute.
|
||||
/// If it's already set, this does nothing.
|
||||
#[inline(always)]
|
||||
pub fn set(&mut self, attribute: Attribute) {
|
||||
self.0 |= attribute.bytes();
|
||||
}
|
||||
|
||||
/// Unsets the attribute.
|
||||
/// If it's not set, this changes nothing.
|
||||
#[inline(always)]
|
||||
pub fn unset(&mut self, attribute: Attribute) {
|
||||
self.0 &= !attribute.bytes();
|
||||
}
|
||||
|
||||
/// Sets the attribute if it's unset, unset it
|
||||
/// if it is set.
|
||||
#[inline(always)]
|
||||
pub fn toggle(&mut self, attribute: Attribute) {
|
||||
self.0 ^= attribute.bytes();
|
||||
}
|
||||
|
||||
/// Returns whether the attribute is set.
|
||||
#[inline(always)]
|
||||
pub const fn has(self, attribute: Attribute) -> bool {
|
||||
self.0 & attribute.bytes() != 0
|
||||
}
|
||||
|
||||
/// Sets all the passed attributes. Removes none.
|
||||
#[inline(always)]
|
||||
pub fn extend(&mut self, attributes: Attributes) {
|
||||
self.0 |= attributes.0;
|
||||
}
|
||||
|
||||
/// Returns whether there is no attribute set.
|
||||
#[inline(always)]
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Attribute, Attributes};
|
||||
|
||||
#[test]
|
||||
fn test_attributes() {
|
||||
let mut attributes: Attributes = Attribute::Bold.into();
|
||||
assert!(attributes.has(Attribute::Bold));
|
||||
attributes.set(Attribute::Italic);
|
||||
assert!(attributes.has(Attribute::Italic));
|
||||
attributes.unset(Attribute::Italic);
|
||||
assert!(!attributes.has(Attribute::Italic));
|
||||
attributes.toggle(Attribute::Bold);
|
||||
assert!(attributes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attributes_const() {
|
||||
const ATTRIBUTES: Attributes = Attributes::none()
|
||||
.with(Attribute::Bold)
|
||||
.with(Attribute::Italic)
|
||||
.without(Attribute::Bold);
|
||||
assert!(!ATTRIBUTES.has(Attribute::Bold));
|
||||
assert!(ATTRIBUTES.has(Attribute::Italic));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
//! This module contains the `content style` that can be applied to an `styled content`.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::style::{Attributes, Color, StyledContent};
|
||||
|
||||
/// The style that can be put on content.
|
||||
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ContentStyle {
|
||||
/// The foreground color.
|
||||
pub foreground_color: Option<Color>,
|
||||
/// The background color.
|
||||
pub background_color: Option<Color>,
|
||||
/// The underline color.
|
||||
pub underline_color: Option<Color>,
|
||||
/// List of attributes.
|
||||
pub attributes: Attributes,
|
||||
}
|
||||
|
||||
impl ContentStyle {
|
||||
/// Creates a `StyledContent` by applying the style to the given `val`.
|
||||
#[inline]
|
||||
pub fn apply<D: Display>(self, val: D) -> StyledContent<D> {
|
||||
StyledContent::new(self, val)
|
||||
}
|
||||
|
||||
/// Creates a new `ContentStyle`.
|
||||
#[inline]
|
||||
pub fn new() -> ContentStyle {
|
||||
ContentStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<ContentStyle> for ContentStyle {
|
||||
fn as_ref(&self) -> &Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
impl AsMut<ContentStyle> for ContentStyle {
|
||||
fn as_mut(&mut self) -> &mut Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
//! This module contains the logic to style some content.
|
||||
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use super::{ContentStyle, PrintStyledContent};
|
||||
|
||||
/// The style with the content to be styled.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use crossterm::style::{style, Color, Attribute, Stylize};
|
||||
///
|
||||
/// let styled = "Hello there"
|
||||
/// .with(Color::Yellow)
|
||||
/// .on(Color::Blue)
|
||||
/// .attribute(Attribute::Bold);
|
||||
///
|
||||
/// println!("{}", styled);
|
||||
/// ```
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StyledContent<D: Display> {
|
||||
/// The style (colors, content attributes).
|
||||
style: ContentStyle,
|
||||
/// A content to apply the style on.
|
||||
content: D,
|
||||
}
|
||||
|
||||
impl<D: Display> StyledContent<D> {
|
||||
/// Creates a new `StyledContent`.
|
||||
#[inline]
|
||||
pub fn new(style: ContentStyle, content: D) -> StyledContent<D> {
|
||||
StyledContent { style, content }
|
||||
}
|
||||
|
||||
/// Returns the content.
|
||||
#[inline]
|
||||
pub fn content(&self) -> &D {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Returns the style.
|
||||
#[inline]
|
||||
pub fn style(&self) -> &ContentStyle {
|
||||
&self.style
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the style, so that it can be further
|
||||
/// manipulated
|
||||
#[inline]
|
||||
pub fn style_mut(&mut self) -> &mut ContentStyle {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: Display> AsRef<ContentStyle> for StyledContent<D> {
|
||||
fn as_ref(&self) -> &ContentStyle {
|
||||
&self.style
|
||||
}
|
||||
}
|
||||
impl<D: Display> AsMut<ContentStyle> for StyledContent<D> {
|
||||
fn as_mut(&mut self) -> &mut ContentStyle {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: Display> Display for StyledContent<D> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
crate::command::execute_fmt(
|
||||
f,
|
||||
PrintStyledContent(StyledContent {
|
||||
style: self.style,
|
||||
content: &self.content,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use super::{style, Attribute, Color, ContentStyle, StyledContent};
|
||||
|
||||
macro_rules! stylize_method {
|
||||
($method_name:ident Attribute::$attribute:ident) => {
|
||||
calculated_docs! {
|
||||
#[doc = concat!(
|
||||
"Applies the [`",
|
||||
stringify!($attribute),
|
||||
"`](Attribute::",
|
||||
stringify!($attribute),
|
||||
") attribute to the text.",
|
||||
)]
|
||||
fn $method_name(self) -> Self::Styled {
|
||||
self.attribute(Attribute::$attribute)
|
||||
}
|
||||
}
|
||||
};
|
||||
($method_name_fg:ident, $method_name_bg:ident, $method_name_ul:ident Color::$color:ident) => {
|
||||
calculated_docs! {
|
||||
#[doc = concat!(
|
||||
"Sets the foreground color to [`",
|
||||
stringify!($color),
|
||||
"`](Color::",
|
||||
stringify!($color),
|
||||
")."
|
||||
)]
|
||||
fn $method_name_fg(self) -> Self::Styled {
|
||||
self.with(Color::$color)
|
||||
}
|
||||
|
||||
#[doc = concat!(
|
||||
"Sets the background color to [`",
|
||||
stringify!($color),
|
||||
"`](Color::",
|
||||
stringify!($color),
|
||||
")."
|
||||
)]
|
||||
fn $method_name_bg(self) -> Self::Styled {
|
||||
self.on(Color::$color)
|
||||
}
|
||||
|
||||
#[doc = concat!(
|
||||
"Sets the underline color to [`",
|
||||
stringify!($color),
|
||||
"`](Color::",
|
||||
stringify!($color),
|
||||
")."
|
||||
)]
|
||||
fn $method_name_ul(self) -> Self::Styled {
|
||||
self.underline(Color::$color)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Provides a set of methods to set attributes and colors.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::style::Stylize;
|
||||
///
|
||||
/// println!("{}", "Bold text".bold());
|
||||
/// println!("{}", "Underlined text".underlined());
|
||||
/// println!("{}", "Negative text".negative());
|
||||
/// println!("{}", "Red on blue".red().on_blue());
|
||||
/// ```
|
||||
pub trait Stylize: Sized {
|
||||
/// This type with styles applied.
|
||||
type Styled: AsRef<ContentStyle> + AsMut<ContentStyle>;
|
||||
|
||||
/// Styles this type.
|
||||
fn stylize(self) -> Self::Styled;
|
||||
|
||||
/// Sets the foreground color.
|
||||
fn with(self, color: Color) -> Self::Styled {
|
||||
let mut styled = self.stylize();
|
||||
styled.as_mut().foreground_color = Some(color);
|
||||
styled
|
||||
}
|
||||
|
||||
/// Sets the background color.
|
||||
fn on(self, color: Color) -> Self::Styled {
|
||||
let mut styled = self.stylize();
|
||||
styled.as_mut().background_color = Some(color);
|
||||
styled
|
||||
}
|
||||
|
||||
/// Sets the underline color.
|
||||
fn underline(self, color: Color) -> Self::Styled {
|
||||
let mut styled = self.stylize();
|
||||
styled.as_mut().underline_color = Some(color);
|
||||
styled
|
||||
}
|
||||
|
||||
/// Styles the content with the attribute.
|
||||
fn attribute(self, attr: Attribute) -> Self::Styled {
|
||||
let mut styled = self.stylize();
|
||||
styled.as_mut().attributes.set(attr);
|
||||
styled
|
||||
}
|
||||
|
||||
stylize_method!(reset Attribute::Reset);
|
||||
stylize_method!(bold Attribute::Bold);
|
||||
stylize_method!(underlined Attribute::Underlined);
|
||||
stylize_method!(reverse Attribute::Reverse);
|
||||
stylize_method!(dim Attribute::Dim);
|
||||
stylize_method!(italic Attribute::Italic);
|
||||
stylize_method!(negative Attribute::Reverse);
|
||||
stylize_method!(slow_blink Attribute::SlowBlink);
|
||||
stylize_method!(rapid_blink Attribute::RapidBlink);
|
||||
stylize_method!(hidden Attribute::Hidden);
|
||||
stylize_method!(crossed_out Attribute::CrossedOut);
|
||||
|
||||
stylize_method!(black, on_black, underline_black Color::Black);
|
||||
stylize_method!(dark_grey, on_dark_grey, underline_dark_grey Color::DarkGrey);
|
||||
stylize_method!(red, on_red, underline_red Color::Red);
|
||||
stylize_method!(dark_red, on_dark_red, underline_dark_red Color::DarkRed);
|
||||
stylize_method!(green, on_green, underline_green Color::Green);
|
||||
stylize_method!(dark_green, on_dark_green, underline_dark_green Color::DarkGreen);
|
||||
stylize_method!(yellow, on_yellow, underline_yellow Color::Yellow);
|
||||
stylize_method!(dark_yellow, on_dark_yellow, underline_dark_yellow Color::DarkYellow);
|
||||
stylize_method!(blue, on_blue, underline_blue Color::Blue);
|
||||
stylize_method!(dark_blue, on_dark_blue, underline_dark_blue Color::DarkBlue);
|
||||
stylize_method!(magenta, on_magenta, underline_magenta Color::Magenta);
|
||||
stylize_method!(dark_magenta, on_dark_magenta, underline_dark_magenta Color::DarkMagenta);
|
||||
stylize_method!(cyan, on_cyan, underline_cyan Color::Cyan);
|
||||
stylize_method!(dark_cyan, on_dark_cyan, underline_dark_cyan Color::DarkCyan);
|
||||
stylize_method!(white, on_white, underline_white Color::White);
|
||||
stylize_method!(grey, on_grey, underline_grey Color::Grey);
|
||||
}
|
||||
|
||||
macro_rules! impl_stylize_for_display {
|
||||
($($t:ty),*) => { $(
|
||||
impl Stylize for $t {
|
||||
type Styled = StyledContent<Self>;
|
||||
#[inline]
|
||||
fn stylize(self) -> Self::Styled {
|
||||
style(self)
|
||||
}
|
||||
}
|
||||
)* }
|
||||
}
|
||||
impl_stylize_for_display!(String, char, &str);
|
||||
|
||||
impl Stylize for ContentStyle {
|
||||
type Styled = Self;
|
||||
#[inline]
|
||||
fn stylize(self) -> Self::Styled {
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<D: Display> Stylize for StyledContent<D> {
|
||||
type Styled = StyledContent<D>;
|
||||
fn stylize(self) -> Self::Styled {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/rust-lang/rust/issues/78835
|
||||
macro_rules! calculated_docs {
|
||||
($(#[doc = $doc:expr] $item:item)*) => { $(#[doc = $doc] $item)* };
|
||||
}
|
||||
// Remove once https://github.com/rust-lang/rust-clippy/issues/7106 stabilizes.
|
||||
#[allow(clippy::single_component_path_imports)]
|
||||
#[allow(clippy::useless_attribute)]
|
||||
use calculated_docs;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Attribute, Color, ContentStyle, Stylize};
|
||||
|
||||
#[test]
|
||||
fn set_fg_bg_add_attr() {
|
||||
let style = ContentStyle::new()
|
||||
.with(Color::Blue)
|
||||
.on(Color::Red)
|
||||
.attribute(Attribute::Bold);
|
||||
|
||||
assert_eq!(style.foreground_color, Some(Color::Blue));
|
||||
assert_eq!(style.background_color, Some(Color::Red));
|
||||
assert!(style.attributes.has(Attribute::Bold));
|
||||
|
||||
let mut styled_content = style.apply("test");
|
||||
|
||||
styled_content = styled_content
|
||||
.with(Color::Green)
|
||||
.on(Color::Magenta)
|
||||
.attribute(Attribute::NoItalic);
|
||||
|
||||
let style = styled_content.style();
|
||||
|
||||
assert_eq!(style.foreground_color, Some(Color::Green));
|
||||
assert_eq!(style.background_color, Some(Color::Magenta));
|
||||
assert!(style.attributes.has(Attribute::Bold));
|
||||
assert!(style.attributes.has(Attribute::NoItalic));
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
use std::convert::TryFrom;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crossterm_winapi::{Console, Handle, HandleType, ScreenBuffer};
|
||||
use winapi::um::wincon;
|
||||
|
||||
use super::super::{Color, Colored};
|
||||
|
||||
const FG_GREEN: u16 = wincon::FOREGROUND_GREEN;
|
||||
const FG_RED: u16 = wincon::FOREGROUND_RED;
|
||||
const FG_BLUE: u16 = wincon::FOREGROUND_BLUE;
|
||||
const FG_INTENSITY: u16 = wincon::FOREGROUND_INTENSITY;
|
||||
|
||||
const BG_GREEN: u16 = wincon::BACKGROUND_GREEN;
|
||||
const BG_RED: u16 = wincon::BACKGROUND_RED;
|
||||
const BG_BLUE: u16 = wincon::BACKGROUND_BLUE;
|
||||
const BG_INTENSITY: u16 = wincon::BACKGROUND_INTENSITY;
|
||||
|
||||
pub(crate) fn set_foreground_color(fg_color: Color) -> std::io::Result<()> {
|
||||
init_console_color()?;
|
||||
|
||||
let color_value: u16 = Colored::ForegroundColor(fg_color).into();
|
||||
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let csbi = screen_buffer.info()?;
|
||||
|
||||
// Notice that the color values are stored in wAttribute.
|
||||
// So we need to use bitwise operators to check if the values exists or to get current console colors.
|
||||
let attrs = csbi.attributes();
|
||||
let bg_color = attrs & 0x0070;
|
||||
let mut color = color_value | bg_color;
|
||||
|
||||
// background intensity is a separate value in attrs,
|
||||
// we need to check if this was applied to the current bg color.
|
||||
if (attrs & wincon::BACKGROUND_INTENSITY) != 0 {
|
||||
color |= wincon::BACKGROUND_INTENSITY;
|
||||
}
|
||||
|
||||
Console::from(screen_buffer.handle().clone()).set_text_attribute(color)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_background_color(bg_color: Color) -> std::io::Result<()> {
|
||||
init_console_color()?;
|
||||
|
||||
let color_value: u16 = Colored::BackgroundColor(bg_color).into();
|
||||
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let csbi = screen_buffer.info()?;
|
||||
|
||||
// Notice that the color values are stored in wAttribute.
|
||||
// So we need to use bitwise operators to check if the values exists or to get current console colors.
|
||||
let attrs = csbi.attributes();
|
||||
let fg_color = attrs & 0x0007;
|
||||
let mut color = fg_color | color_value;
|
||||
|
||||
// Foreground intensity is a separate value in attrs,
|
||||
// So we need to check if this was applied to the current fg color.
|
||||
if (attrs & wincon::FOREGROUND_INTENSITY) != 0 {
|
||||
color |= wincon::FOREGROUND_INTENSITY;
|
||||
}
|
||||
|
||||
Console::from(screen_buffer.handle().clone()).set_text_attribute(color)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reset() -> std::io::Result<()> {
|
||||
if let Ok(original_color) = u16::try_from(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed)) {
|
||||
Console::from(Handle::new(HandleType::CurrentOutputHandle)?)
|
||||
.set_text_attribute(original_color)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initializes the default console color. It will will be skipped if it has already been initialized.
|
||||
pub(crate) fn init_console_color() -> std::io::Result<()> {
|
||||
if ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed) == u32::MAX {
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let attr = screen_buffer.info()?.attributes();
|
||||
ORIGINAL_CONSOLE_COLOR.store(u32::from(attr), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the original console color, make sure to call `init_console_color` before calling this function. Otherwise this function will panic.
|
||||
pub(crate) fn original_console_color() -> u16 {
|
||||
u16::try_from(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed))
|
||||
// safe unwrap, initial console color was set with `init_console_color` in `WinApiColor::new()`
|
||||
.expect("Initial console color not set")
|
||||
}
|
||||
|
||||
// This is either a valid u16 in which case it stores the original console color or it is u32::MAX
|
||||
// in which case it is uninitialized.
|
||||
static ORIGINAL_CONSOLE_COLOR: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
|
||||
impl From<Colored> for u16 {
|
||||
/// Returns the WinAPI color value (u16) from the `Colored` struct.
|
||||
fn from(colored: Colored) -> Self {
|
||||
match colored {
|
||||
Colored::ForegroundColor(color) => {
|
||||
match color {
|
||||
Color::Black => 0,
|
||||
Color::DarkGrey => FG_INTENSITY,
|
||||
Color::Red => FG_INTENSITY | FG_RED,
|
||||
Color::DarkRed => FG_RED,
|
||||
Color::Green => FG_INTENSITY | FG_GREEN,
|
||||
Color::DarkGreen => FG_GREEN,
|
||||
Color::Yellow => FG_INTENSITY | FG_GREEN | FG_RED,
|
||||
Color::DarkYellow => FG_GREEN | FG_RED,
|
||||
Color::Blue => FG_INTENSITY | FG_BLUE,
|
||||
Color::DarkBlue => FG_BLUE,
|
||||
Color::Magenta => FG_INTENSITY | FG_RED | FG_BLUE,
|
||||
Color::DarkMagenta => FG_RED | FG_BLUE,
|
||||
Color::Cyan => FG_INTENSITY | FG_GREEN | FG_BLUE,
|
||||
Color::DarkCyan => FG_GREEN | FG_BLUE,
|
||||
Color::White => FG_INTENSITY | FG_RED | FG_GREEN | FG_BLUE,
|
||||
Color::Grey => FG_RED | FG_GREEN | FG_BLUE,
|
||||
|
||||
Color::Reset => {
|
||||
// safe unwrap, initial console color was set with `init_console_color`.
|
||||
let original_color = original_console_color();
|
||||
|
||||
const REMOVE_BG_MASK: u16 = BG_INTENSITY | BG_RED | BG_GREEN | BG_BLUE;
|
||||
// remove all background values from the original color, we don't want to reset those.
|
||||
|
||||
original_color & !REMOVE_BG_MASK
|
||||
}
|
||||
|
||||
/* WinAPI will be used for systems that do not support ANSI, those are windows version less then 10. RGB and 255 (AnsiBValue) colors are not supported in that case.*/
|
||||
Color::Rgb { .. } => 0,
|
||||
Color::AnsiValue(_val) => 0,
|
||||
}
|
||||
}
|
||||
Colored::BackgroundColor(color) => {
|
||||
match color {
|
||||
Color::Black => 0,
|
||||
Color::DarkGrey => BG_INTENSITY,
|
||||
Color::Red => BG_INTENSITY | BG_RED,
|
||||
Color::DarkRed => BG_RED,
|
||||
Color::Green => BG_INTENSITY | BG_GREEN,
|
||||
Color::DarkGreen => BG_GREEN,
|
||||
Color::Yellow => BG_INTENSITY | BG_GREEN | BG_RED,
|
||||
Color::DarkYellow => BG_GREEN | BG_RED,
|
||||
Color::Blue => BG_INTENSITY | BG_BLUE,
|
||||
Color::DarkBlue => BG_BLUE,
|
||||
Color::Magenta => BG_INTENSITY | BG_RED | BG_BLUE,
|
||||
Color::DarkMagenta => BG_RED | BG_BLUE,
|
||||
Color::Cyan => BG_INTENSITY | BG_GREEN | BG_BLUE,
|
||||
Color::DarkCyan => BG_GREEN | BG_BLUE,
|
||||
Color::White => BG_INTENSITY | BG_RED | BG_GREEN | BG_BLUE,
|
||||
Color::Grey => BG_RED | BG_GREEN | BG_BLUE,
|
||||
|
||||
Color::Reset => {
|
||||
let original_color = original_console_color();
|
||||
|
||||
const REMOVE_FG_MASK: u16 = FG_INTENSITY | FG_RED | FG_GREEN | FG_BLUE;
|
||||
// remove all foreground values from the original color, we don't want to reset those.
|
||||
|
||||
original_color & !REMOVE_FG_MASK
|
||||
}
|
||||
/* WinAPI will be used for systems that do not support ANSI, those are windows version less then 10. RGB and 255 (AnsiBValue) colors are not supported in that case.*/
|
||||
Color::Rgb { .. } => 0,
|
||||
Color::AnsiValue(_val) => 0,
|
||||
}
|
||||
}
|
||||
Colored::UnderlineColor(_) => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::style::sys::windows::set_foreground_color;
|
||||
|
||||
use super::{
|
||||
Color, Colored, BG_INTENSITY, BG_RED, FG_INTENSITY, FG_RED, ORIGINAL_CONSOLE_COLOR,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_parse_fg_color() {
|
||||
let colored = Colored::ForegroundColor(Color::Red);
|
||||
assert_eq!(Into::<u16>::into(colored), FG_INTENSITY | FG_RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bg_color() {
|
||||
let colored = Colored::BackgroundColor(Color::Red);
|
||||
assert_eq!(Into::<u16>::into(colored), BG_INTENSITY | BG_RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_original_console_color_is_set() {
|
||||
assert_eq!(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed), u32::MAX);
|
||||
|
||||
// will call `init_console_color`
|
||||
set_foreground_color(Color::Blue).unwrap();
|
||||
|
||||
assert_ne!(ORIGINAL_CONSOLE_COLOR.load(Ordering::Relaxed), u32::MAX);
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
pub use self::{attribute::Attribute, color::Color, colored::Colored, colors::Colors};
|
||||
|
||||
mod attribute;
|
||||
mod color;
|
||||
mod colored;
|
||||
mod colors;
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::SetAttribute;
|
||||
|
||||
// This macro generates the Attribute enum, its iterator
|
||||
// function, and the static array containing the sgr code
|
||||
// of each attribute
|
||||
macro_rules! Attribute {
|
||||
(
|
||||
$(
|
||||
$(#[$inner:ident $($args:tt)*])*
|
||||
$name:ident = $sgr:expr,
|
||||
)*
|
||||
) => {
|
||||
/// Represents an attribute.
|
||||
///
|
||||
/// # Platform-specific Notes
|
||||
///
|
||||
/// * Only UNIX and Windows 10 terminals do support text attributes.
|
||||
/// * Keep in mind that not all terminals support all attributes.
|
||||
/// * Crossterm implements almost all attributes listed in the
|
||||
/// [SGR parameters](https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters).
|
||||
///
|
||||
/// | Attribute | Windows | UNIX | Notes |
|
||||
/// | :-- | :--: | :--: | :-- |
|
||||
/// | `Reset` | ✓ | ✓ | |
|
||||
/// | `Bold` | ✓ | ✓ | |
|
||||
/// | `Dim` | ✓ | ✓ | |
|
||||
/// | `Italic` | ? | ? | Not widely supported, sometimes treated as inverse. |
|
||||
/// | `Underlined` | ✓ | ✓ | |
|
||||
/// | `SlowBlink` | ? | ? | Not widely supported, sometimes treated as inverse. |
|
||||
/// | `RapidBlink` | ? | ? | Not widely supported. MS-DOS ANSI.SYS; 150+ per minute. |
|
||||
/// | `Reverse` | ✓ | ✓ | |
|
||||
/// | `Hidden` | ✓ | ✓ | Also known as Conceal. |
|
||||
/// | `Fraktur` | ✗ | ✓ | Legible characters, but marked for deletion. |
|
||||
/// | `DefaultForegroundColor` | ? | ? | Implementation specific (according to standard). |
|
||||
/// | `DefaultBackgroundColor` | ? | ? | Implementation specific (according to standard). |
|
||||
/// | `Framed` | ? | ? | Not widely supported. |
|
||||
/// | `Encircled` | ? | ? | This should turn on the encircled attribute. |
|
||||
/// | `OverLined` | ? | ? | This should draw a line at the top of the text. |
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Basic usage:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::style::Attribute;
|
||||
///
|
||||
/// println!(
|
||||
/// "{} Underlined {} No Underline",
|
||||
/// Attribute::Underlined,
|
||||
/// Attribute::NoUnderline
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// Style existing text:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crossterm::style::Stylize;
|
||||
///
|
||||
/// println!("{}", "Bold text".bold());
|
||||
/// println!("{}", "Underlined text".underlined());
|
||||
/// println!("{}", "Negative text".negative());
|
||||
/// ```
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum Attribute {
|
||||
$(
|
||||
$(#[$inner $($args)*])*
|
||||
$name,
|
||||
)*
|
||||
}
|
||||
|
||||
pub static SGR: &'static[i16] = &[
|
||||
$($sgr,)*
|
||||
];
|
||||
|
||||
impl Attribute {
|
||||
/// Iterates over all the variants of the Attribute enum.
|
||||
pub fn iterator() -> impl Iterator<Item = Attribute> {
|
||||
use self::Attribute::*;
|
||||
[ $($name,)* ].iter().copied()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Attribute! {
|
||||
/// Resets all the attributes.
|
||||
Reset = 0,
|
||||
/// Increases the text intensity.
|
||||
Bold = 1,
|
||||
/// Decreases the text intensity.
|
||||
Dim = 2,
|
||||
/// Emphasises the text.
|
||||
Italic = 3,
|
||||
/// Underlines the text.
|
||||
Underlined = 4,
|
||||
|
||||
// Other types of underlining
|
||||
/// Double underlines the text.
|
||||
DoubleUnderlined = 2,
|
||||
/// Undercurls the text.
|
||||
Undercurled = 3,
|
||||
/// Underdots the text.
|
||||
Underdotted = 4,
|
||||
/// Underdashes the text.
|
||||
Underdashed = 5,
|
||||
|
||||
/// Makes the text blinking (< 150 per minute).
|
||||
SlowBlink = 5,
|
||||
/// Makes the text blinking (>= 150 per minute).
|
||||
RapidBlink = 6,
|
||||
/// Swaps foreground and background colors.
|
||||
Reverse = 7,
|
||||
/// Hides the text (also known as Conceal).
|
||||
Hidden = 8,
|
||||
/// Crosses the text.
|
||||
CrossedOut = 9,
|
||||
/// Sets the [Fraktur](https://en.wikipedia.org/wiki/Fraktur) typeface.
|
||||
///
|
||||
/// Mostly used for [mathematical alphanumeric symbols](https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols).
|
||||
Fraktur = 20,
|
||||
/// Turns off the `Bold` attribute. - Inconsistent - Prefer to use NormalIntensity
|
||||
NoBold = 21,
|
||||
/// Switches the text back to normal intensity (no bold, italic).
|
||||
NormalIntensity = 22,
|
||||
/// Turns off the `Italic` attribute.
|
||||
NoItalic = 23,
|
||||
/// Turns off the `Underlined` attribute.
|
||||
NoUnderline = 24,
|
||||
/// Turns off the text blinking (`SlowBlink` or `RapidBlink`).
|
||||
NoBlink = 25,
|
||||
/// Turns off the `Reverse` attribute.
|
||||
NoReverse = 27,
|
||||
/// Turns off the `Hidden` attribute.
|
||||
NoHidden = 28,
|
||||
/// Turns off the `CrossedOut` attribute.
|
||||
NotCrossedOut = 29,
|
||||
/// Makes the text framed.
|
||||
Framed = 51,
|
||||
/// Makes the text encircled.
|
||||
Encircled = 52,
|
||||
/// Draws a line at the top of the text.
|
||||
OverLined = 53,
|
||||
/// Turns off the `Frame` and `Encircled` attributes.
|
||||
NotFramedOrEncircled = 54,
|
||||
/// Turns off the `OverLined` attribute.
|
||||
NotOverLined = 55,
|
||||
}
|
||||
|
||||
impl Display for Attribute {
|
||||
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", SetAttribute(*self))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Attribute {
|
||||
/// Returns a u32 with one bit set, which is the
|
||||
/// signature of this attribute in the Attributes
|
||||
/// bitset.
|
||||
///
|
||||
/// The +1 enables storing Reset (whose index is 0)
|
||||
/// in the bitset Attributes.
|
||||
#[inline(always)]
|
||||
pub const fn bytes(self) -> u32 {
|
||||
1 << ((self as u32) + 1)
|
||||
}
|
||||
/// Returns the SGR attribute value.
|
||||
///
|
||||
/// See <https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters>
|
||||
pub fn sgr(self) -> String {
|
||||
if (self as usize) > 4 && (self as usize) < 9 {
|
||||
return "4:".to_string() + SGR[self as usize].to_string().as_str();
|
||||
}
|
||||
SGR[self as usize].to_string()
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
use std::{
|
||||
convert::{AsRef, TryFrom},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use std::fmt;
|
||||
|
||||
use crate::style::parse_next_u8;
|
||||
|
||||
/// Represents a color.
|
||||
///
|
||||
/// # Platform-specific Notes
|
||||
///
|
||||
/// The following list of 16 base colors are available for almost all terminals (Windows 7 and 8 included).
|
||||
///
|
||||
/// | Light | Dark |
|
||||
/// | :--------- | :------------ |
|
||||
/// | `DarkGrey` | `Black` |
|
||||
/// | `Red` | `DarkRed` |
|
||||
/// | `Green` | `DarkGreen` |
|
||||
/// | `Yellow` | `DarkYellow` |
|
||||
/// | `Blue` | `DarkBlue` |
|
||||
/// | `Magenta` | `DarkMagenta` |
|
||||
/// | `Cyan` | `DarkCyan` |
|
||||
/// | `White` | `Grey` |
|
||||
///
|
||||
/// Most UNIX terminals and Windows 10 consoles support additional colors.
|
||||
/// See [`Color::Rgb`] or [`Color::AnsiValue`] for more info.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
pub enum Color {
|
||||
/// Resets the terminal color.
|
||||
Reset,
|
||||
|
||||
/// Black color.
|
||||
Black,
|
||||
|
||||
/// Dark grey color.
|
||||
DarkGrey,
|
||||
|
||||
/// Light red color.
|
||||
Red,
|
||||
|
||||
/// Dark red color.
|
||||
DarkRed,
|
||||
|
||||
/// Light green color.
|
||||
Green,
|
||||
|
||||
/// Dark green color.
|
||||
DarkGreen,
|
||||
|
||||
/// Light yellow color.
|
||||
Yellow,
|
||||
|
||||
/// Dark yellow color.
|
||||
DarkYellow,
|
||||
|
||||
/// Light blue color.
|
||||
Blue,
|
||||
|
||||
/// Dark blue color.
|
||||
DarkBlue,
|
||||
|
||||
/// Light magenta color.
|
||||
Magenta,
|
||||
|
||||
/// Dark magenta color.
|
||||
DarkMagenta,
|
||||
|
||||
/// Light cyan color.
|
||||
Cyan,
|
||||
|
||||
/// Dark cyan color.
|
||||
DarkCyan,
|
||||
|
||||
/// White color.
|
||||
White,
|
||||
|
||||
/// Grey color.
|
||||
Grey,
|
||||
|
||||
/// An RGB color. See [RGB color model](https://en.wikipedia.org/wiki/RGB_color_model) for more info.
|
||||
///
|
||||
/// Most UNIX terminals and Windows 10 supported only.
|
||||
/// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info.
|
||||
Rgb { r: u8, g: u8, b: u8 },
|
||||
|
||||
/// An ANSI color. See [256 colors - cheat sheet](https://jonasjacek.github.io/colors/) for more info.
|
||||
///
|
||||
/// Most UNIX terminals and Windows 10 supported only.
|
||||
/// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info.
|
||||
AnsiValue(u8),
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// Parses an ANSI color sequence.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crossterm::style::Color;
|
||||
///
|
||||
/// assert_eq!(Color::parse_ansi("5;0"), Some(Color::Black));
|
||||
/// assert_eq!(Color::parse_ansi("5;26"), Some(Color::AnsiValue(26)));
|
||||
/// assert_eq!(Color::parse_ansi("2;50;60;70"), Some(Color::Rgb { r: 50, g: 60, b: 70 }));
|
||||
/// assert_eq!(Color::parse_ansi("invalid color"), None);
|
||||
/// ```
|
||||
///
|
||||
/// Currently, 3/4 bit color values aren't supported so return `None`.
|
||||
///
|
||||
/// See also: [`Colored::parse_ansi`](crate::style::Colored::parse_ansi).
|
||||
pub fn parse_ansi(ansi: &str) -> Option<Self> {
|
||||
Self::parse_ansi_iter(&mut ansi.split(';'))
|
||||
}
|
||||
|
||||
/// The logic for parse_ansi, takes an iterator of the sequences terms (the numbers between the
|
||||
/// ';'). It's a separate function so it can be used by both Color::parse_ansi and
|
||||
/// colored::parse_ansi.
|
||||
/// Tested in Colored tests.
|
||||
pub(crate) fn parse_ansi_iter<'a>(values: &mut impl Iterator<Item = &'a str>) -> Option<Self> {
|
||||
let color = match parse_next_u8(values)? {
|
||||
// 8 bit colors: `5;<n>`
|
||||
5 => {
|
||||
let n = parse_next_u8(values)?;
|
||||
|
||||
use Color::*;
|
||||
[
|
||||
Black, // 0
|
||||
DarkRed, // 1
|
||||
DarkGreen, // 2
|
||||
DarkYellow, // 3
|
||||
DarkBlue, // 4
|
||||
DarkMagenta, // 5
|
||||
DarkCyan, // 6
|
||||
Grey, // 7
|
||||
DarkGrey, // 8
|
||||
Red, // 9
|
||||
Green, // 10
|
||||
Yellow, // 11
|
||||
Blue, // 12
|
||||
Magenta, // 13
|
||||
Cyan, // 14
|
||||
White, // 15
|
||||
]
|
||||
.get(n as usize)
|
||||
.copied()
|
||||
.unwrap_or(Color::AnsiValue(n))
|
||||
}
|
||||
|
||||
// 24 bit colors: `2;<r>;<g>;<b>`
|
||||
2 => Color::Rgb {
|
||||
r: parse_next_u8(values)?,
|
||||
g: parse_next_u8(values)?,
|
||||
b: parse_next_u8(values)?,
|
||||
},
|
||||
|
||||
_ => return None,
|
||||
};
|
||||
// If there's another value, it's unexpected so return None.
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Color {
|
||||
type Error = ();
|
||||
|
||||
/// Try to create a `Color` from the string representation. This returns an error if the string does not match.
|
||||
fn try_from(src: &str) -> Result<Self, Self::Error> {
|
||||
let src = src.to_lowercase();
|
||||
|
||||
match src.as_ref() {
|
||||
"reset" => Ok(Color::Reset),
|
||||
"black" => Ok(Color::Black),
|
||||
"dark_grey" => Ok(Color::DarkGrey),
|
||||
"red" => Ok(Color::Red),
|
||||
"dark_red" => Ok(Color::DarkRed),
|
||||
"green" => Ok(Color::Green),
|
||||
"dark_green" => Ok(Color::DarkGreen),
|
||||
"yellow" => Ok(Color::Yellow),
|
||||
"dark_yellow" => Ok(Color::DarkYellow),
|
||||
"blue" => Ok(Color::Blue),
|
||||
"dark_blue" => Ok(Color::DarkBlue),
|
||||
"magenta" => Ok(Color::Magenta),
|
||||
"dark_magenta" => Ok(Color::DarkMagenta),
|
||||
"cyan" => Ok(Color::Cyan),
|
||||
"dark_cyan" => Ok(Color::DarkCyan),
|
||||
"white" => Ok(Color::White),
|
||||
"grey" => Ok(Color::Grey),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Color {
|
||||
type Err = ();
|
||||
|
||||
/// Creates a `Color` from the string representation.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * Returns `Color::White` in case of an unknown color.
|
||||
/// * Does not return `Err` and you can safely unwrap.
|
||||
fn from_str(src: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Color::try_from(src).unwrap_or(Color::White))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(u8, u8, u8)> for Color {
|
||||
/// Creates a 'Color' from the tuple representation.
|
||||
fn from(val: (u8, u8, u8)) -> Self {
|
||||
let (r, g, b) = val;
|
||||
Self::Rgb { r, g, b }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
impl serde::ser::Serialize for Color {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::ser::Serializer,
|
||||
{
|
||||
let str = match *self {
|
||||
Color::Reset => "reset",
|
||||
Color::Black => "black",
|
||||
Color::DarkGrey => "dark_grey",
|
||||
Color::Red => "red",
|
||||
Color::DarkRed => "dark_red",
|
||||
Color::Green => "green",
|
||||
Color::DarkGreen => "dark_green",
|
||||
Color::Yellow => "yellow",
|
||||
Color::DarkYellow => "dark_yellow",
|
||||
Color::Blue => "blue",
|
||||
Color::DarkBlue => "dark_blue",
|
||||
Color::Magenta => "magenta",
|
||||
Color::DarkMagenta => "dark_magenta",
|
||||
Color::Cyan => "cyan",
|
||||
Color::DarkCyan => "dark_cyan",
|
||||
Color::White => "white",
|
||||
Color::Grey => "grey",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
if str.is_empty() {
|
||||
match *self {
|
||||
Color::AnsiValue(value) => serializer.serialize_str(&format!("ansi_({})", value)),
|
||||
Color::Rgb { r, g, b } => {
|
||||
serializer.serialize_str(&format!("rgb_({},{},{})", r, g, b))
|
||||
}
|
||||
_ => Err(serde::ser::Error::custom("Could not serialize enum type")),
|
||||
}
|
||||
} else {
|
||||
serializer.serialize_str(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
impl<'de> serde::de::Deserialize<'de> for Color {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
|
||||
where
|
||||
D: serde::de::Deserializer<'de>,
|
||||
{
|
||||
struct ColorVisitor;
|
||||
impl serde::de::Visitor<'_> for ColorVisitor {
|
||||
type Value = Color;
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str(
|
||||
"`reset`, `black`, `blue`, `dark_blue`, `cyan`, `dark_cyan`, `green`, `dark_green`, `grey`, `dark_grey`, `magenta`, `dark_magenta`, `red`, `dark_red`, `white`, `yellow`, `dark_yellow`, `ansi_(value)`, or `rgb_(r,g,b)` or `#rgbhex`",
|
||||
)
|
||||
}
|
||||
fn visit_str<E>(self, value: &str) -> Result<Color, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
if let Ok(c) = Color::try_from(value) {
|
||||
Ok(c)
|
||||
} else {
|
||||
if value.contains("ansi") {
|
||||
// strip away `ansi_(..)' and get the inner value between parenthesis.
|
||||
let results = value.replace("ansi_(", "").replace(")", "");
|
||||
|
||||
let ansi_val = results.parse::<u8>();
|
||||
|
||||
if let Ok(ansi) = ansi_val {
|
||||
return Ok(Color::AnsiValue(ansi));
|
||||
}
|
||||
} else if value.contains("rgb") {
|
||||
// strip away `rgb_(..)' and get the inner values between parenthesis.
|
||||
let results = value
|
||||
.replace("rgb_(", "")
|
||||
.replace(")", "")
|
||||
.split(',')
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if results.len() == 3 {
|
||||
let r = results[0].parse::<u8>();
|
||||
let g = results[1].parse::<u8>();
|
||||
let b = results[2].parse::<u8>();
|
||||
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (r, g, b) {
|
||||
return Ok(Color::Rgb { r, g, b });
|
||||
}
|
||||
}
|
||||
} else if let Some(hex) = value.strip_prefix('#') {
|
||||
if hex.is_ascii() && hex.len() == 6 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16);
|
||||
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (r, g, b) {
|
||||
return Ok(Color::Rgb { r, g, b });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(E::invalid_value(serde::de::Unexpected::Str(value), &self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(ColorVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Color;
|
||||
|
||||
#[test]
|
||||
fn test_known_color_conversion() {
|
||||
assert_eq!("reset".parse(), Ok(Color::Reset));
|
||||
assert_eq!("grey".parse(), Ok(Color::Grey));
|
||||
assert_eq!("dark_grey".parse(), Ok(Color::DarkGrey));
|
||||
assert_eq!("red".parse(), Ok(Color::Red));
|
||||
assert_eq!("dark_red".parse(), Ok(Color::DarkRed));
|
||||
assert_eq!("green".parse(), Ok(Color::Green));
|
||||
assert_eq!("dark_green".parse(), Ok(Color::DarkGreen));
|
||||
assert_eq!("yellow".parse(), Ok(Color::Yellow));
|
||||
assert_eq!("dark_yellow".parse(), Ok(Color::DarkYellow));
|
||||
assert_eq!("blue".parse(), Ok(Color::Blue));
|
||||
assert_eq!("dark_blue".parse(), Ok(Color::DarkBlue));
|
||||
assert_eq!("magenta".parse(), Ok(Color::Magenta));
|
||||
assert_eq!("dark_magenta".parse(), Ok(Color::DarkMagenta));
|
||||
assert_eq!("cyan".parse(), Ok(Color::Cyan));
|
||||
assert_eq!("dark_cyan".parse(), Ok(Color::DarkCyan));
|
||||
assert_eq!("white".parse(), Ok(Color::White));
|
||||
assert_eq!("black".parse(), Ok(Color::Black));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_color_conversion_yields_white() {
|
||||
assert_eq!("foo".parse(), Ok(Color::White));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_know_rgb_color_conversion() {
|
||||
assert_eq!(Color::from((0, 0, 0)), Color::Rgb { r: 0, g: 0, b: 0 });
|
||||
assert_eq!(
|
||||
Color::from((255, 255, 255)),
|
||||
Color::Rgb {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "serde")]
|
||||
mod serde_tests {
|
||||
use super::Color;
|
||||
use serde_json;
|
||||
|
||||
#[test]
|
||||
fn test_deserial_known_color_conversion() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"Reset\"").unwrap(),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"reset\"").unwrap(),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"Red\"").unwrap(),
|
||||
Color::Red
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"red\"").unwrap(),
|
||||
Color::Red
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_red\"").unwrap(),
|
||||
Color::DarkRed
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"grey\"").unwrap(),
|
||||
Color::Grey
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_grey\"").unwrap(),
|
||||
Color::DarkGrey
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"green\"").unwrap(),
|
||||
Color::Green
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_green\"").unwrap(),
|
||||
Color::DarkGreen
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"yellow\"").unwrap(),
|
||||
Color::Yellow
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_yellow\"").unwrap(),
|
||||
Color::DarkYellow
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"blue\"").unwrap(),
|
||||
Color::Blue
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_blue\"").unwrap(),
|
||||
Color::DarkBlue
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"magenta\"").unwrap(),
|
||||
Color::Magenta
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_magenta\"").unwrap(),
|
||||
Color::DarkMagenta
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"cyan\"").unwrap(),
|
||||
Color::Cyan
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"dark_cyan\"").unwrap(),
|
||||
Color::DarkCyan
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"white\"").unwrap(),
|
||||
Color::White
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"black\"").unwrap(),
|
||||
Color::Black
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_unknown_color_conversion() {
|
||||
assert!(serde_json::from_str::<Color>("\"unknown\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_ansi_value() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"ansi_(255)\"").unwrap(),
|
||||
Color::AnsiValue(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_unvalid_ansi_value() {
|
||||
assert!(serde_json::from_str::<Color>("\"ansi_(256)\"").is_err());
|
||||
assert!(serde_json::from_str::<Color>("\"ansi_(-1)\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_rgb() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"rgb_(255,255,255)\"").unwrap(),
|
||||
Color::from((255, 255, 255))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_unvalid_rgb() {
|
||||
assert!(serde_json::from_str::<Color>("\"rgb_(255,255,255,255)\"").is_err());
|
||||
assert!(serde_json::from_str::<Color>("\"rgb_(256,255,255)\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_rgb_hex() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"#ffffff\"").unwrap(),
|
||||
Color::from((255, 255, 255))
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Color>("\"#FFFFFF\"").unwrap(),
|
||||
Color::from((255, 255, 255))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserial_unvalid_rgb_hex() {
|
||||
assert!(serde_json::from_str::<Color>("\"#FFFFFFFF\"").is_err());
|
||||
assert!(serde_json::from_str::<Color>("\"#FFGFFF\"").is_err());
|
||||
// Ferris is 4 bytes so this will be considered the correct length.
|
||||
assert!(serde_json::from_str::<Color>("\"#ff🦀\"").is_err());
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
use parking_lot::Once;
|
||||
use std::fmt::{self, Formatter};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::style::{parse_next_u8, Color};
|
||||
|
||||
/// Represents a foreground or background color.
|
||||
///
|
||||
/// This can be converted to a [Colors](struct.Colors.html) by calling `into()` and applied
|
||||
/// using the [SetColors](struct.SetColors.html) command.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
pub enum Colored {
|
||||
/// A foreground color.
|
||||
ForegroundColor(Color),
|
||||
/// A background color.
|
||||
BackgroundColor(Color),
|
||||
/// An underline color.
|
||||
/// Important: doesn't work on windows 10 or lower.
|
||||
UnderlineColor(Color),
|
||||
}
|
||||
|
||||
static ANSI_COLOR_DISABLED: AtomicBool = AtomicBool::new(false);
|
||||
static INITIALIZER: Once = Once::new();
|
||||
|
||||
impl Colored {
|
||||
/// Parse an ANSI foreground or background color.
|
||||
/// This is the string that would appear within an `ESC [ <str> m` escape sequence, as found in
|
||||
/// various configuration files.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crossterm::style::{Colored::{self, ForegroundColor, BackgroundColor}, Color};
|
||||
///
|
||||
/// assert_eq!(Colored::parse_ansi("38;5;0"), Some(ForegroundColor(Color::Black)));
|
||||
/// assert_eq!(Colored::parse_ansi("38;5;26"), Some(ForegroundColor(Color::AnsiValue(26))));
|
||||
/// assert_eq!(Colored::parse_ansi("48;2;50;60;70"), Some(BackgroundColor(Color::Rgb { r: 50, g: 60, b: 70 })));
|
||||
/// assert_eq!(Colored::parse_ansi("49"), Some(BackgroundColor(Color::Reset)));
|
||||
/// assert_eq!(Colored::parse_ansi("invalid color"), None);
|
||||
/// ```
|
||||
///
|
||||
/// Currently, 3/4 bit color values aren't supported so return `None`.
|
||||
///
|
||||
/// See also: [`Color::parse_ansi`].
|
||||
pub fn parse_ansi(ansi: &str) -> Option<Self> {
|
||||
use Colored::{BackgroundColor, ForegroundColor, UnderlineColor};
|
||||
|
||||
let values = &mut ansi.split(';');
|
||||
|
||||
let output = match parse_next_u8(values)? {
|
||||
38 => return Color::parse_ansi_iter(values).map(ForegroundColor),
|
||||
48 => return Color::parse_ansi_iter(values).map(BackgroundColor),
|
||||
58 => return Color::parse_ansi_iter(values).map(UnderlineColor),
|
||||
|
||||
39 => ForegroundColor(Color::Reset),
|
||||
49 => BackgroundColor(Color::Reset),
|
||||
59 => UnderlineColor(Color::Reset),
|
||||
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
/// Checks whether ansi color sequences are disabled by setting of NO_COLOR
|
||||
/// in environment as per <https://no-color.org/>
|
||||
pub fn ansi_color_disabled() -> bool {
|
||||
!std::env::var("NO_COLOR")
|
||||
.unwrap_or("".to_string())
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub fn ansi_color_disabled_memoized() -> bool {
|
||||
INITIALIZER.call_once(|| {
|
||||
ANSI_COLOR_DISABLED.store(Self::ansi_color_disabled(), Ordering::SeqCst);
|
||||
});
|
||||
|
||||
ANSI_COLOR_DISABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn set_ansi_color_disabled(val: bool) {
|
||||
// Force the one-time initializer to run.
|
||||
_ = Self::ansi_color_disabled_memoized();
|
||||
ANSI_COLOR_DISABLED.store(val, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Colored {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
let color;
|
||||
|
||||
if Self::ansi_color_disabled_memoized() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match *self {
|
||||
Colored::ForegroundColor(new_color) => {
|
||||
if new_color == Color::Reset {
|
||||
return f.write_str("39");
|
||||
} else {
|
||||
f.write_str("38;")?;
|
||||
color = new_color;
|
||||
}
|
||||
}
|
||||
Colored::BackgroundColor(new_color) => {
|
||||
if new_color == Color::Reset {
|
||||
return f.write_str("49");
|
||||
} else {
|
||||
f.write_str("48;")?;
|
||||
color = new_color;
|
||||
}
|
||||
}
|
||||
Colored::UnderlineColor(new_color) => {
|
||||
if new_color == Color::Reset {
|
||||
return f.write_str("59");
|
||||
} else {
|
||||
f.write_str("58;")?;
|
||||
color = new_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match color {
|
||||
Color::Black => f.write_str("5;0"),
|
||||
Color::DarkGrey => f.write_str("5;8"),
|
||||
Color::Red => f.write_str("5;9"),
|
||||
Color::DarkRed => f.write_str("5;1"),
|
||||
Color::Green => f.write_str("5;10"),
|
||||
Color::DarkGreen => f.write_str("5;2"),
|
||||
Color::Yellow => f.write_str("5;11"),
|
||||
Color::DarkYellow => f.write_str("5;3"),
|
||||
Color::Blue => f.write_str("5;12"),
|
||||
Color::DarkBlue => f.write_str("5;4"),
|
||||
Color::Magenta => f.write_str("5;13"),
|
||||
Color::DarkMagenta => f.write_str("5;5"),
|
||||
Color::Cyan => f.write_str("5;14"),
|
||||
Color::DarkCyan => f.write_str("5;6"),
|
||||
Color::White => f.write_str("5;15"),
|
||||
Color::Grey => f.write_str("5;7"),
|
||||
Color::Rgb { r, g, b } => write!(f, "2;{r};{g};{b}"),
|
||||
Color::AnsiValue(val) => write!(f, "5;{val}"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::style::{Color, Colored};
|
||||
|
||||
fn check_format_color(colored: Colored, expected: &str) {
|
||||
Colored::set_ansi_color_disabled(true);
|
||||
assert_eq!(colored.to_string(), "");
|
||||
Colored::set_ansi_color_disabled(false);
|
||||
assert_eq!(colored.to_string(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_fg_color() {
|
||||
let colored = Colored::ForegroundColor(Color::Red);
|
||||
check_format_color(colored, "38;5;9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_bg_color() {
|
||||
let colored = Colored::BackgroundColor(Color::Red);
|
||||
check_format_color(colored, "48;5;9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_reset_fg_color() {
|
||||
let colored = Colored::ForegroundColor(Color::Reset);
|
||||
check_format_color(colored, "39");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_reset_bg_color() {
|
||||
let colored = Colored::BackgroundColor(Color::Reset);
|
||||
check_format_color(colored, "49");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_fg_rgb_color() {
|
||||
let colored = Colored::BackgroundColor(Color::Rgb { r: 1, g: 2, b: 3 });
|
||||
check_format_color(colored, "48;2;1;2;3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_fg_ansi_color() {
|
||||
let colored = Colored::ForegroundColor(Color::AnsiValue(255));
|
||||
check_format_color(colored, "38;5;255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ansi_fg() {
|
||||
test_parse_ansi(Colored::ForegroundColor)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ansi_bg() {
|
||||
test_parse_ansi(Colored::ForegroundColor)
|
||||
}
|
||||
|
||||
/// Used for test_parse_ansi_fg and test_parse_ansi_bg
|
||||
fn test_parse_ansi(bg_or_fg: impl Fn(Color) -> Colored) {
|
||||
/// Formats a re-parses `color` to check the result.
|
||||
macro_rules! test {
|
||||
($color:expr) => {
|
||||
let colored = bg_or_fg($color);
|
||||
assert_eq!(Colored::parse_ansi(&format!("{}", colored)), Some(colored));
|
||||
};
|
||||
}
|
||||
|
||||
use Color::*;
|
||||
|
||||
test!(Reset);
|
||||
test!(Black);
|
||||
test!(DarkGrey);
|
||||
test!(Red);
|
||||
test!(DarkRed);
|
||||
test!(Green);
|
||||
test!(DarkGreen);
|
||||
test!(Yellow);
|
||||
test!(DarkYellow);
|
||||
test!(Blue);
|
||||
test!(DarkBlue);
|
||||
test!(Magenta);
|
||||
test!(DarkMagenta);
|
||||
test!(Cyan);
|
||||
test!(DarkCyan);
|
||||
test!(White);
|
||||
test!(Grey);
|
||||
|
||||
// n in 0..=15 will give us the color values above back.
|
||||
for n in 16..=255 {
|
||||
test!(AnsiValue(n));
|
||||
}
|
||||
|
||||
for r in 0..=255 {
|
||||
for g in [0, 2, 18, 19, 60, 100, 200, 250, 254, 255].iter().copied() {
|
||||
for b in [0, 12, 16, 99, 100, 161, 200, 255].iter().copied() {
|
||||
test!(Rgb { r, g, b });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_ansi_color() {
|
||||
/// Checks that trying to parse `s` yields None.
|
||||
fn test(s: &str) {
|
||||
assert_eq!(Colored::parse_ansi(s), None);
|
||||
}
|
||||
test("");
|
||||
test(";");
|
||||
test(";;");
|
||||
test(";;");
|
||||
test("0");
|
||||
test("1");
|
||||
test("12");
|
||||
test("100");
|
||||
test("100048949345");
|
||||
test("39;");
|
||||
test("49;");
|
||||
test("39;2");
|
||||
test("49;2");
|
||||
test("38");
|
||||
test("38;");
|
||||
test("38;0");
|
||||
test("38;5");
|
||||
test("38;5;0;");
|
||||
test("38;5;0;2");
|
||||
test("38;5;80;");
|
||||
test("38;5;80;2");
|
||||
test("38;5;257");
|
||||
test("38;2");
|
||||
test("38;2;");
|
||||
test("38;2;0");
|
||||
test("38;2;0;2");
|
||||
test("38;2;0;2;257");
|
||||
test("38;2;0;2;25;");
|
||||
test("38;2;0;2;25;3");
|
||||
test("48");
|
||||
test("48;");
|
||||
test("48;0");
|
||||
test("48;5");
|
||||
test("48;5;0;");
|
||||
test("48;5;0;2");
|
||||
test("48;5;80;");
|
||||
test("48;5;80;2");
|
||||
test("48;5;257");
|
||||
test("48;2");
|
||||
test("48;2;");
|
||||
test("48;2;0");
|
||||
test("48;2;0;2");
|
||||
test("48;2;0;2;257");
|
||||
test("48;2;0;2;25;");
|
||||
test("48;2;0;2;25;3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_color() {
|
||||
std::env::set_var("NO_COLOR", "1");
|
||||
assert!(Colored::ansi_color_disabled());
|
||||
std::env::set_var("NO_COLOR", "XXX");
|
||||
assert!(Colored::ansi_color_disabled());
|
||||
std::env::set_var("NO_COLOR", "");
|
||||
assert!(!Colored::ansi_color_disabled());
|
||||
std::env::remove_var("NO_COLOR");
|
||||
assert!(!Colored::ansi_color_disabled());
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
use crate::style::{Color, Colored};
|
||||
|
||||
/// Represents, optionally, a foreground and/or a background color.
|
||||
///
|
||||
/// It can be applied using the `SetColors` command.
|
||||
///
|
||||
/// It can also be created from a [Colored](enum.Colored.html) value or a tuple of
|
||||
/// `(Color, Color)` in the order `(foreground, background)`.
|
||||
///
|
||||
/// The [then](#method.then) method can be used to combine `Colors` values.
|
||||
///
|
||||
/// For example:
|
||||
/// ```no_run
|
||||
/// use crossterm::style::{Color, Colors, Colored};
|
||||
///
|
||||
/// // An example color, loaded from a config, file in ANSI format.
|
||||
/// let config_color = "38;2;23;147;209";
|
||||
///
|
||||
/// // Default to green text on a black background.
|
||||
/// let default_colors = Colors::new(Color::Green, Color::Black);
|
||||
/// // Load a colored value from a config and override the default colors
|
||||
/// let colors = match Colored::parse_ansi(config_color) {
|
||||
/// Some(colored) => default_colors.then(&colored.into()),
|
||||
/// None => default_colors,
|
||||
/// };
|
||||
/// ```
|
||||
///
|
||||
/// See [Color](enum.Color.html).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Colors {
|
||||
pub foreground: Option<Color>,
|
||||
pub background: Option<Color>,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
/// Returns a new `Color` which, when applied, has the same effect as applying `self` and *then*
|
||||
/// `other`.
|
||||
pub fn then(&self, other: &Colors) -> Colors {
|
||||
Colors {
|
||||
foreground: other.foreground.or(self.foreground),
|
||||
background: other.background.or(self.background),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
pub fn new(foreground: Color, background: Color) -> Colors {
|
||||
Colors {
|
||||
foreground: Some(foreground),
|
||||
background: Some(background),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Colored> for Colors {
|
||||
fn from(colored: Colored) -> Colors {
|
||||
match colored {
|
||||
Colored::ForegroundColor(color) => Colors {
|
||||
foreground: Some(color),
|
||||
background: None,
|
||||
},
|
||||
Colored::BackgroundColor(color) => Colors {
|
||||
foreground: None,
|
||||
background: Some(color),
|
||||
},
|
||||
Colored::UnderlineColor(color) => Colors {
|
||||
foreground: None,
|
||||
background: Some(color),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::style::{Color, Colors};
|
||||
|
||||
#[test]
|
||||
fn test_colors_then() {
|
||||
use Color::*;
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: Some(Black),
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: Some(Black),
|
||||
background: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: Some(Grey),
|
||||
}),
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: Some(Grey),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors::new(White, Grey)),
|
||||
Colors::new(White, Grey),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: Some(Blue),
|
||||
}
|
||||
.then(&Colors::new(White, Grey)),
|
||||
Colors::new(White, Grey),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors::new(White, Grey)),
|
||||
Colors::new(White, Grey),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors::new(Blue, Green).then(&Colors::new(White, Grey)),
|
||||
Colors::new(White, Grey),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: Some(Green),
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: Some(Grey),
|
||||
}),
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: Some(Grey),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: Some(Green),
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: Some(White),
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: Some(White),
|
||||
background: Some(Green),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: Some(Green),
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: Some(Green),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: Some(Green),
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: None,
|
||||
background: Some(Green),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: None,
|
||||
}
|
||||
.then(&Colors {
|
||||
foreground: None,
|
||||
background: None,
|
||||
}),
|
||||
Colors {
|
||||
foreground: Some(Blue),
|
||||
background: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+568
@@ -0,0 +1,568 @@
|
||||
//! # Terminal
|
||||
//!
|
||||
//! The `terminal` module provides functionality to work with the terminal.
|
||||
//!
|
||||
//! This documentation does not contain a lot of examples. The reason is that it's fairly
|
||||
//! obvious how to use this crate. Although, we do provide
|
||||
//! [examples](https://github.com/crossterm-rs/crossterm/tree/master/examples) repository
|
||||
//! to demonstrate the capabilities.
|
||||
//!
|
||||
//! Most terminal actions can be performed with commands.
|
||||
//! Please have a look at [command documentation](../index.html#command-api) for a more detailed documentation.
|
||||
//!
|
||||
//! ## Screen Buffer
|
||||
//!
|
||||
//! A screen buffer is a two-dimensional array of character
|
||||
//! and color data which is displayed in a terminal screen.
|
||||
//!
|
||||
//! The terminal has several of those buffers and is able to switch between them.
|
||||
//! The default screen in which you work is called the 'main screen'.
|
||||
//! The other screens are called the 'alternative screen'.
|
||||
//!
|
||||
//! It is important to understand that crossterm does not yet support creating screens,
|
||||
//! or switch between more than two buffers, and only offers the ability to change
|
||||
//! between the 'alternate' and 'main screen'.
|
||||
//!
|
||||
//! ### Alternate Screen
|
||||
//!
|
||||
//! By default, you will be working on the main screen.
|
||||
//! There is also another screen called the 'alternative' screen.
|
||||
//! This screen is slightly different from the main screen.
|
||||
//! For example, it has the exact dimensions of the terminal window,
|
||||
//! without any scroll-back area.
|
||||
//!
|
||||
//! Crossterm offers the possibility to switch to the 'alternative' screen,
|
||||
//! make some modifications, and move back to the 'main' screen again.
|
||||
//! The main screen will stay intact and will have the original data as we performed all
|
||||
//! operations on the alternative screen.
|
||||
//!
|
||||
//! An good example of this is Vim.
|
||||
//! When it is launched from bash, a whole new buffer is used to modify a file.
|
||||
//! Then, when the modification is finished, it closes again and continues on the main screen.
|
||||
//!
|
||||
//! ### Raw Mode
|
||||
//!
|
||||
//! By default, the terminal functions in a certain way.
|
||||
//! For example, it will move the cursor to the beginning of the next line when the input hits the end of a line.
|
||||
//! Or that the backspace is interpreted for character removal.
|
||||
//!
|
||||
//! Sometimes these default modes are irrelevant,
|
||||
//! and in this case, we can turn them off.
|
||||
//! This is what happens when you enable raw modes.
|
||||
//!
|
||||
//! Those modes will be set when enabling raw modes:
|
||||
//!
|
||||
//! - Input will not be forwarded to screen
|
||||
//! - Input will not be processed on enter press
|
||||
//! - Input will not be line buffered (input sent byte-by-byte to input buffer)
|
||||
//! - Special keys like backspace and CTRL+C will not be processed by terminal driver
|
||||
//! - New line character will not be processed therefore `println!` can't be used, use `write!` instead
|
||||
//!
|
||||
//! Raw mode can be enabled/disabled with the [enable_raw_mode](terminal::enable_raw_mode) and [disable_raw_mode](terminal::disable_raw_mode) functions.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::io::{self, Write};
|
||||
//! use crossterm::{execute, terminal::{ScrollUp, SetSize, size}};
|
||||
//!
|
||||
//! fn main() -> io::Result<()> {
|
||||
//! let (cols, rows) = size()?;
|
||||
//! // Resize terminal and scroll up.
|
||||
//! execute!(
|
||||
//! io::stdout(),
|
||||
//! SetSize(10, 10),
|
||||
//! ScrollUp(5)
|
||||
//! )?;
|
||||
//!
|
||||
//! // Be a good citizen, cleanup
|
||||
//! execute!(io::stdout(), SetSize(cols, rows))?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! For manual execution control check out [crossterm::queue](../macro.queue.html).
|
||||
|
||||
use std::{fmt, io};
|
||||
|
||||
#[cfg(windows)]
|
||||
use crossterm_winapi::{ConsoleMode, Handle, ScreenBuffer};
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(windows)]
|
||||
use winapi::um::wincon::ENABLE_WRAP_AT_EOL_OUTPUT;
|
||||
|
||||
#[doc(no_inline)]
|
||||
use crate::Command;
|
||||
use crate::{csi, impl_display};
|
||||
|
||||
pub(crate) mod sys;
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
pub use sys::supports_keyboard_enhancement;
|
||||
|
||||
/// Tells whether the raw mode is enabled.
|
||||
///
|
||||
/// Please have a look at the [raw mode](./index.html#raw-mode) section.
|
||||
pub fn is_raw_mode_enabled() -> io::Result<bool> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(sys::is_raw_mode_enabled())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
sys::is_raw_mode_enabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables raw mode.
|
||||
///
|
||||
/// Please have a look at the [raw mode](./index.html#raw-mode) section.
|
||||
pub fn enable_raw_mode() -> io::Result<()> {
|
||||
sys::enable_raw_mode()
|
||||
}
|
||||
|
||||
/// Disables raw mode.
|
||||
///
|
||||
/// Please have a look at the [raw mode](./index.html#raw-mode) section.
|
||||
pub fn disable_raw_mode() -> io::Result<()> {
|
||||
sys::disable_raw_mode()
|
||||
}
|
||||
|
||||
/// Returns the terminal size `(columns, rows)`.
|
||||
///
|
||||
/// The top left cell is represented `(1, 1)`.
|
||||
pub fn size() -> io::Result<(u16, u16)> {
|
||||
sys::size()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WindowSize {
|
||||
pub rows: u16,
|
||||
pub columns: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
/// Returns the terminal size `[WindowSize]`.
|
||||
///
|
||||
/// The width and height in pixels may not be reliably implemented or default to 0.
|
||||
/// For unix, <https://man7.org/linux/man-pages/man4/tty_ioctl.4.html> documents them as "unused".
|
||||
/// For windows it is not implemented.
|
||||
pub fn window_size() -> io::Result<WindowSize> {
|
||||
sys::window_size()
|
||||
}
|
||||
|
||||
/// Disables line wrapping.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DisableLineWrap;
|
||||
|
||||
impl Command for DisableLineWrap {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?7l"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let console_mode = ConsoleMode::from(screen_buffer.handle().clone());
|
||||
let new_mode = console_mode.mode()? & !ENABLE_WRAP_AT_EOL_OUTPUT;
|
||||
console_mode.set_mode(new_mode)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable line wrapping.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EnableLineWrap;
|
||||
|
||||
impl Command for EnableLineWrap {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?7h"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let console_mode = ConsoleMode::from(screen_buffer.handle().clone());
|
||||
let new_mode = console_mode.mode()? | ENABLE_WRAP_AT_EOL_OUTPUT;
|
||||
console_mode.set_mode(new_mode)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that switches to alternate screen.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
/// * Use [LeaveAlternateScreen](./struct.LeaveAlternateScreen.html) command to leave the entered alternate screen.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::io::{self, Write};
|
||||
/// use crossterm::{execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen}};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// execute!(io::stdout(), EnterAlternateScreen)?;
|
||||
///
|
||||
/// // Do anything on the alternate screen
|
||||
///
|
||||
/// execute!(io::stdout(), LeaveAlternateScreen)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EnterAlternateScreen;
|
||||
|
||||
impl Command for EnterAlternateScreen {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?1049h"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
let alternate_screen = ScreenBuffer::create()?;
|
||||
alternate_screen.show()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that switches back to the main screen.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
/// * Use [EnterAlternateScreen](./struct.EnterAlternateScreen.html) to enter the alternate screen.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::io::{self, Write};
|
||||
/// use crossterm::{execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen}};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// execute!(io::stdout(), EnterAlternateScreen)?;
|
||||
///
|
||||
/// // Do anything on the alternate screen
|
||||
///
|
||||
/// execute!(io::stdout(), LeaveAlternateScreen)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LeaveAlternateScreen;
|
||||
|
||||
impl Command for LeaveAlternateScreen {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?1049l"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
let screen_buffer = ScreenBuffer::from(Handle::current_out_handle()?);
|
||||
screen_buffer.show()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Different ways to clear the terminal buffer.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
pub enum ClearType {
|
||||
/// All cells.
|
||||
All,
|
||||
/// All plus history
|
||||
Purge,
|
||||
/// All cells from the cursor position downwards.
|
||||
FromCursorDown,
|
||||
/// All cells from the cursor position upwards.
|
||||
FromCursorUp,
|
||||
/// All cells at the cursor row.
|
||||
CurrentLine,
|
||||
/// All cells from the cursor position until the new line.
|
||||
UntilNewLine,
|
||||
}
|
||||
|
||||
/// A command that scrolls the terminal screen a given number of rows up.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScrollUp(pub u16);
|
||||
|
||||
impl Command for ScrollUp {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
if self.0 != 0 {
|
||||
write!(f, csi!("{}S"), self.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
sys::scroll_up(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that scrolls the terminal screen a given number of rows down.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScrollDown(pub u16);
|
||||
|
||||
impl Command for ScrollDown {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
if self.0 != 0 {
|
||||
write!(f, csi!("{}T"), self.0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
sys::scroll_down(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that clears the terminal screen buffer.
|
||||
///
|
||||
/// See the [`ClearType`](enum.ClearType.html) enum.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Clear(pub ClearType);
|
||||
|
||||
impl Command for Clear {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(match self.0 {
|
||||
ClearType::All => csi!("2J"),
|
||||
ClearType::Purge => csi!("3J"),
|
||||
ClearType::FromCursorDown => csi!("J"),
|
||||
ClearType::FromCursorUp => csi!("1J"),
|
||||
ClearType::CurrentLine => csi!("2K"),
|
||||
ClearType::UntilNewLine => csi!("K"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
sys::clear(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets the terminal buffer size `(columns, rows)`.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetSize(pub u16, pub u16);
|
||||
|
||||
impl Command for SetSize {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, csi!("8;{};{}t"), self.1, self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
sys::set_size(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that sets the terminal title
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Commands must be executed/queued for execution otherwise they do nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetTitle<T>(pub T);
|
||||
|
||||
impl<T: fmt::Display> Command for SetTitle<T> {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1B]0;{}\x07", &self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
sys::set_window_title(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that instructs the terminal emulator to begin a synchronized frame.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
/// * Use [EndSynchronizedUpdate](./struct.EndSynchronizedUpdate.html) command to leave the entered alternate screen.
|
||||
///
|
||||
/// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and
|
||||
/// renders its current state. With applications updating the screen at a higher frequency this can cause tearing.
|
||||
///
|
||||
/// This mode attempts to mitigate that.
|
||||
///
|
||||
/// When the synchronization mode is enabled following render calls will keep rendering the last rendered state.
|
||||
/// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled
|
||||
/// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect
|
||||
/// by unintentionally rendering in the middle a of an application screen update.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::io::{self, Write};
|
||||
/// use crossterm::{execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// execute!(io::stdout(), BeginSynchronizedUpdate)?;
|
||||
///
|
||||
/// // Anything performed here will not be rendered until EndSynchronizedUpdate is called.
|
||||
///
|
||||
/// execute!(io::stdout(), EndSynchronizedUpdate)?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BeginSynchronizedUpdate;
|
||||
|
||||
impl Command for BeginSynchronizedUpdate {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?2026h"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that instructs the terminal to end a synchronized frame.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// * Commands must be executed/queued for execution otherwise they do nothing.
|
||||
/// * Use [BeginSynchronizedUpdate](./struct.BeginSynchronizedUpdate.html) to enter the alternate screen.
|
||||
///
|
||||
/// When rendering the screen of the terminal, the Emulator usually iterates through each visible grid cell and
|
||||
/// renders its current state. With applications updating the screen a at higher frequency this can cause tearing.
|
||||
///
|
||||
/// This mode attempts to mitigate that.
|
||||
///
|
||||
/// When the synchronization mode is enabled following render calls will keep rendering the last rendered state.
|
||||
/// The terminal Emulator keeps processing incoming text and sequences. When the synchronized update mode is disabled
|
||||
/// again the renderer may fetch the latest screen buffer state again, effectively avoiding the tearing effect
|
||||
/// by unintentionally rendering in the middle a of an application screen update.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::io::{self, Write};
|
||||
/// use crossterm::{execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}};
|
||||
///
|
||||
/// fn main() -> io::Result<()> {
|
||||
/// execute!(io::stdout(), BeginSynchronizedUpdate)?;
|
||||
///
|
||||
/// // Anything performed here will not be rendered until EndSynchronizedUpdate is called.
|
||||
///
|
||||
/// execute!(io::stdout(), EndSynchronizedUpdate)?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EndSynchronizedUpdate;
|
||||
|
||||
impl Command for EndSynchronizedUpdate {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
f.write_str(csi!("?2026l"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl_display!(for ScrollUp);
|
||||
impl_display!(for ScrollDown);
|
||||
impl_display!(for SetSize);
|
||||
impl_display!(for Clear);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{io::stdout, thread, time};
|
||||
|
||||
use crate::execute;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Test is disabled, because it's failing on Travis CI
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_resize_ansi() {
|
||||
let (width, height) = size().unwrap();
|
||||
|
||||
execute!(stdout(), SetSize(35, 35)).unwrap();
|
||||
|
||||
// see issue: https://github.com/eminence/terminal-size/issues/11
|
||||
thread::sleep(time::Duration::from_millis(30));
|
||||
|
||||
assert_eq!((35, 35), size().unwrap());
|
||||
|
||||
// reset to previous size
|
||||
execute!(stdout(), SetSize(width, height)).unwrap();
|
||||
|
||||
// see issue: https://github.com/eminence/terminal-size/issues/11
|
||||
thread::sleep(time::Duration::from_millis(30));
|
||||
|
||||
assert_eq!((width, height), size().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_mode() {
|
||||
// check we start from normal mode (may fail on some test harnesses)
|
||||
assert!(!is_raw_mode_enabled().unwrap());
|
||||
|
||||
// enable the raw mode
|
||||
if enable_raw_mode().is_err() {
|
||||
// Enabling raw mode doesn't work on the ci
|
||||
// So we just ignore it
|
||||
return;
|
||||
}
|
||||
|
||||
// check it worked (on unix it doesn't really check the underlying
|
||||
// tty but rather check that the code is consistent)
|
||||
assert!(is_raw_mode_enabled().unwrap());
|
||||
|
||||
// enable it again, this should not change anything
|
||||
enable_raw_mode().unwrap();
|
||||
|
||||
// check we're still in raw mode
|
||||
assert!(is_raw_mode_enabled().unwrap());
|
||||
|
||||
// now let's disable it
|
||||
disable_raw_mode().unwrap();
|
||||
|
||||
// check we're back to normal mode
|
||||
assert!(!is_raw_mode_enabled().unwrap());
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
//! This module provides platform related functions.
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(feature = "events")]
|
||||
pub use self::unix::supports_keyboard_enhancement;
|
||||
#[cfg(unix)]
|
||||
pub(crate) use self::unix::{
|
||||
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, size, window_size,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
#[cfg(feature = "events")]
|
||||
pub use self::windows::supports_keyboard_enhancement;
|
||||
#[cfg(all(windows, test))]
|
||||
pub(crate) use self::windows::temp_screen_buffer;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use self::windows::{
|
||||
clear, disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, scroll_down, scroll_up,
|
||||
set_size, set_window_title, size, window_size,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod file_descriptor;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::io;
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
use libc::size_t;
|
||||
#[cfg(not(feature = "libc"))]
|
||||
use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd};
|
||||
#[cfg(feature = "libc")]
|
||||
use std::{
|
||||
fs,
|
||||
marker::PhantomData,
|
||||
os::unix::{
|
||||
io::{IntoRawFd, RawFd},
|
||||
prelude::AsRawFd,
|
||||
},
|
||||
};
|
||||
|
||||
/// A file descriptor wrapper.
|
||||
///
|
||||
/// It allows to retrieve raw file descriptor, write to the file descriptor and
|
||||
/// mainly it closes the file descriptor once dropped.
|
||||
#[derive(Debug)]
|
||||
#[cfg(feature = "libc")]
|
||||
pub struct FileDesc<'a> {
|
||||
fd: RawFd,
|
||||
close_on_drop: bool,
|
||||
phantom: PhantomData<&'a ()>,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
pub enum FileDesc<'a> {
|
||||
Owned(OwnedFd),
|
||||
Borrowed(BorrowedFd<'a>),
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
impl FileDesc<'_> {
|
||||
/// Constructs a new `FileDesc` with the given `RawFd`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `fd` - raw file descriptor
|
||||
/// * `close_on_drop` - specify if the raw file descriptor should be closed once the `FileDesc` is dropped
|
||||
pub fn new(fd: RawFd, close_on_drop: bool) -> FileDesc<'static> {
|
||||
FileDesc {
|
||||
fd,
|
||||
close_on_drop,
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let result = unsafe {
|
||||
libc::read(
|
||||
self.fd,
|
||||
buffer.as_mut_ptr() as *mut libc::c_void,
|
||||
buffer.len() as size_t,
|
||||
)
|
||||
};
|
||||
|
||||
if result < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(result as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the underlying file descriptor.
|
||||
pub fn raw_fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
impl FileDesc<'_> {
|
||||
pub fn read(&self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let fd = match self {
|
||||
FileDesc::Owned(fd) => fd.as_fd(),
|
||||
FileDesc::Borrowed(fd) => fd.as_fd(),
|
||||
};
|
||||
let result = rustix::io::read(fd, buffer)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn raw_fd(&self) -> RawFd {
|
||||
match self {
|
||||
FileDesc::Owned(fd) => fd.as_raw_fd(),
|
||||
FileDesc::Borrowed(fd) => fd.as_raw_fd(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
impl Drop for FileDesc<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.close_on_drop {
|
||||
// Note that errors are ignored when closing a file descriptor. The
|
||||
// reason for this is that if an error occurs we don't actually know if
|
||||
// the file descriptor was closed or not, and if we retried (for
|
||||
// something like EINTR), we might close another valid file descriptor
|
||||
// opened after we closed ours.
|
||||
let _ = unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRawFd for FileDesc<'_> {
|
||||
fn as_raw_fd(&self) -> RawFd {
|
||||
self.raw_fd()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
impl AsFd for FileDesc<'_> {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
match self {
|
||||
FileDesc::Owned(fd) => fd.as_fd(),
|
||||
FileDesc::Borrowed(fd) => fd.as_fd(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
/// Creates a file descriptor pointing to the standard input or `/dev/tty`.
|
||||
pub fn tty_fd() -> io::Result<FileDesc<'static>> {
|
||||
let (fd, close_on_drop) = if unsafe { libc::isatty(libc::STDIN_FILENO) == 1 } {
|
||||
(libc::STDIN_FILENO, false)
|
||||
} else {
|
||||
(
|
||||
fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/tty")?
|
||||
.into_raw_fd(),
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(FileDesc::new(fd, close_on_drop))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
/// Creates a file descriptor pointing to the standard input or `/dev/tty`.
|
||||
pub fn tty_fd() -> io::Result<FileDesc<'static>> {
|
||||
use std::fs::File;
|
||||
|
||||
let stdin = rustix::stdio::stdin();
|
||||
let fd = if rustix::termios::isatty(stdin) {
|
||||
FileDesc::Borrowed(stdin)
|
||||
} else {
|
||||
let dev_tty = File::options().read(true).write(true).open("/dev/tty")?;
|
||||
FileDesc::Owned(dev_tty.into())
|
||||
};
|
||||
Ok(fd)
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
//! UNIX related logic for terminal manipulation.
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
use crate::event::KeyboardEnhancementFlags;
|
||||
use crate::terminal::{
|
||||
sys::file_descriptor::{tty_fd, FileDesc},
|
||||
WindowSize,
|
||||
};
|
||||
#[cfg(feature = "libc")]
|
||||
use libc::{
|
||||
cfmakeraw, ioctl, tcgetattr, tcsetattr, termios as Termios, winsize, STDOUT_FILENO, TCSANOW,
|
||||
TIOCGWINSZ,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
#[cfg(not(feature = "libc"))]
|
||||
use rustix::{
|
||||
fd::AsFd,
|
||||
termios::{Termios, Winsize},
|
||||
};
|
||||
|
||||
use std::{fs::File, io, process};
|
||||
#[cfg(feature = "libc")]
|
||||
use std::{
|
||||
mem,
|
||||
os::unix::io::{IntoRawFd, RawFd},
|
||||
};
|
||||
|
||||
// Some(Termios) -> we're in the raw mode and this is the previous mode
|
||||
// None -> we're not in the raw mode
|
||||
static TERMINAL_MODE_PRIOR_RAW_MODE: Mutex<Option<Termios>> = parking_lot::const_mutex(None);
|
||||
|
||||
pub(crate) fn is_raw_mode_enabled() -> bool {
|
||||
TERMINAL_MODE_PRIOR_RAW_MODE.lock().is_some()
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
impl From<winsize> for WindowSize {
|
||||
fn from(size: winsize) -> WindowSize {
|
||||
WindowSize {
|
||||
columns: size.ws_col,
|
||||
rows: size.ws_row,
|
||||
width: size.ws_xpixel,
|
||||
height: size.ws_ypixel,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "libc"))]
|
||||
impl From<Winsize> for WindowSize {
|
||||
fn from(size: Winsize) -> WindowSize {
|
||||
WindowSize {
|
||||
columns: size.ws_col,
|
||||
rows: size.ws_row,
|
||||
width: size.ws_xpixel,
|
||||
height: size.ws_ypixel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::useless_conversion)]
|
||||
#[cfg(feature = "libc")]
|
||||
pub(crate) fn window_size() -> io::Result<WindowSize> {
|
||||
// http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc
|
||||
let mut size = winsize {
|
||||
ws_row: 0,
|
||||
ws_col: 0,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
let file = File::open("/dev/tty").map(|file| (FileDesc::new(file.into_raw_fd(), true)));
|
||||
let fd = if let Ok(file) = &file {
|
||||
file.raw_fd()
|
||||
} else {
|
||||
// Fallback to libc::STDOUT_FILENO if /dev/tty is missing
|
||||
STDOUT_FILENO
|
||||
};
|
||||
|
||||
if wrap_with_result(unsafe { ioctl(fd, TIOCGWINSZ.into(), &mut size) }).is_ok() {
|
||||
return Ok(size.into());
|
||||
}
|
||||
|
||||
Err(std::io::Error::last_os_error().into())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
pub(crate) fn window_size() -> io::Result<WindowSize> {
|
||||
let file = File::open("/dev/tty").map(|file| FileDesc::Owned(file.into()));
|
||||
let fd = if let Ok(file) = &file {
|
||||
file.as_fd()
|
||||
} else {
|
||||
// Fallback to libc::STDOUT_FILENO if /dev/tty is missing
|
||||
rustix::stdio::stdout()
|
||||
};
|
||||
let size = rustix::termios::tcgetwinsize(fd)?;
|
||||
Ok(size.into())
|
||||
}
|
||||
|
||||
#[allow(clippy::useless_conversion)]
|
||||
pub(crate) fn size() -> io::Result<(u16, u16)> {
|
||||
if let Ok(window_size) = window_size() {
|
||||
return Ok((window_size.columns, window_size.rows));
|
||||
}
|
||||
|
||||
tput_size().ok_or_else(|| std::io::Error::last_os_error().into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
pub(crate) fn enable_raw_mode() -> io::Result<()> {
|
||||
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
||||
if original_mode.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tty = tty_fd()?;
|
||||
let fd = tty.raw_fd();
|
||||
let mut ios = get_terminal_attr(fd)?;
|
||||
let original_mode_ios = ios;
|
||||
raw_terminal_attr(&mut ios);
|
||||
set_terminal_attr(fd, &ios)?;
|
||||
// Keep it last - set the original mode only if we were able to switch to the raw mode
|
||||
*original_mode = Some(original_mode_ios);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
pub(crate) fn enable_raw_mode() -> io::Result<()> {
|
||||
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
||||
if original_mode.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tty = tty_fd()?;
|
||||
let mut ios = get_terminal_attr(&tty)?;
|
||||
let original_mode_ios = ios.clone();
|
||||
ios.make_raw();
|
||||
set_terminal_attr(&tty, &ios)?;
|
||||
// Keep it last - set the original mode only if we were able to switch to the raw mode
|
||||
*original_mode = Some(original_mode_ios);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset the raw mode.
|
||||
///
|
||||
/// More precisely, reset the whole termios mode to what it was before the first call
|
||||
/// to [enable_raw_mode]. If you don't mess with termios outside of crossterm, it's
|
||||
/// effectively disabling the raw mode and doing nothing else.
|
||||
#[cfg(feature = "libc")]
|
||||
pub(crate) fn disable_raw_mode() -> io::Result<()> {
|
||||
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
||||
if let Some(original_mode_ios) = original_mode.as_ref() {
|
||||
let tty = tty_fd()?;
|
||||
set_terminal_attr(tty.raw_fd(), original_mode_ios)?;
|
||||
// Keep it last - remove the original mode only if we were able to switch back
|
||||
*original_mode = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
pub(crate) fn disable_raw_mode() -> io::Result<()> {
|
||||
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
||||
if let Some(original_mode_ios) = original_mode.as_ref() {
|
||||
let tty = tty_fd()?;
|
||||
set_terminal_attr(&tty, original_mode_ios)?;
|
||||
// Keep it last - remove the original mode only if we were able to switch back
|
||||
*original_mode = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
fn get_terminal_attr(fd: impl AsFd) -> io::Result<Termios> {
|
||||
let result = rustix::termios::tcgetattr(fd)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "libc"))]
|
||||
fn set_terminal_attr(fd: impl AsFd, termios: &Termios) -> io::Result<()> {
|
||||
rustix::termios::tcsetattr(fd, rustix::termios::OptionalActions::Now, termios)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queries the terminal's support for progressive keyboard enhancement.
|
||||
///
|
||||
/// On unix systems, this function will block and possibly time out while
|
||||
/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called.
|
||||
#[cfg(feature = "events")]
|
||||
pub fn supports_keyboard_enhancement() -> io::Result<bool> {
|
||||
query_keyboard_enhancement_flags().map(|flags| flags.is_some())
|
||||
}
|
||||
|
||||
/// Queries the terminal's currently active keyboard enhancement flags.
|
||||
///
|
||||
/// On unix systems, this function will block and possibly time out while
|
||||
/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called.
|
||||
#[cfg(feature = "events")]
|
||||
pub fn query_keyboard_enhancement_flags() -> io::Result<Option<KeyboardEnhancementFlags>> {
|
||||
if is_raw_mode_enabled() {
|
||||
query_keyboard_enhancement_flags_raw()
|
||||
} else {
|
||||
query_keyboard_enhancement_flags_nonraw()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
fn query_keyboard_enhancement_flags_nonraw() -> io::Result<Option<KeyboardEnhancementFlags>> {
|
||||
enable_raw_mode()?;
|
||||
let flags = query_keyboard_enhancement_flags_raw();
|
||||
disable_raw_mode()?;
|
||||
flags
|
||||
}
|
||||
|
||||
#[cfg(feature = "events")]
|
||||
fn query_keyboard_enhancement_flags_raw() -> io::Result<Option<KeyboardEnhancementFlags>> {
|
||||
use crate::event::{
|
||||
filter::{KeyboardEnhancementFlagsFilter, PrimaryDeviceAttributesFilter},
|
||||
poll_internal, read_internal, InternalEvent,
|
||||
};
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
// This is the recommended method for testing support for the keyboard enhancement protocol.
|
||||
// We send a query for the flags supported by the terminal and then the primary device attributes
|
||||
// query. If we receive the primary device attributes response but not the keyboard enhancement
|
||||
// flags, none of the flags are supported.
|
||||
//
|
||||
// See <https://sw.kovidgoyal.net/kitty/keyboard-protocol/#detection-of-support-for-this-protocol>
|
||||
|
||||
// ESC [ ? u Query progressive keyboard enhancement flags (kitty protocol).
|
||||
// ESC [ c Query primary device attributes.
|
||||
const QUERY: &[u8] = b"\x1B[?u\x1B[c";
|
||||
|
||||
let result = File::open("/dev/tty").and_then(|mut file| {
|
||||
file.write_all(QUERY)?;
|
||||
file.flush()
|
||||
});
|
||||
if result.is_err() {
|
||||
let mut stdout = io::stdout();
|
||||
stdout.write_all(QUERY)?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
loop {
|
||||
match poll_internal(
|
||||
Some(Duration::from_millis(2000)),
|
||||
&KeyboardEnhancementFlagsFilter,
|
||||
) {
|
||||
Ok(true) => {
|
||||
match read_internal(&KeyboardEnhancementFlagsFilter) {
|
||||
Ok(InternalEvent::KeyboardEnhancementFlags(current_flags)) => {
|
||||
// Flush the PrimaryDeviceAttributes out of the event queue.
|
||||
read_internal(&PrimaryDeviceAttributesFilter).ok();
|
||||
return Ok(Some(current_flags));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"The keyboard enhancement status could not be read within a normal duration",
|
||||
));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// execute tput with the given argument and parse
|
||||
/// the output as a u16.
|
||||
///
|
||||
/// The arg should be "cols" or "lines"
|
||||
fn tput_value(arg: &str) -> Option<u16> {
|
||||
let output = process::Command::new("tput").arg(arg).output().ok()?;
|
||||
let value = output
|
||||
.stdout
|
||||
.into_iter()
|
||||
.filter_map(|b| char::from(b).to_digit(10))
|
||||
.fold(0, |v, n| v * 10 + n as u16);
|
||||
|
||||
if value > 0 {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the size of the screen as determined by tput.
|
||||
///
|
||||
/// This alternate way of computing the size is useful
|
||||
/// when in a subshell.
|
||||
fn tput_size() -> Option<(u16, u16)> {
|
||||
match (tput_value("cols"), tput_value("lines")) {
|
||||
(Some(w), Some(h)) => Some((w, h)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
// Transform the given mode into an raw mode (non-canonical) mode.
|
||||
fn raw_terminal_attr(termios: &mut Termios) {
|
||||
unsafe { cfmakeraw(termios) }
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
fn get_terminal_attr(fd: RawFd) -> io::Result<Termios> {
|
||||
unsafe {
|
||||
let mut termios = mem::zeroed();
|
||||
wrap_with_result(tcgetattr(fd, &mut termios))?;
|
||||
Ok(termios)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
fn set_terminal_attr(fd: RawFd, termios: &Termios) -> io::Result<()> {
|
||||
wrap_with_result(unsafe { tcsetattr(fd, TCSANOW, termios) })
|
||||
}
|
||||
|
||||
#[cfg(feature = "libc")]
|
||||
fn wrap_with_result(result: i32) -> io::Result<()> {
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
//! WinAPI related logic for terminal manipulation.
|
||||
|
||||
use std::fmt::{self, Write};
|
||||
use std::io::{self};
|
||||
|
||||
use crossterm_winapi::{Console, ConsoleMode, Coord, Handle, ScreenBuffer, Size};
|
||||
use winapi::{
|
||||
shared::minwindef::DWORD,
|
||||
um::wincon::{SetConsoleTitleW, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cursor,
|
||||
terminal::{ClearType, WindowSize},
|
||||
};
|
||||
|
||||
/// bits which can't be set in raw mode
|
||||
const NOT_RAW_MODE_MASK: DWORD = ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT;
|
||||
|
||||
pub(crate) fn is_raw_mode_enabled() -> std::io::Result<bool> {
|
||||
let console_mode = ConsoleMode::from(Handle::current_in_handle()?);
|
||||
|
||||
let dw_mode = console_mode.mode()?;
|
||||
|
||||
Ok(
|
||||
// check none of the "not raw" bits is set
|
||||
dw_mode & NOT_RAW_MODE_MASK == 0,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn enable_raw_mode() -> std::io::Result<()> {
|
||||
let console_mode = ConsoleMode::from(Handle::current_in_handle()?);
|
||||
|
||||
let dw_mode = console_mode.mode()?;
|
||||
|
||||
let new_mode = dw_mode & !NOT_RAW_MODE_MASK;
|
||||
|
||||
console_mode.set_mode(new_mode)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn disable_raw_mode() -> std::io::Result<()> {
|
||||
let console_mode = ConsoleMode::from(Handle::current_in_handle()?);
|
||||
|
||||
let dw_mode = console_mode.mode()?;
|
||||
|
||||
let new_mode = dw_mode | NOT_RAW_MODE_MASK;
|
||||
|
||||
console_mode.set_mode(new_mode)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn size() -> io::Result<(u16, u16)> {
|
||||
let terminal_size = ScreenBuffer::current()?.info()?.terminal_size();
|
||||
// windows starts counting at 0, unix at 1, add one to replicated unix behaviour.
|
||||
Ok((
|
||||
(terminal_size.width + 1) as u16,
|
||||
(terminal_size.height + 1) as u16,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn window_size() -> io::Result<WindowSize> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Window pixel size not implemented for the Windows API.",
|
||||
))
|
||||
}
|
||||
|
||||
/// Queries the terminal's support for progressive keyboard enhancement.
|
||||
///
|
||||
/// This always returns `Ok(false)` on Windows.
|
||||
#[cfg(feature = "events")]
|
||||
pub fn supports_keyboard_enhancement() -> std::io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(clear_type: ClearType) -> std::io::Result<()> {
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let csbi = screen_buffer.info()?;
|
||||
|
||||
let pos = csbi.cursor_pos();
|
||||
let buffer_size = csbi.buffer_size();
|
||||
let current_attribute = csbi.attributes();
|
||||
|
||||
match clear_type {
|
||||
ClearType::All => {
|
||||
clear_entire_screen(buffer_size, current_attribute)?;
|
||||
}
|
||||
ClearType::FromCursorDown => clear_after_cursor(pos, buffer_size, current_attribute)?,
|
||||
ClearType::FromCursorUp => clear_before_cursor(pos, buffer_size, current_attribute)?,
|
||||
ClearType::CurrentLine => clear_current_line(pos, buffer_size, current_attribute)?,
|
||||
ClearType::UntilNewLine => clear_until_line(pos, buffer_size, current_attribute)?,
|
||||
_ => {
|
||||
clear_entire_screen(buffer_size, current_attribute)?;
|
||||
} //TODO: make purge flush the entire screen buffer not just the visible window.
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_up(row_count: u16) -> std::io::Result<()> {
|
||||
let csbi = ScreenBuffer::current()?;
|
||||
let mut window = csbi.info()?.terminal_window();
|
||||
|
||||
// check whether the window is too close to the screen buffer top
|
||||
let count = row_count as i16;
|
||||
if window.top >= count {
|
||||
window.top -= count; // move top down
|
||||
window.bottom -= count; // move bottom down
|
||||
|
||||
Console::output()?.set_console_info(true, window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_down(row_count: u16) -> std::io::Result<()> {
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let csbi = screen_buffer.info()?;
|
||||
let mut window = csbi.terminal_window();
|
||||
let buffer_size = csbi.buffer_size();
|
||||
|
||||
// check whether the window is too close to the screen buffer top
|
||||
let count = row_count as i16;
|
||||
if window.bottom < buffer_size.height - count {
|
||||
window.top += count; // move top down
|
||||
window.bottom += count; // move bottom down
|
||||
|
||||
Console::output()?.set_console_info(true, window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_size(width: u16, height: u16) -> std::io::Result<()> {
|
||||
if width <= 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"terminal width must be at least 1",
|
||||
));
|
||||
}
|
||||
|
||||
if height <= 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"terminal height must be at least 1",
|
||||
));
|
||||
}
|
||||
|
||||
// get the position of the current console window
|
||||
let screen_buffer = ScreenBuffer::current()?;
|
||||
let console = Console::from(screen_buffer.handle().clone());
|
||||
let csbi = screen_buffer.info()?;
|
||||
|
||||
let current_size = csbi.buffer_size();
|
||||
let window = csbi.terminal_window();
|
||||
|
||||
let mut new_size = Size::new(current_size.width, current_size.height);
|
||||
|
||||
// If the buffer is smaller than this new window size, resize the
|
||||
// buffer to be large enough. Include window position.
|
||||
let mut resize_buffer = false;
|
||||
|
||||
let width = width as i16;
|
||||
if current_size.width < window.left + width {
|
||||
if window.left >= i16::MAX - width {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"terminal width too large",
|
||||
));
|
||||
}
|
||||
|
||||
new_size.width = window.left + width;
|
||||
resize_buffer = true;
|
||||
}
|
||||
let height = height as i16;
|
||||
if current_size.height < window.top + height {
|
||||
if window.top >= i16::MAX - height {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"terminal height too large",
|
||||
));
|
||||
}
|
||||
|
||||
new_size.height = window.top + height;
|
||||
resize_buffer = true;
|
||||
}
|
||||
|
||||
if resize_buffer {
|
||||
screen_buffer.set_size(new_size.width - 1, new_size.height - 1)?;
|
||||
}
|
||||
|
||||
let mut window = window;
|
||||
|
||||
// preserve the position, but change the size.
|
||||
window.bottom = window.top + height - 1;
|
||||
window.right = window.left + width - 1;
|
||||
console.set_console_info(true, window)?;
|
||||
|
||||
// if we resized the buffer, un-resize it.
|
||||
if resize_buffer {
|
||||
screen_buffer.set_size(current_size.width - 1, current_size.height - 1)?;
|
||||
}
|
||||
|
||||
let bounds = console.largest_window_size()?;
|
||||
|
||||
if width > bounds.x {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("terminal width {width} too large"),
|
||||
));
|
||||
}
|
||||
if height > bounds.y {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("terminal height {height} too large"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_window_title(title: impl fmt::Display) -> std::io::Result<()> {
|
||||
struct Utf16Encoder(Vec<u16>);
|
||||
impl Write for Utf16Encoder {
|
||||
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||
self.0.extend(s.encode_utf16());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut title_utf16 = Utf16Encoder(Vec::new());
|
||||
write!(title_utf16, "{title}").expect("formatting failed");
|
||||
title_utf16.0.push(0);
|
||||
let title = title_utf16.0;
|
||||
|
||||
let result = unsafe { SetConsoleTitleW(title.as_ptr()) };
|
||||
if result != 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_after_cursor(
|
||||
location: Coord,
|
||||
buffer_size: Size,
|
||||
current_attribute: u16,
|
||||
) -> std::io::Result<()> {
|
||||
let (mut x, mut y) = (location.x, location.y);
|
||||
|
||||
// if cursor position is at the outer right position
|
||||
if x > buffer_size.width {
|
||||
y += 1;
|
||||
x = 0;
|
||||
}
|
||||
|
||||
// location where to start clearing
|
||||
let start_location = Coord::new(x, y);
|
||||
|
||||
// get sum cells before cursor
|
||||
let cells_to_write = buffer_size.width as u32 * buffer_size.height as u32;
|
||||
|
||||
clear_winapi(start_location, cells_to_write, current_attribute)
|
||||
}
|
||||
|
||||
fn clear_before_cursor(
|
||||
location: Coord,
|
||||
buffer_size: Size,
|
||||
current_attribute: u16,
|
||||
) -> std::io::Result<()> {
|
||||
let (xpos, ypos) = (location.x, location.y);
|
||||
|
||||
// one cell after cursor position
|
||||
let x = 0;
|
||||
// one at row of cursor position
|
||||
let y = 0;
|
||||
|
||||
// location where to start clearing
|
||||
let start_location = Coord::new(x, y);
|
||||
|
||||
// get sum cells before cursor
|
||||
let cells_to_write = (buffer_size.width as u32 * ypos as u32) + (xpos as u32 + 1);
|
||||
|
||||
// clear everything before cursor position
|
||||
clear_winapi(start_location, cells_to_write, current_attribute)
|
||||
}
|
||||
|
||||
fn clear_entire_screen(buffer_size: Size, current_attribute: u16) -> std::io::Result<()> {
|
||||
// get sum cells before cursor
|
||||
let cells_to_write = buffer_size.width as u32 * buffer_size.height as u32;
|
||||
|
||||
// location where to start clearing
|
||||
let start_location = Coord::new(0, 0);
|
||||
|
||||
// clear the entire screen
|
||||
clear_winapi(start_location, cells_to_write, current_attribute)?;
|
||||
|
||||
// put the cursor back at cell 0,0
|
||||
cursor::sys::move_to(0, 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_current_line(
|
||||
location: Coord,
|
||||
buffer_size: Size,
|
||||
current_attribute: u16,
|
||||
) -> std::io::Result<()> {
|
||||
// location where to start clearing
|
||||
let start_location = Coord::new(0, location.y);
|
||||
|
||||
// get sum cells before cursor
|
||||
let cells_to_write = buffer_size.width as u32;
|
||||
|
||||
// clear the whole current line
|
||||
clear_winapi(start_location, cells_to_write, current_attribute)?;
|
||||
|
||||
// put the cursor back at cell 1 on current row
|
||||
cursor::sys::move_to(0, location.y as u16)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_until_line(
|
||||
location: Coord,
|
||||
buffer_size: Size,
|
||||
current_attribute: u16,
|
||||
) -> std::io::Result<()> {
|
||||
let (x, y) = (location.x, location.y);
|
||||
|
||||
// location where to start clearing
|
||||
let start_location = Coord::new(x, y);
|
||||
|
||||
// get sum cells before cursor
|
||||
let cells_to_write = (buffer_size.width - x) as u32;
|
||||
|
||||
// clear until the current line
|
||||
clear_winapi(start_location, cells_to_write, current_attribute)?;
|
||||
|
||||
// put the cursor back at original cursor position before we did the clearing
|
||||
cursor::sys::move_to(x as u16, y as u16)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_winapi(
|
||||
start_location: Coord,
|
||||
cells_to_write: u32,
|
||||
current_attribute: u16,
|
||||
) -> std::io::Result<()> {
|
||||
let console = Console::from(Handle::current_out_handle()?);
|
||||
console.fill_whit_character(start_location, cells_to_write, ' ')?;
|
||||
console.fill_whit_attribute(start_location, cells_to_write, current_attribute)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// Create a new screen buffer to avoid changing the terminal the test
|
||||
// is running within.
|
||||
pub fn temp_screen_buffer() -> std::io::Result<ScreenBuffer> {
|
||||
let alternate_screen = ScreenBuffer::create()?;
|
||||
alternate_screen.show().unwrap();
|
||||
Ok(alternate_screen)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
||||
|
||||
use crossterm_winapi::ScreenBuffer;
|
||||
use serial_test::serial;
|
||||
use winapi::um::wincon::GetConsoleTitleW;
|
||||
|
||||
use super::{scroll_down, scroll_up, set_size, set_window_title, size, temp_screen_buffer};
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_resize_winapi_20_21() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let (width, height) = size().unwrap();
|
||||
|
||||
// The values 20 and 21 are arbitrary and different from each other
|
||||
// just to see they're not crossed over.
|
||||
set_size(20, 21).unwrap();
|
||||
assert_eq!((20, 21), size().unwrap());
|
||||
|
||||
// reset to previous size
|
||||
set_size(width, height).unwrap();
|
||||
assert_eq!((width, height), size().unwrap());
|
||||
}
|
||||
|
||||
// This is similar to test_resize_winapi_20_21() above. This verifies that
|
||||
// another test of similar functionality runs independently (that a testing
|
||||
// race condition has been addressed).
|
||||
#[test]
|
||||
#[serial]
|
||||
#[ignore]
|
||||
fn test_resize_winapi_30_31() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let (width, height) = size().unwrap();
|
||||
|
||||
set_size(30, 31).unwrap();
|
||||
assert_eq!((30, 31), size().unwrap());
|
||||
|
||||
// reset to previous size
|
||||
set_size(width, height).unwrap();
|
||||
assert_eq!((width, height), size().unwrap());
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis CI
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_scroll_down_winapi() {
|
||||
let current_window = ScreenBuffer::current()
|
||||
.unwrap()
|
||||
.info()
|
||||
.unwrap()
|
||||
.terminal_window();
|
||||
|
||||
scroll_down(2).unwrap();
|
||||
|
||||
let new_window = ScreenBuffer::current()
|
||||
.unwrap()
|
||||
.info()
|
||||
.unwrap()
|
||||
.terminal_window();
|
||||
|
||||
assert_eq!(new_window.top, current_window.top + 2);
|
||||
assert_eq!(new_window.bottom, current_window.bottom + 2);
|
||||
}
|
||||
|
||||
// Test is disabled, because it's failing on Travis CI
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_scroll_up_winapi() {
|
||||
// move the terminal buffer down before moving it up
|
||||
test_scroll_down_winapi();
|
||||
|
||||
let current_window = ScreenBuffer::current()
|
||||
.unwrap()
|
||||
.info()
|
||||
.unwrap()
|
||||
.terminal_window();
|
||||
|
||||
scroll_up(2).unwrap();
|
||||
|
||||
let new_window = ScreenBuffer::current()
|
||||
.unwrap()
|
||||
.info()
|
||||
.unwrap()
|
||||
.terminal_window();
|
||||
|
||||
assert_eq!(new_window.top, current_window.top - 2);
|
||||
assert_eq!(new_window.bottom, current_window.bottom - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_set_title_winapi() {
|
||||
let _test_screen = temp_screen_buffer().unwrap();
|
||||
|
||||
let test_title = "this is a crossterm test title";
|
||||
set_window_title(test_title).unwrap();
|
||||
|
||||
let mut raw = [0_u16; 128];
|
||||
let length = unsafe { GetConsoleTitleW(raw.as_mut_ptr(), raw.len() as u32) } as usize;
|
||||
assert_ne!(0, length);
|
||||
|
||||
let console_title = OsString::from_wide(&raw[..length]).into_string().unwrap();
|
||||
assert_eq!(test_title, &console_title[..]);
|
||||
}
|
||||
}
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
//! Making it a little more convenient and safe to query whether
|
||||
//! something is a terminal teletype or not.
|
||||
//! This module defines the IsTty trait and the is_tty method to
|
||||
//! return true if the item represents a terminal.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::io::AsRawFd;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
|
||||
#[cfg(windows)]
|
||||
use winapi::um::consoleapi::GetConsoleMode;
|
||||
|
||||
/// Adds the `is_tty` method to types that might represent a terminal
|
||||
///
|
||||
/// ```rust
|
||||
/// use std::io::stdout;
|
||||
/// use crossterm::tty::IsTty;
|
||||
///
|
||||
/// let is_tty: bool = stdout().is_tty();
|
||||
/// ```
|
||||
pub trait IsTty {
|
||||
/// Returns true when an instance is a terminal teletype, otherwise false.
|
||||
fn is_tty(&self) -> bool;
|
||||
}
|
||||
|
||||
/// On UNIX, the `isatty()` function returns true if a file
|
||||
/// descriptor is a terminal.
|
||||
#[cfg(all(unix, feature = "libc"))]
|
||||
impl<S: AsRawFd> IsTty for S {
|
||||
fn is_tty(&self) -> bool {
|
||||
let fd = self.as_raw_fd();
|
||||
unsafe { libc::isatty(fd) == 1 }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(feature = "libc")))]
|
||||
impl<S: AsRawFd> IsTty for S {
|
||||
fn is_tty(&self) -> bool {
|
||||
let fd = self.as_raw_fd();
|
||||
rustix::termios::isatty(unsafe { std::os::unix::io::BorrowedFd::borrow_raw(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
/// On windows, `GetConsoleMode` will return true if we are in a terminal.
|
||||
/// Otherwise false.
|
||||
#[cfg(windows)]
|
||||
impl<S: AsRawHandle> IsTty for S {
|
||||
fn is_tty(&self) -> bool {
|
||||
let mut mode = 0;
|
||||
let ok = unsafe { GetConsoleMode(self.as_raw_handle() as *mut _, &mut mode) };
|
||||
ok == 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user