592 lines
19 KiB
Rust
592 lines
19 KiB
Rust
//! this crate runs a terminal
|
|
|
|
#![expect(
|
|
clippy::needless_return,
|
|
clippy::shadow_reuse,
|
|
clippy::blanket_clippy_restriction_lints,
|
|
clippy::must_use_candidate,
|
|
clippy::missing_trait_methods,
|
|
clippy::pattern_type_mismatch,
|
|
clippy::std_instead_of_alloc,
|
|
clippy::cargo_common_metadata,
|
|
clippy::multiple_crate_versions,
|
|
clippy::semicolon_outside_block,
|
|
static_mut_refs,
|
|
unused_doc_comments,
|
|
reason = ""
|
|
)]
|
|
|
|
use crate::enums::*;
|
|
|
|
use bpaf::Bpaf;
|
|
|
|
use iced::widget::{column, rich_text, row, scrollable, span, text};
|
|
use iced::{Element, Task, keyboard, time, window};
|
|
|
|
use nix::errno::Errno;
|
|
use nix::fcntl;
|
|
use nix::pty::{ForkptyResult, forkpty};
|
|
use nix::unistd::write;
|
|
|
|
use std::fs::File;
|
|
use std::io::{self, Read as _};
|
|
use std::os::unix::io::{AsFd as _, OwnedFd};
|
|
use std::process::Command;
|
|
use std::{error, fmt, thread, time as core_time};
|
|
|
|
pub mod enums;
|
|
pub mod parsers;
|
|
|
|
/// whether to enable verbose logging; see `Flags::verbose`
|
|
static mut VERBOSE: bool = false;
|
|
|
|
/// whether to enable debug logging; see `Flags::debug`
|
|
static mut DEBUG: bool = false;
|
|
|
|
/// whether to enable vomit logging; see `Flags::vomit`
|
|
static mut VOMIT: bool = false;
|
|
|
|
/// shell path; see `Flags::shell`
|
|
static mut SHELL: Option<String> = None;
|
|
|
|
/// events to be passed to `Model::update`
|
|
#[non_exhaustive]
|
|
#[derive(Debug, Clone)]
|
|
pub enum Msg {
|
|
Exit,
|
|
KeyPressed(keyboard::Key),
|
|
Tick,
|
|
}
|
|
|
|
/// errors for this program
|
|
#[non_exhaustive]
|
|
#[derive(Debug)]
|
|
enum Error {
|
|
/// out of bounds err while accessing a slice
|
|
IndexOutOfBounds,
|
|
/// io error
|
|
Io(io::Error),
|
|
/// nix crate error
|
|
Nix(NixError),
|
|
/// try to access a `File::from::<OwnedFd>()` without an `OwnedFd`
|
|
NoFileDescriptor,
|
|
/// impossible error
|
|
Unreachable,
|
|
}
|
|
|
|
impl fmt::Display for Error {
|
|
#[expect(clippy::min_ident_chars, reason = "it's in the docs like that")]
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Nix(nix_error) => return write!(f, "{nix_error}"),
|
|
Self::Io(io_error) => return write!(f, "{io_error}"),
|
|
Self::NoFileDescriptor => return write!(f, "no file descriptor specified"),
|
|
Self::IndexOutOfBounds => return write!(f, "index out of bounds"),
|
|
Self::Unreachable => return write!(f, "unreachable error, panic"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl error::Error for Error {}
|
|
|
|
/// error wrapper for the `nix` crate
|
|
#[non_exhaustive]
|
|
#[derive(Debug)]
|
|
enum NixError {
|
|
/// an OS error
|
|
Errno(Errno),
|
|
/// the error when `OFlags::from_bits(..)` returns `None`
|
|
UnrecognisedFlag,
|
|
}
|
|
|
|
impl fmt::Display for NixError {
|
|
#[expect(clippy::min_ident_chars, reason = "it's in the docs like that")]
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match *self {
|
|
Self::UnrecognisedFlag => return write!(f, "unrecognised flag"),
|
|
Self::Errno(errno) => return write!(f, "bad fcntl argument. errno: {errno}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// cli flags
|
|
#[derive(Debug, Clone, Bpaf)]
|
|
#[bpaf(options)]
|
|
pub struct Flags {
|
|
/// path to shell
|
|
#[bpaf(short('S'), long)]
|
|
shell: Option<String>,
|
|
|
|
/// no logging, NOOP; log level 0
|
|
#[bpaf(short, long)]
|
|
quiet: bool,
|
|
|
|
/// whether to error log; log level 1
|
|
#[bpaf(short('v'), long)]
|
|
verbose: bool,
|
|
|
|
/// whether to debug log; log level 2
|
|
#[bpaf(long)]
|
|
debug: bool,
|
|
|
|
/// whether to vomit log; log level 3
|
|
#[bpaf(long)]
|
|
vomit: bool,
|
|
|
|
/// whether to display version, NOOP; TODO
|
|
#[expect(dead_code, reason = "TODO")]
|
|
#[bpaf(short('V'), long)]
|
|
version: bool,
|
|
}
|
|
|
|
/// represents the terminal emulator\
|
|
/// example usage:
|
|
/// ```rust
|
|
/// iced::application("window title", Model::update, Model::view)
|
|
/// .theme(Model::theme)
|
|
/// .default_font(iced::Font::MONOSPACE)
|
|
/// .decorations(false)
|
|
/// .subscription(Model::subscription)
|
|
/// .run()
|
|
/// ```
|
|
pub struct Model {
|
|
/// location of cursor in user input line
|
|
cursor_index: usize,
|
|
/// fd of pty
|
|
fd: Option<OwnedFd>,
|
|
/// user input line
|
|
input: String,
|
|
/// path to shell
|
|
shell: String,
|
|
|
|
screen: Vec<Vec<String>>,
|
|
cursor: (usize, usize),
|
|
dimensions: (usize, usize),
|
|
}
|
|
|
|
impl Model {
|
|
/// applies needed side effects when taking an input char
|
|
#[expect(
|
|
clippy::arithmetic_side_effects,
|
|
reason = "cursor_index is bound checked"
|
|
)]
|
|
fn input_char(&mut self, chr: char) {
|
|
if self.cursor_index == self.input.len() {
|
|
self.input.push_str(chr.to_string().as_str());
|
|
} else {
|
|
self.input.insert(self.cursor_index, chr);
|
|
}
|
|
self.cursor_index += 1;
|
|
}
|
|
|
|
/// subscription logic for model
|
|
#[inline]
|
|
pub fn subscription(&self) -> iced::Subscription<Msg> {
|
|
let tick = time::every(time::Duration::new(0, 1)).map(|_| {
|
|
return Msg::Tick;
|
|
});
|
|
let key = keyboard::on_key_press(|key, _| {
|
|
return Some(Msg::KeyPressed(key));
|
|
});
|
|
return iced::Subscription::batch(vec![tick, key]);
|
|
}
|
|
|
|
/// theme logic for model
|
|
#[inline]
|
|
pub const fn theme(&self) -> iced::Theme {
|
|
return iced::Theme::GruvboxDark;
|
|
}
|
|
/// update logic for model
|
|
/// TODO fix pattern type mismatch
|
|
/// TODO add more keys
|
|
#[inline]
|
|
#[expect(
|
|
clippy::arithmetic_side_effects,
|
|
clippy::wildcard_enum_match_arm,
|
|
reason = "bounds checked"
|
|
)]
|
|
pub fn update(&mut self, msg: Msg) -> Task<Msg> {
|
|
match msg {
|
|
Msg::Exit => return window::get_latest().and_then(window::close),
|
|
Msg::KeyPressed(key) => {
|
|
match key {
|
|
keyboard::Key::Character(chr) => match chr.chars().nth(0) {
|
|
Some(chr) => self.input_char(chr),
|
|
None => return window::get_latest().and_then(window::close),
|
|
},
|
|
keyboard::Key::Named(keyboard::key::Named::Enter) => {
|
|
self.input.push('\n');
|
|
let write_buffer = self.input.as_bytes().to_vec();
|
|
if let Some(fd) = &self.fd {
|
|
match write(fd.as_fd(), &write_buffer) {
|
|
Ok(_) => (),
|
|
Err(error) => print_err(&Error::Nix(NixError::Errno(error))),
|
|
}
|
|
}
|
|
self.input = String::new();
|
|
self.cursor_index = 0;
|
|
}
|
|
keyboard::Key::Named(keyboard::key::Named::Space) => {
|
|
self.input_char(' ');
|
|
}
|
|
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
|
|
if self.cursor_index == 0 {
|
|
self.cursor_index = 0;
|
|
} else {
|
|
self.cursor_index -= 1;
|
|
}
|
|
}
|
|
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
|
|
if self.cursor_index >= self.input.len() {
|
|
self.cursor_index = self.input.len();
|
|
} else {
|
|
self.cursor_index += 1;
|
|
}
|
|
}
|
|
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
|
|
self.cursor_index = 0;
|
|
}
|
|
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
|
|
self.cursor_index = self.input.len();
|
|
}
|
|
_ => (),
|
|
}
|
|
return iced::Task::none();
|
|
}
|
|
Msg::Tick => {
|
|
let red = read_from_option_fd(self.fd.as_ref());
|
|
match red {
|
|
Ok(red) => {
|
|
if let Err(error) = self.update_screen(red) {
|
|
print_err(&error);
|
|
}
|
|
}
|
|
Err(error) => print_vomit(&error.to_string()),
|
|
}
|
|
return iced::Task::none();
|
|
}
|
|
}
|
|
}
|
|
/*
|
|
/// reads from the pty and adds it to the buffer
|
|
#[expect(
|
|
clippy::arithmetic_side_effects,
|
|
clippy::indexing_slicing,
|
|
reason = "all is bound checked"
|
|
)]
|
|
fn update_screen_buffer(&mut self, vec: Vec<u8>) -> Result<(), Error> {
|
|
for chr in String::from_utf8_lossy(&vec).ansi_parse() {
|
|
match chr {
|
|
Token::Text(txt) => {
|
|
print_debug(&(String::from("[CHR]") + txt));
|
|
if self.screen_buffer_index < self.screen_buffer.len() {
|
|
self.screen_buffer[self.screen_buffer_index] =
|
|
*txt.as_bytes().get(0).unwrap_or(&b'_');
|
|
self.screen_buffer_index += 1;
|
|
}
|
|
}
|
|
Token::C0(c0) => print_debug(&(String::from("[C0]") + &format!("{:?}", c0))),
|
|
Token::EscapeSequence(seq) => {
|
|
print_debug(&(String::from("[SEQ]") + &format!("{:?}", seq)))
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
*/
|
|
fn update_screen(&mut self, vec: Vec<u8>) -> Result<(), Error> {
|
|
for chr in String::from_utf8_lossy(&vec).ansi_parse() {
|
|
match chr {
|
|
Token::Text(chr) => {
|
|
print_debug(&(String::from("[CHR]") + chr));
|
|
if self.cursor.1 < self.dimensions.1 {
|
|
self.cursor.1 += 1;
|
|
} else {
|
|
if self.cursor.0 < self.dimensions.0 {
|
|
self.cursor.0 += 1;
|
|
self.cursor.1 = 1;
|
|
} else {
|
|
self.screen.remove(0);
|
|
self.cursor.1 = 1;
|
|
}
|
|
}
|
|
let res = self.write_chr_to_screen(chr);
|
|
}
|
|
Token::C0(c0) => {
|
|
print_debug(&(String::from("[C0]") + &format!("{:?}", c0)));
|
|
match c0 {
|
|
C0::SP => {
|
|
if self.cursor.1 < self.dimensions.1 {
|
|
self.cursor.1 += 1;
|
|
} else {
|
|
self.cursor.0 += 1;
|
|
self.cursor.1 = 1;
|
|
}
|
|
let res = self.write_chr_to_screen(" ");
|
|
}
|
|
C0::CR => self.cursor.1 = 1,
|
|
C0::LF => {
|
|
if self.cursor.0 < self.dimensions.0 {
|
|
self.cursor.0 += 1;
|
|
} else {
|
|
self.screen.remove(0);
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
Token::EscapeSequence(seq) => {
|
|
print_debug(&(String::from("[SEQ]") + &format!("{:?}", seq)))
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
fn write_chr_to_screen(&mut self, chr: &str) -> Result<(), Error> {
|
|
if self.dimensions.0 >= self.cursor.0 && self.dimensions.1 >= self.cursor.1 {
|
|
while self.screen.len() < self.cursor.0 {
|
|
self.screen.push(vec![]);
|
|
}
|
|
while self.screen[self.cursor.0 - 1].len() < self.cursor.1 {
|
|
self.screen[self.cursor.0 - 1].push("_".to_string());
|
|
}
|
|
self.screen[self.cursor.0 - 1].remove(self.cursor.1 - 1);
|
|
self.screen[self.cursor.0 - 1].insert(self.cursor.1 - 1, chr.to_string());
|
|
} else {
|
|
return Err(Error::IndexOutOfBounds);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// view logic for model\
|
|
/// TODO add wide char support\
|
|
/// TODO bound check
|
|
#[inline]
|
|
#[expect(
|
|
clippy::arithmetic_side_effects,
|
|
clippy::indexing_slicing,
|
|
clippy::string_slice,
|
|
reason = "TODO"
|
|
)]
|
|
pub fn view(&self) -> Element<'_, Msg> {
|
|
/*
|
|
let (left, right) =
|
|
String::from_utf8_lossy(&self.screen_buffer[..self.screen_buffer_index])
|
|
.rsplit_once('\n')
|
|
.map_or_else(
|
|
|| {
|
|
return (
|
|
String::new(),
|
|
String::from_utf8_lossy(
|
|
&self.screen_buffer[..self.screen_buffer_index],
|
|
)
|
|
.to_string(),
|
|
);
|
|
},
|
|
|tup| {
|
|
return (tup.0.to_owned(), tup.1.to_owned());
|
|
},
|
|
);
|
|
*/
|
|
let mut body = String::new();
|
|
for row in &self.screen {
|
|
body += &row.iter().map(|a| a.to_owned()).collect::<String>();
|
|
body += "\n";
|
|
}
|
|
return scrollable(column![
|
|
text(body),
|
|
row![
|
|
text(&self.input[..(self.cursor_index)]),
|
|
if self.cursor_index < self.input.len() {
|
|
row![
|
|
if self.input[self.cursor_index..=self.cursor_index] == *" " {
|
|
text("_").color(iced::Color::from_rgb(f32::MAX, 0.0, 0.0))
|
|
} else {
|
|
text(&self.input[self.cursor_index..=self.cursor_index])
|
|
.color(iced::Color::from_rgb(f32::MAX, 0.0, 0.0))
|
|
},
|
|
text(&self.input[(self.cursor_index + 1)..])
|
|
]
|
|
} else {
|
|
row![
|
|
text(&self.input[self.cursor_index..]),
|
|
rich_text![
|
|
span("_")
|
|
.color(iced::Color::from_rgb(f32::MAX, 0.0, 0.0))
|
|
.background(iced::Color::from_rgb(f32::MAX, f32::MAX, 0.0))
|
|
]
|
|
]
|
|
}
|
|
]
|
|
])
|
|
.into();
|
|
}
|
|
}
|
|
|
|
impl Default for Model {
|
|
#[inline]
|
|
#[expect(clippy::undocumented_unsafe_blocks, reason = "clippy be trippin")]
|
|
fn default() -> Self {
|
|
let mut me = Self {
|
|
cursor_index: 0,
|
|
fd: None,
|
|
input: String::new(),
|
|
/// SAFETY call *after* `init()`
|
|
shell: unsafe { SHELL.clone() }.map_or_else(
|
|
|| return String::from("/home/mtgmonkey/.nix-profile/bin/dash"),
|
|
|shell| return shell,
|
|
),
|
|
screen: vec![],
|
|
cursor: (1, 1),
|
|
dimensions: (25, 80),
|
|
};
|
|
me.fd = spawn_pty_with_shell(&me.shell).ok();
|
|
let mut nored = true;
|
|
while nored {
|
|
let red = read_from_option_fd(me.fd.as_ref());
|
|
if let Ok(red) = red {
|
|
nored = false;
|
|
if let Err(error) = me.update_screen(red) {
|
|
print_err(&error);
|
|
}
|
|
}
|
|
}
|
|
return me;
|
|
}
|
|
}
|
|
|
|
/// # Safety
|
|
/// call *before* creating a `Model` because `Model::default()` relies on `SHELL`
|
|
/// call *before* `print_err()` because `print_err()` relies on `VERBOSE`
|
|
#[inline]
|
|
#[expect(clippy::undocumented_unsafe_blocks, reason = "clippy be trippin")]
|
|
pub unsafe fn init(flags: Flags) {
|
|
unsafe {
|
|
DEBUG = flags.debug;
|
|
}
|
|
unsafe {
|
|
VERBOSE = flags.verbose;
|
|
}
|
|
unsafe {
|
|
VOMIT = flags.vomit;
|
|
}
|
|
unsafe {
|
|
SHELL = flags.shell;
|
|
}
|
|
}
|
|
|
|
/// spawns a pty with the specified shell program
|
|
#[expect(clippy::single_call_fn, reason = "abstraction")]
|
|
fn spawn_pty_with_shell(default_shell: &str) -> Result<OwnedFd, Error> {
|
|
// SAFETY: always safe unless the OS is out of ptys
|
|
// so it is always safe
|
|
match unsafe { forkpty(None, None) } {
|
|
Ok(fork_pty_res) => match fork_pty_res {
|
|
ForkptyResult::Parent { master, .. } => {
|
|
if let Err(error) = set_nonblock(&master) {
|
|
return Err(error);
|
|
}
|
|
return Ok(master);
|
|
}
|
|
ForkptyResult::Child => {
|
|
if let Err(error) = Command::new(default_shell).spawn() {
|
|
return Err(Error::Io(error));
|
|
}
|
|
thread::sleep(core_time::Duration::MAX);
|
|
return Err(Error::Unreachable);
|
|
}
|
|
},
|
|
Err(error) => return Err(Error::Nix(NixError::Errno(error))),
|
|
}
|
|
}
|
|
|
|
/// reads from an `&OwnedFd`
|
|
/// TODO check bounds
|
|
#[expect(
|
|
clippy::single_call_fn,
|
|
clippy::indexing_slicing,
|
|
reason = "abstraction"
|
|
)]
|
|
fn read_from_fd(fd: &OwnedFd) -> Result<Vec<u8>, Error> {
|
|
let mut read_buffer = [0; 0x4000];
|
|
#[expect(clippy::unwrap_used, reason = "platform-specific but fine")]
|
|
let mut file = File::from(fd.try_clone().unwrap());
|
|
let file = file.read(&mut read_buffer);
|
|
match file {
|
|
Ok(file) => return Ok(read_buffer[..file].to_vec()),
|
|
Err(error) => return Err(Error::Io(error)),
|
|
}
|
|
}
|
|
|
|
/// reads from an `Option<&OwnedFd>` if it's there
|
|
fn read_from_option_fd(maybe_fd: Option<&OwnedFd>) -> Result<Vec<u8>, Error> {
|
|
return maybe_fd.map_or(Err(Error::NoFileDescriptor), |fd| {
|
|
return read_from_fd(fd);
|
|
});
|
|
}
|
|
|
|
/// sets a `OwnedFd` as nonblocking.
|
|
#[expect(clippy::single_call_fn, reason = "abstraction")]
|
|
fn set_nonblock(fd: &OwnedFd) -> Result<(), Error> {
|
|
let flags = match fcntl::fcntl(fd, fcntl::FcntlArg::F_GETFL) {
|
|
Ok(flags) => flags,
|
|
Err(errno) => return Err(Error::Nix(NixError::Errno(errno))),
|
|
};
|
|
let flags = fcntl::OFlag::from_bits(flags & fcntl::OFlag::O_ACCMODE.bits());
|
|
match flags {
|
|
Some(mut flags) => {
|
|
flags.set(fcntl::OFlag::O_NONBLOCK, true);
|
|
if let Err(errno) = fcntl::fcntl(fd, fcntl::FcntlArg::F_SETFL(flags)) {
|
|
return Err(Error::Nix(NixError::Errno(errno)));
|
|
}
|
|
}
|
|
None => return Err(Error::Nix(NixError::UnrecognisedFlag)),
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
/// if `VERBOSE` is `true`, logs errors
|
|
#[inline]
|
|
#[expect(
|
|
clippy::print_stdout,
|
|
clippy::undocumented_unsafe_blocks,
|
|
reason = "toggleable with VERBOSE option\n
|
|
clippy be buggin"
|
|
)]
|
|
fn print_err(error: &Error) {
|
|
/// SAFETY the only time `VERBOSE` is written to should be `init()`
|
|
if unsafe { VERBOSE } {
|
|
println!("[ERROR] {error}");
|
|
}
|
|
}
|
|
|
|
/// if `VOMIT` is `true`, logs vomit
|
|
#[inline]
|
|
#[expect(
|
|
clippy::print_stdout,
|
|
clippy::undocumented_unsafe_blocks,
|
|
reason = "toggleable with VERBOSE option\n
|
|
clippy be buggin"
|
|
)]
|
|
fn print_vomit(vomit: &str) {
|
|
/// SAFETY the only time `VOMIT` is written to should be `init()`
|
|
if unsafe { VOMIT } {
|
|
println!("[VOMIT] {:?}", vomit);
|
|
}
|
|
}
|
|
|
|
/// if `DEBUG` is `true`, logs errors
|
|
#[inline]
|
|
#[expect(
|
|
clippy::print_stdout,
|
|
clippy::undocumented_unsafe_blocks,
|
|
reason = "toggleable with VERBOSE option\n
|
|
clippy be buggin"
|
|
)]
|
|
fn print_debug(debug: &str) {
|
|
/// SAFETY the only time `DEBUG` is written to should be `init()`
|
|
if unsafe { DEBUG } {
|
|
println!("[DEBUG] {:?}", debug);
|
|
}
|
|
}
|