use std::sync::{Arc, Mutex, OnceLock};
use crate::status::StatusLine;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditMode {
Prompt,
Turn,
}
pub struct EditView {
line: String,
mode: EditMode,
status: Option<Arc<StatusLine>>,
menu_rows: usize,
menu_hidden: bool,
menu_enabled: bool,
prompt_width: usize,
}
pub type SharedView = Arc<Mutex<EditView>>;
impl EditView {
pub fn shared(status: Option<Arc<StatusLine>>) -> SharedView {
Arc::new(Mutex::new(Self {
line: String::new(),
mode: EditMode::Prompt,
status,
menu_rows: 0,
menu_hidden: false,
menu_enabled: false,
prompt_width: 2,
}))
}
pub fn set_mode(&mut self, mode: EditMode) {
self.mode = mode;
if let Some(status) = &self.status {
match mode {
EditMode::Turn if !self.line.is_empty() => status.set_input(Some(&self.line)),
_ => status.set_input(None),
}
}
}
fn on_status(&self) -> Option<&StatusLine> {
(self.mode == EditMode::Turn).then_some(self.status.as_deref()).flatten()
}
fn insert(&mut self, text: &str) {
self.line.push_str(text);
match self.on_status() {
Some(status) => status.set_input(Some(&self.line)),
None => write(text),
}
self.line_changed();
}
fn line_changed(&mut self) {
self.menu_hidden = false;
self.draw_menu();
}
fn menu_visible(&self) -> bool {
self.menu_rows > 0
}
fn draw_menu(&mut self) {
if !self.menu_enabled || self.mode != EditMode::Prompt {
return;
}
let (rows, cols) = crate::status::terminal_size().unwrap_or((24, 80));
let reserved = if self.status.is_some() { 2 } else { 1 };
let max_rows = (rows as usize).saturating_sub(reserved).min(16);
let lines = if self.menu_hidden { Vec::new() } else { crate::commands::menu(&self.line, cols as usize, max_rows) };
let (seq, used) = menu_sequence(self.menu_rows, &lines);
self.menu_rows = used;
if !seq.is_empty() {
write(&seq);
}
}
fn hide_menu(&mut self) {
self.menu_hidden = true;
self.draw_menu();
}
fn tab(&mut self) {
let is_command = self.line.starts_with('/') && !self.line.contains(char::is_whitespace);
match is_command.then(|| crate::commands::complete(&self.line)).flatten() {
Some(done) => {
let rest = done[self.line.len()..].to_string();
if !rest.is_empty() {
self.insert(&rest);
}
}
None if is_command => {}
None => self.insert(" "),
}
}
pub fn prompt(&mut self) -> String {
let stamp = crate::ui::stamp();
self.prompt_width = crate::ui::visible_width(&stamp) + 2;
format!("{stamp}> {}", self.line)
}
fn restamp_prompt(&self) {
let stamp = crate::ui::stamp();
if !self.menu_enabled || self.mode != EditMode::Prompt || stamp.is_empty() || self.prompt_width <= 2 {
return;
}
let cols = crate::status::terminal_size().map(|(_, c)| c as usize).unwrap_or(80);
let up = rows_above_cursor(self.prompt_width + self.line.chars().count(), cols);
let up = if up > 0 { format!("\x1b[{up}A") } else { String::new() };
write(&format!("\x1b7{up}\r{stamp}\x1b8"));
}
pub fn prompt_redrawn(&mut self) {
self.menu_rows = 0;
self.draw_menu();
}
pub fn resize(&mut self) {
if self.menu_rows > 0 {
let (seq, _) = menu_sequence(self.menu_rows, &[]);
write(&seq);
self.menu_rows = 0;
}
self.draw_menu();
}
fn erase(&mut self, n: usize) {
let mut erased = String::new();
for _ in 0..n {
match self.line.pop() {
Some(c) => erased.push(c),
None => break,
}
}
match self.on_status() {
Some(status) => status.set_input((!self.line.is_empty()).then_some(self.line.as_str())),
None => write(&"\x08 \x08".repeat(erased.chars().count())),
}
if !erased.is_empty() {
self.line_changed();
}
}
fn erase_word(&mut self) {
let trimmed = self.line.trim_end();
let word_start = trimmed
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
let n = self.line[word_start..].chars().count();
self.erase(n);
}
fn take(&mut self) -> String {
if self.menu_visible() {
let (seq, _) = menu_sequence(self.menu_rows, &[]);
write(&seq);
self.menu_rows = 0;
}
self.menu_hidden = false;
self.restamp_prompt();
let line = std::mem::take(&mut self.line);
match self.on_status() {
Some(status) => status.set_input(None),
None => write("\r\n"),
}
line
}
}
fn menu_sequence(old_rows: usize, lines: &[String]) -> (String, usize) {
if old_rows == 0 && lines.is_empty() {
return (String::new(), 0);
}
let mut seq = String::new();
if lines.len() > old_rows {
seq.push_str(&"\x1bD".repeat(lines.len()));
seq.push_str(&format!("\x1b[{}A", lines.len()));
}
let rows = old_rows.max(lines.len());
seq.push_str("\x1b7");
for i in 0..rows {
seq.push_str("\x1b[1B\r\x1b[2K");
if let Some(line) = lines.get(i) {
seq.push_str(line);
}
}
seq.push_str("\x1b8");
(seq, if lines.is_empty() { 0 } else { rows })
}
fn rows_above_cursor(chars: usize, cols: usize) -> usize {
if chars == 0 || cols == 0 {
return 0;
}
if chars.is_multiple_of(cols) { chars / cols - 1 } else { chars / cols }
}
fn write(text: &str) {
use std::io::Write;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes());
let _ = out.flush();
}
pub enum Key {
Line(String),
Eof,
Interrupt,
ToggleThinking,
Escape,
}
const ESCAPE_SEQUENCE_WAIT_MS: i32 = 30;
static ORIGINAL: OnceLock<libc::termios> = OnceLock::new();
fn get_termios() -> Option<libc::termios> {
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
(unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } == 0).then_some(termios)
}
pub fn restore_terminal() {
if let Some(original) = ORIGINAL.get() {
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) };
}
}
struct KeyMode;
impl KeyMode {
fn enter() -> Option<Self> {
let original = get_termios()?;
let _ = ORIGINAL.set(original);
let mut raw = *ORIGINAL.get().unwrap();
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
(unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } == 0).then_some(Self)
}
}
impl Drop for KeyMode {
fn drop(&mut self) {
restore_terminal();
}
}
#[derive(Default)]
pub struct LineReader {
pending: std::collections::VecDeque<u8>,
utf8: Vec<u8>,
}
impl LineReader {
fn next_byte(&mut self) -> Option<u8> {
if let Some(byte) = self.pending.pop_front() {
return Some(byte);
}
let mut buf = [0u8; 1024];
loop {
let n = unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr().cast(), buf.len()) };
if n > 0 {
self.pending.extend(&buf[..n as usize]);
return self.pending.pop_front();
}
if n == 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return None;
}
}
}
fn byte_within(&mut self, ms: i32) -> Option<u8> {
if self.pending.is_empty() {
let mut fd = libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 };
if unsafe { libc::poll(&mut fd, 1, ms) } <= 0 {
return None;
}
}
self.next_byte()
}
pub fn read_line(&mut self, view: &SharedView, send: &dyn Fn(Key)) -> Key {
let _mode = KeyMode::enter();
view.lock().unwrap().menu_enabled = true;
let shared = view;
loop {
let Some(byte) = self.next_byte() else { return Key::Eof };
let mut view = view.lock().unwrap();
match byte {
b'\r' | b'\n' => {
if byte == b'\r' && self.pending.front() == Some(&b'\n') {
self.pending.pop_front();
}
return Key::Line(view.take() + "\n");
}
0x03 => {
let n = view.line.chars().count();
view.erase(n);
drop(view);
send(Key::Interrupt);
}
0x04 if view.line.is_empty() => return Key::Eof,
0x0f => {
drop(view);
send(Key::ToggleThinking);
}
0x7f | 0x08 => view.erase(1),
0x15 => {
let n = view.line.chars().count();
view.erase(n);
}
0x17 => view.erase_word(),
0x1b => {
let menu = view.menu_visible();
drop(view);
match self.byte_within(ESCAPE_SEQUENCE_WAIT_MS) {
Some(b'[' | b'O') => {
while let Some(b) = self.next_byte() {
if (0x40..=0x7e).contains(&b) {
break;
}
}
}
None if menu => shared.lock().unwrap().hide_menu(),
None => send(Key::Escape),
Some(0x1b) => {
self.pending.push_front(0x1b);
send(Key::Escape);
}
Some(_) => {}
}
}
b'\t' => view.tab(),
byte if byte < 0x20 => {}
byte => {
self.utf8.push(byte);
match std::str::from_utf8(&self.utf8) {
Ok(text) => {
let text = text.to_string();
self.utf8.clear();
view.insert(&text);
}
Err(e) if e.error_len().is_some() => self.utf8.clear(),
Err(_) => {}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erase_word_handles_multibyte_spaces() {
let view = EditView::shared(None);
let mut view = view.lock().unwrap();
view.mode = EditMode::Turn; view.line = "run a\u{a0}bé ".into();
view.erase_word();
assert_eq!(view.line, "run a\u{a0}");
view.erase_word();
assert_eq!(view.line, "run ");
}
#[test]
fn menu_rows_are_reserved_drawn_and_cleared() {
let lines = vec!["a".to_string(), "b".to_string()];
let (seq, rows) = menu_sequence(0, &lines);
assert_eq!(rows, 2);
assert_eq!(seq, "\x1bD\x1bD\x1b[2A\x1b7\x1b[1B\r\x1b[2Ka\x1b[1B\r\x1b[2Kb\x1b8");
let (seq, rows) = menu_sequence(2, &lines[..1]);
assert_eq!((seq.as_str(), rows), ("\x1b7\x1b[1B\r\x1b[2Ka\x1b[1B\r\x1b[2K\x1b8", 2));
let (seq, rows) = menu_sequence(2, &[]);
assert_eq!((seq.as_str(), rows), ("\x1b7\x1b[1B\r\x1b[2K\x1b[1B\r\x1b[2K\x1b8", 0));
assert_eq!(menu_sequence(0, &[]), (String::new(), 0));
}
#[test]
fn finds_the_prompt_row_of_a_wrapped_line() {
assert_eq!(rows_above_cursor(11, 80), 0);
assert_eq!(rows_above_cursor(80, 80), 0, "pending wrap stays on the row");
assert_eq!(rows_above_cursor(81, 80), 1);
assert_eq!(rows_above_cursor(200, 80), 2);
}
#[test]
fn tab_completes_commands_and_is_a_space_elsewhere() {
let view = EditView::shared(None);
let mut view = view.lock().unwrap();
view.mode = EditMode::Turn; view.line = "/comp".into();
view.tab();
assert_eq!(view.line, "/compact ");
view.line = "/s".into();
view.tab();
assert_eq!(view.line, "/s", "ambiguous: unchanged");
view.line = "fix it".into();
view.tab();
assert_eq!(view.line, "fix it ");
}
}