use std::path::Path;
use embers_core::{
BufferId, FloatGeometry, FloatingId, MuxError, Result, SessionId, Size, new_request_id,
};
use embers_protocol::{
BufferRecordState, BufferRequest, ClientMessage, FloatingRequest, ProtocolClient,
ServerEnvelope, ServerResponse, SubscribeRequest,
};
use tokio::time::{Duration, sleep};
use crate::CliConnection;
pub(crate) const REMAIN_ON_EXIT: &str = "remain-on-exit";
const POLL_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopupLength {
Cells(u16),
Percent(u16),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopupPosition {
Cells(u16),
Percent(u16),
Centre,
End,
}
pub fn parse_popup_length(value: &str) -> std::result::Result<PopupLength, String> {
match value.strip_suffix('%') {
Some(percent) => percent
.parse()
.ok()
.filter(|percent| (1..=100).contains(percent))
.map(PopupLength::Percent)
.ok_or_else(|| "expected a percentage from 1% to 100%".to_owned()),
None => value
.parse()
.ok()
.filter(|cells| *cells > 0)
.map(PopupLength::Cells)
.ok_or_else(|| "expected a number of cells or a percentage such as 90%".to_owned()),
}
}
pub fn parse_popup_position(value: &str) -> std::result::Result<PopupPosition, String> {
match value {
"C" => Ok(PopupPosition::Centre),
"R" => Ok(PopupPosition::End),
_ => match value.strip_suffix('%') {
Some(percent) => percent
.parse()
.ok()
.filter(|percent| *percent <= 100)
.map(PopupPosition::Percent)
.ok_or_else(|| "expected a percentage from 0% to 100%".to_owned()),
None => value.parse().map(PopupPosition::Cells).map_err(|_| {
"expected a cell, a percentage such as 10%, C (centre) or R (right)".to_owned()
}),
},
}
}
pub(crate) fn popup_geometry(
viewport: Size,
width: Option<PopupLength>,
height: Option<PopupLength>,
x: Option<PopupPosition>,
y: Option<PopupPosition>,
) -> FloatGeometry {
let length = |value: PopupLength, extent: u16| -> u16 {
let cells = match value {
PopupLength::Cells(cells) => cells,
PopupLength::Percent(percent) => {
u16::try_from(u32::from(extent) * u32::from(percent) / 100).unwrap_or(extent)
}
};
cells.clamp(1, extent.max(1))
};
let position = |value: PopupPosition, extent: u16, length: u16| -> u16 {
let room = extent.saturating_sub(length);
let start = match value {
PopupPosition::Cells(cells) => cells,
PopupPosition::Percent(percent) => {
u16::try_from(u32::from(extent) * u32::from(percent) / 100).unwrap_or(extent)
}
PopupPosition::Centre => room / 2,
PopupPosition::End => room,
};
start.min(room)
};
let width = length(width.unwrap_or(PopupLength::Percent(50)), viewport.width);
let height = length(height.unwrap_or(PopupLength::Percent(50)), viewport.height);
FloatGeometry::new(
position(x.unwrap_or(PopupPosition::Centre), viewport.width, width),
position(y.unwrap_or(PopupPosition::Centre), viewport.height, height),
width,
height,
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CloseOnExit {
Always,
OnSuccess,
}
pub(crate) struct WaitedPopup {
pub session_id: SessionId,
pub floating_id: FloatingId,
pub buffer_id: BufferId,
pub close: CloseOnExit,
}
impl CliConnection {
pub(crate) async fn wait_for_popup(
&mut self,
socket: &Path,
popup: WaitedPopup,
) -> Result<i32> {
let mut events = ProtocolClient::connect(socket)
.await
.map_err(|error| MuxError::transport(error.to_string()))?;
match events
.request(&ClientMessage::Subscribe(SubscribeRequest {
request_id: new_request_id(),
session_id: Some(popup.session_id),
}))
.await
.map_err(|error| MuxError::transport(error.to_string()))?
{
ServerResponse::SubscriptionAck(_) => {}
other => {
return Err(MuxError::protocol(format!(
"unexpected response to display-popup subscribe: {other:?}"
)));
}
}
let mut exit_code = None;
let interrupted = interrupt_signal();
tokio::pin!(interrupted);
loop {
let open = self.popup_is_open(&popup).await.map_err(server_gone)?;
let buffer = match self
.request(ClientMessage::Buffer(BufferRequest::Get {
request_id: new_request_id(),
buffer_id: popup.buffer_id,
}))
.await
{
Ok(ServerResponse::Buffer(response)) => Some(response.buffer),
Ok(_) => None,
Err(error) if error.is_not_found() => None,
Err(error) => return Err(server_gone(error)),
};
let exited = buffer
.as_ref()
.is_some_and(|buffer| buffer.state == BufferRecordState::Exited);
if exited {
exit_code = buffer.as_ref().and_then(|buffer| buffer.exit_code);
}
if !open {
if !exited && buffer.is_some() {
self.kill_detached_buffers(vec![popup.buffer_id]).await?;
}
return Ok(exit_code.unwrap_or(0));
}
if exited && (popup.close == CloseOnExit::Always || exit_code == Some(0)) {
self.close_popup(&popup).await?;
return Ok(exit_code.unwrap_or(0));
}
tokio::select! {
envelope = events.recv() => match envelope {
Ok(Some(ServerEnvelope::Event(_) | ServerEnvelope::Response(_))) => {}
Ok(None) | Err(_) => return Err(server_gone(MuxError::transport("connection closed"))),
},
() = sleep(POLL_INTERVAL) => {}
signal = &mut interrupted => {
self.release_popup(&popup).await;
return Ok(128 + signal);
}
}
}
}
async fn popup_is_open(&mut self, popup: &WaitedPopup) -> Result<bool> {
match self.session_snapshot(popup.session_id).await {
Ok(snapshot) => Ok(snapshot
.floating
.iter()
.any(|floating| floating.id == popup.floating_id)),
Err(error) if error.is_not_found() => Ok(false),
Err(error) => Err(error),
}
}
async fn close_popup(&mut self, popup: &WaitedPopup) -> Result<()> {
match self
.request(ClientMessage::Floating(FloatingRequest::Close {
request_id: new_request_id(),
floating_id: popup.floating_id,
}))
.await
{
Ok(_) => {}
Err(error) if error.is_not_found() => {}
Err(error) => return Err(error),
}
Ok(())
}
async fn release_popup(&mut self, popup: &WaitedPopup) {
let result = self
.request(ClientMessage::Buffer(BufferRequest::SetUserOption {
request_id: new_request_id(),
buffer_id: popup.buffer_id,
key: REMAIN_ON_EXIT.to_owned(),
value: match popup.close {
CloseOnExit::Always => None,
CloseOnExit::OnSuccess => Some("failed".to_owned()),
},
}))
.await;
if let Err(error) = result {
tracing::warn!(%error, "failed to release the popup display-popup waited for");
}
}
}
fn server_gone(error: MuxError) -> MuxError {
if error.is_not_found() {
return error;
}
MuxError::transport(format!(
"the server went away before the popup closed: {error}"
))
}
#[cfg(unix)]
async fn interrupt_signal() -> i32 {
use tokio::signal::unix::{SignalKind, signal};
let (Ok(mut interrupt), Ok(mut terminate), Ok(mut hangup)) = (
signal(SignalKind::interrupt()),
signal(SignalKind::terminate()),
signal(SignalKind::hangup()),
) else {
return std::future::pending().await;
};
tokio::select! {
_ = interrupt.recv() => libc::SIGINT,
_ = terminate.recv() => libc::SIGTERM,
_ = hangup.recv() => libc::SIGHUP,
}
}
#[cfg(not(unix))]
async fn interrupt_signal() -> i32 {
let _ = tokio::signal::ctrl_c().await;
2
}
#[cfg(test)]
mod tests {
use super::*;
fn size(width: u16, height: u16) -> Size {
Size { width, height }
}
#[test]
fn popups_default_to_half_the_view_centred() {
assert_eq!(
popup_geometry(size(100, 40), None, None, None, None),
FloatGeometry::new(25, 10, 50, 20)
);
}
#[test]
fn popup_sizes_and_positions_take_cells_percentages_and_tokens() {
let percent = |value| Some(PopupLength::Percent(value));
assert_eq!(
popup_geometry(size(100, 40), percent(90), percent(90), None, None),
FloatGeometry::new(5, 2, 90, 36)
);
assert_eq!(
popup_geometry(
size(100, 40),
Some(PopupLength::Cells(20)),
Some(PopupLength::Cells(5)),
Some(PopupPosition::End),
Some(PopupPosition::Percent(10)),
),
FloatGeometry::new(80, 4, 20, 5)
);
assert_eq!(
popup_geometry(
size(100, 40),
Some(PopupLength::Cells(200)),
Some(PopupLength::Cells(10)),
Some(PopupPosition::Cells(90)),
Some(PopupPosition::Cells(35)),
),
FloatGeometry::new(0, 30, 100, 10)
);
}
#[test]
fn popup_arguments_parse_like_tmux() {
assert_eq!(parse_popup_length("90%"), Ok(PopupLength::Percent(90)));
assert_eq!(parse_popup_length("12"), Ok(PopupLength::Cells(12)));
assert!(parse_popup_length("0").is_err());
assert!(parse_popup_length("101%").is_err());
assert_eq!(parse_popup_position("C"), Ok(PopupPosition::Centre));
assert_eq!(parse_popup_position("R"), Ok(PopupPosition::End));
assert_eq!(parse_popup_position("0"), Ok(PopupPosition::Cells(0)));
assert_eq!(parse_popup_position("25%"), Ok(PopupPosition::Percent(25)));
assert!(parse_popup_position("M").is_err());
}
}