Skip to content

Commit

Permalink
feat: move inside input with left/right keys
Browse files Browse the repository at this point in the history
  • Loading branch information
funbiscuit committed Jan 21, 2024
1 parent 8db09f3 commit 67acf13
Show file tree
Hide file tree
Showing 14 changed files with 527 additions and 58 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square)](LICENSE-APACHE)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE-MIT)
[![Build Status](https://img.shields.io/github/actions/workflow/status/funbiscuit/embedded-cli-rs/ci.yml?branch=main&style=flat-square)](https://github.com/funbiscuit/embedded-cli-rs/actions/workflows/ci.yml?query=branch%3Amain)
[![Coverage Status](https://img.shields.io/codecov/c/github/funbiscuit/embedded-cli-rs?style=flat-square)](https://app.codecov.io/github/funbiscuit/embedded-cli-rs)
[![Coverage Status](https://img.shields.io/codecov/c/github/funbiscuit/embedded-cli-rs/main?style=flat-square)](https://app.codecov.io/github/funbiscuit/embedded-cli-rs)


[Demo](examples/arduino/README.md) of CLI running on Arduino Nano.
Expand All @@ -27,6 +27,7 @@ for now. If you have suggestions - open an Issue or a Pull Request.
- [x] No dynamic dispatch
- [x] Configurable memory usage
- [x] Declaration of commands with enums
- [x] Left/right support (move inside current input)
- [x] Parsing of arguments to common types
- [x] Autocompletion of command names (with tab)
- [x] History (navigate with up and down keypress)
Expand Down
45 changes: 32 additions & 13 deletions demo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#
# To convert recorded mp4 it's best to use gifski:
# ffmpeg -i embedded-cli.mp4 frame%04d.png
# gifski -o demo.gif -Q 20 frame*.png
# gifski -o demo.gif -Q 50 frame*.png

type () {
xdotool type --delay 300 -- "$1"
Expand All @@ -16,16 +16,27 @@ submit () {
xdotool key --delay 500 Return
}
backspace () {
xdotool key --delay 500 BackSpace
local repeat=${1:-1}
xdotool key --delay 400 --repeat $repeat BackSpace
}
tab () {
xdotool key --delay 500 Tab
}
left () {
local repeat=${1:-1}
xdotool key --delay 400 --repeat $repeat Left
}
right () {
local repeat=${1:-1}
xdotool key --delay 400 --repeat $repeat Right
}
up () {
xdotool key --delay 800 Up
local repeat=${1:-1}
xdotool key --delay 800 --repeat $repeat Up
}
down () {
xdotool key --delay 800 Down
local repeat=${1:-1}
xdotool key --delay 800 --repeat $repeat Down
}

echo "Demo started"
Expand Down Expand Up @@ -56,18 +67,26 @@ submit

up

type "--help"
type "--hlp"
left 2
type "e"
submit

sleep 0.5

up
up
up 2

type "Rust"
submit

type "help get-led"
type "got-l"
left 5
type "help "
right 2
backspace
type "e"
right 3
type "ed"
submit

type "g"
Expand All @@ -79,8 +98,7 @@ tab
type "12"
submit

up
up
up 2
down
backspace
type "3"
Expand All @@ -91,10 +109,11 @@ submit

up
type " 123 789"
backspace
backspace
backspace
backspace 3
type "456"
left 4
backspace 2
type "01"
submit

# Wait until keys disappear
Expand Down
1 change: 1 addition & 0 deletions embedded-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ embedded-io = "0.6.1"
ufmt = "0.2.0"

[dev-dependencies]
regex = "1.10.2"
rstest = "0.18.2"
38 changes: 34 additions & 4 deletions embedded-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ enum NavigateHistory {
Newer,
}

enum NavigateInput {
Backward,
Forward,
}

#[doc(hidden)]
pub struct Cli<W: Write<Error = E>, E: Error, CommandBuffer: Buffer, HistoryBuffer: Buffer> {
editor: Option<Editor<CommandBuffer>>,
Expand Down Expand Up @@ -184,8 +189,13 @@ where
}

fn on_text_input(&mut self, editor: &mut Editor<CommandBuffer>, text: &str) -> Result<(), E> {
let is_inside = editor.cursor() < editor.len();
if let Some(c) = editor.insert(text) {
//TODO: cursor position not at end
if is_inside {
// text is always one char
debug_assert_eq!(c.chars().count(), 1);
self.writer.write_bytes(codes::INSERT_CHAR)?;
}
self.writer.flush_str(c)?;
}
Ok(())
Expand Down Expand Up @@ -219,16 +229,36 @@ where
ControlInput::Backspace => {
if editor.move_left() {
editor.remove();
self.writer.flush_str("\x08 \x08")?;
self.writer.flush_bytes(codes::CURSOR_BACKWARD)?;
self.writer.flush_bytes(codes::DELETE_CHAR)?;
}
}
ControlInput::Down => self.navigate_history(editor, NavigateHistory::Newer)?,
ControlInput::Up => self.navigate_history(editor, NavigateHistory::Older)?,
ControlInput::Forward => self.navigate_input(editor, NavigateInput::Forward)?,
ControlInput::Back => self.navigate_input(editor, NavigateInput::Backward)?,
}

Ok(())
}

fn navigate_input(
&mut self,
editor: &mut Editor<CommandBuffer>,
dir: NavigateInput,
) -> Result<(), E> {
match dir {
NavigateInput::Backward if editor.move_left() => {
self.writer.flush_bytes(codes::CURSOR_BACKWARD)?;
}
NavigateInput::Forward if editor.move_right() => {
self.writer.flush_bytes(codes::CURSOR_FORWARD)?;
}
_ => return Ok(()),
}
Ok(())
}

fn navigate_history(
&mut self,
editor: &mut Editor<CommandBuffer>,
Expand Down Expand Up @@ -265,8 +295,8 @@ where
_ => {}
}
});
let autocompleted = editor.text_range(initial_cursor..);
if !autocompleted.is_empty() {
if editor.cursor() > initial_cursor {
let autocompleted = editor.text_range(initial_cursor..);
self.writer.flush_str(autocompleted)?;
}
Ok(())
Expand Down
6 changes: 6 additions & 0 deletions embedded-cli/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ pub const CARRIAGE_RETURN: u8 = 0x0D;
pub const ESCAPE: u8 = 0x1B;

pub const CRLF: &str = "\r\n";

// escape sequence reference: https://ecma-international.org/publications-and-standards/standards/ecma-48
pub const CURSOR_FORWARD: &[u8] = b"\x1B[C";
pub const CURSOR_BACKWARD: &[u8] = b"\x1B[D";
pub const INSERT_CHAR: &[u8] = b"\x1B[@";
pub const DELETE_CHAR: &[u8] = b"\x1B[P";
117 changes: 111 additions & 6 deletions embedded-cli/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,30 @@ impl<B: Buffer> Editor<B> {

/// Calls given function to create autocompletion of current input
pub fn autocompletion(&mut self, f: impl FnOnce(Request<'_>, &mut Autocompletion<'_>)) {
if self.cursor < self.len() {
//autocompletion is possible only when cursor is at the end
return;
}

// SAFETY: self.valid is always less than or equal to buffer len
let (text, buf) = unsafe { utils::split_at_mut(self.buffer.as_slice_mut(), self.valid) };

// SAFETY: buffer stores only valid utf-8 bytes 0..valid range
let text = unsafe { core::str::from_utf8_unchecked(text) };
let mut text = unsafe { core::str::from_utf8_unchecked(text) };
let mut removed_spaces = 0;

if let Some(pos) = text
.char_indices()
.skip(self.cursor)
.map(|(pos, _)| pos)
.next()
{
// cursor is inside text, so trim all whitespace, that is on the right to the cursor
let right = &text.as_bytes()[pos..];
let pos2 = right
.iter()
.rev()
.position(|&b| b != b' ')
.unwrap_or(right.len());
// SAFETY: pos2 is at the char boundary
text = unsafe { text.get_unchecked(..text.len() - pos2) };
removed_spaces = pos2;
}

if let Some(request) = Request::from_input(text) {
let mut autocompletion = Autocompletion::new(buf);
Expand All @@ -62,6 +76,13 @@ impl<B: Buffer> Editor<B> {
buf[bytes] = b' ';
bytes += 1;
}
if removed_spaces > 0 {
// shift autocompleted text to the left
self.buffer
.as_slice_mut()
.copy_within(self.valid.., self.valid - removed_spaces);
self.valid -= removed_spaces;
}
self.valid += bytes;
self.cursor = self.len();
}
Expand Down Expand Up @@ -122,6 +143,15 @@ impl<B: Buffer> Editor<B> {
}
}

pub fn move_right(&mut self) -> bool {
if self.cursor < self.len() {
self.cursor += 1;
true
} else {
false
}
}

/// Removes char at cursor position
pub fn remove(&mut self) {
let mut it = self
Expand Down Expand Up @@ -253,6 +283,60 @@ mod tests {
}
}

#[rstest]
#[case("abc", 1, "Ж", "abЖc")]
#[case("abc", 2, "Ж", "aЖbc")]
#[case("abc", 3, "Ж ", "Ж abc")]
#[case("abc", 4, "Ж ", "Ж abc")]
#[case("adbc佐佗𑿌", 2, "Ж", "adbc佐Ж佗𑿌")]
fn move_left_insert(
#[case] initial: &str,
#[case] count: usize,
#[case] inserted: &str,
#[case] expected: &str,
) {
let mut editor = Editor::new([0; 128]);

editor.insert(initial);

for _ in 0..count {
editor.move_left();
}

editor.insert(inserted);

assert_eq!(editor.text_range(..), expected);
}

#[rstest]
#[case("abc", 3, 1, "Ж", "aЖbc")]
#[case("абв", 3, 2, "Ж", "абЖв")]
#[case("абв", 1, 1, "Ж ", "абвЖ ")]
#[case("абв", 1, 2, "Ж ", "абвЖ ")]
#[case("adbc佐佗𑿌", 4, 2, "Ж", "adbc佐Ж佗𑿌")]
fn move_left_then_right_insert(
#[case] initial: &str,
#[case] count_left: usize,
#[case] count_right: usize,
#[case] inserted: &str,
#[case] expected: &str,
) {
let mut editor = Editor::new([0; 128]);

editor.insert(initial);

for _ in 0..count_left {
editor.move_left();
}
for _ in 0..count_right {
editor.move_right();
}

editor.insert(inserted);

assert_eq!(editor.text_range(..), expected);
}

#[test]
fn remove() {
let mut editor = Editor::new([0; 128]);
Expand Down Expand Up @@ -301,6 +385,27 @@ mod tests {
assert_eq!(editor.text(), "");
}

#[rstest]
#[case(1, "adbc佐佗")]
#[case(2, "adbc佐𑿌")]
#[case(3, "adbc佗𑿌")]
#[case(4, "adb佐佗𑿌")]
#[case(5, "adc佐佗𑿌")]
#[case(6, "abc佐佗𑿌")]
#[case(7, "dbc佐佗𑿌")]
fn remove_inside(#[case] dist: usize, #[case] expected: &str) {
let mut editor = Editor::new([0; 128]);

editor.insert("adbc佐佗𑿌");

for _ in 0..dist {
editor.move_left();
}
editor.remove();

assert_eq!(editor.text(), expected);
}

#[rstest]
#[case(.., "adbc佐佗𑿌")]
#[case(..2, "ad")]
Expand Down
6 changes: 6 additions & 0 deletions embedded-cli/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub enum ControlInput {
Backspace,
Down,
Enter,
Back,
Forward,
Tab,
Up,
}
Expand Down Expand Up @@ -64,6 +66,8 @@ impl InputGenerator {
let control = match byte {
b'A' => ControlInput::Up,
b'B' => ControlInput::Down,
b'C' => ControlInput::Forward,
b'D' => ControlInput::Back,
_ => return None,

Check warning on line 71 in embedded-cli/src/input.rs

View check run for this annotation

Codecov / codecov/patch

embedded-cli/src/input.rs#L71

Added line #L71 was not covered by tests
};
Some(control)
Expand Down Expand Up @@ -103,6 +107,8 @@ mod tests {
#[case(b"\x1B[A", ControlInput::Up)]
#[case(b"\x1B[B", ControlInput::Down)]
#[case(b"\x1B[24B", ControlInput::Down)]
#[case(b"\x1B[C", ControlInput::Forward)]
#[case(b"\x1B[D", ControlInput::Back)]
fn process_csi_control(#[case] bytes: &[u8], #[case] expected: ControlInput) {
let mut accum = InputGenerator::new();

Expand Down
Loading

0 comments on commit 67acf13

Please sign in to comment.