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

This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 01:27:44 -04:00
1 parent dd09d2802a
commit 7da1368cd9
30 files changed
+3527 -12

No files matched your search

+4
View File
@@ -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.
Generated
+75
View File
@@ -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"
+2
View File
@@ -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"
+1
View File
@@ -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 |
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-arrow-down-to-line"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 17V3" />
<path d="m6 11 6 6 6-6" />
<path d="M19 21H5" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

+2
View File
@@ -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,
];
+124
View File
@@ -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<PastCommand> {
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::<SharedString>, 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")),
)
}
+196
View File
@@ -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<PaneGroup>, Entity<Vec<(PaneId, Entity<Terminal>)>>);
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<Vec<(PaneId, Entity<Terminal>)>>,
pane: PaneId,
shell: usize,
cx: &mut App,
) -> Entity<Terminal> {
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<TextInput> {
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::<SharedString>, 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))
}
}),
)),
)
}
+44
View File
@@ -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()
}
+163
View File
@@ -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<Vec<LogLine>> {
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<TextInput> {
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<Process> {
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::<u32>::new, window, cx);
let said = keep("terminal-ended", || None::<SharedString>, 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<Process> = 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))
}
+2
View File
@@ -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);
}
+1
View File
@@ -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",
+180
View File
@@ -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<Color>,
pub bg: Option<Color>,
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<usize>, 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<usize>, 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<Color>, 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<SharedString>) -> 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<usize>, 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)]);
}
}
+191
View File
@@ -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<dyn Fn(&mut Window, &mut App)>;
type OnFold = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// 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<OnFold>,
on_rerun: Option<Run>,
}
impl CommandBlock {
/// `output` as printed, escape codes and all.
pub fn new(
id: impl Into<ElementId>,
cwd: impl Into<SharedString>,
command: impl Into<SharedString>,
output: impl Into<SharedString>,
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)))
})
}
}
+155
View File
@@ -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()
);
}
}
+206
View File
@@ -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<Hsla>,
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<Row>,
/// Lit cells: the row, its columns, and why.
pub lit: Vec<(usize, Range<usize>, 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<T: EventListener>(
term: &Term<T>,
ink: &Ink,
found: &[Match],
current: Option<usize>,
) -> 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<usize>, 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<usize>, 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<VoidListener> {
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::<String>(),
"中 a"
);
}
}
+117
View File
@@ -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<i32>,
}
type OnPick = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// 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<PastCommand>,
on_pick: Option<OnPick>,
on_close: Run,
}
impl CommandHistory {
/// `commands` newest first.
pub fn new(
id: impl Into<ElementId>,
commands: impl IntoIterator<Item = PastCommand>,
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)
}
}
+295
View File
@@ -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<E: InteractiveElement>(root: E, cx: &mut Context<Terminal>) -> 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<u8>, cx: &mut Context<Self>) {
{
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<Self>) {
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<Self>) {
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<Self>) {
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<Pixels>) -> (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<usize>, 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<Self>) {
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<Self>) {
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>) {
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<usize>,
_: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
None
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: 0..0,
reversed: false,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
None
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.input(text.as_bytes().to_vec(), cx);
}
fn replace_and_mark_text_in_range(
&mut self,
_: Option<Range<usize>>,
_: &str,
_: Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) {
}
fn bounds_for_range(
&mut self,
_: Range<usize>,
_: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
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<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
+129
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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<Vec<u8>> {
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()
);
}
}
+141
View File
@@ -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<u32>,
column: Option<u32>,
},
}
static URL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"\b(?:https?|file)://[^\s<>"'`]+"#).expect("the URL pattern compiles")
});
static PATH: LazyLock<Regex> = 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<usize>, Target)> {
let mut found: Vec<(Range<usize>, 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::<usize>();
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"
);
}
}
+303
View File
@@ -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<usize> {
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<dyn Fn(LogLevel, &mut Window, &mut App)>;
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// 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<Vec<LogLine>>,
least: LogLevel,
query: SharedString,
follow: bool,
on_least: Option<OnLevel>,
on_follow: Option<OnBool>,
}
impl LogViewer {
pub fn new(id: impl Into<ElementId>, lines: impl Into<Rc<Vec<LogLine>>>) -> 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<SharedString>) -> 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");
}
}
+28
View File
@@ -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};
+145
View File
@@ -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<Pixels>,
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<Terminal>,
frame: Rc<Frame>,
hovered: Option<(usize, Range<usize>, 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<TextRun> = 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()
}
+151
View File
@@ -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<dyn Fn(Vec<u32>, &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<Process>,
query: SharedString,
selected: Vec<u32>,
on_select: Option<OnPids>,
on_end: Option<OnPids>,
}
impl ProcessList {
pub fn new(id: impl Into<ElementId>, processes: impl IntoIterator<Item = Process>) -> 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<SharedString>) -> Self {
self.query = query.into();
self
}
pub fn selected(mut self, pids: impl IntoIterator<Item = u32>) -> Self {
self.selected = pids.into_iter().collect();
self
}
pub fn on_select(
mut self,
handler: impl Fn(Vec<u32>, &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<u32>, &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<Row> = 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)
}
}
+134
View File
@@ -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<Event>);
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<String>)>,
pub cwd: Option<PathBuf>,
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<FairMutex<Term<Listener>>>,
pub pty: Option<Notifier>,
pub events: UnboundedReceiver<Event>,
}
/// 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<Session> {
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<String, String> = 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");
}
}
+131
View File
@@ -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<Match>,
current: Option<usize>,
}
impl Search {
pub(crate) fn visible(&self) -> (&[Match], Option<usize>) {
(&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<T>(term: &Term<T>, pattern: &str) -> Result<Vec<Match>, 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<Self>,
) -> Result<usize, String> {
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<Self>) {
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<usize>) {
(self.search.all.len(), self.search.current)
}
fn reveal(&mut self, cx: &mut Context<Self>) {
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());
}
}
+195
View File
@@ -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<dyn Fn(usize, &mut Window, &mut App)>;
/// 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<ShellChoice>,
default: usize,
on_pick: Option<OnIndex>,
}
impl ShellSelector {
pub fn new(id: impl Into<ElementId>, shells: impl IntoIterator<Item = ShellChoice>) -> Self {
let shells: Vec<ShellChoice> = 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<dyn Fn(&mut Window, &mut App)>;
/// 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<i32>,
shells: Option<AnyElement>,
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<ElementId>,
title: impl Into<SharedString>,
exit: Option<i32>,
) -> 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))
}))
}
}
+51
View File
@@ -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<Terminal>, &'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()
);
});
}
+332
View File
@@ -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<FairMutex<Term<Listener>>>,
pub(crate) pty: Option<Notifier>,
pub(crate) focus: FocusHandle,
title: SharedString,
exited: Option<ExitStatus>,
/// Columns and lines.
pub(crate) size: (usize, usize),
pub(crate) cell: Size<Pixels>,
/// The grid's top-left in the window, from the last paint.
pub(crate) origin: Point<Pixels>,
pub(crate) shown: Rc<Frame>,
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<usize>, 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<Instant>,
_events: Task<()>,
}
impl EventEmitter<TerminalEvent> 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<Self>) -> anyhow::Result<Self> {
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>) -> 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>,
) -> 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<ExitStatus> {
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<Cow<'static, [u8]>>) {
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<Self>) {
{
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>) {
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<Event>, cx: &mut Context<Self>) {
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<Pixels> {
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<Self>) -> 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)
}
}
+12 -12
View File
@@ -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`