Files
Ely-GPUI-Components/src/terminal/view.rs
T

333 lines
11 KiB
Rust

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)
}
}