Compare commits
3 Commits
20b02fbd90
...
61127339bc
Author | SHA1 | Date |
---|---|---|
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",
|
||||
"cyberpunk-splash",
|
||||
"dashboard",
|
||||
"editor-challenge",
|
||||
"emseries",
|
||||
"file-service",
|
||||
"fitnesstrax/core",
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
[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" ] }
|
|
@ -0,0 +1,289 @@
|
|||
use crossterm::{
|
||||
event::{self, KeyCode, KeyEvent, KeyModifiers},
|
||||
terminal::{disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::{self, BufRead, BufReader, Read},
|
||||
path::PathBuf,
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Terminal,
|
||||
};
|
||||
|
||||
const TITLE: &str = "Text Editor Challenge";
|
||||
const COPYRIGHT: &str = "(c) Savanni D'Gerinel - all rights reserved";
|
||||
const TICK_RATE_MS: u64 = 200;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Document {
|
||||
rows: Vec<String>
|
||||
}
|
||||
|
||||
impl Document {
|
||||
fn contents(&self) -> String {
|
||||
self.rows.join("\n")
|
||||
}
|
||||
|
||||
fn row_length(&self, idx: usize) -> usize {
|
||||
self.rows[idx].len()
|
||||
}
|
||||
|
||||
fn row_count(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AppState {
|
||||
path: Option<PathBuf>,
|
||||
cursor: Cursor,
|
||||
contents: Document, // Obviously this is bad, but it's also only temporary.
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn open(path: PathBuf) -> Self {
|
||||
let mut file = File::open(path.clone()).unwrap();
|
||||
let mut 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{ rows: contents },
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_up(&mut self) {
|
||||
self.cursor.cursor_up(&self.contents);
|
||||
}
|
||||
|
||||
fn cursor_down(&mut self) {
|
||||
self.cursor.cursor_down(&self.contents);
|
||||
}
|
||||
|
||||
fn cursor_right(&mut self) {
|
||||
self.cursor.cursor_right(&self.contents);
|
||||
}
|
||||
|
||||
fn cursor_left(&mut self) {
|
||||
self.cursor.cursor_left();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cursor {
|
||||
row: usize,
|
||||
column: usize,
|
||||
desired_column: usize,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn cursor_up(&mut self, doc: &Document) {
|
||||
if self.row > 0 {
|
||||
self.row -= 1;
|
||||
}
|
||||
self.correct_columns(doc);
|
||||
}
|
||||
|
||||
fn cursor_down(&mut self, doc: &Document) {
|
||||
if self.row < doc.row_count() - 1 {
|
||||
self.row += 1;
|
||||
}
|
||||
self.correct_columns(doc);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_right(&mut self, doc: &Document) {
|
||||
if self.column < doc.row_length(self.row) {
|
||||
self.column += 1;
|
||||
}
|
||||
self.desired_column = self.column;
|
||||
}
|
||||
|
||||
fn cursor_left(&mut self) {
|
||||
if self.column > 0 {
|
||||
self.column -= 1;
|
||||
}
|
||||
self.desired_column = self.column;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn render<T>(app_state: &AppState, terminal: &mut Terminal<T>) -> Result<(), anyhow::Error>
|
||||
where
|
||||
T: tui::backend::Backend,
|
||||
{
|
||||
terminal.draw(|rect| {
|
||||
let size = rect.size();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(2)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(2),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(size);
|
||||
|
||||
let title = Paragraph::new(
|
||||
app_state
|
||||
.path
|
||||
.clone()
|
||||
.map(|path| path.to_string_lossy().to_owned().into())
|
||||
.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))
|
||||
.title("Copyright")
|
||||
.border_type(BorderType::Plain),
|
||||
);
|
||||
rect.render_widget(title, chunks[0]);
|
||||
|
||||
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]);
|
||||
|
||||
// TODO: rework scrolling as soon as I have a working cursor
|
||||
let contents = Paragraph::new(app_state.contents.contents()).scroll((0, 0));
|
||||
rect.render_widget(contents, chunks[1]);
|
||||
|
||||
// TODO: keeping track of the index of the top row and subtract that from the cursor row.
|
||||
let row = app_state.cursor.row as u16;
|
||||
let column = app_state.cursor.column as u16;
|
||||
rect.set_cursor(chunks[1].x + column, chunks[1].y + row);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 {
|
||||
if let Ok(_) = tx.send(Event::Tick) {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app_loop<T>(
|
||||
mut app_state: AppState,
|
||||
terminal: &mut Terminal<T>,
|
||||
rx: mpsc::Receiver<Event<KeyEvent>>,
|
||||
) -> Result<(), anyhow::Error>
|
||||
where
|
||||
T: tui::backend::Backend,
|
||||
{
|
||||
loop {
|
||||
render(&app_state, terminal)?;
|
||||
|
||||
let event = rx.recv()?;
|
||||
|
||||
match event {
|
||||
Event::Input(KeyEvent { code, modifiers })
|
||||
if code == KeyCode::Char('x') && modifiers == KeyModifiers::CONTROL =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Event::Input(KeyEvent { code, .. }) if code == KeyCode::Down => {
|
||||
app_state.cursor_down();
|
||||
}
|
||||
Event::Input(KeyEvent { code, .. }) if code == KeyCode::Up => {
|
||||
app_state.cursor_up();
|
||||
}
|
||||
Event::Input(KeyEvent { code, .. }) if code == KeyCode::Right => {
|
||||
app_state.cursor_right();
|
||||
}
|
||||
Event::Input(KeyEvent { code, .. }) if code == KeyCode::Left => {
|
||||
app_state.cursor_left();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
enable_raw_mode()?;
|
||||
|
||||
let stdout = io::stdout();
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let _ = terminal.clear()?;
|
||||
|
||||
let result = app_loop(app_state, &mut terminal, rx);
|
||||
|
||||
disable_raw_mode()?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in New Issue