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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 01:27:44 -04:00
1 parent 601a29a374
commit 8374b77906
30 files changed
+3527 -12

No files matched your search

+2
View File
@@ -16,6 +16,7 @@ pub mod overlays;
pub mod primitives;
pub mod shell;
pub mod tables;
pub mod terminal;
pub mod theme;
pub mod typography;
@@ -40,4 +41,5 @@ pub fn init(cx: &mut App) {
]);
forms::bind_keys(cx);
editor::bind_keys(cx);
terminal::bind_keys(cx);
}
+1
View File
@@ -43,6 +43,7 @@ icons! {
AppWindow => "app-window",
Archive => "archive",
ArrowDownLeft => "arrow-down-left",
ArrowDownToLine => "arrow-down-to-line",
ArrowDownWideNarrow => "arrow-down-wide-narrow",
ArrowDown => "arrow-down",
ArrowLeft => "arrow-left",
+180
View File
@@ -0,0 +1,180 @@
use std::ops::Range;
use alacritty_terminal::{
term::color::Colors,
vte::ansi::{Attr, Color, Handler, Processor},
};
use gpui::{
App, FontStyle, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce,
SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div,
};
use super::colors::Ink;
use crate::theme::{ActiveTheme, TextSize};
/// How text prints after the escape codes so far.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Pen {
pub fg: Option<Color>,
pub bg: Option<Color>,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub inverse: bool,
}
/// Collects the printed text and the pen each stretch of it took.
#[derive(Default)]
struct Printed {
text: String,
spans: Vec<(Range<usize>, Pen)>,
pen: Pen,
}
impl Handler for Printed {
fn input(&mut self, ch: char) {
let start = self.text.len();
self.text.push(ch);
let end = self.text.len();
match self.spans.last_mut() {
Some((range, pen)) if range.end == start && *pen == self.pen => range.end = end,
_ if self.pen == Pen::default() => {}
_ => self.spans.push((start..end, self.pen)),
}
}
fn linefeed(&mut self) {
self.text.push('\n');
}
fn put_tab(&mut self, count: u16) {
self.text.extend(std::iter::repeat_n('\t', count as usize));
}
fn terminal_attribute(&mut self, attr: Attr) {
let pen = &mut self.pen;
match attr {
Attr::Reset => *pen = Pen::default(),
Attr::Bold => pen.bold = true,
Attr::Dim => pen.dim = true,
Attr::Italic => pen.italic = true,
Attr::Underline
| Attr::DoubleUnderline
| Attr::Undercurl
| Attr::DottedUnderline
| Attr::DashedUnderline => pen.underline = true,
Attr::Reverse => pen.inverse = true,
Attr::Strike => pen.strike = true,
Attr::CancelBold => pen.bold = false,
Attr::CancelBoldDim => (pen.bold, pen.dim) = (false, false),
Attr::CancelItalic => pen.italic = false,
Attr::CancelUnderline => pen.underline = false,
Attr::CancelReverse => pen.inverse = false,
Attr::CancelStrike => pen.strike = false,
Attr::Foreground(color) => pen.fg = Some(color),
Attr::Background(color) => pen.bg = Some(color),
Attr::BlinkSlow
| Attr::BlinkFast
| Attr::CancelBlink
| Attr::Hidden
| Attr::CancelHidden
| Attr::UnderlineColor(_) => {}
}
}
}
/// The text `source` prints, carriage returns and other codes gone, and the pens that are not plain.
pub(crate) fn printed(source: &str) -> (String, Vec<(Range<usize>, Pen)>) {
let mut printed = Printed::default();
let mut parser: Processor = Processor::new();
parser.advance(&mut printed, source.as_bytes());
(printed.text, printed.spans)
}
impl Pen {
fn style(&self, ink: &Ink, rule: gpui::Pixels) -> HighlightStyle {
let set = Colors::default();
let paint =
|color: Option<Color>, plain| color.map_or(plain, |color| ink.resolve(color, &set));
let (mut fg, mut bg) = (paint(self.fg, ink.fg), paint(self.bg, ink.bg));
if self.inverse {
std::mem::swap(&mut fg, &mut bg);
}
if self.dim {
fg = fg.opacity(0.7);
}
HighlightStyle {
color: Some(fg),
background_color: (self.bg.is_some() || self.inverse).then_some(bg),
font_weight: self.bold.then_some(FontWeight::BOLD),
font_style: self.italic.then_some(FontStyle::Italic),
underline: self.underline.then_some(UnderlineStyle {
thickness: rule,
color: Some(fg),
wavy: false,
}),
strikethrough: self.strike.then_some(StrikethroughStyle {
thickness: rule,
color: Some(fg),
}),
..HighlightStyle::default()
}
}
}
/// Text colored by the escape codes in it, as a terminal would print it; other codes drop away.
#[derive(IntoElement)]
pub struct AnsiText {
source: SharedString,
}
impl AnsiText {
pub fn new(source: impl Into<SharedString>) -> Self {
Self {
source: source.into(),
}
}
}
impl RenderOnce for AnsiText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let ink = Ink::new(&theme.colors);
let (text, spans) = printed(&self.source);
let rule = theme.underline_thickness();
let styles: Vec<(Range<usize>, HighlightStyle)> = spans
.into_iter()
.map(|(range, pen)| (range, pen.style(&ink, rule)))
.collect();
div()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.text_color(ink.fg)
.child(StyledText::new(text).with_highlights(styles))
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::vte::ansi::NamedColor;
use super::*;
#[test]
fn codes_become_pens_and_drop_out_of_the_text() {
let (text, spans) = printed("\x1b[1;31merror\x1b[0m: bad\r\n\x1b[4mnext\x1b[24m ok");
assert_eq!(text, "error: bad\nnext ok");
let red = Pen {
fg: Some(Color::Named(NamedColor::Red)),
bold: true,
..Pen::default()
};
let under = Pen {
underline: true,
..Pen::default()
};
assert_eq!(spans, [(0..5, red), (11..15, under)]);
}
}
+191
View File
@@ -0,0 +1,191 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ClipboardItem, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::ansi::{AnsiText, printed};
use crate::{
buttons::{ButtonVariant, IconButton},
motion::Spinner,
primitives::{Disclosure, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{DurationStyle, format},
};
/// Where a command stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandState {
Running,
Done { code: i32, took: Duration },
}
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
type OnFold = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// One command and what it printed, as a block: where it ran, how it ended and how long it took. Its output folds away; the command and output copy, and it runs again.
#[derive(IntoElement)]
pub struct CommandBlock {
id: ElementId,
cwd: SharedString,
command: SharedString,
output: SharedString,
state: CommandState,
folded: bool,
on_fold: Option<OnFold>,
on_rerun: Option<Run>,
}
impl CommandBlock {
/// `output` as printed, escape codes and all.
pub fn new(
id: impl Into<ElementId>,
cwd: impl Into<SharedString>,
command: impl Into<SharedString>,
output: impl Into<SharedString>,
state: CommandState,
) -> Self {
Self {
id: id.into(),
cwd: cwd.into(),
command: command.into(),
output: output.into(),
state,
folded: false,
on_fold: None,
on_rerun: None,
}
}
pub fn folded(mut self, folded: bool) -> Self {
self.folded = folded;
self
}
pub fn on_fold(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_fold = Some(Rc::new(handler));
self
}
pub fn on_rerun(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_rerun = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CommandBlock {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (mark, outcome): (gpui::AnyElement, SharedString) = match self.state {
CommandState::Running => (
Spinner::new((self.id.clone(), "spinner"))
.size(IconSize::Sm)
.into_any_element(),
"Running".into(),
),
CommandState::Done { code: 0, took } => (
Icon::new(IconName::CircleCheck)
.size(IconSize::Sm)
.color(colors.success)
.into_any_element(),
format::duration(took.as_secs().max(1), DurationStyle::Compact).into(),
),
CommandState::Done { code, took } => (
Icon::new(IconName::CircleX)
.size(IconSize::Sm)
.color(colors.danger)
.into_any_element(),
format!(
"Exit {code} · {}",
format::duration(took.as_secs().max(1), DurationStyle::Compact)
)
.into(),
),
};
let copy = |key: &'static str, icon, words: &'static str, text: String| {
IconButton::new((self.id.clone(), key), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(words)
.on_click(move |_, _, cx| {
log::info!("command block: {words}");
cx.write_to_clipboard(ClipboardItem::new_string(text.clone()))
})
};
let plain = printed(&self.output).0;
let (fold, folded) = (self.on_fold, self.folded);
let has_output = !plain.trim().is_empty();
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_1()
.py_2()
.pl_3()
.pr_2()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(mark)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.items_baseline()
.gap_2()
.child(
div()
.font_family(theme.mono_family.clone())
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(self.command.clone()),
)
.child(div().text_color(colors.fg_subtle).child(self.cwd)),
)
.child(div().text_color(colors.fg_muted).child(outcome))
.child(copy(
"copy-command",
IconName::Copy,
"Copy command",
self.command.to_string(),
))
.child(copy(
"copy-output",
IconName::FileText,
"Copy output",
plain,
))
.children(self.on_rerun.map(|rerun| {
IconButton::new((self.id.clone(), "rerun"), IconName::RotateCcw)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Run again")
.on_click(move |_, window, cx| rerun(window, cx))
}))
.when(has_output, |header| {
header.child(
div()
.id((self.id.clone(), "fold"))
.cursor_pointer()
.when_some(fold, |toggle, fold| {
toggle.on_click(move |_, window, cx| fold(!folded, window, cx))
})
.child(Disclosure::new((self.id.clone(), "chevron"), !folded)),
)
}),
)
.when(has_output && !folded, |block| {
block.child(div().pl_6().child(AnsiText::new(self.output)))
})
}
}
+155
View File
@@ -0,0 +1,155 @@
use alacritty_terminal::{
term::color::Colors,
vte::ansi::{Color, NamedColor, Rgb},
};
use gpui::{Hsla, Rgba};
use crate::theme::Palette;
/// What a terminal paints with: the theme's sixteen colors, its text and its ground.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Ink {
pub ansi: [Hsla; 16],
pub fg: Hsla,
pub bg: Hsla,
}
impl Ink {
pub(crate) fn new(colors: &Palette) -> Self {
Self {
ansi: colors.ansi,
fg: colors.fg,
bg: colors.surface,
}
}
/// A color as painted, after any the program set with OSC 4, 10 or 11.
pub(crate) fn resolve(&self, color: Color, set: &Colors) -> Hsla {
match color {
Color::Spec(spec) => hsla(spec),
Color::Indexed(index) => self.indexed(index as usize, set),
Color::Named(name) => self.named(name, set),
}
}
fn named(&self, name: NamedColor, set: &Colors) -> Hsla {
if let Some(spec) = set[name] {
return hsla(spec);
}
match name {
NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::Cursor => self.fg,
NamedColor::Background => self.bg,
NamedColor::DimForeground => self.fg.opacity(0.7),
dim if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize)
.contains(&(dim as usize)) =>
{
self.named(dim.to_bright(), set).opacity(0.7)
}
ansi => self.ansi[ansi as usize],
}
}
fn indexed(&self, index: usize, set: &Colors) -> Hsla {
if let Some(spec) = set[index] {
return hsla(spec);
}
match index {
0..16 => self.ansi[index],
16..232 => {
let step = |level: usize| if level == 0 { 0 } else { 55 + 40 * level as u8 };
let cube = index - 16;
hsla(Rgb {
r: step(cube / 36),
g: step(cube / 6 % 6),
b: step(cube % 6),
})
}
_ => {
let gray = 8 + 10 * (index - 232) as u8;
hsla(Rgb {
r: gray,
g: gray,
b: gray,
})
}
}
}
/// Color `index` as an answer to a program's query.
pub(crate) fn query(&self, index: usize, set: &Colors) -> Rgb {
let color = match index {
256 => self.named(NamedColor::Foreground, set),
257 => self.named(NamedColor::Background, set),
258 => self.named(NamedColor::Cursor, set),
index => self.indexed(index.min(255), set),
};
let rgba = color.to_rgb();
let byte = |channel: f32| (channel * 255.0).round() as u8;
Rgb {
r: byte(rgba.r),
g: byte(rgba.g),
b: byte(rgba.b),
}
}
}
fn hsla(spec: Rgb) -> Hsla {
let channel = |value: u8| f32::from(value) / 255.0;
Rgba {
r: channel(spec.r),
g: channel(spec.g),
b: channel(spec.b),
a: 1.0,
}
.into()
}
#[cfg(test)]
mod tests {
use gpui::{black, white};
use super::*;
fn ink() -> Ink {
let mut ansi = [black(); 16];
ansi[1] = hsla(Rgb { r: 200, g: 0, b: 0 });
Ink {
ansi,
fg: black(),
bg: white(),
}
}
#[test]
fn indexed_colors_follow_the_xterm_cube_and_ramp() {
let (ink, set) = (ink(), Colors::default());
assert_eq!(ink.query(196, &set), Rgb { r: 255, g: 0, b: 0 });
assert_eq!(
ink.query(244, &set),
Rgb {
r: 128,
g: 128,
b: 128
}
);
assert_eq!(
ink.query(1, &set),
Rgb { r: 200, g: 0, b: 0 },
"the theme's red"
);
}
#[test]
fn a_color_the_program_set_wins() {
let (ink, mut set) = (ink(), Colors::default());
set[1] = Some(Rgb { r: 1, g: 2, b: 3 });
assert_eq!(
ink.resolve(Color::Named(NamedColor::Red), &set),
hsla(Rgb { r: 1, g: 2, b: 3 })
);
assert_eq!(
ink.resolve(Color::Named(NamedColor::Background), &set),
white()
);
}
}
+206
View File
@@ -0,0 +1,206 @@
use std::ops::Range;
use alacritty_terminal::{
event::EventListener,
grid::Dimensions,
term::{Term, cell::Cell, cell::Flags, color::Colors, search::Match},
vte::ansi::CursorShape,
};
use gpui::Hsla;
use super::colors::Ink;
/// How a run of cells paints.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Style {
pub fg: Hsla,
/// None where the terminal's ground shows.
pub bg: Option<Hsla>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
}
/// One screen line: a char per cell, and style runs over its bytes.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Row {
pub text: String,
pub runs: Vec<(usize, Style)>,
}
/// Why cells are lit: selected, or a find match, the current one stronger.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Lit {
Selected,
Found,
Current,
}
/// A terminal's screen as it paints.
#[derive(Clone, Debug, Default)]
pub(crate) struct Frame {
pub rows: Vec<Row>,
/// Lit cells: the row, its columns, and why.
pub lit: Vec<(usize, Range<usize>, Lit)>,
/// The cursor's row, column and shape, when it shows.
pub cursor: Option<(usize, usize, CursorShape)>,
}
impl Frame {
/// The screen's text, trailing spaces gone.
pub(crate) fn text(&self) -> String {
let lines: Vec<&str> = self.rows.iter().map(|row| row.text.trim_end()).collect();
lines.join("\n").trim_end().to_string()
}
}
/// The screen as it shows now, scrolled back or not, with `found` lit and match `current` stronger.
pub(crate) fn frame<T: EventListener>(
term: &Term<T>,
ink: &Ink,
found: &[Match],
current: Option<usize>,
) -> Frame {
let content = term.renderable_content();
let offset = content.display_offset as i32;
let mut rows = vec![Row::default(); term.screen_lines()];
let mut lit: Vec<(usize, Range<usize>, Lit)> = Vec::new();
let (top, bottom) = (-offset, rows.len() as i32 - 1 - offset);
let near: Vec<(usize, &Match)> = found
.iter()
.enumerate()
.filter(|(_, hit)| hit.end().line.0 >= top && hit.start().line.0 <= bottom)
.collect();
for indexed in content.display_iter {
let (point, cell) = (indexed.point, indexed.cell);
let row = (point.line.0 + offset) as usize;
let spacer = cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
push(
&mut rows[row],
if spacer { ' ' } else { cell.c },
style(cell, ink, content.colors),
);
let why = if content.selection.is_some_and(|range| range.contains(point)) {
Some(Lit::Selected)
} else {
near.iter()
.find(|(_, hit)| hit.contains(&point))
.map(|(ix, _)| {
if Some(*ix) == current {
Lit::Current
} else {
Lit::Found
}
})
};
if let Some(why) = why {
light(&mut lit, row, point.column.0, why);
}
}
let at = content.cursor.point.line.0 + offset;
let shows = content.cursor.shape != CursorShape::Hidden && (0..rows.len() as i32).contains(&at);
let cursor = shows.then_some((
at as usize,
content.cursor.point.column.0,
content.cursor.shape,
));
Frame { rows, lit, cursor }
}
fn push(row: &mut Row, ch: char, style: Style) {
let len = ch.len_utf8();
row.text.push(ch);
match row.runs.last_mut() {
Some((bytes, last)) if *last == style => *bytes += len,
_ => row.runs.push((len, style)),
}
}
fn light(lit: &mut Vec<(usize, Range<usize>, Lit)>, row: usize, column: usize, why: Lit) {
match lit.last_mut() {
Some((at, columns, last)) if *at == row && columns.end == column && *last == why => {
columns.end += 1
}
_ => lit.push((row, column..column + 1, why)),
}
}
fn style(cell: &Cell, ink: &Ink, set: &Colors) -> Style {
let flags = cell.flags;
let (mut fg, mut bg) = (ink.resolve(cell.fg, set), ink.resolve(cell.bg, set));
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if flags.contains(Flags::DIM) {
fg = fg.opacity(0.7);
}
if flags.contains(Flags::HIDDEN) {
fg = bg;
}
Style {
fg,
bg: (bg != ink.bg).then_some(bg),
bold: flags.contains(Flags::BOLD),
italic: flags.contains(Flags::ITALIC),
underline: flags.intersects(Flags::ALL_UNDERLINES),
strike: flags.contains(Flags::STRIKEOUT),
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::{
event::VoidListener,
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use gpui::{black, white};
use super::*;
fn replayed(bytes: &[u8]) -> Term<VoidListener> {
let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, bytes);
term
}
fn ink() -> Ink {
let mut ansi = [black(); 16];
ansi[1] = gpui::red();
Ink {
ansi,
fg: black(),
bg: white(),
}
}
#[test]
fn cells_join_into_runs_of_one_style() {
let term = replayed(b"\x1b[31mred\x1b[0m ok\r\n\x1b[7minv\x1b[0m");
let shown = frame(&term, &ink(), &[], None);
assert_eq!(shown.text(), "red ok\ninv");
let (bytes, red) = shown.rows[0].runs[0];
assert_eq!((bytes, red.fg, red.bg), (3, gpui::red(), None));
let (_, inverse) = shown.rows[1].runs[0];
assert_eq!(
(inverse.fg, inverse.bg),
(white(), Some(black())),
"inverse swaps"
);
assert_eq!(shown.cursor, Some((1, 3, CursorShape::Block)));
}
#[test]
fn a_wide_char_keeps_the_columns_after_it() {
let term = replayed("中a".as_bytes());
let shown = frame(&term, &ink(), &[], None);
assert_eq!(
shown.rows[0].text.chars().take(3).collect::<String>(),
"中 a"
);
}
}
+117
View File
@@ -0,0 +1,117 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
primitives::IconName,
theme::ActiveTheme,
};
/// A command run before: what, where, when, and how it ended when that is known.
#[derive(Clone, Debug, PartialEq)]
pub struct PastCommand {
pub command: SharedString,
pub cwd: SharedString,
pub when: SharedString,
pub code: Option<i32>,
}
type OnPick = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// Commands run before, newest first, found by any of their letters and picked to run again: a palette over the terminal.
#[derive(IntoElement)]
pub struct CommandHistory {
id: ElementId,
commands: Vec<PastCommand>,
on_pick: Option<OnPick>,
on_close: Run,
}
impl CommandHistory {
/// `commands` newest first.
pub fn new(
id: impl Into<ElementId>,
commands: impl IntoIterator<Item = PastCommand>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
commands: commands.into_iter().collect(),
on_pick: None,
on_close: Rc::new(on_close),
}
}
pub fn on_pick(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CommandHistory {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = query_field(&self.id, "Search past commands", window, cx);
let query = input.read(cx).text().trim().to_string();
let colors = cx.theme().colors.clone();
let mut found: Vec<(i32, Row)> = self
.commands
.iter()
.filter_map(|past| {
let (score, hits) = if query.is_empty() {
(0, Vec::new())
} else {
let fit = fuzzy(&query, &past.command)?;
(fit.score, fit.hits)
};
let icon = match past.code {
Some(0) => IconName::CircleCheck,
Some(_) => IconName::CircleX,
None => IconName::History,
};
Some((
score,
Row {
value: past.command.clone(),
icon: Some(icon),
label: marked(past.command.clone(), hits, cx),
detail: Some(
div()
.text_color(colors.fg_subtle)
.child(past.cwd.clone())
.into_any_element(),
),
end: Some(
div()
.text_color(colors.fg_subtle)
.child(past.when.clone())
.into_any_element(),
),
},
))
})
.collect();
if !query.is_empty() {
found.sort_by(|(a, _), (b, _)| b.cmp(a));
}
Palette {
id: self.id,
input,
groups: vec![Group {
title: None,
rows: found.into_iter().map(|(_, row)| row).collect(),
}],
start: 0,
empty: "No past command fits".into(),
on_pick: self.on_pick,
on_close: self.on_close,
}
.overlay(window, cx)
}
}
+295
View File
@@ -0,0 +1,295 @@
use std::ops::Range;
use alacritty_terminal::{
grid::Scroll,
index::{Column, Line, Point as Cell, Side},
selection::{Selection, SelectionType},
term::TermMode,
};
use gpui::{
App, Bounds, ClipboardItem, Context, EntityInputHandler, InteractiveElement, KeyBinding,
KeyDownEvent, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels,
Point, ScrollWheelEvent, UTF16Selection, Window, actions,
};
use super::{
keys,
links::{Target, links},
view::{Terminal, TerminalEvent},
};
actions!(
ely_terminal,
[
SendTab,
SendBackTab,
Copy,
Paste,
Clear,
PageUp,
PageDown,
Find
]
);
pub(crate) const CONTEXT: &str = "ElyTerminal";
/// Tab and Shift-Tab go to the program here, ahead of focus moves; the rest are the app's own.
pub(crate) fn bind_keys(cx: &mut App) {
let context = Some(CONTEXT);
let (copy, paste, clear, find) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v", "cmd-k", "cmd-f")
} else {
(
"ctrl-shift-c",
"ctrl-shift-v",
"ctrl-shift-k",
"ctrl-shift-f",
)
};
cx.bind_keys([
KeyBinding::new("tab", SendTab, context),
KeyBinding::new("shift-tab", SendBackTab, context),
KeyBinding::new(copy, Copy, context),
KeyBinding::new(paste, Paste, context),
KeyBinding::new(clear, Clear, context),
KeyBinding::new("shift-pageup", PageUp, context),
KeyBinding::new("shift-pagedown", PageDown, context),
KeyBinding::new(find, Find, context),
]);
}
pub(crate) fn listen<E: InteractiveElement>(root: E, cx: &mut Context<Terminal>) -> E {
root.on_action(cx.listener(|terminal, _: &SendTab, _, cx| terminal.input(b"\t".to_vec(), cx)))
.on_action(
cx.listener(|terminal, _: &SendBackTab, _, cx| terminal.input(b"\x1b[Z".to_vec(), cx)),
)
.on_action(cx.listener(|terminal, _: &Copy, _, cx| terminal.copy(cx)))
.on_action(cx.listener(|terminal, _: &Paste, _, cx| terminal.paste(cx)))
.on_action(cx.listener(|terminal, _: &Clear, _, cx| terminal.clear(cx)))
.on_action(cx.listener(|terminal, _: &PageUp, _, cx| terminal.scroll(Scroll::PageUp, cx)))
.on_action(
cx.listener(|terminal, _: &PageDown, _, cx| terminal.scroll(Scroll::PageDown, cx)),
)
.on_action(cx.listener(|_, _: &Find, _, cx| cx.emit(TerminalEvent::Find)))
.on_key_down(cx.listener(Terminal::key_down))
.on_mouse_down(MouseButton::Left, cx.listener(Terminal::press))
.on_mouse_move(cx.listener(Terminal::moved))
.on_mouse_up(
MouseButton::Left,
cx.listener(|terminal, _, _, _| terminal.selecting = false),
)
.on_scroll_wheel(cx.listener(Terminal::wheel))
.on_modifiers_changed(
cx.listener(|terminal, event: &ModifiersChangedEvent, _, cx| {
if !event.modifiers.platform && terminal.hovered.take().is_some() {
cx.notify();
}
}),
)
}
impl Terminal {
/// Bytes from the keyboard: back to the newest line, no selection.
pub(crate) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
{
let mut term = self.term.lock();
term.scroll_display(Scroll::Bottom);
term.selection = None;
}
self.write(bytes);
cx.notify();
}
fn key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
let app_cursor = self.term.lock().mode().contains(TermMode::APP_CURSOR);
if let Some(bytes) = keys::bytes(&event.keystroke, app_cursor) {
self.input(bytes, cx);
cx.stop_propagation();
}
}
fn copy(&mut self, cx: &mut Context<Self>) {
if let Some(text) = self.term.lock().selection_to_string() {
log::info!("terminal: copied {} characters", text.chars().count());
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}
fn paste(&mut self, cx: &mut Context<Self>) {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
let bracketed = self.term.lock().mode().contains(TermMode::BRACKETED_PASTE);
self.input(keys::paste(&text, bracketed), cx);
}
/// The cell under a window point, and which half of it.
fn cell_at(&self, position: Point<Pixels>) -> (usize, Cell, Side) {
let x = ((position.x - self.origin.x) / self.cell.width).max(0.0);
let y = ((position.y - self.origin.y) / self.cell.height).max(0.0);
let (columns, lines) = self.size;
let (column, row) = ((x as usize).min(columns - 1), (y as usize).min(lines - 1));
let offset = self.term.lock().grid().display_offset() as i32;
let side = if x.fract() < 0.5 {
Side::Left
} else {
Side::Right
};
(
row,
Cell::new(Line(row as i32 - offset), Column(column)),
side,
)
}
/// The link a row shows at `column`: its columns and target.
fn link_at(&self, row: usize, column: usize) -> Option<(usize, Range<usize>, Target)> {
let text = &self.shown.rows.get(row)?.text;
links(text).into_iter().find_map(|(bytes, target)| {
let start = text[..bytes.start].chars().count();
let end = start + text[bytes].chars().count();
(start..end)
.contains(&column)
.then_some((row, start..end, target))
})
}
fn press(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) {
let (row, cell, side) = self.cell_at(event.position);
if event.modifiers.platform
&& let Some((_, _, target)) = self.link_at(row, cell.column.0)
{
log::info!("terminal: open {target:?}");
match &target {
Target::Url(url) => cx.open_url(url),
Target::Path { .. } => cx.emit(TerminalEvent::Open(target)),
}
return;
}
let kind = match event.click_count {
2 => SelectionType::Semantic,
count if count >= 3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
self.term.lock().selection = Some(Selection::new(kind, cell, side));
self.selecting = true;
cx.notify();
}
fn moved(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
let (row, cell, side) = self.cell_at(event.position);
if self.selecting && event.pressed_button == Some(MouseButton::Left) {
if let Some(selection) = self.term.lock().selection.as_mut() {
selection.update(cell, side);
}
cx.notify();
return;
}
let hovered = event
.modifiers
.platform
.then(|| self.link_at(row, cell.column.0))
.flatten();
if hovered != self.hovered {
self.hovered = hovered;
cx.notify();
}
}
fn wheel(&mut self, event: &ScrollWheelEvent, _: &mut Window, cx: &mut Context<Self>) {
self.rest += event.delta.pixel_delta(self.cell.height).y;
let lines = (self.rest / self.cell.height).trunc() as i32;
if lines == 0 {
return;
}
self.rest -= self.cell.height * lines as f32;
let mode = *self.term.lock().mode();
if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) {
(true, true) => b"\x1bOA",
(true, false) => b"\x1b[A",
(false, true) => b"\x1bOB",
(false, false) => b"\x1b[B",
};
self.write(arrow.repeat(lines.unsigned_abs() as usize));
} else {
self.scroll(Scroll::Delta(lines), cx);
}
}
}
impl EntityInputHandler for Terminal {
fn text_for_range(
&mut self,
_: Range<usize>,
_: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
None
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: 0..0,
reversed: false,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
None
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.input(text.as_bytes().to_vec(), cx);
}
fn replace_and_mark_text_in_range(
&mut self,
_: Option<Range<usize>>,
_: &str,
_: Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) {
}
fn bounds_for_range(
&mut self,
_: Range<usize>,
_: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let (row, column, _) = self.shown.cursor?;
let origin = self.origin
+ gpui::point(
self.cell.width * column as f32,
self.cell.height * row as f32,
);
Some(Bounds::new(origin, self.cell))
}
fn character_index_for_point(
&mut self,
_: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
+129
View File
@@ -0,0 +1,129 @@
use gpui::Keystroke;
/// The bytes a key sends to the shell as xterm does, or none when the app keeps it or text input types it.
pub(crate) fn bytes(keystroke: &Keystroke, app_cursor: bool) -> Option<Vec<u8>> {
let held = &keystroke.modifiers;
if held.platform {
return None;
}
let code = 1 + u8::from(held.shift) + 2 * u8::from(held.alt) + 4 * u8::from(held.control);
let cursor = |end: char| match (code, app_cursor) {
(1, true) => format!("\x1bO{end}"),
(1, false) => format!("\x1b[{end}"),
_ => format!("\x1b[1;{code}{end}"),
};
let tilde = |number: u8| match code {
1 => format!("\x1b[{number}~"),
_ => format!("\x1b[{number};{code}~"),
};
let function = |end: char| match code {
1 => format!("\x1bO{end}"),
_ => format!("\x1b[1;{code}{end}"),
};
let sent = match keystroke.key.as_str() {
"enter" if held.alt => "\x1b\r".to_string(),
"enter" => "\r".to_string(),
"backspace" if held.control => "\x08".to_string(),
"backspace" if held.alt => "\x1b\x7f".to_string(),
"backspace" => "\x7f".to_string(),
"tab" if held.shift => "\x1b[Z".to_string(),
"tab" => "\t".to_string(),
"escape" => "\x1b".to_string(),
"up" => cursor('A'),
"down" => cursor('B'),
"right" => cursor('C'),
"left" => cursor('D'),
"home" => cursor('H'),
"end" => cursor('F'),
"insert" => tilde(2),
"delete" => tilde(3),
"pageup" => tilde(5),
"pagedown" => tilde(6),
"f1" => function('P'),
"f2" => function('Q'),
"f3" => function('R'),
"f4" => function('S'),
"f5" => tilde(15),
"f6" => tilde(17),
"f7" => tilde(18),
"f8" => tilde(19),
"f9" => tilde(20),
"f10" => tilde(21),
"f11" => tilde(23),
"f12" => tilde(24),
key if held.control => return control(key, held.alt),
_ => return None,
};
Some(sent.into_bytes())
}
/// Ctrl with a letter or symbol: its control code, after Escape when Alt is held too.
fn control(key: &str, alt: bool) -> Option<Vec<u8>> {
let code = match key {
"space" | "@" | "2" => 0,
"[" | "3" => 0x1b,
"\\" | "4" => 0x1c,
"]" | "5" => 0x1d,
"^" | "6" => 0x1e,
"_" | "-" | "7" => 0x1f,
"?" | "8" => 0x7f,
letter if letter.len() == 1 && letter.as_bytes()[0].is_ascii_lowercase() => {
letter.as_bytes()[0] - b'a' + 1
}
_ => return None,
};
Some(if alt { vec![0x1b, code] } else { vec![code] })
}
/// Pasted text as the shell takes it: bracketed when it asked, line ends as carriage returns.
pub(crate) fn paste(text: &str, bracketed: bool) -> Vec<u8> {
let text = text.replace("\r\n", "\r").replace('\n', "\r");
if bracketed {
format!("\x1b[200~{}\x1b[201~", text.replace('\x1b', "")).into_bytes()
} else {
text.into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sent(keys: &str, app_cursor: bool) -> Option<Vec<u8>> {
bytes(&Keystroke::parse(keys).expect("a keystroke"), app_cursor)
}
#[test]
fn keys_send_what_xterm_sends() {
assert_eq!(sent("enter", false), Some(b"\r".to_vec()));
assert_eq!(sent("up", false), Some(b"\x1b[A".to_vec()));
assert_eq!(
sent("up", true),
Some(b"\x1bOA".to_vec()),
"application cursor keys"
);
assert_eq!(sent("shift-up", true), Some(b"\x1b[1;2A".to_vec()));
assert_eq!(sent("ctrl-c", false), Some(vec![3]));
assert_eq!(sent("ctrl-alt-a", false), Some(vec![0x1b, 1]));
assert_eq!(sent("ctrl-[", false), Some(vec![0x1b]));
assert_eq!(sent("f5", false), Some(b"\x1b[15~".to_vec()));
assert_eq!(sent("ctrl-delete", false), Some(b"\x1b[3;5~".to_vec()));
assert_eq!(sent("shift-tab", false), Some(b"\x1b[Z".to_vec()));
}
#[test]
fn app_shortcuts_and_typed_text_stay_out() {
assert_eq!(sent("cmd-c", false), None);
assert_eq!(sent("a", false), None, "text input types letters");
assert_eq!(sent("ctrl-1", false), None);
}
#[test]
fn pastes_bracket_when_asked() {
assert_eq!(paste("a\nb", false), b"a\rb".to_vec());
assert_eq!(
paste("a\x1b[201~b", true),
b"\x1b[200~a[201~b\x1b[201~".to_vec()
);
}
}
+141
View File
@@ -0,0 +1,141 @@
use std::{ops::Range, sync::LazyLock};
use gpui::SharedString;
use regex::Regex;
/// What a link in terminal text points at.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
Url(SharedString),
/// A file, with the line and column a compiler or a grep names, counted from one.
Path {
path: SharedString,
line: Option<u32>,
column: Option<u32>,
},
}
static URL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"\b(?:https?|file)://[^\s<>"'`]+"#).expect("the URL pattern compiles")
});
static PATH: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:\.{1,2}/|/|~/)?(?:[\w.-]+/)+[\w.-]+\.\w+(?::(\d+))?(?::(\d+))?")
.expect("the path pattern compiles")
});
/// Links in one line of text: URLs, and paths with a folder and an extension. Byte ranges, in order.
pub fn links(text: &str) -> Vec<(Range<usize>, Target)> {
let mut found: Vec<(Range<usize>, Target)> = URL
.find_iter(text)
.map(|hit| {
let end = hit.start() + trimmed(hit.as_str()).len();
let url = &text[hit.start()..end];
(hit.start()..end, Target::Url(url.to_string().into()))
})
.collect();
for hit in PATH.captures_iter(text) {
let whole = hit.get(0).expect("a capture has its match");
if found
.iter()
.any(|(range, _)| range.contains(&whole.start()))
{
continue;
}
let number = |group: usize| {
hit.get(group)
.and_then(|digits| digits.as_str().parse().ok())
};
let (line, column) = (number(1), number(2));
let suffix = [hit.get(1), hit.get(2)]
.iter()
.flatten()
.map(|digits| digits.len() + 1)
.sum::<usize>();
let path = &text[whole.start()..whole.end() - suffix];
found.push((
whole.range(),
Target::Path {
path: path.to_string().into(),
line,
column,
},
));
}
found.sort_by_key(|(range, _)| range.start);
found
}
/// A URL without the punctuation that ends its sentence, keeping a closing bracket it opened.
fn trimmed(url: &str) -> &str {
let mut end = url.len();
while let Some(last) = url[..end].chars().last() {
let open = match last {
'.' | ',' | ';' | ':' | '!' | '?' => None,
')' => Some('('),
']' => Some('['),
_ => break,
};
if let Some(open) = open
&& url[..end].matches(open).count() >= url[..end].matches(last).count()
{
break;
}
end -= last.len_utf8();
}
&url[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_drop_the_punctuation_that_ends_their_sentence() {
let found =
links("see https://ely.dev/docs. or (https://a.b/c) or https://w.org/Rust_(language)");
let urls: Vec<_> = found
.iter()
.map(|(_, target)| match target {
Target::Url(url) => url.to_string(),
other => panic!("a URL, not {other:?}"),
})
.collect();
assert_eq!(
urls,
[
"https://ely.dev/docs",
"https://a.b/c",
"https://w.org/Rust_(language)"
]
);
}
#[test]
fn paths_carry_the_line_and_column_a_compiler_names() {
let text = " --> src/main.rs:46:22";
let found = links(text);
assert_eq!(found.len(), 1);
let (range, target) = &found[0];
assert_eq!(&text[range.clone()], "src/main.rs:46:22");
assert_eq!(
*target,
Target::Path {
path: "src/main.rs".into(),
line: Some(46),
column: Some(22),
}
);
assert!(links("a.b and v1.2").is_empty(), "a path needs a folder");
let huge = links("src/a.rs:99999999999");
assert_eq!(
huge[0].1,
Target::Path {
path: "src/a.rs".into(),
line: None,
column: None
},
"no line past u32"
);
}
}
+303
View File
@@ -0,0 +1,303 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
ScrollDelta, ScrollStrategy, ScrollWheelEvent, SharedString, Styled, UniformListScrollHandle,
Window, div, prelude::*, uniform_list,
};
use super::ansi::{AnsiText, printed};
use crate::{
buttons::{SegmentedControl, ToggleButton, ToggleItem},
primitives::IconName,
theme::{ActiveTheme, ControlSize, Palette, TextSize},
typography::format,
};
/// How loud a log line is, quietest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
const LEVELS: [LogLevel; 5] = [
LogLevel::Trace,
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
];
impl LogLevel {
fn word(self) -> &'static str {
match self {
Self::Trace => "TRACE",
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
fn color(self, colors: &Palette) -> Hsla {
match self {
Self::Trace | Self::Debug => colors.fg_subtle,
Self::Info => colors.info,
Self::Warn => colors.warning,
Self::Error => colors.danger,
}
}
}
/// One line of a log: when, how loud, from where, and what it says, escape codes and all.
#[derive(Clone, Debug, PartialEq)]
pub struct LogLine {
pub at: SharedString,
pub level: LogLevel,
pub target: SharedString,
pub message: SharedString,
}
/// The lines at or above `least` whose target or text holds `query`, case aside, by index.
pub(crate) fn shown(lines: &[LogLine], least: LogLevel, query: &str) -> Vec<usize> {
let query = query.trim().to_lowercase();
lines
.iter()
.enumerate()
.filter(|(_, line)| line.level >= least)
.filter(|(_, line)| {
query.is_empty()
|| line.target.to_lowercase().contains(&query)
|| printed(&line.message).0.to_lowercase().contains(&query)
})
.map(|(ix, _)| ix)
.collect()
}
type OnLevel = Rc<dyn Fn(LogLevel, &mut Window, &mut App)>;
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// A log, newest at the bottom: from a chosen level up, found by a query, following new lines until a scroll up lets go. Long logs draw only the lines in view.
#[derive(IntoElement)]
pub struct LogViewer {
id: ElementId,
lines: Rc<Vec<LogLine>>,
least: LogLevel,
query: SharedString,
follow: bool,
on_least: Option<OnLevel>,
on_follow: Option<OnBool>,
}
impl LogViewer {
pub fn new(id: impl Into<ElementId>, lines: impl Into<Rc<Vec<LogLine>>>) -> Self {
Self {
id: id.into(),
lines: lines.into(),
least: LogLevel::Trace,
query: SharedString::default(),
follow: true,
on_least: None,
on_follow: None,
}
}
/// The quietest level shown.
pub fn least(mut self, least: LogLevel) -> Self {
self.least = least;
self
}
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
pub fn follow(mut self, follow: bool) -> Self {
self.follow = follow;
self
}
pub fn on_least(mut self, handler: impl Fn(LogLevel, &mut Window, &mut App) + 'static) -> Self {
self.on_least = Some(Rc::new(handler));
self
}
pub fn on_follow(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_follow = Some(Rc::new(handler));
self
}
}
impl RenderOnce for LogViewer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let scroll = window
.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| {
UniformListScrollHandle::new()
})
.read(cx)
.clone();
let theme = cx.theme();
let colors = theme.colors.clone();
let rows = Rc::new(shown(&self.lines, self.least, &self.query));
if self.follow && !rows.is_empty() {
scroll.scroll_to_item(rows.len() - 1, ScrollStrategy::Bottom);
}
let levels = LEVELS.iter().fold(
SegmentedControl::new((self.id.clone(), "levels"), self.least.word())
.size(ControlSize::Sm),
|control, level| control.segment(level.word(), level.word(), None),
);
let levels = match self.on_least {
Some(on_least) => levels.on_change(move |word, window, cx| {
let level = LEVELS
.into_iter()
.find(|level| level.word() == word.as_ref())
.expect("a segment names a level");
on_least(level, window, cx)
}),
None => levels,
};
let follow = ToggleButton::new(
(self.id.clone(), "follow"),
ToggleItem::new("follow")
.icon(IconName::ArrowDownToLine)
.tooltip("Follow new lines"),
self.follow,
)
.size(ControlSize::Sm);
let (release, follow) = match self.on_follow {
Some(on_follow) => (
Some(on_follow.clone()),
follow.on_toggle(move |on, window, cx| on_follow(on, window, cx)),
),
None => (None, follow),
};
let (lines, painted) = (self.lines.clone(), rows.clone());
let list = uniform_list(
(self.id.clone(), "lines"),
rows.len(),
move |range, _, cx| {
let colors = cx.theme().colors.clone();
range
.map(|ix| {
let line = &lines[painted[ix]];
div()
.id(ix)
.w_full()
.flex()
.gap_3()
.px_2()
.whitespace_nowrap()
.overflow_hidden()
.when(line.level == LogLevel::Error, |row| {
row.bg(colors.danger.opacity(0.06))
})
.child(
div()
.flex_none()
.text_color(colors.fg_subtle)
.child(line.at.clone()),
)
.child(
div()
.flex_none()
.w_12()
.text_color(line.level.color(&colors))
.child(line.level.word()),
)
.child(
div()
.flex_none()
.w_24()
.overflow_hidden()
.text_color(colors.fg_muted)
.child(line.target.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.child(AnsiText::new(line.message.clone())),
)
})
.collect()
},
)
.track_scroll(scroll)
.size_full();
div()
.size_full()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_2()
.pb_2()
.child(levels)
.child(div().flex_1().text_color(colors.fg_subtle).child(format!(
"{} of {}",
rows.len(),
format::plural(self.lines.len() as u64, "line", "lines")
)))
.child(follow),
)
.child(
div()
.id((self.id.clone(), "body"))
.flex_1()
.min_h_0()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.when_some(release.filter(|_| self.follow), |body, on_follow| {
body.on_scroll_wheel(move |event: &ScrollWheelEvent, window, cx| {
let back = match event.delta {
ScrollDelta::Pixels(delta) => delta.y > Pixels::ZERO,
ScrollDelta::Lines(delta) => delta.y > 0.0,
};
if back {
on_follow(false, window, cx)
}
})
})
.child(list),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(level: LogLevel, target: &str, message: &str) -> LogLine {
LogLine {
at: "12:00:00".into(),
level,
target: target.to_string().into(),
message: message.to_string().into(),
}
}
#[test]
fn lines_show_from_a_level_up_and_by_query() {
let lines = [
line(LogLevel::Debug, "gpui", "frame"),
line(LogLevel::Info, "ely::terminal", "started \x1b[1mzsh\x1b[0m"),
line(LogLevel::Error, "ely::pty", "the program ended"),
];
assert_eq!(shown(&lines, LogLevel::Info, ""), [1, 2]);
assert_eq!(
shown(&lines, LogLevel::Trace, "ZSH"),
[1],
"codes and case aside"
);
assert_eq!(shown(&lines, LogLevel::Trace, "pty"), [2], "targets count");
}
}
+28
View File
@@ -0,0 +1,28 @@
mod ansi;
mod block;
mod colors;
mod frame;
mod history;
mod input;
mod keys;
mod links;
mod logs;
mod paint;
mod processes;
mod pty;
mod search;
mod shells;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod view;
pub use ansi::AnsiText;
pub use block::{CommandBlock, CommandState};
pub use history::{CommandHistory, PastCommand};
pub(crate) use input::bind_keys;
pub use links::{Target, links};
pub use logs::{LogLevel, LogLine, LogViewer};
pub use processes::{Process, ProcessList};
pub use pty::Launch;
pub use shells::{ShellChoice, ShellSelector, TerminalToolbar};
pub use view::{Terminal, TerminalEvent};
+145
View File
@@ -0,0 +1,145 @@
use std::{ops::Range, rc::Rc};
use alacritty_terminal::vte::ansi::CursorShape;
use gpui::{
BorderStyle, Bounds, ElementInputHandler, Entity, Font, FontStyle, FontWeight, Hsla,
IntoElement, Pixels, Size, StrikethroughStyle, Styled, TextRun, UnderlineStyle, canvas, fill,
outline, point, size,
};
use super::{
frame::{Frame, Lit},
links::Target,
view::Terminal,
};
/// How a terminal paints: font and cell, ink, and the colors of what it marks.
pub(crate) struct Look {
pub font: Font,
pub size: Pixels,
pub cell: Size<Pixels>,
pub focused: bool,
pub caret: Hsla,
pub caret_width: Pixels,
pub rule: Pixels,
pub selection: Hsla,
pub found: Hsla,
pub current: Hsla,
pub link: Hsla,
}
/// The grid, fitted to its box: it tells the terminal its size and origin, then paints each row.
pub(crate) fn grid(
terminal: Entity<Terminal>,
frame: Rc<Frame>,
hovered: Option<(usize, Range<usize>, Target)>,
look: Look,
) -> impl IntoElement {
let (fit, typing) = (terminal.clone(), terminal);
let cell = look.cell;
canvas(
move |bounds, window, cx| {
let columns = (bounds.size.width / cell.width).floor() as usize;
let lines = (bounds.size.height / cell.height).floor() as usize;
fit.update(cx, |terminal, cx| {
terminal.origin = bounds.origin;
let before = terminal.size;
terminal.resize(columns, lines);
if terminal.size != before {
cx.notify();
window.request_animation_frame();
}
});
},
move |bounds, _, window, cx| {
let focus = typing.read(cx).focus.clone();
window.handle_input(&focus, ElementInputHandler::new(bounds, typing.clone()), cx);
let at = |row: usize, column: usize| {
bounds.origin + point(cell.width * column as f32, cell.height * row as f32)
};
for (ix, row) in frame.rows.iter().enumerate() {
let runs: Vec<TextRun> = row
.runs
.iter()
.map(|(len, style)| TextRun {
len: *len,
font: Font {
weight: if style.bold {
FontWeight::BOLD
} else {
FontWeight::NORMAL
},
style: if style.italic {
FontStyle::Italic
} else {
FontStyle::Normal
},
..look.font.clone()
},
color: style.fg,
background_color: style.bg,
underline: style.underline.then_some(UnderlineStyle {
thickness: look.rule,
color: Some(style.fg),
wavy: false,
}),
strikethrough: style.strike.then_some(StrikethroughStyle {
thickness: look.rule,
color: Some(style.fg),
}),
})
.collect();
let shaped = window.text_system().shape_line(
row.text.clone().into(),
look.size,
&runs,
Some(cell.width),
);
shaped
.paint_background(at(ix, 0), cell.height, window, cx)
.expect("a terminal row's ground paints");
for (_, columns, why) in frame.lit.iter().filter(|(row, ..)| *row == ix) {
let wash = match why {
Lit::Selected => look.selection,
Lit::Found => look.found,
Lit::Current => look.current,
};
let extent = size(cell.width * columns.len() as f32, cell.height);
window.paint_quad(fill(Bounds::new(at(ix, columns.start), extent), wash));
}
shaped
.paint(at(ix, 0), cell.height, window, cx)
.expect("a terminal row paints");
}
if let Some((row, columns, _)) = &hovered {
let under = at(*row, columns.start) + point(Pixels::ZERO, cell.height - look.rule);
let extent = size(cell.width * columns.len() as f32, look.rule);
window.paint_quad(fill(Bounds::new(under, extent), look.link));
}
if let Some((row, column, shape)) = frame.cursor {
let origin = at(row, column);
let quad = match (shape, look.focused) {
(CursorShape::HollowBlock, _) | (_, false) => {
outline(Bounds::new(origin, cell), look.caret, BorderStyle::Solid)
}
(CursorShape::Beam, true) => fill(
Bounds::new(origin, size(look.caret_width, cell.height)),
look.caret,
),
(CursorShape::Underline, true) => fill(
Bounds::new(
origin + point(Pixels::ZERO, cell.height - look.caret_width),
size(cell.width, look.caret_width),
),
look.caret,
),
(CursorShape::Block | CursorShape::Hidden, true) => {
fill(Bounds::new(origin, cell), look.caret.opacity(0.45))
}
};
window.paint_quad(quad);
}
},
)
.size_full()
}
+151
View File
@@ -0,0 +1,151 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
tables::{Cell, Column, DataTable, Row, SortKey},
theme::{ActiveTheme, ControlSize, TextSize},
typography::format,
};
/// A running process: its id, name and owner, and what it takes.
#[derive(Clone, Debug, PartialEq)]
pub struct Process {
pub pid: u32,
pub name: SharedString,
pub user: SharedString,
/// Percent of one core.
pub cpu: f32,
pub memory: u64,
}
type OnPids = Rc<dyn Fn(Vec<u32>, &mut Window, &mut App)>;
/// Running processes as a table, busiest first: found by a query, sorted by any column, and the selected ones ended.
#[derive(IntoElement)]
pub struct ProcessList {
id: ElementId,
processes: Vec<Process>,
query: SharedString,
selected: Vec<u32>,
on_select: Option<OnPids>,
on_end: Option<OnPids>,
}
impl ProcessList {
pub fn new(id: impl Into<ElementId>, processes: impl IntoIterator<Item = Process>) -> Self {
Self {
id: id.into(),
processes: processes.into_iter().collect(),
query: SharedString::default(),
selected: Vec::new(),
on_select: None,
on_end: None,
}
}
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
pub fn selected(mut self, pids: impl IntoIterator<Item = u32>) -> Self {
self.selected = pids.into_iter().collect();
self
}
pub fn on_select(
mut self,
handler: impl Fn(Vec<u32>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Called with the selected processes when End is pressed.
pub fn on_end(mut self, handler: impl Fn(Vec<u32>, &mut Window, &mut App) + 'static) -> Self {
self.on_end = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ProcessList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let busy: f32 = self.processes.iter().map(|process| process.cpu).sum();
let columns = [
Column::new("name", "Name"),
Column::new("pid", "PID").end(),
Column::new("user", "User"),
Column::new("cpu", "CPU").end().decimals(1).suffix(" %"),
Column::new("memory", "Memory")
.end()
.decimals(1)
.suffix(" MB"),
];
let rows: Vec<Row> = self
.processes
.iter()
.map(|process| {
Row::new(
process.pid.to_string(),
[
Cell::Text(process.name.clone()),
Cell::Text(process.pid.to_string().into()),
Cell::Text(process.user.clone()),
Cell::Number(f64::from(process.cpu)),
Cell::Number(process.memory as f64 / (1024.0 * 1024.0)),
],
)
})
.collect();
let pid =
|key: &SharedString| -> u32 { key.parse().expect("a process row is keyed by its pid") };
let table = DataTable::new((self.id.clone(), "table"), columns)
.rows(rows)
.query(self.query)
.sorts([SortKey {
column: "cpu".into(),
rising: false,
}])
.selected(self.selected.iter().map(|pid| pid.to_string()));
let table = match self.on_select {
Some(on_select) => table.on_select(move |keys, window, cx| {
on_select(keys.iter().map(pid).collect(), window, cx)
}),
None => table,
};
let chosen = self.selected.clone();
div()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_3()
.child(div().flex_1().text_color(colors.fg_muted).child(format!(
"{} · {:.1} % CPU",
format::plural(self.processes.len() as u64, "process", "processes"),
busy
)))
.children(self.on_end.map(|on_end| {
Button::new((self.id.clone(), "end"), "End process")
.variant(ButtonVariant::Danger)
.size(ControlSize::Sm)
.disabled(chosen.is_empty())
.on_click(move |_, window, cx| {
log::info!("process list: end {chosen:?}");
on_end(chosen.clone(), window, cx)
})
})),
)
.child(table)
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use alacritty_terminal::{
event::{Event, EventListener, WindowSize},
event_loop::{EventLoop, Notifier},
sync::FairMutex,
term::{Config, Term, test::TermSize},
tty::{self, Options, Shell},
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
/// Hands a terminal's events to its view.
#[derive(Clone)]
pub(crate) struct Listener(UnboundedSender<Event>);
impl EventListener for Listener {
fn send_event(&self, event: Event) {
if self.0.unbounded_send(event).is_err() {
log::debug!("terminal: an event after its view closed");
}
}
}
/// What a terminal runs: a program and its arguments, the login shell when none; where; and variables on top of TERM, COLORTERM, and LANG when the app has none.
#[derive(Clone, Debug, Default)]
pub struct Launch {
pub program: Option<(String, Vec<String>)>,
pub cwd: Option<PathBuf>,
pub env: Vec<(String, String)>,
}
/// A grid, the channel of its events, and the pseudo-terminal that feeds it when one does.
pub(crate) struct Session {
pub term: Arc<FairMutex<Term<Listener>>>,
pub pty: Option<Notifier>,
pub events: UnboundedReceiver<Event>,
}
/// A grid no process feeds, for replayed output.
pub(crate) fn detached(size: TermSize) -> Session {
let (sender, events) = unbounded();
let term = Term::new(Config::default(), &size, Listener(sender));
Session {
term: Arc::new(FairMutex::new(term)),
pty: None,
events,
}
}
/// Starts `launch` on a pseudo-terminal of `size` cells, each `cell` pixels.
pub(crate) fn spawn(launch: &Launch, size: TermSize, cell: (u16, u16)) -> anyhow::Result<Session> {
let (sender, events) = unbounded();
let listener = Listener(sender);
let term = Arc::new(FairMutex::new(Term::new(
Config::default(),
&size,
listener.clone(),
)));
let mut env: HashMap<String, String> = HashMap::from([
("TERM".to_string(), "xterm-256color".to_string()),
("COLORTERM".to_string(), "truecolor".to_string()),
]);
if std::env::var_os("LANG").is_none() {
env.insert("LANG".into(), "en_US.UTF-8".into());
}
env.extend(launch.env.iter().cloned());
let options = Options {
shell: launch
.program
.clone()
.map(|(program, args)| Shell::new(program, args)),
working_directory: launch.cwd.clone(),
drain_on_exit: true,
env,
};
let pty = tty::new(&options, window_size(&size, cell), 0)?;
let event_loop = EventLoop::new(term.clone(), listener, pty, true, false)?;
let pty = Notifier(event_loop.channel());
event_loop.spawn();
log::info!("terminal: started {:?}", launch.program);
Ok(Session {
term,
pty: Some(pty),
events,
})
}
pub(crate) fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize {
let cells =
|count: usize| u16::try_from(count).expect("a terminal is under 65536 cells a side");
WindowSize {
num_lines: cells(size.screen_lines),
num_cols: cells(size.columns),
cell_width: cell.0,
cell_height: cell.1,
}
}
#[cfg(all(test, unix))]
mod tests {
use alacritty_terminal::index::{Column, Line};
use futures::StreamExt;
use super::*;
#[test]
fn a_program_prints_into_the_grid_through_a_pseudo_terminal() {
let launch = Launch {
program: Some((
"/bin/sh".into(),
vec!["-c".into(), "printf 'ely %s' \"$ELY\"".into()],
)),
env: vec![("ELY".into(), "ok".into())],
..Launch::default()
};
let mut session = spawn(&launch, TermSize::new(20, 4), (8, 16)).expect("sh starts");
let status = futures::executor::block_on(async {
let mut status = None;
while let Some(event) = session.events.next().await {
match event {
Event::ChildExit(ended) => status = Some(ended),
Event::Exit => break,
_ => {}
}
}
status
});
assert!(status.expect("the program ended").success());
let term = session.term.lock();
let row = &term.grid()[Line(0)];
let text: String = (0..6).map(|column| row[Column(column)].c).collect();
assert_eq!(text, "ely ok");
}
}
+131
View File
@@ -0,0 +1,131 @@
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Direction, Point},
term::{
Term,
search::{Match, RegexIter, RegexSearch},
},
};
use gpui::Context;
use super::view::Terminal;
use crate::editor::FindOptions;
/// A find's matches over the scrollback and the screen, and the one stepped to.
#[derive(Default)]
pub(crate) struct Search {
all: Vec<Match>,
current: Option<usize>,
}
impl Search {
pub(crate) fn visible(&self) -> (&[Match], Option<usize>) {
(&self.all, self.current)
}
}
/// `query` under `options` as the grid's search reads it; word edges are ASCII, which its lazy DFA needs.
fn pattern(query: &str, options: FindOptions) -> String {
let body = if options.regex {
query.to_string()
} else {
regex::escape(query)
};
let body = if options.word {
format!(r"(?-u:\b)(?:{body})(?-u:\b)")
} else {
body
};
let case = if options.case { "(?-i)" } else { "(?i)" };
format!("{case}{body}")
}
/// Every match of `pattern` from the oldest line to the newest.
fn search_all<T>(term: &Term<T>, pattern: &str) -> Result<Vec<Match>, String> {
let mut regex = RegexSearch::new(pattern).map_err(|error| error.to_string())?;
let start = Point::new(term.topmost_line(), Column(0));
let end = Point::new(term.bottommost_line(), term.last_column());
Ok(RegexIter::new(start, end, Direction::Right, term, &mut regex).collect())
}
impl Terminal {
/// Finds `query` in the scrollback and the screen and steps to the newest match; a bad pattern is an error to show.
pub fn find(
&mut self,
query: &str,
options: FindOptions,
cx: &mut Context<Self>,
) -> Result<usize, String> {
let all = if query.is_empty() {
Vec::new()
} else {
search_all(&self.term.lock(), &pattern(query, options))?
};
log::debug!("terminal: {} matches of {query:?}", all.len());
self.search = Search {
current: all.len().checked_sub(1),
all,
};
self.reveal(cx);
Ok(self.search.all.len())
}
/// Steps to the next match, or the one before, round the ends.
pub fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
let count = self.search.all.len();
let Some(at) = self.search.current else {
return;
};
self.search.current = Some(if forward {
(at + 1) % count
} else {
(at + count - 1) % count
});
self.reveal(cx);
}
/// How many matches there are, and which one is current.
pub fn matches(&self) -> (usize, Option<usize>) {
(self.search.all.len(), self.search.current)
}
fn reveal(&mut self, cx: &mut Context<Self>) {
if let Some(at) = self.search.current {
let start = *self.search.all[at].start();
self.term.lock().scroll_to_point(start);
}
cx.notify();
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::term::test::mock_term;
use super::*;
#[test]
fn find_takes_case_words_and_patterns() {
let term = mock_term("one two One\r\nthree phone one");
let count = |query: &str, options: FindOptions| {
search_all(&term, &pattern(query, options)).map(|all| all.len())
};
assert_eq!(count("one", FindOptions::default()), Ok(4));
let case = FindOptions {
case: true,
..FindOptions::default()
};
assert_eq!(count("One", case), Ok(1));
let word = FindOptions {
word: true,
..FindOptions::default()
};
assert_eq!(count("one", word), Ok(3), "phone holds no whole word");
let regex = FindOptions {
regex: true,
..FindOptions::default()
};
assert_eq!(count("t[wh]", regex), Ok(2));
assert!(count("(", regex).is_err());
}
}
+195
View File
@@ -0,0 +1,195 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
buttons::{ButtonVariant, IconButton},
menus::{Menu, MenuItem, SplitButton},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// A shell a terminal can start: its name and its program.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShellChoice {
pub name: SharedString,
pub program: SharedString,
}
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// A new terminal in the default shell, with the others under its arrow, the default checked.
#[derive(IntoElement)]
pub struct ShellSelector {
id: ElementId,
shells: Vec<ShellChoice>,
default: usize,
on_pick: Option<OnIndex>,
}
impl ShellSelector {
pub fn new(id: impl Into<ElementId>, shells: impl IntoIterator<Item = ShellChoice>) -> Self {
let shells: Vec<ShellChoice> = shells.into_iter().collect();
assert!(
!shells.is_empty(),
"a shell selector offers at least one shell"
);
Self {
id: id.into(),
shells,
default: 0,
on_pick: None,
}
}
pub fn default_shell(mut self, default: usize) -> Self {
assert!(
default < self.shells.len(),
"the default is one of the shells"
);
self.default = default;
self
}
/// Called with the shell to start: the default from the button, any from the menu.
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ShellSelector {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let menu = self
.shells
.iter()
.enumerate()
.fold(Menu::new(), |menu, (ix, shell)| {
let pick = self.on_pick.clone();
menu.item(
MenuItem::check(shell.name.clone(), ix == self.default).on_click(
move |window, cx| {
if let Some(pick) = &pick {
pick(ix, window, cx)
}
},
),
)
});
let (pick, default) = (self.on_pick, self.default);
SplitButton::new(self.id, format!("New {}", self.shells[default].name), menu)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
if let Some(pick) = &pick {
pick(default, window, cx)
}
})
}
}
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// The row over a terminal: its title and how its program stands, the shell selector, and split, clear, stop and maximize.
#[derive(IntoElement)]
pub struct TerminalToolbar {
id: ElementId,
title: SharedString,
exit: Option<i32>,
shells: Option<AnyElement>,
actions: Vec<(&'static str, IconName, &'static str, Run)>,
}
impl TerminalToolbar {
/// `exit` is the program's exit code once it has ended.
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
exit: Option<i32>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
exit,
shells: None,
actions: Vec::new(),
}
}
pub fn shells(mut self, selector: ShellSelector) -> Self {
self.shells = Some(selector.into_any_element());
self
}
fn action(
mut self,
key: &'static str,
icon: IconName,
words: &'static str,
run: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.actions.push((key, icon, words, Rc::new(run)));
self
}
pub fn on_split(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.action("split", IconName::Columns2, "Split terminal", run)
}
pub fn on_clear(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.action("clear", IconName::Eraser, "Clear", run)
}
pub fn on_stop(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.action("stop", IconName::CircleStop, "Stop the program", run)
}
pub fn on_maximize(self, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.action("maximize", IconName::Maximize2, "Maximize", run)
}
}
impl RenderOnce for TerminalToolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (mark, color) = match self.exit {
None => (IconName::Terminal, colors.fg_muted),
Some(0) => (IconName::CircleCheck, colors.success),
Some(_) => (IconName::CircleX, colors.danger),
};
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Sm))
.child(Icon::new(mark).size(IconSize::Sm).color(color))
.child(
div()
.flex_1()
.min_w_0()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(self.title),
)
.children(self.exit.filter(|code| *code != 0).map(|code| {
div()
.text_color(colors.danger)
.child(format!("Exit {code}"))
}))
.children(self.shells)
.children(self.actions.into_iter().map(|(key, icon, words, run)| {
IconButton::new((self.id.clone(), key), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(words)
.on_click(move |_, window, cx| run(window, cx))
}))
}
}
+51
View File
@@ -0,0 +1,51 @@
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::Terminal;
use crate::{editor::FindOptions, theme::Theme};
fn replayed<'a>(
bytes: &'static [u8],
cx: &'a mut TestAppContext,
) -> (Entity<Terminal>, &'a mut VisualTestContext) {
cx.update(Theme::init);
let (terminal, cx) = cx.add_window_view(move |_, cx| Terminal::replay(bytes, 30, 4, cx));
cx.run_until_parked();
(terminal, cx)
}
#[gpui::test]
fn replayed_output_shows_without_its_codes(cx: &mut TestAppContext) {
let (terminal, cx) = replayed(b"\x1b[32mok\x1b[0m done\r\n\x1b]0;build\x07next", cx);
terminal.read_with(cx, |terminal, _| {
assert_eq!(terminal.text(), "ok done\nnext");
assert_eq!(terminal.title().as_ref(), "build", "OSC 0 names it");
assert_eq!(terminal.shown.cursor, None, "a replay shows no cursor");
});
}
#[gpui::test]
fn find_counts_matches_and_steps_round(cx: &mut TestAppContext) {
let (terminal, cx) = replayed(b"one two one\r\nthree one", cx);
terminal.update(cx, |terminal, cx| {
assert_eq!(terminal.find("one", FindOptions::default(), cx), Ok(3));
assert_eq!(terminal.matches(), (3, Some(2)), "the newest match first");
terminal.step(true, cx);
assert_eq!(
terminal.matches(),
(3, Some(0)),
"past the last comes the first"
);
assert!(
terminal
.find(
"(",
FindOptions {
regex: true,
..FindOptions::default()
},
cx
)
.is_err()
);
});
}
+332
View File
@@ -0,0 +1,332 @@
use std::{borrow::Cow, process::ExitStatus, rc::Rc, sync::Arc, time::Instant};
use alacritty_terminal::{
event::{Event, Notify, OnResize},
event_loop::Notifier,
grid::Scroll,
sync::FairMutex,
term::{Term, test::TermSize},
vte::ansi::{ClearMode, Handler, Processor},
};
use futures::StreamExt;
use gpui::{
App, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement,
Pixels, Point, Render, SharedString, Size, Styled, Task, Window, div, font, prelude::*, size,
};
use super::{
colors::Ink,
frame::{Frame, frame},
input,
links::Target,
paint,
pty::{self, Launch, Listener, Session},
search::Search,
};
use crate::{
motion,
theme::{ActiveTheme, TextSize},
};
/// Line height over the text size.
const LEADING: f32 = 1.35;
/// No program types into a replay, so it shows no cursor.
const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
/// What a terminal tells its host.
#[derive(Clone, Debug, PartialEq)]
pub enum TerminalEvent {
Title(SharedString),
Exited(ExitStatus),
Bell,
/// A path a Cmd-press opened; URLs open in the browser.
Open(Target),
/// Cmd-F: the host shows its find widget.
Find,
}
/// A terminal: a program on a pseudo-terminal, or replayed output, drawn cell by cell. Type to it, drag to select, Cmd-press a link.
pub struct Terminal {
pub(crate) term: Arc<FairMutex<Term<Listener>>>,
pub(crate) pty: Option<Notifier>,
pub(crate) focus: FocusHandle,
title: SharedString,
exited: Option<ExitStatus>,
/// Columns and lines.
pub(crate) size: (usize, usize),
pub(crate) cell: Size<Pixels>,
/// The grid's top-left in the window, from the last paint.
pub(crate) origin: Point<Pixels>,
pub(crate) shown: Rc<Frame>,
pub(crate) search: Search,
/// The link under the pointer while Cmd is held: its row, columns and target.
pub(crate) hovered: Option<(usize, std::ops::Range<usize>, Target)>,
/// Wheel pixels not yet a whole line.
pub(crate) rest: Pixels,
/// Whether a press here started the drag that selects.
pub(crate) selecting: bool,
bell: Option<Instant>,
_events: Task<()>,
}
impl EventEmitter<TerminalEvent> for Terminal {}
impl Focusable for Terminal {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Terminal {
/// Starts `launch` on a pseudo-terminal; the first layout sizes its grid to the box.
pub fn spawn(launch: Launch, cx: &mut Context<Self>) -> anyhow::Result<Self> {
let size = (80, 24);
let session = pty::spawn(&launch, TermSize::new(size.0, size.1), (8, 16))?;
Ok(Self::new(session, size, "Terminal".into(), cx))
}
/// Output recorded elsewhere, escape codes and all, on a grid of `columns` by `lines` no process feeds.
pub fn replay(bytes: &[u8], columns: usize, lines: usize, cx: &mut Context<Self>) -> Self {
let session = pty::detached(TermSize::new(columns, lines));
{
let mut term = session.term.lock();
let mut parser: Processor = Processor::new();
parser.advance(&mut *term, bytes);
parser.advance(&mut *term, HIDE_CURSOR);
}
Self::new(session, (columns, lines), "Output".into(), cx)
}
fn new(
session: Session,
size: (usize, usize),
title: SharedString,
cx: &mut Context<Self>,
) -> Self {
let Session {
term,
pty,
mut events,
} = session;
let task = cx.spawn(async move |this, cx| {
while let Some(first) = events.next().await {
let mut batch = vec![first];
while let Ok(more) = events.try_recv() {
batch.push(more);
}
if this
.update(cx, |terminal, cx| terminal.handle(batch, cx))
.is_err()
{
break;
}
}
});
Self {
term,
pty,
focus: cx.focus_handle(),
title,
exited: None,
size,
cell: size_of_cell(),
origin: Point::default(),
shown: Rc::default(),
search: Search::default(),
hovered: None,
rest: Pixels::ZERO,
selecting: false,
bell: None,
_events: task,
}
}
pub fn title(&self) -> &SharedString {
&self.title
}
/// How the program ended, once it has.
pub fn exit_status(&self) -> Option<ExitStatus> {
self.exited
}
/// The screen's text as it shows now, trailing spaces gone.
pub fn text(&self) -> String {
self.shown.text()
}
/// Sends bytes to the program, as typing would.
pub fn write(&mut self, bytes: impl Into<Cow<'static, [u8]>>) {
match &self.pty {
Some(pty) => pty.notify(bytes),
None => log::debug!("terminal: input to a replayed grid"),
}
}
/// Clears the screen and the scrollback; a shell redraws its prompt.
pub fn clear(&mut self, cx: &mut Context<Self>) {
{
let mut term = self.term.lock();
term.clear_screen(ClearMode::Saved);
if self.pty.is_none() {
term.clear_screen(ClearMode::All);
}
}
if self.pty.is_some() {
self.write(&b"\x0c"[..]);
}
log::info!("terminal: cleared");
cx.notify();
}
pub(crate) fn scroll(&mut self, scroll: Scroll, cx: &mut Context<Self>) {
self.term.lock().scroll_display(scroll);
cx.notify();
}
/// Fits the grid to `columns` by `lines`, and tells the program.
pub(crate) fn resize(&mut self, columns: usize, lines: usize) {
let size = (columns.max(2), lines.max(1));
if size == self.size {
return;
}
self.size = size;
self.term.lock().resize(TermSize::new(size.0, size.1));
let cell = (
f32::from(self.cell.width).round() as u16,
f32::from(self.cell.height).round() as u16,
);
if let Some(pty) = &mut self.pty {
pty.on_resize(pty::window_size(&TermSize::new(size.0, size.1), cell));
}
log::debug!("terminal: {columns} by {lines}");
}
fn handle(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
for event in events {
match event {
Event::Wakeup | Event::MouseCursorDirty | Event::CursorBlinkingChange => {}
Event::Title(title) => {
self.title = title.into();
cx.emit(TerminalEvent::Title(self.title.clone()));
}
Event::ResetTitle => {
self.title = "Terminal".into();
cx.emit(TerminalEvent::Title(self.title.clone()));
}
Event::Bell => {
self.bell = Some(Instant::now());
cx.emit(TerminalEvent::Bell);
}
Event::ClipboardStore(_, text) => {
cx.write_to_clipboard(ClipboardItem::new_string(text))
}
Event::ClipboardLoad(_, reply) => {
let text = cx
.read_from_clipboard()
.and_then(|item| item.text())
.unwrap_or_default();
self.write(reply(&text).into_bytes());
}
Event::ColorRequest(index, reply) => {
let ink = Ink::new(&cx.theme().colors);
let rgb = ink.query(index, self.term.lock().colors());
self.write(reply(rgb).into_bytes());
}
Event::TextAreaSizeRequest(reply) => {
let cell = (
f32::from(self.cell.width).round() as u16,
f32::from(self.cell.height).round() as u16,
);
let size = TermSize::new(self.size.0, self.size.1);
self.write(reply(pty::window_size(&size, cell)).into_bytes());
}
Event::PtyWrite(text) => self.write(text.into_bytes()),
Event::ChildExit(status) => {
log::info!("terminal: the program ended, {status}");
self.exited = Some(status);
cx.emit(TerminalEvent::Exited(status));
}
Event::Exit => log::info!("terminal: the grid closed"),
}
}
cx.notify();
}
}
/// A cell before the first layout measures one.
fn size_of_cell() -> Size<Pixels> {
size(Pixels::from(8.0), Pixels::from(16.0))
}
impl Drop for Terminal {
fn drop(&mut self) {
if let Some(pty) = &self.pty
&& pty
.0
.send(alacritty_terminal::event_loop::Msg::Shutdown)
.is_err()
{
log::debug!("terminal: the program had already ended");
}
}
}
impl Render for Terminal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let ink = Ink::new(&theme.colors);
let text = theme.text_size(TextSize::Sm);
let mono = font(theme.mono_family.clone());
let pixels = text.to_pixels(window.rem_size());
let advance = window
.text_system()
.advance(window.text_system().resolve_font(&mono), pixels, 'm')
.expect("the code font has an m")
.width;
self.cell = size(advance, (pixels * LEADING).round());
let (found, current) = self.search.visible();
self.shown = Rc::new(frame(&self.term.lock(), &ink, found, current));
let flash = self.bell.and_then(|rang| {
let lasting = motion::duration(motion::SLOW, cx);
let spent = rang.elapsed();
(spent < lasting).then(|| 1.0 - spent.as_secs_f32() / lasting.as_secs_f32())
});
if flash.is_some() {
window.request_animation_frame();
}
let colors = theme.colors.clone();
let look = paint::Look {
font: mono,
size: pixels,
cell: self.cell,
focused: self.focus.is_focused(window),
caret: colors.focus,
caret_width: theme.caret_width().to_pixels(window.rem_size()),
rule: theme.underline_thickness(),
selection: colors.selection,
found: colors.warning.opacity(0.25),
current: colors.warning.opacity(0.55),
link: colors.link,
};
let root = div()
.id(("terminal", cx.entity_id()))
.key_context(input::CONTEXT)
.track_focus(&self.focus)
.size_full()
.p_2()
.bg(ink.bg)
.when_some(flash, |root, left| {
root.bg(ink.bg.blend(colors.warning.opacity(0.12 * left)))
})
.when(self.hovered.is_some(), |root| root.cursor_pointer())
.child(paint::grid(
cx.entity(),
self.shown.clone(),
self.hovered.clone(),
look,
));
input::listen(root, cx)
}
}