From 433cf63571fdbccb367424d6931ee9ebc2ebd8cc Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 12 Aug 2026 18:02:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20terminal=20vertical=20slice=20=E2=80=94?= =?UTF-8?q?=20nebula=5Fterminal=20rendered=20as=20GPUI=20element?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- nebula_gpui/ARCHITECTURE.md | 24 + nebula_gpui/Cargo.lock | 193 ++++++-- nebula_gpui/Cargo.toml | 4 + nebula_gpui/src/main.rs | 1 + nebula_gpui/src/terminal/colors.rs | 146 ++++++ nebula_gpui/src/terminal/element.rs | 441 ++++++++++++++++++ nebula_gpui/src/terminal/keymap.rs | 161 +++++++ nebula_gpui/src/terminal/mod.rs | 10 + nebula_gpui/src/terminal/session.rs | 75 ++++ nebula_gpui/src/terminal/view.rs | 493 +++++++++++++++++++++ nebula_gpui/src/views/component_gallery.rs | 21 +- 11 files changed, 1519 insertions(+), 50 deletions(-) create mode 100644 nebula_gpui/src/terminal/colors.rs create mode 100644 nebula_gpui/src/terminal/element.rs create mode 100644 nebula_gpui/src/terminal/keymap.rs create mode 100644 nebula_gpui/src/terminal/mod.rs create mode 100644 nebula_gpui/src/terminal/session.rs create mode 100644 nebula_gpui/src/terminal/view.rs diff --git a/nebula_gpui/ARCHITECTURE.md b/nebula_gpui/ARCHITECTURE.md index 28d5d03..ceb3447 100644 --- a/nebula_gpui/ARCHITECTURE.md +++ b/nebula_gpui/ARCHITECTURE.md @@ -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`. diff --git a/nebula_gpui/Cargo.lock b/nebula_gpui/Cargo.lock index 8503267..0e0f3df 100644 --- a/nebula_gpui/Cargo.lock +++ b/nebula_gpui/Cargo.lock @@ -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" diff --git a/nebula_gpui/Cargo.toml b/nebula_gpui/Cargo.toml index 526b925..b63104a 100644 --- a/nebula_gpui/Cargo.toml +++ b/nebula_gpui/Cargo.toml @@ -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" diff --git a/nebula_gpui/src/main.rs b/nebula_gpui/src/main.rs index 1d3deb0..6bb985d 100644 --- a/nebula_gpui/src/main.rs +++ b/nebula_gpui/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod terminal; mod ui; mod views; diff --git a/nebula_gpui/src/terminal/colors.rs b/nebula_gpui/src/terminal/colors.rs new file mode 100644 index 0000000..5ea892a --- /dev/null +++ b/nebula_gpui/src/terminal/colors.rs @@ -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 { + 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) +} diff --git a/nebula_gpui/src/terminal/element.rs b/nebula_gpui/src/terminal/element.rs new file mode 100644 index 0000000..1c2dad0 --- /dev/null +++ b/nebula_gpui/src/terminal/element.rs @@ -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, +} + +impl TerminalElement { + pub fn new(view: gpui::Entity) -> 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>, + bgs: Vec, + selection: Vec, + cursor: Option, +} + +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 { + 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, 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 { + 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, + _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, + _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 { + 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 = 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); + } + } + } +} diff --git a/nebula_gpui/src/terminal/keymap.rs b/nebula_gpui/src/terminal/keymap.rs new file mode 100644 index 0000000..354df42 --- /dev/null +++ b/nebula_gpui/src/terminal/keymap.rs @@ -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 { + 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 { + 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> { + 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 { + 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> { + 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::() { + return function_key(ks, n); + } + }, + key if key.len() == 3 && key.starts_with('f') => { + if let Ok(n) = key[1..].parse::() { + 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 +} diff --git a/nebula_gpui/src/terminal/mod.rs b/nebula_gpui/src/terminal/mod.rs new file mode 100644 index 0000000..349e903 --- /dev/null +++ b/nebula_gpui/src/terminal/mod.rs @@ -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; diff --git a/nebula_gpui/src/terminal/session.rs b/nebula_gpui/src/terminal/session.rs new file mode 100644 index 0000000..12e28db --- /dev/null +++ b/nebula_gpui/src/terminal/session.rs @@ -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); + +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>>, + pub notifier: Notifier, +} + +/// 启动一个本地 shell 会话。尺寸随后由首帧 prepaint 按真实布局重设。 +pub fn spawn( + window_size: WindowSize, +) -> std::io::Result<(TerminalSession, UnboundedReceiver)> { + 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)) +} diff --git a/nebula_gpui/src/terminal/view.rs b/nebula_gpui/src/terminal/view.rs new file mode 100644 index 0000000..3f172fb --- /dev/null +++ b/nebula_gpui/src/terminal/view.rs @@ -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, + 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, + pub ime_bounds: Bounds, + pub title: String, + error: Option, + exited: Option, + origin: Point, + 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 { + 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) { + 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) { + if let Some(session) = &self.session { + session.notifier.notify(bytes); + } + } + + /// 输入后回到底部并请求重绘。 + fn write_input(&mut self, bytes: Vec, cx: &mut Context) { + 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, + 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) { + 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) { + 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) { + 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) -> (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, + ) { + 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, + ) { + 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.selecting = false; + cx.notify(); + } + + fn on_scroll( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + cx: &mut Context, + ) { + 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, + _adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + // 终端没有文本框语义的选区;返回折叠选区让 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, + ) -> Option> { + self.marked_text.as_ref().map(|_| 0..self.marked_utf16_len()) + } + + fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context) { + self.marked_text = None; + cx.notify(); + } + + fn replace_text_in_range( + &mut self, + _range: Option>, + text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + 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>, + new_text: &str, + _new_selected_range: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + 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, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + Some(self.ime_bounds) + } + + fn character_index_for_point( + &mut self, + _point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +impl Render for TerminalView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 + } +} diff --git a/nebula_gpui/src/views/component_gallery.rs b/nebula_gpui/src/views/component_gallery.rs index 08370ba..8481009 100644 --- a/nebula_gpui/src/views/component_gallery.rs +++ b/nebula_gpui/src/views/component_gallery.rs @@ -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, select: Entity>>, dock_area: Entity, + terminal: Entity, 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(