375 lines
9.5 KiB
Rust
375 lines
9.5 KiB
Rust
use core::slice::GetDisjointMutIndex;
|
|
use std::{collections::HashSet, fs::File, io::{self, Read}, path::PathBuf};
|
|
use crossterm::event::KeyEvent;
|
|
use ratatui::{style::Stylize, text::Span};
|
|
use serde::{Deserialize, Serialize};
|
|
use crate::{BYTES_PER_LINE, action::{Action, AppAction}, buffer::actions::bytes_to_nat, config::Config, cursor::Cursor, edit_action::EditAction, popup::Popup, window_size::WindowSize};
|
|
|
|
mod widget;
|
|
mod actions;
|
|
|
|
pub struct Buffer {
|
|
pub file_name: String,
|
|
pub file_path: PathBuf,
|
|
|
|
pub contents: Vec<u8>,
|
|
|
|
pub scroll_position: usize,
|
|
pub primary_cursor: Cursor,
|
|
pub cursors: Vec<Cursor>,
|
|
|
|
pub marks: HashSet<usize>,
|
|
|
|
pub mode: Mode,
|
|
pub partial_action: Option<PartialAction>,
|
|
pub partial_replace: Option<u8>,
|
|
|
|
pub alert_message: Span<'static>,
|
|
pub popups: Vec<Popup>,
|
|
|
|
pub inspection_status: Option<InspectionStatus>,
|
|
|
|
pub edit_history: Vec<EditAction>,
|
|
// the index *after* the latest edit action
|
|
pub time_traveling: Option<usize>,
|
|
// the index *after* the last saved edit action
|
|
pub last_saved_at: Option<usize>,
|
|
|
|
pub logs: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[derive(Debug)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Mode {
|
|
Normal, Select, Insert
|
|
}
|
|
|
|
#[derive(Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[derive(Debug)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PartialAction {
|
|
Goto, View, Replace, Space, Repeat, To
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum InspectionStatus {
|
|
Normal, ColorsOnly
|
|
}
|
|
|
|
impl TryFrom<&str> for PartialAction {
|
|
type Error = ();
|
|
|
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
use PartialAction::*;
|
|
|
|
match value {
|
|
"goto" => Ok(Goto),
|
|
"view" => Ok(View),
|
|
"replace" => Ok(Replace),
|
|
"space" => Ok(Space),
|
|
"repeat" => Ok(Repeat),
|
|
"to" => Ok(To),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Buffer {
|
|
pub fn from_file_at(file_path: PathBuf) -> io::Result<Self> {
|
|
let mut file = File::open(&file_path)?;
|
|
let mut contents = Vec::new();
|
|
file.read_to_end(&mut contents)?;
|
|
|
|
Ok(Self::new(file_path, contents))
|
|
}
|
|
|
|
pub fn new(file_path: PathBuf, contents: Vec<u8>) -> Self {
|
|
Self {
|
|
file_name: file_path.file_name().unwrap().to_str().unwrap().to_owned(),
|
|
file_path,
|
|
|
|
contents,
|
|
|
|
scroll_position: 0,
|
|
primary_cursor: Cursor::default(),
|
|
cursors: Vec::new(),
|
|
|
|
marks: HashSet::new(),
|
|
|
|
mode: Mode::Normal,
|
|
partial_action: None,
|
|
partial_replace: None,
|
|
|
|
alert_message: "".into(),
|
|
popups: Vec::new(),
|
|
|
|
inspection_status: None,
|
|
|
|
edit_history: Vec::new(),
|
|
time_traveling: None,
|
|
last_saved_at: Some(0),
|
|
|
|
logs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn handle_key(
|
|
&mut self,
|
|
event: KeyEvent,
|
|
config: &Config,
|
|
primary_cursor_register: &[u8],
|
|
other_cursor_registers: &[Vec<u8>],
|
|
window_size: WindowSize
|
|
) -> Option<AppAction> {
|
|
self.alert_message = "".into();
|
|
// self.logs.push(format!("{event:?}"));
|
|
|
|
let app_action = match self.partial_action {
|
|
Some(PartialAction::Replace) => {
|
|
self.handle_replace(event, window_size);
|
|
None
|
|
},
|
|
Some(PartialAction::Repeat) => {
|
|
self.handle_repeat(
|
|
event,
|
|
config,
|
|
primary_cursor_register,
|
|
other_cursor_registers,
|
|
window_size
|
|
);
|
|
None
|
|
},
|
|
_ => self.handle_other_modes(event, config, window_size),
|
|
};
|
|
|
|
assert!(self.scroll_position.is_multiple_of(BYTES_PER_LINE));
|
|
assert!(self.scroll_position < self.contents.len());
|
|
assert!(self.primary_cursor.head < self.contents.len());
|
|
assert!(self.primary_cursor.tail < self.contents.len());
|
|
assert!(self.scroll_position <= self.primary_cursor.head);
|
|
assert!(self.primary_cursor.head < self.scroll_position + window_size.visible_byte_count());
|
|
|
|
debug_assert!(self.cursors.is_sorted_by_key(|cursor| cursor.head));
|
|
|
|
app_action
|
|
}
|
|
|
|
fn handle_replace(&mut self, event: KeyEvent, window_size: WindowSize) {
|
|
if let Some(hex_character) = event.code.as_char() &&
|
|
let Some(nybble) = nybble_from_hex(hex_character)
|
|
{
|
|
if let Some(partial_replace) = self.partial_replace.take() {
|
|
self.execute_and_add(
|
|
EditAction::Replace {
|
|
primary_cursor: self.primary_cursor,
|
|
cursors: self.cursors.clone(),
|
|
primary_old_data: self.contents[self.primary_cursor.range()].to_vec(),
|
|
old_data: self.cursors
|
|
.iter()
|
|
.map(|cursor| self.contents[cursor.range()].to_vec())
|
|
.collect(),
|
|
new_byte: partial_replace << 4 | nybble
|
|
},
|
|
window_size
|
|
);
|
|
self.partial_action = None;
|
|
} else {
|
|
self.partial_replace = Some(nybble);
|
|
}
|
|
} else {
|
|
self.partial_action = None;
|
|
self.partial_replace = None;
|
|
}
|
|
}
|
|
|
|
fn handle_other_modes(
|
|
&mut self,
|
|
event: KeyEvent,
|
|
config: &Config,
|
|
window_size: WindowSize
|
|
) -> Option<AppAction> {
|
|
use Action::*;
|
|
|
|
let mut result = None;
|
|
|
|
let should_reset_partial = self.partial_action.is_some();
|
|
|
|
if let Some(mode_config) = config.0.get(&self.mode) &&
|
|
let Some(keybinds) = mode_config.0.get(&self.partial_action) &&
|
|
let Some(action) = keybinds.0.get(&event.into())
|
|
{
|
|
if action.clears_popups() {
|
|
self.popups.clear();
|
|
}
|
|
|
|
match action {
|
|
App(app_action) => result = Some(*app_action),
|
|
Buffer(buffer_action) => self.execute(*buffer_action, window_size),
|
|
Cursor(cursor_action) => {
|
|
let max_contents_index = self.max_contents_index();
|
|
|
|
self.primary_cursor.execute(*cursor_action, max_contents_index);
|
|
|
|
for cursor in &mut self.cursors {
|
|
cursor.execute(*cursor_action, max_contents_index);
|
|
}
|
|
self.cursors.sort_by_key(|cursor| cursor.head);
|
|
|
|
self.combine_cursors_if_overlapping();
|
|
self.clamp_screen_to_primary_cursor(window_size);
|
|
},
|
|
}
|
|
|
|
if action.clears_popups() && !action.is_inspection() {
|
|
self.inspection_status = None;
|
|
}
|
|
}
|
|
|
|
if should_reset_partial {
|
|
self.partial_action = None;
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn handle_repeat(
|
|
&mut self,
|
|
event: KeyEvent,
|
|
config: &Config,
|
|
primary_cursor_register: &[u8],
|
|
other_cursor_registers: &[Vec<u8>],
|
|
window_size: WindowSize
|
|
) {
|
|
self.partial_action = None;
|
|
|
|
if let Some(mode_config) = config.0.get(&self.mode) &&
|
|
let Some(keybinds) = mode_config.0.get(&Some(PartialAction::Repeat)) &&
|
|
let Some(action) = keybinds.0.get(&event.into())
|
|
{
|
|
match action {
|
|
Action::Cursor(cursor_action) => {
|
|
let Some(primary_repeat_count) = bytes_to_nat(primary_cursor_register) else {
|
|
self.alert_message = Span::from(
|
|
"repeat count is too large"
|
|
).red();
|
|
return;
|
|
};
|
|
let other_repeat_counts = other_cursor_registers
|
|
.iter()
|
|
.map(|register| bytes_to_nat(register));
|
|
|
|
if other_repeat_counts.clone().any(|count| count.is_none()) {
|
|
self.alert_message = Span::from(
|
|
"repeat count is too large"
|
|
).red();
|
|
return;
|
|
}
|
|
|
|
let max_contents_index = self.max_contents_index();
|
|
|
|
for _ in 0..primary_repeat_count {
|
|
self.primary_cursor.execute(*cursor_action, max_contents_index);
|
|
}
|
|
|
|
for (cursor, repeat_count) in self.cursors.iter_mut().zip(other_repeat_counts) {
|
|
for _ in 0..repeat_count.unwrap() {
|
|
cursor.execute(*cursor_action, max_contents_index);
|
|
}
|
|
}
|
|
self.cursors.sort_by_key(|cursor| cursor.head);
|
|
|
|
self.combine_cursors_if_overlapping();
|
|
self.clamp_screen_to_primary_cursor(window_size);
|
|
},
|
|
_ => panic!("repeated actions may only be cursor actions"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const fn has_unsaved_changes(&self) -> bool {
|
|
!self.all_changes_saved()
|
|
}
|
|
|
|
pub const fn all_changes_saved(&self) -> bool {
|
|
if let Some(last_saved_at) = self.last_saved_at {
|
|
if let Some(time_traveling) = self.time_traveling {
|
|
last_saved_at == time_traveling
|
|
} else {
|
|
last_saved_at == self.edit_history.len()
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
// returns 0 if empty
|
|
pub const fn max_contents_index(&self) -> usize {
|
|
self.contents.len().saturating_sub(1)
|
|
}
|
|
|
|
pub fn combine_cursors_if_overlapping(&mut self) {
|
|
let mut index = 0;
|
|
|
|
// TODO: this can miss some in the WEIRD case that
|
|
// [ *]
|
|
// [ *]
|
|
// [ *]
|
|
// where * is the head.
|
|
// the first one wont merge with the 2nd, but the 2nd will
|
|
// merge with the 3rd, which would then overlap with the 1st,
|
|
// but won't be checked
|
|
|
|
while !self.cursors.is_empty() && index < self.cursors.len() {
|
|
while index < self.cursors.len() - 1 &&
|
|
self.cursors[index].range().is_overlapping(
|
|
&self.cursors[index + 1].range()
|
|
)
|
|
{
|
|
let next_cursor = self.cursors[index + 1];
|
|
self.cursors[index].combine_with(next_cursor);
|
|
self.cursors.remove(index + 1);
|
|
}
|
|
|
|
if self.primary_cursor.range()
|
|
.is_overlapping(&self.cursors[index].range())
|
|
{
|
|
self.primary_cursor.combine_with(self.cursors[index]);
|
|
self.cursors.remove(index);
|
|
} else {
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn nybble_from_hex(hex: char) -> Option<u8> {
|
|
if !hex.is_ascii() { return None; }
|
|
|
|
match hex {
|
|
'0'..='9' => Some(u8::try_from(hex).unwrap() - u8::try_from('0').unwrap()),
|
|
'a'..='f' => Some(u8::try_from(hex).unwrap() - u8::try_from('a').unwrap() + 10),
|
|
'A'..='F' => Some(u8::try_from(hex).unwrap() - u8::try_from('A').unwrap() + 10),
|
|
_ => None
|
|
}
|
|
}
|
|
|
|
mod tests {
|
|
#[allow(unused_imports)]
|
|
use crate::buffer::nybble_from_hex;
|
|
|
|
#[test]
|
|
fn nybble_from_hex_case_doesnt_matter() {
|
|
for character in 'a'..='f' {
|
|
assert_eq!(nybble_from_hex(character), nybble_from_hex(character.to_ascii_uppercase()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nybble_from_hex_digits_are_correct() {
|
|
for (index, character) in ('0'..='9').enumerate() {
|
|
assert_eq!(nybble_from_hex(character), Some(index as u8));
|
|
}
|
|
}
|
|
}
|