Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-tui/src/terminal.rs

Raw
//! Terminal engine boundary (`SMH-SPEC-SPEC0001`, Observer mode).

use crossterm::{
    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
    execute, terminal,
};
use ratatui::{
    Terminal,
    backend::CrosstermBackend,
    layout::Rect,
    widgets::{Block, Borders, Paragraph},
};
use smith_ui::Observer;
use std::io::{self, Stdout};
use std::time::Duration;

/// Smith-owned terminal input, independent of the terminal engine.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Input {
    /// A keyboard action.
    Key(Key),
    /// Terminal dimensions changed.
    Resize,
}

/// Smith-owned keyboard action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
    /// A printable character.
    Char(char),
    /// Delete the preceding search character.
    Backspace,
    /// Submit search input.
    Enter,
    /// Cancel search or leave observer mode.
    Escape,
    /// Navigate to earlier rows.
    PageUp,
    /// Navigate to later rows.
    PageDown,
    /// Abort observer mode.
    Interrupt,
}

/// RAII terminal ownership, including error and normal-exit restoration.
pub struct Screen {
    terminal: Terminal<CrosstermBackend<Stdout>>,
    restored: bool,
}

impl Screen {
    /// Enter raw mode and alternate screen.
    ///
    /// # Errors
    /// Returns terminal I/O error, restoring modes on partial initialization.
    pub fn open() -> io::Result<Self> {
        terminal::enable_raw_mode()?;
        if let Err(error) = execute!(io::stdout(), terminal::EnterAlternateScreen) {
            // Preserve the setup error; raw-mode cleanup is best-effort on this path.
            let _restore_result: io::Result<()> = terminal::disable_raw_mode();
            return Err(error);
        }
        match Terminal::new(CrosstermBackend::new(io::stdout())) {
            Ok(terminal) => Ok(Self {
                terminal,
                restored: false,
            }),
            Err(error) => {
                // Preserve the initialization error; terminal cleanup is best-effort.
                let _restore_result: io::Result<()> = restore_terminal();
                Err(error)
            }
        }
    }

    /// Draw only already prepared, immutable snapshot data.
    ///
    /// # Errors
    /// Returns terminal I/O error.
    pub fn draw(&mut self, id: &str, ui: &Observer, input: bool) -> io::Result<()> {
        self.terminal.draw(|frame| render(frame, id, ui, input))?;
        Ok(())
    }

    /// Restore raw-mode and alternate-screen state on explicit shutdown.
    ///
    /// # Errors
    /// Returns a terminal restoration error after attempting both operations.
    pub fn close(mut self) -> io::Result<()> {
        self.restore()
    }

    fn restore(&mut self) -> io::Result<()> {
        if self.restored {
            return Ok(());
        }
        let result = restore_terminal();
        self.restored = result.is_ok();
        result
    }
}

impl Drop for Screen {
    fn drop(&mut self) {
        // Drop cannot return terminal errors; explicit close reports them.
        let _restore_result: io::Result<()> = self.restore();
    }
}

fn restore_terminal() -> io::Result<()> {
    let raw = terminal::disable_raw_mode();
    let alternate = execute!(io::stdout(), terminal::LeaveAlternateScreen);
    raw.and(alternate)
}

/// Wait for keyboard or resize; no session I/O.
///
/// # Errors
/// Returns terminal I/O error.
pub fn next_input(deadline: Duration) -> io::Result<Option<Input>> {
    if !event::poll(deadline)? {
        return Ok(None);
    }
    Ok(match event::read()? {
        Event::Key(key) if key.kind == KeyEventKind::Press => key_input(key).map(Input::Key),
        Event::Resize(..) => Some(Input::Resize),
        _ => None,
    })
}

