T06: terminal view: frame, colors and styles, wide and combining cells, cursor and blink, font, padding, resize; bundled faces and icon font; shot example
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 12:59:32 -04:00
1 parent b4750e61c9
commit ae1d8ce746
23 files changed
+1582 -72

No files matched your search

+89
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod app;
pub mod settings;
pub mod term;
#[cfg(test)]
+20 -42
View File
@@ -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);
});
}
+259
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+333
View File
@@ -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
View File
@@ -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
View File
@@ -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(_) => {}
+269
View File
@@ -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:#}"))
}
}
}
}