ratkit 0.2.16

A comprehensive collection of reusable TUI components for ratatui including resizable splits, tree views, markdown rendering, toast notifications, dialogs, and terminal embedding
Documentation
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;

use anyhow::Result;
use crossterm::{cursor::SetCursorStyle, event::KeyCode, event::KeyModifiers, execute};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders};
use ratatui::Frame;
use ratkit::primitives::termtui::{render_screen, CursorStyle, Parser, VtEvent};
use ratkit::{
    run_with_diagnostics, CoordinatorAction, CoordinatorApp, CoordinatorEvent, KeyboardEvent,
    RedrawSignal, ResizeEvent, RunnerConfig,
};

struct TermMprocsTerminal {
    parser: Arc<Mutex<Parser>>,
    writer: Arc<Mutex<Box<dyn Write + Send>>>,
    master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
    redraw_signal: RedrawSignal,
    _child: Box<dyn Child + Send + Sync>,
}

impl TermMprocsTerminal {
    fn spawn_shell(rows: u16, cols: u16) -> Result<Self> {
        let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_string());
        Self::spawn_with_command(&shell, &[], rows, cols)
    }

    fn spawn_with_command(command: &str, args: &[&str], rows: u16, cols: u16) -> Result<Self> {
        let pty_system = native_pty_system();
        let pty_size = PtySize {
            rows,
            cols,
            pixel_width: 0,
            pixel_height: 0,
        };
        let pair = pty_system.openpty(pty_size)?;

        let mut cmd = CommandBuilder::new(command);
        for arg in args {
            cmd.arg(arg);
        }
        cmd.env("TERM", "xterm-256color");
        cmd.cwd(std::env::current_dir()?);

        let child = pair.slave.spawn_command(cmd)?;

        let mut master = pair.master;
        let mut reader = master.try_clone_reader()?;
        let writer = master.take_writer()?;

        let writer = Arc::new(Mutex::new(writer));
        let parser = Arc::new(Mutex::new(Parser::new(rows, cols, 10000)));
        let redraw_signal = RedrawSignal::new();
        redraw_signal.request_redraw();

        let parser_clone = Arc::clone(&parser);
        let writer_clone = Arc::clone(&writer);
        let redraw_signal_clone = redraw_signal.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 8192];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(n) => {
                        let mut events = Vec::new();
                        if let Ok(mut parser) = parser_clone.lock() {
                            parser.screen.process(&buf[..n], &mut events);
                            redraw_signal_clone.request_redraw();
                        }
                        if !events.is_empty() {
                            if let Ok(mut writer) = writer_clone.lock() {
                                for event in events {
                                    if let VtEvent::Reply(reply) = event {
                                        let _ = writer.write_all(reply.as_bytes());
                                        let _ = writer.flush();
                                    }
                                }
                            }
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(Self {
            parser,
            writer,
            master: Arc::new(Mutex::new(master)),
            redraw_signal,
            _child: child,
        })
    }

    fn resize(&mut self, rows: u16, cols: u16) {
        if rows == 0 || cols == 0 {
            return;
        }
        if let Ok(mut parser) = self.parser.lock() {
            parser.set_size(rows, cols);
        }
        if let Ok(mut master) = self.master.lock() {
            let _ = master.resize(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            });
        }
        self.redraw_signal.request_redraw();
    }

    fn render(&self, frame: &mut Frame, area: Rect) {
        if let Ok(parser) = self.parser.lock() {
            render_screen(parser.screen(), area, frame.buffer_mut());
        }
    }

    fn write_input(&self, bytes: &[u8]) {
        if let Ok(mut writer) = self.writer.lock() {
            let _ = writer.write_all(bytes);
            let _ = writer.flush();
        }
    }

    fn take_needs_redraw(&self) -> bool {
        self.redraw_signal.take_redraw_request()
    }
}

struct TermMprocsDemo {
    terminal: TermMprocsTerminal,
    last_area: Rect,
    terminal_focused: bool,
}

impl TermMprocsDemo {
    fn new() -> Result<Self> {
        Ok(Self {
            terminal: TermMprocsTerminal::spawn_shell(24, 80)?,
            last_area: Rect::default(),
            terminal_focused: true,
        })
    }
}

