use std::io::{self, Write};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use ratatui::prelude::*;
use termina::{EventStream, PlatformTerminal, Terminal};
use tokio::sync::mpsc::{Receiver, Sender};
use shuvarie_core::{Command, Config, Connections, Event as CoreEvent, ThemeSet};
use crate::tui::event::Event;
use self::app::{App, AppEffect, AppMessage};
pub mod add_provider;
mod app;
mod command_menu;
mod commands;
mod components;
mod confirm_quit;
mod context;
mod escape;
mod event;
mod history_search;
mod list;
mod model_picker;
mod permission;
mod question;
mod registry;
mod scene;
mod search;
mod session;
mod session_picker;
mod sidebar;
mod slash;
mod spinner;
mod theme;
mod title;
pub mod trust;
mod utils;
mod variant;
mod warning;
mod welcome;
mod workspace;
pub enum TuiResponse {
SessionSaved { session_id: uuid::Uuid },
}
pub async fn run_tui(
config: Config,
theme_set: ThemeSet,
cmd_tx: Sender<Command>,
event_rx: Receiver<CoreEvent>,
) -> io::Result<Option<TuiResponse>> {
let mut term = PlatformTerminal::new()?;
term.enter_raw_mode()?;
let reader = term.event_reader();
let theme = theme_set.resolve(
config.ui.theme.as_deref(),
self::theme::detect_variant(&mut term, &reader),
);
let event_stream = EventStream::new(reader, |_| true);
let initial_cols = term.get_dimensions()?.cols;
let frame_budget = frame_budget(config.ui.frame_rate);
let mut rat = ratatui::Terminal::new(TerminaBackend::new(term))?;
let connections = Connections::load().map_err(|e| io::Error::other(e.to_string()))?;
let workspace = workspace::WorkspaceInfo::detect();
let app = App::new(
config.ui,
theme,
config.registries.selune(),
connections,
cmd_tx,
initial_cols,
workspace,
);
init_terminal(rat.backend_mut().terminal_mut())?;
let session_id = render_tui(app, &mut rat, event_rx, event_stream, frame_budget).await;
let deinit = deinit_terminal(rat.backend_mut().terminal_mut());
let session_id = session_id?;
deinit?;
if let Some(session_id) = session_id {
return Ok(Some(TuiResponse::SessionSaved { session_id }));
}
Ok(None)
}
fn frame_budget(frame_rate: u32) -> Option<Duration> {
if frame_rate == 0 {
None
} else {
Some(Duration::from_secs_f64(1.0 / frame_rate as f64))
}
}
async fn render_tui(
mut app: App,
rat: &mut ratatui::Terminal<TerminaBackend<PlatformTerminal>>,
mut event_rx: Receiver<CoreEvent>,
mut event_stream: EventStream,
frame_budget: Option<Duration>,
) -> io::Result<Option<uuid::Uuid>>
where
io::Error: From<<TerminaBackend<PlatformTerminal> as Backend>::Error>,
{
let mut last_draw: Instant;
let mut frame_deadline: Option<tokio::time::Instant> = None;
let mut last_title = String::new();
let mut spinner_wake;
let mut picker_wake;
const PICKER_REFRESH: Duration = Duration::from_secs(2);
'render_loop: loop {
sync_window_title(
rat.backend_mut().terminal_mut(),
&mut last_title,
&app.window_title(),
)?;
rat.draw(|frame| app.view(frame, frame.area()))?;
last_draw = Instant::now();
spinner_wake = spinner::next_wake(app.active_spinners())
.map(|until| tokio::time::Instant::now() + until);
picker_wake = app
.session_picker
.open
.then(|| tokio::time::Instant::now() + PICKER_REFRESH);
'event_listening: loop {
let changed = tokio::select! {
_ = async {
if let Some(deadline) = frame_deadline {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
}, if frame_deadline.is_some() => {
frame_deadline = None;
break 'event_listening;
}
_ = async {
if let Some(deadline) = spinner_wake {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
}, if spinner_wake.is_some() => {
spinner_wake = None;
apply_msg(
&mut app,
rat.backend_mut().terminal_mut(),
Some(AppMessage::SpinnerUpdate),
)
}
_ = async {
if let Some(deadline) = picker_wake {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
}, if picker_wake.is_some() => {
picker_wake = Some(tokio::time::Instant::now() + PICKER_REFRESH);
apply_msg(
&mut app,
rat.backend_mut().terminal_mut(),
Some(AppMessage::PickerRefresh),
)
}
ev = event_stream.next() => {
let Some(ev_result) = ev else { break 'render_loop Ok(app.session.session_id); };
let msg = app.map_event(Event::Terminal(ev_result?));
apply_msg(&mut app, rat.backend_mut().terminal_mut(), msg)
}
ev = event_rx.recv() => {
let Some(ev) = ev else { break 'render_loop Ok(app.session.session_id); };
let msg = app.map_event(Event::Core(ev));
apply_msg(&mut app, rat.backend_mut().terminal_mut(), msg)
}
};
if app.quit_requested() {
break 'render_loop Ok(app.session.session_id);
}
if !changed {
continue;
}
match frame_budget {
None => {
frame_deadline = None;
break 'event_listening;
}
Some(budget) => {
if last_draw.elapsed() >= budget {
frame_deadline = None;
break 'event_listening;
}
frame_deadline = Some(tokio::time::Instant::from_std(last_draw + budget));
}
}
}
}
}
fn apply_msg(app: &mut App, terminal: &mut PlatformTerminal, msg: Option<AppMessage>) -> bool {
if let Some(msg) = msg {
match app.update(msg) {
Some(AppEffect::Quit) => app.mark_quit(),
Some(AppEffect::CopyToClipboard(text)) => {
let _ = write!(terminal, "{}", escape::set_clipboard(&text));
let _ = terminal.flush();
}
None => {}
}
true
} else {
false
}
}
fn sync_window_title<W: io::Write>(
terminal: &mut W,
last: &mut String,
title: &str,
) -> io::Result<()> {
if *last != title {
write!(terminal, "{}", escape::set_window_title(title))?;
terminal.flush()?;
last.clear();
last.push_str(title);
}
Ok(())
}
fn init_terminal(terminal: &mut PlatformTerminal) -> io::Result<()> {
write!(
terminal,
"{}{}{}{}{}{}",
escape::ENTER_ALTERNATE_SCREEN,
escape::ENABLE_MOUSE,
escape::ENABLE_SGR_MOUSE,
escape::ENABLE_KITTY_KEYBOARD,
escape::ENABLE_BRACKETED_PASTE,
escape::push_window_title()
)?;
terminal.flush()?;
Ok(())
}
fn deinit_terminal(terminal: &mut PlatformTerminal) -> io::Result<()> {
write!(
terminal,
"{}{}{}{}{}{}",
escape::DISABLE_KITTY_KEYBOARD,
escape::DISABLE_SGR_MOUSE,
escape::DISABLE_MOUSE,
escape::DISABLE_BRACKETED_PASTE,
escape::EXIT_ALTERNATE_SCREEN,
escape::pop_window_title()
)?;
terminal.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_window_title_writes_only_on_change() {
let mut out = Vec::new();
let mut last = String::new();
sync_window_title(&mut out, &mut last, "Shuvarie").unwrap();
assert!(!out.is_empty());
assert_eq!(last, "Shuvarie");
let unchanged = out.len();
sync_window_title(&mut out, &mut last, "Shuvarie").unwrap();
assert_eq!(out.len(), unchanged);
sync_window_title(&mut out, &mut last, "Shuvarie — fix the bug").unwrap();
assert!(out.len() > unchanged);
assert_eq!(last, "Shuvarie — fix the bug");
}
}