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"));
}
}