impl CoordinatorApp for TermMprocsDemo {
    fn on_event(&mut self, event: CoordinatorEvent) -> ratkit::LayoutResult<CoordinatorAction> {
        match event {
            CoordinatorEvent::Resize(ResizeEvent { width, height }) => {
                self.terminal.resize(height, width);
                Ok(CoordinatorAction::Redraw)
            }
            CoordinatorEvent::Keyboard(keyboard) => {
                if !keyboard.is_key_down() {
                    return Ok(CoordinatorAction::Continue);
                }
                if keyboard.is_char('x') && keyboard.modifiers.contains(KeyModifiers::CONTROL) {
                    if self.terminal_focused {
                        self.terminal_focused = false;
                        return Ok(CoordinatorAction::Redraw);
                    }
                    return Ok(CoordinatorAction::Continue);
                }
                if keyboard.is_char('q') && keyboard.modifiers.contains(KeyModifiers::CONTROL) {
                    return Ok(CoordinatorAction::Quit);
                }
                if !self.terminal_focused {
                    if keyboard.is_char('q') && keyboard.modifiers.is_empty() {
                        return Ok(CoordinatorAction::Quit);
                    }
                    return Ok(CoordinatorAction::Continue);
                }
                if let Some(bytes) = encode_key_event(&keyboard) {
                    self.terminal.write_input(&bytes);
                }
                Ok(CoordinatorAction::Redraw)
            }
            CoordinatorEvent::Mouse(mouse) => {
                if mouse.is_click() && mouse.is_inside(self.last_area) && !self.terminal_focused {
                    self.terminal_focused = true;
                    return Ok(CoordinatorAction::Redraw);
                }
                Ok(CoordinatorAction::Continue)
            }
            CoordinatorEvent::Tick(_) => {
                if self.terminal.take_needs_redraw() {
                    Ok(CoordinatorAction::Redraw)
                } else {
                    Ok(CoordinatorAction::Continue)
                }
            }
            _ => Ok(CoordinatorAction::Continue),
        }
    }

    fn on_draw(&mut self, frame: &mut Frame) {
        let area = frame.area();
        let title = if self.terminal_focused {
            " termtui demo (terminal focused) "
        } else {
            " termtui demo (wrapper focused) "
        };
        let border_style = if self.terminal_focused {
            Style::default().fg(Color::Cyan)
        } else {
            Style::default().fg(Color::DarkGray)
        };
        let block = Block::default()
            .title(title)
            .borders(Borders::ALL)
            .border_style(border_style);
        let inner = block.inner(area);
        frame.render_widget(block, area);

        if inner != self.last_area {
            self.terminal.resize(inner.height, inner.width);
            self.last_area = inner;
        }

        self.terminal.render(frame, inner);

        if self.terminal_focused {
            if let Ok(parser) = self.terminal.parser.lock() {
                let screen = parser.screen();
                if !screen.hide_cursor() {
                    let (row, col) = screen.cursor_position();
                    let x = inner.x.saturating_add(col);
                    let y = inner.y.saturating_add(row);
                    if x < inner.x + inner.width && y < inner.y + inner.height {
                        frame.set_cursor_position((x, y));
                        apply_cursor_style(screen.cursor_style());
                    }
                }
            }
        }
    }
}

fn apply_cursor_style(style: CursorStyle) {
    let style = match style {
        CursorStyle::Default => SetCursorStyle::DefaultUserShape,
        CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,
        CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,
        CursorStyle::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore,
        CursorStyle::SteadyUnderline => SetCursorStyle::SteadyUnderScore,
        CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,
        CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,
    };
    let _ = execute!(std::io::stdout(), style);
}

fn encode_key_event(event: &KeyboardEvent) -> Option<Vec<u8>> {
    if !event.is_key_down() {
        return None;
    }
    match event.key_code {
        KeyCode::Char(mut c) => {
            if event.modifiers.contains(KeyModifiers::CONTROL) {
                if c == 'x' {
                    return None;
                }
                c = c.to_ascii_lowercase();
                if c.is_ascii() {
                    return Some(vec![(c as u8) & 0x1f]);
                }
                return None;
            }
            if event.modifiers.contains(KeyModifiers::ALT) {
                if c.is_ascii() {
                    return Some(vec![0x1b, c as u8]);
                }
                return None;
            }
            if c.is_ascii() {
                Some(vec![c as u8])
            } else {
                Some(c.to_string().into_bytes())
            }
        }
        KeyCode::Enter => Some(vec![b'\r']),
        KeyCode::Backspace => Some(vec![0x7f]),
        KeyCode::Tab => Some(vec![b'\t']),
        KeyCode::Esc => Some(vec![0x1b]),
        KeyCode::Up => Some(b"\x1b[A".to_vec()),
        KeyCode::Down => Some(b"\x1b[B".to_vec()),
        KeyCode::Right => Some(b"\x1b[C".to_vec()),
        KeyCode::Left => Some(b"\x1b[D".to_vec()),
        _ => None,
    }
}

fn main() -> Result<()> {
    let app = TermMprocsDemo::new()?;
    run_with_diagnostics(app, RunnerConfig::default())?;
    Ok(())
}