use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration}; use alacritty_terminal::{ event::Event, grid::Dimensions, term::ClipboardType, vte::ansi::CursorShape, }; use futures::StreamExt; use gpui::{ Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle, FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription, Task, Window, div, prelude::*, px, size, }; use super::{ frame::frame, ink::Ink, input::{self, Copy, Paste}, mouse::MouseMode, paint::{self, Look}, pointer::Pointer, pty::{Events, Pty, PtyEvent, config, fit}, }; use crate::{ settings::{Settings, SettingsStore}, themes, }; /// The bundled Nerd Font that draws icons any font lacks; gpui loads no font without an `m`. pub const ICONS: &str = "JetBrainsMono Nerd Font Mono"; /// The bundled code font, the default. pub const MONO: &str = "JetBrains Mono"; /// How long the cursor shows, then hides, while it blinks. const BLINK: Duration = Duration::from_millis(600); /// What a terminal tells its owner. #[derive(Clone, Debug, PartialEq)] pub enum TerminalEvent { Title(String), Folder(PathBuf), Bell, Exited(ExitStatus), } /// A program on a pseudo-terminal, drawn cell by cell in the theme and font the settings choose. pub struct TerminalView { pub(super) pty: Pty, pub(super) focus: FocusHandle, /// Text an input method is composing, drawn at the cursor. pub(super) marked: Option, /// The cursor's cell in the window, from the last layout. pub(super) caret: Bounds, pub(super) pointer: Pointer, /// The mouse mode, as the tap follows it. pub(super) mouse: MouseMode, /// Keys whose press this terminal saw, so their releases may go out. pub(super) held: HashSet, pub(super) size: (usize, usize), cell: (u16, u16), blinking: bool, shown: bool, _events: Task<()>, _blink: Task<()>, _activation: Subscription, _settings: Subscription, } impl EventEmitter for TerminalView {} impl Focusable for TerminalView { fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus.clone() } } impl TerminalView { /// Draws `pty`; the first layout fits its grid to the box. pub fn new(pty: Pty, events: Events, window: &mut Window, cx: &mut Context) -> Self { let size = { let term = pty.term.lock(); (term.columns(), term.screen_lines()) }; Self { pty, focus: cx.focus_handle().tab_stop(true), marked: None, caret: Bounds::default(), pointer: Pointer::default(), mouse: MouseMode::default(), held: HashSet::new(), size, cell: (8, 16), blinking: false, shown: true, _events: Self::pump(events, cx), _blink: Self::blink(cx), _activation: cx.observe_window_activation(window, |_, _, cx| cx.notify()), _settings: cx.observe_global::(|view, cx| { view.pty .term .lock() .set_options(config(SettingsStore::get(cx))); cx.notify(); }), } } pub fn pid(&self) -> u32 { self.pty.pid } fn pump(mut events: Events, cx: &mut Context) -> Task<()> { cx.spawn(async move |view, cx| { while let Some(first) = events.next().await { let mut batch = vec![first]; while let Ok(more) = events.try_recv() { batch.push(more); } if view.update(cx, |view, cx| view.handle(batch, cx)).is_err() { break; } } }) } fn blink(cx: &mut Context) -> Task<()> { cx.spawn(async move |view, cx| { loop { cx.background_executor().timer(BLINK).await; let ticked = view.update(cx, |view, cx| { if view.blinking { view.shown = !view.shown; cx.notify(); } }); if ticked.is_err() { break; } } }) } /// Fits the grid to `columns` by `lines`; true when the size changed. pub(super) fn fit(&mut self, columns: usize, lines: usize) -> bool { let fitted = fit(columns, lines, self.cell); let size = (usize::from(fitted.num_cols), usize::from(fitted.num_lines)); if size == self.size { return false; } self.size = size; self.pty.resize(size, self.cell); log::debug!("terminal {}: {} by {}", self.pty.pid, size.0, size.1); true } fn handle(&mut self, events: Vec, cx: &mut Context) { for event in events { match event { PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)), PtyEvent::Term(event) => self.term_event(event, cx), PtyEvent::Mouse(mode) => self.mouse = mode, } } cx.notify(); } fn term_event(&mut self, event: Event, cx: &mut Context) { match event { Event::Wakeup => self.shown = true, Event::MouseCursorDirty | Event::CursorBlinkingChange => {} Event::Title(title) => cx.emit(TerminalEvent::Title(title)), Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())), Event::Bell => cx.emit(TerminalEvent::Bell), Event::ClipboardStore(kind, text) => { log::info!("terminal {}: OSC 52 writes the {kind:?}", self.pty.pid); self.copy_text(text, kind == ClipboardType::Selection, cx); } Event::ClipboardLoad(..) => log::error!( "terminal {}: a clipboard read arrived though OSC 52 reads are off", self.pty.pid ), Event::ColorRequest(index, reply) => { let theme = themes::active(SettingsStore::get(cx), cx.window_appearance()); let term = self.pty.term.lock(); let rgb = Ink::new(theme, term.colors()).query(index, term.colors()); drop(term); self.pty.write(reply(rgb).into_bytes()); } Event::TextAreaSizeRequest(reply) => { let size = fit(self.size.0, self.size.1, self.cell); self.pty.write(reply(size).into_bytes()); } Event::PtyWrite(text) => self.pty.write(text.into_bytes()), Event::ChildExit(status) => cx.emit(TerminalEvent::Exited(status)), Event::Exit => log::debug!("terminal {}: the grid closed", self.pty.pid), } } } /// The font, cell and metrics the settings give at this window's scale. fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result { let font = Font { family: settings.font_family.clone().into(), features: if settings.ligatures { FontFeatures::default() } else { FontFeatures::disable_ligatures() }, fallbacks: None, weight: FontWeight::NORMAL, style: FontStyle::Normal, }; let text = px(settings.font_size); let system = window.text_system(); let id = system.resolve_font(&font); let advance = system .advance(id, text, 'M') .map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?; let scale = window.scale_factor(); let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale); let cell = size( snap(advance.width).max(px(1.)), snap(text * settings.line_height), ); let (ascent, descent) = (system.ascent(id, text), system.descent(id, text).abs()); let icons = Font { family: ICONS.into(), features: FontFeatures::default(), ..font.clone() }; Ok(Look { font_id: id, icons, baseline: (cell.height - ascent - descent) / 2. + ascent, descent, rule: snap((text / 14.).max(px(1.))), size: text, cell, font, cursor: ink.cursor, cursor_text: ink.cursor_text, fg: ink.fg, bg: ink.bg, }) } impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let settings = SettingsStore::get(cx).clone(); let theme = themes::active(&settings, window.appearance()); let term = self.pty.term.lock(); let ink = Ink::new(theme, term.colors()); let shown = frame(&term, &ink); let blinks = term.cursor_style().blinking; drop(term); let focused = self.focus.is_focused(window) && window.is_window_active(); self.blinking = focused && blinks; if !self.blinking { self.shown = true; } let cursor = shown .cursor .clone() .and_then(|cursor| match (focused, self.shown) { (false, _) => Some((cursor, CursorShape::HollowBlock)), (true, false) => None, (true, true) => { let shape = cursor.shape; Some((cursor, shape)) } }); let root = div() .id(("terminal", cx.entity_id())) .key_context(input::CONTEXT) .track_focus(&self.focus) .on_key_down(cx.listener(Self::key_down)) .on_key_up(cx.listener(Self::key_up)) .on_action(cx.listener(|view, _: &Copy, _, cx| view.copy(cx))) .on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx))) .on_any_mouse_down(cx.listener(Self::mouse_down)) .on_mouse_move(cx.listener(Self::mouse_move)) .on_scroll_wheel(cx.listener(Self::wheel)) .cursor_text() .size_full() .px(px(settings.padding_x)) .py(px(settings.padding_y)) .bg(ink.bg.alpha(settings.background_opacity)); let root = [MouseButton::Left, MouseButton::Middle, MouseButton::Right] .into_iter() .fold(root, |root, button| { root.on_mouse_up(button, cx.listener(Self::mouse_up)) .on_mouse_up_out(button, cx.listener(Self::mouse_up)) }); match look(&settings, &ink, window) { Ok(look) => { self.cell = ( f32::from(look.cell.width).round() as u16, f32::from(look.cell.height).round() as u16, ); let preedit = self.marked.clone(); root.child(paint::grid( cx.entity(), Rc::new(shown), cursor, preedit, look, )) } Err(error) => { log::error!("terminal {}: {error:#}", self.pty.pid); root.text_color(ink.fg).child(format!("{error:#}")) } } } }