Compare commits
10 Commits
main
...
editor-cha
Author | SHA1 | Date |
---|---|---|
Savanni D'Gerinel | 77bc77cd20 | |
Savanni D'Gerinel | 02af9e4ea7 | |
Savanni D'Gerinel | 24d266ab34 | |
Savanni D'Gerinel | a2146a0168 | |
Savanni D'Gerinel | 54b34d81ec | |
Savanni D'Gerinel | 8f4d424d1d | |
Savanni D'Gerinel | 4a62372fd3 | |
Savanni D'Gerinel | 61127339bc | |
Savanni D'Gerinel | 612713ab1b | |
Savanni D'Gerinel | fd444a620d |
File diff suppressed because it is too large
Load Diff
|
@ -8,6 +8,7 @@ members = [
|
||||||
"coordinates",
|
"coordinates",
|
||||||
"cyberpunk-splash",
|
"cyberpunk-splash",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"editor-challenge",
|
||||||
"emseries",
|
"emseries",
|
||||||
"file-service",
|
"file-service",
|
||||||
"fitnesstrax/core",
|
"fitnesstrax/core",
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "editor-challenge"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { version = "1" }
|
||||||
|
crossterm = { version = "0.19", features = [ "serde" ] }
|
||||||
|
serde = { version = "1", features = [ "derive" ] }
|
||||||
|
serde_yml = { version = "*" }
|
||||||
|
thiserror = { version = "1" }
|
||||||
|
tui = { version = "0.19", default-features = false, features = [ "crossterm", "serde" ] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bench"
|
||||||
|
# main = "bin/bench.rs"
|
||||||
|
# features = [ "bench" ]
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,5 @@
|
||||||
|
use editor_challenge::types::bench::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
bench_insert_lines();
|
||||||
|
}
|
|
@ -0,0 +1,269 @@
|
||||||
|
use std::{mem, ops::Deref};
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 10;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct NodeId(usize);
|
||||||
|
|
||||||
|
impl Deref for NodeId {
|
||||||
|
type Target = usize;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Node {
|
||||||
|
Interior {
|
||||||
|
// the number of characters to the left of this node
|
||||||
|
// the number of lines to the left of this node
|
||||||
|
char_count: usize,
|
||||||
|
|
||||||
|
left: Option<NodeId>,
|
||||||
|
right: Option<NodeId>,
|
||||||
|
},
|
||||||
|
Leaf(LeafNode),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Node::Interior { char_count, .. } => 0,
|
||||||
|
Node::Leaf(ln) => ln.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LeafNode(String);
|
||||||
|
|
||||||
|
impl LeafNode {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self) -> String {
|
||||||
|
let content = mem::replace(&mut self.0, "".to_owned());
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for LeafNode {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Rope is a regular tree which adds on some extra behavior for dealing with a continuous data
|
||||||
|
/// structure. In this case, the nodes all contain strings, and the rope is arranged such that a
|
||||||
|
/// depth-first traversal will yield the entire contents of the rope in proper order.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Rope {
|
||||||
|
node_count: usize,
|
||||||
|
contents: Vec<Option<Node>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rope {
|
||||||
|
/// Insert text at an index within the document. loc is the number of characters from the
|
||||||
|
/// beginning.
|
||||||
|
pub fn insert_at(&mut self, loc: usize, text: String) {
|
||||||
|
match self.find_insertion_node_id(loc) {
|
||||||
|
None => {
|
||||||
|
let node = Node::Leaf(LeafNode::from(text));
|
||||||
|
self.node_count += 1;
|
||||||
|
self.contents.push(Some(node));
|
||||||
|
}
|
||||||
|
Some(id) => {
|
||||||
|
self.insert_at_node(id, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append text to the end of the document.
|
||||||
|
pub fn append(&mut self, text: String) {
|
||||||
|
self.insert_at(self.len(), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the entire rope back to a continuous String.
|
||||||
|
pub fn to_string(&self) -> String {
|
||||||
|
if self.contents.is_empty() {
|
||||||
|
return "".to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut r = String::new();
|
||||||
|
let mut stack = vec![NodeId(0)];
|
||||||
|
|
||||||
|
while let Some(current_id) = stack.pop() {
|
||||||
|
let node = &self.contents[*current_id];
|
||||||
|
match node {
|
||||||
|
Some(Node::Interior { left, right, .. }) => {
|
||||||
|
if let Some(right_id) = *right {
|
||||||
|
stack.push(right_id);
|
||||||
|
}
|
||||||
|
if let Some(left_id) = *left {
|
||||||
|
stack.push(left_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Node::Leaf(ln)) => r.push_str(ln.as_str()),
|
||||||
|
None => panic!("Should never leave an empty space in the node list"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the length of the stored string.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
// This can be optimized later. Do a traversal of each right node. We already have
|
||||||
|
// character counts of each left tree. Only count the length of the final right leaf.
|
||||||
|
self.contents.iter().fold(0, |acc, node| {
|
||||||
|
if let Some(Node::Leaf(s)) = node {
|
||||||
|
acc + s.len()
|
||||||
|
} else {
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn max_depth(&self) -> usize {
|
||||||
|
unimplemented!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn node_count(&self) -> usize {
|
||||||
|
self.node_count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the node ID of the insertion point. This is not fully implemented, in that this
|
||||||
|
// function ignores the offset from the beginning. Because of that, it is also always inserting
|
||||||
|
// onto the right side, and never traversing down the left.
|
||||||
|
fn find_insertion_node_id(&self, _loc: usize) -> Option<NodeId> {
|
||||||
|
let mut current_id = NodeId(0);
|
||||||
|
loop {
|
||||||
|
match self.contents.get(*current_id) {
|
||||||
|
Some(Some(Node::Interior { ref right, .. })) => match right {
|
||||||
|
Some(id) => current_id = *id,
|
||||||
|
None => return Some(current_id),
|
||||||
|
},
|
||||||
|
Some(Some(Node::Leaf(_))) => return Some(current_id),
|
||||||
|
Some(None) => panic!("There should never be an empty node in the tree"),
|
||||||
|
// This only happens when the list is empty. Otherwise, we're detecting the None in
|
||||||
|
// advance.
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert text at a particular node location.
|
||||||
|
//
|
||||||
|
// This is not a self-balancing operation (yet). Once we know where text needs to be inserted,
|
||||||
|
// based on the offset from the beginning, we can grab that node and either replace it (if it
|
||||||
|
// is a Leaf node) or update it (if it is an Interior node).
|
||||||
|
//
|
||||||
|
// This function is currently naive, in that it will always assume that text needs to be added
|
||||||
|
// to the right side, which may not be correct.
|
||||||
|
fn insert_at_node(&mut self, id: NodeId, text: String) {
|
||||||
|
match self.contents[*id] {
|
||||||
|
Some(Node::Interior { ref mut right, .. }) => {
|
||||||
|
let new_node = Node::Leaf(LeafNode::from(text));
|
||||||
|
let new_node_id = NodeId(self.node_count + 1);
|
||||||
|
|
||||||
|
*right = Some(new_node_id);
|
||||||
|
self.contents.push(Some(new_node));
|
||||||
|
|
||||||
|
self.node_count += 1;
|
||||||
|
}
|
||||||
|
Some(Node::Leaf(_)) => {
|
||||||
|
let Some(Node::Leaf(mut ln)) = mem::replace(&mut self.contents[*id], None) else {
|
||||||
|
panic!("Should never leave an empty space in the node list")
|
||||||
|
};
|
||||||
|
let contents = ln.take();
|
||||||
|
|
||||||
|
let lnode = Node::Leaf(LeafNode::from(contents));
|
||||||
|
let rnode = Node::Leaf(LeafNode::from(text));
|
||||||
|
|
||||||
|
let lnode_id = self.node_count;
|
||||||
|
let rnode_id = self.node_count + 1;
|
||||||
|
|
||||||
|
let interior_node = Node::Interior {
|
||||||
|
char_count: lnode.len(),
|
||||||
|
left: Some(NodeId(lnode_id)),
|
||||||
|
right: Some(NodeId(rnode_id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = mem::replace(&mut self.contents[*id], Some(interior_node));
|
||||||
|
|
||||||
|
self.node_count += 2;
|
||||||
|
self.contents.push(Some(lnode));
|
||||||
|
self.contents.push(Some(rnode));
|
||||||
|
|
||||||
|
}
|
||||||
|
None => panic!("Should never leave an empty space in the node list"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Rope {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
node_count: 0,
|
||||||
|
contents: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the initial rope. The simplest way is to split along lines and turn each line into its
|
||||||
|
// own leaf node.
|
||||||
|
impl From<String> for Rope {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
let mut rope = Rope::default();
|
||||||
|
#[allow(unused_assignments)]
|
||||||
|
let mut first = s.as_str();
|
||||||
|
let mut lst = s.as_str();
|
||||||
|
|
||||||
|
while lst.len() > CHUNK_SIZE {
|
||||||
|
(first, lst) = lst.split_at(CHUNK_SIZE);
|
||||||
|
rope.append(first.to_owned());
|
||||||
|
}
|
||||||
|
rope.append(lst.to_owned());
|
||||||
|
rope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct TestCase {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_creates_a_rope_from_a_string() {
|
||||||
|
let test_cases = vec![
|
||||||
|
TestCase{ content: "".to_owned() },
|
||||||
|
TestCase{ content: "This".to_owned() },
|
||||||
|
TestCase{ content: "This is some basic".to_owned() },
|
||||||
|
TestCase{ content: "This is some basic context which is much smaller".to_owned() },
|
||||||
|
TestCase{ content:
|
||||||
|
"This is some basic context which is much smaller than the rope is designed for."
|
||||||
|
.to_owned()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for case in test_cases {
|
||||||
|
let rope = Rope::from(case.content.clone());
|
||||||
|
|
||||||
|
for (idx, node) in rope.contents.iter().enumerate() {
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(rope.len(), case.content.len(), "{}", case.content);
|
||||||
|
assert_eq!(rope.to_string(), case.content, "{:?}", case.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod doc_rope;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod state;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
use crossterm::event::{self, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use editor_challenge::*;
|
||||||
|
use state::AppState;
|
||||||
|
use std::{
|
||||||
|
env, io,
|
||||||
|
sync::mpsc,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use ui::Canvas;
|
||||||
|
|
||||||
|
// const TITLE: &str = "Text Editor Challenge";
|
||||||
|
// const COPYRIGHT: &str = "(c) Savanni D'Gerinel - all rights reserved";
|
||||||
|
const TICK_RATE_MS: u64 = 200;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Event<I> {
|
||||||
|
Input(I),
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_input(tx: mpsc::Sender<Event<KeyEvent>>, tick_rate: Duration) {
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
loop {
|
||||||
|
let timeout = tick_rate
|
||||||
|
.checked_sub(last_tick.elapsed())
|
||||||
|
.unwrap_or_else(|| Duration::from_secs(0));
|
||||||
|
|
||||||
|
if event::poll(timeout).expect("poll works") {
|
||||||
|
if let event::Event::Key(key) = event::read().expect("can read events") {
|
||||||
|
tx.send(Event::Input(key)).expect("can send events");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_tick.elapsed() >= tick_rate && tx.send(Event::Tick).is_ok() {
|
||||||
|
last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_loop<T>(
|
||||||
|
mut app_state: AppState,
|
||||||
|
mut screen: Canvas<T>,
|
||||||
|
rx: mpsc::Receiver<Event<KeyEvent>>,
|
||||||
|
) -> Result<(), anyhow::Error>
|
||||||
|
where
|
||||||
|
T: tui::backend::Backend,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
screen.render(&app_state)?;
|
||||||
|
|
||||||
|
match rx.recv()? {
|
||||||
|
Event::Input(event)
|
||||||
|
if event.code == KeyCode::Char('x') && event.modifiers == KeyModifiers::CONTROL =>
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Event::Input(event) => app_state.handle_event(event),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), anyhow::Error> {
|
||||||
|
let args = env::args().collect::<Vec<String>>();
|
||||||
|
|
||||||
|
let file_name = if args.len() > 1 { Some(&args[1]) } else { None };
|
||||||
|
|
||||||
|
let app_state = match file_name {
|
||||||
|
Some(name) => AppState::open(name.into()),
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let tick_rate = Duration::from_millis(TICK_RATE_MS);
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle_input(tx, tick_rate);
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
terminal.clear()?;
|
||||||
|
|
||||||
|
let screen = Canvas::new(terminal);
|
||||||
|
|
||||||
|
let result = app_loop(app_state, screen, rx);
|
||||||
|
|
||||||
|
result?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
||||||
|
use crate::types::{Cursor, Document};
|
||||||
|
use std::{fs::File, io::{BufRead, BufReader}, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub cursor: Cursor,
|
||||||
|
pub contents: Document, // Obviously this is bad, but it's also only temporary.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn open(path: PathBuf) -> Self {
|
||||||
|
let file = File::open(path.clone()).unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let contents = reader
|
||||||
|
.lines()
|
||||||
|
.collect::<Result<Vec<String>, std::io::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
path: Some(path),
|
||||||
|
cursor: Default::default(),
|
||||||
|
contents: Document::new(contents),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_up(&mut self) {
|
||||||
|
self.cursor.cursor_up(&self.contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_down(&mut self) {
|
||||||
|
self.cursor.cursor_down(&self.contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_right(&mut self) {
|
||||||
|
self.cursor.cursor_right(&self.contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_left(&mut self) {
|
||||||
|
self.cursor.cursor_left();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_event(&mut self, event: KeyEvent) {
|
||||||
|
let KeyEvent { code, .. }: KeyEvent = event;
|
||||||
|
match code {
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.cursor_down();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.cursor_up();
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.cursor_right();
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.cursor_left();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.contents.backspace(&mut self.cursor);
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
self.contents.delete_at(&mut self.cursor);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.contents.new_line(&mut self.cursor);
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.contents.insert_at(&mut self.cursor, c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,202 @@
|
||||||
|
// TODO: I'm increasingly feeling that cursors are per-document, not per-application. So I think I
|
||||||
|
// want to move the cursor into here, and then rendering requires asking for the cursor for the
|
||||||
|
// current document.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Document {
|
||||||
|
rows: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
pub fn new(contents: Vec<String>) -> Self {
|
||||||
|
if contents.len() > (u16::MAX.into()) {
|
||||||
|
panic!("Document row count exceeds u16::MAX. The current scrolling code cannot handle that.");
|
||||||
|
}
|
||||||
|
Self { rows: contents }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line(&self, id: usize) -> Option<&str> {
|
||||||
|
self.rows.get(id).map(|x| x.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contents(&self) -> String {
|
||||||
|
self.rows.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_length(&self, idx: usize) -> usize {
|
||||||
|
self.rows[idx].len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_count(&self) -> usize {
|
||||||
|
self.rows.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_at(&mut self, cursor: &mut Cursor, c: char) {
|
||||||
|
let (row, column) = cursor.addr();
|
||||||
|
|
||||||
|
self.rows[row].insert(column, c);
|
||||||
|
cursor.cursor_right(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self, cursor: &mut Cursor) {
|
||||||
|
let (row, column) = cursor.addr();
|
||||||
|
if cursor.column > 0 {
|
||||||
|
let _ = self.rows[row].remove(column - 1);
|
||||||
|
cursor.cursor_left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_at(&mut self, cursor: &mut Cursor) {
|
||||||
|
let (row, column) = cursor.addr();
|
||||||
|
if cursor.column < self.rows[row].len() {
|
||||||
|
self.rows[row].remove(column);
|
||||||
|
cursor.correct_columns(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_line(&mut self, cursor: &mut Cursor) {
|
||||||
|
// when doing a newline, take everything to the right of the cursor from the current line
|
||||||
|
// and move it to the next line.
|
||||||
|
let (row, _) = cursor.addr();
|
||||||
|
|
||||||
|
self.rows.insert(row, String::new());
|
||||||
|
cursor.cursor_down(&self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Cursor {
|
||||||
|
row: usize,
|
||||||
|
column: usize,
|
||||||
|
desired_column: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cursor {
|
||||||
|
pub fn addr(&self) -> (usize, usize) {
|
||||||
|
(self.row, self.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_up(&mut self, doc: &Document) {
|
||||||
|
if self.row > 0 {
|
||||||
|
self.row -= 1;
|
||||||
|
}
|
||||||
|
self.correct_columns(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_down(&mut self, doc: &Document) {
|
||||||
|
if self.row < doc.row_count() - 1 {
|
||||||
|
self.row += 1;
|
||||||
|
}
|
||||||
|
self.correct_columns(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correct_columns(&mut self, doc: &Document) {
|
||||||
|
let row_len = doc.row_length(self.row);
|
||||||
|
if self.desired_column < row_len {
|
||||||
|
self.column = self.desired_column;
|
||||||
|
} else {
|
||||||
|
self.column = row_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_right(&mut self, doc: &Document) {
|
||||||
|
if self.column < doc.row_length(self.row) {
|
||||||
|
self.column += 1;
|
||||||
|
}
|
||||||
|
self.desired_column = self.column;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_left(&mut self) {
|
||||||
|
if self.column > 0 {
|
||||||
|
self.column -= 1;
|
||||||
|
}
|
||||||
|
self.desired_column = self.column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod test_utils {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{BufRead, BufReader},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn with_moby_dick<F>(test: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(Document),
|
||||||
|
{
|
||||||
|
let f = File::open("fixtures/moby-dick.txt").unwrap();
|
||||||
|
let reader = BufReader::new(f);
|
||||||
|
let contents = reader
|
||||||
|
.lines()
|
||||||
|
.collect::<Result<Vec<String>, std::io::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
let doc = Document::new(contents);
|
||||||
|
test(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn measure<F>(test: F) -> Duration
|
||||||
|
where
|
||||||
|
F: FnOnce(),
|
||||||
|
{
|
||||||
|
let start = Instant::now();
|
||||||
|
test();
|
||||||
|
let end = Instant::now();
|
||||||
|
end - start
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn benchmark<A>(
|
||||||
|
num_iterations: usize,
|
||||||
|
setup: impl Fn() -> A,
|
||||||
|
test: impl FnOnce(A) + Copy,
|
||||||
|
) -> Duration {
|
||||||
|
let mut measurements: Duration = Duration::from_millis(0);
|
||||||
|
|
||||||
|
for _i in 0..num_iterations {
|
||||||
|
let data = setup();
|
||||||
|
measurements += measure(move || test(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements / (num_iterations as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::{test_utils::*, *};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_inserts_a_line() {
|
||||||
|
with_moby_dick(|mut doc| {
|
||||||
|
let mut cursor = Cursor::default();
|
||||||
|
|
||||||
|
let num_lines = doc.row_count();
|
||||||
|
assert_eq!(
|
||||||
|
doc.line(num_lines - 3),
|
||||||
|
Some("subscribe to our email newsletter to hear about new eBooks.")
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.new_line(&mut cursor);
|
||||||
|
assert_eq!(doc.row_count(), num_lines + 1);
|
||||||
|
assert_eq!(doc.line(0), Some(""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod bench {
|
||||||
|
use super::{test_utils::*, *};
|
||||||
|
|
||||||
|
pub fn bench_insert_lines() {
|
||||||
|
with_moby_dick(|doc| {
|
||||||
|
let performance = benchmark(
|
||||||
|
1000,
|
||||||
|
|| doc.clone(),
|
||||||
|
|mut doc| {
|
||||||
|
let mut cursor = Cursor::default();
|
||||||
|
doc.new_line(&mut cursor);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
println!("[bench_insert_lines] {:?}", performance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
|
use tui::{layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Style}, widgets::{Block, BorderType, Borders, Paragraph}, Terminal};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub struct Canvas<T: tui::backend::Backend> {
|
||||||
|
top_row: usize,
|
||||||
|
terminal: Terminal<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: tui::backend::Backend> Canvas<T> {
|
||||||
|
pub fn new(terminal: Terminal<T>) -> Self {
|
||||||
|
enable_raw_mode().unwrap();
|
||||||
|
Self {
|
||||||
|
top_row: 0,
|
||||||
|
terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&mut self, app_state: &AppState) -> Result<(), anyhow::Error>
|
||||||
|
{
|
||||||
|
self.terminal.draw(|rect| {
|
||||||
|
let size = rect.size();
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(2)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Min(2),
|
||||||
|
Constraint::Length(3),
|
||||||
|
// Constraint::Length(3),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
let title = Paragraph::new(
|
||||||
|
app_state
|
||||||
|
.path
|
||||||
|
.clone()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or("No file opened".to_owned()),
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::LightCyan))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.border_type(BorderType::Plain),
|
||||||
|
);
|
||||||
|
rect.render_widget(title, chunks[1]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
let cp = Paragraph::new(COPYRIGHT)
|
||||||
|
.style(Style::default().fg(Color::LightCyan))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.title("Copyright")
|
||||||
|
.border_type(BorderType::Plain),
|
||||||
|
);
|
||||||
|
rect.render_widget(cp, chunks[2]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
let (row, column) = app_state.cursor.addr();
|
||||||
|
if row == self.top_row && row >= 1 {
|
||||||
|
self.top_row -= 1;
|
||||||
|
} else if row - self.top_row == (chunks[0].height - 1).into() {
|
||||||
|
self.top_row += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = Paragraph::new(app_state.contents.contents()).scroll((self.top_row as u16, 0));
|
||||||
|
rect.render_widget(contents, chunks[0]);
|
||||||
|
|
||||||
|
rect.set_cursor(chunks[0].x + column as u16, chunks[0].y + (row - self.top_row) as u16);
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: tui::backend::Backend> Drop for Canvas<T> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = self.terminal.show_cursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue