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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
601a29a374
commit
8374b77906
30 files changed
+3527
-12
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 |
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}),
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user