use color_eyre::Result;
use crossterm::{
cursor,
event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyEventKind,
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
use std::io::{self, Stdout, stdout};
use std::time::Duration;
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use crate::event::Event;
use crate::session::visibility::{self, PowerState};
pub(crate) type Terminal = ratatui::Terminal<CrosstermBackend<Stdout>>;
const VISIBILITY_PROBE_INTERVAL: Duration = Duration::from_secs(3);
const DOZE_TICK_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) struct Tui {
pub terminal: Terminal,
pub event_tx: UnboundedSender<Event>,
pub event_rx: UnboundedReceiver<Event>,
task: Option<JoinHandle<()>>,
cancellation_token: CancellationToken,
tick_rate: Duration,
poll_local_interval: Duration,
poll_fetch_interval: Duration,
sleep_when_hidden: bool,
doze_after: Duration,
mouse: bool,
}
impl Tui {
pub fn new() -> Result<Self> {
let backend = CrosstermBackend::new(stdout());
let terminal = ratatui::Terminal::new(backend)?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
Ok(Self {
terminal,
event_tx,
event_rx,
task: None,
cancellation_token: CancellationToken::new(),
tick_rate: Duration::from_millis(250),
poll_local_interval: Duration::from_secs(5),
poll_fetch_interval: Duration::from_secs(60),
sleep_when_hidden: true,
doze_after: Duration::from_secs(120),
mouse: false,
})
}
#[allow(dead_code)]
pub fn mouse(mut self, mouse: bool) -> Self {
self.mouse = mouse;
self
}
pub fn poll_local_interval(mut self, interval: Duration) -> Self {
self.poll_local_interval = interval;
self
}
pub fn poll_fetch_interval(mut self, interval: Duration) -> Self {
self.poll_fetch_interval = interval;
self
}
pub fn sleep_when_hidden(mut self, enabled: bool) -> Self {
self.sleep_when_hidden = enabled;
self
}
pub fn doze_after(mut self, idle: Duration) -> Self {
self.doze_after = idle;
self
}
pub fn enter(&mut self) -> Result<()> {
self.cancellation_token = CancellationToken::new();
let mouse = self.mouse;
enable_raw_mode()?;
let setup = (|| -> io::Result<()> {
crossterm::execute!(
io::stdout(),
EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange,
)?;
if mouse {
crossterm::execute!(io::stdout(), EnableMouseCapture)?;
}
Ok(())
})();
if let Err(e) = setup {
let _ = crossterm::execute!(
io::stdout(),
LeaveAlternateScreen,
DisableBracketedPaste,
DisableFocusChange,
cursor::Show,
);
let _ = disable_raw_mode();
return Err(e.into());
}
self.install_panic_hook();
self.start_event_loop();
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
self.cancellation_token.cancel();
if let Some(task) = self.task.take() {
task.abort();
}
let mut first_err: Option<std::io::Error> = None;
if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
if self.mouse
&& let Err(e) = crossterm::execute!(io::stdout(), DisableMouseCapture)
{
first_err.get_or_insert(e);
}
if let Err(e) = crossterm::execute!(
io::stdout(),
LeaveAlternateScreen,
DisableBracketedPaste,
DisableFocusChange,
cursor::Show,
) {
first_err.get_or_insert(e);
}
if let Err(e) = disable_raw_mode() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
fn install_panic_hook(&self) {
static HOOK: std::sync::Once = std::sync::Once::new();
HOOK.call_once(|| {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
LeaveAlternateScreen,
DisableBracketedPaste,
DisableFocusChange,
DisableMouseCapture,
cursor::Show,
);
original_hook(panic_info);
}));
});
}
fn start_event_loop(&mut self) {
let tick_rate = self.tick_rate;
let poll_local = self.poll_local_interval;
let poll_fetch = self.poll_fetch_interval;
let doze_after = self.doze_after;
let probe_pane = self
.sleep_when_hidden
.then(|| std::env::var("TMUX_PANE").ok())
.flatten();
let (input_tx, input_rx) = if probe_pane.is_none() && self.sleep_when_hidden {
let (tx, rx) = mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
let event_tx = self.event_tx.clone();
let token = self.cancellation_token.clone();
self.task = Some(tokio::spawn(async move {
let mut reader = EventStream::new();
let mut tick_interval = tokio::time::interval(tick_rate);
let mut local_timer = tokio::time::interval(poll_local);
let mut fetch_timer = tokio::time::interval(poll_fetch);
tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
local_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
fetch_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut power_rx =
spawn_visibility_probe(probe_pane, input_rx, doze_after, token.clone());
let mut power = PowerState::Awake;
let mut reset_fetch = false;
let mut retune_tick: Option<Duration> = None;
let _ = event_tx.send(Event::Init);
loop {
if reset_fetch {
fetch_timer.reset();
reset_fetch = false;
}
if let Some(rate) = retune_tick.take() {
tick_interval = tokio::time::interval(rate);
tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
let awake = power == PowerState::Awake;
let ticking = power != PowerState::DeepSleep;
let tick_delay = tick_interval.tick();
let local_delay = local_timer.tick();
let fetch_delay = fetch_timer.tick();
let crossterm_event = reader.next();
tokio::select! {
_ = token.cancelled() => break,
Some(state) = power_rx.recv() => {
if state == PowerState::Awake && power != PowerState::Awake {
reset_fetch = true;
}
if state != power {
retune_tick = Some(if state == PowerState::Awake {
tick_rate
} else {
DOZE_TICK_INTERVAL
});
}
power = state;
let _ = event_tx.send(Event::Power(state));
}
_ = tick_delay, if ticking => {
let _ = event_tx.send(Event::Tick);
}
_ = local_delay, if awake => {
let _ = event_tx.send(Event::PollLocal);
let _ = event_tx.send(Event::Render);
}
_ = fetch_delay, if awake => {
let _ = event_tx.send(Event::PollFetch);
let _ = event_tx.send(Event::Render);
}
Some(Ok(event)) = crossterm_event => {
if let Some(tx) = &input_tx
&& !matches!(
&event,
CrosstermEvent::Mouse(m)
if m.kind == crossterm::event::MouseEventKind::Moved
)
{
let _ = tx.send(());
}
match event {
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => {
let _ = event_tx.send(Event::Key(key));
}
CrosstermEvent::Mouse(mouse) => {
let _ = event_tx.send(Event::Mouse(mouse));
}
CrosstermEvent::Paste(text) => {
let _ = event_tx.send(Event::Paste(text));
}
CrosstermEvent::Resize(w, h) => {
let _ = event_tx.send(Event::Resize(w, h));
}
CrosstermEvent::FocusGained => {
let _ = event_tx.send(Event::FocusGained);
}
CrosstermEvent::FocusLost => {
let _ = event_tx.send(Event::FocusLost);
}
_ => {}
}
let _ = event_tx.send(Event::Render);
}
}
}
}));
}
}
impl Drop for Tui {
fn drop(&mut self) {
let _ = self.exit();
}
}
fn emit_if_changed(
tx: &UnboundedSender<PowerState>,
last: &mut Option<PowerState>,
state: PowerState,
) -> bool {
if *last == Some(state) {
return true;
}
*last = Some(state);
tx.send(state).is_ok()
}
fn spawn_visibility_probe(
pane: Option<String>,
input_rx: Option<UnboundedReceiver<()>>,
doze_after: Duration,
token: CancellationToken,
) -> UnboundedReceiver<PowerState> {
let (tx, rx) = mpsc::unbounded_channel();
let Some(pane) = pane else {
let Some(input_rx) = input_rx else {
return rx;
};
spawn_input_idle_probe(input_rx, doze_after, token, tx);
return rx;
};
tokio::spawn(async move {
let mut interval = tokio::time::interval(VISIBILITY_PROBE_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last: Option<PowerState> = None;
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = interval.tick() => {}
}
let pane_arg = pane.clone();
let output = tokio::task::spawn_blocking(move || visibility::probe(&pane_arg))
.await
.ok()
.flatten();
let now_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let state = output
.and_then(|o| visibility::parse_power_state(&o, now_epoch, doze_after))
.unwrap_or(PowerState::Awake);
if !emit_if_changed(&tx, &mut last, state) {
break;
}
}
});
rx
}
fn spawn_input_idle_probe(
mut input_rx: UnboundedReceiver<()>,
doze_after: Duration,
token: CancellationToken,
tx: UnboundedSender<PowerState>,
) {
tokio::spawn(async move {
let mut last = None;
if !emit_if_changed(&tx, &mut last, PowerState::Awake) {
return;
}
let mut deadline = tokio::time::Instant::now() + doze_after;
loop {
tokio::select! {
_ = token.cancelled() => break,
maybe = input_rx.recv() => {
let Some(()) = maybe else { break };
deadline = tokio::time::Instant::now() + doze_after;
if !emit_if_changed(&tx, &mut last, PowerState::Awake) {
break;
}
}
_ = tokio::time::sleep_until(deadline),
if last != Some(PowerState::DeepSleep) =>
{
if !emit_if_changed(&tx, &mut last, PowerState::DeepSleep) {
break;
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn input_idle_probe_deep_sleeps_and_wakes_on_input() {
let (input_tx, input_rx) = mpsc::unbounded_channel();
let (state_tx, mut state_rx) = mpsc::unbounded_channel();
let token = CancellationToken::new();
spawn_input_idle_probe(
input_rx,
Duration::from_millis(100),
token.clone(),
state_tx,
);
assert_eq!(state_rx.recv().await, Some(PowerState::Awake));
assert_eq!(state_rx.recv().await, Some(PowerState::DeepSleep));
input_tx.send(()).unwrap();
assert_eq!(state_rx.recv().await, Some(PowerState::Awake));
token.cancel();
}
}