From 7da1368cd995e87b129f36997c85ed59eeb88edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 01:27:44 -0400 Subject: [PATCH] T18 Terminal: a real shell on a pseudo-terminal through alacritty_terminal 0.26, drawn cell by cell in the theme's ANSI colors, with keys, text input, selection, scrollback, links and regex find; replayed output; tabs, splits and toolbar with a shell selector; command blocks and history; AnsiText, LogViewer and ProcessList --- AGENTS.md | 4 + Cargo.lock | 75 +++++ Cargo.toml | 2 + TASKS.md | 1 + assets/icons/arrow-down-to-line.svg | 17 ++ examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/terminal/blocks.rs | 124 ++++++++ examples/gallery/pages/terminal/live.rs | 196 +++++++++++++ examples/gallery/pages/terminal/mod.rs | 44 +++ examples/gallery/pages/terminal/panels.rs | 163 +++++++++++ src/lib.rs | 2 + src/primitives/icon.rs | 1 + src/terminal/ansi.rs | 180 ++++++++++++ src/terminal/block.rs | 191 +++++++++++++ src/terminal/colors.rs | 155 ++++++++++ src/terminal/frame.rs | 206 ++++++++++++++ src/terminal/history.rs | 117 ++++++++ src/terminal/input.rs | 295 +++++++++++++++++++ src/terminal/keys.rs | 129 +++++++++ src/terminal/links.rs | 141 +++++++++ src/terminal/logs.rs | 303 ++++++++++++++++++++ src/terminal/mod.rs | 28 ++ src/terminal/paint.rs | 145 ++++++++++ src/terminal/processes.rs | 151 ++++++++++ src/terminal/pty.rs | 134 +++++++++ src/terminal/search.rs | 131 +++++++++ src/terminal/shells.rs | 195 +++++++++++++ src/terminal/tests.rs | 51 ++++ src/terminal/view.rs | 332 ++++++++++++++++++++++ tasks/ch11-20.md | 24 +- 30 files changed, 3527 insertions(+), 12 deletions(-) create mode 100644 assets/icons/arrow-down-to-line.svg create mode 100644 examples/gallery/pages/terminal/blocks.rs create mode 100644 examples/gallery/pages/terminal/live.rs create mode 100644 examples/gallery/pages/terminal/mod.rs create mode 100644 examples/gallery/pages/terminal/panels.rs create mode 100644 src/terminal/ansi.rs create mode 100644 src/terminal/block.rs create mode 100644 src/terminal/colors.rs create mode 100644 src/terminal/frame.rs create mode 100644 src/terminal/history.rs create mode 100644 src/terminal/input.rs create mode 100644 src/terminal/keys.rs create mode 100644 src/terminal/links.rs create mode 100644 src/terminal/logs.rs create mode 100644 src/terminal/mod.rs create mode 100644 src/terminal/paint.rs create mode 100644 src/terminal/processes.rs create mode 100644 src/terminal/pty.rs create mode 100644 src/terminal/search.rs create mode 100644 src/terminal/shells.rs create mode 100644 src/terminal/tests.rs create mode 100644 src/terminal/view.rs 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`