use std::cell::Cell;
use std::ops::Range;
use std::rc::Rc;
use gpui::{
AnyElement, Bounds, Context, FocusHandle, Focusable, Font, FontFallbacks, HighlightStyle,
KeyDownEvent, Keystroke, Render, StyledText, canvas, rgb,
};
use crate::prelude::*;
const TERMINAL_FONT_FAMILY: &str = "IBM Plex Mono";
const TERMINAL_FONT_SIZE: Pixels = px(12.5);
const TERMINAL_LINE_HEIGHT: f32 = 1.5;
fn terminal_font() -> Font {
Font {
family: TERMINAL_FONT_FAMILY.into(),
fallbacks: Some(FontFallbacks::from_fonts(vec![
"Symbols Nerd Font Mono".into(),
"Symbols Nerd Font".into(),
"JetBrainsMono Nerd Font Mono".into(),
"JetBrainsMono Nerd Font".into(),
"Hack Nerd Font Mono".into(),
"Hack Nerd Font".into(),
"MesloLGS NF".into(),
"FiraCode Nerd Font Mono".into(),
"FiraCode Nerd Font".into(),
])),
..Default::default()
}
}
const CELL_WIDTH: u16 = 8;
const CELL_HEIGHT: u16 = 18;
const DEFAULT_ROWS: u16 = 24;
const DEFAULT_COLUMNS: u16 = 80;
fn encode_keystroke(keystroke: &Keystroke) -> Option<Vec<u8>> {
if keystroke.modifiers.platform {
return None;
}
if keystroke.modifiers.control
&& let Some(ch) = keystroke.key.chars().next()
&& ch.is_ascii_alphabetic()
{
let code = ch.to_ascii_uppercase() as u8 - b'A' + 1;
return Some(vec![code]);
}
match keystroke.key.as_str() {
"enter" => Some(b"\r".to_vec()),
"backspace" => Some(b"\x7f".to_vec()),
"tab" => Some(b"\t".to_vec()),
"escape" => Some(b"\x1b".to_vec()),
"up" => Some(b"\x1b[A".to_vec()),
"down" => Some(b"\x1b[B".to_vec()),
"right" => Some(b"\x1b[C".to_vec()),
"left" => Some(b"\x1b[D".to_vec()),
"home" => Some(b"\x1b[H".to_vec()),
"end" => Some(b"\x1b[F".to_vec()),
"pageup" => Some(b"\x1b[5~".to_vec()),
"pagedown" => Some(b"\x1b[6~".to_vec()),
"delete" => Some(b"\x1b[3~".to_vec()),
"insert" => Some(b"\x1b[2~".to_vec()),
"f1" => Some(b"\x1bOP".to_vec()),
"f2" => Some(b"\x1bOQ".to_vec()),
"f3" => Some(b"\x1bOR".to_vec()),
"f4" => Some(b"\x1bOS".to_vec()),
"f5" => Some(b"\x1b[15~".to_vec()),
"f6" => Some(b"\x1b[17~".to_vec()),
"f7" => Some(b"\x1b[18~".to_vec()),
"f8" => Some(b"\x1b[19~".to_vec()),
"f9" => Some(b"\x1b[20~".to_vec()),
"f10" => Some(b"\x1b[21~".to_vec()),
"f11" => Some(b"\x1b[23~".to_vec()),
"f12" => Some(b"\x1b[24~".to_vec()),
"space" => Some(b" ".to_vec()),
_ => keystroke
.key_char
.as_ref()
.map(|text| text.as_bytes().to_vec()),
}
}
fn default_terminal_size() -> terminal::TerminalSize {
terminal::TerminalSize {
rows: DEFAULT_ROWS,
columns: DEFAULT_COLUMNS,
cell_width: CELL_WIDTH,
cell_height: CELL_HEIGHT,
}
}
fn size_for_bounds(bounds: Bounds<Pixels>) -> terminal::TerminalSize {
let columns = (f32::from(bounds.size.width) / CELL_WIDTH as f32).floor() as u16;
let rows = (f32::from(bounds.size.height) / CELL_HEIGHT as f32).floor() as u16;
terminal::TerminalSize {
rows: rows.max(1),
columns: columns.max(1),
cell_width: CELL_WIDTH,
cell_height: CELL_HEIGHT,
}
}
fn rgb_to_hsla(color: terminal::Rgb) -> gpui::Hsla {
gpui::rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into()
}
fn styled_screen_text(
rows: Vec<Vec<terminal::TerminalCell>>,
) -> (String, Vec<(Range<usize>, HighlightStyle)>) {
let mut text = String::new();
let mut highlights = Vec::new();
for (row_ix, row) in rows.into_iter().enumerate() {
if row_ix > 0 {
text.push('\n');
}
let mut span_start = text.len();
let mut current_style: Option<(Option<terminal::Rgb>, bool, bool, bool)> = None;
for cell in row {
let style_key = (cell.fg, cell.bold, cell.italic, cell.underline);
if current_style != Some(style_key) {
if let Some((fg, bold, italic, underline)) = current_style
&& text.len() > span_start
{
highlights.push((
span_start..text.len(),
cell_highlight_style(fg, bold, italic, underline),
));
}
span_start = text.len();
current_style = Some(style_key);
}
text.push(cell.text);
}
if let Some((fg, bold, italic, underline)) = current_style
&& text.len() > span_start
{
highlights.push((
span_start..text.len(),
cell_highlight_style(fg, bold, italic, underline),
));
}
}
(text, highlights)
}
fn cell_highlight_style(
fg: Option<terminal::Rgb>,
bold: bool,
italic: bool,
underline: bool,
) -> HighlightStyle {
HighlightStyle {
color: fg.map(rgb_to_hsla),
font_weight: bold.then_some(gpui::FontWeight::BOLD),
font_style: italic.then_some(gpui::FontStyle::Italic),
underline: underline.then_some(gpui::UnderlineStyle::default()),
..Default::default()
}
}
pub struct TerminalView {
terminal: Option<terminal::Terminal>,
spawn_error: Option<String>,
focus_handle: FocusHandle,
container_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl TerminalView {
pub fn new(cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
match terminal::Terminal::spawn(default_terminal_size()) {
Ok((terminal, events)) => {
cx.spawn(async move |this, cx| {
while events.recv().await.is_ok() {
if this.update(cx, |_, cx| cx.notify()).is_err() {
break;
}
}
})
.detach();
Self {
terminal: Some(terminal),
spawn_error: None,
focus_handle,
container_bounds: Rc::new(Cell::new(None)),
}
}
Err(error) => Self {
terminal: None,
spawn_error: Some(error.to_string()),
focus_handle,
container_bounds: Rc::new(Cell::new(None)),
},
}
}
pub fn focus(&self, window: &mut Window, cx: &mut App) {
window.focus(&self.focus_handle, cx);
}
fn handle_key_down(
&mut self,
event: &KeyDownEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(terminal) = &self.terminal else {
return;
};
if let Some(bytes) = encode_keystroke(&event.keystroke) {
terminal.write_input(bytes);
cx.notify();
}
}
fn sync_size_to_container(&self) {
let Some(terminal) = &self.terminal else {
return;
};
let Some(bounds) = self.container_bounds.get() else {
return;
};
let wanted = size_for_bounds(bounds);
let current = terminal.current_size();
if wanted.rows != current.rows || wanted.columns != current.columns {
terminal.resize(wanted);
}
}
}
impl Focusable for TerminalView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TerminalView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_size_to_container();
let body: AnyElement = if let Some(error) = &self.spawn_error {
div()
.text_color(rgb(0xE06C75))
.child(format!("Failed to start terminal: {error}"))
.into_any_element()
} else {
let (text, highlights) = self
.terminal
.as_ref()
.map(|terminal| styled_screen_text(terminal.screen_cells()))
.unwrap_or_default();
StyledText::new(text)
.with_highlights(highlights)
.into_any_element()
};
let measure = self.container_bounds.clone();
div()
.id(("terminal-view", cx.entity_id()))
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::handle_key_down))
.relative()
.w_full()
.h_full()
.overflow_y_scroll()
.p_3()
.bg(rgb(0x0D1117))
.text_color(rgb(0xC9D1D9))
.font(terminal_font())
.text_size(TERMINAL_FONT_SIZE)
.line_height(relative(TERMINAL_LINE_HEIGHT))
.child(
canvas(
move |bounds, _, _| measure.set(Some(bounds)),
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.child(body)
}
}
pub fn terminal_view_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
div()
.h(px(280.))
.rounded_lg()
.overflow_hidden()
.child(cx.new(|cx| TerminalView::new(cx)))
.into_any_element()
}