T06: terminal view: frame, colors and styles, wide and combining cells, cursor and blink, font, padding, resize; bundled faces and icon font; shot example
This commit is contained in:
23 files changed
+1582
-72
No files matched your search
+89
@@ -0,0 +1,89 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::{primitives::FocusScope, theme::ActiveTheme};
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement,
|
||||
Render, Styled, TitlebarOptions, Window, WindowBounds, WindowHandle, WindowOptions, px, size,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
settings::SettingsStore,
|
||||
term::{ICONS, Launch, MONO, TerminalView, config, shell::ShellIntegration, spawn},
|
||||
themes,
|
||||
};
|
||||
|
||||
/// The main window's content.
|
||||
pub struct Workspace {
|
||||
focus: FocusHandle,
|
||||
terminal: Entity<TerminalView>,
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
FocusScope::new(&self.focus)
|
||||
.root()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors.bg)
|
||||
.child(self.terminal.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts, settings, integration scripts and the chosen font, each checked before a window opens.
|
||||
pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
cx.text_system()
|
||||
.add_fonts(vec![
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/JetBrainsMono-Bold.ttf")),
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/JetBrainsMono-Italic.ttf")),
|
||||
Cow::Borrowed(include_bytes!(
|
||||
"../assets/fonts/JetBrainsMono-BoldItalic.ttf"
|
||||
)),
|
||||
Cow::Borrowed(include_bytes!(
|
||||
"../assets/fonts/JetBrainsMonoNerdFontMono-Regular.ttf"
|
||||
)),
|
||||
])
|
||||
.context("app: the bundled fonts failed to register")?;
|
||||
let names = cx.text_system().all_font_names();
|
||||
for family in [MONO, ICONS] {
|
||||
anyhow::ensure!(
|
||||
names.iter().any(|name| name == family),
|
||||
"app: {family} is not among the registered fonts"
|
||||
);
|
||||
}
|
||||
SettingsStore::init(cx)?;
|
||||
ShellIntegration::init(cx)?;
|
||||
let family = &SettingsStore::get(cx).font_family;
|
||||
anyhow::ensure!(
|
||||
cx.text_system().all_font_names().contains(family),
|
||||
"settings: font_family is {family:?}, which is not installed"
|
||||
);
|
||||
themes::apply(cx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the main window running `launch`.
|
||||
pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspace>> {
|
||||
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("ELYTerminal".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let (pty, events) = spawn(&launch, (80, 24), (8, 16), config(SettingsStore::get(cx)))?;
|
||||
let terminal = cx.new(|cx| TerminalView::new(pty, events, cx));
|
||||
let window = cx.open_window(options, |window, cx| {
|
||||
window
|
||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||
.detach();
|
||||
window.focus(&terminal.focus_handle(cx), cx);
|
||||
cx.new(|cx| Workspace {
|
||||
focus: cx.focus_handle(),
|
||||
terminal,
|
||||
})
|
||||
})?;
|
||||
log::info!("app: main window open");
|
||||
Ok(window)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod app;
|
||||
pub mod settings;
|
||||
pub mod term;
|
||||
#[cfg(test)]
|
||||
|
||||
+20
-42
@@ -1,22 +1,10 @@
|
||||
use ely_gpui_component::{Assets, primitives::FocusScope, theme::ActiveTheme};
|
||||
use elyterminal::{settings::SettingsStore, term::shell::ShellIntegration, themes};
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions,
|
||||
Window, WindowBounds, WindowOptions, px, size,
|
||||
use ely_gpui_component::Assets;
|
||||
use elyterminal::{
|
||||
app,
|
||||
settings::SettingsStore,
|
||||
term::shell::{ShellIntegration, launch},
|
||||
};
|
||||
|
||||
struct Workspace {
|
||||
focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
FocusScope::new(&self.focus)
|
||||
.root()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors.bg)
|
||||
}
|
||||
}
|
||||
use gpui::App;
|
||||
|
||||
/// A Linux app with no display server fails at start instead of running headless.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -29,37 +17,27 @@ fn application() -> gpui::Application {
|
||||
gpui_platform::application()
|
||||
}
|
||||
|
||||
fn run(cx: &mut App) -> anyhow::Result<()> {
|
||||
app::start(cx)?;
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("app: no home folder"))?;
|
||||
let launch = launch(
|
||||
&SettingsStore::get(cx).shell,
|
||||
home,
|
||||
&cx.global::<ShellIntegration>().0,
|
||||
)?;
|
||||
app::open(launch, cx)?;
|
||||
cx.activate(true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info"))
|
||||
.init();
|
||||
application().with_assets(Assets).run(|cx: &mut App| {
|
||||
ely_gpui_component::init(cx).expect("Ely failed to start");
|
||||
if let Err(error) = SettingsStore::init(cx).and_then(|()| ShellIntegration::init(cx)) {
|
||||
if let Err(error) = run(cx) {
|
||||
log::error!("{error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
themes::apply(cx);
|
||||
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("ELYTerminal".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
cx.open_window(options, |window, cx| {
|
||||
cx.new(|cx| {
|
||||
window
|
||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||
.detach();
|
||||
let focus = cx.focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
Workspace { focus }
|
||||
})
|
||||
})
|
||||
.expect("the main window failed to open");
|
||||
log::info!("app: main window open");
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use alacritty_terminal::{
|
||||
event::EventListener,
|
||||
grid::Dimensions,
|
||||
term::{Term, cell::Cell, cell::Flags, color::Colors},
|
||||
vte::ansi::CursorShape,
|
||||
};
|
||||
use gpui::Hsla;
|
||||
|
||||
use super::ink::{DIM, Ink};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Underline {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
Curly,
|
||||
Dotted,
|
||||
Dashed,
|
||||
}
|
||||
|
||||
/// How a run of cells paints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Style {
|
||||
pub fg: Hsla,
|
||||
/// None where the terminal's ground shows.
|
||||
pub bg: Option<Hsla>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: Underline,
|
||||
pub underline_color: Hsla,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
/// One screen line: a char per cell, and style runs over its bytes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Row {
|
||||
pub text: String,
|
||||
pub runs: Vec<(usize, Style)>,
|
||||
/// Cells with combining marks, painted alone at their column.
|
||||
pub clusters: Vec<(usize, String, Style)>,
|
||||
}
|
||||
|
||||
/// The cursor where it shows: its cell, shape, and what the cell holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Cursor {
|
||||
pub row: usize,
|
||||
pub column: usize,
|
||||
pub shape: CursorShape,
|
||||
pub wide: bool,
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
/// A terminal's screen as it paints.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Frame {
|
||||
pub rows: Vec<Row>,
|
||||
pub cursor: Option<Cursor>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// The screen's text, trailing spaces gone.
|
||||
pub fn text(&self) -> String {
|
||||
let lines: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut cells: Vec<String> = row.text.chars().map(String::from).collect();
|
||||
for (column, cluster, _) in &row.clusters {
|
||||
cells[*column] = cluster.clone();
|
||||
}
|
||||
cells.concat().trim_end().to_string()
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen as it shows now, scrolled back or not.
|
||||
pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
|
||||
let content = term.renderable_content();
|
||||
let offset = content.display_offset as i32;
|
||||
let mut rows = vec![Row::default(); term.screen_lines()];
|
||||
let at = content.cursor.point;
|
||||
let mut cursor = None;
|
||||
for indexed in content.display_iter {
|
||||
let (point, cell) = (indexed.point, indexed.cell);
|
||||
let row = (point.line.0 + offset) as usize;
|
||||
let look = style(cell, ink, content.colors);
|
||||
let spacer = cell
|
||||
.flags
|
||||
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
|
||||
let cluster = cell
|
||||
.zerowidth()
|
||||
.filter(|marks| !marks.is_empty())
|
||||
.map(|marks| {
|
||||
std::iter::once(cell.c)
|
||||
.chain(marks.iter().copied())
|
||||
.collect::<String>()
|
||||
});
|
||||
let shown = if spacer || cluster.is_some() {
|
||||
' '
|
||||
} else {
|
||||
cell.c
|
||||
};
|
||||
push(&mut rows[row], shown, look);
|
||||
if point == at && content.cursor.shape != CursorShape::Hidden {
|
||||
cursor = Some(Cursor {
|
||||
row,
|
||||
column: point.column.0,
|
||||
shape: content.cursor.shape,
|
||||
wide: cell.flags.contains(Flags::WIDE_CHAR),
|
||||
text: cluster.clone().unwrap_or_else(|| cell.c.to_string()),
|
||||
style: look,
|
||||
});
|
||||
}
|
||||
if let Some(cluster) = cluster {
|
||||
rows[row].clusters.push((point.column.0, cluster, look));
|
||||
}
|
||||
}
|
||||
Frame { rows, cursor }
|
||||
}
|
||||
|
||||
fn push(row: &mut Row, shown: char, style: Style) {
|
||||
let len = shown.len_utf8();
|
||||
row.text.push(shown);
|
||||
match row.runs.last_mut() {
|
||||
Some((bytes, last)) if *last == style => *bytes += len,
|
||||
_ => row.runs.push((len, style)),
|
||||
}
|
||||
}
|
||||
|
||||
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(DIM);
|
||||
}
|
||||
if flags.contains(Flags::HIDDEN) {
|
||||
fg = bg;
|
||||
}
|
||||
let underline = if flags.contains(Flags::DOUBLE_UNDERLINE) {
|
||||
Underline::Double
|
||||
} else if flags.contains(Flags::UNDERCURL) {
|
||||
Underline::Curly
|
||||
} else if flags.contains(Flags::DOTTED_UNDERLINE) {
|
||||
Underline::Dotted
|
||||
} else if flags.contains(Flags::DASHED_UNDERLINE) {
|
||||
Underline::Dashed
|
||||
} else if flags.contains(Flags::UNDERLINE) {
|
||||
Underline::Single
|
||||
} else {
|
||||
Underline::None
|
||||
};
|
||||
Style {
|
||||
fg,
|
||||
bg: (bg != ink.bg).then_some(bg),
|
||||
bold: flags.contains(Flags::BOLD),
|
||||
italic: flags.contains(Flags::ITALIC),
|
||||
underline,
|
||||
underline_color: cell
|
||||
.underline_color()
|
||||
.map_or(fg, |color| ink.resolve(color, set)),
|
||||
strike: flags.contains(Flags::STRIKEOUT),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use alacritty_terminal::{
|
||||
event::VoidListener,
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::themes;
|
||||
|
||||
fn shown(bytes: &[u8]) -> (Frame, Ink) {
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, bytes);
|
||||
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
|
||||
(frame(&term, &ink), ink)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_join_into_runs_of_one_style() {
|
||||
let (frame, ink) = shown(b"\x1b[31mred\x1b[0m ok\r\n\x1b[7minv\x1b[0m");
|
||||
assert_eq!(frame.text(), "red ok\ninv");
|
||||
let (bytes, red) = frame.rows[0].runs[0];
|
||||
assert_eq!((bytes, red.fg, red.bg), (3, ink.ansi[1], None));
|
||||
let (_, inverse) = frame.rows[1].runs[0];
|
||||
assert_eq!((inverse.fg, inverse.bg), (ink.bg, Some(ink.fg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_char_keeps_the_columns_after_it() {
|
||||
let (frame, _) = shown("中a".as_bytes());
|
||||
assert_eq!(
|
||||
frame.rows[0].text.chars().take(3).collect::<String>(),
|
||||
"中 a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_marks_stay_on_their_cell() {
|
||||
let (frame, _) = shown("e\u{301}x".as_bytes());
|
||||
assert_eq!(frame.text(), "e\u{301}x");
|
||||
assert_eq!(&frame.rows[0].text[..2], " x");
|
||||
assert_eq!(frame.rows[0].clusters[0].1, "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underline_styles_and_colors_read_from_sgr() {
|
||||
let (frame, ink) = shown(
|
||||
b"\x1b[4ma\x1b[4:2mb\x1b[4:3mc\x1b[4:4md\x1b[4:5me\x1b[58;2;1;2;3m\x1b[4mf\x1b[0;9mg",
|
||||
);
|
||||
let kinds: Vec<_> = frame.rows[0]
|
||||
.runs
|
||||
.iter()
|
||||
.map(|(_, s)| s.underline)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&kinds[..6],
|
||||
[
|
||||
Underline::Single,
|
||||
Underline::Double,
|
||||
Underline::Curly,
|
||||
Underline::Dotted,
|
||||
Underline::Dashed,
|
||||
Underline::Single
|
||||
]
|
||||
);
|
||||
let (_, colored) = frame.rows[0].runs[5];
|
||||
assert_ne!(colored.underline_color, ink.fg);
|
||||
assert!(frame.rows[0].runs[6].1.strike);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_reads_its_cell() {
|
||||
let (frame, _) = shown("ab中\x1b[2D".as_bytes());
|
||||
let cursor = frame.cursor.expect("a cursor");
|
||||
assert_eq!((cursor.row, cursor.column, cursor.wide), (0, 2, true));
|
||||
assert_eq!(cursor.text, "中");
|
||||
let (hidden, _) = shown(b"x\x1b[?25l");
|
||||
assert_eq!(hidden.cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_takes_its_ground() {
|
||||
let (frame, ink) = shown(b"\x1b[8mh");
|
||||
assert_eq!(frame.rows[0].runs[0].1.fg, ink.bg);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
use alacritty_terminal::{
|
||||
term::color::Colors,
|
||||
vte::ansi::{Color, NamedColor, Rgb},
|
||||
};
|
||||
use gpui::{Hsla, Rgba};
|
||||
|
||||
use crate::themes::TerminalTheme;
|
||||
|
||||
/// The colors a terminal paints with: its theme, under any a program set by OSC 4, 10, 11 or 12.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Ink {
|
||||
pub ansi: [Hsla; 16],
|
||||
pub fg: Hsla,
|
||||
pub bg: Hsla,
|
||||
pub cursor: Hsla,
|
||||
pub cursor_text: Hsla,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
pub fn new(theme: &TerminalTheme, set: &Colors) -> Self {
|
||||
let named = |name: NamedColor| set[name].map(hsla);
|
||||
let mut ansi = theme.ansi;
|
||||
for (index, color) in ansi.iter_mut().enumerate() {
|
||||
if let Some(spec) = set[index] {
|
||||
*color = hsla(spec);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
ansi,
|
||||
fg: named(NamedColor::Foreground).unwrap_or(theme.foreground),
|
||||
bg: named(NamedColor::Background).unwrap_or(theme.background),
|
||||
cursor: named(NamedColor::Cursor).unwrap_or(theme.cursor),
|
||||
cursor_text: theme.cursor_text,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self, color: Color, set: &Colors) -> Hsla {
|
||||
match color {
|
||||
Color::Spec(spec) => hsla(spec),
|
||||
Color::Indexed(index) => self.indexed(index.into(), set),
|
||||
Color::Named(name) => self.named(name),
|
||||
}
|
||||
}
|
||||
|
||||
fn named(&self, name: NamedColor) -> Hsla {
|
||||
match name {
|
||||
NamedColor::Foreground | NamedColor::BrightForeground => self.fg,
|
||||
NamedColor::Background => self.bg,
|
||||
NamedColor::Cursor => self.cursor,
|
||||
NamedColor::DimForeground => self.fg.opacity(DIM),
|
||||
dim if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize)
|
||||
.contains(&(dim as usize)) =>
|
||||
{
|
||||
self.named(dim.to_bright()).opacity(DIM)
|
||||
}
|
||||
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: 0–255, then 256 text, 257 ground, 258 cursor.
|
||||
pub fn query(&self, index: usize, set: &Colors) -> Rgb {
|
||||
let color = match index {
|
||||
256 => self.fg,
|
||||
257 => self.bg,
|
||||
258 => self.cursor,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the text color dim text keeps.
|
||||
pub const DIM: f32 = 0.66;
|
||||
|
||||
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 super::*;
|
||||
use crate::themes;
|
||||
|
||||
fn ink(set: &Colors) -> Ink {
|
||||
Ink::new(themes::find("catppuccin-mocha").expect("mocha"), set)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_colors_follow_the_xterm_cube_and_ramp() {
|
||||
let set = Colors::default();
|
||||
let ink = ink(&set);
|
||||
assert_eq!(ink.query(196, &set), Rgb { r: 255, g: 0, b: 0 });
|
||||
assert_eq!(ink.query(16, &set), Rgb { r: 0, 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: 0xf3,
|
||||
g: 0x8b,
|
||||
b: 0xa8
|
||||
},
|
||||
"the theme's red"
|
||||
);
|
||||
assert_eq!(
|
||||
ink.query(257, &set),
|
||||
Rgb {
|
||||
r: 0x1e,
|
||||
g: 0x1e,
|
||||
b: 0x2e
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_the_program_set_wins() {
|
||||
let mut set = Colors::default();
|
||||
set[1] = Some(Rgb { r: 1, g: 2, b: 3 });
|
||||
set[NamedColor::Background] = Some(Rgb { r: 9, g: 9, b: 9 });
|
||||
let ink = ink(&set);
|
||||
assert_eq!(
|
||||
ink.resolve(Color::Named(NamedColor::Red), &set),
|
||||
hsla(Rgb { r: 1, g: 2, b: 3 })
|
||||
);
|
||||
assert_eq!(ink.bg, hsla(Rgb { r: 9, g: 9, b: 9 }));
|
||||
assert_eq!(
|
||||
ink.resolve(Color::Named(NamedColor::DimRed), &set),
|
||||
ink.ansi[1].opacity(DIM)
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
pub mod frame;
|
||||
pub mod ink;
|
||||
mod paint;
|
||||
mod pty;
|
||||
pub mod shell;
|
||||
mod tap;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
mod view;
|
||||
|
||||
pub use pty::{Launch, Listener, Pty, PtyEvent, fit, spawn};
|
||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||
pub use view::{ICONS, MONO, TerminalEvent, TerminalView};
|
||||
@@ -0,0 +1,333 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use alacritty_terminal::vte::ansi::CursorShape;
|
||||
use gpui::{
|
||||
App, Bounds, Entity, Font, FontId, FontStyle, FontWeight, Hsla, IntoElement, Pixels, Point,
|
||||
Size, StrikethroughStyle, Styled, TextAlign, TextRun, UnderlineStyle, Window, canvas, fill,
|
||||
outline, point, px, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
frame::{Cursor, Frame, Style, Underline},
|
||||
view::TerminalView,
|
||||
};
|
||||
|
||||
/// How the grid paints: font, cell, metrics and the cursor's colors.
|
||||
#[derive(Clone)]
|
||||
pub struct Look {
|
||||
pub font: Font,
|
||||
pub font_id: FontId,
|
||||
/// Draws icons the font lacks.
|
||||
pub icons: Font,
|
||||
pub size: Pixels,
|
||||
pub cell: Size<Pixels>,
|
||||
/// From a cell's top to the text's baseline.
|
||||
pub baseline: Pixels,
|
||||
pub descent: Pixels,
|
||||
pub rule: Pixels,
|
||||
pub cursor: Hsla,
|
||||
pub cursor_text: Hsla,
|
||||
}
|
||||
|
||||
/// The grid fitted to its box: it sizes the pty, then paints each row and the cursor.
|
||||
pub fn grid(
|
||||
view: Entity<TerminalView>,
|
||||
frame: Rc<Frame>,
|
||||
cursor: Option<(Cursor, CursorShape)>,
|
||||
look: Look,
|
||||
) -> impl IntoElement {
|
||||
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;
|
||||
view.update(cx, |view, cx| {
|
||||
if view.fit(columns, lines) {
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
}
|
||||
});
|
||||
},
|
||||
move |bounds, _, window, cx| {
|
||||
log::trace!(
|
||||
"terminal: paint {:?}, row 0 {:?}",
|
||||
bounds,
|
||||
frame.rows.first().map(|row| row.text.trim_end())
|
||||
);
|
||||
for (index, row) in frame.rows.iter().enumerate() {
|
||||
let top = bounds.origin + point(px(0.), cell.height * index as f32);
|
||||
paint_row(row, top, &look, window, cx);
|
||||
}
|
||||
if let Some((cursor, shape)) = &cursor {
|
||||
paint_cursor(cursor, *shape, bounds.origin, &look, window, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
|
||||
fn face(style: &Style, base: &Font) -> Font {
|
||||
Font {
|
||||
weight: if style.bold {
|
||||
FontWeight::BOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
},
|
||||
style: if style.italic {
|
||||
FontStyle::Italic
|
||||
} else {
|
||||
FontStyle::Normal
|
||||
},
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Each run's style with the columns it covers.
|
||||
fn spans(row: &super::frame::Row) -> Vec<(usize, usize, Style)> {
|
||||
let (mut byte, mut column) = (0, 0);
|
||||
row.runs
|
||||
.iter()
|
||||
.map(|(len, style)| {
|
||||
let columns = row.text[byte..byte + len].chars().count();
|
||||
let span = (column, columns, *style);
|
||||
byte += len;
|
||||
column += columns;
|
||||
span
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn paint_row(
|
||||
row: &super::frame::Row,
|
||||
top: Point<Pixels>,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let cell = look.cell;
|
||||
let at = |column: usize| top + point(cell.width * column as f32, px(0.));
|
||||
let spans = spans(row);
|
||||
for (column, columns, style) in &spans {
|
||||
if let Some(bg) = style.bg {
|
||||
let width = cell.width * *columns as f32;
|
||||
window.paint_quad(fill(Bounds::new(at(*column), size(width, cell.height)), bg));
|
||||
}
|
||||
}
|
||||
let mut byte = 0;
|
||||
let runs: Vec<TextRun> = row
|
||||
.runs
|
||||
.iter()
|
||||
.flat_map(|(len, style)| {
|
||||
let text = &row.text[byte..byte + len];
|
||||
byte += len;
|
||||
runs(text, style, style.fg, look, window)
|
||||
})
|
||||
.collect();
|
||||
let shaped = window.text_system().shape_line(
|
||||
row.text.clone().into(),
|
||||
look.size,
|
||||
&runs,
|
||||
Some(cell.width),
|
||||
);
|
||||
if let Err(error) = shaped.paint(top, cell.height, TextAlign::Left, None, window, cx) {
|
||||
log::error!("terminal: a row failed to paint: {error:#}");
|
||||
}
|
||||
for (column, cluster, style) in &row.clusters {
|
||||
text(cluster, style.fg, style, at(*column), look, window, cx);
|
||||
}
|
||||
for (column, columns, style) in spans {
|
||||
let width = cell.width * columns as f32;
|
||||
decorate(at(column), width, &style, look, window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text runs for `text` in `style` and `color`: icons the font lacks take the icon font.
|
||||
fn runs(text: &str, style: &Style, color: Hsla, look: &Look, window: &Window) -> Vec<TextRun> {
|
||||
let base = face(style, &look.font);
|
||||
let system = window.text_system();
|
||||
let mut runs: Vec<TextRun> = Vec::new();
|
||||
for ch in text.chars() {
|
||||
let missing = private(ch) && system.advance(look.font_id, look.size, ch).is_err();
|
||||
let font = if missing { &look.icons } else { &base };
|
||||
match runs.last_mut() {
|
||||
Some(last) if last.font == *font => last.len += ch.len_utf8(),
|
||||
_ => runs.push(TextRun {
|
||||
len: ch.len_utf8(),
|
||||
font: font.clone(),
|
||||
color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Unicode's private use areas, where Nerd Font icons live.
|
||||
fn private(ch: char) -> bool {
|
||||
matches!(ch, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..='\u{FFFFD}' | '\u{100000}'..='\u{10FFFD}')
|
||||
}
|
||||
|
||||
/// Paints `content` alone at `origin`, a cell's worth.
|
||||
fn text(
|
||||
content: &str,
|
||||
color: Hsla,
|
||||
style: &Style,
|
||||
origin: Point<Pixels>,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let runs = runs(content, style, color, look, window);
|
||||
let shaped = window.text_system().shape_line(
|
||||
content.to_string().into(),
|
||||
look.size,
|
||||
&runs,
|
||||
Some(look.cell.width),
|
||||
);
|
||||
if let Err(error) = shaped.paint(origin, look.cell.height, TextAlign::Left, None, window, cx) {
|
||||
log::error!("terminal: `{content}` failed to paint: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Underlines and strikethrough under `width` from `origin`, a cell's top-left.
|
||||
fn decorate(origin: Point<Pixels>, width: Pixels, style: &Style, look: &Look, window: &mut Window) {
|
||||
let rule = look.rule;
|
||||
let line = origin.y + look.baseline + (look.descent * 0.45).max(rule);
|
||||
let bar = |x: Pixels, y: Pixels, w: Pixels| {
|
||||
fill(
|
||||
Bounds::new(point(x, y), size(w, rule)),
|
||||
style.underline_color,
|
||||
)
|
||||
};
|
||||
match style.underline {
|
||||
Underline::None => {}
|
||||
Underline::Single => window.paint_quad(bar(origin.x, line, width)),
|
||||
Underline::Double => {
|
||||
window.paint_quad(bar(origin.x, line - rule, width));
|
||||
window.paint_quad(bar(origin.x, line + rule, width));
|
||||
}
|
||||
Underline::Curly => window.paint_underline(
|
||||
point(origin.x, line - rule),
|
||||
width,
|
||||
&UnderlineStyle {
|
||||
thickness: rule,
|
||||
color: Some(style.underline_color),
|
||||
wavy: true,
|
||||
},
|
||||
),
|
||||
Underline::Dotted => {
|
||||
let mut x = origin.x;
|
||||
while x < origin.x + width {
|
||||
window.paint_quad(bar(x, line, rule));
|
||||
x += rule * 2.;
|
||||
}
|
||||
}
|
||||
Underline::Dashed => {
|
||||
let dash = look.cell.width * 0.5;
|
||||
let mut x = origin.x;
|
||||
while x < origin.x + width {
|
||||
window.paint_quad(bar(x, line, dash.min(origin.x + width - x)));
|
||||
x += dash * 2.;
|
||||
}
|
||||
}
|
||||
}
|
||||
if style.strike {
|
||||
let middle = origin.y + look.baseline - look.size * 0.3;
|
||||
window.paint_strikethrough(
|
||||
point(origin.x, middle),
|
||||
width,
|
||||
&StrikethroughStyle {
|
||||
thickness: rule,
|
||||
color: Some(style.fg),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_cursor(
|
||||
cursor: &Cursor,
|
||||
shape: CursorShape,
|
||||
origin: Point<Pixels>,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let cell = look.cell;
|
||||
let corner = origin
|
||||
+ point(
|
||||
cell.width * cursor.column as f32,
|
||||
cell.height * cursor.row as f32,
|
||||
);
|
||||
let width = cell.width * if cursor.wide { 2. } else { 1. };
|
||||
let whole = Bounds::new(corner, size(width, cell.height));
|
||||
match shape {
|
||||
CursorShape::Block => {
|
||||
window.paint_quad(fill(whole, look.cursor));
|
||||
text(
|
||||
&cursor.text,
|
||||
look.cursor_text,
|
||||
&cursor.style,
|
||||
corner,
|
||||
look,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
CursorShape::HollowBlock => {
|
||||
window.paint_quad(outline(whole, look.cursor, gpui::BorderStyle::Solid));
|
||||
}
|
||||
CursorShape::Beam => {
|
||||
let bar = Bounds::new(corner, size((look.rule * 2.).max(px(1.)), cell.height));
|
||||
window.paint_quad(fill(bar, look.cursor));
|
||||
}
|
||||
CursorShape::Underline => {
|
||||
let height = look.rule * 2.;
|
||||
let bar = Bounds::new(
|
||||
corner + point(px(0.), cell.height - height),
|
||||
size(width, height),
|
||||
);
|
||||
window.paint_quad(fill(bar, look.cursor));
|
||||
}
|
||||
CursorShape::Hidden => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::term::frame::{Row, Underline};
|
||||
|
||||
#[test]
|
||||
fn spans_count_columns_not_bytes() {
|
||||
let style = Style {
|
||||
fg: gpui::black(),
|
||||
bg: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: Underline::None,
|
||||
underline_color: gpui::black(),
|
||||
strike: false,
|
||||
};
|
||||
let bold = Style {
|
||||
bold: true,
|
||||
..style
|
||||
};
|
||||
let row = Row {
|
||||
text: "中 ab".into(),
|
||||
runs: vec![(4, style), (2, bold)],
|
||||
clusters: Vec::new(),
|
||||
};
|
||||
let spans: Vec<_> = spans(&row).into_iter().map(|(at, n, _)| (at, n)).collect();
|
||||
assert_eq!(spans, [(0, 2), (2, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_live_in_the_private_use_areas() {
|
||||
assert!(private('\u{E0B0}'));
|
||||
assert!(private('\u{F0035}'));
|
||||
assert!(!private('a'));
|
||||
assert!(!private('中'));
|
||||
}
|
||||
}
|
||||
+42
-20
@@ -7,10 +7,12 @@ use alacritty_terminal::{
|
||||
event_loop::{EventLoop, Msg, Notifier},
|
||||
sync::FairMutex,
|
||||
term::{Config, Term, test::TermSize},
|
||||
vte::ansi::CursorShape,
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::tap::Tapped;
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty: the grid's events and the folder the shell reports.
|
||||
#[derive(Debug)]
|
||||
@@ -49,23 +51,44 @@ pub struct Launch {
|
||||
/// A running program on a pseudo-terminal and the grid it draws into.
|
||||
pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub events: UnboundedReceiver<PtyEvent>,
|
||||
pub pid: u32,
|
||||
notifier: Notifier,
|
||||
}
|
||||
|
||||
/// What a pty reports, in order.
|
||||
pub type Events = UnboundedReceiver<PtyEvent>;
|
||||
|
||||
#[cfg(unix)]
|
||||
type Raw = super::unix::Pty;
|
||||
#[cfg(windows)]
|
||||
type Raw = tty::Pty;
|
||||
|
||||
/// Starts `launch` on a pty of `columns` by `lines` cells, each `cell` pixels, keeping `scrollback` lines.
|
||||
/// The grid's options from the settings: scrollback, the cursor programs start from, kitty keys.
|
||||
pub fn config(settings: &Settings) -> Config {
|
||||
let shape = match settings.cursor_style {
|
||||
CursorStyle::Block => CursorShape::Block,
|
||||
CursorStyle::Bar => CursorShape::Beam,
|
||||
CursorStyle::Underline => CursorShape::Underline,
|
||||
CursorStyle::Hollow => CursorShape::HollowBlock,
|
||||
};
|
||||
Config {
|
||||
scrolling_history: settings.scrollback as usize,
|
||||
default_cursor_style: alacritty_terminal::vte::ansi::CursorStyle {
|
||||
shape,
|
||||
blinking: settings.cursor_blink,
|
||||
},
|
||||
kitty_keyboard: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `launch` on a pty of `columns` by `lines` cells, each `cell` pixels.
|
||||
pub fn spawn(
|
||||
launch: &Launch,
|
||||
(columns, lines): (usize, usize),
|
||||
cell: (u16, u16),
|
||||
scrollback: usize,
|
||||
) -> anyhow::Result<Pty> {
|
||||
config: Config,
|
||||
) -> anyhow::Result<(Pty, Events)> {
|
||||
let mut env = HashMap::from([
|
||||
("TERM".to_string(), "xterm-256color".to_string()),
|
||||
("COLORTERM".to_string(), "truecolor".to_string()),
|
||||
@@ -93,11 +116,6 @@ pub fn spawn(
|
||||
sender: sender.clone(),
|
||||
pid,
|
||||
};
|
||||
let config = Config {
|
||||
scrolling_history: scrollback,
|
||||
kitty_keyboard: true,
|
||||
..Config::default()
|
||||
};
|
||||
let grid = TermSize::new(size.num_cols.into(), size.num_lines.into());
|
||||
let term = Arc::new(FairMutex::new(Term::new(config, &grid, listener.clone())));
|
||||
let event_loop = EventLoop::new(
|
||||
@@ -109,12 +127,14 @@ pub fn spawn(
|
||||
)?;
|
||||
let notifier = Notifier(event_loop.channel());
|
||||
event_loop.spawn();
|
||||
Ok(Pty {
|
||||
term,
|
||||
Ok((
|
||||
Pty {
|
||||
term,
|
||||
pid,
|
||||
notifier,
|
||||
},
|
||||
events,
|
||||
pid,
|
||||
notifier,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -219,10 +239,11 @@ mod tests {
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
};
|
||||
let mut pty = spawn(&launch, size, (8, 16), 100).expect("sh starts");
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let (folders, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut success) = (Vec::new(), false);
|
||||
while let Some(event) = pty.events.next().await {
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
@@ -277,9 +298,10 @@ mod tests {
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
};
|
||||
let mut pty = spawn(&launch, (20, 4), (8, 16), 100).expect("sh starts");
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = pty.events.next().await {
|
||||
while let Some(event) = events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
@@ -320,7 +342,7 @@ mod tests {
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), 100)
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
@@ -337,7 +359,7 @@ mod tests {
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), 100);
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
|
||||
+10
-4
@@ -4,7 +4,10 @@ use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
|
||||
use futures::channel::mpsc::TryRecvError;
|
||||
|
||||
use super::*;
|
||||
use crate::term::{Pty, PtyEvent, spawn};
|
||||
use crate::{
|
||||
settings::Settings,
|
||||
term::{Events, Pty, PtyEvent, config, spawn},
|
||||
};
|
||||
|
||||
/// A folder name with a space, a percent, a bell and a newline.
|
||||
const INNER: &str = "in ner%\u{7}x\ny";
|
||||
@@ -35,6 +38,7 @@ fn installed(shell: &str) -> String {
|
||||
struct Run {
|
||||
shell: String,
|
||||
pty: Pty,
|
||||
events: Events,
|
||||
root: PathBuf,
|
||||
start: PathBuf,
|
||||
folders: Vec<PathBuf>,
|
||||
@@ -65,10 +69,12 @@ impl Run {
|
||||
install(&dir).expect("scripts install");
|
||||
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
|
||||
launch.env.push(("HOME".into(), home.display().to_string()));
|
||||
let pty = spawn(&launch, (100, 30), (8, 16), 100).expect("the shell starts");
|
||||
let (pty, events) = spawn(&launch, (100, 30), (8, 16), config(&Settings::default()))
|
||||
.expect("the shell starts");
|
||||
Self {
|
||||
shell: shell.into(),
|
||||
pty,
|
||||
events,
|
||||
root,
|
||||
start,
|
||||
folders: Vec::new(),
|
||||
@@ -88,7 +94,7 @@ impl Run {
|
||||
self.titles,
|
||||
self.screen()
|
||||
);
|
||||
match self.pty.events.try_recv() {
|
||||
match self.events.try_recv() {
|
||||
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
|
||||
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||
@@ -142,7 +148,7 @@ impl Run {
|
||||
"{} did not exit",
|
||||
self.shell
|
||||
);
|
||||
match self.pty.events.try_recv() {
|
||||
match self.events.try_recv() {
|
||||
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
|
||||
|
||||
use alacritty_terminal::{event::Event, grid::Dimensions, vte::ansi::CursorShape};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle,
|
||||
FontWeight, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task, Window,
|
||||
div, prelude::*, px, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
frame::frame,
|
||||
ink::Ink,
|
||||
paint::{self, Look},
|
||||
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 {
|
||||
pty: Pty,
|
||||
focus: FocusHandle,
|
||||
size: (usize, usize),
|
||||
cell: (u16, u16),
|
||||
blinking: bool,
|
||||
shown: bool,
|
||||
_events: Task<()>,
|
||||
_blink: Task<()>,
|
||||
_settings: Subscription,
|
||||
}
|
||||
|
||||
impl EventEmitter<TerminalEvent> 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, cx: &mut Context<Self>) -> Self {
|
||||
let size = {
|
||||
let term = pty.term.lock();
|
||||
(term.columns(), term.screen_lines())
|
||||
};
|
||||
Self {
|
||||
pty,
|
||||
focus: cx.focus_handle().tab_stop(true),
|
||||
size,
|
||||
cell: (8, 16),
|
||||
blinking: false,
|
||||
shown: true,
|
||||
_events: Self::pump(events, cx),
|
||||
_blink: Self::blink(cx),
|
||||
_settings: cx.observe_global::<SettingsStore>(|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<Self>) -> 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<Self>) -> 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<PtyEvent>, cx: &mut Context<Self>) {
|
||||
for event in events {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
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(_, text) => {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text))
|
||||
}
|
||||
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<Look> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);
|
||||
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("Terminal")
|
||||
.track_focus(&self.focus)
|
||||
.size_full()
|
||||
.px(px(settings.padding_x))
|
||||
.py(px(settings.padding_y))
|
||||
.bg(ink.bg.alpha(settings.background_opacity));
|
||||
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,
|
||||
);
|
||||
root.child(paint::grid(cx.entity(), Rc::new(shown), cursor, look))
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("terminal {}: {error:#}", self.pty.pid);
|
||||
root.text_color(ink.fg).child(format!("{error:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user