Files
slerm/src/pane.rs
T

462 lines
16 KiB
Rust

use crate::{
config::Config,
input,
pty::Pty,
render::{Grid, Renderer, cell_size},
theme::{self, Theme},
};
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,
path::{Path, PathBuf},
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,
}
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
/// handed to a new shell, so a remote host (an SSH session, say) is rejected here
/// and the caller falls back to the local process.
fn local_pwd(pwd: &str) -> Option<PathBuf> {
if pwd.is_empty() {
return None;
}
// Some shells emit a bare path instead of a URL.
let Some(rest) = pwd.strip_prefix("file://") else {
return pwd.starts_with('/').then(|| PathBuf::from(pwd));
};
let (host, path) = rest.split_once('/')?;
// A shell may report either the short name or the FQDN, so compare the leading
// label of each.
let short = |name: &str| name.split('.').next().unwrap_or(name).to_ascii_lowercase();
let local = host.is_empty()
|| host.eq_ignore_ascii_case("localhost")
|| hostname().is_some_and(|name| short(&name) == short(host));
local.then(|| PathBuf::from(percent_decode(&format!("/{path}"))))
}
fn hostname() -> Option<String> {
unsafe extern "C" {
fn gethostname(name: *mut std::ffi::c_char, len: usize) -> std::ffi::c_int;
}
let mut buf = [0u8; 256];
// SAFETY: at most `len` bytes are written, leaving the final byte of `buf` as
// the terminator even when the name is truncated.
if unsafe { gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) } != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0)?;
String::from_utf8(buf[..end].to_vec()).ok()
}
fn percent_decode(path: &str) -> String {
let mut out = Vec::with_capacity(path.len());
let mut bytes = path.bytes();
while let Some(byte) = bytes.next() {
let decoded = (byte == b'%')
.then(|| {
let hex = [bytes.clone().next()?, bytes.clone().nth(1)?];
let hex = std::str::from_utf8(&hex).ok()?;
u8::from_str_radix(hex, 16).ok()
})
.flatten();
match decoded {
Some(value) => {
bytes.nth(1);
out.push(value);
}
None => out.push(byte),
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn terminal(config: &Config, theme: &Theme) -> 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, theme)?;
term.set_default_cursor_style(Some(terminal::CursorStyle::BlockHollow))?;
term.vt_write(b"\x1b[0 q");
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
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(),
})
})?;
let scheme = if theme.dark {
terminal::ColorScheme::Dark
} else {
terminal::ColorScheme::Light
};
term.on_color_scheme(move |_| Some(scheme))?;
Ok(term)
}
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config, theme: &Theme) -> Result<()> {
let palette = theme.palette(term.default_color_palette()?);
term.set_default_fg_color(Some(theme::rgb_color(theme.foreground)))?
.set_default_bg_color(Some(theme::rgb_color(theme.background)))?
.set_default_cursor_color(Some(theme::rgb_color(theme.cursor)))?
.set_default_color_palette(Some(palette))?
.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,
theme: &Theme,
ctx: &egui::Context,
cwd: Option<&Path>,
) -> Result<Self> {
let mut terminal = terminal(config, theme)?;
let renderer = Renderer::new()?;
let mouse_encoder = mouse::Encoder::new()?;
let mouse_event = mouse::Event::new()?;
let pty = Pty::spawn(config, ctx, cwd)?;
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.
let mut drained = 0;
for _ in 0..64 {
match self.pty.output.try_recv() {
Ok(data) => {
self.terminal.vt_write(&data);
drained += 1;
}
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);
}
// Workers wake the UI on output/exit/errors. Only poll again if we hit
// the read budget and may have left queued output behind.
if drained == 64 {
ctx.request_repaint();
}
}
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()))
}
/// Where a pane spawned from this one should start: the directory the shell
/// reports through OSC 7, or the foreground process group's own directory.
pub fn cwd(&self) -> Option<PathBuf> {
self.terminal
.pwd()
.ok()
.and_then(local_pwd)
.or_else(|| self.pty.cwd())
.filter(|path| path.is_dir())
}
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,
theme: &Theme,
active: bool,
) -> Result<bool> {
let font = FontId::monospace(config.font_size);
let cell = cell_size(ui.painter(), &font);
let (cw, ch) = (cell.x, cell.y);
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 {
rect: grid,
cell,
font: &font,
theme,
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),
theme::color(theme.ansi[9]),
);
}
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(())
}
}