Compare commits

..
4 Commits
Author SHA1 Message Date
Gregor Lohaus 8d49a993e9 better floating nac 2026-09-14 13:35:38 +02:00
Gregor Lohaus c21472b2f2 scrolling 2026-09-10 10:24:16 +02:00
Gregor Lohaus ca30e9a0ee explicit scrollback editor command 2026-09-09 12:49:47 +02:00
Gregor Lohaus c5e4e5fb85 smaller cursor corner radius 2026-09-09 11:41:06 +02:00
12 changed files with 768 additions and 92 deletions
Generated
+1
View File
@@ -2319,6 +2319,7 @@ dependencies = [
"png", "png",
"portable-pty", "portable-pty",
"serde", "serde",
"shell-words",
"toml", "toml",
"wgpu", "wgpu",
] ]
+1
View File
@@ -16,6 +16,7 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
png = "0.18" png = "0.18"
portable-pty = "0.9" portable-pty = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
shell-words = "1"
toml = "0.9" toml = "0.9"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
+7
View File
@@ -18,6 +18,10 @@
# regular-only fonts retain synthetic bold/italic emphasis. # regular-only fonts retain synthetic bold/italic emphasis.
font_size = 16 font_size = 16
scrollback = 10000 scrollback = 10000
# Command for opening scrollback; omit to use $EDITOR, or vi if unset.
# Supports quoted arguments; the scrollback file path is appended automatically.
# Runs directly without shell expansion. The editor should stay in the foreground.
# scrollback_editor = "nvim -R"
window_width = 1100 window_width = 1100
window_height = 720 window_height = 720
@@ -60,6 +64,7 @@ floating_max_height = 640
"Alt+F" = "toggle_floats" "Alt+F" = "toggle_floats"
"Alt+M" = "new_pane" "Alt+M" = "new_pane"
"Alt+H" = "focus_left" "Alt+H" = "focus_left"
# With floating panes shown, J/K cycle forward/backward through them.
"Alt+J" = "focus_down" "Alt+J" = "focus_down"
"Alt+K" = "focus_up" "Alt+K" = "focus_up"
"Alt+L" = "focus_right" "Alt+L" = "focus_right"
@@ -67,6 +72,8 @@ floating_max_height = 640
"Alt+Shift+H" = "previous_tab" "Alt+Shift+H" = "previous_tab"
"Alt+Shift+L" = "next_tab" "Alt+Shift+L" = "next_tab"
"Alt+X" = "close_pane" "Alt+X" = "close_pane"
# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit.
"Alt+S" = "open_scrollback"
"Ctrl+Shift+Z" = "zoom_pane" "Ctrl+Shift+Z" = "zoom_pane"
"Ctrl+Shift+R" = "reload_config" "Ctrl+Shift+R" = "reload_config"
"Ctrl+Shift+C" = "copy" "Ctrl+Shift+C" = "copy"
+1
View File
@@ -207,6 +207,7 @@
checkFlags = [ checkFlags = [
"--test-threads=1" "--test-threads=1"
"--skip=app::tests::" "--skip=app::tests::"
"--skip=pane::scrollback_tests::"
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized" "--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from" "--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine" "--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
+10 -2
View File
@@ -235,6 +235,13 @@ impl App {
} }
} }
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
Action::OpenScrollback => {
self.panes.get_mut(&active).unwrap().open_scrollback(
&self.config,
&self.theme,
ctx,
)?;
}
} }
Ok(()) Ok(())
} }
@@ -425,6 +432,7 @@ impl eframe::App for App {
("Zoom / restore", Action::ZoomPane), ("Zoom / restore", Action::ZoomPane),
("Next pane", Action::NextPane), ("Next pane", Action::NextPane),
("Close pane", Action::ClosePane), ("Close pane", Action::ClosePane),
("Open scrollback in editor", Action::OpenScrollback),
] { ] {
if ui.button(label).clicked() { if ui.button(label).clicked() {
action = Some(a); action = Some(a);
@@ -571,7 +579,7 @@ fn grab_keys(ctx: &egui::Context) {
} }
/// Corner rounding shared by a pane's border and its title bar. /// Corner rounding shared by a pane's border and its title bar.
const CORNER: u8 = 5; const CORNER: u8 = theme::CORNER_RADIUS;
fn show_pane( fn show_pane(
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -638,7 +646,7 @@ fn show_pane(
rect, rect,
CORNER, CORNER,
Stroke::new( Stroke::new(
1.0_f32, theme::BORDER_WIDTH,
if active { if active {
accent accent
} else { } else {
+17
View File
@@ -21,6 +21,7 @@ pub struct Config {
pub font_bold_italic_path: Option<PathBuf>, pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>, pub font_fallbacks: Vec<String>,
pub scrollback: usize, pub scrollback: usize,
pub scrollback_editor: Option<String>,
pub window_width: f32, pub window_width: f32,
pub window_height: f32, pub window_height: f32,
pub theme: String, pub theme: String,
@@ -77,6 +78,7 @@ pub enum Action {
ReloadConfig, ReloadConfig,
Copy, Copy,
Paste, Paste,
OpenScrollback,
} }
impl Default for Kitty { impl Default for Kitty {
@@ -115,6 +117,7 @@ impl Default for Config {
font_bold_italic_path: None, font_bold_italic_path: None,
font_fallbacks: Vec::new(), font_fallbacks: Vec::new(),
scrollback: 10000, scrollback: 10000,
scrollback_editor: None,
window_width: 1100.0, window_width: 1100.0,
window_height: 720.0, window_height: 720.0,
theme: theme::DEFAULT_NAME.into(), theme: theme::DEFAULT_NAME.into(),
@@ -135,6 +138,7 @@ impl Default for Config {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
("Ctrl+Shift+Z", Action::ZoomPane), ("Ctrl+Shift+Z", Action::ZoomPane),
("Ctrl+Shift+R", Action::ReloadConfig), ("Ctrl+Shift+R", Action::ReloadConfig),
("Ctrl+Shift+C", Action::Copy), ("Ctrl+Shift+C", Action::Copy),
@@ -186,6 +190,14 @@ impl Config {
!config.shell.is_empty() && !config.shell[0].is_empty(), !config.shell.is_empty() && !config.shell[0].is_empty(),
"shell must contain an executable" "shell must contain an executable"
); );
if let Some(editor) = &config.scrollback_editor {
let command =
shell_words::split(editor).context("Invalid scrollback_editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"scrollback_editor must contain an executable"
);
}
ensure!( ensure!(
(8.0..=48.0).contains(&config.font_size), (8.0..=48.0).contains(&config.font_size),
"font_size must be 8..48" "font_size must be 8..48"
@@ -307,6 +319,10 @@ mod tests {
"font_fallbacks = [' ']", "font_fallbacks = [' ']",
"font_family = 'Test'\nfont_path = '/font.ttf'", "font_family = 'Test'\nfont_path = '/font.ttf'",
"shell = []", "shell = []",
"scrollback_editor = ''",
"scrollback_editor = ' '",
"scrollback_editor = \"''\"",
"scrollback_editor = \"'\"",
"background = '#xx0000'", "background = '#xx0000'",
"font_sze = 16", "font_sze = 16",
"[keys]\n'Hyper+Q' = 'close_pane'", "[keys]\n'Hyper+Q' = 'close_pane'",
@@ -330,6 +346,7 @@ mod tests {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
] { ] {
assert_eq!(config.keys.get(binding), Some(&action)); assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt); assert!(shortcut(binding).unwrap().modifiers.alt);
+21 -2
View File
@@ -1,6 +1,25 @@
use anyhow::Result; use anyhow::Result;
use libghostty_vt::{Terminal, key}; use libghostty_vt::{Terminal, key};
/// Avoid libghostty-vt 0.2.1's encode_to_vec capacity calculation, which can
/// reserve too little when the destination already has some spare capacity.
pub fn append_encoded(
out: &mut Vec<u8>,
mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result<usize>,
) -> Result<()> {
let mut buffer = [0; 128];
match encode(&mut buffer) {
Ok(written) => out.extend_from_slice(&buffer[..written]),
Err(libghostty_vt::Error::OutOfSpace { required }) => {
let mut buffer = vec![0; required];
let written = encode(&mut buffer)?;
out.extend_from_slice(&buffer[..written]);
}
Err(error) => return Err(error.into()),
}
Ok(())
}
pub fn mods(m: egui::Modifiers) -> key::Mods { pub fn mods(m: egui::Modifiers) -> key::Mods {
let mut out = key::Mods::empty(); let mut out = key::Mods::empty();
if m.ctrl { if m.ctrl {
@@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
}) })
.set_unshifted_codepoint(ucp) .set_unshifted_codepoint(ucp)
.set_utf8(text); .set_utf8(text);
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Text(text) if !used_text.contains(&index) => { egui::Event::Text(text) if !used_text.contains(&index) => {
event event
@@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
.set_consumed_mods(key::Mods::empty()) .set_consumed_mods(key::Mods::empty())
.set_unshifted_codepoint('\0') .set_unshifted_codepoint('\0')
.set_utf8(Some(text.as_str())); .set_utf8(Some(text.as_str()));
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Ime(egui::ImeEvent::Commit(text)) => { egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
out.extend_from_slice(text.as_bytes()) out.extend_from_slice(text.as_bytes())
+93 -5
View File
@@ -152,6 +152,7 @@ pub struct Tab {
pub floats: Vec<PaneId>, pub floats: Vec<PaneId>,
pub active: PaneId, pub active: PaneId,
pub floats_visible: bool, pub floats_visible: bool,
last_active_float: Option<PaneId>,
pub zoomed: bool, pub zoomed: bool,
} }
@@ -163,6 +164,7 @@ impl Tab {
floats: vec![], floats: vec![],
active: pane, active: pane,
floats_visible: false, floats_visible: false,
last_active_float: None,
zoomed: false, zoomed: false,
} }
} }
@@ -177,6 +179,9 @@ impl Tab {
pub fn detach(&mut self, id: PaneId) { pub fn detach(&mut self, id: PaneId) {
self.root = self.root.take().and_then(|r| r.remove(id)); self.root = self.root.take().and_then(|r| r.remove(id));
self.floats.retain(|p| *p != id); self.floats.retain(|p| *p != id);
if self.last_active_float == Some(id) {
self.last_active_float = None;
}
if self.floats.is_empty() { if self.floats.is_empty() {
self.floats_visible = false; self.floats_visible = false;
} }
@@ -201,17 +206,29 @@ impl Tab {
self.floats_visible = !self.floats_visible; self.floats_visible = !self.floats_visible;
self.zoomed = false; self.zoomed = false;
if self.floats_visible { if self.floats_visible {
if let Some(id) = self.floats.last() { if let Some(id) = self
self.active = *id; .last_active_float
} .filter(|id| self.floats.contains(id))
} else if self.floats.contains(&self.active) .or_else(|| self.floats.last().copied())
&& let Some(root) = &self.root
{ {
self.active = id;
}
} else if self.floats.contains(&self.active) {
self.last_active_float = Some(self.active);
if let Some(root) = &self.root {
self.active = root.first(); self.active = root.first();
} }
} }
}
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) { pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
if self.floats_visible
&& !self.floats.is_empty()
&& matches!(direction, Direction::Down | Direction::Up)
{
self.cycle(matches!(direction, Direction::Up));
return;
}
let Some((_, source)) = rects let Some((_, source)) = rects
.iter() .iter()
.find(|(id, _)| *id == self.active && self.is_visible(*id)) .find(|(id, _)| *id == self.active && self.is_visible(*id))
@@ -322,6 +339,51 @@ mod tests {
assert!(tab.is_visible(2)); assert!(tab.is_visible(2));
} }
#[test]
fn showing_floats_restores_the_pane_active_when_hidden() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4, 5];
tab.toggle_floats();
assert_eq!(tab.active, 5);
tab.cycle(true);
assert_eq!(tab.active, 4);
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 4);
tab.cycle(true);
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
tab.root = None;
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
}
#[test]
fn showing_floats_falls_back_when_remembered_pane_is_detached() {
for dock in [false, true] {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3];
tab.floats_visible = true;
tab.active = 2;
tab.toggle_floats();
tab.detach(2);
if dock {
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
}
tab.toggle_floats();
assert_eq!(tab.active, 3);
}
}
#[test] #[test]
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() { fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
let mut tab = Tab::new(1, 1); let mut tab = Tab::new(1, 1);
@@ -375,6 +437,30 @@ mod tests {
); );
} }
#[test]
fn up_and_down_cycle_visible_floats_without_geometry() {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3, 4];
tab.floats_visible = true;
for (direction, expected) in [
(Direction::Down, 2),
(Direction::Down, 3),
(Direction::Down, 4),
(Direction::Down, 2),
(Direction::Up, 4),
(Direction::Up, 3),
(Direction::Up, 2),
] {
tab.focus_direction(direction, &[]);
assert_eq!(tab.active, expected);
}
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.toggle_floats();
assert_eq!(tab.active, 2);
}
#[test] #[test]
fn visible_floats_constrain_directional_and_cyclic_navigation() { fn visible_floats_constrain_directional_and_cyclic_navigation() {
let mut tab = Tab::new(1, 1); let mut tab = Tab::new(1, 1);
@@ -395,6 +481,8 @@ mod tests {
(Direction::Right, 4), (Direction::Right, 4),
(Direction::Down, 5), (Direction::Down, 5),
(Direction::Left, 6), (Direction::Left, 6),
(Direction::Up, 5),
(Direction::Up, 4),
(Direction::Up, 3), (Direction::Up, 3),
] { ] {
tab.focus_direction(direction, &rects); tab.focus_direction(direction, &rects);
+277 -14
View File
@@ -5,7 +5,7 @@ use crate::{
render::{Grid, Renderer, cell_size}, render::{Grid, Renderer, cell_size},
theme::{self, Theme}, theme::{self, Theme},
}; };
use anyhow::{Result, ensure}; use anyhow::{Context, Result, ensure};
use egui::{FontId, Rect, Sense, vec2}; use egui::{FontId, Rect, Sense, vec2};
use libghostty_vt::{ use libghostty_vt::{
Terminal, TerminalOptions, mouse, Terminal, TerminalOptions, mouse,
@@ -31,8 +31,193 @@ pub struct Pane {
callback_error: Rc<RefCell<Option<String>>>, callback_error: Rc<RefCell<Option<String>>>,
mouse_encoder: mouse::Encoder<'static>, mouse_encoder: mouse::Encoder<'static>,
mouse_event: mouse::Event<'static>, mouse_event: mouse::Event<'static>,
scrollback_scroll: ScrollAccumulator,
mouse_scroll: ScrollAccumulator,
selection_start: Option<PointCoordinate>, selection_start: Option<PointCoordinate>,
focused: bool, focused: bool,
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
}
struct ScrollbackFile(PathBuf);
#[derive(Default)]
struct ScrollAccumulator(f32);
impl ScrollAccumulator {
fn rows(&mut self, delta: f32) -> isize {
self.0 += delta;
let rows = self.0.trunc();
self.0 -= rows;
rows as isize
}
fn wheel(&mut self, unit: egui::MouseWheelUnit, delta: f32, cell: f32, page: u16) -> isize {
self.rows(match unit {
egui::MouseWheelUnit::Point => delta / cell,
egui::MouseWheelUnit::Line => delta * 3.0,
egui::MouseWheelUnit::Page => delta * f32::from(page),
})
}
}
impl ScrollbackFile {
fn new(bytes: &[u8]) -> Result<Self> {
use std::{
fs::OpenOptions,
io::Write,
os::unix::fs::OpenOptionsExt,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
loop {
let path = std::env::temp_dir().join(format!(
"slerm-scrollback-{}-{}.txt",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
let temporary = Self(path);
file.write_all(bytes)?;
return Ok(temporary);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
#[test]
fn precise_scrolling_preserves_distance_across_frames() {
let mut scroll = ScrollAccumulator::default();
// Sub-pixel events from a slow gesture must eventually move a row.
let rows: isize = (0..128).map(|_| scroll.rows(0.25 / 16.0)).sum();
assert_eq!(rows, 2);
assert_eq!(scroll.0, 0.0);
let mut batched = ScrollAccumulator::default();
assert_eq!(batched.rows(32.0 / 16.0), rows);
assert_eq!(scroll.rows(1.75), 1);
assert_eq!(scroll.rows(0.25), 1);
assert_eq!(scroll.rows(-0.75), 0);
assert_eq!(scroll.rows(-0.25), -1);
assert_eq!(scroll.rows(0.75), 0);
assert_eq!(scroll.rows(-0.75), 0);
}
#[test]
fn mouse_tracking_respects_precise_distance_and_wheel_units() {
use egui::MouseWheelUnit::{Line, Page, Point};
let mut scroll = ScrollAccumulator::default();
assert_eq!(scroll.wheel(Point, 0.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 4.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 12.0, 16.0, 24), 1);
assert_eq!(scroll.wheel(Point, -32.0, 16.0, 24), -2);
assert_eq!(scroll.wheel(Line, 1.0, 16.0, 24), 3);
assert_eq!(scroll.wheel(Page, -1.0, 16.0, 24), -24);
}
}
#[cfg(test)]
mod scrollback_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn editor_receives_history_and_restores_original_session_on_exit() {
let ctx = egui::Context::default();
let config = Config {
shell: vec![
"/bin/sh".into(),
"-c".into(),
"read answer; printf ORIGINAL_ALIVE; read answer".into(),
],
scrollback_editor: Config::parse(
"scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"",
)
.unwrap()
.scrollback_editor,
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.terminal.resize(12, 2, 8, 16).unwrap();
pane.terminal
.vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast");
pane.open_scrollback(&config, &theme::DEFAULT, &ctx)
.unwrap();
let (original, file) = pane.saved_session.as_ref().unwrap();
assert!(!original.dead);
let path = file.0.clone();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
contents.contains("history\nred\nlong line that wraps\nlast"),
"{contents:?}"
);
assert!(!contents.contains('\x1b'));
pane.send(b"done\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while pane.saved_session.is_some() && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
assert!(!path.exists());
pane.send(b"continue\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
pane.drain(&ctx);
let text = libghostty_vt::fmt::Formatter::new(
&pane.terminal,
libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true),
)
.unwrap()
.format_alloc(None)
.unwrap();
if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") {
break;
}
assert!(
Instant::now() < deadline,
"original process did not resume input"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn invalid_editor_leaves_the_original_pane_intact() {
let ctx = egui::Context::default();
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
for editor in ["", "'", "/no/such/slerm-editor"] {
assert!(
pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor)
.is_err()
);
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
}
}
}
impl Drop for ScrollbackFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
} }
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be /// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
@@ -100,6 +285,8 @@ pub fn terminal(config: &Config, theme: &Theme) -> Result<Terminal<'static, 'sta
})?; })?;
term.resize(80, 24, 8, 16)?; term.resize(80, 24, 8, 16)?;
apply_config(&mut term, config, theme)?; 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_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
term.on_device_attributes(|_| { term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes { Some(terminal::DeviceAttributes {
@@ -177,12 +364,18 @@ impl Pane {
callback_error, callback_error,
mouse_encoder, mouse_encoder,
mouse_event, mouse_event,
scrollback_scroll: ScrollAccumulator::default(),
mouse_scroll: ScrollAccumulator::default(),
selection_start: None, selection_start: None,
focused: false, focused: false,
saved_session: None,
}) })
} }
pub fn drain(&mut self, ctx: &egui::Context) { pub fn drain(&mut self, ctx: &egui::Context) {
if let Some((session, _)) = &mut self.saved_session {
session.drain(ctx);
}
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others. // Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
let mut drained = 0; let mut drained = 0;
for _ in 0..64 { for _ in 0..64 {
@@ -208,6 +401,66 @@ impl Pane {
if drained == 64 { if drained == 64 {
ctx.request_repaint(); ctx.request_repaint();
} }
if self.dead
&& let Some((session, _file)) = self.saved_session.take()
{
*self = *session;
ctx.request_repaint();
}
}
pub fn open_scrollback(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
) -> Result<()> {
if self.saved_session.is_some() {
return Ok(());
}
let editor = config
.scrollback_editor
.clone()
.unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()));
self.open_scrollback_with_editor(config, theme, ctx, &editor)
}
fn open_scrollback_with_editor(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
editor: &str,
) -> Result<()> {
use libghostty_vt::fmt::{Formatter, FormatterOptions};
let mut command =
shell_words::split(editor).context("Invalid scrollback editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"Scrollback editor command is empty"
);
self.drain(ctx);
let bytes = Formatter::new(
&self.terminal,
FormatterOptions::new().with_trim(true).with_unwrap(true),
)?
.format_alloc(None)?;
let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?;
command.push(
file.0
.to_str()
.context("Scrollback path is not valid UTF-8")?
.into(),
);
let mut editor_config = config.clone();
editor_config.shell = command;
let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref())
.context("Cannot open scrollback editor")?;
editor_pane.last_rect = self.last_rect;
let session = std::mem::replace(self, editor_pane);
self.saved_session = Some((Box::new(session), file));
ctx.request_repaint();
Ok(())
} }
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> { pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
@@ -220,6 +473,7 @@ impl Pane {
.try_send(bytes) .try_send(bytes)
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?; .map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
self.terminal.scroll_viewport(ScrollViewport::Bottom); self.terminal.scroll_viewport(ScrollViewport::Bottom);
self.scrollback_scroll = ScrollAccumulator::default();
Ok(()) Ok(())
} }
@@ -337,6 +591,12 @@ impl Pane {
) -> Result<()> { ) -> Result<()> {
let modifiers = ui.input(|i| i.modifiers); let modifiers = ui.input(|i| i.modifiers);
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift; let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
if !response.hovered() || tracking {
self.scrollback_scroll = ScrollAccumulator::default();
}
if !response.hovered() || !tracking {
self.mouse_scroll = ScrollAccumulator::default();
}
let pos = response let pos = response
.interact_pointer_pos() .interact_pointer_pos()
.or_else(|| ui.input(|i| i.pointer.hover_pos())); .or_else(|| ui.input(|i| i.pointer.hover_pos()));
@@ -385,8 +645,9 @@ impl Pane {
mouse::Action::Release mouse::Action::Release
}) })
.set_button(Some(button)); .set_button(Some(button));
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
egui::Event::PointerMoved(_) => { egui::Event::PointerMoved(_) => {
let button = ui.input(|i| { let button = ui.input(|i| {
@@ -401,20 +662,23 @@ impl Pane {
self.mouse_event self.mouse_event
.set_action(mouse::Action::Motion) .set_action(mouse::Action::Motion)
.set_button(button); .set_button(button);
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
egui::Event::MouseWheel { delta, .. } => { egui::Event::MouseWheel { unit, delta, .. } => {
let rows = self.mouse_scroll.wheel(unit, delta.y, cell.y, self.size.1);
self.mouse_event self.mouse_event
.set_action(mouse::Action::Press) .set_action(mouse::Action::Press)
.set_button(Some(if delta.y > 0.0 { .set_button(Some(if rows > 0 {
mouse::Button::Four mouse::Button::Four
} else { } else {
mouse::Button::Five mouse::Button::Five
})); }));
for _ in 0..3 { for _ in 0..rows.unsigned_abs() {
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
} }
_ => {} _ => {}
@@ -447,10 +711,9 @@ impl Pane {
} }
if response.hovered() { if response.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y); let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() >= 1.0 { let rows = self.scrollback_scroll.rows(scroll / cell.y);
self.terminal.scroll_viewport(ScrollViewport::Delta( if rows != 0 {
-(scroll / cell.y).round() as isize, self.terminal.scroll_viewport(ScrollViewport::Delta(-rows));
));
} }
} }
} }
+210 -15
View File
@@ -9,12 +9,14 @@ use libghostty_vt::{
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator}, kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator}, render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
screen::CellWide, screen::CellWide,
style::{RgbColor, Underline}, style::{RgbColor, Style, Underline},
terminal::Mode,
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
io::Cursor, io::Cursor,
sync::Arc, sync::Arc,
time::{Duration, Instant},
}; };
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's /// Where a pane's terminal is drawn this frame, and the colors that are slerm's
@@ -143,6 +145,23 @@ struct CachedRow {
backgrounds: Vec<egui::Shape>, backgrounds: Vec<egui::Shape>,
foregrounds: Vec<egui::Shape>, foregrounds: Vec<egui::Shape>,
selection: Option<(u16, u16)>, selection: Option<(u16, u16)>,
cells: Vec<CachedCell>,
}
#[derive(PartialEq)]
struct CellAppearance {
text: String,
style: Style,
fg: Color32,
bg: Color32,
background: bool,
wide: CellWide,
}
struct CachedCell {
appearance: CellAppearance,
backgrounds: std::ops::Range<usize>,
foregrounds: std::ops::Range<usize>,
} }
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> { fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
@@ -205,8 +224,12 @@ pub struct Renderer {
cached_selection: (Color32, Color32), cached_selection: (Color32, Color32),
cached_cell: egui::Vec2, cached_cell: egui::Vec2,
cached_cols: u16, cached_cols: u16,
frame: Vec<egui::epaint::ClippedShape>,
sync_started: Option<Instant>,
#[cfg(test)] #[cfg(test)]
rebuilt_rows: usize, rebuilt_rows: usize,
#[cfg(test)]
rebuilt_cells: usize,
} }
impl Renderer { impl Renderer {
@@ -225,8 +248,12 @@ impl Renderer {
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER), cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
cached_cell: egui::Vec2::ZERO, cached_cell: egui::Vec2::ZERO,
cached_cols: 0, cached_cols: 0,
frame: Vec::new(),
sync_started: None,
#[cfg(test)] #[cfg(test)]
rebuilt_rows: 0, rebuilt_rows: 0,
#[cfg(test)]
rebuilt_cells: 0,
}) })
} }
@@ -256,9 +283,26 @@ impl Renderer {
if fonts_changed { if fonts_changed {
self.row_cache.clear(); self.row_cache.clear();
self.glyphs.clear(); self.glyphs.clear();
self.frame.clear();
} }
self.font_marker = Some(marker); self.font_marker = Some(marker);
self.cached_cell = cell; self.cached_cell = cell;
// Editors may clear and redraw in separate PTY reads. Keep presenting
// the last complete frame until DEC synchronized output ends. Bound
// the wait so a crashed application cannot leave the display frozen.
if term.mode(Mode::SYNC_OUTPUT)? {
let elapsed = self.sync_started.get_or_insert_with(Instant::now).elapsed();
let timeout = Duration::from_secs(1);
if elapsed < timeout && !self.frame.is_empty() {
painter.ctx().request_repaint_after(timeout - elapsed);
self.present(&painter, rect);
return Ok(());
}
} else {
self.sync_started = None;
}
let screen_rect = rect;
let rect = Rect::from_min_size(egui::Pos2::ZERO, rect.size());
let style_fonts = painter.ctx().fonts(|fonts| { let style_fonts = painter.ctx().fonts(|fonts| {
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| { [(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
let family = crate::fonts::family(bold, italic); let family = crate::fonts::family(bold, italic);
@@ -304,7 +348,8 @@ impl Renderer {
self.cached_colors = Some(colors.clone()); self.cached_colors = Some(colors.clone());
self.cached_selection = selected_colors; self.cached_selection = selected_colors;
self.cached_cols = cols; self.cached_cols = cols;
painter.rect_filled(rect, 0, color(colors.background)); let mut shapes = vec![egui::Shape::rect_filled(rect, 0, color(colors.background))];
let mut cursor_shapes = Vec::new();
let mut rows = self.rows.update(&snapshot)?; let mut rows = self.rows.update(&snapshot)?;
let mut text = String::new(); let mut text = String::new();
let mut y = 0; let mut y = 0;
@@ -343,18 +388,43 @@ impl Renderer {
if selected { if selected {
(bg, fg) = selected_colors; (bg, fg) = selected_colors;
} }
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if style.faint { if style.faint {
fg = fg.gamma_multiply(0.6); fg = fg.gamma_multiply(0.6);
} }
let wide = c.raw_cell()?.wide()?; let wide = c.raw_cell()?.wide()?;
text.clear();
c.graphemes_utf8(&mut text)?;
let appearance = CellAppearance {
text: text.clone(),
style,
fg,
bg,
background: explicit_bg.is_some() || style.inverse || selected,
wide,
};
// Dirty flags are row-granular. Compare cells so edits in one
// split don't rebuild its unchanged neighbor on the same row.
let background_start = backgrounds.len();
let foreground_start = foregrounds.len();
if let Some(old) = self.row_cache[y].cells.get(x)
&& old.appearance == appearance
{
backgrounds
.extend_from_slice(&self.row_cache[y].backgrounds[old.backgrounds.clone()]);
foregrounds
.extend_from_slice(&self.row_cache[y].foregrounds[old.foregrounds.clone()]);
} else {
#[cfg(test)]
{
self.rebuilt_cells += 1;
}
if appearance.background {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if !style.invisible if !style.invisible
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) && !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
&& c.graphemes_len()? > 0 && c.graphemes_len()? > 0
{ {
c.graphemes_utf8(&mut text)?;
// Placeholder cells are reserved by the Kitty protocol, not printable text. // Placeholder cells are reserved by the Kitty protocol, not printable text.
if let Some(separator) = powerline_shape(&text, cell_rect, fg) { if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
foregrounds.push(separator); foregrounds.push(separator);
@@ -409,6 +479,12 @@ impl Renderer {
)); ));
} }
} }
}
cached.cells.push(CachedCell {
appearance,
backgrounds: background_start..backgrounds.len(),
foregrounds: foreground_start..foregrounds.len(),
});
x += 1; x += 1;
} }
self.row_cache[y] = cached; self.row_cache[y] = cached;
@@ -419,7 +495,7 @@ impl Renderer {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] { for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
if layer == Layer::BelowText { if layer == Layer::BelowText {
painter.extend( shapes.extend(
self.row_cache self.row_cache
.iter() .iter()
.flat_map(|row| row.backgrounds.iter()) .flat_map(|row| row.backgrounds.iter())
@@ -431,7 +507,7 @@ impl Renderer {
); );
} }
if layer == Layer::AboveText { if layer == Layer::AboveText {
painter.extend( shapes.extend(
self.row_cache self.row_cache
.iter() .iter()
.flat_map(|row| row.foregrounds.iter()) .flat_map(|row| row.foregrounds.iter())
@@ -451,6 +527,7 @@ impl Renderer {
&mut self.placements, &mut self.placements,
&mut self.textures, &mut self.textures,
&mut seen, &mut seen,
&mut shapes,
)?; )?;
} }
self.textures.retain(|id, _| seen.contains(id)); self.textures.retain(|id, _| seen.contains(id));
@@ -465,25 +542,74 @@ impl Renderer {
match snapshot.cursor_visual_style()? { match snapshot.cursor_visual_style()? {
CursorVisualStyle::Bar if active => { CursorVisualStyle::Bar if active => {
cursor_rect.max.x = cursor_rect.min.x + 2.0; cursor_rect.max.x = cursor_rect.min.x + 2.0;
painter.rect_filled(cursor_rect, 0, c); shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
} }
CursorVisualStyle::Underline if active => { CursorVisualStyle::Underline if active => {
cursor_rect.min.y = cursor_rect.max.y - 2.0; cursor_rect.min.y = cursor_rect.max.y - 2.0;
painter.rect_filled(cursor_rect, 0, c); shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
}
CursorVisualStyle::Block if active => {
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
// Redraw the covered glyph in the background color so an
// application's solid block cursor leaves text readable.
let ink = color(colors.background);
cursor_shapes.extend(
self.row_cache[cursor.y as usize]
.foregrounds
.iter()
.cloned()
.map(|mut shape| {
shape.translate(rect.min.to_vec2());
egui::epaint::shape_transform::adjust_colors(
&mut shape,
move |color| {
if *color != Color32::TRANSPARENT {
*color = ink;
}
},
);
egui::epaint::ClippedShape {
clip_rect: cursor_rect.intersect(rect),
shape,
}
}),
);
} }
_ => { _ => {
painter.rect_stroke( shapes.push(egui::Shape::rect_stroke(
cursor_rect, cursor_rect,
0, theme::CURSOR_CORNER_RADIUS,
Stroke::new(if active { 1.5_f32 } else { 0.75_f32 }, c), Stroke::new(theme::BORDER_WIDTH, c),
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); ));
} }
} }
} }
snapshot.set_dirty(Dirty::Clean)?; snapshot.set_dirty(Dirty::Clean)?;
self.frame = shapes
.into_iter()
.map(|shape| egui::epaint::ClippedShape {
clip_rect: rect,
shape,
})
.chain(cursor_shapes)
.collect();
self.present(&painter, screen_rect);
Ok(()) Ok(())
} }
fn present(&self, painter: &Painter, rect: Rect) {
for cached in &self.frame {
let mut shape = cached.shape.clone();
shape.translate(rect.min.to_vec2());
let clip = cached
.clip_rect
.translate(rect.min.to_vec2())
.intersect(rect)
.intersect(painter.clip_rect());
painter.with_clip_rect(clip).add(shape);
}
}
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -496,6 +622,7 @@ fn paint_images(
iterator: &mut PlacementIterator<'_>, iterator: &mut PlacementIterator<'_>,
textures: &mut HashMap<u32, (u64, TextureHandle)>, textures: &mut HashMap<u32, (u64, TextureHandle)>,
seen: &mut HashSet<u32>, seen: &mut HashSet<u32>,
shapes: &mut Vec<egui::Shape>,
) -> Result<()> { ) -> Result<()> {
let graphics = term.kitty_graphics()?; let graphics = term.kitty_graphics()?;
let mut placements = iterator.update(&graphics)?; let mut placements = iterator.update(&graphics)?;
@@ -570,7 +697,12 @@ fn paint_images(
} }
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid)); images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
for (_, id, _, dest, uv) in images { for (_, id, _, dest, uv) in images {
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE); shapes.push(egui::Shape::image(
textures[&id].1.id(),
dest,
uv,
Color32::WHITE,
));
} }
Ok(()) Ok(())
} }
@@ -681,6 +813,69 @@ mod tests {
.shapes .shapes
} }
#[test]
fn scrolling_one_editor_split_reuses_unchanged_cells() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25l");
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {row} right {row}").as_bytes());
}
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 100);
// Simulate an editor repainting entire rows while only its left split changes.
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {} right {row}", row + 1).as_bytes());
}
let actual = frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105, "only five digits changed");
assert_eq!(
actual,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// Rewriting identical text should reuse every cell, even on dirty rows.
term.vt_write(b"\x1b[1;1Hleft 2 right 1");
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105);
}
#[test]
fn synchronized_updates_keep_the_last_complete_frame() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25lleft buffer\r\nright buffer");
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
let initial = frame(&ctx, &mut renderer, &term, rect, &font);
let cells = renderer.rebuilt_cells;
term.vt_write(b"\x1b[?2026h\x1b[2J\x1b[Hpartial");
assert!(term.mode(Mode::SYNC_OUTPUT).unwrap());
assert_eq!(initial, frame(&ctx, &mut renderer, &term, rect, &font));
assert_eq!(renderer.rebuilt_cells, cells);
term.vt_write(b"\rcomplete\r\nright buffer\x1b[?2026l");
let completed = frame(&ctx, &mut renderer, &term, rect, &font);
assert_ne!(initial, completed);
assert_eq!(
completed,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// A missing end marker must not freeze the terminal indefinitely.
term.vt_write(b"\x1b[?2026h\x1b[Htimeout");
assert_eq!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
renderer.sync_started = Some(Instant::now() - Duration::from_secs(2));
assert_ne!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
}
#[test] #[test]
fn cached_rows_match_fresh_rendering_after_terminal_changes() { fn cached_rows_match_fresh_rendering_after_terminal_changes() {
use libghostty_vt::{ use libghostty_vt::{
+71
View File
@@ -28,6 +28,29 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
} }
} }
#[test]
fn cursor_defaults_to_hollow_and_respects_application_styles() {
use libghostty_vt::{RenderState, render::CursorVisualStyle};
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut state = RenderState::new().unwrap();
let snapshot = state.update(&term).unwrap();
assert_eq!(
snapshot.cursor_visual_style().unwrap(),
CursorVisualStyle::BlockHollow
);
for (sequence, expected) in [
(b"\x1b[2 q", CursorVisualStyle::Block),
(b"\x1b[4 q", CursorVisualStyle::Underline),
(b"\x1b[6 q", CursorVisualStyle::Bar),
(b"\x1b[0 q", CursorVisualStyle::BlockHollow),
] {
term.vt_write(sequence);
let snapshot = state.update(&term).unwrap();
assert_eq!(snapshot.cursor_visual_style().unwrap(), expected);
}
}
#[test] #[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() { fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
@@ -57,6 +80,54 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
assert!(plain(&term).contains("hello red")); assert!(plain(&term).contains("hello red"));
} }
#[test]
fn input_encoding_grows_buffers_without_losing_events() {
let term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut events = vec![Event::Text("a".into()), Event::Text("123456789".into())];
let long_text = "界".repeat(200);
events.push(Event::Text(long_text.clone()));
for _ in 0..100 {
events.push(key(Key::ArrowUp, Modifiers::NONE));
}
let expected = format!("a123456789{long_text}{}", "\x1b[A".repeat(100));
assert_eq!(input::encode(&term, &events).unwrap(), expected.as_bytes());
}
#[test]
fn mouse_encoding_appends_when_destination_has_insufficient_spare_capacity() {
use libghostty_vt::mouse;
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
term.vt_write(b"\x1b[?1000h\x1b[?1006h");
let mut encoder = mouse::Encoder::new().unwrap();
encoder
.set_options_from_terminal(&term)
.set_size(mouse::EncoderSize {
screen_width: 800,
screen_height: 600,
cell_width: 8,
cell_height: 16,
padding_top: 0,
padding_bottom: 0,
padding_left: 0,
padding_right: 0,
});
let mut event = mouse::Event::new().unwrap();
event
.set_action(mouse::Action::Press)
.set_button(Some(mouse::Button::Left))
.set_position(mouse::Position { x: 0.0, y: 0.0 });
let mut out = Vec::with_capacity(16);
out.resize(9, b'x');
for _ in 0..100 {
input::append_encoded(&mut out, |buffer| encoder.encode(&event, buffer)).unwrap();
}
assert_eq!(
out,
format!("xxxxxxxxx{}", "\x1b[<0;1;1M".repeat(100)).as_bytes()
);
}
#[test] #[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() { fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
+5
View File
@@ -7,6 +7,11 @@ use libghostty_vt::style::{Palette, PaletteIndex, RgbColor};
/// integers rather than anything that needs allocating or parsing at startup. /// integers rather than anything that needs allocating or parsing at startup.
pub type Rgb = u32; pub type Rgb = u32;
/// Shared stroke width for pane borders and the default hollow cursor.
pub const BORDER_WIDTH: f32 = 1.0;
pub const CORNER_RADIUS: u8 = 5;
pub const CURSOR_CORNER_RADIUS: u8 = 2;
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen /// Every color slerm draws with. A theme covers the terminal grid, the sixteen
/// ANSI colors programs address by index, and the chrome around them, so that /// ANSI colors programs address by index, and the chrome around them, so that
/// picking a light theme does not leave a dark tab bar behind. /// picking a light theme does not leave a dark tab bar behind.