use crate::script::convert::err;
use crossterm::tty::IsTty;
use crossterm::{cursor, execute, queue, style::Print, terminal};
use rhai::{Array, Dynamic, Engine, EvalAltResult, FnPtr, NativeCallContext};
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
pub fn register(engine: &mut Engine) {
let mut module = rhai::Module::new();
let _ = module.set_native_fn(
"run",
|ctx: NativeCallContext, callback: FnPtr| -> Result<(), Box<EvalAltResult>> {
run_dashboard(&ctx, callback)
},
);
engine.register_static_module("tui", module.into());
engine.register_type_with_name::<Dashboard>("Dashboard");
engine.register_type_with_name::<PaneHandle>("PaneHandle");
engine.register_fn(
"split_vertical",
|d: &mut Dashboard, percents: Array| -> Result<Array, Box<EvalAltResult>> {
d.split(LayoutKind::Vertical, percents)
},
);
engine.register_fn(
"split_horizontal",
|d: &mut Dashboard, percents: Array| -> Result<Array, Box<EvalAltResult>> {
d.split(LayoutKind::Horizontal, percents)
},
);
engine.register_fn("println", |p: &mut PaneHandle, line: &str| {
p.send(PaneUpdate::Push(p.idx, line.to_string()));
});
engine.register_fn("title", |p: &mut PaneHandle, t: &str| {
p.send(PaneUpdate::Title(p.idx, t.to_string()));
});
engine.register_fn("clear", |p: &mut PaneHandle| {
p.send(PaneUpdate::Clear(p.idx));
});
}
#[derive(Clone)]
struct PaneHandle {
idx: usize,
tx: mpsc::Sender<PaneUpdate>,
}
impl PaneHandle {
fn send(&self, u: PaneUpdate) {
let _ = self.tx.send(u);
}
}
#[derive(Clone)]
struct Dashboard {
tx: mpsc::Sender<PaneUpdate>,
layout_set: Arc<AtomicBool>,
}
impl Dashboard {
fn split(
&self,
kind: LayoutKind,
percents: Array,
) -> Result<Array, Box<EvalAltResult>> {
if self.layout_set.swap(true, Ordering::SeqCst) {
return Err(err(
"tui: split_vertical / split_horizontal can only be called once per dashboard",
));
}
if percents.is_empty() {
return Err(err("tui: split needs at least one percent"));
}
let mut ps: Vec<u8> = Vec::with_capacity(percents.len());
for (i, d) in percents.iter().enumerate() {
let v = d
.as_int()
.map_err(|_| err(format!("tui: percent[{i}] must be an integer")))?;
if v <= 0 || v > 100 {
return Err(err(format!(
"tui: percent[{i}] = {v} out of (0, 100]"
)));
}
ps.push(v as u8);
}
let n = ps.len();
let _ = self.tx.send(PaneUpdate::Layout(kind, ps));
let mut out = Array::with_capacity(n);
for i in 0..n {
out.push(Dynamic::from(PaneHandle {
idx: i,
tx: self.tx.clone(),
}));
}
Ok(out)
}
}
#[derive(Clone, Copy, Default)]
enum LayoutKind {
#[default]
Vertical,
Horizontal,
}
enum PaneUpdate {
Layout(LayoutKind, Vec<u8>),
Push(usize, String),
Title(usize, String),
Clear(usize),
Shutdown,
}
fn run_dashboard(
ctx: &NativeCallContext,
callback: FnPtr,
) -> Result<(), Box<EvalAltResult>> {
if !io::stdout().is_tty() {
return Err(err(
"tui::run: stdout is not a TTY (piped or redirected)",
));
}
if TUI_ACTIVE.swap(true, Ordering::SeqCst) {
return Err(err(
"tui::run: another dashboard is already active in this process",
));
}
let (tx, rx) = mpsc::channel::<PaneUpdate>();
struct RestoreGuard;
impl Drop for RestoreGuard {
fn drop(&mut self) {
let _ = execute!(
io::stdout(),
terminal::LeaveAlternateScreen,
cursor::Show
);
TUI_ACTIVE.store(false, Ordering::SeqCst);
}
}
if let Err(e) = execute!(
io::stdout(),
terminal::EnterAlternateScreen,
cursor::Hide
) {
TUI_ACTIVE.store(false, Ordering::SeqCst);
return Err(err(format!("tui::run: enter alt screen: {e}")));
}
let _guard = RestoreGuard;
let _ = ctrlc::try_set_handler(|| {
let _ = execute!(
io::stdout(),
terminal::LeaveAlternateScreen,
cursor::Show
);
std::process::exit(130);
});
let renderer = thread::spawn(move || renderer_loop(rx));
let dashboard = Dashboard {
tx: tx.clone(),
layout_set: Arc::new(AtomicBool::new(false)),
};
let dyn_d: Dynamic = Dynamic::from(dashboard);
let result = callback.call_within_context::<Dynamic>(ctx, (dyn_d,));
let _ = tx.send(PaneUpdate::Shutdown);
let _ = renderer.join();
let _ = io::stdout().flush();
result.map(|_| ())
}
fn renderer_loop(rx: mpsc::Receiver<PaneUpdate>) {
let mut state = RendererState::default();
loop {
match rx.recv_timeout(Duration::from_millis(150)) {
Ok(PaneUpdate::Shutdown) => return,
Ok(PaneUpdate::Layout(kind, percents)) => {
state.kind = kind;
state.percents = percents.clone();
state.panes = (0..percents.len()).map(|_| PaneState::default()).collect();
let _ = state.redraw();
}
Ok(PaneUpdate::Push(i, line)) => {
if let Some(p) = state.panes.get_mut(i) {
p.lines.push(line);
const MAX: usize = 1000;
if p.lines.len() > MAX {
let trim = p.lines.len() - MAX;
p.lines.drain(..trim);
}
}
let _ = state.redraw();
}
Ok(PaneUpdate::Title(i, t)) => {
if let Some(p) = state.panes.get_mut(i) {
p.title = Some(t);
}
let _ = state.redraw();
}
Ok(PaneUpdate::Clear(i)) => {
if let Some(p) = state.panes.get_mut(i) {
p.lines.clear();
}
let _ = state.redraw();
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok((w, h)) = terminal::size() {
if state.last_size != (w, h) {
let _ = state.redraw();
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
}
#[derive(Default, Clone)]
struct PaneState {
title: Option<String>,
lines: Vec<String>,
}
#[derive(Default)]
struct RendererState {
kind: LayoutKind,
percents: Vec<u8>,
panes: Vec<PaneState>,
last_size: (u16, u16),
}
impl RendererState {
fn redraw(&mut self) -> io::Result<()> {
let (w, h) = terminal::size()?;
self.last_size = (w, h);
if self.panes.is_empty() {
return Ok(());
}
let regions = compute_regions(self.kind, &self.percents, w, h);
let mut out = io::stdout().lock();
queue!(out, terminal::Clear(terminal::ClearType::All))?;
for (i, region) in regions.iter().enumerate() {
let pane = &self.panes[i];
queue!(out, cursor::MoveTo(region.x, region.y))?;
let title = pane.title.as_deref().unwrap_or("");
let title_text = if title.is_empty() {
String::new()
} else {
format!(" {title} ")
};
let pad_len = (region.w as usize).saturating_sub(title_text.chars().count());
let rule = "─".repeat(pad_len);
queue!(out, Print(format!("{title_text}{rule}")))?;
let content_h = region.h.saturating_sub(1) as usize;
let start = pane.lines.len().saturating_sub(content_h);
for (li, line) in pane.lines[start..].iter().enumerate() {
queue!(
out,
cursor::MoveTo(region.x, region.y + 1 + li as u16)
)?;
let truncated: String = line.chars().take(region.w as usize).collect();
queue!(out, Print(&truncated))?;
}
}
out.flush()?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Region {
x: u16,
y: u16,
w: u16,
h: u16,
}
fn compute_regions(kind: LayoutKind, percents: &[u8], w: u16, h: u16) -> Vec<Region> {
let total: u32 = percents.iter().map(|p| *p as u32).sum();
if total == 0 {
return Vec::new();
}
let mut out = Vec::with_capacity(percents.len());
match kind {
LayoutKind::Vertical => {
let mut y = 0u16;
for (i, p) in percents.iter().enumerate() {
let height = if i + 1 == percents.len() {
h.saturating_sub(y)
} else {
((h as u32) * (*p as u32) / total) as u16
};
out.push(Region { x: 0, y, w, h: height });
y = y.saturating_add(height);
}
}
LayoutKind::Horizontal => {
let mut x = 0u16;
for (i, p) in percents.iter().enumerate() {
let width = if i + 1 == percents.len() {
w.saturating_sub(x)
} else {
((w as u32) * (*p as u32) / total) as u16
};
out.push(Region { x, y: 0, w: width, h });
x = x.saturating_add(width);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vertical_split_sums_to_full_height() {
let r = compute_regions(LayoutKind::Vertical, &[60, 40], 80, 24);
assert_eq!(r.len(), 2);
assert_eq!(r[0], Region { x: 0, y: 0, w: 80, h: 14 });
assert_eq!(r[1], Region { x: 0, y: 14, w: 80, h: 10 });
assert_eq!(r[0].h + r[1].h, 24);
}
#[test]
fn horizontal_split_three_panes() {
let r = compute_regions(LayoutKind::Horizontal, &[33, 33, 34], 90, 30);
assert_eq!(r.len(), 3);
for region in &r {
assert_eq!(region.h, 30);
assert_eq!(region.y, 0);
}
let total_w: u16 = r.iter().map(|x| x.w).sum();
assert_eq!(total_w, 90);
}
#[test]
fn split_handles_uneven_percentages() {
let r = compute_regions(LayoutKind::Vertical, &[20, 30, 50], 80, 100);
let total_h: u16 = r.iter().map(|x| x.h).sum();
assert_eq!(total_h, 100);
}
#[test]
fn zero_total_returns_empty() {
let r = compute_regions(LayoutKind::Vertical, &[], 80, 24);
assert!(r.is_empty());
}
}