diff --git a/document.go b/document.go index 110b94a2..ab8a6d21 100644 --- a/document.go +++ b/document.go @@ -144,6 +144,36 @@ func (d *Document) FindStartOfPreviousWord() int { return 0 } +// FindStartOfPreviousWordCursor finds the start of the previous word and returns +// the associated cursor position. +// Determines whether a rune is part of a word using `isWordPart`. +func (d *Document) FindStartOfPreviousWordCursor(isWordPart func(rune) bool) int { + textBeforeCursor := []rune(d.TextBeforeCursor()) + cursor := len(textBeforeCursor) - 1 + for cursor >= 0 && !isWordPart(textBeforeCursor[cursor]) { + cursor-- + } + for cursor >= 0 && isWordPart(textBeforeCursor[cursor]) { + cursor-- + } + return cursor + 1 +} + +// FindEndOfCurrentWordCursor finds the end of the current word and returns +// the cursor position exactly after the end. +// Determines whether a rune is part of a word using `isWordPart`. +func (d *Document) FindEndOfCurrentWordCursor(isWordPart func(rune) bool) int { + textAfterCursor := []rune(d.TextAfterCursor()) + cursor := 0 + for cursor < len(textAfterCursor) && !isWordPart(textAfterCursor[cursor]) { + cursor++ + } + for cursor < len(textAfterCursor) && isWordPart(textAfterCursor[cursor]) { + cursor++ + } + return d.cursorPosition + cursor +} + // FindStartOfPreviousWordWithSpace is almost the same as FindStartOfPreviousWord. // The only difference is to ignore contiguous spaces. func (d *Document) FindStartOfPreviousWordWithSpace() int { diff --git a/document_test.go b/document_test.go index 64a05b6c..cb1d4e9b 100644 --- a/document_test.go +++ b/document_test.go @@ -1476,3 +1476,113 @@ func Test_GetCustomCursorPosition(t *testing.T) { }) } } + +func TestFindStartOfPreviousWordCursor(t *testing.T) { + isWordPart := func(r rune) bool { + return r != '\n' && r != ' ' + } + cases := []struct { + description string + input string + cursor int + expectedCursor int + }{ + { + "wo[r]d -> [w]ord", + "word", + 2, + 0, + }, + { + " word[] -> [w]ord ", + " word ", + 5, + 1, + }, + { + "строка 1\n[с]трока2\nстрока3 -> строка [1]\nстрока2\nстрока3", + "строка 1\nстрока2\nстрока3 ", + 9, + 7, + }, + { + "слово\nслово2\n[с]лово3 -> слово\n[с]лово2\nслово3", + "слово\nслово2\nслово3", + 13, + 6, + }, + { + "some long [] line -> some [l]ong line", + "some long line", + 10, + 5, + }, + { + "[] -> []", + "", + 0, + 0, + }, + { + "a\n\n\n\n\n\n[b]a -> [a]\n\n\n\n\n\nba", + "a\n\n\n\n\n\nba", + 7, + 0, + }, + } + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + d := Document{Text: tc.input, cursorPosition: tc.cursor} + assert.Equal(t, tc.expectedCursor, d.FindStartOfPreviousWordCursor(isWordPart)) + }) + } +} + +func TestFindEndOfCurrentWordCursor(t *testing.T) { + isWordPart := func(r rune) bool { + return r != '\n' && r != ' ' + } + cases := []struct { + description string + input string + cursor int + expectedCursor int + }{ + { + "wo[r]d -> word[]", + "word", + 2, + 4, + }, + { + " [] word -> word[]", + " word", + 1, + 8, + }, + { + "строка1\n[]строка2\nстрока3 -> строка1\n строка2[\n]строка3", + "строка1\n строка2\nстрока3", + 8, + 16, + }, + { + "[] -> []", + "", + 0, + 0, + }, + { + "[\n]\n\n\nслово->\n\n\n\nслово[]", + "\n\n\n\nслово", + 0, + 9, + }, + } + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + d := Document{Text: tc.input, cursorPosition: tc.cursor} + assert.Equal(t, tc.expectedCursor, d.FindEndOfCurrentWordCursor(isWordPart)) + }) + } +} diff --git a/emacs.go b/emacs.go index 58859e42..bde4884b 100644 --- a/emacs.go +++ b/emacs.go @@ -42,18 +42,12 @@ var emacsKeyBindings = []KeyBind{ // Go to the End of the line { Key: ControlE, - Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextAfterCursor()) - buf.CursorRight(len(x)) - }, + Fn: GoCmdEnd, }, // Go to the beginning of the line { Key: ControlA, - Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextBeforeCursor()) - buf.CursorLeft(len(x)) - }, + Fn: GoCmdBeginning, }, // Cut the Line after the cursor { @@ -90,16 +84,12 @@ var emacsKeyBindings = []KeyBind{ // Right allow: Forward one character { Key: ControlF, - Fn: func(buf *Buffer) { - buf.CursorRight(1) - }, + Fn: GoRightChar, }, // Left allow: Backward one character { Key: ControlB, - Fn: func(buf *Buffer) { - buf.CursorLeft(1) - }, + Fn: GoLeftChar, }, // Cut the Word before the cursor. { diff --git a/internal/prompt_app/prompt_app.go b/internal/prompt_app/prompt_app.go index 9382b702..6a8c9745 100644 --- a/internal/prompt_app/prompt_app.go +++ b/internal/prompt_app/prompt_app.go @@ -9,6 +9,16 @@ import ( "github.com/c-bata/go-prompt" ) +var ( + ControlLeftBytes []byte + ControlRightBytes []byte +) + +func init() { + ControlLeftBytes = []byte{0x1b, 0x62} + ControlRightBytes = []byte{0x1b, 0x66} +} + // Console describes the console. type Console struct { title string @@ -81,6 +91,23 @@ func getPromptOptions(console *Console) []prompt.Option { prompt.OptionDisableAutoHistory(), prompt.OptionReverseSearch(), + + prompt.OptionAddASCIICodeBind( + // Move to one word left. + prompt.ASCIICodeBind{ + ASCIICode: ControlLeftBytes, + Fn: func(buf *prompt.Buffer) { + prompt.GoLeftWord(buf) + }, + }, + // Move to one word right. + prompt.ASCIICodeBind{ + ASCIICode: ControlRightBytes, + Fn: func(buf *prompt.Buffer) { + prompt.GoRightWord(buf) + }, + }, + ), } args := os.Args if len(args) > 1 { diff --git a/key_bind.go b/key_bind.go index 44124e82..fdafe781 100644 --- a/key_bind.go +++ b/key_bind.go @@ -29,12 +29,12 @@ var commonKeyBindings = []KeyBind{ // Go to the End of the line { Key: End, - Fn: GoLineEnd, + Fn: GoCmdEnd, }, // Go to the beginning of the line { Key: Home, - Fn: GoLineBeginning, + Fn: GoCmdBeginning, }, // Delete character under the cursor { diff --git a/key_bind_func.go b/key_bind_func.go index 8d693d50..f0e8b5ed 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -56,13 +56,26 @@ func GoLeftChar(buf *Buffer) { buf.CursorLeft(1) } -// GoRightWord Forward one word. +// GoRightWord moves the cursor to the end of the next word. func GoRightWord(buf *Buffer) { - buf.CursorRight(buf.Document().FindEndOfCurrentWordWithSpace()) + buf.setCursorPosition(buf.Document().FindEndOfCurrentWordCursor(func(r rune) bool { + return r != '\n' && r != ' ' + })) } -// GoLeftWord Backward one word. +// GoLeftWord moves the cursor to the beginning of the previous word. func GoLeftWord(buf *Buffer) { - buf.CursorLeft(len([]rune(buf.Document().TextBeforeCursor())) - buf.Document(). - FindStartOfPreviousWordWithSpace()) + buf.setCursorPosition(buf.Document().FindStartOfPreviousWordCursor(func(r rune) bool { + return r != '\n' && r != ' ' + })) +} + +// GoCmdBeginning moves the cursor to the beginning of the command. +func GoCmdBeginning(buf *Buffer) { + buf.setCursorPosition(0) +} + +// GoCmdEnd moves the cursor to the end of the command. +func GoCmdEnd(buf *Buffer) { + buf.setCursorPosition(len([]rune(buf.Text()))) } diff --git a/key_bind_func_test.go b/key_bind_func_test.go index efe9124a..76f1e5fe 100644 --- a/key_bind_func_test.go +++ b/key_bind_func_test.go @@ -41,3 +41,155 @@ func TestGoLeftChar(t *testing.T) { GoLeftChar(buf) assert.Equal(t, 7, buf.cursorPosition) } + +func TestGoCmdBeginning(t *testing.T) { + t.Run("basic", func(t *testing.T) { + input := "зеленый\nред\nсиний" + buf := NewBuffer() + buf.InsertText(input, false, true) + + GoCmdBeginning(buf) + assert.Equal(t, 0, buf.cursorPosition) + + buf.cursorPosition = 2 + GoCmdBeginning(buf) + assert.Equal(t, 0, buf.cursorPosition) + }) + + t.Run("empty buffer", func(t *testing.T) { + buf := NewBuffer() + GoCmdBeginning(buf) + assert.Equal(t, 0, buf.cursorPosition) + }) +} + +func TestGoCmdEnd(t *testing.T) { + t.Run("basic", func(t *testing.T) { + input := "зеленый\nред\nсиний" + buf := NewBuffer() + buf.InsertText(input, false, true) + + GoCmdEnd(buf) + assert.Equal(t, 17, buf.cursorPosition) + + buf.cursorPosition = 0 + GoCmdEnd(buf) + assert.Equal(t, 17, buf.cursorPosition) + }) + + t.Run("empty buffer", func(t *testing.T) { + buf := NewBuffer() + GoCmdBeginning(buf) + assert.Equal(t, 0, buf.cursorPosition) + }) +} + +func TestGoLeftWord(t *testing.T) { + cases := []struct { + description string + input string + cursor int + expectedCursor int + }{ + { + "wo[r]d -> [w]ord", + "word", + 2, + 0, + }, + { + " word[] -> [w]ord ", + " word ", + 5, + 1, + }, + { + "строка 1\n[с]трока2\nстрока3 -> строка [1]\nстрока2\nстрока3", + "строка 1\nстрока2\nстрока3 ", + 9, + 7, + }, + { + "слово\nслово2\n[с]лово3 -> слово\n[с]лово2\nслово3", + "слово\nслово2\nслово3", + 13, + 6, + }, + { + "some long [] line -> some [l]ong line", + "some long line", + 10, + 5, + }, + { + "[] -> []", + "", + 0, + 0, + }, + { + "a\n\n\n\n\n\n[b]a -> [a]\n\n\n\n\n\nba", + "a\n\n\n\n\n\nba", + 7, + 0, + }, + } + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + buf := NewBuffer() + buf.InsertText(tc.input, false, true) + buf.setCursorPosition(tc.cursor) + GoLeftWord(buf) + assert.Equal(t, tc.expectedCursor, buf.cursorPosition) + }) + } +} + +func TestGoRightWord(t *testing.T) { + cases := []struct { + description string + input string + cursor int + expectedCursor int + }{ + { + "wo[r]d -> word[]", + "word", + 2, + 4, + }, + { + " [] word -> word[]", + " word", + 1, + 8, + }, + { + "строка1\n[]строка2\nстрока3 -> строка1\n строка2[\n]строка3", + "строка1\n строка2\nстрока3", + 8, + 16, + }, + { + "[] -> []", + "", + 0, + 0, + }, + { + "[\n]\n\n\nслово->\n\n\n\nслово[]", + "\n\n\n\nслово", + 0, + 9, + }, + } + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + buf := NewBuffer() + buf.InsertText(tc.input, false, true) + buf.setCursorPosition(tc.cursor) + GoRightWord(buf) + assert.Equal(t, tc.expectedCursor, buf.cursorPosition) + }) + } +} diff --git a/test/integration/prompt/test_prompt.py b/test/integration/prompt/test_prompt.py index 4f27a6c4..a1bf7447 100644 --- a/test/integration/prompt/test_prompt.py +++ b/test/integration/prompt/test_prompt.py @@ -23,50 +23,57 @@ def test_input_text(prompt): assert prompt.dump_workspace() == expected -def test_remove_text(prompt): +@pytest.mark.parametrize("erase_key", ["C-h", "BSpace"]) +def test_remove_text(prompt, erase_key): prompt.send_keys("##### e хай hello") prompt.send_keys(["Left"] * 9) - prompt.send_keys(["C-h"] * 7) + prompt.send_keys([erase_key] * 7) assert prompt.get_cursor() == (13, 0) expected = "prompt_app> #хай hello" assert prompt.dump_workspace() == expected - prompt.send_keys(["C-h"] * 2) + prompt.send_keys([erase_key] * 2) assert prompt.get_cursor() == (12, 0) expected2 = "prompt_app> хай hello" assert prompt.dump_workspace() == expected2 -def test_move_over_input(prompt): +@pytest.mark.parametrize("left_key, right_key", [ + pytest.param("Left", "Right"), + pytest.param("C-b", "C-f") # emacs +]) +def test_move_over_input(prompt, left_key, right_key): prompt.send_keys("hello!") - prompt.send_keys(["Left"] * 2) + prompt.send_keys([left_key] * 2) assert prompt.get_cursor() == (16, 0) - prompt.send_keys(["Left"] * 10) + prompt.send_keys([left_key] * 10) assert prompt.get_cursor() == (12, 0) - prompt.send_keys(["Right"] * 7) + prompt.send_keys([right_key] * 7) assert prompt.get_cursor() == (18, 0) prompt.send_keys("слово") - prompt.send_keys(["Left"] * 3) + prompt.send_keys([left_key] * 3) assert prompt.get_cursor() == (20, 0) expected = "prompt_app> hello!слово" assert prompt.dump_workspace() == expected +@pytest.mark.parametrize("left_key", ["Left", "C-b"]) +@pytest.mark.parametrize("erase_key", ["BSpace", "C-h"]) @pytest.mark.parametrize("prompt", [{"x": "100"}], indirect=True) -def test_multiline_commands(prompt): +def test_multiline_commands(prompt, left_key, erase_key): prompt.send_keys("строка1\nline2\nline3a") assert prompt.get_cursor() == (6, 2) - prompt.send_keys(["Left"] * 7) + prompt.send_keys([left_key] * 7) assert prompt.get_cursor() == (5, 1) - prompt.send_keys(["Left"] * 4) - prompt.send_keys(["C-h"] * 2) + prompt.send_keys([left_key] * 4) + prompt.send_keys([erase_key] * 2) assert prompt.get_cursor() == (19, 0) expected = """prompt_app> строка1ine2 @@ -249,3 +256,84 @@ def test_console_not_broken(prompt): prompt.send_keys(["exit", "text"]) expected = """prompt_app> exittext""" assert prompt.dump_workspace() == expected + + +@pytest.mark.parametrize("home_key, end_key", [ + pytest.param("Home", "End"), + pytest.param("C-a", "C-e"), # emacs +]) +def test_home_end_keys(prompt, home_key, end_key): + cmd = """здравствуй, +nebo +в облаках""" + prompt.send_keys(cmd) + assert prompt.get_cursor() == (9, 2) + + prompt.send_keys(home_key) + assert prompt.get_cursor() == (12, 0) + + prompt.send_keys(["Right"] * 7 + [home_key]) + assert prompt.get_cursor() == (12, 0) + + prompt.send_keys(end_key) + assert prompt.get_cursor() == (9, 2) + + prompt.send_keys(["Left"] * 9 + [end_key]) + assert prompt.get_cursor() == (9, 2) + + prompt.send_keys(["\n-текст"]) + expected = """prompt_app> здравствуй, +nebo +в облаках +-текст""" + assert prompt.dump_workspace() == expected + + +@pytest.mark.parametrize("word_left_key, word_right_key", [ + pytest.param("M-b", "M-f") +]) +def test_go_left_right_word(prompt, word_left_key, word_right_key): + cmd = """a b c d +слово1 слово2 слово3 слово4 +d + + +б""" + # Go left from the end. + cmds = [ + [cmd, word_left_key], + word_left_key, + word_left_key, + ["Left", "Left", word_left_key], + [word_left_key] * 6 + ] + cursors = [ + (0, 5), + (0, 2), + (23, 1), + (14, 1), + (12, 0), + ] + for cmd, cursor in zip(cmds, cursors): + prompt.send_keys(cmd) + assert prompt.get_cursor() == cursor + + # Go right from the beginning. + cmds = [ + word_right_key, + word_right_key, + [word_right_key] * 5, + ["Right", "Right", word_right_key], + [word_right_key] * 2 + ] + cursors = [ + (13, 0), + (15, 0), + (20, 1), + (29, 1), + (1, 5), + ] + + for cmd, cursor in zip(cmds, cursors): + prompt.send_keys(cmd) + assert prompt.get_cursor() == cursor