Compare commits

...

10 Commits

11 changed files with 23478 additions and 89 deletions

488
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ members = [
"coordinates",
"cyberpunk-splash",
"dashboard",
"editor-challenge",
"emseries",
"file-service",
"fitnesstrax/core",

View File

@ -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

View File

@ -0,0 +1,5 @@
use editor_challenge::types::bench::*;
fn main() {
bench_insert_lines();
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,6 @@
pub mod doc_rope;
pub mod ui;
pub mod state;
pub mod types;

View File

@ -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(())
}

View File

@ -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);
}
_ => {}
};
}
}

View File

@ -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);
});
}
}

View File

@ -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();
}
}