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:
parent
ed1bf1ae85
commit
fd93c27264
|
|
@ -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
|
||||||
16
src/app.rs
16
src/app.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue