Skip to content

Commit

Permalink
Interactive parser development example writing.
Browse files Browse the repository at this point in the history
  • Loading branch information
Gohla committed Nov 22, 2023
1 parent 3c07278 commit 41e6949
Show file tree
Hide file tree
Showing 6 changed files with 344 additions and 9 deletions.
File renamed without changes.
61 changes: 61 additions & 0 deletions src/4_example/e_1_editor_buffer_mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use std::io;

use crossterm::event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::Terminal;
use ratatui::widgets::Paragraph;

use crate::Args;

mod buffer;

/// Live parser development editor.
pub struct Editor {}

impl Editor {
/// Create a new editor from `args`.
pub fn new(_args: Args) -> Result<Self, io::Error> {
Ok(Self {})
}

/// Run the editor, drawing it into an alternate screen of the terminal.
pub fn run(&mut self) -> Result<(), io::Error> {
// Setup terminal for GUI rendering.
enable_raw_mode()?;
let mut backend = CrosstermBackend::new(io::stdout());
crossterm::execute!(backend, EnterAlternateScreen, EnableMouseCapture)?;
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;

// Draw and process events in a loop until a quit is requested or an error occurs.
let result = loop {
match self.draw_and_process_event(&mut terminal) {
Ok(false) => break Ok(()), // Quit requested
Err(e) => break Err(e), // Error
_ => {},
}
};

// First undo our changes to the terminal.
disable_raw_mode()?;
crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
// Then present the result to the user.
result
}

fn draw_and_process_event<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<bool, io::Error> {
terminal.draw(|frame| {
frame.render_widget(Paragraph::new("Hello World! Press Esc to exit."), frame.size());
})?;

match crossterm::event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Release => return Ok(true), // Skip releases.
Event::Key(key) if key.code == KeyCode::Esc => return Ok(false),
_ => {}
};

Ok(true)
}
}
105 changes: 105 additions & 0 deletions src/4_example/e_2_buffer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#![allow(dead_code)]

use std::fs::{File, read_to_string};
use std::io::{self, Write};
use std::path::PathBuf;

use crossterm::event::Event;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use tui_textarea::TextArea;

/// Editable text buffer for a file.
pub struct Buffer {
path: PathBuf,
editor: TextArea<'static>,
feedback: String,
modified: bool,
}

impl Buffer {
/// Create a new [`Buffer`] for file at `path`.
///
/// # Errors
///
/// Returns an error when reading file at `path` fails.
pub fn new(path: PathBuf) -> Result<Self, io::Error> {
let text = read_to_string(&path)?;
let mut editor = TextArea::from(text.lines());

// Enable line numbers. Default style = no additional styling (inherit).
editor.set_line_number_style(Style::default());

Ok(Self { path, editor, feedback: String::default(), modified: false })
}

/// Draws this buffer with `frame` into `area`, highlighting it if it is `active`.
pub fn draw(&mut self, frame: &mut Frame, area: Rect, active: bool) {
// Determine and set styles based on whether this buffer is active. Default style = no additional styling (inherit).
let mut cursor_line_style = Style::default();
let mut cursor_style = Style::default();
let mut block_style = Style::default();
if active { // Highlight active editor.
cursor_line_style = cursor_line_style.add_modifier(Modifier::UNDERLINED);
cursor_style = cursor_style.add_modifier(Modifier::REVERSED);
block_style = block_style.fg(Color::Gray);
}
self.editor.set_cursor_line_style(cursor_line_style);
self.editor.set_cursor_style(cursor_style);

// Create and set the block for the text editor, bordering it and providing a title.
let mut block = Block::default().borders(Borders::ALL).style(block_style);
if let Some(file_name) = self.path.file_name() { // Add file name as title.
block = block.title(format!("{}", file_name.to_string_lossy()))
}
if self.modified { // Add modified to title.
block = block.title("[modified]");
}
self.editor.set_block(block);

// Split area up into a text editor (80% of available space), and feedback text (minimum of 7 lines).
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Percentage(80), Constraint::Min(7)])
.split(area);
// Render text editor into first area (`areas[0]`).
frame.render_widget(self.editor.widget(), areas[0]);
// Render feedback text into second area (`areas[1]`).
let feedback = Paragraph::new(self.feedback.clone())
.wrap(Wrap::default())
.block(Block::default().style(block_style).borders(Borders::ALL - Borders::TOP));
frame.render_widget(feedback, areas[1]);
}

/// Process `event`, updating whether this buffer is modified.
pub fn process_event(&mut self, event: Event) {
self.modified |= self.editor.input(event);
}

/// Save this buffer to its file if it is modified. Does nothing if not modified. Sets as unmodified when successful.
///
/// # Errors
///
/// Returns an error if writing buffer text to the file fails.
pub fn save_if_modified(&mut self) -> Result<(), io::Error> {
if !self.modified {
return Ok(());
}
let mut file = io::BufWriter::new(File::create(&self.path)?);
for line in self.editor.lines() {
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
}
file.flush()?;
self.modified = false;
Ok(())
}

/// Gets the file path of this buffer.
pub fn path(&self) -> &PathBuf { &self.path }

/// Gets the mutable feedback text of this buffer.
pub fn feedback_mut(&mut self) -> &mut String { &mut self.feedback }
}
107 changes: 107 additions & 0 deletions src/4_example/e_3_editor_buffers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use std::io;

use crossterm::event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::Terminal;
use ratatui::widgets::Paragraph;

use crate::Args;
use crate::editor::buffer::Buffer;

mod buffer;

/// Live parser development editor.
pub struct Editor {
buffers: Vec<Buffer>,
active_buffer: usize,
}

impl Editor {
/// Create a new editor from `args`.
///
/// # Errors
///
/// Returns an error when creating a buffer fails.
pub fn new(args: Args) -> Result<Self, io::Error> {
let mut buffers = Vec::with_capacity(1 + args.program_file_paths.len());
buffers.push(Buffer::new(args.grammar_file_path)?); // First buffer is always the grammar buffer.
for path in args.program_file_paths {
buffers.push(Buffer::new(path)?); // Subsequent buffers are always example program buffers.
}

let editor = Self { buffers, active_buffer: 0, };
Ok(editor)
}

/// Run the editor, drawing it into an alternate screen of the terminal.
pub fn run(&mut self) -> Result<(), io::Error> {
// Setup terminal for GUI rendering.
enable_raw_mode()?;
let mut backend = CrosstermBackend::new(io::stdout());
crossterm::execute!(backend, EnterAlternateScreen, EnableMouseCapture)?;
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;

// Draw and process events in a loop until a quit is requested or an error occurs.
let result = loop {
match self.draw_and_process_event(&mut terminal) {
Ok(false) => break Ok(()), // Quit requested
Err(e) => break Err(e), // Error
_ => {},
}
};

// First undo our changes to the terminal.
disable_raw_mode()?;
crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
// Then present the result to the user.
result
}

fn draw_and_process_event<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<bool, io::Error> {
terminal.draw(|frame| {
let root_areas = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Percentage(100), Constraint::Min(1)])
.split(frame.size());
let buffer_areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(root_areas[0]);

// Draw grammar buffer on the left (`buffer_areas[0]`).
self.buffers[0].draw(frame, buffer_areas[0], self.active_buffer == 0);

{ // Draw example program buffers on the right (`buffer_areas[1]`).
let num_program_buffers = self.buffers.len() - 1;
// Split vertical space between example program buffers.
let program_buffer_areas = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Ratio(1, num_program_buffers as u32); num_program_buffers])
.split(buffer_areas[1]);
for ((buffer, area), i) in self.buffers[1..].iter_mut().zip(program_buffer_areas.iter()).zip(1..) {
buffer.draw(frame, *area, self.active_buffer == i);
}
}

// Draw help line on the last line (`root_areas[1]`).
let help = Paragraph::new("Interactive Parser Development. Press Esc to quit, ^T to switch active \
buffer.");
frame.render_widget(help, root_areas[1]);
})?;

match crossterm::event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Release => return Ok(true), // Skip releases.
Event::Key(key) if key.code == KeyCode::Esc => return Ok(false),
Event::Key(key) if key.code == KeyCode::Char('t') && key.modifiers.contains(KeyModifiers::CONTROL) => {
self.active_buffer = (self.active_buffer + 1) % self.buffers.len();
}
event => self.buffers[self.active_buffer].process_event(event), // Otherwise: forward to current buffer.
};

Ok(true)
}
}
72 changes: 64 additions & 8 deletions src/4_example/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ We will put the editor in a separate module, and start out with the basic scaffo
Add `editor` as a public module to `pie/examples/parser_dev/main.rs`:

```diff2html linebyline
{{#include ../gen/4_example/d_2_main_editor.rs.diff}}
{{#include ../gen/4_example/d_2_main_editor_mod.rs.diff}}
```

Create the `pie/examples/parser_dev/editor.rs` file and add the following to it:
Expand Down Expand Up @@ -312,13 +312,69 @@ Press escape to exit out of the application.
If the program ever panics, your terminal will be left in a bad state.
In that case, you'll have to reset your terminal back to a good state, or restart your terminal.

### Text Editing
### Text Editor `Buffer`

