use std::io::{self, BufRead, IsTerminal, Write};
use std::path::PathBuf;
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use crossterm::{cursor, queue};
use crate::{InputSurface, ReadOutcome};
#[derive(Debug, PartialEq, Eq)]
enum Signal {
Submit(String),
Interrupt,
Eof,
}
#[derive(Default)]
struct ReadState {
buf: String,
cursor: usize,
hist_idx: Option<usize>,
stash: String,
}
impl ReadState {
fn insert_str(&mut self, s: &str) {
self.buf.insert_str(self.cursor, s);
self.cursor += s.len();
}
fn insert_char(&mut self, c: char) {
self.buf.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
fn prev_char_len(&self) -> Option<usize> {
self.buf[..self.cursor]
.chars()
.next_back()
.map(char::len_utf8)
}
fn next_char_len(&self) -> Option<usize> {
self.buf[self.cursor..].chars().next().map(char::len_utf8)
}
fn backspace(&mut self) {
if let Some(n) = self.prev_char_len() {
self.cursor -= n;
self.buf.remove(self.cursor);
}
}
fn delete(&mut self) {
if self.next_char_len().is_some() {
self.buf.remove(self.cursor);
}
}
fn left(&mut self) {
if let Some(n) = self.prev_char_len() {
self.cursor -= n;
}
}
fn right(&mut self) {
if let Some(n) = self.next_char_len() {
self.cursor += n;
}
}
fn home(&mut self) {
self.cursor = 0;
}
fn end(&mut self) {
self.cursor = self.buf.len();
}
fn set_line(&mut self, line: &str) {
self.buf = line.to_string();
self.cursor = self.buf.len();
}
fn history_up(&mut self, history: &[String]) {
if history.is_empty() {
return;
}
let next = match self.hist_idx {
None => {
self.stash = self.buf.clone();
history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.hist_idx = Some(next);
let line = history[next].clone();
self.set_line(&line);
}
fn history_down(&mut self, history: &[String]) {
match self.hist_idx {
None => {}
Some(i) if i + 1 < history.len() => {
self.hist_idx = Some(i + 1);
let line = history[i + 1].clone();
self.set_line(&line);
}
Some(_) => {
self.hist_idx = None;
let stash = std::mem::take(&mut self.stash);
self.set_line(&stash);
}
}
}
}
fn sanitize_paste(text: &str) -> String {
text.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
fn apply_key(state: &mut ReadState, key: KeyEvent, history: &[String]) -> Option<Signal> {
if key.kind == KeyEventKind::Release {
return None;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Enter => return Some(Signal::Submit(state.buf.clone())),
KeyCode::Char('c') if ctrl => return Some(Signal::Interrupt),
KeyCode::Char('d') if ctrl => {
if state.buf.is_empty() {
return Some(Signal::Eof);
}
state.delete();
}
KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
state.insert_char(c);
}
KeyCode::Backspace => state.backspace(),
KeyCode::Delete => state.delete(),
KeyCode::Left => state.left(),
KeyCode::Right => state.right(),
KeyCode::Home => state.home(),
KeyCode::End => state.end(),
KeyCode::Up => state.history_up(history),
KeyCode::Down => state.history_down(history),
_ => {}
}
None
}
fn layout(prompt_w: usize, buf: &str, cursor_byte: usize, cols: usize) -> (usize, usize, usize) {
let cols = cols.max(1);
let before = buf[..cursor_byte.min(buf.len())].chars().count();
let cursor_pos = prompt_w + before;
let total = prompt_w + buf.chars().count();
let rows = if total == 0 {
1
} else {
(total - 1) / cols + 1
};
(rows, cursor_pos / cols, cursor_pos % cols)
}
struct RawGuard;
impl Drop for RawGuard {
fn drop(&mut self) {
let _ = queue!(io::stdout(), DisableBracketedPaste);
let _ = io::stdout().flush();
let _ = disable_raw_mode();
}
}
pub(crate) struct LeanSurface {
history: Vec<String>,
history_path: Option<PathBuf>,
pending: Vec<String>,
rows_above_cursor: u16,
}
impl LeanSurface {
pub(crate) fn new(history_path: Option<PathBuf>) -> anyhow::Result<Self> {
let history = history_path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.lines().map(str::to_string).collect())
.unwrap_or_default();
Ok(Self {
history,
history_path,
pending: Vec::new(),
rows_above_cursor: 0,
})
}
fn read_piped(&mut self, prompt: &str) -> io::Result<ReadOutcome> {
let mut out = io::stdout();
write!(out, "{prompt}")?;
out.flush()?;
let mut line = String::new();
if io::stdin().lock().read_line(&mut line)? == 0 {
writeln!(out)?;
return Ok(ReadOutcome::Eof);
}
let line = line.trim_end_matches(['\n', '\r']).to_string();
writeln!(out, "{line}")?;
Ok(ReadOutcome::Line(line))
}
fn render(&mut self, prompt: &str, state: &ReadState) -> io::Result<()> {
let cols = crossterm::terminal::size()
.map(|(c, _)| c)
.unwrap_or(80)
.max(1) as usize;
let prompt_w = prompt.chars().count();
let (_, crow, ccol) = layout(prompt_w, &state.buf, state.cursor, cols);
let end_chars = prompt_w + state.buf.chars().count();
let erow = if end_chars == 0 {
0
} else {
(end_chars - 1) / cols
};
let mut out = io::stdout();
queue!(out, cursor::MoveToColumn(0))?;
if self.rows_above_cursor > 0 {
queue!(out, cursor::MoveUp(self.rows_above_cursor))?;
}
queue!(out, Clear(ClearType::FromCursorDown))?;
write!(out, "{prompt}{}", state.buf)?;
let erow = erow as u16;
let crow_u = crow as u16;
if erow > crow_u {
queue!(out, cursor::MoveUp(erow - crow_u))?;
} else if crow_u > erow {
queue!(out, cursor::MoveDown(crow_u - erow))?;
}
queue!(out, cursor::MoveToColumn(ccol as u16))?;
out.flush()?;
self.rows_above_cursor = crow_u;
Ok(())
}
fn read_tty(&mut self, prompt: &str) -> io::Result<ReadOutcome> {
enable_raw_mode()?;
let _guard = RawGuard;
queue!(io::stdout(), EnableBracketedPaste)?;
io::stdout().flush()?;
let mut state = ReadState::default();
self.rows_above_cursor = 0;
self.render(prompt, &state)?;
let signal = loop {
match event::read()? {
Event::Key(k) => {
if let Some(sig) = apply_key(&mut state, k, &self.history) {
break sig;
}
self.render(prompt, &state)?;
}
Event::Paste(text) => {
state.insert_str(&sanitize_paste(&text));
self.render(prompt, &state)?;
}
Event::Resize(..) => {
self.rows_above_cursor = 0;
self.render(prompt, &state)?;
}
_ => {}
}
};
let cols = crossterm::terminal::size()
.map(|(c, _)| c)
.unwrap_or(80)
.max(1) as usize;
let (rows, crow, _) = layout(prompt.chars().count(), &state.buf, state.cursor, cols);
let down = rows.saturating_sub(1).saturating_sub(crow) as u16;
let mut out = io::stdout();
if down > 0 {
queue!(out, cursor::MoveDown(down))?;
}
write!(out, "\r\n")?;
out.flush()?;
Ok(match signal {
Signal::Submit(s) => ReadOutcome::Line(s),
Signal::Interrupt => ReadOutcome::Interrupted,
Signal::Eof => ReadOutcome::Eof,
})
}
}
impl InputSurface for LeanSurface {
fn read_line(&mut self, prompt: &str) -> anyhow::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Ok(self.read_piped(prompt)?);
}
if !crate::terminal_fd_available() {
return Ok(ReadOutcome::Fatal(
"\nnewt: EMFILE — file descriptor table is full.\n \
Too many subprocesses (e.g. cargo test workers) inherited fds.\n \
Restart newt. The O_CLOEXEC fix prevents recurrence on rebuilt binaries."
.to_string(),
));
}
Ok(self.read_tty(prompt)?)
}
fn add_history(&mut self, entry: &str) {
if entry.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(entry) {
return; }
self.history.push(entry.to_string());
self.pending.push(entry.to_string());
}
fn save_history(&mut self) {
let Some(path) = self.history_path.clone() else {
return;
};
if self.pending.is_empty() {
return;
}
use std::io::Write as _;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
for e in self.pending.drain(..) {
let _ = writeln!(f, "{e}");
}
}
}
fn reload(&mut self) -> anyhow::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn typ(state: &mut ReadState, s: &str) {
for c in s.chars() {
assert_eq!(apply_key(state, key(KeyCode::Char(c)), &[]), None);
}
}
#[test]
fn types_and_submits_on_enter() {
let mut s = ReadState::default();
typ(&mut s, "hello");
assert_eq!(s.buf, "hello");
assert_eq!(s.cursor, 5);
assert_eq!(
apply_key(&mut s, key(KeyCode::Enter), &[]),
Some(Signal::Submit("hello".to_string()))
);
}
#[test]
fn backspace_and_mid_line_insert() {
let mut s = ReadState::default();
typ(&mut s, "helo");
apply_key(&mut s, key(KeyCode::Left), &[]); apply_key(&mut s, key(KeyCode::Char('l')), &[]); assert_eq!(s.buf, "hello");
s.end();
apply_key(&mut s, key(KeyCode::Backspace), &[]);
assert_eq!(s.buf, "hell");
}
#[test]
fn cursor_motion_home_end_delete() {
let mut s = ReadState::default();
typ(&mut s, "abc");
apply_key(&mut s, key(KeyCode::Home), &[]);
assert_eq!(s.cursor, 0);
apply_key(&mut s, key(KeyCode::Delete), &[]); assert_eq!(s.buf, "bc");
apply_key(&mut s, key(KeyCode::End), &[]);
assert_eq!(s.cursor, s.buf.len());
}
#[test]
fn unicode_cursor_is_char_aware() {
let mut s = ReadState::default();
typ(&mut s, "café"); apply_key(&mut s, key(KeyCode::Left), &[]); apply_key(&mut s, key(KeyCode::Backspace), &[]); assert_eq!(s.buf, "caé");
}
#[test]
fn ctrl_c_interrupts_ctrl_d_eofs_when_empty() {
let mut s = ReadState::default();
assert_eq!(apply_key(&mut s, ctrl('c'), &[]), Some(Signal::Interrupt));
assert_eq!(apply_key(&mut s, ctrl('d'), &[]), Some(Signal::Eof));
typ(&mut s, "ab");
s.home();
assert_eq!(apply_key(&mut s, ctrl('d'), &[]), None);
assert_eq!(s.buf, "b");
}
#[test]
fn ctrl_chords_are_not_typed_text() {
let mut s = ReadState::default();
assert_eq!(apply_key(&mut s, ctrl('a'), &[]), None);
assert_eq!(s.buf, "");
}
#[test]
fn history_up_down_recall_and_restore_live() {
let hist = vec!["first".to_string(), "second".to_string()];
let mut s = ReadState::default();
typ(&mut s, "draft");
apply_key(&mut s, key(KeyCode::Up), &hist); assert_eq!(s.buf, "second");
apply_key(&mut s, key(KeyCode::Up), &hist);
assert_eq!(s.buf, "first");
apply_key(&mut s, key(KeyCode::Up), &hist); assert_eq!(s.buf, "first");
apply_key(&mut s, key(KeyCode::Down), &hist);
assert_eq!(s.buf, "second");
apply_key(&mut s, key(KeyCode::Down), &hist); assert_eq!(s.buf, "draft");
}
#[test]
fn paste_collapses_newlines_to_single_line() {
assert_eq!(sanitize_paste("a\nb\tc\rd"), "a b c d");
let mut s = ReadState::default();
s.insert_str(&sanitize_paste("two\nlines"));
assert_eq!(s.buf, "two lines");
}
#[test]
fn layout_wraps_and_places_cursor() {
let (rows, crow, ccol) = layout(4, "abcdefgh", 8, 10);
assert_eq!(rows, 2);
assert_eq!((crow, ccol), (1, 2));
assert_eq!(layout(4, "", 0, 10), (1, 0, 4));
let (rows, crow, ccol) = layout(2, "abcdefgh", 8, 10);
assert_eq!((rows, crow, ccol), (1, 1, 0));
}
#[test]
fn add_history_skips_empty_and_consecutive_dupes() {
let mut surf = LeanSurface::new(None).unwrap();
surf.add_history("");
surf.add_history("a");
surf.add_history("a");
surf.add_history("b");
assert_eq!(surf.history, vec!["a".to_string(), "b".to_string()]);
}
}