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