[//]: # ()
[//]: # (run)
The goal of this application is to develop a grammar alongside example programs of that grammar, getting feedback whether the grammar is correct, but also getting feedback whether the example programs can be parsed with the grammar.
Therefore, we will need to draw multiple text editors along with space for feedback, and be able to swap between active editors.
This will be the responsibility of the `Buffer` struct which we will create in a separate module.
Add the `buffer` module to `pie/examples/parser_dev/editor.rs`:

[//]: # ()
[//]: # (`Buffer`)
```diff2html
{{#include ../gen/4_example/e_1_editor_buffer_mod.rs.diff}}
```

Then create the `pie/examples/parser_dev/editor/buffer.rs` file and add to it:

```rust,
{{#include e_2_buffer.rs}}
```

A `Buffer` is a text editor for a text file at a certain `path`.
It keeps track of a text editor with `TextArea<'static>`, `feedback` text, and whether the text was `modified` in relation to the file.
`new` creates a `Buffer` and is fallible due to reading a file.

The `draw` method draws/renders the buffer (using the Ratatui `frame`) into `area`, with `active` signifying that this buffer is active and should be highlighted differently.
The first part sets the style of the editor, mainly highlighting an active editor by using `Color::Gray` as the block style.
Default styles indicate that no additional styling is done, basically inheriting the style from a parent widget (i.e., a block), or using the style from your terminal.
The second part creates a [block](https://ratatui.rs/how-to/widgets/block.html) that renders a border around the text editor and renders a title on the upper border.
The third part splits up the available space into space for the text editor (80%), and space for the feedback text (at least 7 lines), and renders the text editor and feedback text into those spaces.
The layout can of course be tweaked, but it works for this example.

`process_event` lets the text editor process input events, and updates whether the text has been modified.
`save_if_modified` saves the text to file, but only if modified.
`path` gets the file path of the buffer.
`feedback_mut` returns a mutable borrow to the feedback text, enabling modification of the feedback text.

It is up to the user of `Buffer` to keep track of the active buffer, sending `active: true` to the `draw` method of that buffer, and calling `process_event` on the active buffer.
That's exactly what we're going to implement next.

### Drawing and Updating `Buffer`s

We'll create `Buffers` in `Editor` and keep track of the active buffer.
Modify `pie/examples/parser_dev/editor.rs`:

```diff2html
{{#include ../gen/4_example/e_3_editor_buffers.rs.diff}}
```

`Editor` now has a list of `buffers` via `Vec<Buffer>` and keeps track of the active tracker via `active_buffer` which is an index into `buffers`.
In `new`, we create buffers based on the grammar and program file paths in `args`.
The buffers `Vec` is created in such a way that the first buffer is always the grammar buffer, with the rest being example program buffers.
The grammar buffer always exists because `args.grammar_file_path` is mandatory, but there can be 0 or more example program buffers.

`draw_and_process_event` now splits up the available space.
First vertically: as much space as possible is reserved for buffers, with at least 1 line being reserved for a help line at the bottom.
Then horizontally: half of the horizontal space is reserved for a grammar buffer, and the other half for program buffers.
The vertical space for program buffers (`program_buffer_areas`) is further divided: evenly split between all program buffers.

Then, the buffers are drawn in the corresponding spaces with `active` only being `true` if we are drawing the active buffer, based on the `active_buffer` index.

In the event processing code, we match the Control+T shortcut and increase the `active_buffer` index.
We wrap back to 0 when the `active_buffer` index would overflow using a modulo (%) operator, ensuring that `active_buffer` is always a correct index into the `buffers` `Vec`.
Finally, if none of the other shortcuts match, we send the event to the active buffer.

Try out the code again with `cargo run --example parser_dev -- test.pest main test_1.test test_2.test -e` in a terminal.
This should open up the application with a grammar buffer on the left, and two program buffers on the right.
Use Control+T to swap between buffers, and escape to exit.

[//]: # ()
[//]: # (create buffers in `new`)
### Saving `Buffer`s and Providing Feedback
8 changes: 7 additions & 1 deletion stepper/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,15 @@ pub fn step_all(

stepper.apply([
create_diff_from_destination_file("d_1_Cargo.toml", "pie/Cargo.toml"),
create_diff_from_destination_file("d_2_main_editor.rs", "pie/examples/parser_dev/main.rs"),
create_diff_from_destination_file("d_2_main_editor_mod.rs", "pie/examples/parser_dev/main.rs"),
add("d_3_editor.rs", "pie/examples/parser_dev/editor.rs"),
create_diff_from_destination_file("d_4_main_cli.rs", "pie/examples/parser_dev/main.rs"),
]);

stepper.apply([
create_diff_from_destination_file("e_1_editor_buffer_mod.rs", "pie/examples/parser_dev/editor.rs"),
add("e_2_buffer.rs", "pie/examples/parser_dev/editor/buffer.rs"),
create_diff_from_destination_file("e_3_editor_buffers.rs", "pie/examples/parser_dev/editor.rs"),
]);
});
}

0 comments on commit 41e6949

Please sign in to comment.