use std::{borrow::Cow, process::ExitStatus, rc::Rc, sync::Arc, time::Instant}; use alacritty_terminal::{ event::{Event, Notify, OnResize}, event_loop::Notifier, grid::Scroll, sync::FairMutex, term::{Term, test::TermSize}, vte::ansi::{ClearMode, Handler, Processor}, }; use futures::StreamExt; use gpui::{ App, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Point, Render, SharedString, Size, Styled, Task, Window, div, font, prelude::*, size, }; use super::{ colors::Ink, frame::{Frame, frame}, input, links::Target, paint, pty::{self, Launch, Listener, Session}, search::Search, }; use crate::{ motion, theme::{ActiveTheme, TextSize}, }; /// Line height over the text size. const LEADING: f32 = 1.35; /// No program types into a replay, so it shows no cursor. const HIDE_CURSOR: &[u8] = b"\x1b[?25l"; /// What a terminal tells its host. #[derive(Clone, Debug, PartialEq)] pub enum TerminalEvent { Title(SharedString), Exited(ExitStatus), Bell, /// A path a Cmd-press opened; URLs open in the browser. Open(Target), /// Cmd-F: the host shows its find widget. Find, } /// A terminal: a program on a pseudo-terminal, or replayed output, drawn cell by cell. Type to it, drag to select, Cmd-press a link. pub struct Terminal { pub(crate) term: Arc>>, pub(crate) pty: Option, pub(crate) focus: FocusHandle, title: SharedString, exited: Option, /// Columns and lines. pub(crate) size: (usize, usize), pub(crate) cell: Size, /// The grid's top-left in the window, from the last paint. pub(crate) origin: Point, pub(crate) shown: Rc, pub(crate) search: Search, /// The link under the pointer while Cmd is held: its row, columns and target. pub(crate) hovered: Option<(usize, std::ops::Range, Target)>, /// Wheel pixels not yet a whole line. pub(crate) rest: Pixels, /// Whether a press here started the drag that selects. pub(crate) selecting: bool, bell: Option, _events: Task<()>, } impl EventEmitter for Terminal {} impl Focusable for Terminal { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus.clone() } } impl Terminal { /// Starts `launch` on a pseudo-terminal; the first layout sizes its grid to the box. pub fn spawn(launch: Launch, cx: &mut Context) -> anyhow::Result { let size = (80, 24); let session = pty::spawn(&launch, TermSize::new(size.0, size.1), (8, 16))?; Ok(Self::new(session, size, "Terminal".into(), cx)) } /// Output recorded elsewhere, escape codes and all, on a grid of `columns` by `lines` no process feeds. pub fn replay(bytes: &[u8], columns: usize, lines: usize, cx: &mut Context) -> Self { let session = pty::detached(TermSize::new(columns, lines)); { let mut term = session.term.lock(); let mut parser: Processor = Processor::new(); parser.advance(&mut *term, bytes); parser.advance(&mut *term, HIDE_CURSOR); } Self::new(session, (columns, lines), "Output".into(), cx) } fn new( session: Session, size: (usize, usize), title: SharedString, cx: &mut Context, ) -> Self { let Session { term, pty, mut events, } = session; let task = cx.spawn(async move |this, cx| { while let Some(first) = events.next().await { let mut batch = vec![first]; while let Ok(more) = events.try_recv() { batch.push(more); } if this .update(cx, |terminal, cx| terminal.handle(batch, cx)) .is_err() { break; } } }); Self { term, pty, focus: cx.focus_handle().tab_stop(true), title, exited: None, size, cell: size_of_cell(), origin: Point::default(), shown: Rc::default(), search: Search::default(), hovered: None, rest: Pixels::ZERO, selecting: false, bell: None, _events: task, } } pub fn title(&self) -> &SharedString { &self.title } /// How the program ended, once it has. pub fn exit_status(&self) -> Option { self.exited } /// The screen's text as it shows now, trailing spaces gone. pub fn text(&self) -> String { self.shown.text() } /// Sends bytes to the program, as typing would. pub fn write(&mut self, bytes: impl Into>) { match &self.pty { Some(pty) => pty.notify(bytes), None => log::debug!("terminal: input to a replayed grid"), } } /// Clears the screen and the scrollback; a shell redraws its prompt. pub fn clear(&mut self, cx: &mut Context) { self.search = Search::default(); { let mut term = self.term.lock(); term.clear_screen(ClearMode::Saved); if self.pty.is_none() { term.clear_screen(ClearMode::All); } } if self.pty.is_some() { self.write(&b"\x0c"[..]); } log::info!("terminal: cleared"); cx.notify(); } pub(crate) fn scroll(&mut self, scroll: Scroll, cx: &mut Context) { self.term.lock().scroll_display(scroll); cx.notify(); } /// Fits the grid to `columns` by `lines`, and tells the program. pub(crate) fn resize(&mut self, columns: usize, lines: usize) { let size = (columns.max(2), lines.max(1)); if size == self.size { return; } self.size = size; self.term.lock().resize(TermSize::new(size.0, size.1)); let cell = ( f32::from(self.cell.width).round() as u16, f32::from(self.cell.height).round() as u16, ); if let Some(pty) = &mut self.pty { pty.on_resize(pty::window_size(&TermSize::new(size.0, size.1), cell)); } log::debug!("terminal: {columns} by {lines}"); } fn handle(&mut self, events: Vec, cx: &mut Context) { for event in events { match event { Event::Wakeup | Event::MouseCursorDirty | Event::CursorBlinkingChange => {} Event::Title(title) => { self.title = title.into(); cx.emit(TerminalEvent::Title(self.title.clone())); } Event::ResetTitle => { self.title = "Terminal".into(); cx.emit(TerminalEvent::Title(self.title.clone())); } Event::Bell => { self.bell = Some(Instant::now()); cx.emit(TerminalEvent::Bell); } Event::ClipboardStore(_, text) => { cx.write_to_clipboard(ClipboardItem::new_string(text)) } Event::ClipboardLoad(_, reply) => { let text = cx .read_from_clipboard() .and_then(|item| item.text()) .unwrap_or_default(); self.write(reply(&text).into_bytes()); } Event::ColorRequest(index, reply) => { let ink = Ink::new(&cx.theme().colors); let rgb = ink.query(index, self.term.lock().colors()); self.write(reply(rgb).into_bytes()); } Event::TextAreaSizeRequest(reply) => { let cell = ( f32::from(self.cell.width).round() as u16, f32::from(self.cell.height).round() as u16, ); let size = TermSize::new(self.size.0, self.size.1); self.write(reply(pty::window_size(&size, cell)).into_bytes()); } Event::PtyWrite(text) => self.write(text.into_bytes()), Event::ChildExit(status) => { log::info!("terminal: the program ended, {status}"); self.exited = Some(status); cx.emit(TerminalEvent::Exited(status)); } Event::Exit => log::info!("terminal: the grid closed"), } } cx.notify(); } } /// A cell before the first layout measures one. fn size_of_cell() -> Size { size(Pixels::from(8.0), Pixels::from(16.0)) } impl Drop for Terminal { fn drop(&mut self) { if let Some(pty) = &self.pty && pty .0 .send(alacritty_terminal::event_loop::Msg::Shutdown) .is_err() { log::debug!("terminal: the program had already ended"); } } } impl Render for Terminal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let ink = Ink::new(&theme.colors); let text = theme.text_size(TextSize::Sm); let mono = font(theme.mono_family.clone()); let pixels = text.to_pixels(window.rem_size()); let advance = window .text_system() .advance(window.text_system().resolve_font(&mono), pixels, 'm') .expect("the code font has an m") .width; self.cell = size(advance, (pixels * LEADING).round()); let (found, current) = self.search.visible(); self.shown = Rc::new(frame(&self.term.lock(), &ink, found, current)); let flash = self.bell.and_then(|rang| { let lasting = motion::duration(motion::SLOW, cx); let spent = rang.elapsed(); (spent < lasting).then(|| 1.0 - spent.as_secs_f32() / lasting.as_secs_f32()) }); if flash.is_some() { window.request_animation_frame(); } let colors = theme.colors.clone(); let look = paint::Look { font: mono, size: pixels, cell: self.cell, focused: self.focus.is_focused(window), caret: colors.focus, caret_width: theme.caret_width().to_pixels(window.rem_size()), rule: theme.underline_thickness(), selection: colors.selection, found: colors.warning.opacity(0.25), current: colors.warning.opacity(0.55), link: colors.link, }; let root = div() .id(("terminal", cx.entity_id())) .key_context(input::CONTEXT) .track_focus(&self.focus) .size_full() .p_2() .bg(ink.bg) .when_some(flash, |root, left| { root.bg(ink.bg.blend(colors.warning.opacity(0.12 * left))) }) .when(self.hovered.is_some(), |root| root.cursor_pointer()) .child(paint::grid( cx.entity(), self.shown.clone(), self.hovered.clone(), look, )); input::listen(root, cx) } }