const fn key_input(key: KeyEvent) -> Option<Key> {
    Some(match key.code {
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Interrupt,
        KeyCode::Char(character) => Key::Char(character),
        KeyCode::Backspace => Key::Backspace,
        KeyCode::Enter => Key::Enter,
        KeyCode::Esc => Key::Escape,
        KeyCode::PageUp => Key::PageUp,
        KeyCode::PageDown => Key::PageDown,
        _ => return None,
    })
}

fn render(frame: &mut ratatui::Frame<'_>, id: &str, ui: &Observer, input: bool) {
    let area = frame.area();
    let status = if !ui.status_known {
        "checking writer"
    } else if ui.writer_active {
        "writer active"
    } else {
        "writer absent"
    };
    let follow = if ui.follow { "FOLLOW LIVE" } else { "paused" };
    let mut lines = vec![
        format!("READ ONLY | session {id} | {status} | {follow}"),
        format!(
            "rows {}..{} / {} | b/n browse | f follow | r refresh | / search | e export | k fork | q quit",
            ui.start.saturating_add(1).min(ui.total),
            (ui.start + ui.rows.len()).min(ui.total),
            ui.total
        ),
        "Submission disabled: observer cannot write; no provider or tools started.".to_owned(),
    ];
    if input {
        lines.push(format!("Search: {}", ui.search));
    } else if !ui.search.is_empty() {
        lines.push(format!(
            "Search: {} | match: {}",
            ui.search,
            ui.found.as_deref().unwrap_or("none")
        ));
    }
    if !ui.notice.is_empty() {
        lines.push(ui.notice.clone());
    }
    let width = usize::from(area.width.saturating_sub(4));
    lines.extend(ui.rows.iter().map(|row| {
        let id = row.id.get(..8).unwrap_or(&row.id);
        let text = format!("{id}  {}", row.text);
        if text.len() <= width {
            text
        } else {
            let mut end = width.saturating_sub(3).min(text.len());
            while !text.is_char_boundary(end) {
                end -= 1;
            }
            format!("{}…", &text[..end])
        }
    }));
    frame.render_widget(
        Paragraph::new(lines.join("\n")).block(
            Block::default()
                .title("Smith observer")
                .borders(Borders::ALL),
        ),
        Rect::new(area.x, area.y, area.width, area.height),
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::backend::TestBackend;

    #[test]
    fn ctrl_c_is_an_engine_independent_interrupt() {
        assert_eq!(
            key_input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Some(Key::Interrupt)
        );
    }

    #[test]
    fn narrow_terminal_marks_clipped_visible_row_without_wrapping_into_other_rows() {
        let mut ui = Observer::default();
        ui.publish(
            2,
            false,
            vec![
                smith_ui::Row {
                    id: "12345678-a".into(),
                    text: "x".repeat(200),
                },
                smith_ui::Row {
                    id: "12345678-b".into(),
                    text: "second row".into(),
                },
            ],
        );
        let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
        terminal
            .draw(|frame| render(frame, "session", &ui, false))
            .unwrap();
        let first: String = (0..40)
            .map(|x| terminal.backend().buffer()[(x, 4)].symbol())
            .collect();
        let second: String = (0..40)
            .map(|x| terminal.backend().buffer()[(x, 5)].symbol())
            .collect();
        assert!(first.contains('…'));
        assert!(second.contains("second row"));
    }

    #[test]
    fn observer_status_is_visible_and_submission_disabled() {
        let mut ui = Observer::default();
        ui.publish(
            1,
            true,
            vec![smith_ui::Row {
                id: "entry".into(),
                text: "hello".into(),
            }],
        );
        let mut terminal = Terminal::new(TestBackend::new(100, 9)).unwrap();
        terminal
            .draw(|frame| render(frame, "session", &ui, false))
            .unwrap();
        let rows: Vec<String> = (0..9)
            .map(|y| {
                (0..100)
                    .map(|x| terminal.backend().buffer()[(x, y)].symbol())
                    .collect::<String>()
                    .trim_end()
                    .to_owned()
            })
            .collect();
        insta::assert_snapshot!(rows.join("\n"));
    }
}