stop tab from navigating menus

This commit is contained in:
2026-09-08 01:00:38 +02:00
parent 48b5bc7123
commit 51e1efb523
4 changed files with 166 additions and 9 deletions
+34 -3
View File
@@ -47,7 +47,7 @@ impl App {
) -> 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)?;
let pane = Pane::new(&config, &cc.egui_ctx, None)?;
Ok(Self {
config,
config_path,
@@ -65,7 +65,13 @@ impl App {
self.panes.len() < self.config.multiplexer.max_panes,
"Pane limit reached"
);
let pane = Pane::new(&self.config, ctx)?;
// A new pane starts where the pane it was spawned from currently sits.
let cwd = self
.tabs
.get(self.current)
.and_then(|tab| self.panes.get(&tab.active))
.and_then(Pane::cwd);
let pane = Pane::new(&self.config, ctx, cwd.as_deref())?;
let id = self.next_id;
self.next_id += 1;
self.panes.insert(id, pane);
@@ -337,6 +343,7 @@ impl eframe::App for App {
for pane in self.panes.values_mut() {
pane.drain(ctx);
}
grab_keys(ctx);
if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string());
}
@@ -482,6 +489,30 @@ impl eframe::App for App {
}
}
/// Parks keyboard focus on a widget that never exists, with every navigation key
/// filtered out. Otherwise egui spends Tab, the arrows and Escape on moving focus
/// between the tab bar and the menu buttons instead of leaving them for the shell.
fn grab_keys(ctx: &egui::Context) {
let id = egui::Id::new("keys");
// Registers the id for this pass so egui's dead-mans-switch keeps the focus.
ctx.check_for_id_clash(id, Rect::NOTHING, "key sink");
ctx.memory_mut(|m| {
if m.focused() != Some(id) {
m.request_focus(id);
}
// Only applies once the sink has held focus for a full frame.
m.set_focus_lock_filter(
id,
egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
escape: true,
},
);
});
}
fn show_pane(
ui: &mut egui::Ui,
id: PaneId,
@@ -556,7 +587,7 @@ mod tests {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let pane = Pane::new(&config, ctx).unwrap();
let pane = Pane::new(&config, ctx, None).unwrap();
App {
config,
config_path: PathBuf::new(),
+60 -3
View File
@@ -11,7 +11,12 @@ use libghostty_vt::{
selection::{FormatOptions, Selection},
terminal::{self, Mode, Point, PointCoordinate, ScrollViewport},
};
use std::{cell::RefCell, rc::Rc, sync::mpsc::TryRecvError};
use std::{
cell::RefCell,
path::{Path, PathBuf},
rc::Rc,
sync::mpsc::TryRecvError,
};
pub struct Pane {
pub terminal: Terminal<'static, 'static>,
@@ -29,6 +34,47 @@ pub struct Pane {
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('/')?;
let local = host.is_empty()
|| host.eq_ignore_ascii_case("localhost")
|| std::fs::read_to_string("/proc/sys/kernel/hostname")
.is_ok_and(|name| name.trim().eq_ignore_ascii_case(host));
local.then(|| PathBuf::from(percent_decode(&format!("/{path}"))))
}
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) -> Result<Terminal<'static, 'static>> {
let mut term = Terminal::new(TerminalOptions {
cols: 80,
@@ -68,12 +114,12 @@ pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()>
}
impl Pane {
pub fn new(config: &Config, ctx: &egui::Context) -> Result<Self> {
pub fn new(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> 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 pty = Pty::spawn(config, ctx, cwd)?;
let input = pty.input.clone();
let callback_error = Rc::new(RefCell::new(None));
let errors = callback_error.clone();
@@ -170,6 +216,17 @@ impl Pane {
.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;
+10 -2
View File
@@ -3,6 +3,7 @@ use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::{
io::{Read, Write},
path::{Path, PathBuf},
sync::mpsc::{self, Receiver, Sender, SyncSender},
thread,
};
@@ -18,7 +19,7 @@ pub struct Pty {
}
impl Pty {
pub fn spawn(config: &Config, ctx: &egui::Context) -> Result<Self> {
pub fn spawn(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result<Self> {
let pair = native_pty_system().openpty(PtySize {
rows: 24,
cols: 80,
@@ -31,7 +32,7 @@ impl Pty {
cmd.env("COLORTERM", "truecolor");
cmd.env("TERM_PROGRAM", "slerm");
cmd.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION"));
if let Some(dir) = &config.working_directory {
if let Some(dir) = cwd.or(config.working_directory.as_deref()) {
cmd.cwd(dir);
}
let mut reader = pair.master.try_clone_reader()?;
@@ -110,6 +111,13 @@ impl Pty {
})
}
/// The working directory of the foreground process group, via /proc. Shells
/// that emit OSC 7 are reported by the terminal instead; this covers the rest.
pub fn cwd(&self) -> Option<PathBuf> {
let pid = self.master.process_group_leader()?;
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> {
self.master.resize(PtySize {
cols,
+62 -1
View File
@@ -173,7 +173,7 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() {
..Default::default()
};
let ctx = egui::Context::default();
let mut pane = pane::Pane::new(&config, &ctx).unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
@@ -205,3 +205,64 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() {
assert!(pane.dead);
assert!(pane.send(b"after exit".to_vec()).is_err());
}
#[test]
fn new_panes_start_in_the_directory_of_the_pane_they_came_from() {
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let ctx = egui::Context::default();
let dir = std::env::temp_dir().join(format!("slerm cwd {}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
pane.send(format!("cd '{}'\r", dir.display()).into_bytes())
.unwrap();
// /bin/sh reports no OSC 7, so the directory has to come from the process.
let deadline = Instant::now() + Duration::from_secs(5);
while pane.cwd().as_deref() != Some(dir.as_path()) && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(pane.cwd().as_deref(), Some(dir.as_path()));
let mut child = pane::Pane::new(&config, &ctx, pane.cwd().as_deref()).unwrap();
child
.send(b"printf 'at:%s\\n' \"$PWD\"\r".to_vec())
.unwrap();
let expected = format!("at:{}", dir.display());
let deadline = Instant::now() + Duration::from_secs(5);
while !plain(&child.terminal).contains(&expected) && Instant::now() < deadline {
child.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(plain(&child.terminal).contains(&expected));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn osc7_wins_over_the_process_directory_but_only_for_this_machine() {
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let ctx = egui::Context::default();
let reported = std::env::temp_dir().join(format!("slerm osc7 {}", std::process::id()));
std::fs::create_dir_all(&reported).unwrap();
let reported = reported.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let process_cwd = pane.cwd().unwrap();
assert_ne!(process_cwd, reported);
let escaped = reported.display().to_string().replace(' ', "%20");
pane.terminal
.vt_write(format!("\x1b]7;file://localhost{escaped}\x1b\\").as_bytes());
assert_eq!(pane.cwd(), Some(reported.clone()));
// A directory on another host cannot be entered here, so the process wins again.
pane.terminal
.vt_write(format!("\x1b]7;file://elsewhere{escaped}\x1b\\").as_bytes());
assert_eq!(pane.cwd(), Some(process_cwd));
std::fs::remove_dir_all(&reported).unwrap();
}