diff --git a/AGENTS.md b/AGENTS.md
index b3f5ee5..8375eba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Catalog data: `emojis` (Unicode emoji), `isolang` (ISO 639, native names), `isocountry` (ISO 3166), `iso_currency` (ISO 4217, also minor units for money).
- Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off.
- macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links.
+- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
## Commands
@@ -107,6 +108,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`.
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
+- A terminal takes Tab and Shift-Tab through bindings under `ElyTerminal`, ahead of focus moves; other keys go through `terminal::keys` in a key listener, and typed text through its input handler.
+- A shell in the gallery starts without startup files and with a fixed prompt, so captures show no user or host.
- gpui 0.2.2 has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`.
- Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first.
- A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks.
@@ -123,3 +126,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-09-25: an entry built from a later chapter's parts lands with that chapter; its line points there. MenuBar goes to Menus, QuickLauncher to Navigation.
- 2026-09-25: SystemNotification is blocked. An unbundled app has no notification center (probed on macOS 27.2), and gpui 0.2.2 has no API.
- 2026-09-25: `unexpected_cfgs` declares `feature = "cargo-clippy"`, which the objc 0.2 macros test.
+- 2026-09-26: Terminal proven with `alacritty_terminal` 0.26: its grid, parser and pseudo-terminal, drawn by Ely. A replayed grid serves recorded output and tests.
diff --git a/Cargo.lock b/Cargo.lock
index e58b1c2..ee98e65 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -51,6 +51,30 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "alacritty_terminal"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
+dependencies = [
+ "base64",
+ "bitflags 2.13.2",
+ "home",
+ "libc",
+ "log",
+ "miow",
+ "parking_lot",
+ "piper",
+ "polling",
+ "regex-automata",
+ "rustix 1.1.5",
+ "rustix-openpty",
+ "signal-hook",
+ "unicode-width",
+ "vte",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "aligned"
version = "0.4.3"
@@ -1338,6 +1362,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"
@@ -1548,6 +1578,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
name = "ely-gpui-component"
version = "0.1.0"
dependencies = [
+ "alacritty_terminal",
"anyhow",
"barcoders",
"block",
@@ -1556,6 +1587,7 @@ dependencies = [
"core-graphics 0.24.0",
"emojis",
"env_logger",
+ "futures",
"gpui",
"image",
"iso_currency",
@@ -3500,6 +3532,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"
@@ -4852,6 +4893,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.5",
+]
+
[[package]]
name = "rustls"
version = "0.23.45"
@@ -5233,6 +5285,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "signal-hook"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -6320,6 +6382,19 @@ 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.2",
+ "cursor-icon",
+ "log",
+ "memchr",
+]
+
[[package]]
name = "waker-fn"
version = "1.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 1b54098..da42b8f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,9 +14,11 @@ runtime_shaders = ["gpui/runtime_shaders"]
test-support = ["gpui/test-support"]
[dependencies]
+alacritty_terminal = { version = "0.26", default-features = false }
anyhow = "1"
barcoders = { version = "2", default-features = false, features = ["std"] }
emojis = "0.9"
+futures = "0.3"
gpui = "0.2.2"
iso_currency = { version = "0.7", features = ["iterator"] }
isocountry = "0.3"
diff --git a/TASKS.md b/TASKS.md
index 6b3e746..97778e2 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -239,3 +239,4 @@ Per-component lines live in `tasks/`. Tags there:
| T17d | 1 | FAIL | a path match marked a project name mid-character; pin and remove also opened the row; number settings showed no places |
| T17d | 2 | PASS | |
| T17e | 1 | PASS | |
+| T18 | 1 | — | pending review |
diff --git a/assets/icons/arrow-down-to-line.svg b/assets/icons/arrow-down-to-line.svg
new file mode 100644
index 0000000..d08fa73
--- /dev/null
+++ b/assets/icons/arrow-down-to-line.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index 5445654..c6fb1e5 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -14,6 +14,7 @@ mod overlays;
mod primitives;
mod shell;
mod tables;
+mod terminal;
mod theme;
mod typography;
@@ -49,6 +50,7 @@ pub const ALL: &[Page] = &[
charts::PAGE,
finance::PAGE,
editor::PAGE,
+ terminal::PAGE,
theme::PAGE,
];
diff --git a/examples/gallery/pages/terminal/blocks.rs b/examples/gallery/pages/terminal/blocks.rs
new file mode 100644
index 0000000..33a9e35
--- /dev/null
+++ b/examples/gallery/pages/terminal/blocks.rs
@@ -0,0 +1,124 @@
+use std::time::Duration;
+
+use ely_gpui_component::{
+ buttons::Button,
+ terminal::{AnsiText, CommandBlock, CommandHistory, CommandState, PastCommand},
+ typography::Caption,
+};
+use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use crate::ui::{keep, row, section, set};
+
+const TESTS: &str = "running 5 tests\r\ntest palette::lookup_blends_toward_white ... \x1b[32mok\x1b[0m\r\ntest palette::unknown_names_fall_back ... \x1b[32mok\x1b[0m\r\n\r\ntest result: \x1b[32mok\x1b[0m. 5 passed; 0 failed; finished in 0.02s";
+
+const FAILED: &str = "\x1b[1;31merror[E0308]\x1b[0m\x1b[1m: mismatched types\x1b[0m\r\n \x1b[1;34m-->\x1b[0m src/main.rs:66:21\r\n \x1b[1;34m|\x1b[0m\r\n\x1b[1;34m66\x1b[0m \x1b[1;34m|\x1b[0m let count: u32 = tints.len();\r\n \x1b[1;34m|\x1b[0m \x1b[1;34m---\x1b[0m \x1b[1;31m^^^^^^^^^^^\x1b[0m \x1b[1;31mexpected `u32`, found `usize`\x1b[0m";
+
+fn history() -> Vec {
+ let past = |command: &str, cwd: &str, when: &str, code| PastCommand {
+ command: command.to_string().into(),
+ cwd: cwd.to_string().into(),
+ when: when.to_string().into(),
+ code,
+ };
+ vec![
+ past("cargo test --lib palette", "~/palette", "just now", Some(0)),
+ past("cargo build", "~/palette", "2 min ago", Some(101)),
+ past("git status --short", "~/palette", "5 min ago", Some(0)),
+ past("rg lookup src", "~/palette", "12 min ago", Some(0)),
+ past(
+ "cargo run --example gallery",
+ "~/Ely-GPUI-Components",
+ "1 h ago",
+ None,
+ ),
+ past(
+ "git log --oneline -5",
+ "~/Ely-GPUI-Components",
+ "yesterday",
+ Some(0),
+ ),
+ ]
+}
+
+pub fn blocks(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let folded = keep("terminal-folded", || false, window, cx);
+ let open = keep("terminal-history", || false, window, cx);
+ let said = keep("terminal-said", || None::, window, cx);
+ let (now_folded, now_open, told) = (*folded.read(cx), *open.read(cx), said.read(cx).clone());
+ let (fold, opener, close, pick, rerun) = (
+ folded.clone(),
+ open.clone(),
+ open.clone(),
+ said.clone(),
+ said.clone(),
+ );
+ section(
+ "CommandBlock / CommandHistory",
+ "Each command a block: where it ran, how it ended and how long it took, its output folding away; copy it or run it again. Past commands open as a palette, found by any of their letters.",
+ cx,
+ )
+ .child(
+ div()
+ .w(px(840.))
+ .flex()
+ .flex_col()
+ .gap_3()
+ .child(CommandBlock::new(
+ "terminal-block-tests",
+ "~/palette",
+ "cargo test --lib palette",
+ TESTS,
+ CommandState::Done {
+ code: 0,
+ took: Duration::from_secs(3),
+ },
+ ))
+ .child(
+ CommandBlock::new(
+ "terminal-block-build",
+ "~/palette",
+ "cargo build",
+ FAILED,
+ CommandState::Done {
+ code: 101,
+ took: Duration::from_secs(9),
+ },
+ )
+ .folded(now_folded)
+ .on_fold(move |folded, _, cx| set(&fold, folded, cx))
+ .on_rerun(move |_, cx| set(&rerun, Some("Ran cargo build again.".into()), cx)),
+ )
+ .child(CommandBlock::new(
+ "terminal-block-running",
+ "~/Ely-GPUI-Components",
+ "cargo run --example gallery",
+ "",
+ CommandState::Running,
+ )),
+ )
+ .child(row().child(Button::new("terminal-history-open", "Search history…").on_click(move |_, _, cx| set(&opener, true, cx))))
+ .children(told.map(Caption::new))
+ .children(now_open.then(|| {
+ CommandHistory::new("terminal-history-palette", history(), move |_, cx| set(&close, false, cx))
+ .on_pick(move |command, _, cx| set(&pick, Some(format!("Ran {command}.").into()), cx))
+ }))
+}
+
+pub fn ansi(cx: &mut App) -> impl IntoElement + use<> {
+ section(
+ "AnsiText",
+ "Text colored by the escape codes in it, as a terminal prints it: the sixteen theme colors, bold, dim, italic, underline, inverse, the 256-color cube and true color. Other codes drop away.",
+ cx,
+ )
+ .child(
+ div()
+ .w(px(840.))
+ .flex()
+ .flex_col()
+ .gap_1()
+ .child(AnsiText::new("\x1b[30m■\x1b[31m■\x1b[32m■\x1b[33m■\x1b[34m■\x1b[35m■\x1b[36m■\x1b[37m■ \x1b[90m■\x1b[91m■\x1b[92m■\x1b[93m■\x1b[94m■\x1b[95m■\x1b[96m■\x1b[97m■\x1b[0m the sixteen, dark and bright"))
+ .child(AnsiText::new("\x1b[1mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munderline\x1b[0m \x1b[9mstruck\x1b[0m \x1b[7m inverse \x1b[0m"))
+ .child(AnsiText::new("\x1b[38;5;24m██\x1b[38;5;30m██\x1b[38;5;36m██\x1b[38;5;72m██\x1b[38;5;108m██\x1b[38;5;144m██\x1b[38;5;180m██\x1b[38;5;216m██\x1b[0m the cube \x1b[38;2;81;130;193m██\x1b[38;2;0;149;137m██\x1b[38;2;170;115;43m██\x1b[0m true color"))
+ .child(AnsiText::new("\x1b[42;30m PASS \x1b[0m palette \x1b[41;97m FAIL \x1b[0m tokens \x1b[43;30m SKIP \x1b[0m motion")),
+ )
+}
diff --git a/examples/gallery/pages/terminal/live.rs b/examples/gallery/pages/terminal/live.rs
new file mode 100644
index 0000000..1e28400
--- /dev/null
+++ b/examples/gallery/pages/terminal/live.rs
@@ -0,0 +1,196 @@
+use std::path::PathBuf;
+
+use ely_gpui_component::{
+ editor::{FindOptions, FindWidget},
+ forms::TextInput,
+ layout::{PaneGroup, PaneId},
+ navigation::{EditorTab, EditorTabs},
+ primitives::IconName,
+ terminal::{Launch, ShellChoice, ShellSelector, Terminal, TerminalToolbar},
+ theme::{ActiveTheme, Radius},
+};
+use gpui::{
+ App, AppContext as _, Axis, Entity, IntoElement, ParentElement, SharedString, Styled, Window,
+ div, prelude::*, px,
+};
+
+use crate::{
+ probe::probe,
+ ui::{keep, section, set},
+};
+
+/// A build's output as a terminal printed it, colors and all.
+const BUILD: &str = "\x1b[1;32m Compiling\x1b[0m ely-gpui-component v0.1.0\r\n\x1b[1;33mwarning\x1b[0m\x1b[1m: unused variable: `spare`\x1b[0m\r\n \x1b[1;34m-->\x1b[0m src/main.rs:47:9\r\n\x1b[1;33mwarning\x1b[0m\x1b[1m: field `hint` is never read\x1b[0m\r\n \x1b[1;34m-->\x1b[0m src/palette.rs:12:5\r\n\x1b[1;32m Finished\x1b[0m `dev` profile [unoptimized + debuginfo] target(s) in 4.21s\r\n\x1b[1;32m Running\x1b[0m `target/debug/palette`\r\n\x1b[38;5;110m◆\x1b[0m 5 tints of accent, the last \x1b[38;2;232;243;252m██\x1b[0m #e8f3fc\r\n\x1b[2mdocs: https://github.com/leidianyayi/Ely-GPUI-Components\x1b[0m\r\n";
+
+const SHELLS: [(&str, &str); 3] = [
+ ("bash", "/bin/bash"),
+ ("zsh", "/bin/zsh"),
+ ("sh", "/bin/sh"),
+];
+
+/// A shell with no startup files, its prompt the folder's name, in this repository.
+fn launch(shell: usize) -> Launch {
+ let (name, program) = SHELLS[shell];
+ let args = match name {
+ "bash" => vec!["--noprofile".into(), "--norc".into(), "-i".into()],
+ "zsh" => vec!["-f".into(), "-i".into()],
+ _ => vec!["-i".into()],
+ };
+ Launch {
+ program: Some((program.into(), args)),
+ cwd: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))),
+ env: vec![
+ ("PS1".into(), "\\W › ".into()),
+ ("PROMPT".into(), "%1~ › ".into()),
+ ("BASH_SILENCE_DEPRECATION_WARNING".into(), "1".into()),
+ ],
+ }
+}
+
+/// A pane group of shells, and the shell each pane holds.
+type Panes = (Entity, Entity)>>);
+
+fn panes(shell: usize, window: &mut Window, cx: &mut App) -> Panes {
+ let sessions = window.use_keyed_state(("terminal-sessions", shell), cx, |_, _| Vec::new());
+ let held = sessions.clone();
+ let group = window.use_keyed_state(("terminal-panes", shell), cx, move |_, _| {
+ PaneGroup::new(move |pane, _, cx| session(&held, pane, shell, cx).into_any_element())
+ });
+ (group, sessions)
+}
+
+/// The shell pane `pane` holds, started the first time the pane draws.
+fn session(
+ sessions: &Entity)>>,
+ pane: PaneId,
+ shell: usize,
+ cx: &mut App,
+) -> Entity {
+ if let Some((_, terminal)) = sessions.read(cx).iter().find(|(id, _)| *id == pane) {
+ return terminal.clone();
+ }
+ let terminal = cx.new(|cx| Terminal::spawn(launch(shell), cx).expect("the shell starts"));
+ sessions.update(cx, |all, _| all.push((pane, terminal.clone())));
+ terminal
+}
+
+fn field(key: &'static str, text: &str, window: &mut Window, cx: &mut App) -> Entity {
+ let seed = text.to_string();
+ window.use_keyed_state(key, cx, move |window, cx| {
+ let mut field = TextInput::new(window, cx);
+ field.set_text(seed, cx);
+ field
+ })
+}
+
+pub fn live(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let tab = keep("terminal-tab", || SharedString::from("shell"), window, cx);
+ let shell = keep("terminal-shell-choice", || 0usize, window, cx);
+ let (now_tab, now_shell) = (tab.read(cx).clone(), *shell.read(cx));
+ let (group, sessions) = panes(now_shell, window, cx);
+ let build = window.use_keyed_state("terminal-build", cx, |_, cx| {
+ Terminal::replay(BUILD.as_bytes(), 92, 12, cx)
+ });
+ let query = field("terminal-find", "warning", window, cx);
+ let options = keep("terminal-options", FindOptions::default, window, cx);
+ let searched = keep(
+ "terminal-searched",
+ || None::<(String, FindOptions)>,
+ window,
+ cx,
+ );
+ let wanted = (query.read(cx).text().to_string(), *options.read(cx));
+ let error = keep("terminal-find-error", || None::, window, cx);
+ if searched.read(cx).as_ref() != Some(&wanted) {
+ let found = build.update(cx, |build, cx| build.find(&wanted.0, wanted.1, cx));
+ set(&error, found.err().map(SharedString::from), cx);
+ set(&searched, Some(wanted.clone()), cx);
+ }
+ let (total, current) = build.read(cx).matches();
+ let focused = group.read(cx).focused();
+ let active = session(&sessions, focused, now_shell, cx);
+ let (pick, choose, splitter, clearer, stopper) = (
+ tab.clone(),
+ shell.clone(),
+ group.clone(),
+ active.clone(),
+ active.clone(),
+ );
+ let shells = ShellSelector::new(
+ "terminal-shells",
+ SHELLS.map(|(name, program)| ShellChoice {
+ name: name.into(),
+ program: program.into(),
+ }),
+ )
+ .default_shell(now_shell)
+ .on_pick(move |ix, _, cx| set(&choose, ix, cx));
+ let on_shell = now_tab.as_ref() == "shell";
+ let exit = active
+ .read(cx)
+ .exit_status()
+ .and_then(|status| status.code());
+ let toolbar = TerminalToolbar::new(
+ "terminal-toolbar",
+ format!("{} — Ely-GPUI-Components", SHELLS[now_shell].0),
+ exit,
+ )
+ .shells(shells)
+ .on_split(move |_, cx| {
+ splitter.update(cx, |group, cx| {
+ let pane = group.focused();
+ group.split(pane, Axis::Horizontal, cx);
+ })
+ })
+ .on_clear(move |_, cx| clearer.update(cx, |terminal, cx| terminal.clear(cx)))
+ .on_stop(move |_, cx| stopper.update(cx, |terminal, _| terminal.write(&b"\x03"[..])));
+ let (step, flip) = (build.clone(), options.clone());
+ let find = FindWidget::new("terminal-find-widget", &query, current, total)
+ .options(*options.read(cx))
+ .on_options(move |next, _, cx| set(&flip, next, cx))
+ .on_step(move |forward, _, cx| step.update(cx, |build, cx| build.step(forward, cx)));
+ let find = match error.read(cx).clone() {
+ Some(message) => find.error(message),
+ None => find,
+ };
+ let theme = cx.theme();
+ section(
+ "Terminal / TerminalTabs / TerminalSplit / TerminalToolbar / ShellSelector / TerminalSearch / TerminalLink",
+ "A real shell on a pseudo-terminal, drawn cell by cell in the theme's sixteen colors. Tabs hold shells and output; split for another shell beside it. Find lights every match through the scrollback. Cmd-press a link or a path:line.",
+ cx,
+ )
+ .child(
+ div()
+ .w(px(840.))
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(theme.colors.border)
+ .overflow_hidden()
+ .child(probe(
+ "terminal-tabs",
+ div().w(px(838.)).child(
+ EditorTabs::new(
+ "terminal-tabs",
+ [
+ EditorTab::new("shell", SHELLS[now_shell].0).icon(IconName::Terminal),
+ EditorTab::new("build", "cargo run").icon(IconName::FileText),
+ ],
+ )
+ .selected(now_tab.clone())
+ .on_select(move |id, _, cx| set(&pick, id.clone(), cx)),
+ ),
+ ))
+ .children(on_shell.then_some(toolbar))
+ .child(probe(
+ "terminal",
+ div().w(px(838.)).h(px(300.)).relative().map(|body| {
+ if on_shell {
+ body.child(group)
+ } else {
+ body.child(build)
+ .child(div().absolute().top_1().right_4().child(find))
+ }
+ }),
+ )),
+ )
+}
diff --git a/examples/gallery/pages/terminal/mod.rs b/examples/gallery/pages/terminal/mod.rs
new file mode 100644
index 0000000..6065d4c
--- /dev/null
+++ b/examples/gallery/pages/terminal/mod.rs
@@ -0,0 +1,44 @@
+mod blocks;
+mod live;
+mod panels;
+
+use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
+
+use super::Page;
+use crate::script::Step;
+
+pub const PAGE: Page = Page {
+ number: 18,
+ slug: "terminal",
+ title: "Terminal",
+ summary: "A real shell drawn cell by cell, with tabs, splits, find and links; command blocks and history; colored output, logs and processes.",
+ render,
+ script: SCRIPT,
+};
+
+const SCRIPT: &[Step] = &[
+ Step::Rest,
+ Step::DownAt("terminal", 200.0, 150.0),
+ Step::UpAt("terminal", 200.0, 150.0),
+ Step::Wait(400),
+ Step::Type("ls -G src"),
+ Step::Key("enter"),
+ Step::Wait(800),
+ Step::Shot("shell"),
+ Step::Key("ctrl-l"),
+ Step::DownAt("terminal-tabs", 155.0, 16.0),
+ Step::UpAt("terminal-tabs", 155.0, 16.0),
+ Step::Wait(400),
+ Step::Shot("find"),
+ Step::Rest,
+];
+
+fn render(window: &mut Window, cx: &mut App) -> AnyElement {
+ div()
+ .child(live::live(window, cx))
+ .child(blocks::blocks(window, cx))
+ .child(blocks::ansi(cx))
+ .child(panels::logs(window, cx))
+ .child(panels::process_list(window, cx))
+ .into_any_element()
+}
diff --git a/examples/gallery/pages/terminal/panels.rs b/examples/gallery/pages/terminal/panels.rs
new file mode 100644
index 0000000..a730ed7
--- /dev/null
+++ b/examples/gallery/pages/terminal/panels.rs
@@ -0,0 +1,163 @@
+use std::rc::Rc;
+
+use ely_gpui_component::{
+ forms::{Input, TextInput},
+ terminal::{LogLevel, LogLine, LogViewer, Process, ProcessList},
+ theme::{ActiveTheme, Radius},
+ typography::Caption,
+};
+use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use crate::ui::{keep, section, set};
+
+/// Two hundred lines of a service's day, the same each time.
+fn log() -> Rc> {
+ let targets = [
+ "ely::shell",
+ "ely::pty",
+ "gpui::window",
+ "ely::store",
+ "net::http",
+ ];
+ let messages = [
+ (LogLevel::Debug, "frame drawn in 4.1 ms"),
+ (LogLevel::Info, "window opened at 1280×820"),
+ (LogLevel::Trace, "key down: cmd-k"),
+ (LogLevel::Info, "\x1b[1mzsh\x1b[0m started, pid 4821"),
+ (LogLevel::Warn, "slow frame: 38 ms, over budget"),
+ (LogLevel::Debug, "layout cached: 212 nodes"),
+ (LogLevel::Info, "GET /api/quotes \x1b[32m200\x1b[0m 18 ms"),
+ (
+ LogLevel::Error,
+ "GET /api/orders \x1b[31m503\x1b[0m retry in 2 s",
+ ),
+ (LogLevel::Info, "saved workspace, 3 panes"),
+ ];
+ Rc::new(
+ (0..200)
+ .map(|ix: usize| {
+ let (level, message) = messages[(ix * 7 + ix / 3) % messages.len()];
+ LogLine {
+ at: format!(
+ "09:{:02}:{:02}.{:03}",
+ ix / 60 % 60,
+ ix % 60,
+ ix * 37 % 1000
+ )
+ .into(),
+ level,
+ target: targets[ix * 3 % targets.len()].into(),
+ message: message.into(),
+ }
+ })
+ .collect(),
+ )
+}
+
+fn field(
+ key: &'static str,
+ placeholder: &'static str,
+ window: &mut Window,
+ cx: &mut App,
+) -> gpui::Entity {
+ window.use_keyed_state(key, cx, move |window, cx| {
+ TextInput::new(window, cx).placeholder(placeholder)
+ })
+}
+
+pub fn logs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let lines = keep("terminal-log", log, window, cx);
+ let least = keep("terminal-least", || LogLevel::Debug, window, cx);
+ let follow = keep("terminal-follow", || true, window, cx);
+ let query = field("terminal-log-query", "Filter lines", window, cx);
+ let (now_lines, now_least, now_follow) =
+ (lines.read(cx).clone(), *least.read(cx), *follow.read(cx));
+ let text = query.read(cx).text().to_string();
+ let (pick, keep_up) = (least.clone(), follow.clone());
+ let theme = cx.theme();
+ section(
+ "LogViewer",
+ "A log from a chosen level up, filtered as you type, following new lines until a scroll up lets go. Long logs draw only the lines in view.",
+ cx,
+ )
+ .child(div().w(px(320.)).child(Input::new(&query)))
+ .child(
+ div()
+ .w(px(840.))
+ .h(px(280.))
+ .p_2()
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(theme.colors.border)
+ .child(
+ LogViewer::new("terminal-log-viewer", now_lines)
+ .least(now_least)
+ .query(text)
+ .follow(now_follow)
+ .on_least(move |level, _, cx| set(&pick, level, cx))
+ .on_follow(move |on, _, cx| set(&keep_up, on, cx)),
+ ),
+ )
+}
+
+fn processes() -> Vec {
+ let process = |pid, name: &str, user: &str, cpu, memory_mb: u64| Process {
+ pid,
+ name: name.to_string().into(),
+ user: user.to_string().into(),
+ cpu,
+ memory: memory_mb * 1024 * 1024,
+ };
+ vec![
+ process(4821, "zsh", "ely", 0.2, 6),
+ process(512, "WindowServer", "_windowserver", 14.6, 412),
+ process(9001, "gallery", "ely", 23.4, 188),
+ process(733, "rust-analyzer", "ely", 41.8, 1_240),
+ process(88, "launchd", "root", 0.1, 12),
+ process(2210, "cargo", "ely", 6.3, 96),
+ process(3302, "Safari", "ely", 8.9, 734),
+ process(3303, "Safari Networking", "ely", 1.4, 88),
+ process(401, "mds_stores", "root", 3.2, 140),
+ process(6120, "codex", "ely", 2.7, 310),
+ ]
+}
+
+pub fn process_list(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let running = keep("terminal-processes", processes, window, cx);
+ let chosen = keep("terminal-chosen", Vec::::new, window, cx);
+ let said = keep("terminal-ended", || None::, window, cx);
+ let query = field("terminal-process-query", "Find a process", window, cx);
+ let (now, picked, told) = (
+ running.read(cx).clone(),
+ chosen.read(cx).clone(),
+ said.read(cx).clone(),
+ );
+ let text = query.read(cx).text().to_string();
+ let (select, end, clear, tell) = (
+ chosen.clone(),
+ running.clone(),
+ chosen.clone(),
+ said.clone(),
+ );
+ section(
+ "ProcessList",
+ "Running processes, busiest first: found by a query, sorted by any column, and the selected ones ended.",
+ cx,
+ )
+ .child(div().w(px(320.)).child(Input::new(&query)))
+ .child(
+ div().w(px(840.)).child(
+ ProcessList::new("terminal-process-list", now)
+ .query(text)
+ .selected(picked)
+ .on_select(move |pids, _, cx| set(&select, pids, cx))
+ .on_end(move |pids, _, cx| {
+ let left: Vec = end.read(cx).iter().filter(|process| !pids.contains(&process.pid)).cloned().collect();
+ set(&end, left, cx);
+ set(&clear, Vec::new(), cx);
+ set(&tell, Some(format!("Ended {} process{}.", pids.len(), if pids.len() == 1 { "" } else { "es" }).into()), cx);
+ }),
+ ),
+ )
+ .children(told.map(Caption::new))
+}
diff --git a/src/lib.rs b/src/lib.rs
index 6f8b384..728c7dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -16,6 +16,7 @@ pub mod overlays;
pub mod primitives;
pub mod shell;
pub mod tables;
+pub mod terminal;
pub mod theme;
pub mod typography;
@@ -40,4 +41,5 @@ pub fn init(cx: &mut App) {
]);
forms::bind_keys(cx);
editor::bind_keys(cx);
+ terminal::bind_keys(cx);
}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 0fc5ffd..bed741c 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -43,6 +43,7 @@ icons! {
AppWindow => "app-window",
Archive => "archive",
ArrowDownLeft => "arrow-down-left",
+ ArrowDownToLine => "arrow-down-to-line",
ArrowDownWideNarrow => "arrow-down-wide-narrow",
ArrowDown => "arrow-down",
ArrowLeft => "arrow-left",
diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs
new file mode 100644
index 0000000..2039e7c
--- /dev/null
+++ b/src/terminal/ansi.rs
@@ -0,0 +1,180 @@
+use std::ops::Range;
+
+use alacritty_terminal::{
+ term::color::Colors,
+ vte::ansi::{Attr, Color, Handler, Processor},
+};
+use gpui::{
+ App, FontStyle, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce,
+ SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div,
+};
+
+use super::colors::Ink;
+use crate::theme::{ActiveTheme, TextSize};
+
+/// How text prints after the escape codes so far.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct Pen {
+ pub fg: Option,
+ pub bg: Option,
+ pub bold: bool,
+ pub dim: bool,
+ pub italic: bool,
+ pub underline: bool,
+ pub strike: bool,
+ pub inverse: bool,
+}
+
+/// Collects the printed text and the pen each stretch of it took.
+#[derive(Default)]
+struct Printed {
+ text: String,
+ spans: Vec<(Range, Pen)>,
+ pen: Pen,
+}
+
+impl Handler for Printed {
+ fn input(&mut self, ch: char) {
+ let start = self.text.len();
+ self.text.push(ch);
+ let end = self.text.len();
+ match self.spans.last_mut() {
+ Some((range, pen)) if range.end == start && *pen == self.pen => range.end = end,
+ _ if self.pen == Pen::default() => {}
+ _ => self.spans.push((start..end, self.pen)),
+ }
+ }
+
+ fn linefeed(&mut self) {
+ self.text.push('\n');
+ }
+
+ fn put_tab(&mut self, count: u16) {
+ self.text.extend(std::iter::repeat_n('\t', count as usize));
+ }
+
+ fn terminal_attribute(&mut self, attr: Attr) {
+ let pen = &mut self.pen;
+ match attr {
+ Attr::Reset => *pen = Pen::default(),
+ Attr::Bold => pen.bold = true,
+ Attr::Dim => pen.dim = true,
+ Attr::Italic => pen.italic = true,
+ Attr::Underline
+ | Attr::DoubleUnderline
+ | Attr::Undercurl
+ | Attr::DottedUnderline
+ | Attr::DashedUnderline => pen.underline = true,
+ Attr::Reverse => pen.inverse = true,
+ Attr::Strike => pen.strike = true,
+ Attr::CancelBold => pen.bold = false,
+ Attr::CancelBoldDim => (pen.bold, pen.dim) = (false, false),
+ Attr::CancelItalic => pen.italic = false,
+ Attr::CancelUnderline => pen.underline = false,
+ Attr::CancelReverse => pen.inverse = false,
+ Attr::CancelStrike => pen.strike = false,
+ Attr::Foreground(color) => pen.fg = Some(color),
+ Attr::Background(color) => pen.bg = Some(color),
+ Attr::BlinkSlow
+ | Attr::BlinkFast
+ | Attr::CancelBlink
+ | Attr::Hidden
+ | Attr::CancelHidden
+ | Attr::UnderlineColor(_) => {}
+ }
+ }
+}
+
+/// The text `source` prints, carriage returns and other codes gone, and the pens that are not plain.
+pub(crate) fn printed(source: &str) -> (String, Vec<(Range, Pen)>) {
+ let mut printed = Printed::default();
+ let mut parser: Processor = Processor::new();
+ parser.advance(&mut printed, source.as_bytes());
+ (printed.text, printed.spans)
+}
+
+impl Pen {
+ fn style(&self, ink: &Ink, rule: gpui::Pixels) -> HighlightStyle {
+ let set = Colors::default();
+ let paint =
+ |color: Option, plain| color.map_or(plain, |color| ink.resolve(color, &set));
+ let (mut fg, mut bg) = (paint(self.fg, ink.fg), paint(self.bg, ink.bg));
+ if self.inverse {
+ std::mem::swap(&mut fg, &mut bg);
+ }
+ if self.dim {
+ fg = fg.opacity(0.7);
+ }
+ HighlightStyle {
+ color: Some(fg),
+ background_color: (self.bg.is_some() || self.inverse).then_some(bg),
+ font_weight: self.bold.then_some(FontWeight::BOLD),
+ font_style: self.italic.then_some(FontStyle::Italic),
+ underline: self.underline.then_some(UnderlineStyle {
+ thickness: rule,
+ color: Some(fg),
+ wavy: false,
+ }),
+ strikethrough: self.strike.then_some(StrikethroughStyle {
+ thickness: rule,
+ color: Some(fg),
+ }),
+ ..HighlightStyle::default()
+ }
+ }
+}
+
+/// Text colored by the escape codes in it, as a terminal would print it; other codes drop away.
+#[derive(IntoElement)]
+pub struct AnsiText {
+ source: SharedString,
+}
+
+impl AnsiText {
+ pub fn new(source: impl Into) -> Self {
+ Self {
+ source: source.into(),
+ }
+ }
+}
+
+impl RenderOnce for AnsiText {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let ink = Ink::new(&theme.colors);
+ let (text, spans) = printed(&self.source);
+ let rule = theme.underline_thickness();
+ let styles: Vec<(Range, HighlightStyle)> = spans
+ .into_iter()
+ .map(|(range, pen)| (range, pen.style(&ink, rule)))
+ .collect();
+ div()
+ .font_family(theme.mono_family.clone())
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(ink.fg)
+ .child(StyledText::new(text).with_highlights(styles))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alacritty_terminal::vte::ansi::NamedColor;
+
+ use super::*;
+
+ #[test]
+ fn codes_become_pens_and_drop_out_of_the_text() {
+ let (text, spans) = printed("\x1b[1;31merror\x1b[0m: bad\r\n\x1b[4mnext\x1b[24m ok");
+ assert_eq!(text, "error: bad\nnext ok");
+ let red = Pen {
+ fg: Some(Color::Named(NamedColor::Red)),
+ bold: true,
+ ..Pen::default()
+ };
+ let under = Pen {
+ underline: true,
+ ..Pen::default()
+ };
+ assert_eq!(spans, [(0..5, red), (11..15, under)]);
+ }
+}
diff --git a/src/terminal/block.rs b/src/terminal/block.rs
new file mode 100644
index 0000000..68bf1b3
--- /dev/null
+++ b/src/terminal/block.rs
@@ -0,0 +1,191 @@
+use std::{rc::Rc, time::Duration};
+
+use gpui::{
+ App, ClipboardItem, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
+ RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
+};
+
+use super::ansi::{AnsiText, printed};
+use crate::{
+ buttons::{ButtonVariant, IconButton},
+ motion::Spinner,
+ primitives::{Disclosure, Icon, IconName},
+ theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
+ typography::{DurationStyle, format},
+};
+
+/// Where a command stands.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum CommandState {
+ Running,
+ Done { code: i32, took: Duration },
+}
+
+type Run = Rc;
+type OnFold = Rc;
+
+/// One command and what it printed, as a block: where it ran, how it ended and how long it took. Its output folds away; the command and output copy, and it runs again.
+#[derive(IntoElement)]
+pub struct CommandBlock {
+ id: ElementId,
+ cwd: SharedString,
+ command: SharedString,
+ output: SharedString,
+ state: CommandState,
+ folded: bool,
+ on_fold: Option,
+ on_rerun: Option,
+}
+
+impl CommandBlock {
+ /// `output` as printed, escape codes and all.
+ pub fn new(
+ id: impl Into,
+ cwd: impl Into,
+ command: impl Into,
+ output: impl Into,
+ state: CommandState,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ cwd: cwd.into(),
+ command: command.into(),
+ output: output.into(),
+ state,
+ folded: false,
+ on_fold: None,
+ on_rerun: None,
+ }
+ }
+
+ pub fn folded(mut self, folded: bool) -> Self {
+ self.folded = folded;
+ self
+ }
+
+ pub fn on_fold(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_fold = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_rerun(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_rerun = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for CommandBlock {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let (mark, outcome): (gpui::AnyElement, SharedString) = match self.state {
+ CommandState::Running => (
+ Spinner::new((self.id.clone(), "spinner"))
+ .size(IconSize::Sm)
+ .into_any_element(),
+ "Running".into(),
+ ),
+ CommandState::Done { code: 0, took } => (
+ Icon::new(IconName::CircleCheck)
+ .size(IconSize::Sm)
+ .color(colors.success)
+ .into_any_element(),
+ format::duration(took.as_secs().max(1), DurationStyle::Compact).into(),
+ ),
+ CommandState::Done { code, took } => (
+ Icon::new(IconName::CircleX)
+ .size(IconSize::Sm)
+ .color(colors.danger)
+ .into_any_element(),
+ format!(
+ "Exit {code} · {}",
+ format::duration(took.as_secs().max(1), DurationStyle::Compact)
+ )
+ .into(),
+ ),
+ };
+ let copy = |key: &'static str, icon, words: &'static str, text: String| {
+ IconButton::new((self.id.clone(), key), icon)
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .tooltip(words)
+ .on_click(move |_, _, cx| {
+ log::info!("command block: {words}");
+ cx.write_to_clipboard(ClipboardItem::new_string(text.clone()))
+ })
+ };
+ let plain = printed(&self.output).0;
+ let (fold, folded) = (self.on_fold, self.folded);
+ let has_output = !plain.trim().is_empty();
+ div()
+ .id(self.id.clone())
+ .flex()
+ .flex_col()
+ .gap_1()
+ .py_2()
+ .pl_3()
+ .pr_2()
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(colors.border)
+ .bg(colors.surface)
+ .text_size(theme.text_size(TextSize::Sm))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .child(mark)
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .flex()
+ .items_baseline()
+ .gap_2()
+ .child(
+ div()
+ .font_family(theme.mono_family.clone())
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(colors.fg)
+ .child(self.command.clone()),
+ )
+ .child(div().text_color(colors.fg_subtle).child(self.cwd)),
+ )
+ .child(div().text_color(colors.fg_muted).child(outcome))
+ .child(copy(
+ "copy-command",
+ IconName::Copy,
+ "Copy command",
+ self.command.to_string(),
+ ))
+ .child(copy(
+ "copy-output",
+ IconName::FileText,
+ "Copy output",
+ plain,
+ ))
+ .children(self.on_rerun.map(|rerun| {
+ IconButton::new((self.id.clone(), "rerun"), IconName::RotateCcw)
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .tooltip("Run again")
+ .on_click(move |_, window, cx| rerun(window, cx))
+ }))
+ .when(has_output, |header| {
+ header.child(
+ div()
+ .id((self.id.clone(), "fold"))
+ .cursor_pointer()
+ .when_some(fold, |toggle, fold| {
+ toggle.on_click(move |_, window, cx| fold(!folded, window, cx))
+ })
+ .child(Disclosure::new((self.id.clone(), "chevron"), !folded)),
+ )
+ }),
+ )
+ .when(has_output && !folded, |block| {
+ block.child(div().pl_6().child(AnsiText::new(self.output)))
+ })
+ }
+}
diff --git a/src/terminal/colors.rs b/src/terminal/colors.rs
new file mode 100644
index 0000000..01174b1
--- /dev/null
+++ b/src/terminal/colors.rs
@@ -0,0 +1,155 @@
+use alacritty_terminal::{
+ term::color::Colors,
+ vte::ansi::{Color, NamedColor, Rgb},
+};
+use gpui::{Hsla, Rgba};
+
+use crate::theme::Palette;
+
+/// What a terminal paints with: the theme's sixteen colors, its text and its ground.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Ink {
+ pub ansi: [Hsla; 16],
+ pub fg: Hsla,
+ pub bg: Hsla,
+}
+
+impl Ink {
+ pub(crate) fn new(colors: &Palette) -> Self {
+ Self {
+ ansi: colors.ansi,
+ fg: colors.fg,
+ bg: colors.surface,
+ }
+ }
+
+ /// A color as painted, after any the program set with OSC 4, 10 or 11.
+ pub(crate) fn resolve(&self, color: Color, set: &Colors) -> Hsla {
+ match color {
+ Color::Spec(spec) => hsla(spec),
+ Color::Indexed(index) => self.indexed(index as usize, set),
+ Color::Named(name) => self.named(name, set),
+ }
+ }
+
+ fn named(&self, name: NamedColor, set: &Colors) -> Hsla {
+ if let Some(spec) = set[name] {
+ return hsla(spec);
+ }
+ match name {
+ NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::Cursor => self.fg,
+ NamedColor::Background => self.bg,
+ NamedColor::DimForeground => self.fg.opacity(0.7),
+ dim if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize)
+ .contains(&(dim as usize)) =>
+ {
+ self.named(dim.to_bright(), set).opacity(0.7)
+ }
+ ansi => self.ansi[ansi as usize],
+ }
+ }
+
+ fn indexed(&self, index: usize, set: &Colors) -> Hsla {
+ if let Some(spec) = set[index] {
+ return hsla(spec);
+ }
+ match index {
+ 0..16 => self.ansi[index],
+ 16..232 => {
+ let step = |level: usize| if level == 0 { 0 } else { 55 + 40 * level as u8 };
+ let cube = index - 16;
+ hsla(Rgb {
+ r: step(cube / 36),
+ g: step(cube / 6 % 6),
+ b: step(cube % 6),
+ })
+ }
+ _ => {
+ let gray = 8 + 10 * (index - 232) as u8;
+ hsla(Rgb {
+ r: gray,
+ g: gray,
+ b: gray,
+ })
+ }
+ }
+ }
+
+ /// Color `index` as an answer to a program's query.
+ pub(crate) fn query(&self, index: usize, set: &Colors) -> Rgb {
+ let color = match index {
+ 256 => self.named(NamedColor::Foreground, set),
+ 257 => self.named(NamedColor::Background, set),
+ 258 => self.named(NamedColor::Cursor, set),
+ index => self.indexed(index.min(255), set),
+ };
+ let rgba = color.to_rgb();
+ let byte = |channel: f32| (channel * 255.0).round() as u8;
+ Rgb {
+ r: byte(rgba.r),
+ g: byte(rgba.g),
+ b: byte(rgba.b),
+ }
+ }
+}
+
+fn hsla(spec: Rgb) -> Hsla {
+ let channel = |value: u8| f32::from(value) / 255.0;
+ Rgba {
+ r: channel(spec.r),
+ g: channel(spec.g),
+ b: channel(spec.b),
+ a: 1.0,
+ }
+ .into()
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{black, white};
+
+ use super::*;
+
+ fn ink() -> Ink {
+ let mut ansi = [black(); 16];
+ ansi[1] = hsla(Rgb { r: 200, g: 0, b: 0 });
+ Ink {
+ ansi,
+ fg: black(),
+ bg: white(),
+ }
+ }
+
+ #[test]
+ fn indexed_colors_follow_the_xterm_cube_and_ramp() {
+ let (ink, set) = (ink(), Colors::default());
+ assert_eq!(ink.query(196, &set), Rgb { r: 255, g: 0, b: 0 });
+ assert_eq!(
+ ink.query(244, &set),
+ Rgb {
+ r: 128,
+ g: 128,
+ b: 128
+ }
+ );
+ assert_eq!(
+ ink.query(1, &set),
+ Rgb { r: 200, g: 0, b: 0 },
+ "the theme's red"
+ );
+ }
+
+ #[test]
+ fn a_color_the_program_set_wins() {
+ let (ink, mut set) = (ink(), Colors::default());
+ set[1] = Some(Rgb { r: 1, g: 2, b: 3 });
+ assert_eq!(
+ ink.resolve(Color::Named(NamedColor::Red), &set),
+ hsla(Rgb { r: 1, g: 2, b: 3 })
+ );
+ assert_eq!(
+ ink.resolve(Color::Named(NamedColor::Background), &set),
+ white()
+ );
+ }
+}
diff --git a/src/terminal/frame.rs b/src/terminal/frame.rs
new file mode 100644
index 0000000..d0d6dcf
--- /dev/null
+++ b/src/terminal/frame.rs
@@ -0,0 +1,206 @@
+use std::ops::Range;
+
+use alacritty_terminal::{
+ event::EventListener,
+ grid::Dimensions,
+ term::{Term, cell::Cell, cell::Flags, color::Colors, search::Match},
+ vte::ansi::CursorShape,
+};
+use gpui::Hsla;
+
+use super::colors::Ink;
+
+/// How a run of cells paints.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Style {
+ pub fg: Hsla,
+ /// None where the terminal's ground shows.
+ pub bg: Option,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+ pub strike: bool,
+}
+
+/// One screen line: a char per cell, and style runs over its bytes.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub(crate) struct Row {
+ pub text: String,
+ pub runs: Vec<(usize, Style)>,
+}
+
+/// Why cells are lit: selected, or a find match, the current one stronger.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Lit {
+ Selected,
+ Found,
+ Current,
+}
+
+/// A terminal's screen as it paints.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct Frame {
+ pub rows: Vec,
+ /// Lit cells: the row, its columns, and why.
+ pub lit: Vec<(usize, Range, Lit)>,
+ /// The cursor's row, column and shape, when it shows.
+ pub cursor: Option<(usize, usize, CursorShape)>,
+}
+
+impl Frame {
+ /// The screen's text, trailing spaces gone.
+ pub(crate) fn text(&self) -> String {
+ let lines: Vec<&str> = self.rows.iter().map(|row| row.text.trim_end()).collect();
+ lines.join("\n").trim_end().to_string()
+ }
+}
+
+/// The screen as it shows now, scrolled back or not, with `found` lit and match `current` stronger.
+pub(crate) fn frame(
+ term: &Term,
+ ink: &Ink,
+ found: &[Match],
+ current: Option,
+) -> Frame {
+ let content = term.renderable_content();
+ let offset = content.display_offset as i32;
+ let mut rows = vec![Row::default(); term.screen_lines()];
+ let mut lit: Vec<(usize, Range, Lit)> = Vec::new();
+ let (top, bottom) = (-offset, rows.len() as i32 - 1 - offset);
+ let near: Vec<(usize, &Match)> = found
+ .iter()
+ .enumerate()
+ .filter(|(_, hit)| hit.end().line.0 >= top && hit.start().line.0 <= bottom)
+ .collect();
+ for indexed in content.display_iter {
+ let (point, cell) = (indexed.point, indexed.cell);
+ let row = (point.line.0 + offset) as usize;
+ let spacer = cell
+ .flags
+ .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
+ push(
+ &mut rows[row],
+ if spacer { ' ' } else { cell.c },
+ style(cell, ink, content.colors),
+ );
+ let why = if content.selection.is_some_and(|range| range.contains(point)) {
+ Some(Lit::Selected)
+ } else {
+ near.iter()
+ .find(|(_, hit)| hit.contains(&point))
+ .map(|(ix, _)| {
+ if Some(*ix) == current {
+ Lit::Current
+ } else {
+ Lit::Found
+ }
+ })
+ };
+ if let Some(why) = why {
+ light(&mut lit, row, point.column.0, why);
+ }
+ }
+ let at = content.cursor.point.line.0 + offset;
+ let shows = content.cursor.shape != CursorShape::Hidden && (0..rows.len() as i32).contains(&at);
+ let cursor = shows.then_some((
+ at as usize,
+ content.cursor.point.column.0,
+ content.cursor.shape,
+ ));
+ Frame { rows, lit, cursor }
+}
+
+fn push(row: &mut Row, ch: char, style: Style) {
+ let len = ch.len_utf8();
+ row.text.push(ch);
+ match row.runs.last_mut() {
+ Some((bytes, last)) if *last == style => *bytes += len,
+ _ => row.runs.push((len, style)),
+ }
+}
+
+fn light(lit: &mut Vec<(usize, Range, Lit)>, row: usize, column: usize, why: Lit) {
+ match lit.last_mut() {
+ Some((at, columns, last)) if *at == row && columns.end == column && *last == why => {
+ columns.end += 1
+ }
+ _ => lit.push((row, column..column + 1, why)),
+ }
+}
+
+fn style(cell: &Cell, ink: &Ink, set: &Colors) -> Style {
+ let flags = cell.flags;
+ let (mut fg, mut bg) = (ink.resolve(cell.fg, set), ink.resolve(cell.bg, set));
+ if flags.contains(Flags::INVERSE) {
+ std::mem::swap(&mut fg, &mut bg);
+ }
+ if flags.contains(Flags::DIM) {
+ fg = fg.opacity(0.7);
+ }
+ if flags.contains(Flags::HIDDEN) {
+ fg = bg;
+ }
+ Style {
+ fg,
+ bg: (bg != ink.bg).then_some(bg),
+ bold: flags.contains(Flags::BOLD),
+ italic: flags.contains(Flags::ITALIC),
+ underline: flags.intersects(Flags::ALL_UNDERLINES),
+ strike: flags.contains(Flags::STRIKEOUT),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alacritty_terminal::{
+ event::VoidListener,
+ term::{Config, test::TermSize},
+ vte::ansi::Processor,
+ };
+ use gpui::{black, white};
+
+ use super::*;
+
+ fn replayed(bytes: &[u8]) -> Term {
+ let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
+ let mut parser: Processor = Processor::new();
+ parser.advance(&mut term, bytes);
+ term
+ }
+
+ fn ink() -> Ink {
+ let mut ansi = [black(); 16];
+ ansi[1] = gpui::red();
+ Ink {
+ ansi,
+ fg: black(),
+ bg: white(),
+ }
+ }
+
+ #[test]
+ fn cells_join_into_runs_of_one_style() {
+ let term = replayed(b"\x1b[31mred\x1b[0m ok\r\n\x1b[7minv\x1b[0m");
+ let shown = frame(&term, &ink(), &[], None);
+ assert_eq!(shown.text(), "red ok\ninv");
+ let (bytes, red) = shown.rows[0].runs[0];
+ assert_eq!((bytes, red.fg, red.bg), (3, gpui::red(), None));
+ let (_, inverse) = shown.rows[1].runs[0];
+ assert_eq!(
+ (inverse.fg, inverse.bg),
+ (white(), Some(black())),
+ "inverse swaps"
+ );
+ assert_eq!(shown.cursor, Some((1, 3, CursorShape::Block)));
+ }
+
+ #[test]
+ fn a_wide_char_keeps_the_columns_after_it() {
+ let term = replayed("中a".as_bytes());
+ let shown = frame(&term, &ink(), &[], None);
+ assert_eq!(
+ shown.rows[0].text.chars().take(3).collect::(),
+ "中 a"
+ );
+ }
+}
diff --git a/src/terminal/history.rs b/src/terminal/history.rs
new file mode 100644
index 0000000..75a07ec
--- /dev/null
+++ b/src/terminal/history.rs
@@ -0,0 +1,117 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
+};
+
+use crate::{
+ navigation::{Group, Palette, Row, fuzzy, marked, query_field},
+ primitives::IconName,
+ theme::ActiveTheme,
+};
+
+/// A command run before: what, where, when, and how it ended when that is known.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PastCommand {
+ pub command: SharedString,
+ pub cwd: SharedString,
+ pub when: SharedString,
+ pub code: Option,
+}
+
+type OnPick = Rc;
+type Run = Rc;
+
+/// Commands run before, newest first, found by any of their letters and picked to run again: a palette over the terminal.
+#[derive(IntoElement)]
+pub struct CommandHistory {
+ id: ElementId,
+ commands: Vec,
+ on_pick: Option,
+ on_close: Run,
+}
+
+impl CommandHistory {
+ /// `commands` newest first.
+ pub fn new(
+ id: impl Into,
+ commands: impl IntoIterator- ,
+ on_close: impl Fn(&mut Window, &mut App) + 'static,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ commands: commands.into_iter().collect(),
+ on_pick: None,
+ on_close: Rc::new(on_close),
+ }
+ }
+
+ pub fn on_pick(
+ mut self,
+ handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_pick = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for CommandHistory {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let input = query_field(&self.id, "Search past commands", window, cx);
+ let query = input.read(cx).text().trim().to_string();
+ let colors = cx.theme().colors.clone();
+ let mut found: Vec<(i32, Row)> = self
+ .commands
+ .iter()
+ .filter_map(|past| {
+ let (score, hits) = if query.is_empty() {
+ (0, Vec::new())
+ } else {
+ let fit = fuzzy(&query, &past.command)?;
+ (fit.score, fit.hits)
+ };
+ let icon = match past.code {
+ Some(0) => IconName::CircleCheck,
+ Some(_) => IconName::CircleX,
+ None => IconName::History,
+ };
+ Some((
+ score,
+ Row {
+ value: past.command.clone(),
+ icon: Some(icon),
+ label: marked(past.command.clone(), hits, cx),
+ detail: Some(
+ div()
+ .text_color(colors.fg_subtle)
+ .child(past.cwd.clone())
+ .into_any_element(),
+ ),
+ end: Some(
+ div()
+ .text_color(colors.fg_subtle)
+ .child(past.when.clone())
+ .into_any_element(),
+ ),
+ },
+ ))
+ })
+ .collect();
+ if !query.is_empty() {
+ found.sort_by(|(a, _), (b, _)| b.cmp(a));
+ }
+ Palette {
+ id: self.id,
+ input,
+ groups: vec![Group {
+ title: None,
+ rows: found.into_iter().map(|(_, row)| row).collect(),
+ }],
+ start: 0,
+ empty: "No past command fits".into(),
+ on_pick: self.on_pick,
+ on_close: self.on_close,
+ }
+ .overlay(window, cx)
+ }
+}
diff --git a/src/terminal/input.rs b/src/terminal/input.rs
new file mode 100644
index 0000000..e77700c
--- /dev/null
+++ b/src/terminal/input.rs
@@ -0,0 +1,295 @@
+use std::ops::Range;
+
+use alacritty_terminal::{
+ grid::Scroll,
+ index::{Column, Line, Point as Cell, Side},
+ selection::{Selection, SelectionType},
+ term::TermMode,
+};
+use gpui::{
+ App, Bounds, ClipboardItem, Context, EntityInputHandler, InteractiveElement, KeyBinding,
+ KeyDownEvent, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels,
+ Point, ScrollWheelEvent, UTF16Selection, Window, actions,
+};
+
+use super::{
+ keys,
+ links::{Target, links},
+ view::{Terminal, TerminalEvent},
+};
+
+actions!(
+ ely_terminal,
+ [
+ SendTab,
+ SendBackTab,
+ Copy,
+ Paste,
+ Clear,
+ PageUp,
+ PageDown,
+ Find
+ ]
+);
+
+pub(crate) const CONTEXT: &str = "ElyTerminal";
+
+/// Tab and Shift-Tab go to the program here, ahead of focus moves; the rest are the app's own.
+pub(crate) fn bind_keys(cx: &mut App) {
+ let context = Some(CONTEXT);
+ let (copy, paste, clear, find) = if cfg!(target_os = "macos") {
+ ("cmd-c", "cmd-v", "cmd-k", "cmd-f")
+ } else {
+ (
+ "ctrl-shift-c",
+ "ctrl-shift-v",
+ "ctrl-shift-k",
+ "ctrl-shift-f",
+ )
+ };
+ cx.bind_keys([
+ KeyBinding::new("tab", SendTab, context),
+ KeyBinding::new("shift-tab", SendBackTab, context),
+ KeyBinding::new(copy, Copy, context),
+ KeyBinding::new(paste, Paste, context),
+ KeyBinding::new(clear, Clear, context),
+ KeyBinding::new("shift-pageup", PageUp, context),
+ KeyBinding::new("shift-pagedown", PageDown, context),
+ KeyBinding::new(find, Find, context),
+ ]);
+}
+
+pub(crate) fn listen(root: E, cx: &mut Context) -> E {
+ root.on_action(cx.listener(|terminal, _: &SendTab, _, cx| terminal.input(b"\t".to_vec(), cx)))
+ .on_action(
+ cx.listener(|terminal, _: &SendBackTab, _, cx| terminal.input(b"\x1b[Z".to_vec(), cx)),
+ )
+ .on_action(cx.listener(|terminal, _: &Copy, _, cx| terminal.copy(cx)))
+ .on_action(cx.listener(|terminal, _: &Paste, _, cx| terminal.paste(cx)))
+ .on_action(cx.listener(|terminal, _: &Clear, _, cx| terminal.clear(cx)))
+ .on_action(cx.listener(|terminal, _: &PageUp, _, cx| terminal.scroll(Scroll::PageUp, cx)))
+ .on_action(
+ cx.listener(|terminal, _: &PageDown, _, cx| terminal.scroll(Scroll::PageDown, cx)),
+ )
+ .on_action(cx.listener(|_, _: &Find, _, cx| cx.emit(TerminalEvent::Find)))
+ .on_key_down(cx.listener(Terminal::key_down))
+ .on_mouse_down(MouseButton::Left, cx.listener(Terminal::press))
+ .on_mouse_move(cx.listener(Terminal::moved))
+ .on_mouse_up(
+ MouseButton::Left,
+ cx.listener(|terminal, _, _, _| terminal.selecting = false),
+ )
+ .on_scroll_wheel(cx.listener(Terminal::wheel))
+ .on_modifiers_changed(
+ cx.listener(|terminal, event: &ModifiersChangedEvent, _, cx| {
+ if !event.modifiers.platform && terminal.hovered.take().is_some() {
+ cx.notify();
+ }
+ }),
+ )
+}
+
+impl Terminal {
+ /// Bytes from the keyboard: back to the newest line, no selection.
+ pub(crate) fn input(&mut self, bytes: Vec, cx: &mut Context) {
+ {
+ let mut term = self.term.lock();
+ term.scroll_display(Scroll::Bottom);
+ term.selection = None;
+ }
+ self.write(bytes);
+ cx.notify();
+ }
+
+ fn key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) {
+ let app_cursor = self.term.lock().mode().contains(TermMode::APP_CURSOR);
+ if let Some(bytes) = keys::bytes(&event.keystroke, app_cursor) {
+ self.input(bytes, cx);
+ cx.stop_propagation();
+ }
+ }
+
+ fn copy(&mut self, cx: &mut Context) {
+ if let Some(text) = self.term.lock().selection_to_string() {
+ log::info!("terminal: copied {} characters", text.chars().count());
+ 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 bracketed = self.term.lock().mode().contains(TermMode::BRACKETED_PASTE);
+ self.input(keys::paste(&text, bracketed), cx);
+ }
+
+ /// The cell under a window point, and which half of it.
+ fn cell_at(&self, position: Point) -> (usize, Cell, Side) {
+ let x = ((position.x - self.origin.x) / self.cell.width).max(0.0);
+ let y = ((position.y - self.origin.y) / self.cell.height).max(0.0);
+ let (columns, lines) = self.size;
+ let (column, row) = ((x as usize).min(columns - 1), (y as usize).min(lines - 1));
+ let offset = self.term.lock().grid().display_offset() as i32;
+ let side = if x.fract() < 0.5 {
+ Side::Left
+ } else {
+ Side::Right
+ };
+ (
+ row,
+ Cell::new(Line(row as i32 - offset), Column(column)),
+ side,
+ )
+ }
+
+ /// The link a row shows at `column`: its columns and target.
+ fn link_at(&self, row: usize, column: usize) -> Option<(usize, Range, Target)> {
+ let text = &self.shown.rows.get(row)?.text;
+ links(text).into_iter().find_map(|(bytes, target)| {
+ let start = text[..bytes.start].chars().count();
+ let end = start + text[bytes].chars().count();
+ (start..end)
+ .contains(&column)
+ .then_some((row, start..end, target))
+ })
+ }
+
+ fn press(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context) {
+ let (row, cell, side) = self.cell_at(event.position);
+ if event.modifiers.platform
+ && let Some((_, _, target)) = self.link_at(row, cell.column.0)
+ {
+ log::info!("terminal: open {target:?}");
+ match &target {
+ Target::Url(url) => cx.open_url(url),
+ Target::Path { .. } => cx.emit(TerminalEvent::Open(target)),
+ }
+ return;
+ }
+ let kind = match event.click_count {
+ 2 => SelectionType::Semantic,
+ count if count >= 3 => SelectionType::Lines,
+ _ => SelectionType::Simple,
+ };
+ self.term.lock().selection = Some(Selection::new(kind, cell, side));
+ self.selecting = true;
+ cx.notify();
+ }
+
+ fn moved(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) {
+ let (row, cell, side) = self.cell_at(event.position);
+ if self.selecting && event.pressed_button == Some(MouseButton::Left) {
+ if let Some(selection) = self.term.lock().selection.as_mut() {
+ selection.update(cell, side);
+ }
+ cx.notify();
+ return;
+ }
+ let hovered = event
+ .modifiers
+ .platform
+ .then(|| self.link_at(row, cell.column.0))
+ .flatten();
+ if hovered != self.hovered {
+ self.hovered = hovered;
+ cx.notify();
+ }
+ }
+
+ fn wheel(&mut self, event: &ScrollWheelEvent, _: &mut Window, cx: &mut Context) {
+ self.rest += event.delta.pixel_delta(self.cell.height).y;
+ let lines = (self.rest / self.cell.height).trunc() as i32;
+ if lines == 0 {
+ return;
+ }
+ self.rest -= self.cell.height * lines as f32;
+ let mode = *self.term.lock().mode();
+ if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
+ let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) {
+ (true, true) => b"\x1bOA",
+ (true, false) => b"\x1b[A",
+ (false, true) => b"\x1bOB",
+ (false, false) => b"\x1b[B",
+ };
+ self.write(arrow.repeat(lines.unsigned_abs() as usize));
+ } else {
+ self.scroll(Scroll::Delta(lines), cx);
+ }
+ }
+}
+
+impl EntityInputHandler for Terminal {
+ fn text_for_range(
+ &mut self,
+ _: Range,
+ _: &mut Option>,
+ _: &mut Window,
+ _: &mut Context,
+ ) -> Option {
+ None
+ }
+
+ fn selected_text_range(
+ &mut self,
+ _: bool,
+ _: &mut Window,
+ _: &mut Context,
+ ) -> Option {
+ Some(UTF16Selection {
+ range: 0..0,
+ reversed: false,
+ })
+ }
+
+ fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> {
+ None
+ }
+
+ fn unmark_text(&mut self, _: &mut Window, _: &mut Context) {}
+
+ fn replace_text_in_range(
+ &mut self,
+ _: Option>,
+ text: &str,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.input(text.as_bytes().to_vec(), cx);
+ }
+
+ fn replace_and_mark_text_in_range(
+ &mut self,
+ _: Option>,
+ _: &str,
+ _: Option>,
+ _: &mut Window,
+ _: &mut Context,
+ ) {
+ }
+
+ fn bounds_for_range(
+ &mut self,
+ _: Range,
+ _: Bounds,
+ _: &mut Window,
+ _: &mut Context,
+ ) -> Option> {
+ let (row, column, _) = self.shown.cursor?;
+ let origin = self.origin
+ + gpui::point(
+ self.cell.width * column as f32,
+ self.cell.height * row as f32,
+ );
+ Some(Bounds::new(origin, self.cell))
+ }
+
+ fn character_index_for_point(
+ &mut self,
+ _: Point,
+ _: &mut Window,
+ _: &mut Context,
+ ) -> Option {
+ None
+ }
+}
diff --git a/src/terminal/keys.rs b/src/terminal/keys.rs
new file mode 100644
index 0000000..b3ba8cd
--- /dev/null
+++ b/src/terminal/keys.rs
@@ -0,0 +1,129 @@
+use gpui::Keystroke;
+
+/// The bytes a key sends to the shell as xterm does, or none when the app keeps it or text input types it.
+pub(crate) fn bytes(keystroke: &Keystroke, app_cursor: bool) -> Option> {
+ let held = &keystroke.modifiers;
+ if held.platform {
+ return None;
+ }
+ let code = 1 + u8::from(held.shift) + 2 * u8::from(held.alt) + 4 * u8::from(held.control);
+ let cursor = |end: char| match (code, app_cursor) {
+ (1, true) => format!("\x1bO{end}"),
+ (1, false) => format!("\x1b[{end}"),
+ _ => format!("\x1b[1;{code}{end}"),
+ };
+ let tilde = |number: u8| match code {
+ 1 => format!("\x1b[{number}~"),
+ _ => format!("\x1b[{number};{code}~"),
+ };
+ let function = |end: char| match code {
+ 1 => format!("\x1bO{end}"),
+ _ => format!("\x1b[1;{code}{end}"),
+ };
+ let sent = match keystroke.key.as_str() {
+ "enter" if held.alt => "\x1b\r".to_string(),
+ "enter" => "\r".to_string(),
+ "backspace" if held.control => "\x08".to_string(),
+ "backspace" if held.alt => "\x1b\x7f".to_string(),
+ "backspace" => "\x7f".to_string(),
+ "tab" if held.shift => "\x1b[Z".to_string(),
+ "tab" => "\t".to_string(),
+ "escape" => "\x1b".to_string(),
+ "up" => cursor('A'),
+ "down" => cursor('B'),
+ "right" => cursor('C'),
+ "left" => cursor('D'),
+ "home" => cursor('H'),
+ "end" => cursor('F'),
+ "insert" => tilde(2),
+ "delete" => tilde(3),
+ "pageup" => tilde(5),
+ "pagedown" => tilde(6),
+ "f1" => function('P'),
+ "f2" => function('Q'),
+ "f3" => function('R'),
+ "f4" => function('S'),
+ "f5" => tilde(15),
+ "f6" => tilde(17),
+ "f7" => tilde(18),
+ "f8" => tilde(19),
+ "f9" => tilde(20),
+ "f10" => tilde(21),
+ "f11" => tilde(23),
+ "f12" => tilde(24),
+ key if held.control => return control(key, held.alt),
+ _ => return None,
+ };
+ Some(sent.into_bytes())
+}
+
+/// Ctrl with a letter or symbol: its control code, after Escape when Alt is held too.
+fn control(key: &str, alt: bool) -> Option> {
+ let code = match key {
+ "space" | "@" | "2" => 0,
+ "[" | "3" => 0x1b,
+ "\\" | "4" => 0x1c,
+ "]" | "5" => 0x1d,
+ "^" | "6" => 0x1e,
+ "_" | "-" | "7" => 0x1f,
+ "?" | "8" => 0x7f,
+ letter if letter.len() == 1 && letter.as_bytes()[0].is_ascii_lowercase() => {
+ letter.as_bytes()[0] - b'a' + 1
+ }
+ _ => return None,
+ };
+ Some(if alt { vec![0x1b, code] } else { vec![code] })
+}
+
+/// Pasted text as the shell takes it: bracketed when it asked, line ends as carriage returns.
+pub(crate) fn paste(text: &str, bracketed: bool) -> Vec {
+ let text = text.replace("\r\n", "\r").replace('\n', "\r");
+ if bracketed {
+ format!("\x1b[200~{}\x1b[201~", text.replace('\x1b', "")).into_bytes()
+ } else {
+ text.into_bytes()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sent(keys: &str, app_cursor: bool) -> Option> {
+ bytes(&Keystroke::parse(keys).expect("a keystroke"), app_cursor)
+ }
+
+ #[test]
+ fn keys_send_what_xterm_sends() {
+ assert_eq!(sent("enter", false), Some(b"\r".to_vec()));
+ assert_eq!(sent("up", false), Some(b"\x1b[A".to_vec()));
+ assert_eq!(
+ sent("up", true),
+ Some(b"\x1bOA".to_vec()),
+ "application cursor keys"
+ );
+ assert_eq!(sent("shift-up", true), Some(b"\x1b[1;2A".to_vec()));
+ assert_eq!(sent("ctrl-c", false), Some(vec![3]));
+ assert_eq!(sent("ctrl-alt-a", false), Some(vec![0x1b, 1]));
+ assert_eq!(sent("ctrl-[", false), Some(vec![0x1b]));
+ assert_eq!(sent("f5", false), Some(b"\x1b[15~".to_vec()));
+ assert_eq!(sent("ctrl-delete", false), Some(b"\x1b[3;5~".to_vec()));
+ assert_eq!(sent("shift-tab", false), Some(b"\x1b[Z".to_vec()));
+ }
+
+ #[test]
+ fn app_shortcuts_and_typed_text_stay_out() {
+ assert_eq!(sent("cmd-c", false), None);
+ assert_eq!(sent("a", false), None, "text input types letters");
+ assert_eq!(sent("ctrl-1", false), None);
+ }
+
+ #[test]
+ fn pastes_bracket_when_asked() {
+ assert_eq!(paste("a\nb", false), b"a\rb".to_vec());
+ assert_eq!(
+ paste("a\x1b[201~b", true),
+ b"\x1b[200~a[201~b\x1b[201~".to_vec()
+ );
+ }
+}
diff --git a/src/terminal/links.rs b/src/terminal/links.rs
new file mode 100644
index 0000000..62a2a6f
--- /dev/null
+++ b/src/terminal/links.rs
@@ -0,0 +1,141 @@
+use std::{ops::Range, sync::LazyLock};
+
+use gpui::SharedString;
+use regex::Regex;
+
+/// What a link in terminal text points at.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Target {
+ Url(SharedString),
+ /// A file, with the line and column a compiler or a grep names, counted from one.
+ Path {
+ path: SharedString,
+ line: Option,
+ column: Option,
+ },
+}
+
+static URL: LazyLock = LazyLock::new(|| {
+ Regex::new(r#"\b(?:https?|file)://[^\s<>"'`]+"#).expect("the URL pattern compiles")
+});
+
+static PATH: LazyLock = LazyLock::new(|| {
+ Regex::new(r"(?:\.{1,2}/|/|~/)?(?:[\w.-]+/)+[\w.-]+\.\w+(?::(\d+))?(?::(\d+))?")
+ .expect("the path pattern compiles")
+});
+
+/// Links in one line of text: URLs, and paths with a folder and an extension. Byte ranges, in order.
+pub fn links(text: &str) -> Vec<(Range, Target)> {
+ let mut found: Vec<(Range, Target)> = URL
+ .find_iter(text)
+ .map(|hit| {
+ let end = hit.start() + trimmed(hit.as_str()).len();
+ let url = &text[hit.start()..end];
+ (hit.start()..end, Target::Url(url.to_string().into()))
+ })
+ .collect();
+ for hit in PATH.captures_iter(text) {
+ let whole = hit.get(0).expect("a capture has its match");
+ if found
+ .iter()
+ .any(|(range, _)| range.contains(&whole.start()))
+ {
+ continue;
+ }
+ let number = |group: usize| {
+ hit.get(group)
+ .and_then(|digits| digits.as_str().parse().ok())
+ };
+ let (line, column) = (number(1), number(2));
+ let suffix = [hit.get(1), hit.get(2)]
+ .iter()
+ .flatten()
+ .map(|digits| digits.len() + 1)
+ .sum::();
+ let path = &text[whole.start()..whole.end() - suffix];
+ found.push((
+ whole.range(),
+ Target::Path {
+ path: path.to_string().into(),
+ line,
+ column,
+ },
+ ));
+ }
+ found.sort_by_key(|(range, _)| range.start);
+ found
+}
+
+/// A URL without the punctuation that ends its sentence, keeping a closing bracket it opened.
+fn trimmed(url: &str) -> &str {
+ let mut end = url.len();
+ while let Some(last) = url[..end].chars().last() {
+ let open = match last {
+ '.' | ',' | ';' | ':' | '!' | '?' => None,
+ ')' => Some('('),
+ ']' => Some('['),
+ _ => break,
+ };
+ if let Some(open) = open
+ && url[..end].matches(open).count() >= url[..end].matches(last).count()
+ {
+ break;
+ }
+ end -= last.len_utf8();
+ }
+ &url[..end]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn urls_drop_the_punctuation_that_ends_their_sentence() {
+ let found =
+ links("see https://ely.dev/docs. or (https://a.b/c) or https://w.org/Rust_(language)");
+ let urls: Vec<_> = found
+ .iter()
+ .map(|(_, target)| match target {
+ Target::Url(url) => url.to_string(),
+ other => panic!("a URL, not {other:?}"),
+ })
+ .collect();
+ assert_eq!(
+ urls,
+ [
+ "https://ely.dev/docs",
+ "https://a.b/c",
+ "https://w.org/Rust_(language)"
+ ]
+ );
+ }
+
+ #[test]
+ fn paths_carry_the_line_and_column_a_compiler_names() {
+ let text = " --> src/main.rs:46:22";
+ let found = links(text);
+ assert_eq!(found.len(), 1);
+ let (range, target) = &found[0];
+ assert_eq!(&text[range.clone()], "src/main.rs:46:22");
+ assert_eq!(
+ *target,
+ Target::Path {
+ path: "src/main.rs".into(),
+ line: Some(46),
+ column: Some(22),
+ }
+ );
+ assert!(links("a.b and v1.2").is_empty(), "a path needs a folder");
+ let huge = links("src/a.rs:99999999999");
+ assert_eq!(
+ huge[0].1,
+ Target::Path {
+ path: "src/a.rs".into(),
+ line: None,
+ column: None
+ },
+ "no line past u32"
+ );
+ }
+}
diff --git a/src/terminal/logs.rs b/src/terminal/logs.rs
new file mode 100644
index 0000000..f7093a7
--- /dev/null
+++ b/src/terminal/logs.rs
@@ -0,0 +1,303 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
+ ScrollDelta, ScrollStrategy, ScrollWheelEvent, SharedString, Styled, UniformListScrollHandle,
+ Window, div, prelude::*, uniform_list,
+};
+
+use super::ansi::{AnsiText, printed};
+use crate::{
+ buttons::{SegmentedControl, ToggleButton, ToggleItem},
+ primitives::IconName,
+ theme::{ActiveTheme, ControlSize, Palette, TextSize},
+ typography::format,
+};
+
+/// How loud a log line is, quietest first.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum LogLevel {
+ Trace,
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
+
+const LEVELS: [LogLevel; 5] = [
+ LogLevel::Trace,
+ LogLevel::Debug,
+ LogLevel::Info,
+ LogLevel::Warn,
+ LogLevel::Error,
+];
+
+impl LogLevel {
+ fn word(self) -> &'static str {
+ match self {
+ Self::Trace => "TRACE",
+ Self::Debug => "DEBUG",
+ Self::Info => "INFO",
+ Self::Warn => "WARN",
+ Self::Error => "ERROR",
+ }
+ }
+
+ fn color(self, colors: &Palette) -> Hsla {
+ match self {
+ Self::Trace | Self::Debug => colors.fg_subtle,
+ Self::Info => colors.info,
+ Self::Warn => colors.warning,
+ Self::Error => colors.danger,
+ }
+ }
+}
+
+/// One line of a log: when, how loud, from where, and what it says, escape codes and all.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LogLine {
+ pub at: SharedString,
+ pub level: LogLevel,
+ pub target: SharedString,
+ pub message: SharedString,
+}
+
+/// The lines at or above `least` whose target or text holds `query`, case aside, by index.
+pub(crate) fn shown(lines: &[LogLine], least: LogLevel, query: &str) -> Vec {
+ let query = query.trim().to_lowercase();
+ lines
+ .iter()
+ .enumerate()
+ .filter(|(_, line)| line.level >= least)
+ .filter(|(_, line)| {
+ query.is_empty()
+ || line.target.to_lowercase().contains(&query)
+ || printed(&line.message).0.to_lowercase().contains(&query)
+ })
+ .map(|(ix, _)| ix)
+ .collect()
+}
+
+type OnLevel = Rc;
+type OnBool = Rc;
+
+/// A log, newest at the bottom: from a chosen level up, found by a query, following new lines until a scroll up lets go. Long logs draw only the lines in view.
+#[derive(IntoElement)]
+pub struct LogViewer {
+ id: ElementId,
+ lines: Rc>,
+ least: LogLevel,
+ query: SharedString,
+ follow: bool,
+ on_least: Option,
+ on_follow: Option,
+}
+
+impl LogViewer {
+ pub fn new(id: impl Into, lines: impl Into>>) -> Self {
+ Self {
+ id: id.into(),
+ lines: lines.into(),
+ least: LogLevel::Trace,
+ query: SharedString::default(),
+ follow: true,
+ on_least: None,
+ on_follow: None,
+ }
+ }
+
+ /// The quietest level shown.
+ pub fn least(mut self, least: LogLevel) -> Self {
+ self.least = least;
+ self
+ }
+
+ pub fn query(mut self, query: impl Into) -> Self {
+ self.query = query.into();
+ self
+ }
+
+ pub fn follow(mut self, follow: bool) -> Self {
+ self.follow = follow;
+ self
+ }
+
+ pub fn on_least(mut self, handler: impl Fn(LogLevel, &mut Window, &mut App) + 'static) -> Self {
+ self.on_least = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_follow(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_follow = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for LogViewer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let scroll = window
+ .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| {
+ UniformListScrollHandle::new()
+ })
+ .read(cx)
+ .clone();
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let rows = Rc::new(shown(&self.lines, self.least, &self.query));
+ if self.follow && !rows.is_empty() {
+ scroll.scroll_to_item(rows.len() - 1, ScrollStrategy::Bottom);
+ }
+ let levels = LEVELS.iter().fold(
+ SegmentedControl::new((self.id.clone(), "levels"), self.least.word())
+ .size(ControlSize::Sm),
+ |control, level| control.segment(level.word(), level.word(), None),
+ );
+ let levels = match self.on_least {
+ Some(on_least) => levels.on_change(move |word, window, cx| {
+ let level = LEVELS
+ .into_iter()
+ .find(|level| level.word() == word.as_ref())
+ .expect("a segment names a level");
+ on_least(level, window, cx)
+ }),
+ None => levels,
+ };
+ let follow = ToggleButton::new(
+ (self.id.clone(), "follow"),
+ ToggleItem::new("follow")
+ .icon(IconName::ArrowDownToLine)
+ .tooltip("Follow new lines"),
+ self.follow,
+ )
+ .size(ControlSize::Sm);
+ let (release, follow) = match self.on_follow {
+ Some(on_follow) => (
+ Some(on_follow.clone()),
+ follow.on_toggle(move |on, window, cx| on_follow(on, window, cx)),
+ ),
+ None => (None, follow),
+ };
+ let (lines, painted) = (self.lines.clone(), rows.clone());
+ let list = uniform_list(
+ (self.id.clone(), "lines"),
+ rows.len(),
+ move |range, _, cx| {
+ let colors = cx.theme().colors.clone();
+ range
+ .map(|ix| {
+ let line = &lines[painted[ix]];
+ div()
+ .id(ix)
+ .w_full()
+ .flex()
+ .gap_3()
+ .px_2()
+ .whitespace_nowrap()
+ .overflow_hidden()
+ .when(line.level == LogLevel::Error, |row| {
+ row.bg(colors.danger.opacity(0.06))
+ })
+ .child(
+ div()
+ .flex_none()
+ .text_color(colors.fg_subtle)
+ .child(line.at.clone()),
+ )
+ .child(
+ div()
+ .flex_none()
+ .w_12()
+ .text_color(line.level.color(&colors))
+ .child(line.level.word()),
+ )
+ .child(
+ div()
+ .flex_none()
+ .w_24()
+ .overflow_hidden()
+ .text_color(colors.fg_muted)
+ .child(line.target.clone()),
+ )
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .child(AnsiText::new(line.message.clone())),
+ )
+ })
+ .collect()
+ },
+ )
+ .track_scroll(scroll)
+ .size_full();
+ div()
+ .size_full()
+ .flex()
+ .flex_col()
+ .text_size(theme.text_size(TextSize::Sm))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .pb_2()
+ .child(levels)
+ .child(div().flex_1().text_color(colors.fg_subtle).child(format!(
+ "{} of {}",
+ rows.len(),
+ format::plural(self.lines.len() as u64, "line", "lines")
+ )))
+ .child(follow),
+ )
+ .child(
+ div()
+ .id((self.id.clone(), "body"))
+ .flex_1()
+ .min_h_0()
+ .font_family(theme.mono_family.clone())
+ .text_size(theme.text_size(TextSize::Xs))
+ .when_some(release.filter(|_| self.follow), |body, on_follow| {
+ body.on_scroll_wheel(move |event: &ScrollWheelEvent, window, cx| {
+ let back = match event.delta {
+ ScrollDelta::Pixels(delta) => delta.y > Pixels::ZERO,
+ ScrollDelta::Lines(delta) => delta.y > 0.0,
+ };
+ if back {
+ on_follow(false, window, cx)
+ }
+ })
+ })
+ .child(list),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn line(level: LogLevel, target: &str, message: &str) -> LogLine {
+ LogLine {
+ at: "12:00:00".into(),
+ level,
+ target: target.to_string().into(),
+ message: message.to_string().into(),
+ }
+ }
+
+ #[test]
+ fn lines_show_from_a_level_up_and_by_query() {
+ let lines = [
+ line(LogLevel::Debug, "gpui", "frame"),
+ line(LogLevel::Info, "ely::terminal", "started \x1b[1mzsh\x1b[0m"),
+ line(LogLevel::Error, "ely::pty", "the program ended"),
+ ];
+ assert_eq!(shown(&lines, LogLevel::Info, ""), [1, 2]);
+ assert_eq!(
+ shown(&lines, LogLevel::Trace, "ZSH"),
+ [1],
+ "codes and case aside"
+ );
+ assert_eq!(shown(&lines, LogLevel::Trace, "pty"), [2], "targets count");
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
new file mode 100644
index 0000000..f14735e
--- /dev/null
+++ b/src/terminal/mod.rs
@@ -0,0 +1,28 @@
+mod ansi;
+mod block;
+mod colors;
+mod frame;
+mod history;
+mod input;
+mod keys;
+mod links;
+mod logs;
+mod paint;
+mod processes;
+mod pty;
+mod search;
+mod shells;
+#[cfg(all(test, feature = "test-support"))]
+mod tests;
+mod view;
+
+pub use ansi::AnsiText;
+pub use block::{CommandBlock, CommandState};
+pub use history::{CommandHistory, PastCommand};
+pub(crate) use input::bind_keys;
+pub use links::{Target, links};
+pub use logs::{LogLevel, LogLine, LogViewer};
+pub use processes::{Process, ProcessList};
+pub use pty::Launch;
+pub use shells::{ShellChoice, ShellSelector, TerminalToolbar};
+pub use view::{Terminal, TerminalEvent};
diff --git a/src/terminal/paint.rs b/src/terminal/paint.rs
new file mode 100644
index 0000000..bcbd5ad
--- /dev/null
+++ b/src/terminal/paint.rs
@@ -0,0 +1,145 @@
+use std::{ops::Range, rc::Rc};
+
+use alacritty_terminal::vte::ansi::CursorShape;
+use gpui::{
+ BorderStyle, Bounds, ElementInputHandler, Entity, Font, FontStyle, FontWeight, Hsla,
+ IntoElement, Pixels, Size, StrikethroughStyle, Styled, TextRun, UnderlineStyle, canvas, fill,
+ outline, point, size,
+};
+
+use super::{
+ frame::{Frame, Lit},
+ links::Target,
+ view::Terminal,
+};
+
+/// How a terminal paints: font and cell, ink, and the colors of what it marks.
+pub(crate) struct Look {
+ pub font: Font,
+ pub size: Pixels,
+ pub cell: Size,
+ pub focused: bool,
+ pub caret: Hsla,
+ pub caret_width: Pixels,
+ pub rule: Pixels,
+ pub selection: Hsla,
+ pub found: Hsla,
+ pub current: Hsla,
+ pub link: Hsla,
+}
+
+/// The grid, fitted to its box: it tells the terminal its size and origin, then paints each row.
+pub(crate) fn grid(
+ terminal: Entity,
+ frame: Rc,
+ hovered: Option<(usize, Range, Target)>,
+ look: Look,
+) -> impl IntoElement {
+ let (fit, typing) = (terminal.clone(), terminal);
+ let cell = look.cell;
+ canvas(
+ move |bounds, window, cx| {
+ let columns = (bounds.size.width / cell.width).floor() as usize;
+ let lines = (bounds.size.height / cell.height).floor() as usize;
+ fit.update(cx, |terminal, cx| {
+ terminal.origin = bounds.origin;
+ let before = terminal.size;
+ terminal.resize(columns, lines);
+ if terminal.size != before {
+ cx.notify();
+ window.request_animation_frame();
+ }
+ });
+ },
+ move |bounds, _, window, cx| {
+ let focus = typing.read(cx).focus.clone();
+ window.handle_input(&focus, ElementInputHandler::new(bounds, typing.clone()), cx);
+ let at = |row: usize, column: usize| {
+ bounds.origin + point(cell.width * column as f32, cell.height * row as f32)
+ };
+ for (ix, row) in frame.rows.iter().enumerate() {
+ let runs: Vec = row
+ .runs
+ .iter()
+ .map(|(len, style)| TextRun {
+ len: *len,
+ font: Font {
+ weight: if style.bold {
+ FontWeight::BOLD
+ } else {
+ FontWeight::NORMAL
+ },
+ style: if style.italic {
+ FontStyle::Italic
+ } else {
+ FontStyle::Normal
+ },
+ ..look.font.clone()
+ },
+ color: style.fg,
+ background_color: style.bg,
+ underline: style.underline.then_some(UnderlineStyle {
+ thickness: look.rule,
+ color: Some(style.fg),
+ wavy: false,
+ }),
+ strikethrough: style.strike.then_some(StrikethroughStyle {
+ thickness: look.rule,
+ color: Some(style.fg),
+ }),
+ })
+ .collect();
+ let shaped = window.text_system().shape_line(
+ row.text.clone().into(),
+ look.size,
+ &runs,
+ Some(cell.width),
+ );
+ shaped
+ .paint_background(at(ix, 0), cell.height, window, cx)
+ .expect("a terminal row's ground paints");
+ for (_, columns, why) in frame.lit.iter().filter(|(row, ..)| *row == ix) {
+ let wash = match why {
+ Lit::Selected => look.selection,
+ Lit::Found => look.found,
+ Lit::Current => look.current,
+ };
+ let extent = size(cell.width * columns.len() as f32, cell.height);
+ window.paint_quad(fill(Bounds::new(at(ix, columns.start), extent), wash));
+ }
+ shaped
+ .paint(at(ix, 0), cell.height, window, cx)
+ .expect("a terminal row paints");
+ }
+ if let Some((row, columns, _)) = &hovered {
+ let under = at(*row, columns.start) + point(Pixels::ZERO, cell.height - look.rule);
+ let extent = size(cell.width * columns.len() as f32, look.rule);
+ window.paint_quad(fill(Bounds::new(under, extent), look.link));
+ }
+ if let Some((row, column, shape)) = frame.cursor {
+ let origin = at(row, column);
+ let quad = match (shape, look.focused) {
+ (CursorShape::HollowBlock, _) | (_, false) => {
+ outline(Bounds::new(origin, cell), look.caret, BorderStyle::Solid)
+ }
+ (CursorShape::Beam, true) => fill(
+ Bounds::new(origin, size(look.caret_width, cell.height)),
+ look.caret,
+ ),
+ (CursorShape::Underline, true) => fill(
+ Bounds::new(
+ origin + point(Pixels::ZERO, cell.height - look.caret_width),
+ size(cell.width, look.caret_width),
+ ),
+ look.caret,
+ ),
+ (CursorShape::Block | CursorShape::Hidden, true) => {
+ fill(Bounds::new(origin, cell), look.caret.opacity(0.45))
+ }
+ };
+ window.paint_quad(quad);
+ }
+ },
+ )
+ .size_full()
+}
diff --git a/src/terminal/processes.rs b/src/terminal/processes.rs
new file mode 100644
index 0000000..11df287
--- /dev/null
+++ b/src/terminal/processes.rs
@@ -0,0 +1,151 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
+};
+
+use crate::{
+ buttons::{Button, ButtonVariant},
+ tables::{Cell, Column, DataTable, Row, SortKey},
+ theme::{ActiveTheme, ControlSize, TextSize},
+ typography::format,
+};
+
+/// A running process: its id, name and owner, and what it takes.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Process {
+ pub pid: u32,
+ pub name: SharedString,
+ pub user: SharedString,
+ /// Percent of one core.
+ pub cpu: f32,
+ pub memory: u64,
+}
+
+type OnPids = Rc, &mut Window, &mut App)>;
+
+/// Running processes as a table, busiest first: found by a query, sorted by any column, and the selected ones ended.
+#[derive(IntoElement)]
+pub struct ProcessList {
+ id: ElementId,
+ processes: Vec,
+ query: SharedString,
+ selected: Vec,
+ on_select: Option,
+ on_end: Option,
+}
+
+impl ProcessList {
+ pub fn new(id: impl Into, processes: impl IntoIterator
- ) -> Self {
+ Self {
+ id: id.into(),
+ processes: processes.into_iter().collect(),
+ query: SharedString::default(),
+ selected: Vec::new(),
+ on_select: None,
+ on_end: None,
+ }
+ }
+
+ pub fn query(mut self, query: impl Into) -> Self {
+ self.query = query.into();
+ self
+ }
+
+ pub fn selected(mut self, pids: impl IntoIterator
- ) -> Self {
+ self.selected = pids.into_iter().collect();
+ self
+ }
+
+ pub fn on_select(
+ mut self,
+ handler: impl Fn(Vec, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_select = Some(Rc::new(handler));
+ self
+ }
+
+ /// Called with the selected processes when End is pressed.
+ pub fn on_end(mut self, handler: impl Fn(Vec, &mut Window, &mut App) + 'static) -> Self {
+ self.on_end = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for ProcessList {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let busy: f32 = self.processes.iter().map(|process| process.cpu).sum();
+ let columns = [
+ Column::new("name", "Name"),
+ Column::new("pid", "PID").end(),
+ Column::new("user", "User"),
+ Column::new("cpu", "CPU").end().decimals(1).suffix(" %"),
+ Column::new("memory", "Memory")
+ .end()
+ .decimals(1)
+ .suffix(" MB"),
+ ];
+ let rows: Vec
= self
+ .processes
+ .iter()
+ .map(|process| {
+ Row::new(
+ process.pid.to_string(),
+ [
+ Cell::Text(process.name.clone()),
+ Cell::Text(process.pid.to_string().into()),
+ Cell::Text(process.user.clone()),
+ Cell::Number(f64::from(process.cpu)),
+ Cell::Number(process.memory as f64 / (1024.0 * 1024.0)),
+ ],
+ )
+ })
+ .collect();
+ let pid =
+ |key: &SharedString| -> u32 { key.parse().expect("a process row is keyed by its pid") };
+ let table = DataTable::new((self.id.clone(), "table"), columns)
+ .rows(rows)
+ .query(self.query)
+ .sorts([SortKey {
+ column: "cpu".into(),
+ rising: false,
+ }])
+ .selected(self.selected.iter().map(|pid| pid.to_string()));
+ let table = match self.on_select {
+ Some(on_select) => table.on_select(move |keys, window, cx| {
+ on_select(keys.iter().map(pid).collect(), window, cx)
+ }),
+ None => table,
+ };
+ let chosen = self.selected.clone();
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .text_size(theme.text_size(TextSize::Sm))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_3()
+ .child(div().flex_1().text_color(colors.fg_muted).child(format!(
+ "{} · {:.1} % CPU",
+ format::plural(self.processes.len() as u64, "process", "processes"),
+ busy
+ )))
+ .children(self.on_end.map(|on_end| {
+ Button::new((self.id.clone(), "end"), "End process")
+ .variant(ButtonVariant::Danger)
+ .size(ControlSize::Sm)
+ .disabled(chosen.is_empty())
+ .on_click(move |_, window, cx| {
+ log::info!("process list: end {chosen:?}");
+ on_end(chosen.clone(), window, cx)
+ })
+ })),
+ )
+ .child(table)
+ }
+}
diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs
new file mode 100644
index 0000000..9acddea
--- /dev/null
+++ b/src/terminal/pty.rs
@@ -0,0 +1,134 @@
+use std::{collections::HashMap, path::PathBuf, sync::Arc};
+
+use alacritty_terminal::{
+ event::{Event, EventListener, WindowSize},
+ event_loop::{EventLoop, Notifier},
+ sync::FairMutex,
+ term::{Config, Term, test::TermSize},
+ tty::{self, Options, Shell},
+};
+use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
+
+/// Hands a terminal's events to its view.
+#[derive(Clone)]
+pub(crate) struct Listener(UnboundedSender);
+
+impl EventListener for Listener {
+ fn send_event(&self, event: Event) {
+ if self.0.unbounded_send(event).is_err() {
+ log::debug!("terminal: an event after its view closed");
+ }
+ }
+}
+
+/// What a terminal runs: a program and its arguments, the login shell when none; where; and variables on top of TERM, COLORTERM, and LANG when the app has none.
+#[derive(Clone, Debug, Default)]
+pub struct Launch {
+ pub program: Option<(String, Vec)>,
+ pub cwd: Option,
+ pub env: Vec<(String, String)>,
+}
+
+/// A grid, the channel of its events, and the pseudo-terminal that feeds it when one does.
+pub(crate) struct Session {
+ pub term: Arc>>,
+ pub pty: Option,
+ pub events: UnboundedReceiver,
+}
+
+/// A grid no process feeds, for replayed output.
+pub(crate) fn detached(size: TermSize) -> Session {
+ let (sender, events) = unbounded();
+ let term = Term::new(Config::default(), &size, Listener(sender));
+ Session {
+ term: Arc::new(FairMutex::new(term)),
+ pty: None,
+ events,
+ }
+}
+
+/// Starts `launch` on a pseudo-terminal of `size` cells, each `cell` pixels.
+pub(crate) fn spawn(launch: &Launch, size: TermSize, cell: (u16, u16)) -> anyhow::Result {
+ let (sender, events) = unbounded();
+ let listener = Listener(sender);
+ let term = Arc::new(FairMutex::new(Term::new(
+ Config::default(),
+ &size,
+ listener.clone(),
+ )));
+ let mut env: HashMap = HashMap::from([
+ ("TERM".to_string(), "xterm-256color".to_string()),
+ ("COLORTERM".to_string(), "truecolor".to_string()),
+ ]);
+ if std::env::var_os("LANG").is_none() {
+ env.insert("LANG".into(), "en_US.UTF-8".into());
+ }
+ env.extend(launch.env.iter().cloned());
+ let options = Options {
+ shell: launch
+ .program
+ .clone()
+ .map(|(program, args)| Shell::new(program, args)),
+ working_directory: launch.cwd.clone(),
+ drain_on_exit: true,
+ env,
+ };
+ let pty = tty::new(&options, window_size(&size, cell), 0)?;
+ let event_loop = EventLoop::new(term.clone(), listener, pty, true, false)?;
+ let pty = Notifier(event_loop.channel());
+ event_loop.spawn();
+ log::info!("terminal: started {:?}", launch.program);
+ Ok(Session {
+ term,
+ pty: Some(pty),
+ events,
+ })
+}
+
+pub(crate) fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize {
+ let cells =
+ |count: usize| u16::try_from(count).expect("a terminal is under 65536 cells a side");
+ WindowSize {
+ num_lines: cells(size.screen_lines),
+ num_cols: cells(size.columns),
+ cell_width: cell.0,
+ cell_height: cell.1,
+ }
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use alacritty_terminal::index::{Column, Line};
+ use futures::StreamExt;
+
+ use super::*;
+
+ #[test]
+ fn a_program_prints_into_the_grid_through_a_pseudo_terminal() {
+ let launch = Launch {
+ program: Some((
+ "/bin/sh".into(),
+ vec!["-c".into(), "printf 'ely %s' \"$ELY\"".into()],
+ )),
+ env: vec![("ELY".into(), "ok".into())],
+ ..Launch::default()
+ };
+ let mut session = spawn(&launch, TermSize::new(20, 4), (8, 16)).expect("sh starts");
+ let status = futures::executor::block_on(async {
+ let mut status = None;
+ while let Some(event) = session.events.next().await {
+ match event {
+ Event::ChildExit(ended) => status = Some(ended),
+ Event::Exit => break,
+ _ => {}
+ }
+ }
+ status
+ });
+ assert!(status.expect("the program ended").success());
+ let term = session.term.lock();
+ let row = &term.grid()[Line(0)];
+ let text: String = (0..6).map(|column| row[Column(column)].c).collect();
+ assert_eq!(text, "ely ok");
+ }
+}
diff --git a/src/terminal/search.rs b/src/terminal/search.rs
new file mode 100644
index 0000000..b56f1bf
--- /dev/null
+++ b/src/terminal/search.rs
@@ -0,0 +1,131 @@
+use alacritty_terminal::{
+ grid::Dimensions,
+ index::{Column, Direction, Point},
+ term::{
+ Term,
+ search::{Match, RegexIter, RegexSearch},
+ },
+};
+use gpui::Context;
+
+use super::view::Terminal;
+use crate::editor::FindOptions;
+
+/// A find's matches over the scrollback and the screen, and the one stepped to.
+#[derive(Default)]
+pub(crate) struct Search {
+ all: Vec,
+ current: Option,
+}
+
+impl Search {
+ pub(crate) fn visible(&self) -> (&[Match], Option) {
+ (&self.all, self.current)
+ }
+}
+
+/// `query` under `options` as the grid's search reads it; word edges are ASCII, which its lazy DFA needs.
+fn pattern(query: &str, options: FindOptions) -> String {
+ let body = if options.regex {
+ query.to_string()
+ } else {
+ regex::escape(query)
+ };
+ let body = if options.word {
+ format!(r"(?-u:\b)(?:{body})(?-u:\b)")
+ } else {
+ body
+ };
+ let case = if options.case { "(?-i)" } else { "(?i)" };
+ format!("{case}{body}")
+}
+
+/// Every match of `pattern` from the oldest line to the newest.
+fn search_all(term: &Term, pattern: &str) -> Result, String> {
+ let mut regex = RegexSearch::new(pattern).map_err(|error| error.to_string())?;
+ let start = Point::new(term.topmost_line(), Column(0));
+ let end = Point::new(term.bottommost_line(), term.last_column());
+ Ok(RegexIter::new(start, end, Direction::Right, term, &mut regex).collect())
+}
+
+impl Terminal {
+ /// Finds `query` in the scrollback and the screen and steps to the newest match; a bad pattern is an error to show.
+ pub fn find(
+ &mut self,
+ query: &str,
+ options: FindOptions,
+ cx: &mut Context,
+ ) -> Result {
+ let all = if query.is_empty() {
+ Vec::new()
+ } else {
+ search_all(&self.term.lock(), &pattern(query, options))?
+ };
+ log::debug!("terminal: {} matches of {query:?}", all.len());
+ self.search = Search {
+ current: all.len().checked_sub(1),
+ all,
+ };
+ self.reveal(cx);
+ Ok(self.search.all.len())
+ }
+
+ /// Steps to the next match, or the one before, round the ends.
+ pub fn step(&mut self, forward: bool, cx: &mut Context) {
+ let count = self.search.all.len();
+ let Some(at) = self.search.current else {
+ return;
+ };
+ self.search.current = Some(if forward {
+ (at + 1) % count
+ } else {
+ (at + count - 1) % count
+ });
+ self.reveal(cx);
+ }
+
+ /// How many matches there are, and which one is current.
+ pub fn matches(&self) -> (usize, Option) {
+ (self.search.all.len(), self.search.current)
+ }
+
+ fn reveal(&mut self, cx: &mut Context) {
+ if let Some(at) = self.search.current {
+ let start = *self.search.all[at].start();
+ self.term.lock().scroll_to_point(start);
+ }
+ cx.notify();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alacritty_terminal::term::test::mock_term;
+
+ use super::*;
+
+ #[test]
+ fn find_takes_case_words_and_patterns() {
+ let term = mock_term("one two One\r\nthree phone one");
+ let count = |query: &str, options: FindOptions| {
+ search_all(&term, &pattern(query, options)).map(|all| all.len())
+ };
+ assert_eq!(count("one", FindOptions::default()), Ok(4));
+ let case = FindOptions {
+ case: true,
+ ..FindOptions::default()
+ };
+ assert_eq!(count("One", case), Ok(1));
+ let word = FindOptions {
+ word: true,
+ ..FindOptions::default()
+ };
+ assert_eq!(count("one", word), Ok(3), "phone holds no whole word");
+ let regex = FindOptions {
+ regex: true,
+ ..FindOptions::default()
+ };
+ assert_eq!(count("t[wh]", regex), Ok(2));
+ assert!(count("(", regex).is_err());
+ }
+}
diff --git a/src/terminal/shells.rs b/src/terminal/shells.rs
new file mode 100644
index 0000000..b7799c6
--- /dev/null
+++ b/src/terminal/shells.rs
@@ -0,0 +1,195 @@
+use std::rc::Rc;
+
+use gpui::{
+ AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
+ Styled, Window, div,
+};
+
+use crate::{
+ buttons::{ButtonVariant, IconButton},
+ menus::{Menu, MenuItem, SplitButton},
+ primitives::{Icon, IconName},
+ theme::{ActiveTheme, ControlSize, IconSize, TextSize},
+};
+
+/// A shell a terminal can start: its name and its program.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ShellChoice {
+ pub name: SharedString,
+ pub program: SharedString,
+}
+
+type OnIndex = Rc;
+
+/// A new terminal in the default shell, with the others under its arrow, the default checked.
+#[derive(IntoElement)]
+pub struct ShellSelector {
+ id: ElementId,
+ shells: Vec,
+ default: usize,
+ on_pick: Option,
+}
+
+impl ShellSelector {
+ pub fn new(id: impl Into, shells: impl IntoIterator- ) -> Self {
+ let shells: Vec = shells.into_iter().collect();
+ assert!(
+ !shells.is_empty(),
+ "a shell selector offers at least one shell"
+ );
+ Self {
+ id: id.into(),
+ shells,
+ default: 0,
+ on_pick: None,
+ }
+ }
+
+ pub fn default_shell(mut self, default: usize) -> Self {
+ assert!(
+ default < self.shells.len(),
+ "the default is one of the shells"
+ );
+ self.default = default;
+ self
+ }
+
+ /// Called with the shell to start: the default from the button, any from the menu.
+ pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
+ self.on_pick = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for ShellSelector {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ let menu = self
+ .shells
+ .iter()
+ .enumerate()
+ .fold(Menu::new(), |menu, (ix, shell)| {
+ let pick = self.on_pick.clone();
+ menu.item(
+ MenuItem::check(shell.name.clone(), ix == self.default).on_click(
+ move |window, cx| {
+ if let Some(pick) = &pick {
+ pick(ix, window, cx)
+ }
+ },
+ ),
+ )
+ });
+ let (pick, default) = (self.on_pick, self.default);
+ SplitButton::new(self.id, format!("New {}", self.shells[default].name), menu)
+ .variant(ButtonVariant::Ghost)
+ .on_click(move |_, window, cx| {
+ if let Some(pick) = &pick {
+ pick(default, window, cx)
+ }
+ })
+ }
+}
+
+type Run = Rc;
+
+/// The row over a terminal: its title and how its program stands, the shell selector, and split, clear, stop and maximize.
+#[derive(IntoElement)]
+pub struct TerminalToolbar {
+ id: ElementId,
+ title: SharedString,
+ exit: Option,
+ shells: Option,
+ actions: Vec<(&'static str, IconName, &'static str, Run)>,
+}
+
+impl TerminalToolbar {
+ /// `exit` is the program's exit code once it has ended.
+ pub fn new(
+ id: impl Into,
+ title: impl Into,
+ exit: Option,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ title: title.into(),
+ exit,
+ shells: None,
+ actions: Vec::new(),
+ }
+ }
+
+ pub fn shells(mut self, selector: ShellSelector) -> Self {
+ self.shells = Some(selector.into_any_element());
+ self
+ }
+
+ fn action(
+ mut self,
+ key: &'static str,
+ icon: IconName,
+ words: &'static str,
+ run: impl Fn(&mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.actions.push((key, icon, words, Rc::new(run)));
+ self
+ }
+
+ pub fn on_split(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.action("split", IconName::Columns2, "Split terminal", run)
+ }
+
+ pub fn on_clear(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.action("clear", IconName::Eraser, "Clear", run)
+ }
+
+ pub fn on_stop(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.action("stop", IconName::CircleStop, "Stop the program", run)
+ }
+
+ pub fn on_maximize(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.action("maximize", IconName::Maximize2, "Maximize", run)
+ }
+}
+
+impl RenderOnce for TerminalToolbar {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let (mark, color) = match self.exit {
+ None => (IconName::Terminal, colors.fg_muted),
+ Some(0) => (IconName::CircleCheck, colors.success),
+ Some(_) => (IconName::CircleX, colors.danger),
+ };
+ div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .px_2()
+ .py_1()
+ .border_b_1()
+ .border_color(colors.border)
+ .text_size(theme.text_size(TextSize::Sm))
+ .child(Icon::new(mark).size(IconSize::Sm).color(color))
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(colors.fg)
+ .child(self.title),
+ )
+ .children(self.exit.filter(|code| *code != 0).map(|code| {
+ div()
+ .text_color(colors.danger)
+ .child(format!("Exit {code}"))
+ }))
+ .children(self.shells)
+ .children(self.actions.into_iter().map(|(key, icon, words, run)| {
+ IconButton::new((self.id.clone(), key), icon)
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .tooltip(words)
+ .on_click(move |_, window, cx| run(window, cx))
+ }))
+ }
+}
diff --git a/src/terminal/tests.rs b/src/terminal/tests.rs
new file mode 100644
index 0000000..6cd8547
--- /dev/null
+++ b/src/terminal/tests.rs
@@ -0,0 +1,51 @@
+use gpui::{Entity, TestAppContext, VisualTestContext};
+
+use super::Terminal;
+use crate::{editor::FindOptions, theme::Theme};
+
+fn replayed<'a>(
+ bytes: &'static [u8],
+ cx: &'a mut TestAppContext,
+) -> (Entity, &'a mut VisualTestContext) {
+ cx.update(Theme::init);
+ let (terminal, cx) = cx.add_window_view(move |_, cx| Terminal::replay(bytes, 30, 4, cx));
+ cx.run_until_parked();
+ (terminal, cx)
+}
+
+#[gpui::test]
+fn replayed_output_shows_without_its_codes(cx: &mut TestAppContext) {
+ let (terminal, cx) = replayed(b"\x1b[32mok\x1b[0m done\r\n\x1b]0;build\x07next", cx);
+ terminal.read_with(cx, |terminal, _| {
+ assert_eq!(terminal.text(), "ok done\nnext");
+ assert_eq!(terminal.title().as_ref(), "build", "OSC 0 names it");
+ assert_eq!(terminal.shown.cursor, None, "a replay shows no cursor");
+ });
+}
+
+#[gpui::test]
+fn find_counts_matches_and_steps_round(cx: &mut TestAppContext) {
+ let (terminal, cx) = replayed(b"one two one\r\nthree one", cx);
+ terminal.update(cx, |terminal, cx| {
+ assert_eq!(terminal.find("one", FindOptions::default(), cx), Ok(3));
+ assert_eq!(terminal.matches(), (3, Some(2)), "the newest match first");
+ terminal.step(true, cx);
+ assert_eq!(
+ terminal.matches(),
+ (3, Some(0)),
+ "past the last comes the first"
+ );
+ assert!(
+ terminal
+ .find(
+ "(",
+ FindOptions {
+ regex: true,
+ ..FindOptions::default()
+ },
+ cx
+ )
+ .is_err()
+ );
+ });
+}
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
new file mode 100644
index 0000000..c03e94a
--- /dev/null
+++ b/src/terminal/view.rs
@@ -0,0 +1,332 @@
+use std::{borrow::Cow, process::ExitStatus, rc::Rc, sync::Arc, time::Instant};
+
+use alacritty_terminal::{
+ event::{Event, Notify, OnResize},
+ event_loop::Notifier,
+ grid::Scroll,
+ sync::FairMutex,
+ term::{Term, test::TermSize},
+ vte::ansi::{ClearMode, Handler, Processor},
+};
+use futures::StreamExt;
+use gpui::{
+ App, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement,
+ Pixels, Point, Render, SharedString, Size, Styled, Task, Window, div, font, prelude::*, size,
+};
+
+use super::{
+ colors::Ink,
+ frame::{Frame, frame},
+ input,
+ links::Target,
+ paint,
+ pty::{self, Launch, Listener, Session},
+ search::Search,
+};
+use crate::{
+ motion,
+ theme::{ActiveTheme, TextSize},
+};
+
+/// Line height over the text size.
+const LEADING: f32 = 1.35;
+
+/// No program types into a replay, so it shows no cursor.
+const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
+
+/// What a terminal tells its host.
+#[derive(Clone, Debug, PartialEq)]
+pub enum TerminalEvent {
+ Title(SharedString),
+ Exited(ExitStatus),
+ Bell,
+ /// A path a Cmd-press opened; URLs open in the browser.
+ Open(Target),
+ /// Cmd-F: the host shows its find widget.
+ Find,
+}
+
+/// A terminal: a program on a pseudo-terminal, or replayed output, drawn cell by cell. Type to it, drag to select, Cmd-press a link.
+pub struct Terminal {
+ pub(crate) term: Arc>>,
+ pub(crate) pty: Option,
+ pub(crate) focus: FocusHandle,
+ title: SharedString,
+ exited: Option,
+ /// Columns and lines.
+ pub(crate) size: (usize, usize),
+ pub(crate) cell: Size,
+ /// The grid's top-left in the window, from the last paint.
+ pub(crate) origin: Point,
+ pub(crate) shown: Rc,
+ pub(crate) search: Search,
+ /// The link under the pointer while Cmd is held: its row, columns and target.
+ pub(crate) hovered: Option<(usize, std::ops::Range, Target)>,
+ /// Wheel pixels not yet a whole line.
+ pub(crate) rest: Pixels,
+ /// Whether a press here started the drag that selects.
+ pub(crate) selecting: bool,
+ bell: Option,
+ _events: Task<()>,
+}
+
+impl EventEmitter for Terminal {}
+
+impl Focusable for Terminal {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus.clone()
+ }
+}
+
+impl Terminal {
+ /// Starts `launch` on a pseudo-terminal; the first layout sizes its grid to the box.
+ pub fn spawn(launch: Launch, cx: &mut Context) -> anyhow::Result {
+ let size = (80, 24);
+ let session = pty::spawn(&launch, TermSize::new(size.0, size.1), (8, 16))?;
+ Ok(Self::new(session, size, "Terminal".into(), cx))
+ }
+
+ /// Output recorded elsewhere, escape codes and all, on a grid of `columns` by `lines` no process feeds.
+ pub fn replay(bytes: &[u8], columns: usize, lines: usize, cx: &mut Context) -> Self {
+ let session = pty::detached(TermSize::new(columns, lines));
+ {
+ let mut term = session.term.lock();
+ let mut parser: Processor = Processor::new();
+ parser.advance(&mut *term, bytes);
+ parser.advance(&mut *term, HIDE_CURSOR);
+ }
+ Self::new(session, (columns, lines), "Output".into(), cx)
+ }
+
+ fn new(
+ session: Session,
+ size: (usize, usize),
+ title: SharedString,
+ cx: &mut Context,
+ ) -> Self {
+ let Session {
+ term,
+ pty,
+ mut events,
+ } = session;
+ let task = cx.spawn(async move |this, cx| {
+ while let Some(first) = events.next().await {
+ let mut batch = vec![first];
+ while let Ok(more) = events.try_recv() {
+ batch.push(more);
+ }
+ if this
+ .update(cx, |terminal, cx| terminal.handle(batch, cx))
+ .is_err()
+ {
+ break;
+ }
+ }
+ });
+ Self {
+ term,
+ pty,
+ focus: cx.focus_handle(),
+ title,
+ exited: None,
+ size,
+ cell: size_of_cell(),
+ origin: Point::default(),
+ shown: Rc::default(),
+ search: Search::default(),
+ hovered: None,
+ rest: Pixels::ZERO,
+ selecting: false,
+ bell: None,
+ _events: task,
+ }
+ }
+
+ pub fn title(&self) -> &SharedString {
+ &self.title
+ }
+
+ /// How the program ended, once it has.
+ pub fn exit_status(&self) -> Option {
+ self.exited
+ }
+
+ /// The screen's text as it shows now, trailing spaces gone.
+ pub fn text(&self) -> String {
+ self.shown.text()
+ }
+
+ /// Sends bytes to the program, as typing would.
+ pub fn write(&mut self, bytes: impl Into>) {
+ match &self.pty {
+ Some(pty) => pty.notify(bytes),
+ None => log::debug!("terminal: input to a replayed grid"),
+ }
+ }
+
+ /// Clears the screen and the scrollback; a shell redraws its prompt.
+ pub fn clear(&mut self, cx: &mut Context) {
+ {
+ let mut term = self.term.lock();
+ term.clear_screen(ClearMode::Saved);
+ if self.pty.is_none() {
+ term.clear_screen(ClearMode::All);
+ }
+ }
+ if self.pty.is_some() {
+ self.write(&b"\x0c"[..]);
+ }
+ log::info!("terminal: cleared");
+ cx.notify();
+ }
+
+ pub(crate) fn scroll(&mut self, scroll: Scroll, cx: &mut Context) {
+ self.term.lock().scroll_display(scroll);
+ cx.notify();
+ }
+
+ /// Fits the grid to `columns` by `lines`, and tells the program.
+ pub(crate) fn resize(&mut self, columns: usize, lines: usize) {
+ let size = (columns.max(2), lines.max(1));
+ if size == self.size {
+ return;
+ }
+ self.size = size;
+ self.term.lock().resize(TermSize::new(size.0, size.1));
+ let cell = (
+ f32::from(self.cell.width).round() as u16,
+ f32::from(self.cell.height).round() as u16,
+ );
+ if let Some(pty) = &mut self.pty {
+ pty.on_resize(pty::window_size(&TermSize::new(size.0, size.1), cell));
+ }
+ log::debug!("terminal: {columns} by {lines}");
+ }
+
+ fn handle(&mut self, events: Vec, cx: &mut Context) {
+ for event in events {
+ match event {
+ Event::Wakeup | Event::MouseCursorDirty | Event::CursorBlinkingChange => {}
+ Event::Title(title) => {
+ self.title = title.into();
+ cx.emit(TerminalEvent::Title(self.title.clone()));
+ }
+ Event::ResetTitle => {
+ self.title = "Terminal".into();
+ cx.emit(TerminalEvent::Title(self.title.clone()));
+ }
+ Event::Bell => {
+ self.bell = Some(Instant::now());
+ cx.emit(TerminalEvent::Bell);
+ }
+ Event::ClipboardStore(_, text) => {
+ cx.write_to_clipboard(ClipboardItem::new_string(text))
+ }
+ Event::ClipboardLoad(_, reply) => {
+ let text = cx
+ .read_from_clipboard()
+ .and_then(|item| item.text())
+ .unwrap_or_default();
+ self.write(reply(&text).into_bytes());
+ }
+ Event::ColorRequest(index, reply) => {
+ let ink = Ink::new(&cx.theme().colors);
+ let rgb = ink.query(index, self.term.lock().colors());
+ self.write(reply(rgb).into_bytes());
+ }
+ Event::TextAreaSizeRequest(reply) => {
+ let cell = (
+ f32::from(self.cell.width).round() as u16,
+ f32::from(self.cell.height).round() as u16,
+ );
+ let size = TermSize::new(self.size.0, self.size.1);
+ self.write(reply(pty::window_size(&size, cell)).into_bytes());
+ }
+ Event::PtyWrite(text) => self.write(text.into_bytes()),
+ Event::ChildExit(status) => {
+ log::info!("terminal: the program ended, {status}");
+ self.exited = Some(status);
+ cx.emit(TerminalEvent::Exited(status));
+ }
+ Event::Exit => log::info!("terminal: the grid closed"),
+ }
+ }
+ cx.notify();
+ }
+}
+
+/// A cell before the first layout measures one.
+fn size_of_cell() -> Size {
+ size(Pixels::from(8.0), Pixels::from(16.0))
+}
+
+impl Drop for Terminal {
+ fn drop(&mut self) {
+ if let Some(pty) = &self.pty
+ && pty
+ .0
+ .send(alacritty_terminal::event_loop::Msg::Shutdown)
+ .is_err()
+ {
+ log::debug!("terminal: the program had already ended");
+ }
+ }
+}
+
+impl Render for Terminal {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let theme = cx.theme();
+ let ink = Ink::new(&theme.colors);
+ let text = theme.text_size(TextSize::Sm);
+ let mono = font(theme.mono_family.clone());
+ let pixels = text.to_pixels(window.rem_size());
+ let advance = window
+ .text_system()
+ .advance(window.text_system().resolve_font(&mono), pixels, 'm')
+ .expect("the code font has an m")
+ .width;
+ self.cell = size(advance, (pixels * LEADING).round());
+ let (found, current) = self.search.visible();
+ self.shown = Rc::new(frame(&self.term.lock(), &ink, found, current));
+ let flash = self.bell.and_then(|rang| {
+ let lasting = motion::duration(motion::SLOW, cx);
+ let spent = rang.elapsed();
+ (spent < lasting).then(|| 1.0 - spent.as_secs_f32() / lasting.as_secs_f32())
+ });
+ if flash.is_some() {
+ window.request_animation_frame();
+ }
+ let colors = theme.colors.clone();
+ let look = paint::Look {
+ font: mono,
+ size: pixels,
+ cell: self.cell,
+ focused: self.focus.is_focused(window),
+ caret: colors.focus,
+ caret_width: theme.caret_width().to_pixels(window.rem_size()),
+ rule: theme.underline_thickness(),
+ selection: colors.selection,
+ found: colors.warning.opacity(0.25),
+ current: colors.warning.opacity(0.55),
+ link: colors.link,
+ };
+ let root = div()
+ .id(("terminal", cx.entity_id()))
+ .key_context(input::CONTEXT)
+ .track_focus(&self.focus)
+ .size_full()
+ .p_2()
+ .bg(ink.bg)
+ .when_some(flash, |root, left| {
+ root.bg(ink.bg.blend(colors.warning.opacity(0.12 * left)))
+ })
+ .when(self.hovered.is_some(), |root| root.cursor_pointer())
+ .child(paint::grid(
+ cx.entity(),
+ self.shown.clone(),
+ self.hovered.clone(),
+ look,
+ ));
+ input::listen(root, cx)
+ }
+}
diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md
index 92c9e24..f1eb851 100644
--- a/tasks/ch11-20.md
+++ b/tasks/ch11-20.md
@@ -343,18 +343,18 @@
## T18 Terminal — `src/terminal`
-- [ ] Terminal — prove: `alacritty_terminal` grid and PTY
-- [ ] TerminalTabs → `navigation::EditorTabs`
-- [ ] TerminalSplit → `layout::PaneGroup`
-- [ ] TerminalToolbar
-- [ ] TerminalSearch → `editor::FindWidget`
-- [ ] TerminalLink
-- [ ] ShellSelector
-- [ ] CommandBlock
-- [ ] CommandHistory
-- [ ] AnsiText
-- [ ] LogViewer
-- [ ] ProcessList
+- [x] Terminal — proven: `alacritty_terminal` 0.26 grid on a pseudo-terminal → `terminal::Terminal::spawn`, and `Terminal::replay` for recorded output
+- [x] TerminalTabs → `navigation::EditorTabs`
+- [x] TerminalSplit → `layout::PaneGroup`, a `Terminal` per pane
+- [x] TerminalToolbar
+- [x] TerminalSearch → `editor::FindWidget` over `Terminal::find`
+- [x] TerminalLink → `Terminal`, a Cmd-press on `terminal::links`: URLs and path:line:column
+- [x] ShellSelector
+- [x] CommandBlock
+- [x] CommandHistory
+- [x] AnsiText
+- [x] LogViewer
+- [x] ProcessList
## T19 Git — `src/git`