use anyhow::{Result, bail};
use crossterm::event::Event;
use tokio::sync::watch;
use super::input::TerminalGeometry;
use super::runtime::{AttachTerminal, AttachTerminalEventFuture};
type ViewportGuard = std::sync::Arc<dyn Fn() -> bool + Send + Sync>;
#[derive(Clone)]
struct ViewportUpdate {
geometry: TerminalGeometry,
guard: Option<ViewportGuard>,
closed: bool,
}
#[derive(Clone)]
pub struct HeadlessViewportSender(watch::Sender<ViewportUpdate>);
impl HeadlessViewportSender {
pub fn close(&self) {
self.0.send_modify(|update| update.closed = true);
}
#[cfg(test)]
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self.resize_if(cols, rows, || true)
}
pub fn resize_if(
&self,
cols: u16,
rows: u16,
guard: impl Fn() -> bool + Send + Sync + 'static,
) -> Result<()> {
validate(TerminalGeometry { cols, rows })?;
if self.0.borrow().closed {
bail!("headless viewport channel closed");
}
self.0.send(ViewportUpdate {
geometry: TerminalGeometry { cols, rows },
guard: Some(std::sync::Arc::new(guard)),
closed: false,
})?;
Ok(())
}
}
pub struct HeadlessAttachDriver {
viewport: watch::Receiver<ViewportUpdate>,
accepted: TerminalGeometry,
pub(super) observe_only: bool,
pub(super) render_provider: Option<std::sync::Arc<dyn bmux_client::AttachProvider>>,
guard: std::sync::Arc<std::sync::Mutex<Option<ViewportGuard>>>,
}
pub fn headless_attach_channel(
initial: TerminalGeometry,
) -> Result<(HeadlessAttachDriver, HeadlessViewportSender)> {
validate(initial)?;
let (sender, viewport) = watch::channel(ViewportUpdate {
geometry: initial,
guard: None,
closed: false,
});
Ok((
HeadlessAttachDriver {
viewport,
accepted: initial,
observe_only: false,
render_provider: None,
guard: std::sync::Arc::default(),
},
HeadlessViewportSender(sender),
))
}
fn validate(geometry: TerminalGeometry) -> Result<()> {
if geometry.cols == 0
|| geometry.rows == 0
|| usize::from(geometry.cols) * usize::from(geometry.rows) > 262_144
{
bail!("headless viewport is empty or exceeds cell budget");
}
Ok(())
}
impl std::io::Write for HeadlessAttachDriver {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other(
"terminal output forbidden in headless attach",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other(
"terminal flush forbidden in headless attach",
))
}
}
impl AttachTerminal for HeadlessAttachDriver {
fn presentation_only(&self) -> bool {
true
}
fn geometry(&self) -> TerminalGeometry {
self.accepted
}
fn render_provider(&self) -> Option<std::sync::Arc<dyn bmux_client::AttachProvider>> {
self.render_provider.clone()
}
fn accept_geometry(&mut self, geometry: TerminalGeometry) -> Result<()> {
validate(geometry)?;
self.accepted = geometry;
Ok(())
}
fn viewport_guard(&self) -> Option<ViewportGuard> {
if self.observe_only {
return Some(std::sync::Arc::new(|| false));
}
let current = self.guard.clone();
Some(std::sync::Arc::new(move || {
current
.lock()
.is_ok_and(|guard| guard.as_ref().is_none_or(|guard| guard()))
}))
}
fn enter_attach_mode(&mut self, _: bool, _: bool, _: bool) -> Result<bool> {
Ok(false)
}
fn restore_after_attach_ui(&mut self) -> Result<()> {
Ok(())
}
fn next_event(&mut self) -> AttachTerminalEventFuture<'_> {
Box::pin(async move {
loop {
if self.viewport.borrow().closed {
return Ok(None);
}
if self.viewport.changed().await.is_err() {
return Ok(None);
}
let update = self.viewport.borrow_and_update().clone();
if update.closed {
return Ok(None);
}
if update.guard.as_ref().is_some_and(|guard| !guard()) {
continue;
}
self.accepted = update.geometry;
*self
.guard
.lock()
.map_err(|_| anyhow::anyhow!("viewport guard poisoned"))? = update.guard;
return Ok(Some(Event::Resize(self.accepted.cols, self.accepted.rows)));
}
})
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn detect_image_capabilities(&mut self) -> bmux_image::host_caps::HostImageCapabilities {
bmux_image::host_caps::HostImageCapabilities::default()
}
}
#[cfg(test)]
mod tests {
use super::super::runtime::AttachTerminal;
use super::*;
#[tokio::test]
async fn observed_geometry_is_derived_without_publishing_resize_input() {
let (mut driver, sender) =
headless_attach_channel(TerminalGeometry { cols: 80, rows: 24 }).unwrap();
driver.observe_only = true;
assert!(!driver.viewport_guard().unwrap()());
driver
.accept_geometry(TerminalGeometry {
cols: 120,
rows: 40,
})
.unwrap();
assert_eq!(driver.geometry().cols, 120);
assert!(!driver.viewport_guard().unwrap()());
assert!(
driver
.accept_geometry(TerminalGeometry { cols: 0, rows: 40 })
.is_err()
);
assert_eq!(driver.geometry().cols, 120);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(10), driver.next_event())
.await
.is_err()
);
sender.close();
assert!(driver.next_event().await.unwrap().is_none());
}
#[tokio::test]
async fn viewport_is_latest_only_guarded_and_orderly_closed() {
let (mut driver, sender) =
headless_attach_channel(TerminalGeometry { cols: 80, rows: 24 }).unwrap();
sender.resize(90, 25).unwrap();
sender.resize(100, 30).unwrap();
assert_eq!(
driver.next_event().await.unwrap(),
Some(Event::Resize(100, 30))
);
assert_eq!(driver.geometry().cols, 100);
sender.resize_if(110, 31, || false).unwrap();
sender.close();
assert!(driver.next_event().await.unwrap().is_none());
assert!(driver.next_event().await.unwrap().is_none());
assert_eq!(driver.geometry().cols, 100);
assert!(sender.resize(80, 24).is_err());
}
#[test]
fn rejects_invalid_viewport_and_terminal_output() {
assert!(headless_attach_channel(TerminalGeometry { cols: 0, rows: 24 }).is_err());
let (mut driver, _) =
headless_attach_channel(TerminalGeometry { cols: 80, rows: 24 }).unwrap();
assert!(std::io::Write::write(&mut driver, b"ANSI").is_err());
assert!(driver.enter_attach_mode(true, true, true).is_ok());
assert!(driver.restore_after_attach_ui().is_ok());
}
}