use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, Weak};
use std::time::Duration;
use crossterm::style::Print;
use crossterm::terminal::{Clear, ClearType};
use crossterm::{execute, queue};
use super::caps::LineCaps;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Sink {
Stdout,
Stderr,
}
impl Sink {
fn with<R>(self, f: impl FnOnce(&mut LineWriter<'_>) -> R) -> R {
match self {
Self::Stdout => {
let stdout = io::stdout();
let mut lock = stdout.lock();
f(&mut LineWriter(&mut lock))
}
Self::Stderr => {
let stderr = io::stderr();
let mut lock = stderr.lock();
f(&mut LineWriter(&mut lock))
}
}
}
}
pub struct LineWriter<'a>(&'a mut (dyn Write + 'a));
impl Write for LineWriter<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
pub trait Ephemeral: Send + Sync {
fn erase(&self);
fn restore(&self);
}
#[derive(Default)]
struct Inner {
line_held: bool,
registered: Vec<(u64, Weak<dyn Ephemeral>)>,
suspended: bool,
next_id: u64,
prompt_owner: Option<std::thread::ThreadId>,
prompt_depth: usize,
watcher_reading: bool,
}
fn arbiter() -> &'static (Mutex<Inner>, Condvar) {
static ARBITER: OnceLock<(Mutex<Inner>, Condvar)> = OnceLock::new();
ARBITER.get_or_init(|| (Mutex::new(Inner::default()), Condvar::new()))
}
fn lock() -> MutexGuard<'static, Inner> {
arbiter()
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn live_ephemerals(state: &mut Inner) -> Vec<Arc<dyn Ephemeral>> {
state.registered.retain(|(_, w)| w.strong_count() > 0);
state
.registered
.iter()
.filter_map(|(_, w)| w.upgrade())
.collect()
}
pub(crate) fn suspended() -> bool {
lock().suspended
}
#[must_use = "dropping the registration immediately deregisters the ephemeral"]
pub struct EphemeralRegistration {
id: u64,
}
impl EphemeralRegistration {
pub fn suspended(&self) -> bool {
suspended()
}
}
impl Drop for EphemeralRegistration {
fn drop(&mut self) {
lock().registered.retain(|(id, _)| *id != self.id);
}
}
pub struct LineLease {
id: u64,
sink: Sink,
painted: AtomicBool,
}
impl LineLease {
pub fn sink(&self) -> Sink {
self.sink
}
pub fn erase(&self) {
if !self.painted.swap(false, Ordering::SeqCst) {
return;
}
self.sink.with(|w| {
let _ = execute!(w, Print("\r"), Clear(ClearType::UntilNewLine));
let _ = w.flush();
});
}
pub fn paint(&self, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
if suspended() {
return;
}
self.sink.with(|w| {
let _ = queue!(w, Print("\r"), Clear(ClearType::UntilNewLine));
let _ = f(w);
let _ = w.flush();
});
self.painted.store(true, Ordering::SeqCst);
}
pub fn emit_line(&self, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
self.erase();
self.sink.with(|w| {
let _ = f(w);
let _ = w.flush();
});
}
}
impl Drop for LineLease {
fn drop(&mut self) {
self.erase();
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.line_held = false;
state.registered.retain(|(id, _)| *id != self.id);
cv.notify_all();
}
}
static SUSPENSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn prompt_windows_constructed() -> u64 {
SUSPENSIONS.load(Ordering::SeqCst)
}
pub struct Terminal;
const LEASE_WAIT: Duration = Duration::from_millis(50);
impl Terminal {
pub fn lease(sink: Sink) -> Option<LineLease> {
Self::lease_with_caps(super::caps::detect(), sink)
}
pub fn lease_with_caps(caps: LineCaps, sink: Sink) -> Option<LineLease> {
if super::caps::protocol_mode() || !caps.can_own() {
return None;
}
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if state.line_held {
let (guard, timeout) = cv
.wait_timeout_while(state, LEASE_WAIT, |s| s.line_held)
.unwrap_or_else(std::sync::PoisonError::into_inner);
if timeout.timed_out() {
return None;
}
state = guard;
}
state.line_held = true;
state.next_id += 1;
let id = state.next_id;
Some(LineLease {
id,
sink,
painted: AtomicBool::new(false),
})
}
pub fn emit_line(sink: Sink, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
let live: Vec<Arc<dyn Ephemeral>> = live_ephemerals(&mut lock());
for e in &live {
e.erase();
}
sink.with(|w| {
let _ = f(w);
let _ = w.flush();
});
}
pub(crate) fn register(id: u64, e: &Arc<dyn Ephemeral>) {
lock().registered.push((id, Arc::downgrade(e)));
}
pub fn register_ephemeral(e: &Arc<dyn Ephemeral>) -> EphemeralRegistration {
let mut state = lock();
state.next_id += 1;
let id = state.next_id;
state.registered.push((id, Arc::downgrade(e)));
EphemeralRegistration { id }
}
pub fn suspend_for_prompt() -> PromptWindow {
SUSPENSIONS.fetch_add(1, Ordering::SeqCst);
let stdin = StdinToken::acquire();
let live: Vec<Arc<dyn Ephemeral>> = {
let mut state = lock();
state.suspended = true;
live_ephemerals(&mut state)
};
for e in &live {
e.erase();
}
PromptWindow {
_seal: Seal,
stdin: Some(stdin),
resume: live,
live: true,
}
}
}
struct StdinToken {
#[cfg(unix)]
restore: Option<libc::termios>,
}
impl StdinToken {
fn acquire() -> Self {
let thread = std::thread::current().id();
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while state.watcher_reading
|| state
.prompt_owner
.as_ref()
.is_some_and(|owner| *owner != thread)
{
state = cv
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
state.prompt_owner = Some(thread);
state.prompt_depth += 1;
drop(state);
Self {
#[cfg(unix)]
restore: enter_prompt_line_mode().ok(),
}
}
}
impl Drop for StdinToken {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(prev) = self.restore.take() {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &prev);
}
}
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.prompt_depth = state.prompt_depth.saturating_sub(1);
if state.prompt_depth == 0 {
state.prompt_owner = None;
cv.notify_all();
}
}
}
#[cfg(unix)]
fn enter_prompt_line_mode() -> io::Result<libc::termios> {
unsafe {
let fd = libc::STDIN_FILENO;
let mut prev: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut prev) != 0 {
return Err(io::Error::last_os_error());
}
let mut line = prev;
line.c_lflag |= libc::ICANON | libc::ECHO;
line.c_cc[libc::VMIN] = 1;
line.c_cc[libc::VTIME] = 0;
if libc::tcsetattr(fd, libc::TCSANOW, &line) != 0 {
return Err(io::Error::last_os_error());
}
Ok(prev)
}
}
pub struct WatcherStdinGuard;
pub fn try_watch_stdin() -> Option<WatcherStdinGuard> {
let mut state = lock();
if state.prompt_owner.is_some() || state.watcher_reading {
return None;
}
state.watcher_reading = true;
Some(WatcherStdinGuard)
}
impl Drop for WatcherStdinGuard {
fn drop(&mut self) {
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.watcher_reading = false;
cv.notify_all();
}
}
pub fn prompt_stdin_active() -> bool {
lock().prompt_owner.is_some()
}
struct Seal;
pub struct PromptWindow {
_seal: Seal,
stdin: Option<StdinToken>,
resume: Vec<Arc<dyn Ephemeral>>,
live: bool,
}
impl PromptWindow {
pub fn ask(&self, text: &str) -> io::Result<()> {
let mut out = io::stdout();
write!(out, "{text}")?;
out.flush()
}
pub fn read_line(&self) -> io::Result<String> {
let mut buf = String::new();
self.read_line_into(&mut buf)?;
Ok(buf)
}
pub fn read_line_into(&self, buf: &mut String) -> io::Result<usize> {
io::stdin().read_line(buf)
}
pub fn notice(&self, text: &str) -> io::Result<()> {
let mut out = io::stdout();
writeln!(out, "{text}")?;
out.flush()
}
#[cfg(any(test, feature = "test-util"))]
pub fn test_stub() -> Self {
Self {
_seal: Seal,
stdin: None,
resume: Vec::new(),
live: false,
}
}
}
impl Drop for PromptWindow {
fn drop(&mut self) {
if !self.live {
return;
}
lock().suspended = false;
for e in &self.resume {
e.restore();
}
self.stdin = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
struct Counter {
erased: AtomicUsize,
restored: AtomicUsize,
}
impl Ephemeral for Counter {
fn erase(&self) {
self.erased.fetch_add(1, Ordering::SeqCst);
}
fn restore(&self) {
self.restored.fetch_add(1, Ordering::SeqCst);
}
}
fn counter() -> Arc<Counter> {
Arc::new(Counter {
erased: AtomicUsize::new(0),
restored: AtomicUsize::new(0),
})
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_line_admits_exactly_one_writer() {
let first = Terminal::lease_with_caps(LineCaps::Own, Sink::Stdout)
.expect("an Own capability yields the line");
assert!(
Terminal::lease_with_caps(LineCaps::Own, Sink::Stdout).is_none(),
"a second writer must NOT get the line while the first holds it"
);
drop(first);
let third = Terminal::lease_with_caps(LineCaps::Own, Sink::Stdout);
assert!(third.is_some(), "the line is reusable once released");
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn no_capability_means_no_lease_and_no_bytes() {
assert!(Terminal::lease_with_caps(LineCaps::None, Sink::Stdout).is_none());
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn suspending_erases_every_ephemeral_then_restores() {
let c = counter();
let dynamic: Arc<dyn Ephemeral> = c.clone();
Terminal::register(9_001, &dynamic);
assert_eq!(c.erased.load(Ordering::SeqCst), 0);
{
let w = Terminal::suspend_for_prompt();
assert_eq!(
c.erased.load(Ordering::SeqCst),
1,
"the ephemeral must be erased before the window is handed out"
);
assert!(suspended(), "the ticker must see the suspend flag");
assert_eq!(c.restored.load(Ordering::SeqCst), 0);
drop(w);
}
assert!(!suspended(), "the flag clears when the window drops");
assert_eq!(c.restored.load(Ordering::SeqCst), 1);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_live_prompt_window_makes_painting_a_no_op() {
let lease = Terminal::lease_with_caps(LineCaps::Own, Sink::Stdout).expect("lease");
let w = Terminal::suspend_for_prompt();
lease.paint(|_w| Ok(()));
assert!(
!lease.painted.load(Ordering::SeqCst),
"a paint during a prompt must be dropped, not deferred onto the question"
);
drop(w);
lease.paint(|_w| Ok(()));
assert!(
lease.painted.load(Ordering::SeqCst),
"painting resumes once the window is gone"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn erase_is_idempotent() {
let lease = Terminal::lease_with_caps(LineCaps::Own, Sink::Stdout).expect("lease");
lease.paint(|_w| Ok(()));
assert!(lease.painted.load(Ordering::SeqCst));
lease.erase();
assert!(!lease.painted.load(Ordering::SeqCst));
lease.erase(); assert!(!lease.painted.load(Ordering::SeqCst));
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn nested_prompts_hold_stdin_until_the_outermost_releases() {
assert!(
!prompt_stdin_active(),
"test starts with no active prompt stdin owner"
);
{
let _outer = StdinToken::acquire();
assert!(
try_watch_stdin().is_none(),
"the watcher cannot read while a prompt owns stdin"
);
assert!(prompt_stdin_active());
{
let _nested = StdinToken::acquire();
assert!(prompt_stdin_active(), "nested prompts keep ownership");
}
assert!(
prompt_stdin_active(),
"dropping one nested guard must not release stdin early"
);
}
assert!(
!prompt_stdin_active(),
"prompt stdin ownership must clear when the last guard drops"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn watcher_read_token_blocks_prompt_entry_until_the_read_finishes() {
let watcher = try_watch_stdin().expect("watcher acquires idle stdin");
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let prompt = std::thread::spawn(move || {
let _prompt = StdinToken::acquire();
let _ = entered_tx.send(());
});
assert!(
entered_rx.recv_timeout(Duration::from_millis(50)).is_err(),
"prompt entered during the watcher's protected read"
);
drop(watcher);
entered_rx
.recv_timeout(Duration::from_secs(1))
.expect("prompt enters once the watcher releases stdin");
prompt.join().unwrap();
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn watcher_and_prompt_exclude_each_other_on_stdin() {
assert!(!prompt_stdin_active());
let watcher = try_watch_stdin().expect("the watcher acquires idle stdin");
assert!(
try_watch_stdin().is_none(),
"the watcher's read token is exclusive"
);
drop(watcher);
let token = StdinToken::acquire();
assert!(prompt_stdin_active());
assert!(
try_watch_stdin().is_none(),
"the watcher must not read while a prompt owns stdin"
);
drop(token);
assert!(!prompt_stdin_active());
assert!(try_watch_stdin().is_some(), "released on drop");
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_test_stub_arbitrates_nothing() {
let w = PromptWindow::test_stub();
assert!(!suspended());
drop(w);
assert!(!suspended());
}
}