This commit is contained in:
2026-09-07 21:45:50 +02:00
commit 902a2e4c41
14 changed files with 6148 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
name: Rust
on: [push, pull_request]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- uses: Swatinem/rust-cache@v2
- name: Linux development libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libegl1-mesa-dev
- run: cargo fmt --check
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked -- --test-threads=1
- run: cargo build --locked
+2
View File
@@ -0,0 +1,2 @@
/target/
/.tools/
Generated
+3548
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "rust-term"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "A Ghostty-powered terminal with tiled and floating panes"
license = "MIT"
[dependencies]
anyhow = "1"
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
egui = "0.33"
libghostty-vt = { version = "=0.2.1", features = ["png"] }
png = "0.18"
portable-pty = "0.9"
serde = { version = "1", features = ["derive"] }
toml = "0.9"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
[dev-dependencies]
base64 = "0.22"
[profile.release]
lto = "thin"
+40
View File
@@ -0,0 +1,40 @@
# ~/.config/rust-term/config.toml
# Omit shell to use $SHELL. Arguments are separate array entries.
# shell = ["/bin/bash", "--login"]
# working_directory = "/home/you"
font_path = "/usr/local/share/fonts/MononokiNerdFont-Regular.ttf"
font_size = 16
scrollback = 10000
window_width = 1100
window_height = 720
background = "#17191c"
foreground = "#e2e4e8"
accent = "#58c4ad"
[kitty]
image_storage_mb = 64
allow_file = true
allow_temp_file = true
allow_shared_memory = true
[multiplexer]
max_panes = 32
floating_width = 0.65
floating_height = 0.65
# Providing [keys] replaces the entire default map.
[keys]
"Alt+F" = "toggle_floats"
"Alt+M" = "new_pane"
"Alt+H" = "focus_left"
"Alt+J" = "focus_down"
"Alt+K" = "focus_up"
"Alt+L" = "focus_right"
"Alt+A" = "new_tab"
"Alt+Shift+H" = "previous_tab"
"Alt+Shift+L" = "next_tab"
"Alt+X" = "close_pane"
"Ctrl+Shift+Z" = "zoom_pane"
"Ctrl+Shift+R" = "reload_config"
"Ctrl+Shift+C" = "copy"
"Ctrl+Shift+V" = "paste"
+606
View File
@@ -0,0 +1,606 @@
use crate::{
config::{self, Action, Config},
input,
layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane},
render::color,
};
use anyhow::{Result, ensure};
use egui::{Color32, Rect, Stroke, vec2};
use std::{
collections::{BTreeMap, HashSet},
path::PathBuf,
};
pub struct App {
config: Config,
config_path: PathBuf,
panes: BTreeMap<PaneId, Pane>,
tabs: Vec<Tab>,
current: usize,
next_id: u64,
error: Option<String>,
suppressed: HashSet<egui::Key>,
}
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
let mut fonts = egui::FontDefinitions::default();
if let Some(path) = &config.font_path {
let data = std::fs::read(path)?;
fonts
.font_data
.insert("terminal".into(), egui::FontData::from_owned(data).into());
fonts
.families
.get_mut(&egui::FontFamily::Monospace)
.unwrap()
.insert(0, "terminal".into());
}
Ok(fonts)
}
impl App {
pub fn new(
cc: &eframe::CreationContext<'_>,
config: Config,
config_path: PathBuf,
) -> Result<Self> {
cc.egui_ctx.set_visuals(egui::Visuals::dark());
cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &cc.egui_ctx)?;
Ok(Self {
config,
config_path,
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
current: 0,
next_id: 2,
error: None,
suppressed: HashSet::new(),
})
}
fn spawn(&mut self, ctx: &egui::Context) -> Result<PaneId> {
ensure!(
self.panes.len() < self.config.multiplexer.max_panes,
"Pane limit reached"
);
let pane = Pane::new(&self.config, ctx)?;
let id = self.next_id;
self.next_id += 1;
self.panes.insert(id, pane);
Ok(id)
}
fn action(&mut self, action: Action, ctx: &egui::Context) -> Result<()> {
let active = self.tabs[self.current].active;
match action {
Action::NewPane => {
let action = if self.tabs[self.current].floats_visible {
Action::NewFloating
} else {
let rect = self.panes[&active].last_rect;
if rect.is_positive() && rect.height() > rect.width() {
Action::SplitDown
} else {
Action::SplitRight
}
};
self.action(action, ctx)?;
}
Action::SplitRight | Action::SplitDown => {
let id = self.spawn(ctx)?;
let tab = &mut self.tabs[self.current];
let axis = if action == Action::SplitRight {
Axis::Horizontal
} else {
Axis::Vertical
};
if let Some(root) = &mut tab.root {
root.split(
if tab.floats.contains(&active) {
root.first()
} else {
active
},
id,
axis,
);
} else {
tab.root = Some(Layout::Pane(id));
}
tab.active = id;
tab.zoomed = false;
}
Action::NewFloating => {
let id = self.spawn(ctx)?;
let tab = &mut self.tabs[self.current];
tab.floats.push(id);
tab.active = id;
tab.floats_visible = true;
tab.zoomed = false;
}
Action::ToggleFloating => {
let tab = &mut self.tabs[self.current];
let was_floating = tab.floats.contains(&active);
tab.detach(active);
if was_floating {
if let Some(root) = &mut tab.root {
root.split(root.first(), active, Axis::Horizontal);
} else {
tab.root = Some(Layout::Pane(active));
}
} else {
tab.floats.push(active);
tab.floats_visible = true;
}
}
Action::ToggleFloats => {
if self.tabs[self.current].floats.is_empty() {
self.action(Action::NewFloating, ctx)?;
} else {
self.tabs[self.current].toggle_floats();
}
}
Action::FocusLeft | Action::FocusDown | Action::FocusUp | Action::FocusRight => {
let direction = match action {
Action::FocusLeft => Direction::Left,
Action::FocusDown => Direction::Down,
Action::FocusUp => Direction::Up,
_ => Direction::Right,
};
let rects = self
.panes
.iter()
.map(|(id, pane)| (*id, pane.last_rect))
.collect::<Vec<_>>();
self.tabs[self.current].focus_direction(direction, &rects);
}
Action::NextPane | Action::PreviousPane => {
self.tabs[self.current].cycle(action == Action::PreviousPane)
}
Action::ClosePane => self.close(active, ctx),
Action::ZoomPane => {
let tab = &mut self.tabs[self.current];
if tab.is_visible(tab.active) {
tab.zoomed = !tab.zoomed;
}
}
Action::NewTab => {
let id = self.spawn(ctx)?;
self.tabs.push(Tab::new(id, id));
self.current = self.tabs.len() - 1;
}
Action::NextTab => self.current = (self.current + 1) % self.tabs.len(),
Action::PreviousTab => {
self.current = (self.current + self.tabs.len() - 1) % self.tabs.len()
}
Action::ReloadConfig => {
let config = Config::load(&self.config_path)?;
let fonts = fonts(&config)?;
for pane in self.panes.values_mut() {
pane::apply_config(&mut pane.terminal, &config)?;
}
ctx.set_fonts(fonts);
self.config = config;
}
Action::Copy => {
if let Some(text) = self.panes[&active].copy()? {
ctx.copy_text(text);
}
}
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
}
Ok(())
}
fn close(&mut self, id: PaneId, ctx: &egui::Context) {
self.panes.remove(&id);
for tab in &mut self.tabs {
if !tab.ids().contains(&id) {
continue;
}
tab.detach(id);
if tab.active == id
&& let Some(next) = tab.ids().first()
{
tab.active = *next;
}
if tab.root.is_none() {
tab.floats_visible = true;
}
}
self.tabs.retain(|t| !t.ids().is_empty());
self.current = self.current.min(self.tabs.len().saturating_sub(1));
if self.tabs.is_empty() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
let events = ctx.input(|i| i.events.clone());
let modifiers = ctx.input(|i| i.modifiers);
let mut terminal_events = vec![];
let mut suppress_text = false;
for event in events {
if let egui::Event::Key {
key,
pressed,
repeat,
modifiers,
..
} = &event
{
suppress_text = false;
if !pressed && self.suppressed.remove(key) {
continue;
}
let action = self.config.keys.iter().find_map(|(s, a)| {
let shortcut = config::shortcut(s).ok()?;
let m = shortcut.modifiers;
(shortcut.logical_key == *key
&& m.ctrl == modifiers.ctrl
&& m.shift == modifiers.shift
&& m.alt == modifiers.alt
&& m.mac_cmd == modifiers.mac_cmd)
.then_some(*a)
});
if *pressed && let Some(action) = action {
self.flush_keys(&mut terminal_events)?;
self.suppressed.insert(*key);
suppress_text = true;
if !repeat {
self.action(action, ctx)?;
}
if self.tabs.is_empty() {
return Ok(());
}
continue;
}
}
if suppress_text && matches!(event, egui::Event::Text(_)) {
continue;
}
let active = self.tabs[self.current].active;
if !self.tabs[self.current].is_visible(active) {
continue;
}
match event {
egui::Event::Paste(text) => {
self.flush_keys(&mut terminal_events)?;
self.panes.get_mut(&active).unwrap().paste(&text)?;
}
egui::Event::Copy => {
self.flush_keys(&mut terminal_events)?;
if modifiers.shift || modifiers.mac_cmd {
self.action(Action::Copy, ctx)?;
} else {
self.panes.get_mut(&active).unwrap().send(vec![3])?;
}
}
egui::Event::Cut => {
self.flush_keys(&mut terminal_events)?;
self.panes.get_mut(&active).unwrap().send(vec![24])?;
}
other => terminal_events.push(other),
}
}
self.flush_keys(&mut terminal_events)
}
fn flush_keys(&mut self, events: &mut Vec<egui::Event>) -> Result<()> {
let active = self.tabs[self.current].active;
if !self.tabs[self.current].is_visible(active) {
events.clear();
return Ok(());
}
let pane = self.panes.get_mut(&active).unwrap();
let encoded = input::encode(&pane.terminal, events)?;
pane.send(encoded)?;
events.clear();
Ok(())
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.tabs.is_empty() {
return;
}
for pane in self.panes.values_mut() {
pane.drain(ctx);
}
if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string());
}
if self.tabs.is_empty() {
return;
}
let mut action = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
for (index, tab) in self.tabs.iter().enumerate() {
let title = self.panes[&tab.active].terminal.title().unwrap_or("");
let label = if title.is_empty() {
format!("{}: shell", index + 1)
} else {
format!(
"{}: {}",
index + 1,
title.chars().take(28).collect::<String>()
)
};
if ui.selectable_label(self.current == index, label).clicked() {
self.current = index;
}
}
if ui.button("+").on_hover_text("New tab").clicked() {
action = Some(Action::NewTab);
}
ui.menu_button("Pane", |ui| {
for (label, a) in [
("New pane", Action::NewPane),
("Split right", Action::SplitRight),
("Split down", Action::SplitDown),
("New floating pane", Action::NewFloating),
("Float / dock", Action::ToggleFloating),
("Show / hide floating panes", Action::ToggleFloats),
("Zoom / restore", Action::ZoomPane),
("Next pane", Action::NextPane),
("Close pane", Action::ClosePane),
] {
if ui.button(label).clicked() {
action = Some(a);
ui.close();
}
}
});
ui.menu_button("Settings", |ui| {
if ui.button("Reload config").clicked() {
action = Some(Action::ReloadConfig);
ui.close();
}
});
});
});
if let Some(error) = self.error.clone() {
egui::TopBottomPanel::bottom("error").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.colored_label(Color32::LIGHT_RED, error);
if ui.button("Dismiss").clicked() {
self.error = None;
}
});
});
}
let bg = color(config::rgb(&self.config.background).unwrap());
let tab = &mut self.tabs[self.current];
let mut tiled = vec![];
let mut workspace = Rect::NOTHING;
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(bg))
.show(ctx, |ui| {
workspace = ui.max_rect();
if tab.zoomed {
tiled.push((tab.active, workspace));
} else if let Some(root) = &mut tab.root {
root.arrange(ui, workspace, &mut tiled, 1);
}
for (id, rect) in &tiled {
let pane = self.panes.get_mut(id).unwrap();
let active = tab.active == *id;
if !tab.zoomed {
pane.last_rect = *rect;
}
if show_pane(ui, *id, pane, *rect, &self.config, active) {
tab.active = *id;
}
}
});
if tab.floats_visible && !tab.zoomed {
for id in tab.floats.clone() {
let pane = self.panes.get_mut(&id).unwrap();
let window_id = egui::Id::new(("float", tab.id, id));
let active = tab.active == id;
let result = egui::Window::new(pane_title(id, pane))
.id(window_id)
.title_bar(false)
.constrain_to(workspace)
.default_pos(
workspace.min
+ vec2(40.0 + (id % 5) as f32 * 22.0, 40.0 + (id % 5) as f32 * 22.0),
)
.default_size(
workspace.size()
* vec2(
self.config.multiplexer.floating_width,
self.config.multiplexer.floating_height,
),
)
.min_size(vec2(160.0, 100.0))
.frame(egui::Frame::NONE.fill(bg))
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover());
pane.last_rect = rect;
if show_pane(ui, id, pane, rect, &self.config, active) {
tab.active = id;
}
});
if let Some(result) = result
&& result.response.contains_pointer()
&& ctx.input(|i| i.pointer.any_pressed())
{
tab.active = id;
}
if active {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id));
}
}
}
let active = tab.active;
for (id, pane) in &mut self.panes {
if let Err(e) =
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused))
{
pane.error = Some(e.to_string());
}
}
if !self.tabs.is_empty()
&& let Some(action) = action
&& let Err(e) = self.action(action, ctx)
{
self.error = Some(e.to_string());
}
}
}
fn show_pane(
ui: &mut egui::Ui,
id: PaneId,
pane: &mut Pane,
rect: Rect,
config: &Config,
active: bool,
) -> bool {
let accent = color(config::rgb(&config.accent).unwrap());
let title_rect = Rect::from_min_size(rect.min, vec2(rect.width(), 25.0_f32.min(rect.height())));
ui.painter().rect_filled(
title_rect,
0,
Color32::from_gray(if active { 43 } else { 31 }),
);
ui.painter()
.with_clip_rect(title_rect.shrink(3.0).intersect(ui.clip_rect()))
.text(
title_rect.min + vec2(7.0, 4.0),
egui::Align2::LEFT_TOP,
pane_title(id, pane),
egui::FontId::proportional(13.0),
if active { accent } else { Color32::GRAY },
);
// Click-only leaves dragging to the floating window's existing move handler.
let header = ui.interact(
title_rect,
ui.id().with(("title", id)),
egui::Sense::click(),
);
let mut focused =
header.clicked() || (header.contains_pointer() && ui.input(|i| i.pointer.any_pressed()));
let content = Rect::from_min_max(egui::pos2(rect.left(), title_rect.bottom()), rect.max);
// The pane rectangle is already allocated; a layout scope would grow its parent.
let mut content_ui = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(content));
content_ui.set_clip_rect(content.intersect(ui.clip_rect()));
match pane.show(&mut content_ui, content, config, active) {
Ok(clicked) => focused |= clicked,
Err(e) => pane.error = Some(e.to_string()),
}
ui.painter().rect_stroke(
rect,
0,
Stroke::new(
1.0,
if active {
accent
} else {
Color32::from_gray(54)
},
),
egui::StrokeKind::Inside,
);
focused
}
fn pane_title(id: PaneId, pane: &Pane) -> String {
let title = pane.terminal.title().unwrap_or("");
format!(
"{id} {}{}",
if title.is_empty() { "shell" } else { title },
if pane.dead { " [exited]" } else { "" }
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app(ctx: &egui::Context) -> App {
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let pane = Pane::new(&config, ctx).unwrap();
App {
config,
config_path: PathBuf::new(),
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
current: 0,
next_id: 2,
error: None,
suppressed: HashSet::new(),
}
}
#[test]
fn floating_pane_size_stays_constant_across_frames() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
let mut sizes = vec![];
for frame in 0..20 {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))),
time: Some(frame as f64 / 60.0),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
let window = egui::Window::new("float")
.title_bar(false)
.default_size(vec2(400.0, 240.0))
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover());
show_pane(
ui,
1,
app.panes.get_mut(&1).unwrap(),
rect,
&app.config,
true,
);
})
.unwrap();
if frame >= 2 {
sizes.push(window.response.rect.size());
}
});
}
assert!(
sizes.iter().all(|size| *size == sizes[0]),
"floating pane grew: {sizes:?}"
);
}
#[test]
fn toggle_creates_first_float_then_reuses_it_and_close_removes_focus() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
app.action(Action::ToggleFloats, &ctx).unwrap();
assert_eq!(app.tabs[0].floats, vec![2]);
assert_eq!(app.tabs[0].active, 2);
assert!(app.tabs[0].floats_visible);
app.action(Action::ToggleFloats, &ctx).unwrap();
assert!(!app.tabs[0].floats_visible);
assert_eq!(app.tabs[0].active, 1);
app.action(Action::ToggleFloats, &ctx).unwrap();
assert_eq!(app.panes.len(), 2);
assert_eq!(app.tabs[0].active, 2);
app.action(Action::ClosePane, &ctx).unwrap();
assert_eq!(app.tabs[0].active, 1);
assert!(!app.panes.contains_key(&2));
app.action(Action::ToggleFloats, &ctx).unwrap();
assert_eq!(app.tabs[0].floats, vec![3]);
assert!(app.tabs[0].floats_visible);
}
}
+267
View File
@@ -0,0 +1,267 @@
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
pub const EXAMPLE: &str = include_str!("../config.example.toml");
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub shell: Vec<String>,
pub working_directory: Option<PathBuf>,
pub font_size: f32,
pub font_path: Option<PathBuf>,
pub scrollback: usize,
pub window_width: f32,
pub window_height: f32,
pub background: String,
pub foreground: String,
pub accent: String,
pub kitty: Kitty,
pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Kitty {
pub image_storage_mb: u64,
pub allow_file: bool,
pub allow_temp_file: bool,
pub allow_shared_memory: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Multiplexer {
pub max_panes: usize,
pub floating_width: f32,
pub floating_height: f32,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Action {
SplitRight,
SplitDown,
NewFloating,
NewPane,
ToggleFloating,
ToggleFloats,
NextPane,
PreviousPane,
FocusLeft,
FocusDown,
FocusUp,
FocusRight,
ClosePane,
ZoomPane,
NewTab,
NextTab,
PreviousTab,
ReloadConfig,
Copy,
Paste,
}
impl Default for Kitty {
fn default() -> Self {
Self {
image_storage_mb: 64,
allow_file: true,
allow_temp_file: true,
allow_shared_memory: true,
}
}
}
impl Default for Multiplexer {
fn default() -> Self {
Self {
max_panes: 32,
floating_width: 0.65,
floating_height: 0.65,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
shell: vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())],
working_directory: None,
font_size: 16.0,
font_path: None,
scrollback: 10000,
window_width: 1100.0,
window_height: 720.0,
background: "#17191c".into(),
foreground: "#e2e4e8".into(),
accent: "#58c4ad".into(),
kitty: Kitty::default(),
multiplexer: Multiplexer::default(),
keys: [
("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane),
("Alt+H", Action::FocusLeft),
("Alt+J", Action::FocusDown),
("Alt+K", Action::FocusUp),
("Alt+L", Action::FocusRight),
("Alt+A", Action::NewTab),
("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane),
("Ctrl+Shift+Z", Action::ZoomPane),
("Ctrl+Shift+R", Action::ReloadConfig),
("Ctrl+Shift+C", Action::Copy),
("Ctrl+Shift+V", Action::Paste),
]
.into_iter()
.map(|(k, v)| (k.into(), v))
.collect(),
}
}
}
impl Config {
pub fn path() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|p| !p.is_empty()) {
return Ok(PathBuf::from(dir).join("rust-term/config.toml"));
}
Ok(
PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?)
.join(".config/rust-term/config.toml"),
)
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => {
Self::parse(&text).with_context(|| format!("Invalid config: {}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("Cannot read {}", path.display())),
}
}
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
ensure!(
!config.shell.is_empty() && !config.shell[0].is_empty(),
"shell must contain an executable"
);
ensure!(
(8.0..=48.0).contains(&config.font_size),
"font_size must be 8..48"
);
ensure!(
(320.0..=16384.0).contains(&config.window_width)
&& (200.0..=16384.0).contains(&config.window_height),
"invalid window dimensions"
);
ensure!(
config.scrollback <= 1_000_000,
"scrollback must be <= 1000000"
);
ensure!(
(1..=1024).contains(&config.kitty.image_storage_mb),
"image_storage_mb must be 1..1024"
);
ensure!(
(1..=128).contains(&config.multiplexer.max_panes),
"max_panes must be 1..128"
);
for fraction in [
config.multiplexer.floating_width,
config.multiplexer.floating_height,
] {
ensure!(
(0.2..=0.95).contains(&fraction),
"floating dimensions must be fractions in 0.2..0.95"
);
}
for color in [&config.background, &config.foreground, &config.accent] {
rgb(color)?;
}
for key in config.keys.keys() {
shortcut(key)?;
}
Ok(config)
}
}
pub fn rgb(text: &str) -> Result<libghostty_vt::style::RgbColor> {
ensure!(
text.len() == 7
&& text.starts_with('#')
&& text[1..].bytes().all(|c| c.is_ascii_hexdigit()),
"color must be #RRGGBB: {text}"
);
let value = u32::from_str_radix(&text[1..], 16)?;
Ok(libghostty_vt::style::RgbColor {
r: (value >> 16) as u8,
g: (value >> 8) as u8,
b: value as u8,
})
}
pub fn shortcut(text: &str) -> Result<egui::KeyboardShortcut> {
let mut mods = egui::Modifiers::NONE;
let parts: Vec<_> = text.split('+').collect();
for part in &parts[..parts.len() - 1] {
match *part {
"Ctrl" => mods.ctrl = true,
"Shift" => mods.shift = true,
"Alt" => mods.alt = true,
"Super" => {
mods.mac_cmd = true;
mods.command = true;
}
_ => bail!("unknown modifier in {text}"),
}
}
let key = egui::Key::from_name(parts[parts.len() - 1])
.with_context(|| format!("unknown key in {text}"))?;
Ok(egui::KeyboardShortcut::new(mods, key))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_example_and_rejects_bad_values() {
let example = Config::parse(EXAMPLE).unwrap();
assert_eq!(example.keys, Config::default().keys);
for text in [
"font_size = nan",
"shell = []",
"background = '#xx0000'",
"font_sze = 16",
"[keys]\n'Hyper+Q' = 'close_pane'",
"[multiplexer]\nfloating_width = 2",
] {
assert!(Config::parse(text).is_err(), "accepted {text}");
}
}
#[test]
fn default_shortcuts_match_pane_and_tab_actions() {
let config = Config::default();
for (binding, action) in [
("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane),
("Alt+H", Action::FocusLeft),
("Alt+J", Action::FocusDown),
("Alt+K", Action::FocusUp),
("Alt+L", Action::FocusRight),
("Alt+A", Action::NewTab),
("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane),
] {
assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt);
}
}
}
+180
View File
@@ -0,0 +1,180 @@
use anyhow::Result;
use libghostty_vt::{Terminal, key};
pub fn mods(m: egui::Modifiers) -> key::Mods {
let mut out = key::Mods::empty();
if m.ctrl {
out |= key::Mods::CTRL;
}
if m.shift {
out |= key::Mods::SHIFT;
}
if m.alt {
out |= key::Mods::ALT;
}
if m.mac_cmd {
out |= key::Mods::SUPER;
}
out
}
pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec<u8>> {
let mut encoder = key::Encoder::new()?;
encoder.set_options_from_terminal(terminal);
let mut event = key::Event::new()?;
let mut out = vec![];
let mut used_text = std::collections::HashSet::new();
for (index, e) in events.iter().enumerate() {
match e {
egui::Event::Key {
key: k,
physical_key,
pressed,
repeat,
modifiers,
} => {
let (logical, ucp) = map(*k);
let (physical, _) = map(physical_key.unwrap_or(*k));
let mut text = None;
if *pressed && printable(*k) {
// winit emits text immediately after its key event. Stop at the next key
// so a burst of distinct keys cannot consume each other's text.
for (i, next) in events.iter().enumerate().skip(index + 1) {
if matches!(next, egui::Event::Key { .. }) {
break;
}
if let egui::Event::Text(value) = next {
text = Some(value.as_str());
used_text.insert(i);
break;
}
}
}
event
.set_action(if !pressed {
key::Action::Release
} else if *repeat {
key::Action::Repeat
} else {
key::Action::Press
})
.set_key(if physical == key::Key::Unidentified {
logical
} else {
physical
})
.set_mods(mods(*modifiers))
.set_consumed_mods(if text.is_some() {
key::Mods::SHIFT
} else {
key::Mods::empty()
})
.set_unshifted_codepoint(ucp)
.set_utf8(text);
encoder.encode_to_vec(&event, &mut out)?;
}
egui::Event::Text(text) if !used_text.contains(&index) => {
event
.set_action(key::Action::Press)
.set_key(key::Key::Unidentified)
.set_mods(key::Mods::empty())
.set_consumed_mods(key::Mods::empty())
.set_unshifted_codepoint('\0')
.set_utf8(Some(text.as_str()));
encoder.encode_to_vec(&event, &mut out)?;
}
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
out.extend_from_slice(text.as_bytes())
}
_ => {}
}
}
Ok(out)
}
fn printable(k: egui::Key) -> bool {
let (_, c) = map(k);
c != '\0'
}
fn map(k: egui::Key) -> (key::Key, char) {
use egui::Key as E;
use key::Key as G;
match k {
E::A => (G::A, 'a'),
E::B => (G::B, 'b'),
E::C => (G::C, 'c'),
E::D => (G::D, 'd'),
E::E => (G::E, 'e'),
E::F => (G::F, 'f'),
E::G => (G::G, 'g'),
E::H => (G::H, 'h'),
E::I => (G::I, 'i'),
E::J => (G::J, 'j'),
E::K => (G::K, 'k'),
E::L => (G::L, 'l'),
E::M => (G::M, 'm'),
E::N => (G::N, 'n'),
E::O => (G::O, 'o'),
E::P => (G::P, 'p'),
E::Q => (G::Q, 'q'),
E::R => (G::R, 'r'),
E::S => (G::S, 's'),
E::T => (G::T, 't'),
E::U => (G::U, 'u'),
E::V => (G::V, 'v'),
E::W => (G::W, 'w'),
E::X => (G::X, 'x'),
E::Y => (G::Y, 'y'),
E::Z => (G::Z, 'z'),
E::Num0 => (G::Digit0, '0'),
E::Num1 => (G::Digit1, '1'),
E::Num2 => (G::Digit2, '2'),
E::Num3 => (G::Digit3, '3'),
E::Num4 => (G::Digit4, '4'),
E::Num5 => (G::Digit5, '5'),
E::Num6 => (G::Digit6, '6'),
E::Num7 => (G::Digit7, '7'),
E::Num8 => (G::Digit8, '8'),
E::Num9 => (G::Digit9, '9'),
E::Space => (G::Space, ' '),
E::Minus => (G::Minus, '-'),
E::Equals => (G::Equal, '='),
E::Comma => (G::Comma, ','),
E::Period => (G::Period, '.'),
E::Slash => (G::Slash, '/'),
E::Backslash => (G::Backslash, '\\'),
E::OpenBracket => (G::BracketLeft, '['),
E::CloseBracket => (G::BracketRight, ']'),
E::Backtick => (G::Backquote, '`'),
E::Semicolon => (G::Semicolon, ';'),
E::Quote => (G::Quote, '\''),
E::ArrowDown => (G::ArrowDown, '\0'),
E::ArrowUp => (G::ArrowUp, '\0'),
E::ArrowLeft => (G::ArrowLeft, '\0'),
E::ArrowRight => (G::ArrowRight, '\0'),
E::Enter => (G::Enter, '\0'),
E::Escape => (G::Escape, '\0'),
E::Tab => (G::Tab, '\0'),
E::Backspace => (G::Backspace, '\0'),
E::Delete => (G::Delete, '\0'),
E::Insert => (G::Insert, '\0'),
E::Home => (G::Home, '\0'),
E::End => (G::End, '\0'),
E::PageUp => (G::PageUp, '\0'),
E::PageDown => (G::PageDown, '\0'),
E::F1 => (G::F1, '\0'),
E::F2 => (G::F2, '\0'),
E::F3 => (G::F3, '\0'),
E::F4 => (G::F4, '\0'),
E::F5 => (G::F5, '\0'),
E::F6 => (G::F6, '\0'),
E::F7 => (G::F7, '\0'),
E::F8 => (G::F8, '\0'),
E::F9 => (G::F9, '\0'),
E::F10 => (G::F10, '\0'),
E::F11 => (G::F11, '\0'),
E::F12 => (G::F12, '\0'),
_ => (G::Unidentified, '\0'),
}
}
+365
View File
@@ -0,0 +1,365 @@
use egui::Rect;
pub type PaneId = u64;
#[derive(Clone, Copy, Debug)]
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy)]
pub enum Direction {
Left,
Down,
Up,
Right,
}
#[derive(Debug)]
pub enum Layout {
Pane(PaneId),
Split {
axis: Axis,
ratio: f32,
first: Box<Layout>,
second: Box<Layout>,
},
}
impl Layout {
pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool {
match self {
Self::Pane(id) if *id == target => {
*self = Self::Split {
axis,
ratio: 0.5,
first: Box::new(Self::Pane(target)),
second: Box::new(Self::Pane(new)),
};
true
}
Self::Split { first, second, .. } => {
first.split(target, new, axis) || second.split(target, new, axis)
}
_ => false,
}
}
pub fn remove(self, target: PaneId) -> Option<Self> {
match self {
Self::Pane(id) => (id != target).then_some(Self::Pane(id)),
Self::Split {
axis,
ratio,
first,
second,
} => match (first.remove(target), second.remove(target)) {
(Some(first), Some(second)) => Some(Self::Split {
axis,
ratio,
first: Box::new(first),
second: Box::new(second),
}),
(first, second) => first.or(second),
},
}
}
pub fn ids(&self, out: &mut Vec<PaneId>) {
match self {
Self::Pane(id) => out.push(*id),
Self::Split { first, second, .. } => {
first.ids(out);
second.ids(out);
}
}
}
pub fn first(&self) -> PaneId {
match self {
Self::Pane(id) => *id,
Self::Split { first, .. } => first.first(),
}
}
pub fn arrange(
&mut self,
ui: &mut egui::Ui,
rect: Rect,
out: &mut Vec<(PaneId, Rect)>,
path: u64,
) {
match self {
Self::Pane(id) => out.push((*id, rect)),
Self::Split {
axis,
ratio,
first,
second,
} => {
let horizontal = matches!(axis, Axis::Horizontal);
let span = if horizontal {
rect.width()
} else {
rect.height()
};
let start = if horizontal { rect.left() } else { rect.top() };
let mut a = rect;
let mut b = rect;
let mid = start + span * *ratio;
if horizontal {
a.max.x = mid;
b.min.x = mid;
} else {
a.max.y = mid;
b.min.y = mid;
}
let divider = if horizontal {
Rect::from_min_max(
egui::pos2(mid - 3.0, rect.top()),
egui::pos2(mid + 3.0, rect.bottom()),
)
} else {
Rect::from_min_max(
egui::pos2(rect.left(), mid - 3.0),
egui::pos2(rect.right(), mid + 3.0),
)
};
let response =
ui.interact(divider, ui.id().with(("split", path)), egui::Sense::drag());
response.clone().on_hover_cursor(if horizontal {
egui::CursorIcon::ResizeHorizontal
} else {
egui::CursorIcon::ResizeVertical
});
if response.dragged()
&& let Some(pos) = response.interact_pointer_pos()
{
*ratio = ((if horizontal { pos.x } else { pos.y } - start) / span.max(1.0))
.clamp(0.1, 0.9);
}
first.arrange(ui, a, out, path * 2);
second.arrange(ui, b, out, path * 2 + 1);
}
}
}
}
pub struct Tab {
pub id: u64,
pub root: Option<Layout>,
pub floats: Vec<PaneId>,
pub active: PaneId,
pub floats_visible: bool,
pub zoomed: bool,
}
impl Tab {
pub fn new(id: u64, pane: PaneId) -> Self {
Self {
id,
root: Some(Layout::Pane(pane)),
floats: vec![],
active: pane,
floats_visible: false,
zoomed: false,
}
}
pub fn ids(&self) -> Vec<PaneId> {
let mut ids = vec![];
if let Some(root) = &self.root {
root.ids(&mut ids);
}
ids.extend_from_slice(&self.floats);
ids
}
pub fn detach(&mut self, id: PaneId) {
self.root = self.root.take().and_then(|r| r.remove(id));
self.floats.retain(|p| *p != id);
self.zoomed = false;
}
pub fn is_visible(&self, id: PaneId) -> bool {
self.floats_visible || !self.floats.contains(&id)
}
pub fn toggle_floats(&mut self) {
self.floats_visible = !self.floats_visible;
self.zoomed = false;
if self.floats_visible {
if let Some(id) = self.floats.last() {
self.active = *id;
}
} else if self.floats.contains(&self.active)
&& let Some(root) = &self.root
{
self.active = root.first();
}
}
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
let Some((_, source)) = rects
.iter()
.find(|(id, _)| *id == self.active && self.is_visible(*id))
else {
return;
};
if !source.is_positive() || !source.is_finite() {
return;
}
let horizontal = matches!(direction, Direction::Left | Direction::Right);
let sign = if matches!(direction, Direction::Left | Direction::Up) {
-1.0
} else {
1.0
};
let ids = self.ids();
let next = rects
.iter()
.filter_map(|(id, rect)| {
if *id == self.active
|| !ids.contains(id)
|| !self.is_visible(*id)
|| !rect.is_positive()
|| !rect.is_finite()
{
return None;
}
let delta = rect.center() - source.center();
let forward = sign * if horizontal { delta.x } else { delta.y };
if forward <= 0.5 {
return None;
}
let overlap = if horizontal {
source.bottom().min(rect.bottom()) - source.top().max(rect.top())
} else {
source.right().min(rect.right()) - source.left().max(rect.left())
};
// Prefer aligned neighbors, then the nearest center, without wrapping.
Some((*id, overlap <= 0.0, delta.length_sq()))
})
.min_by(|a, b| {
a.1.cmp(&b.1)
.then_with(|| a.2.total_cmp(&b.2))
.then_with(|| a.0.cmp(&b.0))
});
if let Some((id, _, _)) = next {
self.active = id;
}
}
pub fn cycle(&mut self, backwards: bool) {
let ids: Vec<_> = self
.ids()
.into_iter()
.filter(|id| self.floats_visible || !self.floats.contains(id))
.collect();
if ids.is_empty() {
return;
}
let pos = ids.iter().position(|p| *p == self.active).unwrap_or(0);
self.active = ids[(pos + if backwards { ids.len() - 1 } else { 1 }) % ids.len()];
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removal_collapses_splits_without_losing_siblings() {
let mut root = Layout::Pane(1);
assert!(root.split(1, 2, Axis::Horizontal));
assert!(root.split(2, 3, Axis::Vertical));
assert!(!root.split(99, 4, Axis::Vertical));
let root = root.remove(2).unwrap();
let mut ids = vec![];
root.ids(&mut ids);
assert_eq!(ids, vec![1, 3]);
let root = root.remove(1).unwrap();
assert_eq!(root.first(), 3);
assert!(root.remove(3).is_none());
}
#[test]
fn focus_skips_hidden_floats() {
let mut tab = Tab::new(1, 1);
tab.floats.push(2);
tab.floats_visible = true;
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.floats_visible = false;
tab.cycle(false);
assert_eq!(tab.active, 1);
}
#[test]
fn floating_visibility_controls_focus_even_without_tiles() {
let mut tab = Tab::new(1, 1);
assert!(!tab.floats_visible);
tab.toggle_floats();
assert!(tab.floats_visible);
tab.floats.push(2);
tab.active = 2;
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(!tab.is_visible(2));
tab.toggle_floats();
assert_eq!(tab.active, 2);
tab.root = None;
tab.toggle_floats();
assert!(!tab.is_visible(2));
tab.toggle_floats();
assert!(tab.is_visible(2));
}
#[test]
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
let mut tab = Tab::new(1, 1);
let root = tab.root.as_mut().unwrap();
root.split(1, 2, Axis::Horizontal);
root.split(1, 3, Axis::Vertical);
root.split(2, 4, Axis::Vertical);
tab.floats.push(5);
let rect = |x, y| Rect::from_min_size(egui::pos2(x, y), egui::vec2(100.0, 100.0));
let panes = [
(1, rect(0.0, 0.0)),
(2, rect(100.0, 0.0)),
(3, rect(0.0, 100.0)),
(4, rect(100.0, 100.0)),
(5, rect(20.0, 0.0)),
(99, rect(10.0, 0.0)),
];
tab.focus_direction(Direction::Right, &panes);
assert_eq!(tab.active, 2);
tab.focus_direction(Direction::Down, &panes);
assert_eq!(tab.active, 4);
tab.focus_direction(Direction::Left, &panes);
assert_eq!(tab.active, 3);
tab.focus_direction(Direction::Up, &panes);
assert_eq!(tab.active, 1);
tab.focus_direction(Direction::Left, &panes);
assert_eq!(tab.active, 1);
tab.floats_visible = true;
tab.focus_direction(Direction::Right, &panes);
assert_eq!(tab.active, 5);
}
#[test]
fn split_rectangles_touch_without_gaps() {
let ctx = egui::Context::default();
let mut layout = Layout::Pane(1);
layout.split(1, 2, Axis::Horizontal);
layout.split(2, 3, Axis::Vertical);
let mut rects = vec![];
let workspace = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0));
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default()
.show(ctx, |ui| layout.arrange(ui, workspace, &mut rects, 1));
});
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].1.right(), rects[1].1.left());
assert_eq!(rects[1].1.bottom(), rects[2].1.top());
assert_eq!(
rects.iter().map(|(_, rect)| rect.area()).sum::<f32>(),
workspace.area()
);
}
}
+85
View File
@@ -0,0 +1,85 @@
mod app;
mod config;
mod input;
mod layout;
mod pane;
mod pty;
mod render;
#[cfg(test)]
mod tests;
use anyhow::{Context, Result, bail};
fn main() -> Result<()> {
let mut path = config::Config::path()?;
let mut check = false;
let mut init = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--config" => path = args.next().context("--config needs a path")?.into(),
"--check-config" => check = true,
"--init-config" => init = true,
"--version" => {
println!("rust-term {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!(
"rust-term [--config PATH] [--check-config | --init-config]\n\nDefault config: {}",
path.display()
);
return Ok(());
}
_ => bail!("Unknown argument: {arg}"),
}
}
if init {
use std::io::Write;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.with_context(|| {
format!(
"Cannot create {} (existing files are never overwritten)",
path.display()
)
})?;
file.write_all(config::EXAMPLE.as_bytes())?;
println!("Created {}", path.display());
return Ok(());
}
let config = config::Config::load(&path)?;
if check {
println!(
"Configuration valid: {}{}",
path.display(),
if path.exists() {
""
} else {
" (built-in defaults)"
}
);
return Ok(());
}
libghostty_vt::kitty::graphics::set_png_decoder(Some(Box::new(render::PngDecoder)))?;
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("rust-term")
.with_inner_size([config.window_width, config.window_height])
.with_min_inner_size([320.0, 200.0])
.with_app_id("rust-term"),
renderer: eframe::Renderer::Wgpu,
..Default::default()
};
eframe::run_native(
"rust-term",
options,
Box::new(move |cc| Ok(Box::new(app::App::new(cc, config, path)?))),
)
.map_err(|e| anyhow::anyhow!("Cannot start rust-term: {e}"))
}
+364
View File
@@ -0,0 +1,364 @@
use crate::{
config::{Config, rgb},
input,
pty::Pty,
render::Renderer,
};
use anyhow::{Result, ensure};
use egui::{FontId, Rect, Sense, vec2};
use libghostty_vt::{
Terminal, TerminalOptions, mouse,
selection::{FormatOptions, Selection},
terminal::{self, Mode, Point, PointCoordinate, ScrollViewport},
};
use std::{cell::RefCell, rc::Rc, sync::mpsc::TryRecvError};
pub struct Pane {
pub terminal: Terminal<'static, 'static>,
pub renderer: Renderer,
pub pty: Pty,
pub dead: bool,
pub error: Option<String>,
pub last_rect: Rect,
size: (u16, u16, u32, u32),
report_size: Rc<RefCell<terminal::SizeReportSize>>,
callback_error: Rc<RefCell<Option<String>>>,
mouse_encoder: mouse::Encoder<'static>,
mouse_event: mouse::Event<'static>,
selection_start: Option<PointCoordinate>,
focused: bool,
}
pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
let mut term = Terminal::new(TerminalOptions {
cols: 80,
rows: 24,
max_scrollback: config.scrollback,
})?;
term.resize(80, 24, 8, 16)?;
apply_config(&mut term, config)?;
term.on_xtversion(|_| Some("rust-term 0.1.0"))?;
term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes {
primary: terminal::PrimaryDeviceAttributes::new(
terminal::ConformanceLevel::VT220,
&[terminal::DeviceAttributeFeature::ANSI_COLOR],
),
secondary: terminal::SecondaryDeviceAttributes {
device_type: terminal::DeviceType::VT220,
firmware_version: 1,
rom_cartridge: 0,
},
tertiary: Default::default(),
})
})?;
term.on_color_scheme(|_| Some(terminal::ColorScheme::Dark))?;
Ok(term)
}
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()> {
term.set_default_fg_color(Some(rgb(&config.foreground)?))?
.set_default_bg_color(Some(rgb(&config.background)?))?
.set_default_cursor_color(Some(rgb(&config.accent)?))?
.set_kitty_image_storage_limit(config.kitty.image_storage_mb * 1024 * 1024)?
.set_kitty_image_from_file_allowed(config.kitty.allow_file)?
.set_kitty_image_from_temp_file_allowed(config.kitty.allow_temp_file)?
.set_kitty_image_from_shared_mem_allowed(config.kitty.allow_shared_memory)?;
Ok(())
}
impl Pane {
pub fn new(config: &Config, ctx: &egui::Context) -> Result<Self> {
let mut terminal = terminal(config)?;
let renderer = Renderer::new()?;
let mouse_encoder = mouse::Encoder::new()?;
let mouse_event = mouse::Event::new()?;
let pty = Pty::spawn(config, ctx)?;
let input = pty.input.clone();
let callback_error = Rc::new(RefCell::new(None));
let errors = callback_error.clone();
terminal.on_pty_write(move |_, data| {
if let Err(e) = input.try_send(data.to_vec()) {
*errors.borrow_mut() = Some(format!("PTY response queue: {e}"));
}
})?;
let report_size = Rc::new(RefCell::new(terminal::SizeReportSize {
rows: 24,
columns: 80,
cell_width: 8,
cell_height: 16,
}));
let size = report_size.clone();
terminal.on_size(move |_| Some(*size.borrow()))?;
Ok(Self {
terminal,
renderer,
pty,
dead: false,
error: None,
last_rect: Rect::NOTHING,
size: (80, 24, 8, 16),
report_size,
callback_error,
mouse_encoder,
mouse_event,
selection_start: None,
focused: false,
})
}
pub fn drain(&mut self, ctx: &egui::Context) {
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
for _ in 0..64 {
match self.pty.output.try_recv() {
Ok(data) => self.terminal.vt_write(&data),
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
}
}
if self.pty.exited.try_recv().is_ok() {
self.dead = true;
}
if let Ok(e) = self.pty.errors.try_recv() {
self.error = Some(e);
}
if let Some(e) = self.callback_error.borrow_mut().take() {
self.error = Some(e);
}
if !self.dead {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
if bytes.is_empty() {
return Ok(());
}
ensure!(!self.dead, "shell has exited");
self.pty
.input
.try_send(bytes)
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
self.terminal.scroll_viewport(ScrollViewport::Bottom);
Ok(())
}
pub fn paste(&mut self, text: &str) -> Result<()> {
ensure!(text.len() <= 4 * 1024 * 1024, "paste exceeds 4 MiB");
// ESC can terminate bracketed paste early. Strip it and NUL from clipboard input.
let text = text
.replace(['\x1b', '\0'], "")
.replace("\r\n", "\n")
.replace('\n', "\r");
let data = if self.terminal.mode(Mode::BRACKETED_PASTE)? {
format!("\x1b[200~{text}\x1b[201~").into_bytes()
} else {
text.into_bytes()
};
self.send(data)
}
pub fn copy(&self) -> Result<Option<String>> {
Ok(self
.terminal
.format_selection_alloc(None, FormatOptions::new().with_trim(true).with_unwrap(true))?
.map(|b| String::from_utf8_lossy(&b).into_owned()))
}
pub fn focus(&mut self, focused: bool) -> Result<()> {
if focused != self.focused {
self.focused = focused;
if self
.terminal
.mode(Mode::new(1004, terminal::ModeKind::Dec))?
{
self.send(if focused { b"\x1b[I" } else { b"\x1b[O" }.to_vec())?;
}
}
Ok(())
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
rect: Rect,
config: &Config,
active: bool,
) -> Result<bool> {
let font = FontId::monospace(config.font_size);
let cw = ui
.painter()
.layout_no_wrap("M".into(), font.clone(), egui::Color32::WHITE)
.size()
.x
.ceil()
.max(1.0);
let ch = (config.font_size * 1.3).ceil();
let cell = vec2(cw, ch);
let grid = rect.shrink(6.0);
if grid.width() < cw || grid.height() < ch {
return Ok(false);
}
let size = (
(grid.width() / cw).floor().clamp(1.0, 1000.0) as u16,
(grid.height() / ch).floor().clamp(1.0, 1000.0) as u16,
cw as u32,
ch as u32,
);
if size != self.size {
self.pty.resize(size.0, size.1, size.2, size.3)?;
self.terminal.resize(size.0, size.1, size.2, size.3)?;
self.size = size;
*self.report_size.borrow_mut() = terminal::SizeReportSize {
columns: size.0,
rows: size.1,
cell_width: size.2,
cell_height: size.3,
};
}
let response = ui.interact(grid, ui.id().with("terminal"), Sense::click_and_drag());
self.pointer(ui, &response, grid, cell)?;
self.renderer
.paint(&self.terminal, ui.painter(), grid, cell, &font, active)?;
if let Some(error) = &self.error {
ui.painter().text(
grid.left_bottom() - vec2(0.0, ch),
egui::Align2::LEFT_TOP,
error,
FontId::proportional(12.0),
egui::Color32::LIGHT_RED,
);
}
Ok(response.clicked()
|| response.drag_started()
|| (response.hovered() && ui.input(|i| i.pointer.any_pressed())))
}
fn pointer(
&mut self,
ui: &egui::Ui,
response: &egui::Response,
grid: Rect,
cell: egui::Vec2,
) -> Result<()> {
let modifiers = ui.input(|i| i.modifiers);
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
let pos = response
.interact_pointer_pos()
.or_else(|| ui.input(|i| i.pointer.hover_pos()));
let Some(pos) = pos else {
return Ok(());
};
let local = pos - grid.min;
if tracking && response.hovered() {
let events = ui.input(|i| i.events.clone());
self.mouse_encoder
.set_options_from_terminal(&self.terminal)
.set_size(mouse::EncoderSize {
screen_width: self.size.0 as u32 * self.size.2,
screen_height: self.size.1 as u32 * self.size.3,
cell_width: self.size.2,
cell_height: self.size.3,
padding_top: 0,
padding_bottom: 0,
padding_left: 0,
padding_right: 0,
})
.set_any_button_pressed(ui.input(|i| i.pointer.any_down()))
.set_track_last_cell(true);
self.mouse_event
.set_position(mouse::Position {
x: local.x,
y: local.y,
})
.set_mods(input::mods(modifiers));
let mut data = vec![];
for event in events {
match event {
egui::Event::PointerButton {
button, pressed, ..
} => {
let button = match button {
egui::PointerButton::Primary => mouse::Button::Left,
egui::PointerButton::Secondary => mouse::Button::Right,
egui::PointerButton::Middle => mouse::Button::Middle,
_ => continue,
};
self.mouse_event
.set_action(if pressed {
mouse::Action::Press
} else {
mouse::Action::Release
})
.set_button(Some(button));
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
}
egui::Event::PointerMoved(_) => {
let button = ui.input(|i| {
if i.pointer.primary_down() {
Some(mouse::Button::Left)
} else if i.pointer.secondary_down() {
Some(mouse::Button::Right)
} else {
None
}
});
self.mouse_event
.set_action(mouse::Action::Motion)
.set_button(button);
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
}
egui::Event::MouseWheel { delta, .. } => {
self.mouse_event
.set_action(mouse::Action::Press)
.set_button(Some(if delta.y > 0.0 {
mouse::Button::Four
} else {
mouse::Button::Five
}));
for _ in 0..3 {
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
}
}
_ => {}
}
}
self.send(data)?;
} else if !tracking {
let point = PointCoordinate {
x: (local.x / cell.x)
.floor()
.clamp(0.0, (self.size.0 - 1) as f32) as u16,
y: (local.y / cell.y)
.floor()
.clamp(0.0, (self.size.1 - 1) as f32) as u32,
};
if response.drag_started() {
self.selection_start = Some(point);
}
if response.clicked() {
self.terminal.set_selection(None)?;
self.selection_start = None;
}
if response.dragged()
&& let Some(start) = self.selection_start
{
let a = self.terminal.grid_ref(Point::Viewport(start))?;
let b = self.terminal.grid_ref(Point::Viewport(point))?;
self.terminal
.set_selection(Some(&Selection::new(a, b, modifiers.alt)))?;
}
if response.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() >= 1.0 {
self.terminal.scroll_viewport(ScrollViewport::Delta(
-(scroll / cell.y).round() as isize,
));
}
}
}
Ok(())
}
}
+131
View File
@@ -0,0 +1,131 @@
use crate::config::Config;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::{
io::{Read, Write},
sync::mpsc::{self, Receiver, Sender, SyncSender},
thread,
};
pub struct Pty {
pub output: Receiver<Vec<u8>>,
pub input: SyncSender<Vec<u8>>,
pub errors: Receiver<String>,
pub exited: Receiver<()>,
master: Box<dyn MasterPty + Send>,
shutdown: Sender<()>,
reaper: Option<thread::JoinHandle<()>>,
}
impl Pty {
pub fn spawn(config: &Config, ctx: &egui::Context) -> Result<Self> {
let pair = native_pty_system().openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 640,
pixel_height: 384,
})?;
let mut cmd = CommandBuilder::new(&config.shell[0]);
cmd.args(&config.shell[1..]);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("TERM_PROGRAM", "rust-term");
cmd.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION"));
if let Some(dir) = &config.working_directory {
cmd.cwd(dir);
}
let mut reader = pair.master.try_clone_reader()?;
let mut writer = pair.master.take_writer()?;
let mut child = pair
.slave
.spawn_command(cmd)
.context("Cannot spawn shell")?;
drop(pair.slave);
let (output_tx, output) = mpsc::sync_channel(64);
let (input, input_rx) = mpsc::sync_channel::<Vec<u8>>(64);
let (error_tx, errors) = mpsc::channel();
let (exit_tx, exited) = mpsc::channel();
let (shutdown, shutdown_rx) = mpsc::channel();
let read_ctx = ctx.clone();
let write_ctx = ctx.clone();
let exit_ctx = ctx.clone();
let read_errors = error_tx.clone();
thread::spawn(move || {
let mut buf = [0; 16384];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if output_tx.send(buf[..n].to_vec()).is_err() {
break;
}
read_ctx.request_repaint();
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
// Linux PTYs report EIO when the slave closes.
Err(e) if e.raw_os_error() == Some(5) => break,
Err(e) => {
let _ = read_errors.send(format!("PTY read: {e}"));
read_ctx.request_repaint();
break;
}
}
}
});
thread::spawn(move || {
while let Ok(bytes) = input_rx.recv() {
if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.flush()) {
let _ = error_tx.send(format!("PTY write: {e}"));
write_ctx.request_repaint();
break;
}
}
});
let reaper = thread::spawn(move || {
loop {
if matches!(child.try_wait(), Ok(Some(_))) {
break;
}
if !matches!(
shutdown_rx.recv_timeout(std::time::Duration::from_millis(25)),
Err(mpsc::RecvTimeoutError::Timeout)
) {
// The worker owns the unreaped child, so shutdown cannot signal a reused PID.
let _ = child.kill();
let _ = child.wait();
break;
}
}
let _ = exit_tx.send(());
exit_ctx.request_repaint();
});
Ok(Self {
output,
input,
errors,
exited,
master: pair.master,
shutdown,
reaper: Some(reaper),
})
}
pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> {
self.master.resize(PtySize {
cols,
rows,
pixel_width: (cols as u32 * cw).min(u16::MAX as u32) as u16,
pixel_height: (rows as u32 * ch).min(u16::MAX as u32) as u16,
})?;
Ok(())
}
}
impl Drop for Pty {
fn drop(&mut self) {
let _ = self.shutdown.send(());
if let Some(reaper) = self.reaper.take() {
let _ = reaper.join();
}
}
}
+303
View File
@@ -0,0 +1,303 @@
use anyhow::{Result, ensure};
use egui::{
Color32, ColorImage, FontId, Painter, Rect, Stroke, TextureHandle, TextureOptions, pos2, vec2,
};
use libghostty_vt::{
RenderState, Terminal,
alloc::{Allocator, Bytes},
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
render::{CellIterator, CursorVisualStyle, Dirty, RowIterator},
style::{RgbColor, Underline},
};
use std::{
collections::{HashMap, HashSet},
io::Cursor,
};
pub fn color(c: RgbColor) -> Color32 {
Color32::from_rgb(c.r, c.g, c.b)
}
pub struct PngDecoder;
impl DecodePng for PngDecoder {
fn decode_png<'a>(
&mut self,
alloc: &'a Allocator<'_>,
bytes: &[u8],
) -> Option<DecodedImage<'a>> {
let mut decoder = png::Decoder::new(Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
decoder.set_limits(png::Limits {
bytes: 128 * 1024 * 1024,
});
let mut reader = decoder.read_info().ok()?;
let size = reader.output_buffer_size()?;
if size > 128 * 1024 * 1024 {
return None;
}
let mut pixels = vec![0; size];
let info = reader.next_frame(&mut pixels).ok()?;
let count = (info.width as usize).checked_mul(info.height as usize)?;
let len = count.checked_mul(4)?;
if len > 128 * 1024 * 1024 {
return None;
}
let mut data = Bytes::new_with_alloc(alloc, len).ok()?;
let input = &pixels[..info.buffer_size()];
match info.color_type {
png::ColorType::Rgba => data.copy_from_slice(input),
png::ColorType::Rgb => {
for (out, src) in data.chunks_exact_mut(4).zip(input.chunks_exact(3)) {
out.copy_from_slice(&[src[0], src[1], src[2], 255]);
}
}
png::ColorType::Grayscale => {
for (out, value) in data.chunks_exact_mut(4).zip(input) {
out.copy_from_slice(&[*value, *value, *value, 255]);
}
}
png::ColorType::GrayscaleAlpha => {
for (out, src) in data.chunks_exact_mut(4).zip(input.chunks_exact(2)) {
out.copy_from_slice(&[src[0], src[0], src[0], src[1]]);
}
}
_ => return None,
}
Some(DecodedImage {
width: info.width,
height: info.height,
data,
})
}
}
pub struct Renderer {
state: RenderState<'static>,
rows: RowIterator<'static>,
cells: CellIterator<'static>,
placements: PlacementIterator<'static>,
textures: HashMap<u32, (u64, TextureHandle)>,
}
impl Renderer {
pub fn new() -> Result<Self> {
Ok(Self {
state: RenderState::new()?,
rows: RowIterator::new()?,
cells: CellIterator::new()?,
placements: PlacementIterator::new()?,
textures: HashMap::new(),
})
}
pub fn paint(
&mut self,
term: &Terminal<'static, '_>,
painter: &Painter,
rect: Rect,
cell: egui::Vec2,
font: &FontId,
active: bool,
) -> Result<()> {
let painter = painter.with_clip_rect(rect.intersect(painter.clip_rect()));
let snapshot = self.state.update(term)?;
let colors = snapshot.colors()?;
painter.rect_filled(rect, 0, color(colors.background));
let mut backgrounds = vec![];
let mut foregrounds = vec![];
let mut rows = self.rows.update(&snapshot)?;
let mut text = String::new();
let mut y = 0;
while let Some(row) = rows.next() {
let mut cells = self.cells.update(row)?;
let mut x = 0;
while let Some(c) = cells.next() {
let pos = rect.min + vec2(x as f32 * cell.x, y as f32 * cell.y);
let cell_rect = Rect::from_min_size(pos, cell);
let style = c.style()?;
let mut fg = color(c.fg_color()?.unwrap_or(colors.foreground));
let explicit_bg = c.bg_color()?;
let mut bg = color(explicit_bg.unwrap_or(colors.background));
let selected = c.is_selected()?;
if style.inverse ^ selected {
std::mem::swap(&mut fg, &mut bg);
}
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if style.faint {
fg = fg.gamma_multiply(0.6);
}
if !style.invisible && c.graphemes_len()? > 0 {
c.graphemes_utf8(&mut text)?;
// Placeholder cells are reserved by the Kitty protocol, not printable text.
if !text.starts_with('\u{10eeee}') {
let galley = painter.layout_no_wrap(text.clone(), font.clone(), fg);
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
if style.bold {
foregrounds.push(egui::Shape::galley(
pos + vec2(0.45, 0.0),
galley,
fg,
));
}
}
}
for (enabled, height) in [
(style.underline != Underline::None, cell.y - 2.0),
(style.strikethrough, cell.y * 0.55),
(style.overline, 1.0),
] {
if enabled {
foregrounds.push(egui::Shape::line_segment(
[pos + vec2(0.0, height), pos + vec2(cell.x, height)],
Stroke::new(1.0, fg),
));
}
}
x += 1;
}
row.set_dirty(false)?;
y += 1;
}
// Separate passes preserve Kitty's three z-order bands exactly.
let mut seen = HashSet::new();
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
if layer == Layer::BelowText {
painter.extend(std::mem::take(&mut backgrounds));
}
if layer == Layer::AboveText {
painter.extend(std::mem::take(&mut foregrounds));
}
paint_images(
term,
&painter,
rect,
cell,
layer,
&mut self.placements,
&mut self.textures,
&mut seen,
)?;
}
self.textures.retain(|id, _| seen.contains(id));
if snapshot.cursor_visible()?
&& let Some(cursor) = snapshot.cursor_viewport()?
{
let mut cursor_rect = Rect::from_min_size(
rect.min + vec2(cursor.x as f32 * cell.x, cursor.y as f32 * cell.y),
cell,
);
let c = color(colors.cursor.unwrap_or(colors.foreground));
match snapshot.cursor_visual_style()? {
CursorVisualStyle::Bar if active => {
cursor_rect.max.x = cursor_rect.min.x + 2.0;
painter.rect_filled(cursor_rect, 0, c);
}
CursorVisualStyle::Underline if active => {
cursor_rect.min.y = cursor_rect.max.y - 2.0;
painter.rect_filled(cursor_rect, 0, c);
}
_ => {
painter.rect_stroke(
cursor_rect,
0,
Stroke::new(if active { 1.5 } else { 0.75 }, c),
egui::StrokeKind::Inside,
);
}
}
}
snapshot.set_dirty(Dirty::Clean)?;
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
fn paint_images(
term: &Terminal<'_, '_>,
painter: &Painter,
rect: Rect,
cell: egui::Vec2,
layer: Layer,
iterator: &mut PlacementIterator<'_>,
textures: &mut HashMap<u32, (u64, TextureHandle)>,
seen: &mut HashSet<u32>,
) -> Result<()> {
let graphics = term.kitty_graphics()?;
let mut placements = iterator.update(&graphics)?;
placements.set_layer(layer)?;
let mut images = vec![];
while let Some(p) = placements.next() {
let id = p.image_id()?;
let Some(image) = graphics.image(id) else {
continue;
};
let info = p.placement_render_info(&image, term)?;
if !info.viewport_visible || info.pixel_width == 0 || info.pixel_height == 0 {
continue;
}
let size = [image.width()? as usize, image.height()? as usize];
if size.contains(&0) {
continue;
}
ensure!(
size[0] <= painter.ctx().input(|i| i.max_texture_side)
&& size[1] <= painter.ctx().input(|i| i.max_texture_side),
"Kitty image exceeds GPU texture size limit"
);
seen.insert(id);
let generation = image.generation()?;
if textures.get(&id).is_none_or(|(g, _)| *g != generation) {
let data = image.data()?;
let pixels = match image.format()? {
ImageFormat::Rgba => ColorImage::from_rgba_unmultiplied(size, data),
ImageFormat::Rgb => ColorImage::from_rgb(size, data),
_ => continue,
};
textures.insert(
id,
(
generation,
painter.ctx().load_texture(
format!("kitty-{id}"),
pixels,
TextureOptions::LINEAR,
),
),
);
}
let scale = vec2(1.0, 1.0);
let dest = Rect::from_min_size(
rect.min
+ vec2(
info.viewport_col as f32 * cell.x,
info.viewport_row as f32 * cell.y,
)
+ vec2(
p.x_offset()? as f32 * scale.x,
p.y_offset()? as f32 * scale.y,
),
vec2(
info.pixel_width as f32 * scale.x,
info.pixel_height as f32 * scale.y,
),
);
let uv = Rect::from_min_max(
pos2(
info.source_x as f32 / size[0] as f32,
info.source_y as f32 / size[1] as f32,
),
pos2(
(info.source_x + info.source_width) as f32 / size[0] as f32,
(info.source_y + info.source_height) as f32 / size[1] as f32,
),
);
images.push((p.z()?, id, p.placement_id()?, dest, uv));
}
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
for (_, id, _, dest, uv) in images {
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE);
}
Ok(())
}
+207
View File
@@ -0,0 +1,207 @@
use crate::{config::Config, input, pane, render};
use base64::Engine;
use egui::{Event, Key, Modifiers};
use libghostty_vt::{
fmt,
kitty::graphics::{self, ImageFormat, PlacementIterator},
};
use std::{
cell::RefCell,
rc::Rc,
time::{Duration, Instant},
};
fn plain(term: &libghostty_vt::Terminal<'_, '_>) -> String {
let mut formatter =
fmt::Formatter::new(term, fmt::FormatterOptions::new().with_trim(true)).unwrap();
let bytes = formatter.format_alloc(None).unwrap();
String::from_utf8_lossy(&bytes).into_owned()
}
fn key(key: Key, modifiers: Modifiers) -> Event {
Event::Key {
key,
physical_key: Some(key),
pressed: true,
repeat: false,
modifiers,
}
}
#[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default()).unwrap();
term.vt_write("hello \x1b[31mred\x1b[0m \u{754c}e\u{301}\r\n".as_bytes());
assert!(plain(&term).contains("hello red \u{754c}e\u{301}"));
let events = [
key(Key::A, Modifiers::NONE),
Event::Text("a".into()),
key(Key::B, Modifiers::NONE),
Event::Text("b".into()),
];
assert_eq!(input::encode(&term, &events).unwrap(), b"ab");
assert_eq!(
input::encode(&term, &[key(Key::C, Modifiers::CTRL)]).unwrap(),
b"\x03"
);
assert_eq!(
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
b"\x1b[A"
);
term.vt_write(b"\x1b[?1h");
assert_eq!(
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
b"\x1bOA"
);
term.resize(12, 6, 9, 20).unwrap();
assert!(plain(&term).contains("hello red"));
}
#[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default()).unwrap();
let response = Rc::new(RefCell::new(vec![]));
let writer = response.clone();
term.on_pty_write(move |_, bytes| writer.borrow_mut().extend_from_slice(bytes))
.unwrap();
term.vt_write(b"hello\x1b[6n");
assert_eq!(*response.borrow(), b"\x1b[1;6R");
term.vt_write(b"\x1b[?1049hother screen\x1b[?1049l");
assert!(plain(&term).contains("hello"));
assert!(!plain(&term).contains("other screen"));
}
#[test]
fn kitty_images_decode_render_reuse_and_delete() {
graphics::set_png_decoder(Some(Box::new(render::PngDecoder))).unwrap();
let mut term = pane::terminal(&Config::default()).unwrap();
let replies = Rc::new(RefCell::new(vec![]));
let output = replies.clone();
term.on_pty_write(move |_, data| output.borrow_mut().extend_from_slice(data))
.unwrap();
// Split delivery across VT writes, including the control string terminator.
term.vt_write(b"\x1b_Ga=T,f=24,s=2,v=1,i=7,c=4,r=2;//8A");
term.vt_write(b"AP8A\x1b\\");
assert!(String::from_utf8_lossy(&replies.borrow()).contains("OK"));
{
let graphics = term.kitty_graphics().unwrap();
let image = graphics.image(7).expect("RGB image stored");
assert_eq!(image.format().unwrap(), ImageFormat::Rgb);
assert_eq!(image.data().unwrap(), [255, 255, 0, 0, 255, 0]);
let mut it = PlacementIterator::new().unwrap();
let mut ps = it.update(&graphics).unwrap();
let info = ps
.next()
.unwrap()
.placement_render_info(&image, &term)
.unwrap();
assert!(info.viewport_visible);
assert_eq!((info.pixel_width, info.pixel_height), (32, 32));
}
let mut png = vec![];
{
let mut encoder = png::Encoder::new(&mut png, 2, 1);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[255, 0, 0, 0, 255, 0])
.unwrap();
}
let payload = base64::engine::general_purpose::STANDARD.encode(png);
let split = payload.len() / 8 * 4;
term.vt_write(
format!(
"\x1b_Ga=T,f=100,i=8,c=2,r=1,m=1;{}\x1b\\",
&payload[..split]
)
.as_bytes(),
);
term.vt_write(format!("\x1b_Gm=0;{}\x1b\\", &payload[split..]).as_bytes());
{
let graphics = term.kitty_graphics().unwrap();
let image = graphics.image(8).expect("PNG image stored");
assert_eq!(image.format().unwrap(), ImageFormat::Rgba);
assert_eq!(image.data().unwrap(), [255, 0, 0, 255, 0, 255, 0, 255]);
}
let ctx = egui::Context::default();
let mut renderer = render::Renderer::new().unwrap();
let rect = egui::Rect::from_min_size(egui::pos2(50.0, 60.0), egui::vec2(400.0, 240.0));
let mut paint = || {
ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx
.layer_painter(egui::LayerId::background())
.with_clip_rect(rect);
renderer
.paint(
&term,
&painter,
rect,
egui::vec2(8.0, 16.0),
&egui::FontId::monospace(12.0),
true,
)
.unwrap();
})
};
let first = paint();
assert_eq!(
first
.textures_delta
.set
.iter()
.filter(|(id, _)| matches!(id,egui::TextureId::Managed(n) if *n>0))
.count(),
2
);
assert!(first.shapes.iter().all(|s| rect.contains_rect(s.clip_rect)));
let second = paint();
assert!(
second.textures_delta.set.is_empty(),
"unchanged images must reuse GPU textures"
);
term.vt_write(b"\x1b_Ga=d,d=I,i=7\x1b\\");
assert!(term.kitty_graphics().unwrap().image(7).is_none());
assert!(term.kitty_graphics().unwrap().image(8).is_some());
}
#[test]
fn pty_executes_input_and_bracketed_paste_is_sanitized() {
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let ctx = egui::Context::default();
let mut pane = pane::Pane::new(&config, &ctx).unwrap();
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
pane.drain(&ctx);
if plain(&pane.terminal).contains("pty-success") {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(plain(&pane.terminal).contains("pty-success"));
pane.pty.resize(91, 31, 9, 20).unwrap();
pane.send(b"stty size\r".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
pane.drain(&ctx);
if plain(&pane.terminal).contains("31 91") {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(plain(&pane.terminal).contains("31 91"));
pane.send(b"exit\r".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !pane.dead && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(pane.dead);
assert!(pane.send(b"after exit".to_vec()).is_err());
}