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

+6 -3
View File
@@ -11,16 +11,18 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
## Commands
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more.
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
- `cargo run --example shot -- <png> <program> [args…]` runs a program in the main window and writes a PNG of it (macOS; no permission needed for the app's own window).
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
## Layout
- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them.
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize.
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
## Rules
@@ -40,4 +42,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: license MIT, as Ely.
- 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that.
- 2026-10-14: bash integration loads through `--rcfile`, not Ghostty's `--posix` and `ENV`: macOS's bash 3.2 ignores `ENV` there. bash then runs as a non-login shell; on macOS the script reads the login files itself, so `logout` and `~/.bash_logout` do not apply.
- 2026-10-14: icons fall back by hand: a private-use character the font lacks paints in JetBrains Mono NF Mono. gpui's macOS fallbacks go through CoreText's cascade list, which never sees fonts added in memory, and gpui loads no font without an `m`, so the symbols-only Nerd Font cannot load.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
Generated
+4
View File
@@ -1750,6 +1750,7 @@ version = "0.1.0"
dependencies = [
"alacritty_terminal",
"anyhow",
"core-graphics 0.24.0",
"dirs 7.0.0",
"ely-gpui-component",
"env_logger",
@@ -1757,9 +1758,12 @@ dependencies = [
"gethostname",
"gpui-pre",
"gpui-pre-platform",
"image",
"libc",
"log",
"objc",
"polling",
"raw-window-handle",
"rustix-openpty",
"serde",
"serde_json",
+6
View File
@@ -29,6 +29,12 @@ libc = "0.2"
rustix-openpty = "0.2"
signal-hook = "0.4"
[target.'cfg(target_os = "macos")'.dev-dependencies]
core-graphics = "0.24"
image = { version = "0.25", default-features = false, features = ["png"] }
objc = "0.2"
raw-window-handle = "0.6"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
+2 -1
View File
@@ -44,7 +44,8 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC 7 scanner. The scanner reports the working directory.
- Shell integration for zsh, bash, fish and PowerShell, injected at launch as Ghostty does: each prompt sends OSC 7 and a title. A shell without integration reports nothing, and the panels say so.
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
- Box drawing (U+2500–U+257F) and block elements (U+2580–U+259F) painted as geometry, so lines join across cells.
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
- Cursor: block, bar, underline, hollow block; blinking per setting and per DECSCUSR; hollow when the window is not focused.
- Keys: xterm encoding with modifiers, application cursor and keypad modes, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags.
- IME: marked text drawn at the cursor; committed text sent to the program.
+1 -1
View File
@@ -19,7 +19,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
- [x] T04 PTY: spawn through alacritty's event loop, OSC 7 tap on the reader, child id, events to the app; tests.
- [x] T05 Shell integration for zsh, bash, fish, PowerShell, injected at launch; tests that zsh and bash report their folder.
- [ ] T06 Terminal view: frame from the grid, colors and styles, wide and combining cells, cursor shapes and blink, font, line height, padding, resize.
- [ ] T07 Box drawing and block elements painted as geometry; tests.
- [ ] T07 Box drawing, block elements and powerline shapes (U+E0B0–E0B7) painted as geometry; tests.
- [ ] T08 Keys: xterm encoding, modes, Option as Alt, kitty keyboard protocol, IME; tests.
- [ ] T09 Mouse: selection, mouse reporting, wheel and scrollback, clipboard, copy on select, OSC 52, links; tests.
- [ ] T10 Focus reporting, title, silent bell, scrollback limit, find.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,69 @@
# Nerd Fonts
This is an archived font from the Nerd Fonts release v3.5.1.
For more information see:
* https://github.com/ryanoasis/nerd-fonts/
* https://github.com/ryanoasis/nerd-fonts/releases/latest/
# JetBrains Mono
JetBrains Mono: a typeface made for developers
For more information have a look at the upstream website: https://github.com/JetBrains/JetBrainsMono
Version: 2.304
## Icon sets
| Icon set name | upstream | version | license |
|------------------------|-------------------------------------------------------|-----------------|-------------|
| Codicons | https://github.com/microsoft/vscode-codicons | 0.0.45 | CC BY 4.0 |
| Devicons | https://github.com/devicons/devicon | 2.17.0 | MIT |
| extraglyphs | https://github.com/source-foundry/Hack | - | MIT |
| Font Awesome | https://github.com/FortAwesome/Font-Awesome | 6.5.1 | CC BY 4.0 |
| Font Awesome Extension | https://github.com/AndreLZGava/font-awesome-extension | 0.0.3 | MIT |
| Font Logos | https://github.com/lukas-w/font-logos | 1.3.0 | unlicensed |
| MaterialDesign | https://github.com/Templarian/MaterialDesign-Font | Oct 6, 2022 | Apache 2.0 |
| Octicons | https://github.com/primer/octicons | 18.3.0 | MIT |
| Seti and original | https://github.com/jesseweed/seti-ui | 0.8.1 | MIT |
| Pomicons | https://github.com/gabrielelana/pomicons | 1.001 | OFL 1.1 RFN |
| Powerline Extra | https://github.com/ryanoasis/powerline-extra-symbols | 1.200 | MIT |
| Powerline Symbols | https://github.com/powerline/powerline | 1.000 (ca 2013) | MIT |
| Power Symbols IEC | https://github.com/jloughry/Unicode | Feb 2015 | MIT |
| Weather Icons | https://github.com/erikflowers/weather-icons | 2.0.10 (1.100) | OFL 1.1 |
## Which font?
### TL;DR
* Pick your font family:
* If you are limited to monospaced fonts (because of your terminal, etc) then pick a font with `Nerd Font Mono` (or `NFM`).
* If you want to have bigger icons (usually around 1.5 normal letters wide) pick a font without `Mono` i.e. `Nerd Font` (or `NF`). Most terminals support this, but ymmv.
* If you work in a proportional context (GUI elements or edit a presentation etc) pick a font with `Nerd Font Propo` (or `NFP`).
### Ligatures
Ligatures are generally preserved in the patched fonts.
Nerd Fonts `v2.0.0` had no ligatures in the `Nerd Font Mono` fonts, this has been dropped with `v2.1.0`.
If you have a ligature-aware terminal and don't want ligatures you can (usually) disable them in the terminal settings.
### Explanation
Once you narrow down your font choice of family (`Droid Sans`, `Inconsolata`, etc) and style (`bold`, `italic`, etc) you have 2 main choices:
#### `Option 1: Download already patched font`
* For a stable version download a font package from the [release page](https://github.com/ryanoasis/nerd-fonts/releases)
* [JetBrainsMono.zip](https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip)
* [JetBrainsMono.tar.xz](https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.tar.xz) (smaller)
* Or download the development version from the folders here
#### `Option 2: Patch your own font`
* Patch your own variations with the various options provided by the font patcher (i.e. not include all symbols for smaller font size)
For more information see: [The FAQ](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting#which-font)
[SIL-RFN]:http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web_fonts_and_RFNs#14cbfd4a
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
//! `cargo run --example shot -- <png> <program> [args…]`: runs a program in the main window, then photographs the window.
#[cfg(target_os = "macos")]
fn main() {
use std::time::Duration;
use ely_gpui_component::Assets;
use elyterminal::{app, term::Launch};
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info"))
.init();
let mut args = std::env::args().skip(1);
let out = args.next().expect("a png path");
let program = args.next().expect("a program");
let launch = Launch {
program: Some((program, args.collect())),
cwd: std::env::current_dir().expect("a current folder"),
env: Vec::new(),
};
gpui_platform::application()
.with_assets(Assets)
.run(move |cx: &mut gpui::App| {
ely_gpui_component::init(cx).expect("Ely starts");
app::start(cx).expect("the app starts");
let window = app::open(launch, cx).expect("the window opens");
cx.activate(true);
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
let number = window
.update(cx, |_, window, _| capture::number(window))
.expect("the window lives")
.expect("a window number");
capture::snapshot(number, std::path::Path::new(&out)).expect("a picture");
log::info!("shot: wrote {out}");
cx.update(|cx| cx.quit());
})
.detach();
});
}
#[cfg(target_os = "macos")]
mod capture {
use std::path::Path;
use anyhow::{Context as _, bail};
use objc::{msg_send, runtime::Object, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// A gpui window's number, as CoreGraphics knows it.
pub fn number(window: &gpui::Window) -> anyhow::Result<u32> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("not an AppKit window");
};
let view = appkit.ns_view.as_ptr() as *mut Object;
let native: *mut Object = unsafe { msg_send![view, window] };
let number: i64 = unsafe { msg_send![native, windowNumber] };
u32::try_from(number).context("window number out of range")
}
/// Writes the window `number` as a PNG; the app's own window needs no permission.
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
};
let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow,
number,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
)
.context("window capture returned nothing")?;
if image.bits_per_pixel() != 32 {
bail!("{} bits per pixel", image.bits_per_pixel());
}
let (width, height, stride) = (image.width(), image.height(), image.bytes_per_row());
let data = image.data();
let bytes = data.bytes();
let mut rgba = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
let at = y * stride + x * 4;
rgba.extend_from_slice(&[bytes[at + 2], bytes[at + 1], bytes[at], bytes[at + 3]]);
}
}
image::save_buffer(
path,
&rgba,
width as u32,
height as u32,
image::ColorType::Rgba8,
)
.with_context(|| format!("cannot write {}", path.display()))
}
}
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("shot photographs windows on macOS only");
std::process::exit(1);
}
+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:#}"))
}
}
}
}