From 456ea3119577c00fac5669f7a877692c83d75791 Mon Sep 17 00:00:00 2001 From: Ryan Brue Date: Tue, 28 Oct 2025 04:04:21 +0000 Subject: [PATCH 1/2] Separate `helix-input` and `helix-graphics` from `helix-view`. This commit separates some primitives from `helix-view` into their own crates, for later use by crates that view might depend on, without causing cyclic dependencies. --- Cargo.lock | 36 +++++++++++++++++++ Cargo.toml | 2 ++ helix-graphics/Cargo.toml | 32 +++++++++++++++++ .../src/graphics.rs | 0 helix-graphics/src/lib.rs | 2 ++ {helix-view => helix-graphics}/src/theme.rs | 0 helix-input/Cargo.toml | 35 ++++++++++++++++++ {helix-view => helix-input}/src/clipboard.rs | 0 {helix-view => helix-input}/src/input.rs | 0 {helix-view => helix-input}/src/keyboard.rs | 0 helix-input/src/lib.rs | 3 ++ helix-view/Cargo.toml | 4 ++- helix-view/src/lib.rs | 12 ++++--- 13 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 helix-graphics/Cargo.toml rename {helix-view => helix-graphics}/src/graphics.rs (100%) create mode 100644 helix-graphics/src/lib.rs rename {helix-view => helix-graphics}/src/theme.rs (100%) create mode 100644 helix-input/Cargo.toml rename {helix-view => helix-input}/src/clipboard.rs (100%) rename {helix-view => helix-input}/src/input.rs (100%) rename {helix-view => helix-input}/src/keyboard.rs (100%) create mode 100644 helix-input/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0d781a62bd9f..ab36833a5d0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "helix-graphics" +version = "25.7.1" +dependencies = [ + "anyhow", + "bitflags", + "crossterm", + "helix-core", + "helix-loader", + "log", + "once_cell", + "serde", + "termina", + "toml", +] + +[[package]] +name = "helix-input" +version = "25.7.1" +dependencies = [ + "anyhow", + "bitflags", + "clipboard-win", + "crossterm", + "helix-core", + "helix-stdx", + "libc", + "log", + "rustix 1.1.2", + "serde", + "termina", + "thiserror", +] + [[package]] name = "helix-loader" version = "25.7.1" @@ -1591,6 +1625,8 @@ dependencies = [ "helix-core", "helix-dap", "helix-event", + "helix-graphics", + "helix-input", "helix-loader", "helix-lsp", "helix-stdx", diff --git a/Cargo.toml b/Cargo.toml index 90542831f891..e80bc7440733 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "helix-core", + "helix-graphics", + "helix-input", "helix-view", "helix-term", "helix-tui", diff --git a/helix-graphics/Cargo.toml b/helix-graphics/Cargo.toml new file mode 100644 index 000000000000..8515b3275c3d --- /dev/null +++ b/helix-graphics/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "helix-graphics" +version.workspace = true +edition.workspace = true +authors.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = [] +term = ["termina", "crossterm"] + +[dependencies] +helix-core = { path = "../helix-core" } +helix-loader = { path = "../helix-loader" } +bitflags.workspace = true +anyhow = "1" + +termina = { workspace = true, optional = true } + +# Conversion traits +once_cell = "1.21" + +serde = { version = "1.0", features = ["derive"] } +toml.workspace = true +log = "~0.4" + +[target.'cfg(windows)'.dependencies] +crossterm = { version = "0.28", optional = true } \ No newline at end of file diff --git a/helix-view/src/graphics.rs b/helix-graphics/src/graphics.rs similarity index 100% rename from helix-view/src/graphics.rs rename to helix-graphics/src/graphics.rs diff --git a/helix-graphics/src/lib.rs b/helix-graphics/src/lib.rs new file mode 100644 index 000000000000..06337036c82b --- /dev/null +++ b/helix-graphics/src/lib.rs @@ -0,0 +1,2 @@ +pub mod graphics; +pub mod theme; diff --git a/helix-view/src/theme.rs b/helix-graphics/src/theme.rs similarity index 100% rename from helix-view/src/theme.rs rename to helix-graphics/src/theme.rs diff --git a/helix-input/Cargo.toml b/helix-input/Cargo.toml new file mode 100644 index 000000000000..fe52f181b066 --- /dev/null +++ b/helix-input/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "helix-input" +version.workspace = true +edition.workspace = true +authors.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = [] +term = ["termina", "crossterm"] + +[dependencies] +helix-stdx = { path = "../helix-stdx" } +helix-core = { path = "../helix-core" } + +bitflags.workspace = true +anyhow = "1" +termina = { workspace = true, optional = true } + +serde = { version = "1.0", features = ["derive"] } +log = "~0.4" + +thiserror.workspace = true + +[target.'cfg(windows)'.dependencies] +clipboard-win = { version = "5.4", features = ["std"] } +crossterm = { version = "0.28", optional = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +rustix = { version = "1.1", features = ["fs"] } \ No newline at end of file diff --git a/helix-view/src/clipboard.rs b/helix-input/src/clipboard.rs similarity index 100% rename from helix-view/src/clipboard.rs rename to helix-input/src/clipboard.rs diff --git a/helix-view/src/input.rs b/helix-input/src/input.rs similarity index 100% rename from helix-view/src/input.rs rename to helix-input/src/input.rs diff --git a/helix-view/src/keyboard.rs b/helix-input/src/keyboard.rs similarity index 100% rename from helix-view/src/keyboard.rs rename to helix-input/src/keyboard.rs diff --git a/helix-input/src/lib.rs b/helix-input/src/lib.rs new file mode 100644 index 000000000000..23a6d61b72be --- /dev/null +++ b/helix-input/src/lib.rs @@ -0,0 +1,3 @@ +pub mod clipboard; +pub mod input; +pub mod keyboard; diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 24dd0f2aaab2..6c4c02fc6e17 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -12,7 +12,7 @@ homepage.workspace = true [features] default = [] -term = ["termina", "crossterm"] +term = ["termina", "crossterm", "helix-graphics/term", "helix-input/term"] unicode-lines = [] [dependencies] @@ -23,6 +23,8 @@ helix-loader = { path = "../helix-loader" } helix-lsp = { path = "../helix-lsp" } helix-dap = { path = "../helix-dap" } helix-vcs = { path = "../helix-vcs" } +helix-graphics = { path = "../helix-graphics" } +helix-input = { path = "../helix-input" } bitflags.workspace = true anyhow = "1" diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index a7e9f4618c91..b653f274470c 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -2,22 +2,24 @@ pub mod macros; pub mod annotations; -pub mod clipboard; pub mod document; pub mod editor; pub mod events; pub mod expansion; -pub mod graphics; pub mod gutter; pub mod handlers; pub mod info; -pub mod input; -pub mod keyboard; pub mod register; -pub mod theme; pub mod tree; pub mod view; +pub use helix_input::clipboard; +pub use helix_input::input; +pub use helix_input::keyboard; + +pub use helix_graphics::graphics; +pub use helix_graphics::theme; + use std::num::NonZeroUsize; // uses NonZeroUsize so Option use a byte rather than two From 242ca5e56dec9fd0a6fc2d244d2f0771e8a1d3ff Mon Sep 17 00:00:00 2001 From: Ryan Brue Date: Mon, 31 Oct 2022 15:16:49 +0400 Subject: [PATCH 2/2] Add a toggleable integrated terminal This commit adds the `helix-integrated-terminal` crate, which handles the PTY creation and view logic for an integrated terminal in Helix, using the `alacritty_terminal` crate. The terminal can be opened using the `:term` command, and the terminal can be exited by either using `exit` in the terminal itself, or by using `C-q` twice. The current implementation is as a pop-up, but in the future, we may be able to expose the terminal as a `View`, if work is done to decouple the `Document` attachment to `View`. Signed-off-by: Ryan Brue --- Cargo.lock | 201 ++++++++- Cargo.toml | 1 + helix-integrated-terminal/Cargo.toml | 27 ++ helix-integrated-terminal/src/error.rs | 14 + helix-integrated-terminal/src/lib.rs | 3 + helix-integrated-terminal/src/pty.rs | 459 +++++++++++++++++++ helix-integrated-terminal/src/terminal.rs | 514 ++++++++++++++++++++++ helix-term/src/application.rs | 6 + helix-term/src/commands.rs | 4 + helix-term/src/commands/lsp.rs | 2 +- helix-term/src/commands/typed.rs | 25 ++ helix-term/src/commands/vte.rs | 9 + helix-term/src/ui/editor.rs | 51 ++- helix-term/src/ui/mod.rs | 1 + helix-term/src/ui/terminal.rs | 125 ++++++ helix-view/Cargo.toml | 1 + helix-view/src/editor.rs | 7 + helix-view/src/handlers.rs | 1 + helix-view/src/handlers/vte.rs | 7 + helix-view/src/lib.rs | 2 + 20 files changed, 1446 insertions(+), 14 deletions(-) create mode 100644 helix-integrated-terminal/Cargo.toml create mode 100644 helix-integrated-terminal/src/error.rs create mode 100644 helix-integrated-terminal/src/lib.rs create mode 100644 helix-integrated-terminal/src/pty.rs create mode 100644 helix-integrated-terminal/src/terminal.rs create mode 100644 helix-term/src/commands/vte.rs create mode 100644 helix-term/src/ui/terminal.rs create mode 100644 helix-view/src/handlers/vte.rs diff --git a/Cargo.lock b/Cargo.lock index ab36833a5d0a..85669097f7f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46319972e74179d707445f64aaa2893bbf6a111de3a9af29b7eb382f8b39e282" +dependencies = [ + "base64", + "bitflags", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.2", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width 0.2.0", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -51,17 +76,38 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -160,6 +206,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "content_inspector" version = "0.2.4" @@ -254,6 +309,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "dashmap" version = "6.1.0" @@ -440,6 +501,31 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -457,6 +543,29 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -469,8 +578,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1457,6 +1571,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "helix-integrated-terminal" +version = "25.7.1" +dependencies = [ + "alacritty_terminal", + "anyhow", + "futures", + "helix-graphics", + "helix-input", + "helix-lsp", + "libc", + "log", + "polling", + "thiserror", + "tokio", +] + [[package]] name = "helix-loader" version = "25.7.1" @@ -1627,6 +1758,7 @@ dependencies = [ "helix-event", "helix-graphics", "helix-input", + "helix-integrated-terminal", "helix-loader", "helix-lsp", "helix-stdx", @@ -1656,6 +1788,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "home" version = "0.5.9" @@ -2067,13 +2205,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] +[[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 = "munge" version = "0.4.6" @@ -2130,7 +2277,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", ] @@ -2198,6 +2345,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -2469,6 +2641,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.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3074,6 +3257,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index e80bc7440733..5aaf32d58456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "helix-core", "helix-graphics", "helix-input", + "helix-integrated-terminal", "helix-view", "helix-term", "helix-tui", diff --git a/helix-integrated-terminal/Cargo.toml b/helix-integrated-terminal/Cargo.toml new file mode 100644 index 000000000000..50029b4e8956 --- /dev/null +++ b/helix-integrated-terminal/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "helix-integrated-terminal" +description = "An integrated terminal for Helix, using alacritty_terminal." +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +helix-input = { path = "../helix-input" } +helix-graphics = { path = "../helix-graphics" } +helix-lsp = { path = "../helix-lsp" } + +futures = "0.3" +thiserror.workspace = true +tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } +anyhow = "1" +log = "~0.4" +alacritty_terminal = "0.25.1" +polling = "3.11.0" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2.177" diff --git a/helix-integrated-terminal/src/error.rs b/helix-integrated-terminal/src/error.rs new file mode 100644 index 000000000000..9d319ad7bb38 --- /dev/null +++ b/helix-integrated-terminal/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Pty Error: {0}")] + PtyError(#[from] anyhow::Error), + + #[error("IO Error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Terminal Not Found: {0}")] + TerminalNotFound(u32), + + #[error("MPSC Sender error: {0}")] + SendError(#[from] tokio::sync::mpsc::error::SendError), +} diff --git a/helix-integrated-terminal/src/lib.rs b/helix-integrated-terminal/src/lib.rs new file mode 100644 index 000000000000..b309e2669a4b --- /dev/null +++ b/helix-integrated-terminal/src/lib.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod pty; +pub mod terminal; diff --git a/helix-integrated-terminal/src/pty.rs b/helix-integrated-terminal/src/pty.rs new file mode 100644 index 000000000000..586ad04bb8b8 --- /dev/null +++ b/helix-integrated-terminal/src/pty.rs @@ -0,0 +1,459 @@ +use std::{ + borrow::Cow, + collections::{HashMap, VecDeque}, + io::{self, ErrorKind, Read, Write}, + num::NonZeroUsize, + path::PathBuf, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }, + time::Duration, +}; + +pub type TerminalId = u32; + +use crate::error::Error; +use alacritty_terminal::{ + event::OnResize, + event_loop::Msg, + tty::{self, EventedPty, EventedReadWrite, Options, Pty, Shell}, +}; +use anyhow::Result; +use polling::PollMode; +use tokio::sync::mpsc::UnboundedSender; + +static TERMINAL_ID_SEQ: AtomicU32 = AtomicU32::new(0); + +const READ_BUFFER_SIZE: usize = 0x10_0000; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const PTY_READ_WRITE_TOKEN: usize = 0; +#[cfg(any(target_os = "linux", target_os = "macos"))] +const PTY_CHILD_EVENT_TOKEN: usize = 1; + +#[cfg(target_os = "windows")] +const PTY_READ_WRITE_TOKEN: usize = 2; +#[cfg(target_os = "windows")] +const PTY_CHILD_EVENT_TOKEN: usize = 1; + +pub struct TerminalSender { + tx: UnboundedSender, + poller: Arc, +} + +impl TerminalSender { + pub fn new(tx: UnboundedSender, poller: Arc) -> Self { + Self { tx, poller } + } + + pub async fn send(&self, msg: Msg) { + if let Err(err) = self.tx.send(msg) { + log::error!("{:?}", err); + } + if let Err(err) = self.poller.notify() { + log::error!("{:?}", err); + } + } +} + +pub struct TermConfig { + pub command: Option, + pub arguments: Option>, + pub size: Option<(u16, u16)>, + pub cwd: Option, + pub env: Option>, +} + +impl Default for TermConfig { + fn default() -> Self { + Self { + command: None, + arguments: None, + size: None, + cwd: Some(std::env::current_dir().unwrap()), + env: None, + } + } +} + +struct Terminal { + pty: Pty, + rx: tokio::sync::mpsc::UnboundedReceiver, + tx: tokio::sync::mpsc::UnboundedSender, + pub poller: Arc, + outer_tx: tokio::sync::mpsc::UnboundedSender<(TerminalId, PtyEvent)>, + term_id: TerminalId, +} + +impl Terminal { + /// Create a new terminal from a `TermConfig` + fn new( + cfg: TermConfig, + outer_tx: tokio::sync::mpsc::UnboundedSender<(TerminalId, PtyEvent)>, + ) -> Result { + let poller = polling::Poller::new()?.into(); + + let options = Options { + shell: cfg.command.map(|s| Shell::new(s, Vec::new())), + working_directory: cfg.cwd, + drain_on_exit: true, + env: if let Some(env) = cfg.env { + env + } else { + HashMap::new() + }, + #[cfg(target_os = "windows")] + escape_args: true, // TODO: I have no idea whether this should be true or false + }; + + let pty = tty::new( + &options, + match cfg.size { + Some((r, c)) => alacritty_terminal::event::WindowSize { + num_lines: r, + num_cols: c, + cell_width: 0, + cell_height: 0, + }, + None => alacritty_terminal::event::WindowSize { + num_lines: 24, + num_cols: 80, + cell_width: 0, + cell_height: 0, + }, + }, + 0, + )?; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let term_id = TERMINAL_ID_SEQ.fetch_add(1, Ordering::Relaxed); + + Ok(Terminal { + pty, + rx, + tx, + outer_tx, + poller, + term_id, + }) + } + + fn run(&mut self) { + let mut state = State::default(); + let mut buf = [0u8; READ_BUFFER_SIZE]; + + let poll_opts = PollMode::Level; + let mut interest = polling::Event::readable(0); + + // Register TTY through EventedRW interface. + unsafe { + self.pty + .register(&self.poller, interest, poll_opts) + .unwrap(); + } + + let mut events = polling::Events::with_capacity(NonZeroUsize::new(1024).unwrap()); + + let timeout = Some(Duration::from_secs(6)); + let mut exit_code = None; + 'event_loop: loop { + events.clear(); + if let Err(err) = self.poller.wait(&mut events, timeout) { + match err.kind() { + ErrorKind::Interrupted => continue, + _ => panic!("EventLoop polling error: {err:?}"), + } + } + + // Handle channel events, if there are any. + if !self.drain_recv_channel(&mut state) { + break; + } + + for event in events.iter() { + match event.key { + PTY_CHILD_EVENT_TOKEN => { + if let Some(tty::ChildEvent::Exited(exited_code)) = + self.pty.next_child_event() + { + if let Err(err) = self.pty_read(&mut buf) { + log::error!("{:?}", err); + } + exit_code = exited_code; + break 'event_loop; + } + } + + PTY_READ_WRITE_TOKEN => { + if event.is_interrupt() { + // Don't try to do I/O on a dead PTY. + continue; + } + + if event.readable { + if let Err(err) = self.pty_read(&mut buf) { + // On Linux, a `read` on the master side of a PTY can fail + // with `EIO` if the client side hangs up. In that case, + // just loop back round for the inevitable `Exited` event. + // This sucks, but checking the process is either racy or + // blocking. + #[cfg(target_os = "linux")] + if err.raw_os_error() == Some(libc::EIO) { + continue; + } + + log::error!("Error reading from PTY in event loop: {}", err); + break 'event_loop; + } + } + + if event.writable { + if let Err(_err) = self.pty_write(&mut state) { + // error!( + // "Error writing to PTY in event loop: {}", + // err + // ); + break 'event_loop; + } + } + } + _ => (), + } + } + + // Register write interest if necessary. + let needs_write = state.needs_write(); + if needs_write != interest.writable { + interest.writable = needs_write; + + // Re-register with new interest. + self.pty + .reregister(&self.poller, interest, poll_opts) + .unwrap(); + } + } + let _ = self + .outer_tx + .send((self.term_id, PtyEvent::TerminalStopped(exit_code))); // TODO: Should we be ignoring this? + if let Err(err) = self.pty.deregister(&self.poller) { + log::error!("{:?}", err); + } + } + + /// Drain the channel. + /// + /// Returns `false` when a shutdown message was received. + fn drain_recv_channel(&mut self, state: &mut State) -> bool { + while let Ok(msg) = self.rx.try_recv() { + match msg { + Msg::Input(input) => state.write_list.push_back(input), + Msg::Shutdown => return false, + Msg::Resize(size) => self.pty.on_resize(size), + } + } + + true + } + + #[inline] + fn pty_read(&mut self, buf: &mut [u8]) -> io::Result<()> { + loop { + match self.pty.reader().read(buf) { + Ok(0) => break, + Ok(n) => { + let _ = self + .outer_tx + .send((self.term_id, PtyEvent::UpdateTerminal(buf[..n].to_vec()))); + } + Err(err) => match err.kind() { + ErrorKind::Interrupted | ErrorKind::WouldBlock => { + break; + } + _ => return Err(err), + }, + } + } + Ok(()) + } + + #[inline] + fn pty_write(&mut self, state: &mut State) -> io::Result<()> { + state.ensure_next(); + + 'write_many: while let Some(mut current) = state.take_current() { + 'write_one: loop { + match self.pty.writer().write(current.remaining_bytes()) { + Ok(0) => { + state.set_current(Some(current)); + break 'write_many; + } + Ok(n) => { + current.advance(n); + if current.finished() { + state.goto_next(); + break 'write_one; + } + } + Err(err) => { + state.set_current(Some(current)); + match err.kind() { + ErrorKind::Interrupted | ErrorKind::WouldBlock => { + break 'write_many; + } + _ => return Err(err), + } + } + } + } + } + + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub enum PtyEvent { + UpdateTerminal(Vec), + TerminalStopped(Option), +} + +pub struct TerminalRegistry { + terminals: HashMap, + pub rx: tokio::sync::mpsc::UnboundedReceiver<(TerminalId, PtyEvent)>, + tx: tokio::sync::mpsc::UnboundedSender<(TerminalId, PtyEvent)>, +} + +impl Default for TerminalRegistry { + fn default() -> Self { + Self::new() + } +} + +impl TerminalRegistry { + pub fn new() -> Self { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + Self { + terminals: Default::default(), + tx, + rx, + } + } + + pub fn new_terminal(&mut self, cfg: TermConfig) -> Result { + let tx_outer = self.tx.clone(); + let mut terminal = Terminal::new(cfg, tx_outer)?; + let terminal_id = terminal.term_id.clone(); + let terminal_tx = terminal.tx.clone(); + let poller = terminal.poller.clone(); + let sender = TerminalSender::new(terminal_tx, poller); + self.terminals.insert(terminal_id, sender); + tokio::task::spawn_blocking(move || { + terminal.run(); + }); + Ok(terminal_id) + } + + pub async fn terminate(&mut self, id: TerminalId) -> Result<(), Error> { + let entry = self + .terminals + .get_mut(&id) + .ok_or(Error::TerminalNotFound(id))?; + + entry.send(Msg::Shutdown).await; + Ok(()) + } + + pub async fn write(&mut self, id: TerminalId, data: Cow<'static, [u8]>) -> Result<(), Error> { + let entry = self + .terminals + .get_mut(&id) + .ok_or(Error::TerminalNotFound(id))?; + + entry.send(Msg::Input(data)).await; + Ok(()) + } + + pub async fn resize(&mut self, id: TerminalId, row: u16, col: u16) -> Result<(), Error> { + let entry = self + .terminals + .get_mut(&id) + .ok_or(Error::TerminalNotFound(id))?; + + entry + .send(Msg::Resize(alacritty_terminal::event::WindowSize { + num_lines: row, + num_cols: col, + cell_width: 0, + cell_height: 0, + })) + .await; + + Ok(()) + } +} + +struct Writing { + source: Cow<'static, [u8]>, + written: usize, +} + +impl Writing { + #[inline] + fn new(c: Cow<'static, [u8]>) -> Writing { + Writing { + source: c, + written: 0, + } + } + + #[inline] + fn advance(&mut self, n: usize) { + self.written += n; + } + + #[inline] + fn remaining_bytes(&self) -> &[u8] { + &self.source[self.written..] + } + + #[inline] + fn finished(&self) -> bool { + self.written >= self.source.len() + } +} + +#[derive(Default)] +pub struct State { + write_list: VecDeque>, + writing: Option, +} + +impl State { + #[inline] + fn ensure_next(&mut self) { + if self.writing.is_none() { + self.goto_next(); + } + } + + #[inline] + fn goto_next(&mut self) { + self.writing = self.write_list.pop_front().map(Writing::new); + } + + #[inline] + fn take_current(&mut self) -> Option { + self.writing.take() + } + + #[inline] + fn needs_write(&self) -> bool { + self.writing.is_some() || !self.write_list.is_empty() + } + + #[inline] + fn set_current(&mut self, new: Option) { + self.writing = new; + } +} diff --git a/helix-integrated-terminal/src/terminal.rs b/helix-integrated-terminal/src/terminal.rs new file mode 100644 index 000000000000..1b66af97e17e --- /dev/null +++ b/helix-integrated-terminal/src/terminal.rs @@ -0,0 +1,514 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefCell, RefMut}, + collections::HashMap, +}; + +use alacritty_terminal::{ + event::{Event, EventListener}, + term::{test::TermSize, Config}, + vte::ansi, + Term, +}; +use helix_graphics::{graphics::CursorKind, theme::Color}; +use helix_input::input::{KeyCode, KeyModifiers, MouseEvent}; + +use crate::pty::{PtyEvent, TerminalId, TerminalRegistry}; +use tokio::{select, sync::mpsc}; + +pub fn cursor_kind_from_ansi(shape: ansi::CursorShape) -> CursorKind { + match shape { + ansi::CursorShape::Block => CursorKind::Block, + ansi::CursorShape::Underline => CursorKind::Underline, + ansi::CursorShape::Beam => CursorKind::Bar, + ansi::CursorShape::HollowBlock => CursorKind::Block, + ansi::CursorShape::Hidden => CursorKind::Hidden, + } +} + +pub fn color_from_ansi(col: ansi::Color) -> Color { + match col { + ansi::Color::Named(named) => match named { + ansi::NamedColor::Black => Color::Black, + ansi::NamedColor::Red => Color::Red, + ansi::NamedColor::Green => Color::Green, + ansi::NamedColor::Yellow => Color::Yellow, + ansi::NamedColor::Blue => Color::Blue, + ansi::NamedColor::Magenta => Color::Magenta, + ansi::NamedColor::Cyan => Color::Cyan, + ansi::NamedColor::White => Color::White, + ansi::NamedColor::BrightBlack => Color::Gray, + ansi::NamedColor::BrightRed => Color::LightRed, + ansi::NamedColor::BrightGreen => Color::LightGreen, + ansi::NamedColor::BrightYellow => Color::LightYellow, + ansi::NamedColor::BrightBlue => Color::LightBlue, + ansi::NamedColor::BrightMagenta => Color::LightMagenta, + ansi::NamedColor::BrightCyan => Color::LightCyan, + _ => Color::Reset, + }, + ansi::Color::Spec(c) => Color::Rgb(c.r, c.g, c.b), + ansi::Color::Indexed(idx) => Color::Indexed(idx), + } +} + +pub struct Listener { + term_id: TerminalId, + sender: mpsc::UnboundedSender<(TerminalId, Event)>, +} + +impl EventListener for Listener { + fn send_event(&self, event: Event) { + let _ = self.sender.send((self.term_id, event)); + } +} + +#[derive(Debug, Clone)] +pub enum TerminalEvent { + TitleChange(TerminalId, String), + Update(TerminalId), +} + +pub enum TerminalState { + Initializing, + Normal, + Failed(String), + Terminated(i32), +} + +#[derive(Eq, PartialEq)] +pub enum ChordState { + Normal, + Quit1, +} + +struct TerminalModel { + state: TerminalState, + parser: ansi::Processor, + term: Term, +} + +impl TerminalModel { + #[inline] + fn advance>(&mut self, data: D) { + for b in data { + self.parser.advance(&mut self.term, &[b]); + } + } + + #[inline] + fn resize(&mut self, size: (u16, u16)) { + self.term.resize(TermSize::new(size.1 as _, size.0 as _)); + } +} + +fn resolve_key_event(mut key: helix_input::input::KeyEvent) -> Option<&'static str> { + use helix_input::input::KeyModifiers; + + key.modifiers = + (KeyModifiers::ALT | KeyModifiers::CONTROL | KeyModifiers::SHIFT) & key.modifiers; + + // Generates a `Modifiers` value to check against. + macro_rules! modifiers { + (ctrl) => { + KeyModifiers::CONTROL + }; + + (alt) => { + KeyModifiers::ALT + }; + + (shift) => { + KeyModifiers::SHIFT + }; + + ($mod:ident $(| $($mods:ident)|+)?) => { + modifiers!($mod) $(| modifiers!($($mods)|+) )? + }; + } + + // Generates modifier values for ANSI sequences. + macro_rules! modval { + (shift) => { + // 1 + "2" + }; + (alt) => { + // 2 + "3" + }; + (alt | shift) => { + // 1 + 2 + "4" + }; + (ctrl) => { + // 4 + "5" + }; + (ctrl | shift) => { + // 1 + 4 + "6" + }; + (alt | ctrl) => { + // 2 + 4 + "7" + }; + (alt | ctrl | shift) => { + // 1 + 2 + 4 + "8" + }; + } + + // Generates ANSI sequences to move the cursor by one position. + macro_rules! term_sequence { + // Generate every modifier combination (except meta) + ([all], $evt:ident, $no_mod:literal, $pre:literal, $post:literal) => { + { + term_sequence!([], $evt, $no_mod); + term_sequence!([shift, alt, ctrl], $evt, $pre, $post); + term_sequence!([alt | shift, ctrl | shift, alt | ctrl], $evt, $pre, $post); + term_sequence!([alt | ctrl | shift], $evt, $pre, $post); + return None; + } + }; + // No modifiers + ([], $evt:ident, $no_mod:literal) => { + if $evt.modifiers.is_empty() { + return Some($no_mod); + } + }; + // A single modifier combination + ([$($mod:ident)|+], $evt:ident, $pre:literal, $post:literal) => { + if $evt.modifiers == modifiers!($($mod)|+) { + return Some(concat!($pre, modval!($($mod)|+), $post)); + } + }; + // Break down multiple modifiers into a series of single combination branches + ([$($($mod:ident)|+),+], $evt:ident, $pre:literal, $post:literal) => { + $( + term_sequence!([$($mod)|+], $evt, $pre, $post); + )+ + }; + } + + match key.code { + helix_input::input::KeyCode::Char(c) => { + if key.modifiers == KeyModifiers::CONTROL { + // Convert the character into its index (into a control character). + // In essence, this turns `ctrl+h` into `^h` + let str = match c { + '@' => "\x00", + 'a' => "\x01", + 'b' => "\x02", + 'c' => "\x03", + 'd' => "\x04", + 'e' => "\x05", + 'f' => "\x06", + 'g' => "\x07", + 'h' => "\x08", + 'i' => "\x09", + 'j' => "\x0a", + 'k' => "\x0b", + 'l' => "\x0c", + 'm' => "\x0d", + 'n' => "\x0e", + 'o' => "\x0f", + 'p' => "\x10", + 'q' => "\x11", + 'r' => "\x12", + 's' => "\x13", + 't' => "\x14", + 'u' => "\x15", + 'v' => "\x16", + 'w' => "\x17", + 'x' => "\x18", + 'y' => "\x19", + 'z' => "\x1a", + '[' => "\x1b", + '\\' => "\x1c", + ']' => "\x1d", + '^' => "\x1e", + '_' => "\x1f", + _ => return None, + }; + + Some(str) + } else { + None + } + } + + helix_input::input::KeyCode::Backspace => { + Some(if key.modifiers.contains(KeyModifiers::CONTROL) { + "\x08" // backspace + } else if key.modifiers.contains(KeyModifiers::ALT) { + "\x1b\x7f" + } else { + "\x7f" + }) + } + + helix_input::input::KeyCode::Tab => Some("\x09"), + helix_input::input::KeyCode::Enter => Some("\r"), + helix_input::input::KeyCode::Esc => Some("\x1b"), + + // The following either expands to `\x1b[X` or `\x1b[1;NX` where N is a modifier value + helix_input::input::KeyCode::Up => term_sequence!([all], key, "\x1b[A", "\x1b[1;", "A"), + helix_input::input::KeyCode::Down => term_sequence!([all], key, "\x1b[B", "\x1b[1;", "B"), + helix_input::input::KeyCode::Right => term_sequence!([all], key, "\x1b[C", "\x1b[1;", "C"), + helix_input::input::KeyCode::Left => term_sequence!([all], key, "\x1b[D", "\x1b[1;", "D"), + helix_input::input::KeyCode::Home => term_sequence!([all], key, "\x1bOH", "\x1b[1;", "H"), + helix_input::input::KeyCode::End => term_sequence!([all], key, "\x1bOF", "\x1b[1;", "F"), + helix_input::input::KeyCode::Insert => { + term_sequence!([all], key, "\x1b[2~", "\x1b[2;", "~") + } + helix_input::input::KeyCode::Delete => { + term_sequence!([all], key, "\x1b[3~", "\x1b[3;", "~") + } + _ => None, + } +} + +pub struct TerminalView { + config: Config, + chord_state: ChordState, + pub visible: bool, + pub viewport: (u16, u16), + active_term: Option, + events: mpsc::UnboundedReceiver<(TerminalId, Event)>, + sender: mpsc::UnboundedSender<(TerminalId, Event)>, + pub(crate) registry: TerminalRegistry, + models: HashMap>, +} + +impl TerminalView { + pub fn new() -> TerminalView { + let (sender, events) = mpsc::unbounded_channel(); + + Self { + config: Config::default(), + chord_state: ChordState::Normal, + active_term: None, + visible: false, + viewport: (24, 80), + events, + sender, + registry: TerminalRegistry::new(), + models: Default::default(), + } + } + + pub fn spawn_shell(&mut self, size: (u16, u16)) { + if let Ok(term_id) = self.registry.new_terminal(Default::default()) { + let sender = self.sender.clone(); + let listener = Listener { term_id, sender }; + + let size = TermSize::new(size.1 as _, size.0 as _); + self.active_term = Some(term_id); + self.models.insert( + term_id, + RefCell::new(TerminalModel { + state: TerminalState::Initializing, + parser: ansi::Processor::new(), + term: Term::new(self.config.clone(), &size, listener), + }), + ); + } + } + + pub fn toggle_terminal(&mut self) { + if self.active_term.is_none() { + self.spawn_shell(self.viewport); + } + + if let Some(term_id) = self.active_term { + self.visible = !self.visible; + let _ = self.sender.send((term_id, Event::Wakeup)); + } + } + + #[inline] + pub fn close_active_terminal(&mut self) { + if let Some(term_id) = self.active_term { + self.close_term(term_id) + } + } + + #[inline] + pub fn get_active(&'_ self) -> Option<(TerminalId, Ref<'_, Term>)> { + let id = self.active_term?; + + Some((id, self.get_term(id)?)) + } + + pub fn get_active_mut(&'_ mut self) -> Option<(TerminalId, RefMut<'_, Term>)> { + let id = self.active_term?; + + Some((id, self.get_term_mut(id)?)) + } + + #[inline] + pub fn get_term(&'_ self, id: TerminalId) -> Option>> { + self.models + .get(&id) + .map(|t| Ref::map(t.borrow(), |x| &x.term)) + } + + #[inline] + pub fn get_term_mut(&'_ self, id: TerminalId) -> Option>> { + self.models + .get(&id) + .map(|t| RefMut::map(t.borrow_mut(), |x| &mut x.term)) + } + + pub fn close_term(&mut self, id: TerminalId) { + if let Some(mut term) = self.models.remove(&id) { + if !matches!( + term.get_mut().state, + TerminalState::Failed(_) | TerminalState::Terminated(_) + ) { + let _ = self.registry.terminate(id); + } + + drop(term) + } + } + + async fn handle_key_event( + &mut self, + id: TerminalId, + key: helix_input::input::KeyEvent, + ) -> Result<(), crate::error::Error> { + if self.chord_state == ChordState::Normal + && key.code == KeyCode::Char('q') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + self.chord_state = ChordState::Quit1; + return Ok(()); + } else if self.chord_state == ChordState::Quit1 + && key.code == KeyCode::Char('q') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + self.toggle_terminal(); + return Ok(()); + } else { + self.chord_state = ChordState::Normal; + } + + if let Some(s) = resolve_key_event(key) { + self.registry.write(id, Cow::Borrowed(s.as_bytes())).await?; + } else if let helix_input::input::KeyCode::Char(ch) = key.code { + let mut tmp = [0u8; 4]; + let s = ch.encode_utf8(&mut tmp); + self.registry + .write(id, Cow::Owned(s.as_bytes().to_vec())) + .await?; + } else { + log::warn!("unhandled key event `{:?}`", key); + } + + Ok(()) + } + + async fn handle_input_event_async( + &mut self, + id: TerminalId, + event: &helix_input::input::Event, + ) -> Result<(), crate::error::Error> { + match event { + helix_input::input::Event::FocusGained => (), + helix_input::input::Event::FocusLost => (), + helix_input::input::Event::Key(key) => self.handle_key_event(id, *key).await?, + helix_input::input::Event::Mouse(evt) => self.handle_mouse_event(id, *evt).await?, + helix_input::input::Event::Paste(_) => { /* TODO */ } + helix_input::input::Event::Resize(cols, rows) => { + if let Some(term) = self.models.get_mut(&id) { + let size = (*rows, *cols); + self.viewport = size; + term.get_mut().resize(size); + let _ = self.registry.resize(id, *rows, *cols); + } + } + helix_input::input::Event::IdleTimeout => (), + } + + Ok(()) + } + + pub fn handle_input_event(&mut self, event: &helix_input::input::Event) -> bool { + if let Some(id) = self.active_term { + let _res = helix_lsp::block_on(self.handle_input_event_async(id, event)); + return true; + } + + false + } + + async fn handle_mouse_event( + &mut self, + _id: TerminalId, + _evt: MouseEvent, + ) -> Result<(), crate::error::Error> { + if let Some((_id, _term)) = self.get_active_mut() {} + + Ok(()) + } + + pub async fn poll_event(&mut self) -> Option { + select!( + event = self.events.recv() => { + let (id, event) = event?; + + match event { + Event::Wakeup => Some(TerminalEvent::Update(id)), + Event::Title(title) => Some(TerminalEvent::TitleChange(id, title)), + Event::PtyWrite(data) => { + let _ = self.registry.write(id, Cow::Owned(data.as_bytes().to_vec())).await; + None + } + + // ResetTitle, + // ClipboardStore(ClipboardType, String), + // ClipboardLoad(ClipboardType, Arc String + Sync + Send + 'static>), + // MouseCursorDirty => , + // ColorRequest(usize, Arc String + Sync + Send + 'static>), + // TextAreaSizeRequest(Arc String + Sync + Send + 'static>), + // CursorBlinkingChange, + // Wakeup, + // Bell, + // Exit, + _ => None + } + } + + event = self.registry.rx.recv() => { + let (id, event) = event?; + + match event { + PtyEvent::UpdateTerminal(data) => { + self.models.get(&id)?.borrow_mut().advance(data); + Some(TerminalEvent::Update(id)) + } + // PtyEvent::Error(err) => { + // let term = self.models.get_mut(&id)?; + // term.get_mut().state = TerminalState::Failed(err); + // Some(TerminalEvent::Update(id)) + // } + PtyEvent::TerminalStopped(code) => { + let term = self.models.get_mut(&id)?; + term.get_mut().state = TerminalState::Terminated(code.unwrap_or(0)); // TODO: Should 0 be the default here? + self.active_term = None; + self.visible = false; + Some(TerminalEvent::Update(id)) + } + } + + } + ) + } +} + +impl Default for TerminalView { + fn default() -> Self { + Self::new() + } +} diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db6499080..7a1d31164955 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -678,6 +678,12 @@ impl Application { return true; } } + EditorEvent::TerminalEvent(event) => { + let needs_render = self.editor.handle_virtual_terminal_events(event).await; + if needs_render { + self.render().await; + } + } } false diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 4c12b0239854..92e04df43639 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2,6 +2,7 @@ pub(crate) mod dap; pub(crate) mod lsp; pub(crate) mod syntax; pub(crate) mod typed; +pub(crate) mod vte; pub use dap::*; use futures_util::FutureExt; @@ -18,6 +19,7 @@ use tui::{ widgets::Cell, }; pub use typed::*; +pub use vte::*; use helix_core::{ char_idx_at_visual_offset, @@ -616,6 +618,8 @@ impl MappableCommand { goto_prev_tabstop, "Goto next snippet placeholder", rotate_selections_first, "Make the first selection your primary one", rotate_selections_last, "Make the last selection your primary one", + toggle_terminal, "Toggle integrated terminal", + close_terminal, "Close active terminal", ); } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e7bb0..eeb6dc1cfbb0 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1402,7 +1402,7 @@ fn compute_inlay_hints_for_view( }; let width = label.width(); - let limit = limit.get().into(); + let limit: usize = limit.get().into(); if width > limit { let mut floor_boundary = 0; let mut acc = 0; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b928dd4f28d0..75c94f80d382 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -571,6 +571,20 @@ fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> an Ok(()) } +fn toggle_terminal( + cx: &mut compositor::Context, + _args: Args, + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + cx.editor.terminals.toggle_terminal(); + + Ok(()) +} + fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); @@ -2962,6 +2976,17 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ ..Signature::DEFAULT }, }, + TypableCommand { + name: "term", + aliases: &[], + doc: "Toggle the integrated terminal.", + fun: toggle_terminal, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::DEFAULT + }, + }, TypableCommand { name: "format", aliases: &["fmt"], diff --git a/helix-term/src/commands/vte.rs b/helix-term/src/commands/vte.rs new file mode 100644 index 000000000000..1604e660d6c3 --- /dev/null +++ b/helix-term/src/commands/vte.rs @@ -0,0 +1,9 @@ +use super::Context; + +pub fn toggle_terminal(cx: &mut Context) { + cx.editor.terminals.toggle_terminal(); +} + +pub fn close_terminal(cx: &mut Context) { + cx.editor.terminals.toggle_terminal(); +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107d796..858521828d10 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -41,6 +41,7 @@ pub struct EditorView { pseudo_pending: Vec, pub(crate) last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, + pub(crate) terminal: super::terminal::Terminal, spinners: ProgressSpinners, /// Tracks if the terminal window is focused by reaction to terminal focus events terminal_focused: bool, @@ -65,6 +66,7 @@ impl EditorView { pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, + terminal: super::terminal::Terminal::new(), spinners: ProgressSpinners::default(), terminal_focused: true, } @@ -492,7 +494,7 @@ impl EditorView { let cursor_scope = match mode { Mode::Insert => theme.find_highlight_exact("ui.cursor.insert"), Mode::Select => theme.find_highlight_exact("ui.cursor.select"), - Mode::Normal => theme.find_highlight_exact("ui.cursor.normal"), + _ => theme.find_highlight_exact("ui.cursor.normal"), } .unwrap_or(base_cursor_scope); @@ -1360,6 +1362,10 @@ impl Component for EditorView { event: &Event, context: &mut crate::compositor::Context, ) -> EventResult { + if let Some(result) = self.terminal.handle_event(event, context) { + return result; + } + let mut cx = commands::Context { editor: context.editor, count: None, @@ -1541,9 +1547,21 @@ impl Component for EditorView { editor_area = editor_area.clip_top(1); } + let original_height = editor_area.height; + if cx.editor.terminals.visible { + editor_area = editor_area.clip_bottom(editor_area.height / 2); + } + // if the terminal size suddenly changed, we need to trigger a resize cx.editor.resize(editor_area); + if cx.editor.terminals.visible { + let mut term_area = editor_area; + term_area.height = original_height - editor_area.height; + term_area.y += editor_area.height; + self.terminal.render(term_area, surface, cx); + } + if use_bufferline { Self::render_bufferline(cx.editor, area.with_height(1), surface); } @@ -1624,18 +1642,29 @@ impl Component for EditorView { } } - fn cursor(&self, _area: Rect, editor: &Editor) -> (Option, CursorKind) { - match editor.cursor() { - // all block cursors are drawn manually - (pos, CursorKind::Block) => { - if self.terminal_focused { - (pos, CursorKind::Hidden) - } else { - // use terminal cursor when terminal loses focus - (pos, CursorKind::Underline) + fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { + if editor.terminals.visible { + let mut a = area; + a.y += a.height / 2; + + if let Some(v) = self.terminal.get_cursor(a, editor) { + v + } else { + (None, CursorKind::Hidden) + } + } else { + match editor.cursor() { + // all block cursors are drawn manually + (pos, CursorKind::Block) => { + if self.terminal_focused { + (pos, CursorKind::Hidden) + } else { + // use terminal cursor when terminal loses focus + (pos, CursorKind::Underline) + } } + cursor => cursor, } - cursor => cursor, } } } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 58b6fc008ac6..09b07bcf0f22 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -11,6 +11,7 @@ pub mod popup; pub mod prompt; mod spinner; mod statusline; +mod terminal; mod text; mod text_decorations; diff --git a/helix-term/src/ui/terminal.rs b/helix-term/src/ui/terminal.rs new file mode 100644 index 000000000000..cfdbac3138b4 --- /dev/null +++ b/helix-term/src/ui/terminal.rs @@ -0,0 +1,125 @@ +use std::cell::Cell; + +use helix_core::Position; +use helix_view::{ + graphics::{CursorKind, Rect}, + input, + theme::Modifier, + Editor, +}; +use tui::buffer::Buffer; + +use crate::compositor::{Context, EventResult}; + +pub struct Terminal { + size: Cell<(u16, u16)>, + prev_pressed: Cell, +} + +impl Terminal { + pub fn new() -> Self { + Self { + size: Cell::new((24, 80)), + prev_pressed: Cell::new(false), + } + } + + pub fn render(&self, area: Rect, surface: &mut Buffer, cx: &mut Context) { + if self.size.get() != (area.height, area.width) && area.height > 0 && area.width > 0 { + self.size.set((area.height, area.width)); + + cx.editor + .terminals + .handle_input_event(&input::Event::Resize(self.size.get().1, self.size.get().0)); + } + + if let Some((_, term)) = cx.editor.terminals.get_active() { + let content = term.renderable_content(); + + surface.clear(area); + + for cell in content.display_iter { + if let Some(c) = surface.get_mut( + area.left() + cell.point.column.0 as u16, + area.top() + cell.point.line.0 as u16, + ) { + let style = helix_view::theme::Style::reset() + .bg(helix_view::terminal::color_from_ansi(cell.bg)) + .fg(helix_view::terminal::color_from_ansi(cell.fg)) + .add_modifier(Modifier::from_bits(cell.flags.bits()).unwrap()); + + let style = if let Some(col) = cell.underline_color() { + style.underline_color(helix_view::terminal::color_from_ansi(col)) + } else { + style + }; + + c.reset(); + c.set_char(cell.c); + c.set_style(style); + } + } + } + } + + pub(crate) fn get_cursor( + &self, + area: Rect, + editor: &Editor, + ) -> Option<(Option, CursorKind)> { + editor.terminals.get_active().map(|(_, term)| { + let pt = term.grid().cursor.point; + ( + Some(Position { + row: area.y as usize + pt.line.0 as usize, + col: area.x as usize + pt.column.0 as usize, + }), + helix_view::terminal::cursor_kind_from_ansi(term.cursor_style().shape), + ) + }) + } + + pub(crate) fn handle_event( + &self, + event: &input::Event, + context: &mut Context, + ) -> Option { + if context.editor.terminals.visible { + match event { + input::Event::Key(input::KeyEvent { + code: input::KeyCode::Char('\\'), + .. + }) => { + if self.prev_pressed.get() { + context.editor.terminals.visible = false; + Some(EventResult::Consumed(None)) + } else { + self.prev_pressed.set(true); + + if context.editor.terminals.handle_input_event(event) { + Some(EventResult::Consumed(None)) + } else { + Some(EventResult::Ignored(None)) + } + } + } + + input::Event::Resize(_, _) => Some(EventResult::Ignored(None)), + + event => { + if let input::Event::Key(..) = &event { + self.prev_pressed.set(false); + } + + if context.editor.terminals.handle_input_event(event) { + Some(EventResult::Consumed(None)) + } else { + Some(EventResult::Ignored(None)) + } + } + } + } else { + None + } + } +} diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 6c4c02fc6e17..589ef39c2d10 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -25,6 +25,7 @@ helix-dap = { path = "../helix-dap" } helix-vcs = { path = "../helix-vcs" } helix-graphics = { path = "../helix-graphics" } helix-input = { path = "../helix-input" } +helix-integrated-terminal = { path = "../helix-integrated-terminal" } bitflags.workspace = true anyhow = "1" diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7f8cff9c3e44..3635069b00d2 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -10,6 +10,7 @@ use crate::{ info::Info, input::KeyEvent, register::Registers, + terminal::{TerminalEvent, TerminalView}, theme::{self, Theme}, tree::{self, Tree}, Document, DocumentId, View, ViewId, @@ -1196,6 +1197,7 @@ pub struct Editor { pub idle_timer: Pin>, redraw_timer: Pin>, last_motion: Option, + pub terminals: TerminalView, pub last_completion: Option, last_cwd: Option, @@ -1229,6 +1231,7 @@ pub enum EditorEvent { ConfigEvent(ConfigEvent), LanguageServerMessage((LanguageServerId, Call)), DebuggerEvent((DebugAdapterId, dap::Payload)), + TerminalEvent(TerminalEvent), IdleTimer, Redraw, } @@ -1332,6 +1335,7 @@ impl Editor { last_motion: None, last_completion: None, last_cwd: None, + terminals: TerminalView::new(), config, auto_pairs, exit_code: 0, @@ -2248,6 +2252,9 @@ impl Editor { Some(event) = self.debug_adapters.incoming.next() => { return EditorEvent::DebuggerEvent(event) } + Some(event) = self.terminals.poll_event() => { + return EditorEvent::TerminalEvent(event) + } _ = helix_event::redraw_requested() => { if !self.needs_redraw{ diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 6f3ad1ed2015..e2014a986ae5 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -9,6 +9,7 @@ pub mod completion; pub mod dap; pub mod diagnostics; pub mod lsp; +pub mod vte; pub mod word_index; #[derive(Debug)] diff --git a/helix-view/src/handlers/vte.rs b/helix-view/src/handlers/vte.rs new file mode 100644 index 000000000000..3248ad4a6413 --- /dev/null +++ b/helix-view/src/handlers/vte.rs @@ -0,0 +1,7 @@ +use crate::{terminal::TerminalEvent, Editor}; + +impl Editor { + pub async fn handle_virtual_terminal_events(&mut self, _event: TerminalEvent) -> bool { + true + } +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index b653f274470c..b0794bb8404b 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -20,6 +20,8 @@ pub use helix_input::keyboard; pub use helix_graphics::graphics; pub use helix_graphics::theme; +pub use helix_integrated_terminal::terminal; + use std::num::NonZeroUsize; // uses NonZeroUsize so Option use a byte rather than two