rs-mrxvt is a modernized, distro-agnostic terminal emulator inspired by the classic mrxvt. It is written in Rust and pairs 2008-era "tabbed power" with 2020s reliability.

This commit is contained in:
Jeremy Anderson 2026-07-24 18:24:44 -04:00
parent ed1bf1ae85
commit fd93c27264
3 changed files with 165 additions and 9 deletions

26
rs-mrxvt/.gitignore vendored Executable file
View File

@ -0,0 +1,26 @@
# Build artifacts
/target
**/*.rs.bk
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Distribution artifacts
*.deb
*.rpm
*.tar.gz
*.tar.xz
*.dsc
*.changes
*.buildinfo
# Logs
*.log

View File

@ -378,7 +378,7 @@ impl App {
let poll_ms = 50; // 20 Hz input poll; PTY drain is per-tick. let poll_ms = 50; // 20 Hz input poll; PTY drain is per-tick.
while !self.quit { while !self.quit {
// 1. Drain PTYs. // 1. Drain PTYs.
let (_active_changed, any_eof) = self.manager.poll_all(); let (_active_changed, _any_eof) = self.manager.poll_all();
// 2. Poll for input. // 2. Poll for input.
while let Some(ev) = renderer.poll_event(poll_ms)? { while let Some(ev) = renderer.poll_event(poll_ms)? {
@ -390,14 +390,12 @@ impl App {
} }
} }
// 3. Handle EOF on a tab — close it. // 3. Close tabs whose child process has exited. If the last tab
if any_eof { // closed, quit the app — classic terminal behaviour:
// Heuristic: close active tab if its child has exited. // `exit` in the last tab → app quits
// For the MVP we don't track which tab EOF'd; closing the // `exit` in any other tab → only that tab closes
// active one matches user expectation most of the time. if self.manager.close_dead_tabs() {
// (A more robust approach tracks EOF per-tab.) self.quit = true;
// We avoid closing if there's still data being produced.
// For now: don't auto-close; the user can press Ctrl+Shift+W.
} }
// 4. Render. // 4. Render.

View File

@ -112,6 +112,35 @@ impl TerminalManager {
self.close_tab(i) self.close_tab(i)
} }
/// Close every tab whose child process has exited (`eof_seen == true`).
///
/// Called once per event-loop tick after `poll_all`. Dead tabs are removed
/// and the active index is adjusted to stay in bounds (via `close_tab`).
///
/// Returns `true` when no tabs remain — the caller should quit the app.
/// This gives the classic terminal behaviour:
/// - `exit` in the **last** tab → app quits.
/// - `exit` in **any other** tab → only that tab closes.
pub fn close_dead_tabs(&mut self) -> bool {
// Collect indices of dead tabs, then remove in reverse order so
// earlier removals don't shift the indices we haven't reached yet.
let dead: Vec<usize> = self
.tabs
.iter()
.enumerate()
.filter(|(_, t)| t.eof_seen)
.map(|(i, _)| i)
.collect();
for i in dead.into_iter().rev() {
let title = self.tabs.get(i).map(|t| t.title.as_str()).unwrap_or("?");
log::info!("tab {i} ({title}): child process exited, closing tab");
self.close_tab(i);
}
self.tabs.is_empty()
}
/// Get the active tab (immutable). /// Get the active tab (immutable).
pub fn active_tab(&self) -> Option<&TerminalTab> { pub fn active_tab(&self) -> Option<&TerminalTab> {
self.tabs.get(self.active) self.tabs.get(self.active)
@ -466,4 +495,107 @@ mod tests {
let cfg = Config::default(); let cfg = Config::default();
assert_eq!(m.execute(&Command::Quit, 40, 10, &cfg), Action::Quit); assert_eq!(m.execute(&Command::Quit, 40, 10, &cfg), Action::Quit);
} }
// ─── close_dead_tabs tests ───────────────────────────────────────────
//
// These test the "exit closes the tab; exit in the last tab quits the
// app" behaviour. Each test spawns real child processes (`true` exits
// immediately, `sleep 10` stays alive) and waits for the reader thread
// to deliver the EOF sentinel through the channel.
/// Wait for at least one tab to see EOF, with a timeout. This mirrors
/// what the real main loop does: poll_all sets eof_seen, then
/// close_dead_tabs acts on it.
fn wait_for_any_eof(m: &mut TerminalManager, timeout_ms: u64) {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
m.poll_all();
if m.tabs.iter().any(|t| t.eof_seen) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn close_dead_tabs_quits_when_last_tab_exits() {
let mut m = TerminalManager::new(&Config::default());
let p = test_profile("true"); // exits immediately
let _ = m.open_tab(&p, Some("only".into()), 40, 10);
wait_for_any_eof(&mut m, 2000);
assert!(m.tabs[0].eof_seen, "tab should have observed EOF");
let should_quit = m.close_dead_tabs();
assert!(should_quit, "should quit — last tab closed");
assert!(m.tabs.is_empty());
}
#[test]
fn close_dead_tabs_keeps_alive_tabs_and_only_removes_dead_ones() {
let mut m = TerminalManager::new(&Config::default());
let dead_p = test_profile("true");
let alive_p = test_profile("sleep 10");
let _ = m.open_tab(&dead_p, Some("dead".into()), 40, 10);
let _ = m.open_tab(&alive_p, Some("alive".into()), 40, 10);
wait_for_any_eof(&mut m, 2000);
let should_quit = m.close_dead_tabs();
assert!(!should_quit, "should NOT quit — one tab still alive");
assert_eq!(m.tabs.len(), 1, "only the dead tab should be removed");
assert_eq!(m.tabs[0].title, "alive");
}
#[test]
fn close_dead_tabs_is_noop_when_all_tabs_alive() {
let mut m = fresh_manager(); // two tabs running "sleep 5"
let should_quit = m.close_dead_tabs();
assert!(!should_quit);
assert_eq!(m.tabs.len(), 2, "no tabs should be closed");
}
#[test]
fn close_dead_tabs_adjusts_active_index_when_active_tab_dies() {
let mut m = TerminalManager::new(&Config::default());
let alive_p = test_profile("sleep 10");
let dead_p = test_profile("true");
let _ = m.open_tab(&alive_p, Some("alive".into()), 40, 10); // index 0
let _ = m.open_tab(&dead_p, Some("dead".into()), 40, 10); // index 1
m.active = 1; // user is looking at the tab that's about to die
wait_for_any_eof(&mut m, 2000);
let should_quit = m.close_dead_tabs();
assert!(!should_quit);
assert_eq!(m.tabs.len(), 1);
assert_eq!(
m.active, 0,
"active should fall back to the remaining tab"
);
assert_eq!(m.tabs[0].title, "alive");
}
#[test]
fn close_dead_tabs_handles_multiple_simultaneous_exits() {
let mut m = TerminalManager::new(&Config::default());
let p = test_profile("true");
let _ = m.open_tab(&p, Some("a".into()), 40, 10);
let _ = m.open_tab(&p, Some("b".into()), 40, 10);
let _ = m.open_tab(&p, Some("c".into()), 40, 10);
wait_for_any_eof(&mut m, 2000);
// Wait a bit longer so all three tabs see EOF.
for _ in 0..50 {
m.poll_all();
if m.tabs.iter().all(|t| t.eof_seen) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let should_quit = m.close_dead_tabs();
assert!(should_quit, "all tabs exited — should quit");
assert!(m.tabs.is_empty());
}
} }