feat: terminal vertical slice — nebula_terminal rendered as GPUI element
ConPTY + EventLoop wired through a futures channel into a GPUI entity; custom Element paints the grid from a single-lock snapshot (bg runs, force_width text segments for CJK alignment, cursor, selection); view handles keyboard encoding, mouse selection, wheel scroll, clipboard, and IME via EntityInputHandler. No fork of either side; boundary stays symmetric (no GPUI types in nebula_terminal, no terminal types outside src/terminal).
This commit is contained in:
1 parent
9863cff05b
commit
433cf63571
11 files changed
+1519
-50
No files matched your search
@@ -69,3 +69,27 @@ build path unchanged.
|
||||
This boundary does not prevent future reuse of pure Rust core crates, for
|
||||
example `nebula_terminal`; it prevents GUI native dependencies from being
|
||||
resolved together before their ownership and rendering boundaries are ready.
|
||||
|
||||
## Terminal Vertical Slice
|
||||
|
||||
`src/terminal/` hosts the terminal integration and proves the hardest
|
||||
migration claim: `nebula_terminal` (PTY, VT parser, grid, selection) renders
|
||||
inside GPUI as a custom `Element` without forking either side.
|
||||
|
||||
- `session.rs` — owns the ConPTY + `EventLoop` wiring. Terminal events cross
|
||||
into GPUI through one futures channel; the PTY I/O thread never touches
|
||||
GPUI types.
|
||||
- `element.rs` — the paint hot path. One `FairMutex` lock per frame builds a
|
||||
plain-data snapshot (background runs, styled text segments, cursor,
|
||||
selection), then paints with `paint_quad` + `shape_line(force_width)` so
|
||||
CJK wide cells stay grid-aligned.
|
||||
- `view.rs` — focus, keyboard, mouse selection, wheel scrolling, clipboard,
|
||||
and IME via `EntityInputHandler` (marked text drawn at the cursor cell,
|
||||
candidate window anchored through `bounds_for_range`).
|
||||
- `keymap.rs` — encodes only control/modified keys; printable text (including
|
||||
IME commits) flows through the text-input path so nothing is sent twice.
|
||||
- `colors.rs` — resolves vte colors against `Term::colors()` overrides, then
|
||||
a default palette.
|
||||
|
||||
The boundary rule stays symmetric: no `nebula_terminal` type escapes
|
||||
`src/terminal/`, and no GPUI type enters `nebula_terminal`.
|
||||
Generated
+144
-49
@@ -174,7 +174,7 @@ dependencies = [
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols 0.32.13",
|
||||
"wayland-protocols 0.32.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -405,13 +405,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -552,6 +552,9 @@ name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
@@ -867,9 +870,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
@@ -1283,6 +1286,12 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -1400,9 +1409,9 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
@@ -1421,9 +1430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
@@ -1834,13 +1843,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-macros"
|
||||
version = "0.2.4"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
||||
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2937,9 +2946,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -3471,6 +3480,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miow"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -3519,9 +3537,34 @@ dependencies = [
|
||||
name = "nebula-gpui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"gpui-component-assets",
|
||||
"nebula_terminal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nebula_terminal"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.13.1",
|
||||
"home",
|
||||
"libc",
|
||||
"log",
|
||||
"miow",
|
||||
"parking_lot",
|
||||
"piper",
|
||||
"polling",
|
||||
"regex-automata",
|
||||
"rustix 1.1.4",
|
||||
"rustix-openpty",
|
||||
"serde",
|
||||
"signal-hook",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3769,9 +3812,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
@@ -4223,11 +4266,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4334,6 +4377,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -4904,6 +4956,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix-openpty"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
@@ -5117,9 +5180,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
|
||||
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
@@ -5308,12 +5371,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
name = "signal-hook"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -6036,6 +6108,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -6061,14 +6142,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6232,13 +6313,13 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6475,6 +6556,20 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 2.13.1",
|
||||
"cursor-icon",
|
||||
"log",
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
@@ -6585,9 +6680,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.16"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
@@ -6599,9 +6694,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"rustix 1.1.4",
|
||||
@@ -6611,9 +6706,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-cursor"
|
||||
version = "0.31.14"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
|
||||
checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"wayland-client",
|
||||
@@ -6634,9 +6729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"wayland-backend",
|
||||
@@ -6659,20 +6754,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.37.5",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
@@ -7334,15 +7429,15 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.11"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
|
||||
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
||||
|
||||
[[package]]
|
||||
name = "xim-ctext"
|
||||
|
||||
@@ -15,6 +15,10 @@ path = "src/main.rs"
|
||||
gpui = "=0.2.2"
|
||||
gpui-component = "=0.5.1"
|
||||
gpui-component-assets = "=0.5.1"
|
||||
# 终端核心是纯 Rust(vte/grid/ConPTY),没有 GUI 原生依赖,因此可以跨 workspace
|
||||
# 边界直接 path 引用而不会重新引入 fontconfig links 冲突。
|
||||
nebula_terminal = { path = "../nebula_terminal" }
|
||||
futures = "0.3"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod terminal;
|
||||
mod ui;
|
||||
mod views;
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 终端调色板:把 vte 的 `Color` 解析成 GPUI 的 `Rgba`。
|
||||
//!
|
||||
//! 解析顺序与 nebula_app 一致:OSC 4/10/11 的运行时覆盖(`Term::colors()`)
|
||||
//! 优先,其次是这里的默认调色板。
|
||||
|
||||
use gpui::Rgba;
|
||||
use nebula_terminal::term::color::Colors;
|
||||
use nebula_terminal::vte::ansi::{Color, NamedColor, Rgb};
|
||||
|
||||
/// 默认前景色(暗色主题下的正文灰)。
|
||||
pub const FOREGROUND: Rgba = rgb8(0xd6, 0xda, 0xe2);
|
||||
/// 默认背景色,与 `ui::theme` 的窗口背景同族但独立,避免主题层反向依赖终端。
|
||||
pub const BACKGROUND: Rgba = rgb8(0x11, 0x14, 0x1a);
|
||||
/// 光标颜色。
|
||||
pub const CURSOR: Rgba = rgb8(0x4f, 0xd6, 0x9c);
|
||||
/// 选区背景色。
|
||||
pub const SELECTION: Rgba = Rgba { r: 0.24, g: 0.45, b: 0.38, a: 0.55 };
|
||||
|
||||
const fn rgb8(r: u8, g: u8, b: u8) -> Rgba {
|
||||
Rgba { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 }
|
||||
}
|
||||
|
||||
/// ANSI 0-15 的默认值(VS Code 终端默认配色,可读性经过广泛验证)。
|
||||
const ANSI16: [Rgba; 16] = [
|
||||
rgb8(0x1e, 0x22, 0x28), // black
|
||||
rgb8(0xcd, 0x31, 0x31), // red
|
||||
rgb8(0x0d, 0xbc, 0x79), // green
|
||||
rgb8(0xe5, 0xe5, 0x10), // yellow
|
||||
rgb8(0x24, 0x72, 0xc8), // blue
|
||||
rgb8(0xbc, 0x3f, 0xbc), // magenta
|
||||
rgb8(0x11, 0xa8, 0xcd), // cyan
|
||||
rgb8(0xe5, 0xe5, 0xe5), // white
|
||||
rgb8(0x66, 0x66, 0x66), // bright black
|
||||
rgb8(0xf1, 0x4c, 0x4c), // bright red
|
||||
rgb8(0x23, 0xd1, 0x8b), // bright green
|
||||
rgb8(0xf5, 0xf5, 0x43), // bright yellow
|
||||
rgb8(0x3b, 0x8e, 0xea), // bright blue
|
||||
rgb8(0xd6, 0x70, 0xd6), // bright magenta
|
||||
rgb8(0x29, 0xb8, 0xdb), // bright cyan
|
||||
rgb8(0xff, 0xff, 0xff), // bright white
|
||||
];
|
||||
|
||||
pub fn from_ansi_rgb(rgb: Rgb) -> Rgba {
|
||||
rgb8(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
fn dim(color: Rgba) -> Rgba {
|
||||
Rgba { r: color.r * 0.66, g: color.g * 0.66, b: color.b * 0.66, a: color.a }
|
||||
}
|
||||
|
||||
fn indexed_default(index: u8) -> Rgba {
|
||||
match index {
|
||||
0..=15 => ANSI16[index as usize],
|
||||
16..=231 => {
|
||||
let idx = index as u16 - 16;
|
||||
let comp = |c: u16| -> u8 { if c == 0 { 0 } else { (c * 40 + 55) as u8 } };
|
||||
rgb8(comp(idx / 36), comp(idx / 6 % 6), comp(idx % 6))
|
||||
},
|
||||
232..=255 => {
|
||||
let v = 8 + 10 * (index - 232);
|
||||
rgb8(v, v, v)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn named_default(named: NamedColor) -> Rgba {
|
||||
use NamedColor::*;
|
||||
match named {
|
||||
Foreground | BrightForeground => FOREGROUND,
|
||||
Background => BACKGROUND,
|
||||
Cursor => CURSOR,
|
||||
DimForeground => dim(FOREGROUND),
|
||||
DimBlack | DimRed | DimGreen | DimYellow | DimBlue | DimMagenta | DimCyan | DimWhite => {
|
||||
dim(ANSI16[named as usize - NamedColor::DimBlack as usize])
|
||||
},
|
||||
// 0-15 直接落在 ANSI16 表内。
|
||||
other => ANSI16[(other as usize).min(15)],
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析单元格颜色。`bold` 让 0-7 号命名色提亮为 8-15(经典终端行为)。
|
||||
pub fn resolve(color: Color, overrides: &Colors, bold: bool) -> Rgba {
|
||||
match color {
|
||||
Color::Spec(rgb) => from_ansi_rgb(rgb),
|
||||
Color::Indexed(index) => {
|
||||
let index = if bold && index < 8 { index + 8 } else { index };
|
||||
overrides[index as usize].map(from_ansi_rgb).unwrap_or_else(|| indexed_default(index))
|
||||
},
|
||||
Color::Named(named) => {
|
||||
let named = if bold && (named as usize) < 8 {
|
||||
// SAFETY-free 提亮:0-7 与 8-15 一一对应。
|
||||
match named {
|
||||
NamedColor::Black => NamedColor::BrightBlack,
|
||||
NamedColor::Red => NamedColor::BrightRed,
|
||||
NamedColor::Green => NamedColor::BrightGreen,
|
||||
NamedColor::Yellow => NamedColor::BrightYellow,
|
||||
NamedColor::Blue => NamedColor::BrightBlue,
|
||||
NamedColor::Magenta => NamedColor::BrightMagenta,
|
||||
NamedColor::Cyan => NamedColor::BrightCyan,
|
||||
NamedColor::White => NamedColor::BrightWhite,
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
named
|
||||
};
|
||||
overrides[named].map(from_ansi_rgb).unwrap_or_else(|| named_default(named))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 4/10/11 颜色查询(`Event::ColorRequest`)的回答值。
|
||||
pub fn query_reply(index: usize, overrides: &Colors) -> Rgb {
|
||||
if let Some(rgb) = overrides[index] {
|
||||
return rgb;
|
||||
}
|
||||
let rgba = match index {
|
||||
0..=255 => indexed_default(index as u8),
|
||||
_ => match index_to_named(index) {
|
||||
Some(named) => named_default(named),
|
||||
None => FOREGROUND,
|
||||
},
|
||||
};
|
||||
Rgb { r: (rgba.r * 255.0) as u8, g: (rgba.g * 255.0) as u8, b: (rgba.b * 255.0) as u8 }
|
||||
}
|
||||
|
||||
fn index_to_named(index: usize) -> Option<NamedColor> {
|
||||
use NamedColor::*;
|
||||
// Colors 的 256..268 段与 NamedColor 的显式判别值对齐。
|
||||
[
|
||||
Foreground,
|
||||
Background,
|
||||
Cursor,
|
||||
DimBlack,
|
||||
DimRed,
|
||||
DimGreen,
|
||||
DimYellow,
|
||||
DimBlue,
|
||||
DimMagenta,
|
||||
DimCyan,
|
||||
DimWhite,
|
||||
BrightForeground,
|
||||
DimForeground,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|n| *n as usize == index)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! 终端网格的自定义 GPUI Element。
|
||||
//!
|
||||
//! 热路径约定:paint 内只对 `Term` 加一次锁,把可见网格快照成纯数据后立刻放
|
||||
//! 锁,再用快照分三层绘制(背景块 → 选区/光标底 → 文字段),文字按
|
||||
//! `force_width` 锁定单元格步进,保证 CJK 宽字符与网格对齐。
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, HitboxBehavior, Hsla, InspectorElementId,
|
||||
LayoutId, Pixels, Rgba, SharedString, Style, TextRun, UnderlineStyle, Window, fill, outline,
|
||||
point, px, relative, size,
|
||||
};
|
||||
use gpui_component::PixelsExt as _;
|
||||
use nebula_terminal::index::Point as TermPoint;
|
||||
use nebula_terminal::term::cell::Flags;
|
||||
use nebula_terminal::term::point_to_viewport;
|
||||
use nebula_terminal::vte::ansi::CursorShape;
|
||||
|
||||
use super::colors;
|
||||
use super::view::TerminalView;
|
||||
|
||||
pub struct TerminalElement {
|
||||
view: gpui::Entity<TerminalView>,
|
||||
}
|
||||
|
||||
impl TerminalElement {
|
||||
pub fn new(view: gpui::Entity<TerminalView>) -> Self {
|
||||
Self { view }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TermLayout {
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
struct CellSnap {
|
||||
col: usize,
|
||||
text: String,
|
||||
fg: Hsla,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
struct BgRun {
|
||||
row: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
color: Rgba,
|
||||
}
|
||||
|
||||
struct CursorSnap {
|
||||
row: usize,
|
||||
col: usize,
|
||||
shape: CursorShape,
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
struct Snapshot {
|
||||
rows: Vec<Vec<CellSnap>>,
|
||||
bgs: Vec<BgRun>,
|
||||
selection: Vec<BgRun>,
|
||||
cursor: Option<CursorSnap>,
|
||||
}
|
||||
|
||||
fn rgba_eq(a: Rgba, b: Rgba) -> bool {
|
||||
a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a
|
||||
}
|
||||
|
||||
impl TerminalElement {
|
||||
/// 在一次锁内把可见内容快照成绘制数据。
|
||||
fn snapshot(&self, rows: usize, cols: usize, focused: bool, cx: &App) -> Option<Snapshot> {
|
||||
let view = self.view.read(cx);
|
||||
let session = view.session.as_ref()?;
|
||||
let term = session.term.lock();
|
||||
let content = term.renderable_content();
|
||||
let display_offset = content.display_offset;
|
||||
let palette = content.colors;
|
||||
let selection_range = content.selection;
|
||||
|
||||
let cursor_vp = point_to_viewport(display_offset, content.cursor.point);
|
||||
let cursor_shape = content.cursor.shape;
|
||||
|
||||
let mut snap = Snapshot {
|
||||
rows: (0..rows).map(|_| Vec::new()).collect(),
|
||||
bgs: Vec::new(),
|
||||
selection: Vec::new(),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
let push_bg = |bgs: &mut Vec<BgRun>, row: usize, col: usize, color: Rgba| {
|
||||
if let Some(last) = bgs.last_mut() {
|
||||
if last.row == row && last.end == col && rgba_eq(last.color, color) {
|
||||
last.end = col + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
bgs.push(BgRun { row, start: col, end: col + 1, color });
|
||||
};
|
||||
|
||||
for indexed in content.display_iter {
|
||||
let Some(vp) = point_to_viewport(display_offset, indexed.point) else { continue };
|
||||
let (row, col) = (vp.line, vp.column.0);
|
||||
if row >= rows || col >= cols {
|
||||
continue;
|
||||
}
|
||||
let flags = indexed.cell.flags;
|
||||
let bold = flags.intersects(Flags::BOLD);
|
||||
let mut fg = colors::resolve(indexed.cell.fg, palette, bold);
|
||||
let mut bg = colors::resolve(indexed.cell.bg, palette, false);
|
||||
if flags.contains(Flags::INVERSE) {
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
|
||||
if !rgba_eq(bg, colors::BACKGROUND) {
|
||||
push_bg(&mut snap.bgs, row, col, bg);
|
||||
}
|
||||
if selection_range.is_some_and(|range| range.contains(indexed.point)) {
|
||||
push_bg(&mut snap.selection, row, col, colors::SELECTION);
|
||||
}
|
||||
|
||||
if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
|| flags.contains(Flags::HIDDEN)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_cursor_cell = focused
|
||||
&& matches!(cursor_shape, CursorShape::Block)
|
||||
&& cursor_vp.is_some_and(|c| c.line == row && c.column.0 == col);
|
||||
|
||||
let c = indexed.cell.c;
|
||||
if c == ' '
|
||||
&& indexed.cell.extra.is_none()
|
||||
&& !flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
&& !is_cursor_cell
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut text = String::new();
|
||||
text.push(c);
|
||||
if let Some(zerowidth) = indexed.cell.zerowidth() {
|
||||
text.extend(zerowidth);
|
||||
}
|
||||
|
||||
snap.rows[row].push(CellSnap {
|
||||
col,
|
||||
text,
|
||||
fg: if is_cursor_cell { colors::BACKGROUND.into() } else { fg.into() },
|
||||
bold,
|
||||
italic: flags.intersects(Flags::ITALIC),
|
||||
underline: flags.intersects(Flags::ALL_UNDERLINES),
|
||||
strikethrough: flags.contains(Flags::STRIKEOUT),
|
||||
wide: flags.contains(Flags::WIDE_CHAR),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(vp) = cursor_vp {
|
||||
if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden {
|
||||
let wide = term.grid()
|
||||
[TermPoint::new(content.cursor.point.line, content.cursor.point.column)]
|
||||
.flags
|
||||
.contains(Flags::WIDE_CHAR);
|
||||
snap.cursor =
|
||||
Some(CursorSnap { row: vp.line, col: vp.column.0, shape: cursor_shape, wide });
|
||||
}
|
||||
}
|
||||
|
||||
Some(snap)
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::IntoElement for TerminalElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TerminalElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = TermLayout;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let (font, font_size) = {
|
||||
let view = self.view.read(cx);
|
||||
(view.font.clone(), view.font_size)
|
||||
};
|
||||
let sample = window.text_system().shape_line(
|
||||
SharedString::new_static("M"),
|
||||
font_size,
|
||||
&[TextRun {
|
||||
len: 1,
|
||||
font,
|
||||
color: Hsla::default(),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
let cell_width = px(sample.width.as_f32().max(1.0));
|
||||
let view = self.view.read(cx);
|
||||
let line_height = px((font_size.as_f32() * view.line_height_mul).round().max(1.0));
|
||||
|
||||
let cols = ((bounds.size.width.as_f32() / cell_width.as_f32()).floor().max(2.0)) as usize;
|
||||
let rows = ((bounds.size.height.as_f32() / line_height.as_f32()).floor().max(1.0)) as usize;
|
||||
|
||||
let scale = window.scale_factor();
|
||||
self.view.update(cx, |view, _| {
|
||||
view.set_layout(bounds.origin, cell_width, line_height, cols, rows, scale);
|
||||
});
|
||||
|
||||
window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
|
||||
TermLayout { cell_width, line_height, rows }
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
layout: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.paint_quad(fill(bounds, colors::BACKGROUND));
|
||||
|
||||
let focus_handle = self.view.read(cx).focus_handle.clone();
|
||||
window.handle_input(
|
||||
&focus_handle,
|
||||
gpui::ElementInputHandler::new(bounds, self.view.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
let focused = focus_handle.is_focused(window);
|
||||
let cols =
|
||||
((bounds.size.width.as_f32() / layout.cell_width.as_f32()).floor().max(2.0)) as usize;
|
||||
let Some(snap) = self.snapshot(layout.rows, cols, focused, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cell_rect = |row: usize, start: usize, count: usize| -> Bounds<Pixels> {
|
||||
Bounds::new(
|
||||
point(
|
||||
bounds.origin.x + layout.cell_width * start as f32,
|
||||
bounds.origin.y + layout.line_height * row as f32,
|
||||
),
|
||||
size(layout.cell_width * count as f32, layout.line_height),
|
||||
)
|
||||
};
|
||||
|
||||
for run in &snap.bgs {
|
||||
window.paint_quad(fill(cell_rect(run.row, run.start, run.end - run.start), run.color));
|
||||
}
|
||||
for run in &snap.selection {
|
||||
window.paint_quad(fill(cell_rect(run.row, run.start, run.end - run.start), run.color));
|
||||
}
|
||||
|
||||
// 聚焦时的块状光标垫在文字下面,让字形以反色盖在上面。
|
||||
if let Some(cursor) = &snap.cursor {
|
||||
if focused && matches!(cursor.shape, CursorShape::Block) {
|
||||
let width = if cursor.wide { 2 } else { 1 };
|
||||
window.paint_quad(fill(cell_rect(cursor.row, cursor.col, width), colors::CURSOR));
|
||||
}
|
||||
}
|
||||
|
||||
let (font, bold_font, italic_font, bold_italic_font, font_size) = {
|
||||
let view = self.view.read(cx);
|
||||
(
|
||||
view.font.clone(),
|
||||
view.font_bold.clone(),
|
||||
view.font_italic.clone(),
|
||||
view.font_bold_italic.clone(),
|
||||
view.font_size,
|
||||
)
|
||||
};
|
||||
let pick_font = |bold: bool, italic: bool| match (bold, italic) {
|
||||
(false, false) => font.clone(),
|
||||
(true, false) => bold_font.clone(),
|
||||
(false, true) => italic_font.clone(),
|
||||
(true, true) => bold_italic_font.clone(),
|
||||
};
|
||||
|
||||
for (row, cells) in snap.rows.iter().enumerate() {
|
||||
let mut i = 0;
|
||||
while i < cells.len() {
|
||||
// 一段:列连续且宽度类别一致的单元格,样式差异用多个 TextRun 表达。
|
||||
let seg_wide = cells[i].wide;
|
||||
let seg_start_col = cells[i].col;
|
||||
let step = if seg_wide { 2 } else { 1 };
|
||||
let mut text = String::new();
|
||||
let mut runs: Vec<TextRun> = Vec::new();
|
||||
let mut expected = seg_start_col;
|
||||
let mut cell_count = 0usize;
|
||||
while i < cells.len() && cells[i].wide == seg_wide && cells[i].col == expected {
|
||||
let cell = &cells[i];
|
||||
let bytes = cell.text.len();
|
||||
let underline = cell.underline.then(|| UnderlineStyle {
|
||||
thickness: px(1.0),
|
||||
color: Some(cell.fg),
|
||||
wavy: false,
|
||||
});
|
||||
let strikethrough = cell.strikethrough.then(|| gpui::StrikethroughStyle {
|
||||
thickness: px(1.0),
|
||||
color: Some(cell.fg),
|
||||
});
|
||||
let mergeable = runs.last().is_some_and(|run: &TextRun| {
|
||||
run.color == cell.fg
|
||||
&& run.font == pick_font(cell.bold, cell.italic)
|
||||
&& run.underline == underline
|
||||
&& run.strikethrough == strikethrough
|
||||
});
|
||||
if mergeable {
|
||||
runs.last_mut().unwrap().len += bytes;
|
||||
} else {
|
||||
runs.push(TextRun {
|
||||
len: bytes,
|
||||
font: pick_font(cell.bold, cell.italic),
|
||||
color: cell.fg,
|
||||
background_color: None,
|
||||
underline,
|
||||
strikethrough,
|
||||
});
|
||||
}
|
||||
text.push_str(&cell.text);
|
||||
expected += step;
|
||||
cell_count += 1;
|
||||
i += 1;
|
||||
}
|
||||
if cell_count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let shaped = window.text_system().shape_line(
|
||||
SharedString::from(text),
|
||||
font_size,
|
||||
&runs,
|
||||
Some(layout.cell_width * step as f32),
|
||||
);
|
||||
let origin = point(
|
||||
bounds.origin.x + layout.cell_width * seg_start_col as f32,
|
||||
bounds.origin.y + layout.line_height * row as f32,
|
||||
);
|
||||
let _ = shaped.paint(origin, layout.line_height, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cursor) = &snap.cursor {
|
||||
let width = if cursor.wide { 2 } else { 1 };
|
||||
let rect = cell_rect(cursor.row, cursor.col, width);
|
||||
match (focused, cursor.shape) {
|
||||
(true, CursorShape::Block) => {}, // 已在文字层下方填充
|
||||
(false, CursorShape::Block | CursorShape::HollowBlock)
|
||||
| (true, CursorShape::HollowBlock) => {
|
||||
window.paint_quad(outline(rect, colors::CURSOR, gpui::BorderStyle::Solid));
|
||||
},
|
||||
(_, CursorShape::Beam) => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(rect.origin, size(px(2.0), layout.line_height)),
|
||||
colors::CURSOR,
|
||||
));
|
||||
},
|
||||
(_, CursorShape::Underline) => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(rect.origin.x, rect.origin.y + layout.line_height - px(2.0)),
|
||||
size(rect.size.width, px(2.0)),
|
||||
),
|
||||
colors::CURSOR,
|
||||
));
|
||||
},
|
||||
(_, CursorShape::Hidden) => {},
|
||||
}
|
||||
|
||||
// IME 组合文本锚点与预编辑串:跟随光标单元格。
|
||||
let anchor = cell_rect(cursor.row, cursor.col, 1);
|
||||
let marked = self.view.read(cx).marked_text.clone();
|
||||
self.view.update(cx, |view, _| view.ime_bounds = anchor);
|
||||
if let Some(marked) = marked.filter(|m| !m.is_empty()) {
|
||||
let run = TextRun {
|
||||
len: marked.len(),
|
||||
font: font.clone(),
|
||||
color: colors::FOREGROUND.into(),
|
||||
background_color: None,
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: px(1.0),
|
||||
color: Some(colors::FOREGROUND.into()),
|
||||
wavy: false,
|
||||
}),
|
||||
strikethrough: None,
|
||||
};
|
||||
let shaped = window.text_system().shape_line(
|
||||
SharedString::from(marked),
|
||||
font_size,
|
||||
&[run],
|
||||
None,
|
||||
);
|
||||
let bg = Bounds::new(anchor.origin, size(shaped.width, layout.line_height));
|
||||
window.paint_quad(fill(bg, colors::BACKGROUND));
|
||||
let _ = shaped.paint(anchor.origin, layout.line_height, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! GPUI `Keystroke` → VT 字节序列编码。
|
||||
//!
|
||||
//! 只编码控制键与带修饰组合;普通可打印字符(含中文 IME 提交文本)走
|
||||
//! `EntityInputHandler::replace_text_in_range`,避免同一按键被编码两次。
|
||||
|
||||
use gpui::Keystroke;
|
||||
use nebula_terminal::term::TermMode;
|
||||
|
||||
/// xterm 修饰参数:1 + shift(1) + alt(2) + ctrl(4)。
|
||||
fn modifier_param(ks: &Keystroke) -> u8 {
|
||||
let mut param = 1;
|
||||
if ks.modifiers.shift {
|
||||
param += 1;
|
||||
}
|
||||
if ks.modifiers.alt {
|
||||
param += 2;
|
||||
}
|
||||
if ks.modifiers.control {
|
||||
param += 4;
|
||||
}
|
||||
param
|
||||
}
|
||||
|
||||
fn cursor_key(ks: &Keystroke, mode: &TermMode, letter: char) -> Vec<u8> {
|
||||
let param = modifier_param(ks);
|
||||
if param != 1 {
|
||||
format!("\x1b[1;{param}{letter}").into_bytes()
|
||||
} else if mode.contains(TermMode::APP_CURSOR) {
|
||||
format!("\x1bO{letter}").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[{letter}").into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn tilde_key(ks: &Keystroke, code: u8) -> Vec<u8> {
|
||||
let param = modifier_param(ks);
|
||||
if param != 1 {
|
||||
format!("\x1b[{code};{param}~").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[{code}~").into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn function_key(ks: &Keystroke, index: u8) -> Option<Vec<u8>> {
|
||||
let param = modifier_param(ks);
|
||||
match index {
|
||||
// F1-F4 走 SS3(无修饰)或 CSI 1;m(带修饰)。
|
||||
1..=4 => {
|
||||
let letter = (b'P' + index - 1) as char;
|
||||
Some(if param == 1 {
|
||||
format!("\x1bO{letter}").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[1;{param}{letter}").into_bytes()
|
||||
})
|
||||
},
|
||||
5 => Some(tilde_key(ks, 15)),
|
||||
6 => Some(tilde_key(ks, 17)),
|
||||
7 => Some(tilde_key(ks, 18)),
|
||||
8 => Some(tilde_key(ks, 19)),
|
||||
9 => Some(tilde_key(ks, 20)),
|
||||
10 => Some(tilde_key(ks, 21)),
|
||||
11 => Some(tilde_key(ks, 23)),
|
||||
12 => Some(tilde_key(ks, 24)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+字符 的 C0 映射。
|
||||
fn ctrl_char(c: char) -> Option<u8> {
|
||||
match c {
|
||||
'a'..='z' => Some(c as u8 - b'a' + 1),
|
||||
'A'..='Z' => Some(c.to_ascii_lowercase() as u8 - b'a' + 1),
|
||||
'@' | ' ' => Some(0x00),
|
||||
'[' => Some(0x1b),
|
||||
'\\' => Some(0x1c),
|
||||
']' => Some(0x1d),
|
||||
'^' => Some(0x1e),
|
||||
'_' | '/' => Some(0x1f),
|
||||
'?' => Some(0x7f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。
|
||||
pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
|
||||
let mods = &ks.modifiers;
|
||||
|
||||
match ks.key.as_str() {
|
||||
"enter" => {
|
||||
return Some(if mods.alt { b"\x1b\r".to_vec() } else { b"\r".to_vec() });
|
||||
},
|
||||
"backspace" => {
|
||||
let byte: u8 = if mods.control { 0x08 } else { 0x7f };
|
||||
return Some(if mods.alt { vec![0x1b, byte] } else { vec![byte] });
|
||||
},
|
||||
"tab" => {
|
||||
return Some(if mods.shift { b"\x1b[Z".to_vec() } else { b"\t".to_vec() });
|
||||
},
|
||||
"escape" => return Some(b"\x1b".to_vec()),
|
||||
"up" => return Some(cursor_key(ks, mode, 'A')),
|
||||
"down" => return Some(cursor_key(ks, mode, 'B')),
|
||||
"right" => return Some(cursor_key(ks, mode, 'C')),
|
||||
"left" => return Some(cursor_key(ks, mode, 'D')),
|
||||
"home" => return Some(cursor_key(ks, mode, 'H')),
|
||||
"end" => return Some(cursor_key(ks, mode, 'F')),
|
||||
"insert" => return Some(tilde_key(ks, 2)),
|
||||
"delete" => return Some(tilde_key(ks, 3)),
|
||||
"pageup" => return Some(tilde_key(ks, 5)),
|
||||
"pagedown" => return Some(tilde_key(ks, 6)),
|
||||
key if key.len() == 2 && key.starts_with('f') => {
|
||||
if let Ok(n) = key[1..].parse::<u8>() {
|
||||
return function_key(ks, n);
|
||||
}
|
||||
},
|
||||
key if key.len() == 3 && key.starts_with('f') => {
|
||||
if let Ok(n) = key[1..].parse::<u8>() {
|
||||
return function_key(ks, n);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
// Ctrl 组合:定位到基础字符后映射 C0。
|
||||
if mods.control {
|
||||
let base = if ks.key.as_str() == "space" {
|
||||
Some(' ')
|
||||
} else {
|
||||
let mut chars = ks.key.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(byte) = base.and_then(ctrl_char) {
|
||||
return Some(if mods.alt { vec![0x1b, byte] } else { vec![byte] });
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+可打印字符:ESC 前缀(Windows 上 AltGr 会同时置 alt+control,
|
||||
// key_char 存在说明平台已判定它产生文本,交给文本路径)。
|
||||
if mods.alt && !mods.control {
|
||||
if let Some(text) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
|
||||
let mut bytes = vec![0x1b];
|
||||
bytes.extend_from_slice(text.as_bytes());
|
||||
return Some(bytes);
|
||||
}
|
||||
let mut chars = ks.key.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
||||
let mut bytes = vec![0x1b];
|
||||
let mut buf = [0u8; 4];
|
||||
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
if ks.key.as_str() == "space" && !mods.control {
|
||||
return Some(b" ".to_vec());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 终端垂直切片:nebula_terminal(PTY/网格)× GPUI(渲染/输入/IME)。
|
||||
//!
|
||||
//! 边界:本模块之外不出现 nebula_terminal 类型;nebula_terminal 之内不出现
|
||||
//! GPUI 类型。会话生命周期归 `session`,绘制归 `element`,交互归 `view`。
|
||||
|
||||
pub mod colors;
|
||||
pub mod element;
|
||||
pub mod keymap;
|
||||
pub mod session;
|
||||
pub mod view;
|
||||
@@ -0,0 +1,75 @@
|
||||
//! PTY 会话接线:`Term` + `EventLoop` + ConPTY,全部来自 `nebula_terminal`。
|
||||
//!
|
||||
//! 与 `nebula_app::window_context::create_pane` 相同的模式,只是事件出口换成
|
||||
//! futures channel,让 GPUI 前台任务可以 `await` 事件。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
use nebula_terminal::Term;
|
||||
use nebula_terminal::event::{Event, EventListener, WindowSize};
|
||||
use nebula_terminal::event_loop::{EventLoop, Notifier};
|
||||
use nebula_terminal::grid::Dimensions;
|
||||
use nebula_terminal::sync::FairMutex;
|
||||
use nebula_terminal::term::Config;
|
||||
use nebula_terminal::tty;
|
||||
|
||||
/// 把 PTY 线程发出的终端事件转投到 GPUI 前台的异步通道。
|
||||
#[derive(Clone)]
|
||||
pub struct EventProxy(UnboundedSender<Event>);
|
||||
|
||||
impl EventListener for EventProxy {
|
||||
fn send_event(&self, event: Event) {
|
||||
let _ = self.0.unbounded_send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始网格尺寸;`Term::new`/`Term::resize` 只消费行列数。
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GridSize {
|
||||
pub columns: usize,
|
||||
pub screen_lines: usize,
|
||||
}
|
||||
|
||||
impl Dimensions for GridSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.columns
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalSession {
|
||||
pub term: Arc<FairMutex<Term<EventProxy>>>,
|
||||
pub notifier: Notifier,
|
||||
}
|
||||
|
||||
/// 启动一个本地 shell 会话。尺寸随后由首帧 prepaint 按真实布局重设。
|
||||
pub fn spawn(
|
||||
window_size: WindowSize,
|
||||
) -> std::io::Result<(TerminalSession, UnboundedReceiver<Event>)> {
|
||||
let (tx, rx) = unbounded();
|
||||
let proxy = EventProxy(tx);
|
||||
|
||||
let grid = GridSize {
|
||||
columns: window_size.num_cols as usize,
|
||||
screen_lines: window_size.num_lines as usize,
|
||||
};
|
||||
let term = Arc::new(FairMutex::new(Term::new(Config::default(), &grid, proxy.clone())));
|
||||
|
||||
let options = tty::Options::default();
|
||||
tty::setup_env();
|
||||
let pty = tty::new(&options, window_size, 0)?;
|
||||
|
||||
let event_loop = EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, false)?;
|
||||
let notifier = Notifier(event_loop.channel());
|
||||
let _io_thread = event_loop.spawn();
|
||||
|
||||
Ok((TerminalSession { term, notifier }, rx))
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
//! 终端视图:持有会话、处理输入与 IME、驱动重绘。
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontStyle,
|
||||
FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollWheelEvent,
|
||||
Styled as _, UTF16Selection, Window, div, point, px,
|
||||
};
|
||||
use gpui_component::PixelsExt as _;
|
||||
use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize};
|
||||
use nebula_terminal::grid::Scroll;
|
||||
use nebula_terminal::index::{Column, Point as TermPoint, Side};
|
||||
use nebula_terminal::selection::{Selection, SelectionType};
|
||||
use nebula_terminal::term::TermMode;
|
||||
use nebula_terminal::term::viewport_to_point;
|
||||
|
||||
use super::colors;
|
||||
use super::element::TerminalElement;
|
||||
use super::keymap;
|
||||
use super::session::{self, GridSize, TerminalSession};
|
||||
|
||||
use futures::StreamExt as _;
|
||||
|
||||
pub struct TerminalView {
|
||||
pub session: Option<TerminalSession>,
|
||||
pub focus_handle: FocusHandle,
|
||||
pub font: Font,
|
||||
pub font_bold: Font,
|
||||
pub font_italic: Font,
|
||||
pub font_bold_italic: Font,
|
||||
pub font_size: Pixels,
|
||||
pub line_height_mul: f32,
|
||||
pub marked_text: Option<String>,
|
||||
pub ime_bounds: Bounds<Pixels>,
|
||||
pub title: String,
|
||||
error: Option<String>,
|
||||
exited: Option<String>,
|
||||
origin: Point<Pixels>,
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
window_size: WindowSize,
|
||||
scroll_px: f32,
|
||||
selecting: bool,
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mono = |weight: FontWeight, style: FontStyle| Font {
|
||||
weight,
|
||||
style,
|
||||
..gpui::font("Cascadia Mono")
|
||||
};
|
||||
|
||||
let initial = WindowSize { num_lines: 30, num_cols: 100, cell_width: 9, cell_height: 18 };
|
||||
let (session, error) = match session::spawn(initial) {
|
||||
Ok((session, mut rx)) => {
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Some(event) = rx.next().await {
|
||||
// 合并同一批到达的事件,避免每个 Wakeup 都独立触发一帧。
|
||||
let mut batch = vec![event];
|
||||
while batch.len() < 128 {
|
||||
match rx.try_recv() {
|
||||
Ok(event) => batch.push(event),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let done = batch.iter().any(|e| matches!(e, TermEvent::Exit));
|
||||
if this
|
||||
.update(cx, |view: &mut Self, cx| {
|
||||
for event in batch {
|
||||
view.process_event(event, cx);
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
|| done
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
(Some(session), None)
|
||||
},
|
||||
Err(err) => (None, Some(format!("PTY 启动失败: {err}"))),
|
||||
};
|
||||
|
||||
Self {
|
||||
session,
|
||||
focus_handle: cx.focus_handle(),
|
||||
font: mono(FontWeight::NORMAL, FontStyle::Normal),
|
||||
font_bold: mono(FontWeight::BOLD, FontStyle::Normal),
|
||||
font_italic: mono(FontWeight::NORMAL, FontStyle::Italic),
|
||||
font_bold_italic: mono(FontWeight::BOLD, FontStyle::Italic),
|
||||
font_size: px(14.0),
|
||||
line_height_mul: 1.25,
|
||||
marked_text: None,
|
||||
ime_bounds: Bounds::default(),
|
||||
title: String::from("shell"),
|
||||
error,
|
||||
exited: None,
|
||||
origin: point(px(0.0), px(0.0)),
|
||||
cell_width: px(8.0),
|
||||
line_height: px(18.0),
|
||||
cols: initial.num_cols as usize,
|
||||
rows: initial.num_lines as usize,
|
||||
window_size: initial,
|
||||
scroll_px: 0.0,
|
||||
selecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_event(&mut self, event: TermEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
TermEvent::Wakeup | TermEvent::MouseCursorDirty | TermEvent::CursorBlinkingChange => {
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::Title(title) => {
|
||||
self.title = title;
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::ResetTitle => {
|
||||
self.title = String::from("shell");
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::PtyWrite(text) => self.write_bytes(text.into_bytes()),
|
||||
TermEvent::ClipboardStore(_, text) => {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
},
|
||||
TermEvent::ClipboardLoad(_, formatter) => {
|
||||
let text =
|
||||
cx.read_from_clipboard().and_then(|item| item.text()).unwrap_or_default();
|
||||
self.write_bytes(formatter(&text).into_bytes());
|
||||
},
|
||||
TermEvent::ColorRequest(index, formatter) => {
|
||||
let reply = self.session.as_ref().map(|session| {
|
||||
let term = session.term.lock();
|
||||
colors::query_reply(index, term.colors())
|
||||
});
|
||||
if let Some(rgb) = reply {
|
||||
self.write_bytes(formatter(rgb).into_bytes());
|
||||
}
|
||||
},
|
||||
TermEvent::TextAreaSizeRequest(formatter) => {
|
||||
self.write_bytes(formatter(self.window_size).into_bytes());
|
||||
},
|
||||
TermEvent::ChildExit(code) => {
|
||||
self.exited = Some(format!("进程已退出({code:?})"));
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::PtyFailure(reason) => {
|
||||
self.exited = Some(format!("PTY 故障:{reason}"));
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::Exit => {
|
||||
self.exited.get_or_insert_with(|| String::from("会话已结束"));
|
||||
cx.notify();
|
||||
},
|
||||
// 垂直切片不处理:铃声、cwd 上报、内联图片、OSC133、AI hook 等。
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bytes(&self, bytes: Vec<u8>) {
|
||||
if let Some(session) = &self.session {
|
||||
session.notifier.notify(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 输入后回到底部并请求重绘。
|
||||
fn write_input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
||||
if let Some(session) = &self.session {
|
||||
{
|
||||
let mut term = session.term.lock();
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
term.selection = None;
|
||||
}
|
||||
session.notifier.notify(bytes);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 元素 prepaint 回写布局;行列变化时同步 Term 和 ConPTY。
|
||||
pub fn set_layout(
|
||||
&mut self,
|
||||
origin: Point<Pixels>,
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
scale: f32,
|
||||
) {
|
||||
self.origin = origin;
|
||||
self.cell_width = cell_width;
|
||||
self.line_height = line_height;
|
||||
if cols == self.cols && rows == self.rows {
|
||||
return;
|
||||
}
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
let window_size = WindowSize {
|
||||
num_lines: rows as u16,
|
||||
num_cols: cols as u16,
|
||||
cell_width: (cell_width.as_f32() * scale) as u16,
|
||||
cell_height: (line_height.as_f32() * scale) as u16,
|
||||
};
|
||||
self.window_size = window_size;
|
||||
if let Some(session) = &self.session {
|
||||
session.term.lock().resize(GridSize { columns: cols, screen_lines: rows });
|
||||
let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
|
||||
notifier.on_resize(window_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn term_mode(&self) -> TermMode {
|
||||
self.session.as_ref().map(|s| *s.term.lock().mode()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn copy_selection(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(session) = &self.session {
|
||||
let text = session.term.lock().selection_to_string();
|
||||
if let Some(text) = text.filter(|t| !t.is_empty()) {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paste(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return };
|
||||
let normalized = text.replace("\r\n", "\r").replace('\n', "\r");
|
||||
let bytes = if self.term_mode().contains(TermMode::BRACKETED_PASTE) {
|
||||
let mut b = b"\x1b[200~".to_vec();
|
||||
// 过滤粘贴内容里的收尾哨兵,防止注入截断。
|
||||
b.extend_from_slice(normalized.replace("\x1b[201~", "").as_bytes());
|
||||
b.extend_from_slice(b"\x1b[201~");
|
||||
b
|
||||
} else {
|
||||
normalized.into_bytes()
|
||||
};
|
||||
self.write_input(bytes, cx);
|
||||
}
|
||||
|
||||
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.exited.is_some() {
|
||||
return;
|
||||
}
|
||||
let ks = &event.keystroke;
|
||||
let mods = &ks.modifiers;
|
||||
|
||||
// 终端惯例快捷键优先于编码器。
|
||||
if mods.control && mods.shift {
|
||||
match ks.key.as_str() {
|
||||
"c" => {
|
||||
self.copy_selection(cx);
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
},
|
||||
"v" => {
|
||||
self.paste(cx);
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
let mode = self.term_mode();
|
||||
if let Some(bytes) = keymap::encode(ks, &mode) {
|
||||
self.write_input(bytes, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
|
||||
/// 像素坐标 → 网格坐标(含滚动偏移)与半格判定。
|
||||
fn grid_point(&self, position: Point<Pixels>) -> (TermPoint, Side) {
|
||||
let rel_x = (position.x - self.origin.x).as_f32() / self.cell_width.as_f32().max(1.0);
|
||||
let rel_y = (position.y - self.origin.y).as_f32() / self.line_height.as_f32().max(1.0);
|
||||
let col = (rel_x.floor().max(0.0) as usize).min(self.cols.saturating_sub(1));
|
||||
let row = (rel_y.floor().max(0.0) as usize).min(self.rows.saturating_sub(1));
|
||||
let side = if rel_x.fract() > 0.5 { Side::Right } else { Side::Left };
|
||||
let display_offset =
|
||||
self.session.as_ref().map(|s| s.term.lock().grid().display_offset()).unwrap_or(0);
|
||||
(viewport_to_point(display_offset, TermPoint::new(row, Column(col))), side)
|
||||
}
|
||||
|
||||
fn on_mouse_down(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus_handle);
|
||||
let Some(session) = &self.session else { return };
|
||||
let (point, side) = self.grid_point(event.position);
|
||||
let ty = match event.click_count {
|
||||
1 => SelectionType::Simple,
|
||||
2 => SelectionType::Semantic,
|
||||
_ => SelectionType::Lines,
|
||||
};
|
||||
session.term.lock().selection = Some(Selection::new(ty, point, side));
|
||||
self.selecting = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.selecting || event.pressed_button != Some(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let (point, side) = self.grid_point(event.position);
|
||||
if let Some(session) = &self.session {
|
||||
if let Some(selection) = session.term.lock().selection.as_mut() {
|
||||
selection.update(point, side);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_mouse_up(&mut self, _event: &MouseUpEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selecting = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_scroll(
|
||||
&mut self,
|
||||
event: &ScrollWheelEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32();
|
||||
self.scroll_px += delta_y;
|
||||
let lines = (self.scroll_px / self.line_height.as_f32().max(1.0)).trunc() as i32;
|
||||
if lines == 0 {
|
||||
return;
|
||||
}
|
||||
self.scroll_px -= lines as f32 * self.line_height.as_f32();
|
||||
let Some(session) = &self.session else { return };
|
||||
let mode = *session.term.lock().mode();
|
||||
if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
|
||||
// 备用屏(less/vim):滚轮翻译成方向键。
|
||||
let seq: &[u8] = if mode.contains(TermMode::APP_CURSOR) {
|
||||
if lines > 0 { b"\x1bOA" } else { b"\x1bOB" }
|
||||
} else if lines > 0 {
|
||||
b"\x1b[A"
|
||||
} else {
|
||||
b"\x1b[B"
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..lines.unsigned_abs() {
|
||||
bytes.extend_from_slice(seq);
|
||||
}
|
||||
session.notifier.notify(bytes);
|
||||
} else {
|
||||
session.term.lock().scroll_display(Scroll::Delta(lines));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn marked_utf16_len(&self) -> usize {
|
||||
self.marked_text.as_deref().map(|t| t.encode_utf16().count()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<()> for TerminalView {}
|
||||
|
||||
impl Focusable for TerminalView {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::EntityInputHandler for TerminalView {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
_range: std::ops::Range<usize>,
|
||||
_adjusted_range: &mut Option<std::ops::Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
// 终端没有文本框语义的选区;返回折叠选区让 IME 把组合文本插在光标处。
|
||||
let len = self.marked_utf16_len();
|
||||
Some(UTF16Selection { range: len..len, reversed: false })
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
self.marked_text.as_ref().map(|_| 0..self.marked_utf16_len())
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marked_text = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
_range: Option<std::ops::Range<usize>>,
|
||||
text: &str,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.marked_text = None;
|
||||
if !text.is_empty() && self.exited.is_none() {
|
||||
self.write_input(text.as_bytes().to_vec(), cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
_range: Option<std::ops::Range<usize>>,
|
||||
new_text: &str,
|
||||
_new_selected_range: Option<std::ops::Range<usize>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.marked_text = if new_text.is_empty() { None } else { Some(new_text.to_string()) };
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_range_utf16: std::ops::Range<usize>,
|
||||
_element_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
Some(self.ime_bounds)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let mut root = div()
|
||||
.id("nebula-terminal")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(colors::BACKGROUND)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_key_down(cx.listener(Self::on_key_down))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
|
||||
.on_mouse_move(cx.listener(Self::on_mouse_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
|
||||
.on_scroll_wheel(cx.listener(Self::on_scroll));
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
root = root.child(div().p_4().text_color(gpui::red()).child(error.clone()));
|
||||
} else {
|
||||
root = root.child(TerminalElement::new(cx.entity()));
|
||||
if let Some(exited) = &self.exited {
|
||||
root = root.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.left_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(gpui::opaque_grey(0.2, 0.9))
|
||||
.text_sm()
|
||||
.child(exited.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use gpui::{
|
||||
Window, div, px,
|
||||
};
|
||||
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::ui::prelude::*;
|
||||
|
||||
gpui::actions!(component_gallery, [Increment]);
|
||||
@@ -13,6 +14,7 @@ pub struct ComponentGallery {
|
||||
editor: Entity<InputState>,
|
||||
select: Entity<SelectState<Vec<&'static str>>>,
|
||||
dock_area: Entity<DockArea>,
|
||||
terminal: Entity<TerminalView>,
|
||||
enabled: bool,
|
||||
clicks: usize,
|
||||
active_tab: usize,
|
||||
@@ -93,7 +95,9 @@ impl ComponentGallery {
|
||||
dock.set_center(DockItem::tab(panel, &weak_dock_area, window, cx), window, cx);
|
||||
});
|
||||
|
||||
Self { input, editor, select, dock_area, enabled: true, clicks: 0, active_tab: 0 }
|
||||
let terminal = cx.new(TerminalView::new);
|
||||
|
||||
Self { input, editor, select, dock_area, terminal, enabled: true, clicks: 0, active_tab: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +238,21 @@ impl Render for ComponentGallery {
|
||||
.child(self.dock_area.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().text_sm().child(
|
||||
"终端垂直切片(ConPTY + nebula_terminal + 自定义 Element)",
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h(px(360.0))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded_md()
|
||||
.overflow_hidden()
|
||||
.child(self.terminal.clone()),
|
||||
)
|
||||
.child(div().text_sm().child("Code Editor / Tree-sitter 基础"))
|
||||
.child(Input::new(&self.editor).h(px(220.0)))
|
||||
.child(
|
||||
|
||||
Reference in new issue
Block a user