/* * Copyright (c) 2023 Marceline Cramer * Copyright (c) 2023 Emma Tebibyte * SPDX-License-Identifier: AGPL-3.0-or-later * * This program is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, either version 3 of the License, or (at your option) any * later version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ use std::{ env::args, ffi::OsString, fs::{File, OpenOptions}, io::{stdout, Read, Stdout, Write}, os::fd::FromRawFd, path::{Path, PathBuf}, sync::Arc, }; use crossbeam_channel::Sender; use crossterm::{ cursor, event::{Event, KeyCode, KeyEvent}, terminal, QueueableCommand, Result, }; use parking_lot::Mutex; use ropey::Rope; use yacexits::{exit, EX_DATAERR, EX_UNAVAILABLE}; mod actions; mod buffer; mod config; mod theme; use buffer::Buffer; use theme::StyleStore; #[derive(Copy, Clone, Debug, Default)] pub struct Cursor { pub column: usize, pub line: usize, } #[derive(Clone, Debug, Default)] pub struct NormalState { pub error: Option, } #[derive(Clone, Debug, Default)] pub struct CommandState { pub buf: String, pub cursor: usize, } #[derive(Copy, Clone, Debug)] pub struct InsertState { append: bool, } #[derive(Clone, Debug)] pub enum Mode { Normal(NormalState), Command(CommandState), Visual, Insert(InsertState), } impl Default for Mode { fn default() -> Self { Mode::Normal(Default::default()) } } impl Mode { pub fn cursor_style(&self) -> cursor::SetCursorStyle { use cursor::SetCursorStyle as Style; match self { Mode::Normal(_) => Style::SteadyBlock, Mode::Visual => Style::BlinkingBlock, Mode::Insert(_) => Style::BlinkingBar, Mode::Command(_) => Style::SteadyUnderScore, } } } #[derive(Copy, Clone, Debug)] pub enum Direction { Left, Down, Up, Right, } pub struct State { pub styles: Arc>, pub buffer: Buffer, pub cursor: Cursor, pub file: Option, pub mode: Mode, pub last_saved: Rope, pub scroll: Cursor, pub size: (usize, usize), pub quit: bool, pub theme_tx: Sender, } impl State { pub fn from_str(file_name: Option, text: &str) -> Result { let styles = Arc::new(Mutex::new(StyleStore::default())); let buffer = Buffer::from_str(styles.clone(), text); let last_saved = buffer.as_ref().clone(); let (cols, rows) = terminal::size()?; let theme_tx = config::ThemeWatcher::spawn(styles.clone()); Ok(Self { styles, buffer, cursor: Cursor::default(), file: file_name, mode: Mode::default(), last_saved, scroll: Cursor::default(), size: (cols as usize, rows as usize), quit: false, theme_tx, }) } pub fn draw(&mut self, out: &mut impl Write) -> Result<()> { // begin update let (cols, rows) = terminal::size()?; out.queue(terminal::BeginSynchronizedUpdate)?; out.queue(terminal::Clear(terminal::ClearType::All))?; let mut styles = self.styles.lock(); // draw status line let mut set_cursor_pos = None; let mut show_status_bar = false; match &self.mode { Mode::Command(CommandState { buf, cursor }) => { let col = *cursor as u16 + 1; let row = rows - 1; out.queue(cursor::MoveTo(0, row))?; write!(out, ":{}", buf)?; set_cursor_pos = Some((col, row)); show_status_bar = true; } Mode::Normal(NormalState { error: Some(error) }) => { let error_style = styles.get_scope("error"); out.queue(cursor::MoveTo(0, rows - 1))?; error_style.print_styled(out, error)?; show_status_bar = true; } _ => {} } // done with styles drop(styles); // draw buffer let buffer_rows = if show_status_bar { rows - 1 } else { rows }; let lr_width = self.buffer.draw(cols, buffer_rows, self.scroll, self.cursor, out)?; // draw cursor let cursor_pos = set_cursor_pos.unwrap_or_else(|| { // calculate cursor position on buffer let cursor = self.buffer.clamped_cursor(self.cursor); let col = cursor.column.saturating_sub(self.scroll.column) as u16; let row = cursor.line.saturating_sub(self.scroll.line) as u16; let col = col + lr_width as u16; (col, row) }); out.queue(cursor::MoveTo(cursor_pos.0, cursor_pos.1))?; out.queue(self.mode.cursor_style())?; // finish update out.queue(terminal::EndSynchronizedUpdate)?; out.flush()?; Ok(()) } pub fn on_event(&mut self, event: Event) { match &self.mode { Mode::Normal(state) => self.on_normal_event(event, state.clone()), Mode::Command(state) => self.on_command_event(event, state.clone()), Mode::Visual => self.on_visual_event(event), Mode::Insert(state) => self.on_insert_event(event, state.clone()), } } fn on_normal_event(&mut self, event: Event, mut state: NormalState) { // reset the error from the last event state.error = None; match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char(':') => { self.mode = Mode::Command(Default::default()); } KeyCode::Char('v') => { self.mode = Mode::Visual; } KeyCode::Char('i') => actions::insert_mode(self), KeyCode::Char('a') => actions::append_mode(self), KeyCode::Char('o') => actions::open_below(self), KeyCode::Char('O') => actions::open_above(self), code => self.on_any_key(code), }, event => self.on_any_event(event), } match self.mode { Mode::Normal(_) => self.mode = Mode::Normal(state), _ => {} } } fn on_command_event(&mut self, event: Event, mut state: CommandState) { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char(c) => { state.buf.insert(state.cursor, c); state.cursor += 1; } KeyCode::Backspace => { if state.cursor > 0 { state.cursor -= 1; state.buf.remove(state.cursor); } } KeyCode::Delete if state.cursor < state.buf.len() => { state.buf.remove(state.cursor); } KeyCode::Left if state.cursor > 0 => { state.cursor -= 1; } KeyCode::Right if state.cursor < state.buf.len() => { state.cursor += 1; } KeyCode::Enter => { // TODO add to command history let result = self.queue_command(&state.buf); let error = result.err(); self.mode = Mode::Normal(NormalState { error }); return; } code => return self.on_any_key(code), }, event => return self.on_any_event(event), } self.mode = Mode::Command(state); } fn on_visual_event(&mut self, event: Event) { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Esc => { self.mode = Mode::default(); } code => self.on_any_key(code), }, event => self.on_any_event(event), } } fn on_insert_event(&mut self, event: Event, state: InsertState) { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char(c) => { self.buffer.insert_char(self.cursor, c); self.move_cursor(Direction::Right) } KeyCode::Backspace => { self.move_cursor(Direction::Left); self.buffer.remove(self.cursor); } KeyCode::Delete => { self.buffer.remove(self.cursor); } KeyCode::Enter => { self.buffer.insert_char(self.cursor, '\n'); self.cursor.line += 1; self.cursor.column = 0; } KeyCode::Esc => { if state.append { self.move_cursor(Direction::Left); } self.mode = Mode::default(); } code => self.on_any_key(code), }, event => self.on_any_event(event), } } fn on_any_event(&mut self, event: Event) { match event { Event::Resize(cols, rows) => { self.size = (cols as usize, rows as usize); } Event::Key(KeyEvent { code, .. }) => self.on_any_key(code), _ => {} } } fn on_any_key(&mut self, code: KeyCode) { match code { KeyCode::Esc => self.mode = Mode::default(), KeyCode::Char('h') | KeyCode::Left => actions::move_char_left(self), KeyCode::Char('j') | KeyCode::Down => actions::move_line_down(self), KeyCode::Char('k') | KeyCode::Up => actions::move_line_up(self), KeyCode::Char('l') | KeyCode::Right => actions::move_char_right(self), _ => {} } } fn write_buffer(&mut self, file: OsString) -> Result<()> { self.last_saved = self.buffer.as_ref().clone(); let out = self.buffer.as_ref().bytes().collect::>(); let mut handle = OpenOptions::new().write(true).open(file)?; handle.write_all(out.as_slice())?; Ok(()) } fn write_command(&mut self, command: &str, args: &[&str]) -> std::result::Result<(), String> { let handle = match self.file.clone() { Some(handle) => handle, None => match args.get(0) { Some(part) => OsString::from(part), None => { return Err(format!("{}: No file name.", command)); } }, }; self.write_buffer(handle).map_err(|err| format!("{}", err)) } fn queue_command(&mut self, command: &str) -> std::result::Result<(), String> { let command_parts = command.split(' ').collect::>(); let command = match command_parts.get(0) { Some(command) => command, None => return Ok(()), }; let args = &command_parts[1..]; match *command { "q!" => self.quit = true, "q" => { if self.last_saved == *self.buffer.as_ref() { self.quit = true; } else { return Err("Buffer is unsaved (add ! to override)".into()); } } "w" => return self.write_command(command, args), "wq" => { self.write_command(command, args)?; self.quit = true; } command => match command.parse::() { Err(_) => return Err(format!("{}: Unrecognized command.", command)), Ok(line) if line >= self.buffer.as_ref().len_lines() => { return Err(format!("Line {} is out-of-bounds", line)) } Ok(line) => { self.cursor.line = line; self.scroll_to_cursor(); } }, } Ok(()) } fn move_cursor(&mut self, direction: Direction) { self.buffer.move_cursor(&mut self.cursor, direction, true); self.scroll_to_cursor(); } fn scroll_to_cursor(&mut self) { if self.cursor.column < self.scroll.column + 3 { self.scroll.column = self.cursor.column.saturating_sub(3); } else if self.cursor.column + 6 >= self.scroll.column + self.size.0 { self.scroll.column = self.cursor.column.saturating_sub(self.size.0 - 6); } if self.cursor.line < self.scroll.line { self.scroll.line = self.cursor.line; } else if self.cursor.line + 3 >= self.scroll.line + self.size.1 { self.scroll.line = self.cursor.line + 3 - self.size.1; } } } fn screen_main(stdout: &mut Stdout, mut state: State) -> Result<()> { let poll_timeout = std::time::Duration::from_millis(100); while !state.quit { state.draw(stdout)?; if let true = crossterm::event::poll(poll_timeout)? { let event = crossterm::event::read()?; state.on_event(event); } } Ok(()) } fn main() -> Result<()> { let argv = args().collect::>(); let mut buf = Vec::new(); let file_name: Option; match argv.get(1).map(|s| s.as_str()) { Some("-") | None => { file_name = None; let stdin = 0; // get stdin as a file descriptor if unsafe { libc::isatty(stdin) } == 0 { unsafe { File::from_raw_fd(stdin) } } else { File::open("/dev/null").unwrap() } } Some(path) => { let input_file = Path::new(path); file_name = Some(OsString::from(input_file.clone().display().to_string())); File::open(input_file).unwrap_or_else(|_| { let mut err = String::new(); if !input_file.exists() { err = "No such file or directory.".to_string(); } else if input_file.is_dir() { err = "Is a directory.".to_string(); } eprintln!("{}: {}: {}", argv[0], path, err); exit(EX_UNAVAILABLE); }) } } .read_to_end(&mut buf) .unwrap(); let text = String::from_utf8(buf).unwrap_or_else(|_| { eprintln!( "{}: {}: File contents are not valid UTF-8.", argv[0], argv[1] ); exit(EX_DATAERR); }); let state = State::from_str(file_name, &text)?; let mut stdout = stdout(); terminal::enable_raw_mode()?; stdout.queue(terminal::EnterAlternateScreen)?; let result = screen_main(&mut stdout, state); stdout.queue(terminal::LeaveAlternateScreen)?; terminal::disable_raw_mode()?; result }