use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use super::{Renderer, UiLine};
enum RenderCmd {
Line(UiLine),
Flush,
FlushDeferred,
Resize(u16, u16),
PopApprovalPrompt,
ScrollBody(i32),
ScrollBodyToTop,
ScrollBodyToBottom,
BeginSelection(u16, u16),
UpdateSelection(u16, u16),
EndSelection,
CopySelection(mpsc::Sender<bool>),
Ack {
op: AckOp,
ack: mpsc::Sender<()>,
},
}
#[derive(Debug, Clone, Copy)]
enum AckOp {
Reset,
ClearScreen,
SuspendForExternal,
ResumeFromExternal,
Shutdown,
}
pub struct TaskRenderer {
cmd_tx: mpsc::Sender<RenderCmd>,
worker: Option<thread::JoinHandle<()>>,
}
impl TaskRenderer {
pub fn new(inner: Box<dyn Renderer>) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel::<RenderCmd>();
let worker = thread::Builder::new()
.name("tuix-render".to_string())
.spawn(move || run_worker(inner, cmd_rx))
.expect("spawn render worker thread");
Self {
cmd_tx,
worker: Some(worker),
}
}
fn ack(&self, op: AckOp) {
let (ack_tx, ack_rx) = mpsc::channel();
if self
.cmd_tx
.send(RenderCmd::Ack { op, ack: ack_tx })
.is_err()
{
return;
}
let _ = ack_rx.recv_timeout(Duration::from_secs(10));
}
}
impl Renderer for TaskRenderer {
fn render(&mut self, line: UiLine) {
let _ = self.cmd_tx.send(RenderCmd::Line(line));
}
fn flush(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::Flush);
}
fn shutdown(&mut self) {
self.ack(AckOp::Shutdown);
}
fn reset(&mut self) {
self.ack(AckOp::Reset);
}
fn clear_screen(&mut self) {
self.ack(AckOp::ClearScreen);
}
fn suspend_for_external(&mut self) {
self.ack(AckOp::SuspendForExternal);
}
fn resume_from_external(&mut self) {
self.ack(AckOp::ResumeFromExternal);
}
fn flush_deferred(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::FlushDeferred);
}
fn on_resize(&mut self, cols: u16, rows: u16) {
let _ = self.cmd_tx.send(RenderCmd::Resize(cols, rows));
}
fn pop_approval_prompt(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::PopApprovalPrompt);
}
fn scroll_body(&mut self, delta: i32) {
let _ = self.cmd_tx.send(RenderCmd::ScrollBody(delta));
}
fn scroll_body_to_top(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::ScrollBodyToTop);
}
fn scroll_body_to_bottom(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::ScrollBodyToBottom);
}
fn begin_selection(&mut self, col: u16, row: u16) {
let _ = self.cmd_tx.send(RenderCmd::BeginSelection(col, row));
}
fn update_selection(&mut self, col: u16, row: u16) {
let _ = self.cmd_tx.send(RenderCmd::UpdateSelection(col, row));
}
fn end_selection(&mut self) {
let _ = self.cmd_tx.send(RenderCmd::EndSelection);
}
fn copy_selection(&mut self) -> bool {
let (ack_tx, ack_rx) = mpsc::channel();
if self.cmd_tx.send(RenderCmd::CopySelection(ack_tx)).is_err() {
return false;
}
ack_rx.recv_timeout(Duration::from_secs(5)).unwrap_or(false)
}
}
impl Drop for TaskRenderer {
fn drop(&mut self) {
self.ack(AckOp::Shutdown);
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
}
}
fn run_worker(mut inner: Box<dyn Renderer>, cmd_rx: mpsc::Receiver<RenderCmd>) {
use std::time::Instant;
while let Ok(cmd) = cmd_rx.recv() {
match cmd {
RenderCmd::Line(line) => {
let tag = ui_line_tag(&line);
let t0 = Instant::now();
inner.render(line);
crate::tuix_trace!("REN", "Line {} render={}µs", tag, t0.elapsed().as_micros());
}
RenderCmd::Flush => {
let t0 = Instant::now();
inner.flush();
crate::tuix_trace!("REN", "Flush flush={}µs", t0.elapsed().as_micros());
}
RenderCmd::FlushDeferred => {
let t0 = Instant::now();
inner.flush_deferred();
let d = t0.elapsed();
if d.as_micros() > 100 {
crate::tuix_trace!("REN", "FlushDeferred deferred={}µs", d.as_micros());
}
}
RenderCmd::Resize(cols, rows) => {
let t0 = Instant::now();
inner.on_resize(cols, rows);
crate::tuix_trace!(
"REN",
"Resize {}x{} dur={}µs",
cols,
rows,
t0.elapsed().as_micros()
);
}
RenderCmd::PopApprovalPrompt => {
inner.pop_approval_prompt();
}
RenderCmd::ScrollBody(delta) => {
inner.scroll_body(delta);
}
RenderCmd::ScrollBodyToTop => {
inner.scroll_body_to_top();
}
RenderCmd::ScrollBodyToBottom => {
inner.scroll_body_to_bottom();
}
RenderCmd::BeginSelection(col, row) => {
inner.begin_selection(col, row);
}
RenderCmd::UpdateSelection(col, row) => {
inner.update_selection(col, row);
}
RenderCmd::EndSelection => {
inner.end_selection();
}
RenderCmd::CopySelection(ack) => {
let result = inner.copy_selection();
let _ = ack.send(result);
}
RenderCmd::Ack { op, ack } => {
let t0 = Instant::now();
match op {
AckOp::Reset => inner.reset(),
AckOp::ClearScreen => inner.clear_screen(),
AckOp::SuspendForExternal => inner.suspend_for_external(),
AckOp::ResumeFromExternal => inner.resume_from_external(),
AckOp::Shutdown => {
inner.shutdown();
crate::tuix_trace!(
"REN",
"Ack Shutdown dur={}µs",
t0.elapsed().as_micros()
);
let _ = ack.send(());
return;
}
}
crate::tuix_trace!("REN", "Ack {:?} dur={}µs", op, t0.elapsed().as_micros());
let _ = ack.send(());
}
}
}
inner.shutdown();
}
fn ui_line_tag(l: &UiLine) -> &'static str {
match l {
UiLine::Welcome { .. } => "Welcome",
UiLine::User(_) => "User",
UiLine::AssistantText(_) => "AssistantText",
UiLine::ReasoningText(_) => "ReasoningText",
UiLine::AssistantLineBreak => "AssistantLineBreak",
UiLine::ToolCall { .. } => "ToolCall",
UiLine::ToolCallInFlight { .. } => "ToolCallInFlight",
UiLine::ToolCallCommit { .. } => "ToolCallCommit",
UiLine::ToolGroupRender { .. } => "ToolGroupRender",
UiLine::ToolGroupChildUpdate { .. } => "ToolGroupChildUpdate",
UiLine::ToolGroupSummary { .. } => "ToolGroupSummary",
UiLine::ToolResult { .. } => "ToolResult",
UiLine::DiffLine { .. } => "DiffLine",
UiLine::DiffBlock(_) => "DiffBlock",
UiLine::ApprovalPrompt { .. } => "ApprovalPrompt",
UiLine::Error(_) => "Error",
UiLine::Warning(_) => "Warning",
UiLine::TurnCancelled => "TurnCancelled",
UiLine::TurnComplete => "TurnComplete",
UiLine::Spinner { .. } => "Spinner",
UiLine::StreamingBox { .. } => "StreamingBox",
UiLine::ClearTransient => "ClearTransient",
UiLine::InputPrompt { .. } => "InputPrompt",
UiLine::InputCommit => "InputCommit",
UiLine::CommandOutput(_) => "CommandOutput",
UiLine::ImageAttachment(_) => "ImageAttachment",
UiLine::VisionPreprocessSuccess { .. } => "VisionPreprocessSuccess",
UiLine::TurnSeparator { .. } => "TurnSeparator",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Renderer;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Counts {
renders: usize,
flushes: usize,
shutdowns: usize,
resets: usize,
clear_screens: usize,
suspends: usize,
resumes: usize,
deferred: usize,
}
struct TestRenderer {
counts: Arc<Mutex<Counts>>,
}
impl Renderer for TestRenderer {
fn render(&mut self, _line: UiLine) {
self.counts.lock().unwrap().renders += 1;
}
fn flush(&mut self) {
self.counts.lock().unwrap().flushes += 1;
}
fn shutdown(&mut self) {
self.counts.lock().unwrap().shutdowns += 1;
}
fn reset(&mut self) {
self.counts.lock().unwrap().resets += 1;
}
fn clear_screen(&mut self) {
self.counts.lock().unwrap().clear_screens += 1;
}
fn suspend_for_external(&mut self) {
self.counts.lock().unwrap().suspends += 1;
}
fn resume_from_external(&mut self) {
self.counts.lock().unwrap().resumes += 1;
}
fn flush_deferred(&mut self) {
self.counts.lock().unwrap().deferred += 1;
}
}
fn setup() -> (TaskRenderer, Arc<Mutex<Counts>>) {
let counts = Arc::new(Mutex::new(Counts::default()));
let inner = Box::new(TestRenderer {
counts: counts.clone(),
});
(TaskRenderer::new(inner), counts)
}
#[test]
fn render_and_flush_forward_to_inner() {
let (mut r, counts) = setup();
r.render(UiLine::User("hi".into()));
r.render(UiLine::User("there".into()));
r.flush();
r.reset();
let c = counts.lock().unwrap();
assert_eq!(c.renders, 2);
assert_eq!(c.flushes, 1);
assert_eq!(c.resets, 1);
}
#[test]
fn lifecycle_ack_blocks_until_worker_done() {
let (mut r, counts) = setup();
r.clear_screen();
assert_eq!(counts.lock().unwrap().clear_screens, 1);
r.suspend_for_external();
assert_eq!(counts.lock().unwrap().suspends, 1);
r.resume_from_external();
assert_eq!(counts.lock().unwrap().resumes, 1);
}
#[test]
fn shutdown_drops_worker_and_later_sends_are_noops() {
let (mut r, counts) = setup();
r.render(UiLine::User("before".into()));
r.shutdown();
assert_eq!(counts.lock().unwrap().shutdowns, 1);
r.render(UiLine::User("after".into()));
r.flush();
r.shutdown();
}
#[test]
fn drop_triggers_shutdown_when_not_called_explicitly() {
let counts = {
let counts = Arc::new(Mutex::new(Counts::default()));
let inner = Box::new(TestRenderer {
counts: counts.clone(),
});
let mut r = TaskRenderer::new(inner);
r.render(UiLine::User("one".into()));
counts
};
let c = counts.lock().unwrap();
assert_eq!(c.renders, 1);
assert_eq!(c.shutdowns, 1);
}
#[test]
fn flush_deferred_fire_and_forget() {
let (mut r, counts) = setup();
r.flush_deferred();
r.reset();
assert_eq!(counts.lock().unwrap().deferred, 1);
}
}